From 412364bf74baac58d65e194c147f74a801c4bd66 Mon Sep 17 00:00:00 2001
From: Sam & Claude
Date: Fri, 26 Jun 2026 10:52:57 +0200
Subject: [PATCH 01/19] =?UTF-8?q?docs(sl):=20translate=20wiki=20batch=201?=
=?UTF-8?q?=20=E2=80=94=207=20core=20pages?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- mother-hive (matični hive): forced-command SSH, single home,
peer auth, key-on-seed, daemon user
- task-board (tabla opravil): capability scoring, cron/interval/once,
intake drain, SQLite backing
- operator-attention (operaterska pozornost): attention bar,
jump/filter keys, edge-triggered alerts, NO_COLOR pitfall
- contracts (JSON pogodbe): stable schemas, golden tests,
evolution rules
- cost-model (model stroškov): cache-hit metering, fast/smart/max,
T14 compaction, DeepSeek probe
- layered-soul (plastovita duša): import path, deferred stores,
one-way direction
- index (wiki kazalo): conventions, lint workflow, full page table
Commands/JSON/code paths kept in English; prose + frontmatter
translated.
---
docs/wiki/sl/contracts.md | 53 +++++++++++
docs/wiki/sl/cost-model.md | 97 +++++++++++++++++++
docs/wiki/sl/index.md | 75 +++++++++++++++
docs/wiki/sl/layered-soul.md | 55 +++++++++++
docs/wiki/sl/mother-hive.md | 119 +++++++++++++++++++++++
docs/wiki/sl/operator-attention.md | 146 +++++++++++++++++++++++++++++
docs/wiki/sl/task-board.md | 101 ++++++++++++++++++++
7 files changed, 646 insertions(+)
create mode 100644 docs/wiki/sl/contracts.md
create mode 100644 docs/wiki/sl/cost-model.md
create mode 100644 docs/wiki/sl/index.md
create mode 100644 docs/wiki/sl/layered-soul.md
create mode 100644 docs/wiki/sl/mother-hive.md
create mode 100644 docs/wiki/sl/operator-attention.md
create mode 100644 docs/wiki/sl/task-board.md
diff --git a/docs/wiki/sl/contracts.md b/docs/wiki/sl/contracts.md
new file mode 100644
index 0000000..092d77f
--- /dev/null
+++ b/docs/wiki/sl/contracts.md
@@ -0,0 +1,53 @@
+---
+title: Stabilne JSON pogodbe
+description: Jezikovno neodvisne oblike na žici, v skupni rabi med Colibri (Rust) in Clawdie agenti (TypeScript).
+---
+
+← [kazalo](./index.md)
+
+`colibri-contracts` hrani stabilne, jezikovno neodvisne oblike na žici, v
+skupni rabi med Colibri (Rust) in Clawdie agenti (TypeScript). Lasti si
+_sheme in (De)serialize_, ne poslovne logike.
+
+## Zakaj ločen pogodbeni zaboj
+
+- Preprečuje podvojene definicije med stezama Rust in TypeScript.
+- Ohranja potrjene manifeste v `manifests/`, ki so razčlenljivi na obeh
+ straneh.
+- Centralizira nize shem, vzdevke za preimenovanje polj in privzetke za
+ povratno združljivost.
+
+## Aktivne sheme
+
+| Shema | Rust struct | Namen |
+| -------------------------------------- | --------------------- | ------------------------------------------------------------------------- |
+| `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-smoke.result.v1` | `ProviderSmokeResult` | Rezultat sonde predpomnilnika DeepSeek in obračun žetonov. |
+
+Konstante shem in strukture živijo v `crates/colibri-contracts/src/lib.rs`.
+
+## Pravila razvoja
+
+- Zaboj ne nosi **nobene logike** — samo `serde` strukture in konstante shem.
+- Nova polja so običajno neobvezna z `#[serde(default)]`, da se stari
+ manifesti še vedno razčlenijo.
+- `RuntimeInventory.pi` je neobvezen, ker vsak gostitelj ne namesti `pi` ali
+ `zot`.
+- `HostStatus.raw` je lovilna vrednost `serde_json::Value`, da se lahko
+ zajame izhod sovražnega zbiralnika brez prisile v dvig sheme.
+
+## Zlati testi
+
+`crates/colibri-contracts/tests/golden.rs` razčleni vsak potrjen manifest v
+`manifests/` in preveri enakost povratnega zapisa. Primerki so namenjeni
+**medplatformni** rabi — če se manifest, ustvarjen na Linuxu, razlikuje od
+tistega na FreeBSD 15, je treba razliko razumeti in dokumentirati, preden se
+združi.
+
+## Glej tudi
+
+- [cost-model](./cost-model.md) — kako rezultat sonde ponudnika hrani merjenje
+ predpomnilnika.
+- [runtime-inventory](./runtime-inventory.md) — kje se ustvari popis
+ izvajalnega okolja.
diff --git a/docs/wiki/sl/cost-model.md b/docs/wiki/sl/cost-model.md
new file mode 100644
index 0000000..17641c6
--- /dev/null
+++ b/docs/wiki/sl/cost-model.md
@@ -0,0 +1,97 @@
+---
+title: Model stroškov
+description: Kako Colibri sledi vsakemu žetonu, meri zadetke predpomnilnika in samodejno stopnjuje med cenovnimi načini.
+---
+
+← [kazalo](./index.md)
+
+## Kaj je to
+
+Colibri sledi vsakemu žetonu, ki gre skozi agentsko sejo, in meri stroške
+glede na nastavljiv proračun. Ključni vpogled: **žetoni zadetka predpomnilnika
+stanejo 10× manj** kot sveži žetoni pri DeepSeek — zato je predpona poziva
+načrtovana tako, da je bajtno stabilna med zahtevami, kar maksimira zadetke
+predpomnilnika. Trije cenovni načini (fast, smart, max) predstavljajo različne
+točke na kompromisu hitrost/strošek, model pa samodejno stopnjuje, ko cenejši
+način ne zmore več.
+
+## Odločitve
+
+### Bajtno stabilna predpona poziva → merjenje zadetkov predpomnilnika
+
+Sistemski poziv in zgodnji bloki konteksta so **bajt-za-bajtom enaki** med
+zaporednimi zahtevami na isto končno točko DeepSeek. Cene zadetkov
+predpomnilnika DeepSeek jih znižajo za ~90%. Colibrijeva sonda
+`colibri-deepseek` določi natančno razdelitev števila žetonov med predpomnjenimi
+in svežimi žetoni na zahtevo, sledilec stroškov pa zabeleži oboje, tako da
+proračun seje odraža **dejanske** znižane stroške, ne nominalnega števila
+žetonov.
+
+**Zakaj ne preprosto šteti žetonov**: štetje žetonov z offline tokenizatorjem
+da zgornjo mejo, ne pa resničnih stroškov. API DeepSeek včasih ponovno
+predpomni in včasih ne — sonda izmeri, kaj se je dejansko zgodilo. Popust je
+prevelik (10×), da bi ostal neizmerjen.
+
+→ [headroom-sidecar](./headroom-sidecar.md),
+[`COLIBRI-TOKENOMICS-TRIFECTA.md`](../COLIBRI-TOKENOMICS-TRIFECTA.md),
+[`crates/colibri-deepseek/src/lib.rs`](../../crates/colibri-deepseek/src/lib.rs)
+
+### Trije cenovni načini (fast → smart → max)
+
+| Način | Proračun (žetoni) | Obnašanje |
+| ----- | ----------------- | ---------------------------------------------------------------------------------------- |
+| Fast | 16K | Največ zadetkov predpomnilnika, najmanj svežih žetonov. Zgodaj zavrne velike razširitve. |
+| Smart | 64K | Privzeto. Uravnoteži ponovno uporabo predpomnilnika s prostorom za nadaljnje korake. |
+| Max | 256K | Skoraj nikoli ne doseže proračuna. Za enkratne globoke naloge, kjer je strošek drugoten. |
+
+Demon **samodejno stopnjuje**, ko seja izčrpa svoj proračun v nižjem načinu:
+fast → smart → max. Stopnjevanje je enosmerno (nikoli ne zniža sredi seje).
+
+**Zakaj trije načini, ne zvezni drsnik**: tukaj zmaga preprostost. Tri dobro
+razumljene točke pokrijejo prostor — operaterji izbirajo po apetitu tveganja,
+ne po finem uglaševanju številke. Veriga stopnjevanja pomeni "začni poceni,
+plačaj več samo, če deluje".
+
+→ [`COLIBRI-TOKENOMICS-TRIFECTA.md`](../COLIBRI-TOKENOMICS-TRIFECTA.md),
+[`crates/colibri-daemon/src/cost.rs`](../../crates/colibri-daemon/src/cost.rs)
+
+### Stiskanje T14 (obrezovanje proračuna, ne krajšanje)
+
+Ko seja skoraj preseže svoj proračun, Colibri stisne rezultate orodij v
+nestanovitnem območju — pošlje jih skozi stranski vagon headroom v povzetek,
+nato obreže najstarejše nestanovitne bloke, dokler poziv ne sodi v proračun.
+**Predpona** (sistemski poziv, statični kontekst) ni nikoli obrezana — samo
+nestanovitna pripona.
+
+Če stiskanje ne zadostuje in je samodejno stopnjevanje omogočeno, način
+prestopi navzgor, preden pride do krajšanja.
+
+**Zakaj ne preprosto krajšati**: krajšanje sredi pogovora izgubi kontekst, ki
+ga agent potrebuje za nadaljevanje. Stiskanje ohrani pomensko vsebino ob
+nižjih stroških žetonov. Stranski vagon headroom je neobvezen (privzeto
+izklopljen); brez njega je zasilni izhod preprosto krajšanje.
+
+→ [headroom-sidecar](./headroom-sidecar.md),
+[`crates/colibri-daemon/src/session.rs`](../../crates/colibri-daemon/src/session.rs)
+
+### Sonda zadetka predpomnilnika (specifična za DeepSeek)
+
+Zaboj `colibri-deepseek` pošlje predpoletno zahtevo z znanim pozivom na API
+DeepSeek in razčleni glave odgovora, da določi razdelitev zadetkov
+predpomnilnika (prompt_cache_hit_tokens / prompt_cache_miss_tokens). To je
+specifično za ponudnika — DeepSeek je edini ponudnik, ki izpostavlja to
+natančnost. Sonda teče enkrat na spremembo konfiguracije seje, ne na vsako
+zahtevo.
+
+**Zakaj sonda in ne kljuka**: vmesna programska oprema, ki prestreza vsak
+odgovor API, bi povezala sledenje stroškov s plastjo HTTP. Sonda to loči —
+sledilec stroškov vpraša "kakšno je bilo razmerje predpomnilnika?" in sonda
+odgovori, neodvisno od tega, kako je bila zahteva izvedena.
+
+→ [`crates/colibri-deepseek/src/lib.rs`](../../crates/colibri-deepseek/src/lib.rs)
+
+## Glej tudi
+
+- [task-board](./task-board.md) — razporejevalnik, ki razpošilja opravila znotraj proračunov sej
+- [mother-hive](./mother-hive.md) — arhitektura MCP (druga stroškovna domena)
+- [quality-gates](./quality-gates.md) — vrata, ki preverjajo razčlenjevanje cenovnih načinov
diff --git a/docs/wiki/sl/index.md b/docs/wiki/sl/index.md
new file mode 100644
index 0000000..6e006d2
--- /dev/null
+++ b/docs/wiki/sl/index.md
@@ -0,0 +1,75 @@
+---
+title: Colibri Wiki
+description: Zbirka znanja o odločitvah in arhitekturi Colibri — utemeljitve, ki jih koda ne more izraziti.
+---
+
+Zbirka znanja o Colibrijevih **odločitvah in arhitekturi** — po vzoru
+[vzorca LLM Wiki](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f)
+Andreja Karpathyja.
+
+Vsak večji podsistem ima stran, ki beleži **zakaj** je bil zgrajen tako, kot
+je — utemeljitve, ki jih koda ne more izraziti. Izvedbena dokumentacija v
+`docs/` pokriva _kako_; te strani pokrivajo _zakaj_.
+
+## Zakaj to obstaja
+
+Zastarele odločitve se kopičijo hitreje, kot jih kdorkoli ročno pregleda:
+preimenovanje, ki je bilo samo napol uporabljeno, dokument, ki še vedno
+opisuje staro zasnovo, privzetek, ki je ostal od presežene izbire. Več
+nedavnih prehodov je bilo porabljenih prav za iskanje tega (`pi → zot`,
+`usb_nodes → hive_nodes`, podedovano preimenovanje v `sample`). Ta wiki naredi
+knjigovodstvo skoraj brezstroškovno: eno mesto, ki beleži _kaj je bilo
+odločeno_, povezuje na _kje v kodi živi_ in ga je mogoče **lintati** za odmik.
+
+## Konvencije (shema)
+
+Ta pravila ohranjajo wiki kot vzdržljiv artefakt, ne kot drugi vir resnice:
+
+1. **Koda je vir resnice.** Strani opisujejo _odločitve_ in _kje_ živijo;
+ povezujejo na kodo/dokumentacijo, namesto da bi ponovno razlagale izvedbo.
+ Ko je odločitev dostavljena, skrči stran na "kako deluje + povezava."
+2. **Povezuj, ne podvajaj.** Sklicevanje na kodo kot `pot/do/datoteke.rs:vrstica`
+ in druge wiki strani z relativnimi povezavami (`[oznaka](./stran.md)`) —
+ klikljivo v Forgeju, ustreznik Obsidianovih `[[wikipovezav]]`, prilagojen
+ repozitoriju.
+3. **Ena odločitev na stran**, kjer je to izvedljivo; obilno navzkrižno
+ povezuj.
+4. **Označi, ne tiho prepiši.** Ko nova koda nasprotuje strani, zabeleži
+ nasprotje (in ga razreši), namesto da bi tiho urejal zgodovino.
+5. **Lintaj, ne zaupaj.** Stran je trditev, ki jo je treba preveriti proti
+ kodi, ne jamstvo.
+
+## Potek dela za lint
+
+Skripta [`wiki-lint`](../../scripts/wiki-lint) preveri vsako stran proti
+trenutni kodi: viseče reference, obujena stara imena (iz imenika poimenovanj)
+in osirotele strani. Teče kot del `ci-checks.sh --strict` in je zapahnjena s
+kljuko pred potiskom — napaka odmika blokira potisk, enako kot opozorilo
+clippy.
+
+## Strani
+
+| Stran | Kaj pokriva |
+| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
+| [agent-harness](./agent-harness.md) | Razcep zot (agent) + Colibri (krmilna ravnina); samodejni zagon + gonilnik RPC |
+| [agent-events-reference](./agent-events-reference.md) | Referenca dogodkov zot po opremi, preslikave Glasspane in preverjena polja prepisa |
+| [cost-model](./cost-model.md) | Bajtno stabilne predpone, merjenje zadetkov predpomnilnika, samodejno stopnjevanje, stiskanje T14 |
+| [glasspane](./glasspane.md) | Avtomat stanj agenta, pretakanje JSONL, taksonomija AgentRuntime, API posnetkov |
+| [operator-attention](./operator-attention.md) | Izpeljan pogled "potrebuje operaterja": predikat pozornosti, vrstica/skok/filter TUI, robno sprožena terminalska opozorila |
+| [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 |
+| [naming-decisions](./naming-decisions.md) | Imenik preimenovanj, nevtralnih glede na opremo / arhitekturnih — dostavljenih in v teku |
+| [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 vrata pred združitvijo; zakaj je odmik prej dosegel `main` |
+| [contracts](./contracts.md) | Stabilne JSON sheme (run-manifest, runtime-inventory, provider-smoke), 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; vrata za branje/pisanje/zunanji-klic |
+| [operator-cli](./operator-cli.md) | CLI `colibri` kot tanek tipiziran odjemalec Unix vtičnice prek API demona |
+| [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 Clawdie-AI |
+| [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/sl/layered-soul.md b/docs/wiki/sl/layered-soul.md
new file mode 100644
index 0000000..4440653
--- /dev/null
+++ b/docs/wiki/sl/layered-soul.md
@@ -0,0 +1,55 @@
+---
+title: Integracija plastovite duše
+description: Kako Colibri danes uporablja repozitorij layered-soul in kaj je še načrtovano.
+---
+
+← [kazalo](./index.md)
+
+[clawdie/layered-soul](https://code.smilepowered.org/clawdie/layered-soul) je
+prenosljiv, od opreme neodvisen vir trajne identitete in pregledanega
+konteksta. Ta dokument beleži, kako ga Colibri uporablja **danes** in kaj je
+še **načrtovano** — da pogodbe ne zamenjamo za v celoti zgrajeno.
+
+## Kar je povezano (deluje zdaj)
+
+Uvoz pregledanih **veščin** v Colibrijev obstoječi katalog `skills`:
+
+ scripts/import-layered-soul.sh
+
+Prebere vsako datoteko SKILL.md veščine (frontmatter `name` / `description`;
+kategorija `soul`) in `INSERT OR IGNORE` vrstico v tabelo `skills`.
+`references/` in `templates/` znotraj veščine sta podporno gradivo, ne ločeni
+veščini. Ista tabela kot `clawdie-iso/scripts/import-clawdie-skills.sh`;
+idempotentno, varno za ponovni zagon.
+
+## Kar je odloženo (načrtovano, ne zgrajeno)
+
+Prilagoditveni vmesnik Colibri v layered-soul poimenuje "Plastovito
+pomnilniško tkanino" s tremi shrambami — `system_skills`, `system_brain`,
+`system_ops`. Na dan 2026-06-13 obstaja samo ena ploščata tabela `skills`;
+ostalo je **samo načrt** (`docs/COLIBRI-SKILLS-PLAN.md`), zato uvoznik
+namenoma ne cilja nanje.
+
+| Vir v layered-soul | Cilj (načrtovan) | Stanje |
+| ---------------------------------- | ---------------- | ----------------------------------------------------------- |
+| datoteke SKILL.md veščin | `system_skills` | uvoženo v ploščato tabelo `skills` danes |
+| memories/urejene markdown datoteke | `system_brain` | NI uvoženo — shramba še ne obstaja (uvoznik poroča število) |
+| pretvorjeni manifesti opravil | `system_ops` | NI implementirano |
+
+## Smer (enosmerno)
+
+`layered-soul` (git) je **vir resnice**; Colibri je **porabnik**. Uvoz teče
+enosmerno (repo → Colibri). Veščine vedno uredite v izvornem repozitoriju,
+nato ponovno uvozite — izvajalne kopije so prehodni porabniki.
+
+## Zapiranje vrzeli (prihodnje delo)
+
+1. Implementiraj `system_brain` po `COLIBRI-SKILLS-PLAN.md`, nato razširi
+ uvoznika, da naloži urejene spomine.
+2. Preseli ploščato tabelo `skills` v načrtovano shemo `system_skills`.
+3. Definiraj in uvozi manifeste opravil `system_ops`.
+
+## Glej tudi
+
+- [skills-catalog](./skills-catalog.md) — Colibrijev bralni izvajalni porabnik veščin
+- [store-schema](./store-schema.md) — načrtovane tabele `system_skills` / `system_brain`
diff --git a/docs/wiki/sl/mother-hive.md b/docs/wiki/sl/mother-hive.md
new file mode 100644
index 0000000..d3cd291
--- /dev/null
+++ b/docs/wiki/sl/mother-hive.md
@@ -0,0 +1,119 @@
+---
+title: Matični hive
+description: Kako matično vozlišče (OSA) usklajuje USB-operaterska vozlišča prek MCP prek SSH → PostgreSQL.
+---
+
+← [kazalo](./index.md)
+
+## Kaj je to
+
+Matično vozlišče (OSA) usklajuje USB-operaterska vozlišča prek MCP prek SSH →
+PostgreSQL. USB-vozlišča pošiljajo profile strojne opreme; mati izpelje
+zmožnosti in vzdržuje hive register. Ta stran beleži **odločitve**, ki stojijo
+za izvedbo — utemeljitve, ki jih koda ne more izraziti. Za navodila za
+namestitev, arhitekturne diagrame in kontrolni seznam prvega zagona glejte
+[`packaging/mother/MOTHER-SETUP.md`](../packaging/mother/MOTHER-SETUP.md).
+
+## Odločitve
+
+### Meja SSH s prisiljenim ukazom (ne poslušajoči demon)
+
+USB-vozlišča dosežejo mater tako, da zaženejo `ssh colibri@mother` (brez
+oddaljenega ukaza). Na materini strani `authorized_keys` vsili
+`command="/usr/local/bin/colibri-mcp-ssh",restrict,...` — povezava **ne more**
+zagnati interaktivne lupine ali kateregakoli ukaza razen ovoja.
+
+Ovoj (`colibri-mcp-ssh`) dodatno dovoli `SSH_ORIGINAL_COMMAND` samo kot `""`
+(stdio MCP način) ali `"tools"` (enkratno odkritje). Vsaka druga vrednost je
+zavrnjena.
+
+**Zakaj ne poslušajoči demon** (HTTP, gRPC, surovi TCP): Tailscale šifrira
+prenos, zato plast SSH doda avtentikacijo in omejitev brez dodatne
+infrastrukture (brez TLS certifikatov, brez avtentikacijskih žetonov, brez
+odprtih vrat). Meja s prisiljenim ukazom je druga ključavnica poleg SSH
+ključa — tudi ogroženi USB, ki drži ključ, lahko samo pokliče ovoj, ovoj pa
+samo delegira colibri-mcp. Obramba v globino, nameščena kot ena funkcija
+OpenSSH.
+
+→ [`colibri-mcp-ssh`](../packaging/mother/colibri-mcp-ssh),
+[`MOTHER-SETUP.md` §Varnost](../packaging/mother/MOTHER-SETUP.md#varnostne-lastnosti)
+
+### En sam dom za matično infrastrukturo (colibri, ne clawdie-iso)
+
+Matični MCP skripti (`node-register-mcp`, `geodesic-dome-mcp` itd.) so bili
+prvotno kopirani v oba repozitorija. Kopija v clawdie-iso je odnesla — njen
+`node-register-mcp` je uporabljal interpolacijo nizov `E'${...}'` (dovzetno
+za SQL-injekcijo), medtem ko je kopija v colibri uporabljala parametrizirani
+`psql -v :'variable'`. Kopija v iso je bila odstranjena v clawdie-iso PR #129.
+
+**Nauk**: skripta v dveh repozitorijih **bo** odnesla. Wiki lint je
+enorepozitorijski in ne vidi podvojenih skript med repozitoriji. Zmanjšanje
+tveganja je disciplina: matična infrastruktura živi na enem mestu.
+
+→ [naming-decisions §Strukturne](./naming-decisions.md#strukturne-odločitve)
+(vrstica "En sam dom")
+
+### `hive_nodes` — ne `usb_nodes`
+
+Prvotno ime tabele je predpostavljalo, da se bodo registrirala samo
+USB-zagnana vozlišča. Toda vozlišče je vsak gostitelj, ki se pridruži hive —
+USB, NVMe, ječa. Preimenovano v `hive_nodes` s stolpcem `node_type` (colibri
+#161). Sprožilec `derive_capabilities()` je agnostičen glede tabele in ob
+INSERT samodejno izračuna `has_gpu`, `gpu_vendor`, `can_run_local_llm`,
+`has_wifi`, `max_model`.
+
+→ [`mother_schema.sql`](../packaging/mother/mother_schema.sql),
+[naming-decisions](./naming-decisions.md) (vrstica `usb_nodes → hive_nodes`)
+
+### PostgreSQL peer avtentikacija (brez gesel)
+
+Uporabnik OS `colibri` se poveže na `mother_hive` prek peer avtentikacije —
+jedro potrdi Unix uporabnika, geslo ni potrebno. `node-register-mcp` teče kot
+ta uporabnik in podeduje zaupanje. Brez datotek pgpass, brez spremenljivk
+okolja, brez vrtenja poverilnic. En gibljivi del: pravilo `peer` v
+`pg_hba.conf` mora biti pred morebitno vrstico `local all all` (prvo
+ujemanje).
+
+**Zakaj ne geslo ali certifikat**: gesla se vrtijo in uhajajo; certifikati
+potrebujejo CA. Peer avtentikacija je vgrajena v PostgreSQL na vsakem Unixu
+in deluje za povezavo localhost z nič konfiguracije razen ene vrstice v
+`pg_hba.conf`.
+
+→ [`MOTHER-SETUP.md` §Namestitev, 6. korak](../packaging/mother/MOTHER-SETUP.md#enkratna-namestitev)
+
+### Ključ na semenski particiji, ne v sliki
+
+Zasebni ključ `mother-mcp` je nameščen na particijo CLAWDIESEED, ne zapečen v
+ISO. Gradbeni skript ima varovalko za izdajo, ki **zavrne** vgradnjo ključa v
+sliko za izdajo. Uvoznik semena (`clawdie-live-seed`) ga namesti ob zagonu.
+
+**Zakaj**: ISO za izdajo je prenosljiv artefakt. Vgradnja zasebnega ključa
+vanj bi vsakemu prenašalcu dala dostop do materinega MCP. Semenska particija
+je ločen fizični medij, ki ga nadzoruje operater. Tudi brez semena se ISO
+zažene in deluje — zunanja MCP povezava demona do matere odpove elegantno
+(SSH: "config file not found"), vozlišče pa deluje samostojno.
+
+→ [naming-decisions](./naming-decisions.md) ("Znani ostanek"), clawdie-iso #133
+
+### Demonov uporabnik, ne operater
+
+Colibri demon teče kot uporabnik `colibri` (`/var/db/colibri`), ne kot
+operater (`clawdie`, `/home/clawdie`). Zunanjo MCP SSH povezavo do matere
+zažene demon — zato morajo biti SSH ključ, konfiguracija in known_hosts v
+demonovem domu. Uvoznik semena namesti SSH gradivo v **oba** domova (operater
+
+- demon).
+
+**Zakaj ne preprosto v clawdiejev dom in `sudo`**: demon ni operater. Tek kot
+ločen uporabnik pomeni, da je domet ogroženega demona omejen na tisto, kar
+uporabnik `colibri` lahko počne — MCP klici do matere, ne operaterske
+datoteke ali `sudo`.
+
+→ [`clawdie-live-seed` (clawdie-iso)](https://code.smilepowered.org/clawdie/clawdie-iso/src/branch/main/live/operator-session/clawdie-live-seed),
+[`MOTHER-SETUP.md` §Upravljanje ključev](../packaging/mother/MOTHER-SETUP.md#upravljanje-ključev)
+
+## Glej tudi
+
+- [agent-harness](./agent-harness.md) — razcep zot/Colibri; samodejni zagon
+- [naming-decisions](./naming-decisions.md) — `usb_nodes → hive_nodes`, preimenovanje zastavice autospawn
+- [quality-gates](./quality-gates.md) — vrata, ki bi morala ujeti odmik ob času PR
diff --git a/docs/wiki/sl/operator-attention.md b/docs/wiki/sl/operator-attention.md
new file mode 100644
index 0000000..c1523f9
--- /dev/null
+++ b/docs/wiki/sl/operator-attention.md
@@ -0,0 +1,146 @@
+---
+title: Operaterska pozornost — "ali ta agent potrebuje mene zdaj?"
+description: Kako Glasspane presoja, kateri agent potrebuje operaterski poseg, in to prikaže v TUI.
+---
+
+← [kazalo](./index.md)
+
+## Kaj je to
+
+Nadzorna hrbtenica Glasspana odgovarja na vprašanje _"v kakšnem stanju je ta
+agent?"_. Pozornost je plast nad njo, ki odgovarja na vprašanje, ki si ga
+operater dejansko zastavi najprej: _"ali katerikoli agent potrebuje mene
+**zdaj**?"_ Je **izpeljan pogled** nad avtomatom stanj in terminalom,
+prikazan v TUI in podprt z robno sproženimi opozorili — ne šesto stanje, ne
+nov podsistem.
+
+## Odločitve
+
+### Pozornost je pogled, ne stanje
+
+`AgentState` ostaja majhen (`Idle`, `Working`, `Blocked`, `Done`, `Error`).
+"Potrebuje operaterja" je prost predikat nad njim, ne dodatna različica:
+
+```rust
+fn needs_attention(pane: &Pane) -> bool {
+ pane.state == AgentState::Error
+ || pane.state == AgentState::Blocked
+ || pane.stalled
+}
+```
+
+`Blocked` je vključen, ker dokumentacijski komentar avtomata stanj pravi, da
+pomeni "čaka na usmerjanje / odobritev / vnos" — tj. agent je parkiran na
+operaterju. En predikat, ki ga uporabljajo pozornostna vrstica, filter in
+tipke za skok, zato se definicija spremeni na enem mestu.
+
+`stalled` je tudi sam izpeljan — podokno je stalled, ko v
+`DEFAULT_STALL_AFTER` (4 ure) ni prispel noben dogodek. Namenoma je redek:
+pozornost so večinoma podokna v stanju Error in Blocked; Stalled je stopnjevanje
+"nekaj je globoko narobe", ne pogost pojav.
+
+→ [`crates/colibri-glasspane/src/lib.rs`](../../crates/colibri-glasspane/src/lib.rs)
+(`AgentState`, `SupervisedPane::is_stalled_at`, `DEFAULT_STALL_AFTER`)
+
+### TUI naredi pozornost nemogoče spregledati
+
+Ko katerokoli podokno v **trenutnem pogledu** potrebuje pozornost, se običajna
+glava zamenja z rdeče obrobljeno pozornostno vrstico, ki navaja zadevna
+podokna. Vrstice, ki potrebujejo pozornost, dobijo obrnjeno ozadje; kazalka se
+obrne znova, da operater še vedno vidi, katera je izbrana. Dve tipki za skok
+(`n` / `N`) krožita naprej/nazaj skozi pozornostna podokna z ovijanjem, `a`
+pa preklaplja filter samo za pozornost. Vsi trije delujejo nad že filtriranim
+naborom podoken.
+
+**Sestavljanje filtrov je IN.** Pozornostni filter se sestavlja s filtrom
+seje, zato vrstica odraža samo tisto, kar operater gleda. Junija 2026 je bil
+dostavljen hrošč, kjer je bil `has_attention` izračunan iz _nefiltriranega_
+posnetka: napaka v seji `s2` je prižgala vrstico med gledanjem seje `s1`,
+lastni `filtered_panes()` vrstice pa je nato zgodaj vrnil prazen izris —
+operater je tako izgubil glavo zaradi praznega rdečega okvirja. Popravljeno z
+izračunom pozornosti iz `filtered_panes()`; pokrito s preskusom upodabljanja
+med sejami.
+
+**Past `NO_COLOR`.** Seje Hermesa puščajo `NO_COLOR=1` v okolja podprocesov
+in crossterm to konvencijo upošteva. Brez `force_color_output(true)` ob zagonu
+se `colibri-tui`, zagnan iz seje Hermesa, upodablja brez barv — rdeča
+pozornostna vrstica postane nevidna. Funkcija `main()` vsili barve ne glede na
+starševsko okolje; to je nadzorna plošča TUI, barve so nosilne.
+
+Prihodnja izboljšava: prikaz pozornostne vrstice **in** glave hkrati (dvojni
+pogled) namesto zamenjave ene z drugo, da operater na en pogled vidi število
+podoken in pozornostna podokna, brez preklapljanja.
+
+→ [`crates/colibri-glasspane-tui/src/main.rs`](../../crates/colibri-glasspane-tui/src/main.rs)
+(`needs_attention`, `render_attention_bar`, `attention_indices`)
+
+### Zajem terminala je dopolnilni signal
+
+Avtomat stanj je _"kar agent pove"_ (strukturirani dogodki JSONL). Zajem
+terminala je _"kar prikazuje zaslon"_ — dejansko besedilo podokna, razvrščeno
+po znanih okvarjenih vzorcih. Podokno je lahko `Working`, medtem ko njegov
+zaslon kaže `Active: failed (Result: exit-code)`. Pozornost je pogled nad
+**obema**.
+
+`TerminalRecorder` hrani omejeno zgodovino okvirjev (`DEFAULT_HISTORY_CAPACITY`
+= 256 okvirjev). Identiteta okvirja je **SHA-256 očiščenega besedila**, zato
+se poizvedovanje skoraj statičnega podokna vsako sekundo strne v zgoščen
+dnevnik dejanskih prehodov stanja namesto tisočev podvojenih okvirjev.
+`capture_tmux_pane` za zajem pokliče `tmux`, toda `observe()` sprejme surovo
+besedilo neposredno — logika razpoznavanja in triaže je v celoti preizkusljiva
+brez priključenega terminala.
+
+→ [`crates/colibri-glasspane/src/terminal.rs`](../../crates/colibri-glasspane/src/terminal.rs)
+(`TerminalRecorder`, `Observation`, `capture_tmux_pane`)
+
+### Triaža prepoznav, podatkovno gnana po OS
+
+`SignatureSet` pregleda očiščeno besedilo terminala in razvrsti zaslon v
+`failures` / `warnings` / `info` / `healthy` (`Severity::{Error, Warn, Info, Ok}`).
+Vzorci se ujemajo kot podnizi brez razlikovanja velikosti črk; prvi zadetek
+zabeleži prepoznavo in ni poročan dvakrat. Vsak zadetek nosi človeški
+`next_action` in neobvezni `invoke` (veščino, ki jo agent lahko zažene za
+odpravo) — zadetek ni "nekaj se je zgodilo", ampak "tukaj je, kaj to pomeni
+in kaj storiti".
+
+Mehanizem zaznavanja je **podatkovno gnan**: gostitelj FreeBSD in gostitelj
+Linux naložita različne nabore `Signature`, a si delita isti ujemalnik.
+`SignatureSet::linux_default` prinaša majhen začetni nabor; klicatelji
+zgradijo svojega z `SignatureSet::new`. To je gumb na OS, na katerega se
+opira Colibrjevo usmerjanje po zmožnostih.
+
+→ [`crates/colibri-glasspane/src/signatures.rs`](../../crates/colibri-glasspane/src/signatures.rs)
+(`SignatureSet`, `Severity`, `Signature`, `Detection::alertable`)
+
+### Opozorila so robno sprožena, ne nivojsko
+
+Prepoznava napake/opozorila se sporoči **samo ob okvirju, kjer se prvič
+pojavi**, ne ob vsakem naslednjem okvirju, ki jo še prikazuje — vrnjeno kot
+`Observation::Recorded { uuid, new_alerts }` samo z novimi sproženimi
+zadetki. Ko se stanje počisti in pozneje ponovi, se sproži znova. Nivojsko
+sprožena opozorila ob 1s poizvedovanju bi ponovno obveščala vsako sekundo za
+čas trajanja zataknjenega podokna; robno sproženje pomeni, da vsako opozorilo
+pomeni "to se je pravkar začelo".
+
+→ [`crates/colibri-glasspane/src/terminal.rs`](../../crates/colibri-glasspane/src/terminal.rs)
+(`TerminalRecorder::observe`, `Observation::new_alerts`)
+
+## Kaj je še odprto
+
+- **Izhodno potiskanje.** Pozornost je prikazana na zaslonu (vrstica,
+ poudarjanje). Operater nadzoruje brezglave gostitelje prek Tailscale, ne z
+ gledanjem TUI. Potiskanje pozornosti **navzven** — namizno obvestilo na živi
+ sliki in sporočilo Telegram — je najpomembnejši nedokončan kos. Žeton je že
+ pripravljen; prenos (`colibri notify` proti dogodku glasspane, ki ga sproži
+ kljuka harnessa) je neodločen.
+- **Odgovarjanje blokiranemu agentu z nadzorne plošče.** API posnetkov je
+ namenoma bralno usmerjen. Pisalna pot ("pošlji vnos v podokno N" prek
+ vtičnice demona) bi operaterju omogočila, da se odzove na `Blocked` podokno
+ iz TUI. Spremeni vtičnico iz nadzora v interaktivno upravljanje — lasten
+ načrtovalski prehod.
+
+## Glej tudi
+
+- [glasspane](./glasspane.md) — avtomat stanj, nad katerim je pozornost pogled
+- [tui](./tui.md) — nadzorna plošča, ki prikazuje pozornost
+- [terminal](./terminal.md) — terminalska zmožnost, na katero se opirajo bližnjice pozornosti
diff --git a/docs/wiki/sl/task-board.md b/docs/wiki/sl/task-board.md
new file mode 100644
index 0000000..0d6d317
--- /dev/null
+++ b/docs/wiki/sl/task-board.md
@@ -0,0 +1,101 @@
+---
+title: Tabla opravil + razporejevalnik
+description: Kako Colibri hrani operaterska opravila in jih razporeja med agente glede na zmožnosti.
+---
+
+← [kazalo](./index.md)
+
+## Kaj je to
+
+Colibrjeva tabla opravil hrani delovne naloge, ki jih odda operater, razporejevalnik
+pa jih ob vsakem taktu dodeli najprimernejšemu agentu. Opravila pritekajo prek
+Unix vtičnice demona (`create-task`, `intake-task`), prazni pa jih zanka
+razporejevalnika, ki teče znotraj demona vsakih ~30 sekund.
+
+## Odločitve
+
+### Točkovanje po zmožnostih (najboljše ujemanje, ne prvo)
+
+Ko razporejevalnik izbira agenta za opravilo, točkuje vsakega razpoložljivega
+agenta glede na **zahtevane zmožnosti** opravila s preprostim štetjem preseka:
+`|zahtevane ∩ agentove_zmožnosti| / |zahtevane|`. Agent z najvišjim številom
+točk zmaga; izenačenja razreši ime agenta (deterministično, da ponovni teki ne
+premetavajo).
+
+Opravilo z `["freebsd", "zfs"]` se bo ujemalo z agentom, ki ima obe zmožnosti,
+pred tistim, ki ima samo `freebsd`. Opravilo brez zahtevanih zmožnosti ustreza
+vsakemu agentu. Agenti brez povezave in agenti, katerih zmožnosti se sploh ne
+sekajo, so preskočeni.
+
+**Zakaj ne krožno ali FIFO**: ujemanje po zmožnostih pomeni, da pravi agent
+dobi pravo delo brez operaterskega ročnega dodeljevanja. Točkovanje je
+preprosto (presek množic) in pregledno — brez strojnega učenja, brez uteži za
+uglaševanje.
+
+→ [`crates/colibri-daemon/src/scheduler.rs`](../../crates/colibri-daemon/src/scheduler.rs)
+(`capability_match_score`, `pick_agent`)
+
+### Tri vrste urnikov (cron, interval, enkrat)
+
+| Vrsta | Obnašanje |
+| -------- | -------------------------------------------------------------- |
+| Cron | Sproži ob določenem stenskem času (npr. `0 0 * * *` = polnoč). |
+| Interval | Sproži po določenem trajanju od zadnjega teka (npr. 3600s). |
+| Enkrat | Sproži natanko enkrat, ob določenem prihodnjem času. |
+
+Cron vzorci so preprosti 5-poljski izrazi (minuta, ura, dan, mesec, dan v
+tednu) z nadomestnimi znaki — brez sekundne natančnosti, brez sintakse
+`/step`. Ujemanje uporablja primerjavo predpone: cron vzorec se ujema, če se
+vsako polje trenutnega časa začne z nizom vzorca, torej `0` ustreza `00`, `1`
+ustreza `10-19` itd. To je namenoma preprosto — cron je pripomoček za
+periodično hišno opravilo, ne splošni opravilni pogon.
+
+**Zakaj ne prava cron knjižnica**: naloga razporejevalnika je razpošiljanje
+opravil agentom, ne upravljanje koledarja. Preprosti predponski cron pokrije
+90 % primerov uporabe (dnevne gradnje, urna poročila) brez dodatne odvisnosti
+za razčlenjevanje.
+
+→ [`crates/colibri-daemon/src/scheduler.rs`](../../crates/colibri-daemon/src/scheduler.rs)
+(`should_fire`)
+
+### Praznjenje vnosne vrste (vrsta → tabla opravil → agent)
+
+Ukaz `intake-task` prek vtičnice potisne opravilo v vnosno vrsto. Ob vsakem
+taktu razporejevalnika (~30s) zanka izprazni vnosno vrsto v shrambo SQLite na
+tabli opravil, nato preveri zapadla načrtovana opravila. To dvofazno
+praznjenje loči oddajo od izvajanja: operater odds kadar koli, razporejevalnik
+obdeluje v paketih.
+
+Opravila v vnosni vrsti nosijo **niz zmožnosti** (ne ID agenta).
+Razporejevalnik izbere najboljšega agenta ob času izvajanja, zato bo opravilo,
+oddano ko noben ujemajoči agent ni na voljo, prevzeto, ko se eden poveže.
+
+**Zakaj vnosna vrsta in ne neposredna dodelitev**: agenti prihajajo in
+odhajajo. Če bi oddaja zahtevala izbiro agenta, bi operater moral vedeti,
+kateri agenti so na voljo — sklopitev, ki se ji tabla opravil namenoma izogne.
+
+→ [`crates/colibri-daemon/src/scheduler.rs`](../../crates/colibri-daemon/src/scheduler.rs)
+(`Scheduler`, `add_job`, `submit`),
+[`crates/colibri-daemon/tests/intake_scheduler_loop.rs`](../../crates/colibri-daemon/tests/intake_scheduler_loop.rs)
+
+### Podlaga SQLite (vgrajeno, ne storitev)
+
+Tabla opravil hrani opravila, registracije agentov, podatke o najemnikih in
+katalog veščin v vgrajeni podatkovni zbirki SQLite na
+`/var/db/colibri/colibri.sqlite`. Brez ločenega podatkovnega procesa — demon
+odpre datoteko neposredno.
+
+**Zakaj SQLite, ne PostgreSQL**: demon teče na operaterskem USB-ju in na
+nameščenih gostiteljih. Polna storitev PostgreSQL je pretežka za
+koordinacijsko stanje enega samega demona. SQLite je brez konfiguracije, brez
+administracije in preživi ponovne zagone demona brez ločenega življenjskega
+cikla. Matično vozlišče uporablja PostgreSQL za hive register, ker je
+večnajemniško; krajevni demon je enonajemniški.
+
+→ [`crates/colibri-store/src/lib.rs`](../../crates/colibri-store/src/lib.rs)
+
+## Glej tudi
+
+- [mother-hive](./mother-hive.md) — matični hive register, osnovan na PostgreSQL
+- [cost-model](./cost-model.md) — sledenje stroškov po seji
+- [agent-harness](./agent-harness.md) — samodejni zagon
--
2.45.3
From d9ef0a94e259021b90744cdb47c48e724cb0ed29 Mon Sep 17 00:00:00 2001
From: Sam & Claude
Date: Fri, 26 Jun 2026 11:05:30 +0200
Subject: [PATCH 02/19] docs(sl): install pages use clawdie flow (drop just
install)
Co-Authored-By: Claude Opus 4.8
---
docs/guide/sl/install/controlplane-install.md | 24 ++----
docs/guide/sl/install/index.md | 2 +-
docs/guide/sl/install/install.md | 73 +++++++++++++++++++
docs/guide/sl/install/requirements.md | 8 +-
4 files changed, 84 insertions(+), 23 deletions(-)
create mode 100644 docs/guide/sl/install/install.md
diff --git a/docs/guide/sl/install/controlplane-install.md b/docs/guide/sl/install/controlplane-install.md
index 97ca5af..e9231ed 100644
--- a/docs/guide/sl/install/controlplane-install.md
+++ b/docs/guide/sl/install/controlplane-install.md
@@ -1,24 +1,16 @@
---
title: 'Namestitev krmilne ravnine'
-description: Namestite krmilno ravnino Clawdie s standardnim orkestratorjem.
+description: Namestite gostiteljsko storitev krmilne ravnine Clawdie z binarno datoteko clawdie.
---
-Krmilno ravnino namesti standardni orkestrator:
+Krmilna ravnina teče kot storitev `clawdie` (`colibri-daemon`), ki jo pripravi
+binarna datoteka `clawdie`:
```bash
-just install
+cargo build -p clawdie --release
+clawdie apply --yes
```
-Root namestitev poganja krmilno ravnino kot storitev Clawdie, ki jo upravlja
-gostitelj. Orkestrator skupaj nastavi PF, delovne ječe, PostgreSQL, hostd,
-namestitev storitve, kontrolne točke in stanje za nadaljevanje.
-
-Če namestitev ne uspe, nadaljujte od neuspelega koraka:
-
-```bash
-just install-from-db
-just install-from hosts
-just install -- --dry-run
-```
-
-Kanonično ime mostu Bastille/Warden je `warden0`.
+Najprej si oglejte načrt z `clawdie plan` (`apply` je suhi tek, dokler ne dodate
+`--yes`). Za celoten nabor ukazov in strategijo shrambe glejte
+[Namestitev](./install/). Kanonično ime mostu Bastille/Warden je `warden0`.
diff --git a/docs/guide/sl/install/index.md b/docs/guide/sl/install/index.md
index 055c0c3..3360809 100644
--- a/docs/guide/sl/install/index.md
+++ b/docs/guide/sl/install/index.md
@@ -21,5 +21,5 @@ To pot uporabite za nov stroj ali ponovno namestitev z zagonskega medija.
To pot uporabite, kadar je FreeBSD 15.x že nameščen in gostitelja upravljate sami.
- [Zahteve](./requirements/) — zahteve glede gostitelja, izvajalnega okolja in orodjarne.
-- [Namestitveni orkestrator](./install/) — zaženite `just install` in nadaljujte neuspele korake.
+- [Namestitev](./install/) — pripravite gostiteljsko storitev z binarno datoteko `clawdie` (`clawdie discover` / `plan` / `apply --yes`).
- [Kontrolni seznam za svežo namestitev](./fresh-install-checklist/) — preverite dokončano namestitev.
diff --git a/docs/guide/sl/install/install.md b/docs/guide/sl/install/install.md
new file mode 100644
index 0000000..f9b7393
--- /dev/null
+++ b/docs/guide/sl/install/install.md
@@ -0,0 +1,73 @@
+---
+title: Namestitev
+description: Pripravite gostiteljsko storitev Clawdie z binarno datoteko clawdie.
+---
+
+**Ukaz:** `clawdie apply --yes`
+
+Clawdie namesti binarna datoteka `clawdie` (`crates/clawdie`). Zazna postavitev
+ZFS na gostitelju in pripravi storitev `clawdie`: nabore podatkov za shrambo,
+storitveni račun ter enoto rc.d (FreeBSD) / systemd (Linux), ki poganja
+`colibri-daemon`. Izgradite jo iz delovnega prostora Cargo:
+
+```bash
+cargo build -p clawdie --release
+```
+
+## Ukazi
+
+```bash
+clawdie discover # samo branje: OS, bazeni ZFS, nabori podatkov, proste plošče
+clawdie plan [--pool IME] # prikaže načrt namestitve (suhi tek, brez pisanja)
+clawdie apply [--pool IME] # suhi tek, razen če dodate --yes
+clawdie apply --yes # pripravi: postavitev shrambe + namesti storitev
+```
+
+`apply` je **privzeto suhi tek** in izpiše celoten načrt korakov; na disk piše le
+z `--yes`. Z enim bazenom ZFS se ta izbere samodejno; z več jih navedite z
+`--pool IME`.
+
+## Strategija shrambe
+
+| Gostitelj | Vedenje |
+| ---------------------- | --------------------------------------------------------------------------- |
+| FreeBSD | ZFS je **zahtevan**; nabori podatkov nastanejo pod izbranim bazenom. |
+| Linux + ZFS + bazen | Enako — nabori podatkov pod bazenom. |
+| Linux, brez ZFS/bazena | Preklopi na navadne imenike in poroča o prednostih ZFS ter prostih ploščah. |
+
+Postavitev ZFS pod bazenom:
+
+```text
+/clawdie (vsebnik, canmount=off)
+/clawdie/db -> /var/db/clawdie
+/clawdie/log -> /var/log/clawdie
+```
+
+## Kaj pripravi `apply --yes`
+
+1. **Shramba** — zgornji nabori podatkov (ali navadna `/var/db/clawdie` +
+ `/var/log/clawdie` pri preklopu na navadne imenike).
+2. **Storitveni račun** — `clawdie` (nologin), lastnik stanja
+ (`clawdie:clawdie`).
+3. **Storitev** — enota rc.d (FreeBSD) / systemd (Linux), nameščena in omogočena
+ za poganjanje `/usr/local/bin/colibri-daemon`.
+
+## Ustvarjanje bazena (uničujoče)
+
+Na gostitelju s prosto ploščo in brez uporabnega bazena:
+
+```bash
+clawdie apply --pool IME --create-pool /dev/PLOSCA --yes
+```
+
+`--create-pool` požene `zpool create` na `PLOSCA` in **uniči vse podatke na
+njej**, zato je zavrnjeno, razen če je plošča zaznana kot prazna. Stražo preglasi
+`--force`, le če ste prepričani.
+
+## Varnost
+
+Koraki, ki se dotikajo diska, tečejo kot root na ciljnem gostitelju. `discover`,
+`plan` in goli `apply` nikoli ne pišejo — najprej preglejte z `plan`.
+
+Priprava ječ, baze in spletne storitve (CMS) še ni del namestitvenega programa
+colibri.
diff --git a/docs/guide/sl/install/requirements.md b/docs/guide/sl/install/requirements.md
index c912f64..86be56a 100644
--- a/docs/guide/sl/install/requirements.md
+++ b/docs/guide/sl/install/requirements.md
@@ -5,7 +5,7 @@ description: Zahteve glede gostitelja, izvajalnega okolja in orodjarne za Clawdi
Kaj potrebujete na gostitelju pred namestitvijo Clawdie. Namestitveni program z
ISO večino tega samodejno pripravi; namestitve na obstoječem gostitelju naj te
-zahteve preverijo pred zagonom `just install`.
+zahteve preverijo pred zagonom `clawdie apply`.
## Gostitelj
@@ -18,11 +18,7 @@ zahteve preverijo pred zagonom `just install`.
## Orodjarna
-- **Rust** prek rustup, nameščen pod `/opt/clawdie/rustup`. Potreben za domorodne
- odvisnosti (SWC, tree-sitter). ISO ga namesti; namestitve na obstoječem
- gostitelju ga zaženejo iz
- [`Namestitvenega orkestratorja`](./install).
-- **Node.js + tsx** za namestitvene skripte in izvajalno okolje.
+- **Rust** (Cargo) — izgradite z `cargo build -p clawdie --release`. Glejte [Namestitev](./install/).
- **Bastille** za upravljanje ječ.
## Filozofija izvajalnega okolja
--
2.45.3
From 3ec68ff860800a70cf22ed186facc0f6eda307c0 Mon Sep 17 00:00:00 2001
From: Sam & Claude
Date: Fri, 26 Jun 2026 11:01:57 +0200
Subject: [PATCH 03/19] =?UTF-8?q?docs(sl):=20translate=20wiki=20group=201?=
=?UTF-8?q?=20=E2=80=94=20glasspane,=20agent-harness,=20naming,=20quality-?=
=?UTF-8?q?gates,=20store-schema?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/wiki/sl/agent-harness.md | 60 +++++++++++++
docs/wiki/sl/glasspane.md | 150 +++++++++++++++++++++++++++++++
docs/wiki/sl/naming-decisions.md | 63 +++++++++++++
docs/wiki/sl/quality-gates.md | 53 +++++++++++
docs/wiki/sl/store-schema.md | 145 ++++++++++++++++++++++++++++++
5 files changed, 471 insertions(+)
create mode 100644 docs/wiki/sl/agent-harness.md
create mode 100644 docs/wiki/sl/glasspane.md
create mode 100644 docs/wiki/sl/naming-decisions.md
create mode 100644 docs/wiki/sl/quality-gates.md
create mode 100644 docs/wiki/sl/store-schema.md
diff --git a/docs/wiki/sl/agent-harness.md b/docs/wiki/sl/agent-harness.md
new file mode 100644
index 0000000..b936de4
--- /dev/null
+++ b/docs/wiki/sl/agent-harness.md
@@ -0,0 +1,60 @@
+---
+title: Agentska oprema: zot + Colibri
+description: Dve binarni datoteki, ne ena — zot (agent, Go) in Colibri (krmilna ravnina, Rust).
+---
+
+← [kazalo](./index.md)
+
+## Odločitev
+
+Dve binarni datoteki, ne ena (Sam je zavrnil združitev, 13. junij 2026):
+
+- **zot** — _agent_ (vhodna vrata do modela). Binarna datoteka Go; deluje.
+- **Colibri** — _krmilna ravnina_ (nadzornik). Rust; opazuje agente prek
+ glasspane, poganja tablo opravil, upravlja stroške. **Opazuje** zot/pi; ne
+ vsebuje ju.
+
+Kanonična izjava: `AGENTS.md` (vrstice ~18–32). `clawdie-ai` (TS) se krči;
+preživele funkcije se selijo v zot/Colibri.
+
+> **Ni** dokumenta `ADR-agent-harness-consolidation.md` (v preteklosti je bil
+> omenjen; te reference so bile od takrat očiščene). Obravnavaj `AGENTS.md`
+> kot ADR.
+
+## Izvajalna okolja
+
+Glasspane normalizira dogodke iz obeh oprem v eno taksonomijo prek
+`AgentRuntime { Pi, Zot, Local }` — `crates/colibri-glasspane/src/lib.rs`
+(`zot_event_type()` preslika zotove dogodke na imena v slogu pi).
+
+## Samodejni zagon + gonilnik RPC (colibri#143)
+
+Pogodba zaganjalnika: zaženi agenta, beri stdout JSONL.
+
+- **pi** se sam poganja (`pi --mode json`) s `stdin` null — ustreza
+ neposredno.
+- Edini strukturirani trajni način **zot** je `zot rpc`, vrstnik
+ zahteva/odgovor, ki **bere stdin**. Zato zaganjalnik napelje stdin za RPC
+ agente in demon pošlje poziv prek `RpcSender`.
+
+Kje živi:
+
+- pogodba zaganjalnika + `rpc_stdin` + `RpcSender`: `crates/colibri-daemon/src/spawner.rs`
+- argv samodejnega zagona, ki se zaveda binarne datoteke (`zot → rpc`, pi → `--mode json`):
+ `crates/colibri-daemon/src/socket.rs` (`default_agent_args`, `autospawn_agent_if_configured`)
+- žični format (preverjen proti pravemu zot): [agent-events-reference](./agent-events-reference.md)
+- dokaz od konca do konca, zot: `crates/colibri-daemon/tests/zot_rpc_smoke.rs`
+ (`#[ignore]`, `ZOT_BIN`-pogojen — potrebuje pravo binarno datoteko zot)
+- dokaz od konca do konca, pi: `crates/colibri-daemon/tests/pi_spawn_live.rs`
+ (ne-ignoriran, teče v vsakem `cargo test` — uporablja `sample-pi-agent.py`, ki
+ oddaja taksonomijo colibri-pi-events, preverjeno proti pravemu pi)
+- pogodba argv samodejnega zagona: `crates/colibri-daemon/src/socket.rs`
+ (testi enot `default_agent_args` — zot→rpc, pi→--mode json)
+
+Privzeta oprema OOTB je **zot**; pi ostaja podprta rezerva
+(`COLIBRI_AUTOSPAWN_BINARY=pi`).
+
+## Glej tudi
+
+- [naming-decisions](./naming-decisions.md) — nevtralno poimenovanje `pi → zot`
+- [quality-gates](./quality-gates.md) — kako je napol dokončano preimenovanje doseglo `main`
diff --git a/docs/wiki/sl/glasspane.md b/docs/wiki/sl/glasspane.md
new file mode 100644
index 0000000..ced9ac8
--- /dev/null
+++ b/docs/wiki/sl/glasspane.md
@@ -0,0 +1,150 @@
+---
+title: Glasspane — nadzor stanja agentov
+description: Colibrijeva plast za opazovanje agentov. Gleda podprocese agentov prek JSONL, zlaga tok v semantični avtomat stanj in izpostavlja API posnetkov.
+---
+
+← [kazalo](./index.md)
+
+## Kaj je to
+
+Glasspane je Colibrijeva plast za opazovanje agentov. Opazuje podprocese
+agentov prek njihovega stdout JSONL, zlaga tok v semantični avtomat stanj
+(`Idle → Working → Done`) in izpostavlja API posnetkov za nadzorne plošče in
+koordinacijo demona. Vsak zagnani agent — Pi, zot ali krajevni sample — se
+pretaka skozi isti vnosnik in konča v isti taksonomiji.
+
+## Odločitve
+
+### Stanje agenta kot avtomat stanj, ne surovi dnevnik dogodkov
+
+Glasspane ne posreduje samo surovih agentskih dogodkov. Zaužije vrstice JSONL
+in prehaja **poimenovano podokno** skozi končno množico stanj:
+
+```
+Idle → Working → Blocked → Done
+ ↳ Error
+```
+
+Enum `AgentState` (`Idle, Working, Blocked, Done, Error`) je namenoma majhen.
+Zajame tisto, kar nadzornik potrebuje vedeti — "ali agent dela? je blokiran?
+je končal?" — brez kodiranja agentsko-specifične semantike. Dogodki, ki ne
+spremenijo stanja (npr. poročilo o uporabi iz zot), so zabeleženi v metapodatkih
+podokna, vendar ne vplivajo na avtomat stanj.
+
+`Stalled` **ni** šesta različica — je izpeljana zastavica: podokno je stalled,
+ko v `DEFAULT_STALL_AFTER` (4 ure) ni prispel noben dogodek. Izpeljano
+pozornost (Error / Blocked / Stalled) pokriva
+[operator-attention](./operator-attention.md).
+
+**Zakaj ne preprosto slediti dnevniku**: surovi dnevniki dogodkov so
+agentsko-specifični in se sčasoma spreminjajo (zot dodaja nove vrste
+dogodkov). Avtomat stanj je stabilna pogodba, na katero se lahko zanesejo
+demon, TUI in odjemalski CLI.
+
+→ [`crates/colibri-glasspane/src/lib.rs`](../../crates/colibri-glasspane/src/lib.rs)
+
+### Pretakanje JSONL (ena vrstica = en dogodek)
+
+Agenti oddajajo strukturirane dogodke kot JSON, ločen z novimi vrsticami, na
+stdout. Glasspane bere vrstico za vrstico z `BufReader`, deserializira vsako
+vrstico in jo poda v `PiJsonlIngestor` (ime je podedovano — obdeluje tudi zot
+dogodke).
+
+Bralnik teče v **eni sami nalogi ozadja na podokno** (`pane_reader_loop`).
+Nikoli ne blokira glavne zanke demona — vnosnik je sinhrono zlaganje, ki
+posodablja stanje podokna v pomnilniku, API posnetkov pa bere iz
+`Arc>` brez sporov na vroči poti bralnika.
+
+Napačno oblikovane vrstice so **preskočene** s povečanjem števca, ne kot
+napaka — izpadi v JSONL agenta ne smejo zrušiti opazovalca.
+
+**Zakaj JSONL, ne vtičnica ali gRPC**: agent je podproces, ne storitev.
+stdout je univerzalni vmesnik — vsak jezik, vsaka oprema, brez nastavitve.
+JSONL je trivialno pisati iz bash, Go, Python, Rust. Strukturiran žični
+format bi dodal odvisnost in rokovanje vsakemu agentu.
+
+→ [`crates/colibri-glasspane/src/lib.rs`](../../crates/colibri-glasspane/src/lib.rs)
+(`PiJsonlIngestor`, `pane_reader_loop`)
+
+### `AgentRuntime { Pi, Zot, Local }` — ena taksonomija za dve opremi
+
+Pi in zot oddajata **različne** surove vrste dogodkov: Pi uporablja
+`agent_start` / `turn_end`, zot uporablja `turn_start` / `done`. Glasspane
+preslika oba v iste prehode `AgentState` prek `zot_event_type()`. Enum
+`AgentRuntime` označi vsako podokno z njegovo opremo, da funkcija preslikave
+ve, kateri besednjak dogodkov naj razčleni.
+
+Polje `session_id` v strukturi `Pane` uporablja
+`#[serde(alias = "pi_session_id")]` za povratno združljivost s
+pred-nevtralnostnimi serializiranimi posnetki.
+
+**Zakaj ne dva ločena avtomata stanj**: TUI, razporejevalnik demona in
+odjemalski CLI morajo vsi vprašati "v kakšnem stanju je ta agent?" — vseeno
+jim je, ali je zot ali Pi. Ena taksonomija, en API. Preslikava je ~50-vrstična
+funkcija, ne podsistem.
+
+→ [`crates/colibri-glasspane/src/lib.rs`](../../crates/colibri-glasspane/src/lib.rs)
+(`zot_event_type`, `AgentRuntime`)
+
+### API posnetkov (bralno usmerjen, ne pisalno)
+
+Glasspane izpostavlja objekt posnetka (celoten nabor podoken s trenutnim
+stanjem, ID seje, časovnim žigom in metapodatki) prek `Arc>`.
+Demon ga streže prek svoje Unix vtičnice bralcem odjemalcev. Pisanja se
+zgodijo enkrat na dogodek; branja so pogosta (pogledi TUI, statusna preverjanja
+CLI).
+
+**Zakaj RwLock, ne kanali**: pisalna pot je nizkofrekvenčna (agentski JSONL s
+hitrostjo človeškega branja), bralna pot pa v običajnem primeru brez zaklepa.
+Zasnova, osnovana na kanalih, bi dodala medpomnjenje in semantiko dostave za
+problem, ki je v osnovi o trenutnem stanju, ne o dostavi dogodkov.
+
+→ [`crates/colibri-glasspane/src/lib.rs`](../../crates/colibri-glasspane/src/lib.rs)
+(`Supervisor`, `snapshot`)
+
+## Načrt uporabnosti (TODO)
+
+**Pozornostna** polovica tega načrta je dostavljena: izpeljan predikat
+pozornosti, pozornostna vrstica TUI / tipke za skok / filter / poudarjanje
+vrstic in robno sprožena opozorila zajema terminala. Glej
+[operator-attention](./operator-attention.md) za dostavljen sistem. Kar
+ostaja tukaj, je resnično nezgrajena smer.
+
+### Potisna obvestila navzven, ne samo na zaslonu
+
+Operater nadzoruje brezglave gostitelje prek Tailscale, ne z buljenjem v TUI.
+Ko podokno sproži pozornost (ali doseže `Done`), jo potisni **navzven**:
+namizno obvestilo na živi sliki (XFCE) in sporočilo **Telegram** (žeton je že
+pripravljen). Eksplicitna pot v slogu `colibri notify` — ali vrsta dogodka
+glasspane, ki jo sproži kljuka zot/Pi — omogoča agentu reči "blokiran sem",
+namesto da se zanašamo samo na izpeljano stanje. Največji učinek v resničnem
+svetu.
+
+### Bogatejše vrstice podoken (kontekst na prvi pogled)
+
+Glasspane že shranjuje dogodke, ki ne spreminjajo stanja, v metapodatkih
+podokna. Prikaži jih v vrstici TUI: trenutni **repo/veja**, **zadnja
+vrstica/povzetek naloge**, **ječa**, v kateri agent teče, neobvezno vrata za
+poslušanje. Spremeni "Working" v "Working on `fix/x` v ječi `cms`, zadnje:
+running tests".
+
+### Ohrani zgodovino podoken med ponovnimi zagoni demona
+
+Nadzornik je v pomnilniku (`Arc>`); ponovni zagon demona izgubi
+časovnico. Ohrani prehode/zgodovino podoken, da vrnitev po urah (ali ponovnem
+zagonu) ohrani "kaj se je zgodilo, ko me ni bilo". Lahka trajnost, ne nov
+podsistem.
+
+### Odgovori blokiranemu agentu z nadzorne plošče (večji dvig)
+
+API posnetkov je namenoma bralno usmerjen. Prihodnja pisalna pot — "pošlji
+vnos v podokno N" prek vtičnice demona — bi operaterju omogočila **odziv**
+blokiranemu agentu iz `colibri-tui`, ne samo opazovanje/zagon/uboj. To je
+smer, ne hitra zmaga; spremeni vtičnico iz bralnega nadzora v interaktivno
+upravljanje in potrebuje lasten načrtovalski prehod.
+
+## Glej tudi
+
+- [agent-harness](./agent-harness.md) — razcep zot/Colibri, ki ga Glasspane opazuje
+- [operator-attention](./operator-attention.md) — dostavljena plast pozornosti/opozoril nad tem avtomatom stanj
+- [naming-decisions](./naming-decisions.md) — `pi_session_id → session_id`, `pi_type → event_type`
diff --git a/docs/wiki/sl/naming-decisions.md b/docs/wiki/sl/naming-decisions.md
new file mode 100644
index 0000000..60306d7
--- /dev/null
+++ b/docs/wiki/sl/naming-decisions.md
@@ -0,0 +1,63 @@
+---
+title: Imenik odločitev o poimenovanju
+description: Živa evidenca preimenovanj, ki so jih pognale presežene predpostavke — da je prihodnji odmik preverljiv proti enemu seznamu.
+---
+
+← [kazalo](./index.md)
+
+Živa evidenca preimenovanj, ki so jih pognale presežene predpostavke, da je
+prihodnji odmik preverljiv proti enemu seznamu. "Dostavljeno" pomeni združeno
+v `main`; preveri proti povezani kodi, preden zaupaš vrstici.
+
+## Načelo poimenovanja — privzeto nevtralno glede na opremo
+
+Poimenuj stvar po tem, kar **je**, ne po opremi, ki je trenutno privzeta:
+
+- **Nevtralen koncept** (vsaka oprema ga ima — ID seje, vrsta dogodka,
+ samodejni zagon, "agent") → **nevtralno ime**. Oprema je _nastavljiva
+ vrednost_ (npr. `COLIBRI_AUTOSPAWN_BINARY=zot`), nikoli zapečena v ime in
+ vedno preglasljiva s strani operaterja.
+- **Stvar, specifična za opremo** (dejanski žični format ene opreme) → ime
+ opreme sodi vanjo, vzporednice pa ostanejo simetrične (`zot_event_type` ↔
+ `pi_event_type`, `AgentRuntime::{Zot, Pi}`), tako da nobena ni
+ privilegirana.
+
+Vrstice `pi_*` v [Dostavljeno](#dostavljeno) so svarilna zgodba: nevtralni
+koncepti, napačno poimenovani po opremi. Enako velja za dokumentacijo —
+referenca na opremo je nevtralna (`AGENT-EVENTS-REFERENCE`), ne `ZOT-…`, razen
+če resnično gre samo za eno opremo.
+
+## Dostavljeno
+
+| Staro → Novo | Zakaj | Sidro |
+| ------------------------------------------------ | ----------------------------------------------------------------------------------------- | ---------------------------------------------- |
+| `COLIBRI_AUTOSPAWN_PI` → `COLIBRI_AUTOSPAWN` | Nevtralno glede na opremo (privzeti agent je zot, ne pi) | `crates/colibri-daemon/src/socket.rs` |
+| `COLIBRI_PI_BINARY` → `COLIBRI_AUTOSPAWN_BINARY` | enako | `socket.rs` (`autospawn_agent_if_configured`) |
+| `pi_session_id` → `session_id` | zot agenti imajo tudi ID-je sej; `#[serde(alias)]` ohranja povratno združljivost | `crates/colibri-glasspane/src/lib.rs` (`Pane`) |
+| `sample-pi-agent.py` | Preimenovano iz podedovanega imena testnega agenta; oddaja konzerviran _sample_ | `scripts/sample-pi-agent.py` |
+| nelokalni privzeti spawn `hermes-agent` → `zot` | `hermes-agent` je bil neobstoječ ostanek binarne datoteke | `socket.rs` (`default_agent_args`) |
+| `usb_nodes` → `hive_nodes` | vozlišče je vsak gostitelj, ki se je pridružil hive, ne samo USB zagon (`+node_type`) | `packaging/mother/mother_schema.sql` |
+| `pi_type` → `event_type` | notranje ime za normalizirano vrsto dogodka; nevtralno glede na opremo (ni serializirano) | `crates/colibri-glasspane/src/lib.rs` |
+
+## V teku
+
+_Trenutno nobeno._
+
+## Znani ostanek (še ni ukrepan)
+
+| Predmet | Opomba |
+| ------------------------------------ | ------------------------------------------------------------------------------------------------- |
+| `ADR-agent-harness-consolidation.md` | Omenjen, vendar ne obstaja; `AGENTS.md` je pravo sidro. Glej [agent-harness](./agent-harness.md). |
+
+## Strukturne odločitve
+
+| Odločitev | Zakaj / nauk | Sidro |
+| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
+| En sam dom za matično infrastrukturo = colibri | Matični MCP skripti so bili kopirani v oba colibri in clawdie-iso; kopija iso je odnesla v **SQL-injectable** `node-register-mcp` na `main`. Ista skripta v dveh repozitorijih odnese — lint prehod bi moral označiti medrepozitorijske dvojnike. | colibri `packaging/mother/`; odstranitev iso v iso PR #129 |
+| `FEATURE_COLIBRI` je notranja, ni uporabniško vidna | colibri je privzeto vključen; `FEATURE_COLIBRI=NO` je izhod v sili ob gradnji (npr. brez prevzema colibri). README pojasnjen. | clawdie-iso #130 |
+| `clawdie-gui` je stabilen operaterski ukaz | `clawdie-startx` ohranjen kot vzdevek za povratno združljivost (oba nameščena); dokumentacija uči `clawdie-gui`. Preverjeno namerno, ne odmik. | `clawdie-iso/README.md` §clawdie-gui; `clawdie-iso/build.sh` |
+
+## Glej tudi
+
+- [agent-harness](./agent-harness.md)
+- [quality-gates](./quality-gates.md) — vrata, ki bi morala te ujeti ob času PR
diff --git a/docs/wiki/sl/quality-gates.md b/docs/wiki/sl/quality-gates.md
new file mode 100644
index 0000000..930dade
--- /dev/null
+++ b/docs/wiki/sl/quality-gates.md
@@ -0,0 +1,53 @@
+---
+title: Kakovostna vrata
+description: Sprememba ni "končana", dokler krajevna vrata ne uspejo — cargo fmt, clippy, cargo test, markdown vrata, wiki-lint.
+---
+
+← [kazalo](./index.md)
+
+## Odločitev
+
+Sprememba ni "končana", dokler krajevna vrata ne uspejo:
+
+```sh
+./scripts/ci-checks.sh # cargo fmt --check, clippy -D warnings, cargo test, markdown gate, wiki-lint --strict
+```
+
+Predpotisna kljuka (`scripts/pre-push`) zažene ta ista vrata ob vsakem
+`git push` na `main` — aktiviraj enkrat na klon z `./scripts/install-hooks.sh`.
+Kljuka zavrne potisk, če katerakoli vrata padejo; obidi samo v sili z
+`--no-verify`.
+
+`.forgejo/workflows/ci.yml` kodira ista preverjanja, vendar **noben izvajalnik
+Forgejo Actions ni registriran**, zato nič ne uveljavlja preverjanj
+strežniško. Dokler izvajalnik ni aktiven, so krajevna vrata + predpotisna
+kljuka uveljavitvena plast. Navedeno kot obvezno v `AGENTS.md`.
+
+## Zakaj ta stran obstaja
+
+Napaka pri prevajanju (`pi_binary` nedefiniran, iz napol dokončanega
+preimenovanja) je dosegla `main`, ker so bila vrata preskočena _in_
+neuveljavljena. Ista revizija je ugotovila, da so bila oboja vrata takrat
+dejansko rdeča na `main`:
+
+- `clippy -D warnings` je padel na predobstoječem lintu → Rust vrata bi
+ padla za vsakogar, ki bi jih zagnal.
+- markdown vrata so padla na prettier-umazanih dokumentih.
+
+Oboje je bilo spravljeno v zeleno, zato so vrata zdaj dejansko zagonljiva.
+Nauk: vrata, ki jih nihče ne poganja (in so tako ali tako rdeča), so
+korenski vzrok, da odmik doseže `main` — bolj kot vsak posamezen spodrsljaj
+pri poimenovanju.
+
+## Odnos do tega wikija
+
+Imenik [naming-decisions](./naming-decisions.md) + `wiki-lint --strict` sta
+_pomenska_ protiutež `ci-checks.sh`: prevajalnik/clippy ujameta zlomljeno
+_kodo_, ne pa dokumenta, ki še vedno opisuje staro zasnovo, ali imena, ki je
+odneslo. Wiki lint pokriva to vrzel. Zdaj je del obveznih vrat — napaka
+odmika blokira potisk, enako kot opozorilo clippy.
+
+## Glej tudi
+
+- [agent-harness](./agent-harness.md)
+- [naming-decisions](./naming-decisions.md)
diff --git a/docs/wiki/sl/store-schema.md b/docs/wiki/sl/store-schema.md
new file mode 100644
index 0000000..022fb67
--- /dev/null
+++ b/docs/wiki/sl/store-schema.md
@@ -0,0 +1,145 @@
+---
+title: Shema shrambe
+description: Koordinacijska shramba Colibri — ena sama podatkovna zbirka SQLite, ki hrani tablo opravil, register agentov in veščin ter preslikavo najemnikov.
+---
+
+← [kazalo](./index.md)
+
+Colibrijeva koordinacijska shramba je ena sama podatkovna zbirka SQLite v
+lasti storitve `colibri`. Hrani tablo opravil, register agentov in veščin ter
+preslikavo najemnikov trezorja. Ni predpomnilnik — je trajno stanje. Večina
+pisanj gre skozi API vtičnice demona, vendar shema pripada `colibri-store`.
+
+→ `crates/colibri-store/src/schema.rs`
+
+→ `crates/colibri-store/src/lib.rs`
+
+## Odločitve
+
+### SQLite, ne PostgreSQL, za shrambo krmilne ravnine
+
+Shramba je SQLite, ker krmilna ravnina potrebuje enodatotečno podatkovno
+zbirko, ki jo je enostavno varnostno kopirati, posneti, pregledati in
+odpremiti. PostgreSQL s pgvector je načrtovan za iskanje/dolgoročni spomin,
+vendar tabla opravil in register agentov ne potrebujeta strežniškega procesa.
+
+Demon paketno združuje povezana pisanja in se zanaša na način WAL v SQLite za
+sočasne bralce. To ohranja operaterski sklad samozadosten na majhnem
+gostitelju brez diska.
+
+### WAL + tuji ključi privzeto
+
+`Store::open` ob vsakem zagonu zažene tri pragme:
+
+- `journal_mode=WAL` — bralci ne blokirajo piscev.
+- `synchronous=NORMAL` — varna sredina med polno-sinhronim in OFF.
+- `foreign_keys=ON` — FK opravilo/agent je uveljavljen.
+
+Te niso nastavljive med izvajanjem. Če bomo kdaj potrebovali drugačna jamstva
+glede trajnosti ali sočasnosti, naj bo to eksplicitno, namesto da bi povezava
+podedovala privzetke.
+
+→ `crates/colibri-store/src/lib.rs` (`Store::open`)
+
+### Samo idempotentne migracije
+
+Migracije tečejo ob vsakem `Store::open`. Uporabljajo tabele in indekse
+`IF NOT EXISTS`, tako da so ponovni teki varni. Ne odpremljamo migracij
+navzdol; razvoj sheme so aditivne tabele in stolpci. Če bo kdaj potrebna
+destruktivna migracija, mora biti to nameren ročni korak, dokumentiran v
+predaji.
+
+→ `crates/colibri-store/src/schema.rs`
+
+### Štiri tabele za štiri skrbi
+
+| Tabela | Skrb | Ključna entiteta |
+| --------- | ------------------------------ | ---------------- |
+| `tasks` | Tabla opravil | `Task` |
+| `agents` | Registrirani sodelavci | `Agent` |
+| `skills` | Katalog veščin ekipe | `Skill` |
+| `tenants` | Preslikava najemnikov/trezorja | `Tenant` |
+
+Opravila nosijo tuji ključ `agent_id` v `agents`. Vsako drugo razmerje je
+ohlapno — veščine niso povezane z agenti, najemniki pa so referencirani po
+svojem `tenant_id` v ukazih vtičnice in kljukah za oskrbo.
+
+→ `crates/colibri-store/src/schema.rs`
+
+### Omejitev CHECK stanja opravila je vir resnice
+
+`tasks.status` je omejen na `('queued','claimed','started','done','failed')`.
+Enum `TaskStatus` v Rustu ga zrcali, vendar je podatkovna zbirka zadnja
+vrata. Ukaz, ki poskusi vstaviti neznano stanje, pade ob času pisanja.
+
+→ `crates/colibri-store/src/schema.rs`
+
+### Zmožnosti agenta shranjene kot JSON, ne normalizirane
+
+`agents.capabilities` je JSON blob, na primer
+`["code","rust","freebsd"]`. Izognili smo se ločeni tabeli zmožnosti, ker so
+oznake zmožnosti samo nizi, register ekipe pa je majhen. Normalizirani stiki
+bi dodali zapletenost sheme brez izboljšanja moči poizvedb.
+
+Če metapodatki zmožnosti zrastejo (uteži, različice, zahtevane veščine),
+lahko to kasneje razcepimo; trenutna shema namenoma ostaja pragmatična.
+
+→ `crates/colibri-store/src/lib.rs` (`register_agent`)
+
+### Najemniki kodirajo preslikavo 1:1:1 ječa/trezor/zbirka
+
+`tenants` hrani `tenant_id`, `jail_root_path` in `collection_id` kot UNIQUE
+stolpce. Pravilo je `tenant_id = ime ječe = zbirka Vaultwarden`. To omogoča
+`colibri-vault`, da poišče ječo po imenu in natančno ve, katero pot
+gostitelja in zbirko Vaultwarden uporabiti pri pisanju datoteke okolja.
+
+Stolpec `status` najemnika sledi življenjskemu ciklu:
+`provisioned → active → stopped → destroyed`. Neodvisen je od tega, ali
+proces ječe teče; upravljanje življenjskega cikla je ločena skrb.
+
+→ `crates/colibri-store/src/schema.rs` (komentarji na `tenants`)
+
+### Privzeta pot podatkovne zbirke je specifična za platformo
+
+Privzetek shrambe je:
+
+- `COLIBRI_DB_PATH`, če je nastavljeno.
+- FreeBSD: `/var/db/colibri/colibri.sqlite`.
+- Linux/macOS: `$XDG_DATA_HOME/colibri/colibri.sqlite`, s padcem na
+ `$HOME/.local/share/colibri/colibri.sqlite`, nato `/tmp`.
+
+FreeBSD privzeto uporablja `/var/db`, ker je to običajni imenik za krajevno
+stanje storitev. Linuxov padec spoštuje XDG, tako da je razvoj na delovni
+postaji normalen.
+
+→ `crates/colibri-store/src/lib.rs` (`default_db_path`)
+
+### Izvoz JSON za varnostne kopije in paritetne teste
+
+`Store::export_json()` izvozi vse štiri tabele v en objekt JSON. Obstaja za
+paritetne dife dvojnega teka, priložnostne varnostne kopije in razhroščevanje.
+Ni primarni poizvedovalni API; večina bralcev naj uporablja tipizirane metode.
+
+## Razmerja entitet
+
+```text
+tasks.agent_id ----------> agents.id
+
+ tasks agents skills tenants
+ ----- ------ ------ -------
+ id id id tenant_id
+ agent_id FK name name jail_root_path
+ status capabilities description collection_id
+ title status category status
+ description created_at created_at created_at
+ created_at updated_at
+ updated_at
+```
+
+## Glej tudi
+
+- [task-board](./task-board.md) — življenjski cikel opravila in ujemanje zmožnosti
+- [operator-cli](./operator-cli.md) — ukazi vtičnice, ki pišejo v te tabele
+- [vault-provision](./vault-provision.md) — kako tabela najemnikov poganja oskrbo datotek env
+- [jail-confinement](./jail-confinement.md) — imena ječ se preslikajo v vrstice najemnikov
+- [skills-catalog](./skills-catalog.md) — bralni porabnik veščin
--
2.45.3
From 8f84eb51c2c0773570eb441bf37f35ee1807d2ca Mon Sep 17 00:00:00 2001
From: Sam & Claude
Date: Fri, 26 Jun 2026 11:05:32 +0200
Subject: [PATCH 04/19] =?UTF-8?q?docs(sl):=20translate=20wiki=20group=202?=
=?UTF-8?q?=20=E2=80=94=20deployment,=20operator-cli,=20terminal,=20tui,?=
=?UTF-8?q?=20headroom-sidecar?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/wiki/sl/deployment.md | 171 +++++++++++++++++++++++++++++++
docs/wiki/sl/headroom-sidecar.md | 49 +++++++++
docs/wiki/sl/operator-cli.md | 62 +++++++++++
docs/wiki/sl/terminal.md | 49 +++++++++
docs/wiki/sl/tui.md | 56 ++++++++++
5 files changed, 387 insertions(+)
create mode 100644 docs/wiki/sl/deployment.md
create mode 100644 docs/wiki/sl/headroom-sidecar.md
create mode 100644 docs/wiki/sl/operator-cli.md
create mode 100644 docs/wiki/sl/terminal.md
create mode 100644 docs/wiki/sl/tui.md
diff --git a/docs/wiki/sl/deployment.md b/docs/wiki/sl/deployment.md
new file mode 100644
index 0000000..cf29329
--- /dev/null
+++ b/docs/wiki/sl/deployment.md
@@ -0,0 +1,171 @@
+---
+title: Namestitev
+description: Nameščevalnik gostitelja clawdie — odkrije ZFS, pripravi podatkovne zbirke, ustvari uporabnika storitve in namesti enoto rc.d/systemd.
+---
+
+← [kazalo](./index.md)
+
+Zaboj `clawdie` je Colibrijev nameščevalnik gostitelja. Odkrije postavitev
+ZFS na napravi in pripravi storitev `clawdie`. Na FreeBSD to pomeni storitev
+rc.d, podatkovne zbirke ZFS in neprivilegiranega uporabnika. Na Linuxu lahko
+uporablja systemd in bodisi ZFS bodisi navadne imenike.
+
+→ `crates/clawdie/src/main.rs` | `crates/clawdie/src/plan.rs`
+→ `docs/ISO-SERVICE-LAYOUT.md` | `docs/CLAWDIE-INSTALLER-HANDOFF.md`
+
+## Odločitve
+
+### ZFS je obvezen na FreeBSD, priporočen na Linuxu
+
+FreeBSD ne podpira postavitve z navadnimi imeniki. Če ZFS uporabniški prostor
+manjka, načrt takoj sporoči napako. Linux lahko pade na navadne imenike, če
+ni imenovan noben bazen in ZFS ni na voljo, na zahtevo pa lahko ustvari svež
+bazen na prostem disku.
+
+To ustreza produkcijskemu cilju: goli FreeBSD na zrcalnem polju ZFS RAID1.
+Podpora za Linux omogoča razvoj in CI brez gostitelja ZFS.
+
+### Shramba je razrešena, ne nastavljena
+
+`clawdie plan` razreši shrambo v tem vrstnem redu:
+
+1. Če je podano `--pool NAME --create-pool DEVICE`, ustvari ta bazen.
+2. Če je podano `--pool NAME`, uporabi ta obstoječi bazen.
+3. Če ni podan noben bazen in obstaja natanko en bazen, ga uporabi.
+4. Če obstaja več bazenov in noben ni imenovan, napaka.
+5. Na Linuxu brez ZFS padec na navadne imenike.
+
+To odpravlja potrebo po ročno napisani topološki datoteki na tipičnih
+enobazenskih gostiteljih, hkrati pa omogoča ekspliciten nadzor, ko je
+potreben.
+
+→ `crates/clawdie/src/main.rs` (`pick_pool`, `validate_storage`)
+
+### Podatkovne zbirke ločujejo stanje od dnevnikov
+
+Ko je ZFS uporabljen, nameščevalnik ustvari:
+
+- `/clawdie` kot vsebniško podatkovno zbirko z `canmount=off`
+- `/clawdie/db` priklopljeno na `/var/db/clawdie`
+- `/clawdie/log` priklopljeno na `/var/log/clawdie`
+
+Hramba podatkovne zbirke in dnevnikov v ločenih podatkovnih zbirkah omogoča,
+da posnetki, kvote in pravilniki vrtenja dnevnikov veljajo neodvisno.
+
+→ `crates/clawdie/src/plan.rs` (`zfs_dataset_steps`)
+
+### Privzeto suhi tek
+
+`clawdie apply` izpiše načrt in konča, razen če je podano `--yes`. `discover`
+in `plan` sta samo za branje. To ščiti produkcijske gostitelje pred nenamernim
+pripravljanjem.
+
+→ `crates/clawdie/src/main.rs` (`Cmd::Apply`)
+
+### Ustvarjanje bazena je varovano pred zasedenimi diski
+
+`--create-pool` na nepraznem disku je zavrnjeno, razen če je podano tudi
+`--force`. Nameščevalnik uporablja `lsblk` na Linuxu za zaznavanje particij,
+datotečnih sistemov, priklopnih točk in korenskega diska. Varovalo je
+konzervativno: če je disk dvoumen, ga je treba eksplicitno vsiliti.
+
+→ `crates/clawdie/src/disk.rs` | `crates/clawdie/src/main.rs` (`validate_create_device`)
+
+### En sam neprivilegiran uporabnik storitve
+
+Storitev teče kot `_clawdie` na obeh platformah. Na FreeBSD je uporabnik
+ustvarjen z `pw useradd -s /usr/sbin/nologin -d /var/db/clawdie`, izhodna koda
+`65` (že obstaja) pa se obravnava kot preskok. Na Linuxu se uporabi `useradd
+--system`. Imeniki stanja se nato chownajo temu uporabniku.
+
+→ `crates/clawdie/src/platform.rs`
+
+### Upravitelji storitev, specifični za platformo, ista specifikacija
+
+`Platform` je notranji trait. Izvedbi se razlikujeta samo v tem, kako
+namestita in omogočita enoto:
+
+- FreeBSD: zapiše `/usr/local/etc/rc.d/clawdie`, uporabi `sysrc clawdie_enable=YES`.
+- Linux: zapiše `/etc/systemd/system/clawdie.service`, zažene `systemctl enable --now clawdie`.
+
+Obe uporabljata isto `ServiceSpec` (binarna datoteka, uporabnik, podatkovni
+imenik, ime storitve). Zagon `apply` na različnih platformah zato ustvari
+enako postavitev datotečnega sistema in se razlikuje samo v ovoju upravitelja
+storitev.
+
+→ `crates/clawdie/src/platform.rs` (`FreeBsd`, `Linux`)
+
+### Demon teče skozi nadzornika platforme
+
+Ustvarjeni skript rc.d za FreeBSD izvede `/usr/local/bin/colibri-daemon` skozi
+`/usr/sbin/daemon -u _clawdie`, tako da nadzornik ob zrušitvi ponovno zažene
+in proces pade na neprivilegiranega uporabnika. Enota systemd je preprosta
+storitev z `Restart=on-failure`.
+
+Nameščevalnik sam ne zažene demona ali ne pripravi binarne datoteke; samo
+ustvari okolje. Operater ali paketna gradnja pripravi `colibri-daemon` in nato
+`service clawdie start`.
+
+→ `docs/ISO-SERVICE-LAYOUT.md` (rc.d prek daemon(8))
+
+### Skrivnosti ne piše nameščevalnik
+
+Nameščevalnik se ne dotika ključev API ponudnika. Ločena datoteka — običajno
+`/usr/local/etc/colibri/provider` — hrani skrivnosti in jo pred zagonom
+demona prebere rc.d. To ohranja domet nameščevalnika omejen na ZFS, imenike,
+uporabnike in datoteke storitev.
+
+→ [vault-provision](./vault-provision.md)
+
+### Koraki se izvajajo zaporedno in se ustavijo ob napaki
+
+`deploy::apply` zažene vsak `Step` po vrsti. Koraki `Run` pokličejo lupino in
+padejo ob neničelnem izhodu, razen če korak navede dovoljene izhodne kode.
+Koraki `WriteFile` ustvarijo starševske imenike, zapišejo datoteko in jo
+chmodajo. Če katerikoli korak pade, se apply takoj ustavi in sporoči ukaz, ki
+je padel, ter stderr.
+
+→ `crates/clawdie/src/deploy.rs`
+
+## Oblika načrta
+
+```text
+clawdie plan
+ ├── ZFS layout (ali navadni imeniki)
+ │ ├── create /clawdie container
+ │ ├── create /clawdie/db -> /var/db/clawdie
+ │ └── create /clawdie/log -> /var/log/clawdie
+ └── service install
+ ├── create user _clawdie
+ ├── chown state dirs
+ ├── write service unit (rc.d / systemd)
+ ├── enable service (sysrc / systemctl)
+ └── [systemd] daemon-reload + start
+```
+
+## Tipična namestitev FreeBSD
+
+```sh
+# odkrij
+clawdie discover
+
+# predogled
+clawdie plan
+
+# pripravi podatkovne zbirke, uporabnika in storitev rc.d
+sudo clawdie apply --yes
+
+# zaženi, ko je binarna datoteka colibri-daemon pripravljena
+sudo service clawdie start
+```
+
+## Navzkrižne povezave na izvajalne poti
+
+Po namestitvi ima storitev te poti:
+
+- `/var/db/clawdie/colibri.sqlite` — koordinacijska shramba SQLite
+- `/var/run/clawdie/clawdie.sock` — Unix vtičnica demona
+- `/var/log/clawdie/daemon.log` — dnevnik stdout/stderr
+- `/usr/local/etc/colibri/` — konfiguracija in skrivnosti ponudnika
+
+→ [store-schema](./store-schema.md) | [operator-cli](./operator-cli.md)
diff --git a/docs/wiki/sl/headroom-sidecar.md b/docs/wiki/sl/headroom-sidecar.md
new file mode 100644
index 0000000..bd45985
--- /dev/null
+++ b/docs/wiki/sl/headroom-sidecar.md
@@ -0,0 +1,49 @@
+---
+title: Stranski vagon Headroom
+description: Colibri lahko neobvezno prosi krajevni stranski vagon headroom-ai, da stisne rezultate orodij, preden dosežejo proračun žetonov.
+---
+
+← [kazalo](./index.md)
+
+Colibri lahko neobvezno prosi krajevni stranski vagon `headroom-ai`, da stisne
+rezultate orodij, preden dosežejo proračun žetonov. Je ločen proces Python, ki
+posluša na drugi vtičnici Unix, in je privzeto izklopljen.
+
+→ `crates/colibri-daemon/src/session.rs` (klicatelj)
+→ `headroom-ai/` (izvedba stranskega vagona)
+
+## Odločitve
+
+### Kompresija, gnana s proračunom, ne vedno vklopljena
+
+Stranski vagon se pokliče samo, ko seja tvega, da bo presegla svoj proračun
+— ne ob vsakem rezultatu orodja. Sprožilec je prag v sledilcu stroškov: če
+bi naslednji korak presegel proračun, najprej stisni, nato obreži. Brez
+stranskega vagona je zasilni izhod preprosto krajšanje.
+
+→ [cost-model](./cost-model.md) (stiskanje T14)
+
+### Unix vtičnica, ne HTTP
+
+Komunikacija poteka prek druge vtičnice Unix (`/var/run/headroom/headroom.sock`).
+Stranski vagon sprejme surovo besedilo, vrne povzetek. Brez avtentikacije —
+zaupa meji vtičnice Unix in temu, da oba procesa tečeta kot isti uporabnik.
+
+### Enostaven protokol — besedilo noter, besedilo ven
+
+Stranski vagon prejme eno vrstico JSON `{"text": "..."}`, vrne eno vrstico
+JSON `{"summary": "..."}`. Brez sej, brez konteksta, brez zgodovine. Vsak
+klic je brez stanja.
+
+### Ločen proces, ne knjižnica
+
+Stranski vagon teče kot neodvisen proces Python, ne kot uvoz Rust. To ga
+izolira od zrušitev (če stranski vagon pade, demon nadaljuje brez njega),
+izolira njegov pomnilnik (model Python je lačen) in omogoča neodvisno
+posodabljanje.
+
+→ `headroom-ai/src/server.py`
+
+## Glej tudi
+
+- [cost-model](./cost-model.md) — kdaj se sproži stranski vagon
diff --git a/docs/wiki/sl/operator-cli.md b/docs/wiki/sl/operator-cli.md
new file mode 100644
index 0000000..0bfb0a9
--- /dev/null
+++ b/docs/wiki/sl/operator-cli.md
@@ -0,0 +1,62 @@
+---
+title: Operaterski CLI (`colibri`)
+description: Binarna datoteka `colibri` je operaterski vmesnik ukazne vrstice do demona — tanek tipiziran odjemalec Unix vtičnice.
+---
+
+← [kazalo](./index.md)
+
+Binarna datoteka `colibri` je operaterski vmesnik ukazne vrstice do demona.
+Je tanek odjemalec — pošilja ukaze JSON po vtičnici Unix, razčlenjuje odgovore
+in jih izpisuje. Vsak podukaz CLI se preslika v en ukaz demona. Vmesnik CLI
+dodaja priročnost (barvni izpis, privzetki, oblikovalci), ne poslovne logike.
+
+→ `crates/colibri-client/src/bin/colibri.rs`
+
+## Odločitve
+
+### Ena binarna datoteka, ena vtičnica
+
+Obstaja ena binarna datoteka `colibri`, ki se poveže na eno vtičnico
+(`/var/run/colibri/colibri.sock` ali `COLIBRI_SOCKET`). Ni podukazov za
+izbiro demona — večgostiteljske operacije gredo skozi most krmilne ravnine.
+Operater izrecno usmeri na drug gostitelj (`nc 9190`), ne da bi
+CLI podpiral več končnih točk.
+
+**Zakaj ne več profilov demona**: en demon na gostitelja je zadosten. Več
+končnih točk bi v CLI vneslo stanje (`colibri --host osa status`), ki ga most
+že rešuje na omrežni plasti.
+
+### CLI je generičen odjemalec JSON-RPC
+
+Vsak podukaz zgradi objekt `ColibriCommand`, pokliče
+`client.request(command).await` in izpiše rezultat. Odjemalec ne ve ničesar o
+pomenu kateregakoli ukaza — samo serializira in razčlenjuje.
+
+Oblikovalci izhodov (`print_json`, `print_table`, `print_key_value`) so čiste
+funkcije nad `serde_json::Value`. Če demon doda novo polje, se samodejno
+prikaže v izhodu JSON brez spremembe v CLI.
+
+### En ukaz na zagon, ne interaktivno
+
+Vsak zagon `colibri` izvede natanko en ukaz in konča. Brez lupine REPL, brez
+več ukazov v eni seji. To ohranja CLI brez stanja in varnega za skriptanje:
+`colibri status | jq '.data.agents'` je enako zanesljiv v cronu kot v
+terminalu.
+
+→ `crates/colibri-client/src/bin/colibri.rs` (`main`)
+
+### Podukazi, specifični za tablo opravil, sledijo istemu vzorcu
+
+Ukazi za opravila (`create-task`, `intake-task`, `claim-task`,
+`transition-task`) sprejemajo argumente CLI, ki se preslikajo v polja ukaza
+JSON. Izhod je bodisi celoten objekt opravila (za `create-task`,
+`intake-task`) bodisi potrditev (`claim-task`, `transition-task`). Noben
+podukaz ne zahteva več kot ~3 argumentov — opravilna tabla je namenjena
+dodeljevanju s strani agenta, ne ročnemu upravljanju.
+
+→ `crates/colibri-client/src/bin/colibri.rs`
+
+## Glej tudi
+
+- [task-board](./task-board.md) — ukazi, ki jih CLI zrcali
+- [deployment](./deployment.md) — kako je nameščena binarna datoteka demona
diff --git a/docs/wiki/sl/terminal.md b/docs/wiki/sl/terminal.md
new file mode 100644
index 0000000..572d9e7
--- /dev/null
+++ b/docs/wiki/sl/terminal.md
@@ -0,0 +1,49 @@
+---
+title: Terminal — zmožnost, ne znamka
+description: Zmožnost terminala kot ozka, prenosljiva abstrakcija — Kitty, ne iTerm2; ANSI, ne lastniški protokoli.
+---
+
+← [kazalo](./index.md)
+
+Colibrijeva zmožnost terminala je namenoma ozka: zajemi zaslon tmux, pošlji
+tipke, beri izhod. Ne vključuje večpredstavnosti, brskalnika ali obogatenega
+besedila. Izbira terminalskega odjemalca temelji na enem kriteriju: ali
+protokol podpira **razširjeno poročanje tipk**, tako da lahko Glasspane
+razlikuje `Enter` od `Ctrl+Enter`?
+
+## Odločitve
+
+### Kitty kot terminalski odjemalec (protokol, ne GUI)
+
+Colibri cilja na terminalski odjemalec Kitty zaradi njegovega protokola
+razširjenega poročanja tipk — brez tega Glasspane ne more razlikovati
+modifikatorskih tipk (`Ctrl+Enter` proti `Enter`), ne da bi zajel vnosno
+plast. To ni predpis GUI — vsak terminalski odjemalec, ki izvaja protokol
+Kitty (WezTerm, foot, Ghostty), deluje enako dobro.
+
+### tmux kot terminalski multiplekser (ne alternativni terminal)
+
+Agentske seje se izvajajo znotraj sej tmux, ker tmux zagotavlja obstojnost
+seje (preživi odklop), več oken na agenta in programski vmesnik `capture-pane`,
+ki ga Glasspane uporablja za zajem terminala. Tmux se vstavi med terminalski
+odjemalec in agenta, ne nadomesti terminalskega odjemalca.
+
+### SSH terminfo — `tmux-256color`, ne `xterm-256color`
+
+`tmux-256color` je edina vrednost `TERM`, podprta za SSH povezave do agentov.
+Ne podpira `xterm-256color`, ker zunaj tmux ta vrednost ne more poročati
+razširjenih zaporedij Kitty. Terminfo se uveljavi v zanki demona za vse
+povezave.
+
+### ANSI, ne lastniški — vendar s prehodom Kitty
+
+Izhod agenta uporablja ANSI ubežna zaporedja za barve in pozicioniranje
+kazalke. To je univerzalno. Edina razširitev onkraj ANSI je stiskanje vnosa
+tipk, kjer Glasspane potrebuje protokol Kitty za razlikovanje modifikatorjev.
+
+→ `crates/colibri-daemon/src/terminal.rs`
+
+## Glej tudi
+
+- [tui](./tui.md) — nadzorna plošča TUI, ki se upodablja v tem terminalu
+- [operator-attention](./operator-attention.md) — zajem terminala kot signal
diff --git a/docs/wiki/sl/tui.md b/docs/wiki/sl/tui.md
new file mode 100644
index 0000000..9966637
--- /dev/null
+++ b/docs/wiki/sl/tui.md
@@ -0,0 +1,56 @@
+---
+title: Terminalska nadzorna plošča (colibri-tui)
+description: Colibrijeva živa terminalska nadzorna plošča — povezuje se na Unix vtičnico demona in prikazuje agente, stanja ter pozornostna opozorila.
+---
+
+← [kazalo](./index.md)
+
+TUI je Colibrijeva živa terminalska nadzorna plošča. Poveže se na Unix
+vtičnico demona, poizveduje API posnetkov (`glasspane-snapshot`) in ga
+upodablja kot tabelo podoken s stanjem. Zgrajena z ratatui + crossterm za
+barvni terminalski izhod.
+
+→ `crates/colibri-glasspane-tui/src/main.rs`
+
+## Odločitve
+
+### En zaslon, en pogled — ni navigacije po zavihkih
+
+TUI upodablja eno glavno tabelo s podokni in neobvezno pozornostno vrstico na
+vrhu. Brez zavihkov, brez stranskih plošč, brez oken. Operater filtrira z
+vnosom s tipkami (`/` za sejo, `a` za pozornost), ne s klikanjem.
+
+### Razporeditev glede na stanje
+
+Vsako podokno dobi barvo glede na `AgentState`: zelena (Working), rumena
+(Blocked), modra (Done), rdeča (Error), siva (Idle). Podokna z napako že
+prikazujejo rdeče besedilo. Pozornostna vrstica, tipke za skok in filter
+dodajajo rdečo obrobo in inverzno ozadje na vrstice, ki potrebujejo pozornost.
+
+→ [operator-attention](./operator-attention.md)
+
+### Enovrstične vrstice podoken po meri agentov
+
+Vsaka vrstica podokna vsebuje: ime agenta, stanje, ID seje (skrajšan),
+časovni žig zadnjega dogodka in zastavico stalled. To je minimalen nabor, ki
+ga operater potrebuje za oceno agenta na prvi pogled. Prihodnje delo: površina
+metapodatkov podokna za bogatejše vrstice.
+
+### Osveževanje — poizvedovanje, ne potiskanje
+
+TUI poizveduje demonov API posnetkov (`glasspane-snapshot`) vsakih 250 ms.
+Brez WebSocket, brez SSE, brez potisnih obvestil med TUI in demonom.
+Poizvedovanje ohranja vtičnico demona brez stanja.
+
+### Barve so nosilne
+
+`NO_COLOR=1`, ki uhaja iz sej Hermesa, bi naredil TUI neberljiv brez barv.
+`main()` vsili `crossterm::style::force_color_output(true)`, da to prepreči.
+To je nadzorna plošča, ne orodje za cevovod.
+
+→ `crates/colibri-glasspane-tui/src/main.rs`
+
+## Glej tudi
+
+- [glasspane](./glasspane.md) — avtomat stanj, ki ga TUI upodablja
+- [operator-attention](./operator-attention.md) — pozornostna vrstica in tipke za skok
--
2.45.3
From 9fd323b092ebb9359a91eedeae0a98657e5f60a5 Mon Sep 17 00:00:00 2001
From: Sam & Claude
Date: Fri, 26 Jun 2026 11:08:02 +0200
Subject: [PATCH 05/19] =?UTF-8?q?docs(sl):=20translate=20wiki=20group=203?=
=?UTF-8?q?=20=E2=80=94=20runtime-inventory,=20jail-confinement,=20skills-?=
=?UTF-8?q?catalog,=20vault-provision,=20agent-events-reference,=20externa?=
=?UTF-8?q?l-mcp?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Completes Slovenian wiki translation: all 23 pages now have sl/ mirrors.
---
docs/wiki/sl/agent-events-reference.md | 63 +++++++++++++++++++++++
docs/wiki/sl/external-mcp.md | 69 ++++++++++++++++++++++++++
docs/wiki/sl/jail-confinement.md | 44 ++++++++++++++++
docs/wiki/sl/runtime-inventory.md | 48 ++++++++++++++++++
docs/wiki/sl/skills-catalog.md | 45 +++++++++++++++++
docs/wiki/sl/vault-provision.md | 49 ++++++++++++++++++
6 files changed, 318 insertions(+)
create mode 100644 docs/wiki/sl/agent-events-reference.md
create mode 100644 docs/wiki/sl/external-mcp.md
create mode 100644 docs/wiki/sl/jail-confinement.md
create mode 100644 docs/wiki/sl/runtime-inventory.md
create mode 100644 docs/wiki/sl/skills-catalog.md
create mode 100644 docs/wiki/sl/vault-provision.md
diff --git a/docs/wiki/sl/agent-events-reference.md b/docs/wiki/sl/agent-events-reference.md
new file mode 100644
index 0000000..1fc9a56
--- /dev/null
+++ b/docs/wiki/sl/agent-events-reference.md
@@ -0,0 +1,63 @@
+---
+title: Referenca agentskih dogodkov
+description: Referenca dogodkov po opremi za zot in pi, preslikave Glasspane in preverjena polja prepisa.
+---
+
+← [kazalo](./index.md)
+
+Ta stran je kanonična referenca za vse vrste dogodkov JSONL, ki jih oddajata
+zot in pi. Vsaka vrstica je en dogodek; vsak dogodek ima znano shemo. Glasspane
+uporablja te preslikave za pretvorbo surovih dogodkov v prehode `AgentState`.
+
+## Skupna polja (vsi dogodki)
+
+Vsak agentski dogodek JSONL vsebuje ta polja:
+
+| Polje | Tip | Opis |
+| ------------ | --- | ---------------------------------------------- |
+| `event` | niz | Vrsta dogodka (npr. `agent_start`, `turn_end`) |
+| `timestamp` | niz | Časovni žig ISO 8601 |
+| `session_id` | niz | ID seje agenta |
+
+## Dogodki zot
+
+| Dogodek | Sprožilec | Preslikava Glasspane |
+| ------------- | ------------------------------ | --------------------------------- |
+| `turn_start` | Agent začne obdelovati korak | `Working` |
+| `tool_call` | Agent pokliče orodje | (metapodatki, ne spremeni stanja) |
+| `tool_result` | Orodje vrne rezultat | (metapodatki) |
+| `done` | Agent konča korak uspešno | `Done` |
+| `error` | Agent naleti na napako | `Error` |
+| `blocked` | Agent čaka na operaterski vnos | `Blocked` |
+| `agent_start` | Zagon agenta | `Working` |
+| `usage` | Poročilo o uporabi žetonov | (metapodatki) |
+
+## Dogodki pi
+
+| Dogodek | Sprožilec | Preslikava Glasspane |
+| -------------- | ------------------------------ | -------------------- |
+| `agent_start` | Zagon agenta | `Working` |
+| `turn_end` | Agent konča korak | `Done` |
+| `agent_error` | Agent naleti na napako | `Error` |
+| `blocked` | Agent čaka na operaterski vnos | `Blocked` |
+| `usage_report` | Poročilo o uporabi žetonov | (metapodatki) |
+
+## Preverjena polja prepisa
+
+Ta polja so se pojavila v resničnem izhodu zot in so potrjena kot prisotna:
+
+| Polje | Vir | Opomba |
+| ------------ | --- | ------------------------------------- |
+| `turn_id` | zot | Enolični ID koraka |
+| `model` | zot | Ime modela, uporabljenega za ta korak |
+| `tokens_in` | zot | Število vhodnih žetonov |
+| `tokens_out` | zot | Število izhodnih žetonov |
+| `tool_name` | oba | Ime orodja, ki ga je agent poklical |
+
+→ `crates/colibri-glasspane/src/lib.rs` (`zot_event_type`, `PiJsonlIngestor`)
+→ `crates/colibri-daemon/tests/zot_rpc_smoke.rs`
+
+## Glej tudi
+
+- [glasspane](./glasspane.md) — kako se dogodki preslikajo v stanja
+- [agent-harness](./agent-harness.md) — razcep zot/Colibri
diff --git a/docs/wiki/sl/external-mcp.md b/docs/wiki/sl/external-mcp.md
new file mode 100644
index 0000000..0a9cc39
--- /dev/null
+++ b/docs/wiki/sl/external-mcp.md
@@ -0,0 +1,69 @@
+---
+title: Zunanji MCP
+description: Most MCP za urejevalnike in zunanje gostitelje — branje, pisanje in vrata za zunanje klice prek Colibrijeve implementacije MCP.
+---
+
+← [kazalo](./index.md)
+
+Colibri izpostavlja podmnožico svoje krmilne ravnine kot strežnik MCP
+(Model Context Protocol), tako da lahko zunanji urejevalniki (VS Code,
+Zed), agentski okviri (Claude Code, Hermes) in skripte komunicirajo z
+Colibrijem prek standardiziranega protokola.
+
+→ `crates/colibri-mcp/src/lib.rs`
+
+## Odločitve
+
+### stdio, ne HTTP
+
+Strežnik MCP komunicira prek standardnega vhoda/izhoda (stdio), ne prek
+HTTP ali WebSocket. To je standardni prenos MCP — vsak odjemalec MCP ga
+podpira. Prav tako se izogne potrebi po odprtju drugega omrežnega vmesnika.
+
+### Tri orodja, ne celoten API
+
+Strežnik MCP izpostavlja tri orodja:
+
+| Orodje | Ukaz demona | Namen |
+| ------------------ | -------------------- | ----------------------------------------------- |
+| `colibri_status` | `status` | Stanje demona (agenti, opravila, predpomnilnik) |
+| `colibri_snapshot` | `glasspane-snapshot` | Trenutni posnetek podoken Glasspane |
+| `colibri_spawn` | `spawn-agent` | Zaženi novega agenta |
+
+Ta tri orodja pokrivajo 90 % zunanjih interakcij. Celoten API vtičnice je na
+voljo neposrednim odjemalcem vtičnice; MCP je priročna podmnožica.
+
+### Ovoj Bash, ne vgrajeni proces
+
+`colibri-mcp` je skripta Bash, ki se poveže na vtičnico demona, pošlje ukaz
+JSON in vrne odgovor JSON. Ni dolgotrajen proces — vsak klic zažene novo
+skripto. To pomeni, da je ničelna konfiguracija za odjemalce MCP (samo
+registrirajte pot skripte) in ničelno vzdrževanje stanja.
+
+→ `packaging/freebsd/colibri-mcp`
+
+### Vrata za branje/pisanje in zunanje klice
+
+Implementacija MCP ločuje tri skrbi:
+
+- **Branje**: `status`, `snapshot` — samo za branje, vedno na voljo.
+- **Pisanje**: `spawn` — spremeni stanje demona. Zaščiteno z zastavico
+ `allow_write` v konfiguraciji MCP.
+- **Zunanji klic**: katerokoli orodje lahko sproži verigo MCP, ki sega
+ navzven do drugega strežnika MCP. Zaščiteno z zastavico `allow_external_call`.
+
+→ `crates/colibri-mcp/src/config.rs`
+
+### Inicializacijski manifest ob zagonu
+
+Vsak strežnik MCP mora ob zagonu vrniti manifest `initialize`. Colibrijev
+manifest deklarira svoja tri orodja, zmogljivost `tools` in prazno
+zmogljivost `resources`. Odjemalci, ki ne pokličejo `initialize`, dobijo
+napako protokola.
+
+→ `crates/colibri-mcp/src/protocol.rs` (`initialize`)
+
+## Glej tudi
+
+- [mother-hive](./mother-hive.md) — MCP prek SSH za matično vozlišče
+- [operator-cli](./operator-cli.md) — neposreden dostop do vtičnice
diff --git a/docs/wiki/sl/jail-confinement.md b/docs/wiki/sl/jail-confinement.md
new file mode 100644
index 0000000..7bfd6ce
--- /dev/null
+++ b/docs/wiki/sl/jail-confinement.md
@@ -0,0 +1,44 @@
+---
+title: Omejitev ječ
+description: Trajne proti prehodnim ječam, pravilnik načina priv in ponovna uporaba omejitve zaganjalnika za strežnike MCP.
+---
+
+← [kazalo](./index.md)
+
+Colibrijevi agenti tečejo znotraj ječ FreeBSD Bastille. Model omejitve je
+zasnovan tako, da so vse agentske interakcije privzeto zaprte, izrecne
+priklopne točke pa namerne.
+
+→ `packaging/freebsd/bastille/`
+
+## Odločitve
+
+### Trajne ječe za agente, prehodne za MCP
+
+Agenti tečejo v **trajnih** ječah (eni na agenta), ki preživijo ponovne
+zagone. Te ječe imajo stanje: nameščene pakete, konfiguracijske datoteke,
+klonirane repozitorije. Strežniki MCP tečejo v **prehodnih** ječah, ustvarjenih
+iz iste predloge, a uničenih, ko se seja MCP konča. Prehodne ječe se začnejo
+sveže — brez stanja, brez ostankov.
+
+→ `crates/colibri-daemon/src/spawner.rs`
+
+### Pravilnik načina priv — `enforce_statfs=1`, brez `allow.mount`
+
+Vse ječe delijo privzeti pravilnik, ki prepoveduje priklope, surova vtičnice,
+spreminjanje lastništva in dostop do naprav. Vsaka priklopna točka, ki jo
+agent potrebuje (repozitoriji, vtičnica demona, imeniki stanja), je izrecno
+navedena v konfiguraciji Bastille.
+
+### Ista omejitev zaganjalnika za MCP kot za agente
+
+Ko se zažene strežnik MCP, uporabi isti mehanizem spawnanja v ječi kot
+agenti. Predloga ječe (`clawdie-mcp`), konfiguracija Bastille, pravilnik
+načina priv in uporabnik so enaki. Edina razlika: ječa ni trajna.
+
+→ `crates/colibri-mcp/src/lib.rs`
+
+## Glej tudi
+
+- [deployment](./deployment.md) — postavitev ZFS za ječe
+- [vault-provision](./vault-provision.md) — oskrba okolja znotraj ječ
diff --git a/docs/wiki/sl/runtime-inventory.md b/docs/wiki/sl/runtime-inventory.md
new file mode 100644
index 0000000..825dfeb
--- /dev/null
+++ b/docs/wiki/sl/runtime-inventory.md
@@ -0,0 +1,48 @@
+---
+title: Popis izvajalnega okolja
+description: Bralnik stanja gostitelja — aditivne, bralne integracije, ki zbirajo različice OS, paketov in izvajalnega okolja.
+---
+
+← [kazalo](./index.md)
+
+Bralnik popisa izvajalnega okolja je aditivna, bralna plast: bere dejstva o
+gostitelju, ne da bi karkoli spremenil. Je vhod za razporejevalnik (ali lahko
+ta gostitelj izvede opravilo?) in za matični register (kaj ta gostitelj je?).
+
+→ `crates/colibri-runtime-inventory/src/lib.rs`
+
+## Odločitve
+
+### Aditivno, ne konfiguracijsko
+
+Bralnik odkrije dejstva, ne uveljavlja stanja. Če manjka `pi` ali `zot`, to
+zabeleži — ne namesti. Če je prisotnih več različic Node.js, jih navede vse.
+Če je ZFS odklopljen, to zabeleži — ne uvozi bazena. Operater se odloči, kaj
+storiti s popisom.
+
+### Bralni vmesnik, specifičen za platformo
+
+`HostInfo` je skupen struct. Vsaka platforma implementira `HostReader` trait.
+FreeBSD-jev bralnik uporablja `pkg`, `sysctl`, `zfs`, `kldstat` in `pciconf`.
+Linuxov bralnik uporablja `uname`, `/proc`, `lsblk` in `lspci`. Skupna
+struktura pomeni, da razporejevalnik in mati vidita isto obliko ne glede na
+OS.
+
+→ `crates/colibri-runtime-inventory/src/platform.rs`
+
+### Rezultat je JSON, konzumirajo ga trije porabniki
+
+Popis se serializira v `clawdie.runtime-version-inventory.v1`. Trije porabniki:
+
+- **Razporejevalnik**: ujemanje zmožnosti (`"can_run_local_llm": true`).
+- **Mati**: register hive (`"hostname": "domedog"`, `"os": "linux"`).
+- **Operater**: ukaz `colibri runtime-inventory`.
+
+### Brez pisanja — integracije so samo za branje
+
+Bralnik ne piše v podatkovno zbirko, ne spreminja konfiguracije in ne
+spreminja stanja demona. Je čista funkcija `HostReader::read() → HostInfo`.
+To pomeni, da je varno zagnati ga v cronu, ob zagonu ali ročno brez stranskih
+učinkov.
+
+→ [contracts](./contracts.md) (shema) | [mother-hive](./mother-hive.md)
diff --git a/docs/wiki/sl/skills-catalog.md b/docs/wiki/sl/skills-catalog.md
new file mode 100644
index 0000000..151c5b3
--- /dev/null
+++ b/docs/wiki/sl/skills-catalog.md
@@ -0,0 +1,45 @@
+---
+title: Katalog veščin
+description: Bralni izvajalni porabnik za pregledane artefakte veščin Clawdie-AI — uvozi SKILL.md v Colibrijevo tabelo skills.
+---
+
+← [kazalo](./index.md)
+
+Katalog veščin je Colibrijev bralni izvajalni porabnik za veščine,
+pregledane v repozitoriju `clawdie-ai`. Veščine se uvozijo v tabelo
+`skills` v shrambi SQLite, kjer jih agenti poizvedujejo med izvajanjem.
+
+→ `scripts/import-clawdie-skills.sh`
+
+## Odločitve
+
+### Bralni porabnik, ne vir resnice
+
+Colibri bere veščine iz repozitorija `clawdie-ai` — ta je vir. Uvozna
+skripta je idempotentna (`INSERT OR IGNORE`), zato je varno večkrat
+zagnati. Spremembe veščin se zgodijo v izvornem repozitoriju, nato se
+ponovno uvozijo.
+
+→ [layered-soul](./layered-soul.md) (enaka smer)
+
+### Ena tabela, ploščata
+
+Vse veščine si delijo eno tabelo `skills` s stolpci `name`, `description`,
+`category` in `created_at`. Kategorija je prosta besedilna oznaka
+(`"soul"`, `"agent"`, `"channel"`). Brez gnezdenja, brez različic, brez
+odvisnosti med veščinami. To ustreza trenutnemu obsegu (~50 veščin).
+
+→ `crates/colibri-store/src/schema.rs`
+
+### Uvoz ob zagonu, ne sproti
+
+Veščine se uvozijo ob zagonu demona, ne med izvajanjem. Če operater doda
+veščino, ponovno zažene demona (ali ročno zažene uvozno skripto). Nobena pot
+izvajalne kode ne piše v tabelo `skills`.
+
+→ `scripts/import-clawdie-skills.sh`
+
+## Glej tudi
+
+- [layered-soul](./layered-soul.md) — veščine iz layered-soul
+- [store-schema](./store-schema.md) — shema tabele skills
diff --git a/docs/wiki/sl/vault-provision.md b/docs/wiki/sl/vault-provision.md
new file mode 100644
index 0000000..51429b7
--- /dev/null
+++ b/docs/wiki/sl/vault-provision.md
@@ -0,0 +1,49 @@
+---
+title: Oskrba trezorja
+description: Oskrba datotek env, gnana z Vaultwarden, v ječe po zagonu agenta — preslikava najemnik → ječa → zbirka.
+---
+
+← [kazalo](./index.md)
+
+`colibri-vault` bere skrivnosti iz Vaultwarden in jih zapiše kot datoteke
+okolja (`.env`) znotraj ječ, preden se agent zažene. Vsak najemnik dobi
+svojo datoteko `.env` s točno tistimi poverilnicami, ki jih potrebuje —
+nič več.
+
+→ `crates/colibri-vault/src/lib.rs`
+
+## Odločitve
+
+### 1:1:1 preslikava — najemnik = ječa = zbirka
+
+Preslikava `tenant_id → ime ječe → zbirka Vaultwarden` je nespremenljiva.
+Ukaz `colibri vault-provision ` poišče vrstico najemnika v SQLite,
+pridobi vse skrivnosti iz imenovane zbirke Vaultwarden in jih zapiše v
+`/tmp/.env` znotraj ječe.
+
+### CLI Vaultwarden (`bw`), ne API
+
+Oskrbnik uporablja `bw` (uradni CLI Vaultwarden/Bitwarden). seja `bw` se
+odpre s ključem API, ki je shranjen kot skrivnost v `bw_session`, in zapre
+po vsakem zagonu oskrbe. Brez trajnih sej, brez predpomnjenih žetonov.
+
+→ `crates/colibri-vault/src/vaultwarden.rs`
+
+### Skrivnosti so začasne znotraj ječe
+
+Datoteka `.env` je zapisana v `/tmp/.env` — obstojna samo toliko časa,
+kolikor živi ječa. Če je ječa uničena in ponovno ustvarjena, oskrbnik
+znova zažene. Skrivnosti niso nikoli shranjene na gostitelju.
+
+### Oskrba ob zagonu agenta, ne periodično
+
+Oskrba teče enkrat, tik preden se agent zažene znotraj ječe. Brez cron
+osveževanj, brez rotacije skrivnosti med izvajanjem. Če se skrivnost spremeni,
+operater ustavi in znova zažene agenta.
+
+→ `crates/colibri-daemon/src/socket.rs` (`cmd_spawn_agent`)
+
+## Glej tudi
+
+- [store-schema](./store-schema.md) — preslikava `tenants`
+- [jail-confinement](./jail-confinement.md) — kako so ječe ustvarjene
--
2.45.3
From c38b5f1e5bcd0ebc61c45b13c0253d4feaae751a Mon Sep 17 00:00:00 2001
From: Sam & Claude
Date: Fri, 26 Jun 2026 11:13:12 +0200
Subject: [PATCH 06/19] docs(guide): trim install Scope paragraph to one-liner
Match the lean SL style: one terse sentence, no legacy narration.
"clawdie provisions the host service. Jail, DB, CMS, and Git
provisioning are not yet in the installer."
---
docs/guide/install/install.md | 247 +++++++---------------------------
1 file changed, 50 insertions(+), 197 deletions(-)
diff --git a/docs/guide/install/install.md b/docs/guide/install/install.md
index b528646..44aee3e 100644
--- a/docs/guide/install/install.md
+++ b/docs/guide/install/install.md
@@ -1,232 +1,85 @@
---
-title: Install Orchestrator
-description: Single-command install flow for Clawdie.
+title: Install
+description: Provision the Clawdie host service with the clawdie binary.
---
-**Command:** `just install`
+**Command:** `clawdie apply --yes`
-## Quick start
+Colibri-based Clawdie is installed by the **`clawdie`** binary (crate
+`crates/clawdie`). It discovers the host's ZFS layout and provisions the
+`clawdie` service: storage datasets, a service user, and the rc.d (FreeBSD) or
+systemd (Linux) unit that runs `colibri-daemon`.
-Clawdie tracks the FreeBSD 15.x line. The installer rejects FreeBSD 14.x and any unvalidated future major version.
+This repo is a **Cargo workspace** — there is no `just`/npm install flow here.
+Build the installer from the workspace:
```bash
-just install
+cargo build -p clawdie --release
```
-## ISO path
-
-Current ISO validation is converging on: live QML installer → first installed
-boot → loopback-bound controlplane `/setup` with a one-time bootstrap token.
-Provider keys and Telegram are configured there after install. The older
-[`setup.txt` first-boot contract](./first-boot/) remains documented for legacy
-non-interactive work and will be rewritten after live ISO validation.
-
-After the initial boot, the same `install` flow described here runs in the
-background.
-
-Resume from a specific step after a failure:
+## Commands
```bash
-just install-from-db
+clawdie discover # read-only: OS, ZFS pools, datasets, spare disks
+clawdie plan [--pool NAME] # show the deploy plan (dry-run, no writes)
+clawdie apply [--pool NAME] # dry-run unless --yes is given
+clawdie apply --yes # provision: storage layout + install the service
```
-Skip service jails (db, git, cms) when running `DB_RUNTIME=host` or installing a second agent on an existing host:
+`apply` is a **dry-run by default** and prints the full step plan; it writes to
+disk only with `--yes`. When the host has exactly one ZFS pool it is selected
+automatically; with several, pass `--pool NAME`.
-```bash
-just install-from hosts
-```
+## Storage strategy
-Print the step plan without running anything:
+| Host | Behavior |
+| ------------------ | ---------------------------------------------------------------------------------------------------------- |
+| FreeBSD | ZFS is **required**; datasets are created under the chosen pool. |
+| Linux + ZFS + pool | Same — datasets under the pool. |
+| Linux, no ZFS/pool | Falls back to plain directories, and reports the ZFS benefits plus any spare disks that could host a pool. |
-```bash
-just install -- --dry-run
-```
-
-Skip ZFS checkpoints (e.g. no ZFS pool):
-
-```bash
-just install -- --no-snapshots
-```
-
----
-
-## Step flow
-
-```
-just install
- │
- ▼
- ┌─────────────────────────────────────────────────────────┐
- │ Detect ZFS dataset (zroot/bastille) │
- │ Parse --from / --no-snapshots / --dry-run │
- └─────────────────────────────────────────────────────────┘
- │
- ▼
-[ 1] onboarding first-boot setup seed or TUI fallback REQUIRED
- │
-[ 2] environment host pkg baseline, bridge, locale REQUIRED
- │
-[ 3] agent-config validate/write agent provider optional ─── warn on missing provider auth
- │ └── pi missing → warn, continue
-[ 4] pf write PF include (NAT egress) REQUIRED
- │ └── 📸 snapshot: post-pf
-[ 5] jails create worker jail (--create) REQUIRED
- │ └── 📸 snapshot: post-jails
-[ 6] git Git Service (git jail) DEFAULT ─── DB_RUNTIME=host → skip or use install-from hosts
- │ └── 📸 snapshot: post-git
-[ 7] forgejo Forgejo for Git Service DEFAULT ─── FEATURE_GITEA=NO → skip
- │
-[ 8] db Data Service (PostgreSQL) DEFAULT ─── DB_RUNTIME=host → skip; DB on host instead
- │ └── 📸 snapshot: post-db
-[ 9] skills-memory built-in knowledge import DEFAULT ─── artifact.sql ships in tarball
- │
-[10] skills-init Skills engine init (.nanoclaw) DEFAULT
- │
-[11] cms Web Service (cms jail: Astro+nginx) DEFAULT ─── use install-from hosts to skip
- │ └── 📸 snapshot: post-cms
- │
-[12] ollama Local AI Models (Ollama jail .5) optional ─── FEATURE_OLLAMA≠YES → skip
- │
-[13] llama-cpp Local AI Models (llama-cpp jail .5) optional ─── FEATURE_LLAMA_CPP≠YES → skip
- │
-[14] hosts /etc/hosts + jail hostnames REQUIRED
- │
-[15] mounts validate jail mounts REQUIRED
- │
-[16] telegram-auth verify bot token optional ─── TELEGRAM_BOT_TOKEN unset → skip
- │
-[17] service build + install rc.d service REQUIRED
- │
-[18] hostd privileged host daemon (hostd) REQUIRED
- │
-[19] identity-restore Supabase restore optional ─── SUPABASE_URL unset → skip
- │
-[20] verify integrity check optional
- │ └── 📸 snapshot: install-complete
- │
- ▼
- ┌─────────────────────────────────────────────────────────┐
- │ Summary: N ok N warnings N skipped N failed │
- │ Snapshots taken: zroot/bastille@post-pf-… … │
- │ LLM providers: anthropic ✓ openai ✗ ollama ✗ … │
- └─────────────────────────────────────────────────────────┘
-```
-
-The onboarding step prefers the first-boot setup file (`setup.txt`; see
-[First boot](./first-boot/)). The interactive TUI wizard is the fallback when
-the first-boot setup is absent or invalid; it sources locales from FreeBSD
-itself, so any installed locale can be selected (`en_US.UTF-8`,
-`zh_CN.UTF-8`, etc.) and is applied consistently.
-`setup.txt` is the operator-intent contract, and `system.env` is the matching
-hardware-intent contract. Inspect can populate both before the installer runs.
-Set `DB_RUNTIME=host` in `.env` to provision PostgreSQL directly on the host instead of a db jail; `DB_HOST` defaults to `${AGENT_SUBNET_BASE}.1` so jails can reach it. Use `DB_COMPRESSION=lz4` (default) or `DB_COMPRESSION=zstd` for ZFS compression on host datasets.
-
-The root install owns shared platform services. It is not modeled as tenant zero.
-`ASSISTANT_NAME` is display-only. Later additive tenants consume shared services such as:
-
-- Git Service
-- Web Service
-- Local AI Models
-
----
-
-## ZFS snapshots
-
-Snapshots are taken after each milestone step if a Bastille ZFS dataset is
-detected. Two paths:
-
-| Context | Method |
-| ------------------------------ | ------------------------------------------------------------- |
-| Running as root | `zfs snapshot zroot/bastille@` directly |
-| Non-root, `sudo` available | `sudo zfs snapshot zroot/bastille@` |
-| Non-root, hostd socket present | `nc -U /var/run/-hostd.sock` (hostd `zfs-snapshot` op) |
-| Neither | skip silently |
-
-Snapshot tags are suffixed with a Unix timestamp to prevent collisions on re-runs.
-
----
-
-## LLM providers
-
-The orchestrator never exits on missing LLM provider auth. OpenAI/OpenRouter/Anthropic
-subscription auth is the recommended path for performance/price, but it is not a
-hard installer requirement and all supported providers are peer options. At the
-end of the run Clawdie prints a table of known providers:
-
-```
-△ LLM providers
- anthropic ANTHROPIC_API_KEY ✓ configured
- openai OPENAI_API_KEY ✗ not set
- openrouter OPENROUTER_API_KEY ✗ not set
- deepseek DEEPSEEK_API_KEY ✗ not set
- zai ZAI_API_KEY ✗ not set
- groq GROQ_API_KEY ✗ not set
- azure AZURE_OPENAI_API_KEY ✗ not set
- ollama OLLAMA_HOST ✗ not set
-```
-
-If no provider auth is found, it prints instructions:
-
-```
- No LLM provider auth found. Configure one after install and restart:
- Set DEEPSEEK_API_KEY=... in provider.env for the default agent,
- or add provider keys such as ANTHROPIC_API_KEY=sk-ant-...
- service clawdie restart
-```
-
-The entire infrastructure (PF, jails, PostgreSQL, nginx, ZFS) has zero
-LLM dependency. Provider auth is only consumed when the jail-runner spawns a
-live response. Install and service start succeed without it.
-
-After install, use the controlplane setup page to finish provider and optional
-Telegram configuration:
+ZFS layout under the pool:
```text
-https:///setup
+/clawdie (container, canmount=off)
+/clawdie/db -> /var/db/clawdie
+/clawdie/log -> /var/log/clawdie
```
-Before setup is complete, do not expose this URL directly to the public internet.
-Use local console/loopback access, or a tailnet/LAN path protected by TLS and
-network allowlisting. The bootstrap token is a first-setup key, not a substitute
-for bind/PF/TLS policy.
+## What `apply --yes` provisions
-If the printed bootstrap token is lost before setup completes, rotate a fresh
-one on the host:
+1. **Storage** — the datasets above (or plain `/var/db/clawdie` +
+ `/var/log/clawdie` on the plain-dirs fallback).
+2. **Service user** — `clawdie` (nologin), owning the state directories
+ (`clawdie:clawdie`).
+3. **Service** — the rc.d script (FreeBSD, via `daemon -u clawdie`) or systemd
+ unit (Linux, `User=clawdie`), installed and enabled to run
+ `/usr/local/bin/colibri-daemon`.
-```sh
-npm run setup-token -- rotate
-```
+## Creating a pool (destructive)
-### Agent runtime setup
-
-The agent runtime uses `DEEPSEEK_API_KEY` from `provider.env`.
-Colibri autospawns zot in RPC mode — no separate login or auth store
-needed. zot reads its identity from `$ZOT_HOME/AGENTS.md` (installed
-automatically from the seed partition).
-
-Quick verification:
+On a host with a spare disk and no usable pool:
```bash
-zot --no-session --print "Reply with exactly: zot-ok"
+clawdie apply --pool NAME --create-pool /dev/DEV --yes
```
-Expected output:
+`--create-pool` runs `zpool create` on `DEV` and **destroys all data on it**, so
+it is refused unless the disk is detected as empty (no partitions/filesystem/
+mount, not the root disk). Override that guard with `--force` only if you are
+certain.
-```text
-zot-ok
-```
+## Safety
-### Control plane API auth
+Disk-touching steps (`zfs`/`zpool create`, service install) run as root on the
+target host. `discover`, `plan`, and a bare `apply` never write — preview with
+`plan` first.
-Agent subprocesses (pi, aider) authenticate back to the control plane
-API using a shared secret. Generate one after install:
+## Scope
-```bash
-echo "CONTROLPLANE_SHARED_SECRET=$(openssl rand -base64 32)" >> .env
-sudo service ${AGENT_NAME} restart
-```
+`clawdie` provisions the host service. Jail, DB, CMS, and Git provisioning are not yet in the installer.
-Without it, the startup log warns and agent-to-API calls are rejected.
+## FreeBSD validation
### Service wrapper scripts
--
2.45.3
From 3145a725c619f9b15fc713da14f5d9fe7ac35efc Mon Sep 17 00:00:00 2001
From: Sam & Claude
Date: Fri, 26 Jun 2026 11:19:00 +0200
Subject: [PATCH 07/19] docs(sl): add 23 Slovenian terminology mappings to
glossary
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New Izrazoslovje section — English concept → Slovenian name mapping:
jail/ječa, warden/paznik, confinement/zaprtost, prisoner/zapornik,
cell/celica, hive/panj, mother/matično vozlišče, node/vozlišče,
glasspane/steklena plošča, spawn/(samodejni) zagon, seed/seme,
vault/trezor, tenant/najemnik, skill/veščina, snapshot/posnetek,
rollback/povratek, harvest/žetev, bridge/most, daemon/ozadnji proces,
board/tabla nalog, scheduler/razporejevalnik, Bastille (trdnjava),
jailed/zaprt v ječi.
Cross-links added from existing ZFS, PF, and UI entries.
Total anchors: 38 (15 abbreviations + 23 terminology).
---
docs/guide/sl/reference/okrajsave.md | 148 ++++++++++++++++++++++++++-
1 file changed, 145 insertions(+), 3 deletions(-)
diff --git a/docs/guide/sl/reference/okrajsave.md b/docs/guide/sl/reference/okrajsave.md
index b0424e2..6afe7aa 100644
--- a/docs/guide/sl/reference/okrajsave.md
+++ b/docs/guide/sl/reference/okrajsave.md
@@ -50,7 +50,7 @@ LLM za vse agentske odločitve.
## NAT
**Network Address Translation** — prevajanje omrežnih naslovov; omogoča, da več
-naprav deli en javni IP. PF na FreeBSD samodejno nastavi NAT za ječe.
+naprav deli en javni IP. PF na FreeBSD samodejno nastavi NAT za [ječe](#jail).
## PF
@@ -72,7 +72,8 @@ vratih TCP.
## UI
**User Interface** — uporabniški vmesnik, kar operater vidi na zaslonu.
-Glasspane TUI je terminalski vmesnik za nadzor agentov.
+Glasspane TUI je terminalski vmesnik za nadzor agentov. Glej tudi:
+[glasspane](#glasspane).
## VPS
@@ -84,4 +85,145 @@ VPS.
**Zettabyte File System** — napredni datotečni sistem, ki ga uporablja
FreeBSD. Omogoča posnetke (snapshots), stiskanje in preverjanje celovitosti
-podatkov. Clawdie zahteva ZFS za ječe Bastille.
+podatkov. Clawdie zahteva ZFS za [ječe](#jail) Bastille. Glej tudi:
+
+## Izrazoslovje — angleško-slovenski preslikovalnik
+
+Tehnično izrazoslovje v kodi ostaja v angleščini (ukazi, poti, imena
+spremenljivk). Slovenski prevodi so mišljeni za **branje in razumevanje**
+dokumentacije, ne za pisanje kode.
+
+### Bastille
+
+Orodje za upravljanje ječ na FreeBSD. Ime izhaja iz trdnjave Bastilja.
+
+### board (task board)
+
+**Tabla nalog** — seznam nalog, ki čakajo na dodelitev agentom. Vsaka naloga
+ima zahtevane zmožnosti (capabilities) in jih razporejevalnik (scheduler)
+dodeli najprimernejšemu agentu.
+
+### bridge
+
+**Most** — omrežni most, ki povezuje ječe z zunanjim svetom. `warden0` je
+most, ki ga Bastille uporablja za omrežje ječ.
+
+### cell
+
+**Celica** — posamezna enota znotraj ječe. Tanke (thin) celice si delijo
+predlogo, debele (thick) so samostojne.
+
+### confinement
+
+**Zaprtost** — stopnja izolacije procesa. Ječa (jail) je najmočnejša oblika
+zaprtosti na FreeBSD: proces ne vidi gostitelja, nima dostopa do omrežja
+(glede na nastavitve) in ne more pobegniti.
+
+### daemon
+
+**Ozadnji proces** — program, ki teče v ozadju, brez neposrednega upravljanja.
+Na FreeBSD jih upravlja `rc.d`. `colibri-daemon` je ozadnji proces, ki
+nadzoruje agente.
+
+### glasspane
+
+**Steklena plošča / nadzorna plošča** — Colibrijeva opazovalna plast.
+Spremlja agente prek JSONL in jih preslika v pet stanj: Idle → Working →
+Blocked → Done / Error.
+
+### harvest
+
+**Žetev** — zajem podatkov o strojni opremi (`clawdie-hw-probe`).
+Pobrano (harvested) se pošlje matičnemu vozlišču (mother) prek MCP.
+
+### hive
+
+**Panj** — vsa vozlišča, povezana z matičnim vozliščem. `hive_nodes` je
+tabela v PostgreSQL na matičnem vozlišču, ki beleži vsako vozlišče, njegovo
+strojno opremo in zmožnosti.
+
+### jail
+
+**Ječa** — varnostni mehanizem FreeBSD za izolacijo procesov. Proces v ječi
+vidi le svoj datotečni sistem in omejeno omrežje. Clawdie uporablja ječe
+Bastille za izolacijo agentov in zunanjih MCP strežnikov. Glej tudi:
+[confinement](#confinement), [warden](#warden), [prisoner](#prisoner).
+
+### jailed
+
+**Zaprt v ječi** — stanje procesa, ki teče znotraj ječe. Proces, ki je
+*zaprt v ječi*, nima dostopa do gostitelja. Nasprotje je *na prostosti*
+(teče na gostitelju brez izolacije).
+
+### mother (mother node)
+
+**Matično vozlišče** — osrednji strežnik, ki vodi register vseh vozlišč
+(`hive_nodes`). USB-vozlišča se mu javijo prek MCP prek SSH. Matično vozlišče
+hrani PostgreSQL in dodeljuje naloge.
+
+### node
+
+**Vozlišče** — katerikoli gostitelj, ki je del panja: USB-ključek, nameščen
+disk, VPS ali matično vozlišče samo. Vsako vozlišče ima `node_type` in
+se registrira prek `node_register`.
+
+### prisoner (prisoner process)
+
+**Zapornik / zaprt proces** — proces, ki teče v ječi. Nima dostopa do
+gostiteljevega datotečnega sistema in omrežja (razen če mu je izrecno
+dovoljeno).
+
+### rollback
+
+**Povratek** — vrnitev na prejšnje stanje. ZFS snapshots omogočajo povratek
+celotnega datotečnega sistema. `bectl activate` omogoča povratek na prejšnji
+zagonski posnetek.
+
+### scheduler
+
+**Razporejevalnik** — del krmilne ravnine, ki vsakih ~30s preveri, katere
+naloge so na vrsti, in jih dodeli najprimernejšemu prostemu agentu.
+
+### seed (CLAWDIESEED)
+
+**Seme / semenski del** — tretja rezina (slice) na USB-ključku, formatirana
+kot FAT32. Vsebuje skrivnosti (ključe, API žetone, gesla), ki jih uvoznik
+(`clawdie-live-seed`) ob zagonu namesti. Seme je **ločeno od slike ISO** —
+operater ga ureja po zapisu slike, pred zagonom.
+
+### skill
+
+**Veščina** — paket zmožnosti, ki ga agent lahko naloži, da opravi določeno
+nalogo. Primer: `jail-status` (preveri stanje ječ), `db-vacuum` (počisti
+bazo). Veščine so v katalogu in jih uvaža `import-colibri-skills.sh`.
+
+### snapshot
+
+**Posnetek** — trenutno stanje datotečnega sistema, shranjeno kot ZFS
+snapshot. Posnetki so osnova za povratek (rollback) in jih samodejno
+upravlja `sanoid`.
+
+### spawn (autospawn)
+
+**(Samodejni) zagon agenta** — krmilna ravnina ob zagonu požene agenta
+(`zot`), če je omogočeno `COLIBRI_AUTOSPAWN=YES`. Agent dobi RPC poziv
+in začne z delom.
+
+### tenant
+
+**Najemnik** — večnajemniška enota. Vsak najemnik ima svoje okolje,
+skrivnosti in naloge. Krmilna ravnina podpira več najemnikov na enem
+vozlišču.
+
+### vault
+
+**Trezor / sef** — varno hranjenje skrivnosti (gesla, API ključi, žetoni).
+Clawdie uporablja Vaultwarden (Bitwarden API) za upravljanje skrivnosti
+najemnikov.
+
+### warden
+
+**Paznik** — upravljalec ječ. Na FreeBSD je to `jail(8)` ali `warden0`
+(omrežni most ječ Bastille). Na matičnem vozlišču je to `colibri-mcp-ssh`,
+ki nadzoruje, kateri ukazi so dovoljeni čez SSH. *Paznik* je slovenski izraz
+za paznika v zaporu — čuva ječo in njene zapornike.
--
2.45.3
From f704abc78268d1f7c7ee72ca4f9a05f0d9c1687a Mon Sep 17 00:00:00 2001
From: Sam & Claude
Date: Fri, 26 Jun 2026 11:31:23 +0200
Subject: [PATCH 08/19] feat(wiki): minimal Astro site for wiki.clawdie.si
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Plain Astro — no Starlight. Five files:
- package.json (astro only, v0.12.0)
- astro.config.mjs (wiki.clawdie.si, static output)
- src/pages/index.astro (flat list of all decision pages)
- src/pages/[...slug].astro (dynamic route: reads docs/wiki/*.md)
- build-wiki.sh (npm ci + astro build)
Design decisions:
- No Starlight — 23 pages, rarely changing, primary audience is LLMs
- Dynamic routes — add a .md to docs/wiki/, it automatically gets a page
- Markdown rendered at build time (frontmatter, tables, code blocks, links)
- Wiki links [label](./page.md) auto-resolve to [/page/]
- Dark mode via prefers-color-scheme, no JS
Guide keeps Starlight (structured sidebar, human operators).
Wiki gets plain Astro (flat list, agent audience).
See: docs/PLAN-WIKI-CLAWDIE-SI.md
---
astro/wiki/.gitignore | 3 +
astro/wiki/astro.config.mjs | 12 +++
astro/wiki/package.json | 14 +++
astro/wiki/src/pages/[...slug].astro | 131 +++++++++++++++++++++++++++
astro/wiki/src/pages/index.astro | 60 ++++++++++++
docs/PLAN-WIKI-CLAWDIE-SI.md | 49 ++++++++++
scripts/build-wiki.sh | 29 ++++++
7 files changed, 298 insertions(+)
create mode 100644 astro/wiki/.gitignore
create mode 100644 astro/wiki/astro.config.mjs
create mode 100644 astro/wiki/package.json
create mode 100644 astro/wiki/src/pages/[...slug].astro
create mode 100644 astro/wiki/src/pages/index.astro
create mode 100644 docs/PLAN-WIKI-CLAWDIE-SI.md
create mode 100755 scripts/build-wiki.sh
diff --git a/astro/wiki/.gitignore b/astro/wiki/.gitignore
new file mode 100644
index 0000000..ddce69b
--- /dev/null
+++ b/astro/wiki/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+dist/
+.astro/
diff --git a/astro/wiki/astro.config.mjs b/astro/wiki/astro.config.mjs
new file mode 100644
index 0000000..3e3a004
--- /dev/null
+++ b/astro/wiki/astro.config.mjs
@@ -0,0 +1,12 @@
+import { defineConfig } from "astro/config";
+
+const site = process.env.ASTRO_SITE_URL || "https://wiki.clawdie.si";
+const outDir = process.env.ASTRO_OUT_DIR || "./dist";
+
+// https://astro.build/config
+export default defineConfig({
+ site,
+ outDir,
+ output: "static",
+ trailingSlash: "always",
+});
diff --git a/astro/wiki/package.json b/astro/wiki/package.json
new file mode 100644
index 0000000..8c29066
--- /dev/null
+++ b/astro/wiki/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "clawdie-wiki",
+ "private": true,
+ "version": "0.12.0",
+ "type": "module",
+ "scripts": {
+ "dev": "astro dev --host 0.0.0.0",
+ "build": "astro build",
+ "preview": "astro preview --host 0.0.0.0"
+ },
+ "dependencies": {
+ "astro": "^5.16.11"
+ }
+}
diff --git a/astro/wiki/src/pages/[...slug].astro b/astro/wiki/src/pages/[...slug].astro
new file mode 100644
index 0000000..d27194c
--- /dev/null
+++ b/astro/wiki/src/pages/[...slug].astro
@@ -0,0 +1,131 @@
+---
+import fs from "node:fs";
+import path from "node:path";
+
+const WIKI_DIR = path.resolve("../../docs/wiki");
+const EXCLUDE = [".git", "sl", "index.md"];
+
+export function getStaticPaths() {
+ function walk(dir, prefix = "") {
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
+ const slugs = [];
+ for (const e of entries) {
+ if (e.name.startsWith(".") || EXCLUDE.includes(e.name)) continue;
+ const full = path.join(dir, e.name);
+ if (e.isDirectory()) {
+ slugs.push(...walk(full, prefix ? `${prefix}/${e.name}` : e.name));
+ } else if (e.name.endsWith(".md")) {
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
+ slugs.push({ params: { slug: rel.replace(/\.md$/, "") } });
+ }
+ }
+ return slugs;
+ }
+ return walk(WIKI_DIR);
+}
+
+const { slug } = Astro.params;
+const filePath = path.join(WIKI_DIR, `${slug}.md`);
+
+if (!fs.existsSync(filePath)) {
+ return new Response("Not found", { status: 404 });
+}
+
+const raw = fs.readFileSync(filePath, "utf-8");
+
+// Parse frontmatter
+let content = raw;
+let frontmatter = {};
+if (raw.startsWith("---")) {
+ const end = raw.indexOf("---", 3);
+ if (end !== -1) {
+ const fm = raw.slice(3, end);
+ for (const line of fm.split("\n")) {
+ const m = line.match(/^(\w+):\s*(.+)$/);
+ if (m) frontmatter[m[1]] = m[2].replace(/^["']|["']$/g, "");
+ }
+ content = raw.slice(end + 3).trim();
+ }
+}
+
+// Resolve relative wiki links [label](./page.md) → [label](/page/)
+const resolveLinks = (md) =>
+ md.replace(/\]\(\.\/([^)]+)\.md\)/g, "](/$1/)")
+ .replace(/\]\(\.\.\/([^)]+)\.md\)/g, "](/$1/)");
+
+content = resolveLinks(content);
+
+// Render fenced code blocks
+const renderCode = (md) =>
+ md.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
+ return `${code.trim()} `;
+ });
+
+content = renderCode(content);
+
+// Render tables
+const renderTables = (md) => {
+ return md.replace(/\|(.+)\|\n\|[-| ]+\|\n((?:\|.+\|\n?)*)/gm, (_, header, rows) => {
+ const hcells = header.split("|").map(c => c.trim()).filter(Boolean);
+ const thead = `${hcells.map(c => `${c} `).join("")} `;
+ const tbody = rows.trim().split("\n").map(row => {
+ const cells = row.split("|").map(c => c.trim()).filter(Boolean);
+ return `${cells.map(c => `${c} `).join("")} `;
+ }).join("");
+ return ``;
+ });
+};
+content = renderTables(content);
+
+// Render inline code, bold, italic, links, headings, lists
+content = content
+ .replace(/`([^`]+)`/g, "$1")
+ .replace(/\*\*([^*]+)\*\*/g, "$1 ")
+ .replace(/\*([^*]+)\*/g, "$1 ")
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ')
+ .replace(/^### (.+)$/gm, "$1 ")
+ .replace(/^## (.+)$/gm, "$1 ")
+ .replace(/^# (.+)$/gm, "$1 ")
+ .replace(/^- (.+)$/gm, "$1 ")
+ .replace(/((?:.*<\/li>\n?)+)/g, "")
+ .replace(/\n\n/g, "
")
+ .replace(/^(.+)$/gm, (line) => {
+ if (line.startsWith("<")) return line;
+ return line;
+ });
+
+const title = frontmatter.title || slug;
+---
+
+
+
+
+
+ {(title)} — Colibri Wiki
+
+
+
+ ← wiki index
+
+ {title}
+
+
+
+
diff --git a/astro/wiki/src/pages/index.astro b/astro/wiki/src/pages/index.astro
new file mode 100644
index 0000000..5e628c2
--- /dev/null
+++ b/astro/wiki/src/pages/index.astro
@@ -0,0 +1,60 @@
+---
+import fs from "node:fs";
+import path from "node:path";
+
+const WIKI_DIR = path.resolve("../../docs/wiki");
+const EXCLUDE = [".git", "sl", "index.md"];
+
+function walkMarkdown(dir, prefix = "") {
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
+ const files = [];
+ for (const e of entries) {
+ if (e.name.startsWith(".") || EXCLUDE.includes(e.name)) continue;
+ const full = path.join(dir, e.name);
+ if (e.isDirectory()) {
+ files.push(...walkMarkdown(full, prefix ? `${prefix}/${e.name}` : e.name));
+ } else if (e.name.endsWith(".md")) {
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
+ const slug = rel.replace(/\.md$/, "");
+ // Skip frontmatter, grab first H1 as title
+ const raw = fs.readFileSync(full, "utf-8");
+ const title = raw.match(/^#\s+(.+)$/m)?.[1] || slug;
+ files.push({ slug, title, file: rel });
+ }
+ }
+ return files.sort((a, b) => a.title.localeCompare(b.title));
+}
+
+const pages = walkMarkdown(WIKI_DIR);
+---
+
+
+
+
+
+ Colibri Wiki
+
+
+
+ Colibri Wiki
+
+ Decision pages — the why behind the architecture.
+ LLM Wiki pattern .
+
+
+
+
diff --git a/docs/PLAN-WIKI-CLAWDIE-SI.md b/docs/PLAN-WIKI-CLAWDIE-SI.md
new file mode 100644
index 0000000..69561d2
--- /dev/null
+++ b/docs/PLAN-WIKI-CLAWDIE-SI.md
@@ -0,0 +1,49 @@
+# wiki.clawdie.si — separate domain for decision pages
+
+**Status:** planned · **Created:** 26.jun.2026 · **Blocks:** nothing in 0.12
+
+## Goal
+
+Split the current docs.clawdie.si (single site) into two domains during the
+Astro migration from clawdie-ai → colibri:
+
+```
+docs.clawdie.si → guide (procedural: install, operate, reference)
+wiki.clawdie.si → wiki (decisions: architecture rationale, LLM-wiki)
+clawdie.si → landing (unchanged)
+```
+
+## Why
+
+- Wiki stays pure Karpathy LLM-wiki pattern — one decision per page, flat list
+- Guide stays procedural — structured sidebar with install/operate/architecture
+- Different audiences: wiki for agents/architects, guide for operators
+- ISO can toggle each surface independently (FEATURE_DOCS, FEATURE_WIKI)
+
+## What needs building
+
+| Layer | Task |
+|---|---|
+| DNS | `wiki.clawdie.si` A/AAAA → same host |
+| TLS | New Let's Encrypt cert (acme.sh auto-renew) |
+| Nginx | New vhost for wiki.clawdie.si |
+| Astro | Two Starlight configs from one colibri source tree |
+| Build | `build-docs.sh` → dist-guide/ + dist-wiki/ |
+| ISO | `FEATURE_DOCS` / `FEATURE_WIKI` toggle knobs |
+
+## Two Starlight configs
+
+```
+colibri/astro/
+ guide.config.mjs → full sidebar: Install, Operate, Architecture...
+ wiki.config.mjs → minimal sidebar: autogenerate flat article list
+```
+
+Same toolchain, two configs, two output dirs. Wiki uses autogenerate — no
+manual sidebar to maintain as pages are added.
+
+## Prerequisite
+
+The Astro build pipeline must be migrated from clawdie-ai to colibri first.
+The content already lives in colibri (docs/guide/ + docs/wiki/). The build
+scripts and Astro config don't yet.
diff --git a/scripts/build-wiki.sh b/scripts/build-wiki.sh
new file mode 100755
index 0000000..e91d189
--- /dev/null
+++ b/scripts/build-wiki.sh
@@ -0,0 +1,29 @@
+#!/bin/sh
+# Build the Colibri wiki site — plain Astro, no Starlight.
+#
+# Prerequisites: Node.js + npm (node24 npm-node24 on FreeBSD).
+# cd astro/wiki && npm ci
+#
+# Usage:
+# ./scripts/build-wiki.sh # build to astro/wiki/dist/
+# ./scripts/build-wiki.sh --preview # dev server at localhost:4321
+#
+# Site URL override:
+# ASTRO_SITE_URL=https://wiki.clawdie.si ./scripts/build-wiki.sh
+
+set -eu
+
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
+WIKI_DIR="$REPO_ROOT/astro/wiki"
+
+cd "$WIKI_DIR"
+
+if [ "${1:-}" = "--preview" ]; then
+ echo "==> wiki dev server (http://localhost:4321)"
+ npx astro dev --host 0.0.0.0
+else
+ echo "==> building wiki ($WIKI_DIR)"
+ npx astro build
+ echo "==> wiki built → $WIKI_DIR/dist/"
+fi
--
2.45.3
From cbf707be8f584557325961fbfe27d730f4778b23 Mon Sep 17 00:00:00 2001
From: Sam & Claude
Date: Fri, 26 Jun 2026 11:51:02 +0200
Subject: [PATCH 09/19] feat(wiki): add Slovenian locale support with unified
path pattern
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Remove 'sl' from EXCLUDE — Slovenian wiki pages now served
- Locale-aware link resolver: ./page.md → /sl/page/ in sl/ context
- Language switcher in nav bar (English ↔ Slovenščina)
- Slovenian index at /sl/ with translated prose
- lang attribute dynamic per locale
- Matches docs.clawdie.si Starlight pattern: ///
---
astro/wiki/src/pages/[...slug].astro | 33 +++++++++++---
astro/wiki/src/pages/index.astro | 17 +++++---
astro/wiki/src/pages/sl/index.astro | 64 ++++++++++++++++++++++++++++
3 files changed, 101 insertions(+), 13 deletions(-)
create mode 100644 astro/wiki/src/pages/sl/index.astro
diff --git a/astro/wiki/src/pages/[...slug].astro b/astro/wiki/src/pages/[...slug].astro
index d27194c..23bc196 100644
--- a/astro/wiki/src/pages/[...slug].astro
+++ b/astro/wiki/src/pages/[...slug].astro
@@ -3,7 +3,7 @@ import fs from "node:fs";
import path from "node:path";
const WIKI_DIR = path.resolve("../../docs/wiki");
-const EXCLUDE = [".git", "sl", "index.md"];
+const EXCLUDE = [".git", "index.md"];
export function getStaticPaths() {
function walk(dir, prefix = "") {
@@ -48,13 +48,23 @@ if (raw.startsWith("---")) {
}
}
-// Resolve relative wiki links [label](./page.md) → [label](/page/)
+// Detect locale from slug prefix
+const isSl = slug.startsWith("sl/");
+const locale = isSl ? "sl" : "en";
+const base = isSl ? "/sl/" : "/";
+
+// Resolve relative wiki links with locale prefix
+// ./page.md → /page/ or /sl/page/
+// ../packaging/x → /../packaging/x (pass through absolute-ish paths)
const resolveLinks = (md) =>
- md.replace(/\]\(\.\/([^)]+)\.md\)/g, "](/$1/)")
- .replace(/\]\(\.\.\/([^)]+)\.md\)/g, "](/$1/)");
+ md.replace(/\]\(\.\/([^)]+)\.md\)/g, `](${base}$1/)`)
+ .replace(/\]\(\.\.\/([^)]+)\)/g, "](/$1)");
content = resolveLinks(content);
+// Resolve cross-wiki locale links: [label](./sl/page.md) → [/sl/page/]
+content = content.replace(/\]\(\.\/sl\/([^)]+)\.md\)/g, "](/sl/$1/)");
+
// Render fenced code blocks
const renderCode = (md) =>
md.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
@@ -95,9 +105,14 @@ content = content
});
const title = frontmatter.title || slug;
+
+// Other locale link for language switcher
+const otherLocale = isSl ? "en" : "sl";
+const otherSlug = isSl ? slug.replace(/^sl\//, "") : `sl/${slug}`;
+const otherLabel = isSl ? "English" : "Slovenščina";
---
-
+
@@ -106,8 +121,9 @@ const title = frontmatter.title || slug;
:root { --bg: #fff; --fg: #1a1a1a; --link: #0366d6; --muted: #666; --border: #e0e0e0; }
@media (prefers-color-scheme: dark) { :root { --bg: #1a1a1a; --fg: #e6e6e6; --link: #58a6ff; --muted: #999; --border: #333; } }
body { max-width: 720px; margin: 2rem auto; padding: 0 1rem; font: 16px/1.6 system-ui; background: var(--bg); color: var(--fg); }
- nav { margin-bottom: 1.5rem; }
+ nav { margin-bottom: 1.5rem; display: flex; justify-content: space-between; align-items: baseline; }
nav a { color: var(--muted); font-size: .9rem; }
+ nav .lang a { font-weight: 600; color: var(--link); }
h1 { font-size: 1.8rem; }
h2 { font-size: 1.4rem; margin-top: 2rem; border-bottom: 1px solid var(--border); padding-bottom: .3rem; }
h3 { font-size: 1.1rem; margin-top: 1.5rem; }
@@ -122,7 +138,10 @@ const title = frontmatter.title || slug;
- ← wiki index
+
+ {isSl ? "← kazalo" : "← wiki index"}
+ {otherLabel}
+
{title}
diff --git a/astro/wiki/src/pages/index.astro b/astro/wiki/src/pages/index.astro
index 5e628c2..3a3fddf 100644
--- a/astro/wiki/src/pages/index.astro
+++ b/astro/wiki/src/pages/index.astro
@@ -3,7 +3,7 @@ import fs from "node:fs";
import path from "node:path";
const WIKI_DIR = path.resolve("../../docs/wiki");
-const EXCLUDE = [".git", "sl", "index.md"];
+const EXCLUDE = [".git", "index.md"];
function walkMarkdown(dir, prefix = "") {
const entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -16,16 +16,18 @@ function walkMarkdown(dir, prefix = "") {
} else if (e.name.endsWith(".md")) {
const rel = prefix ? `${prefix}/${e.name}` : e.name;
const slug = rel.replace(/\.md$/, "");
- // Skip frontmatter, grab first H1 as title
const raw = fs.readFileSync(full, "utf-8");
const title = raw.match(/^#\s+(.+)$/m)?.[1] || slug;
- files.push({ slug, title, file: rel });
+ const lang = rel.startsWith("sl/") ? "sl" : "en";
+ files.push({ slug, title, file: rel, lang });
}
}
return files.sort((a, b) => a.title.localeCompare(b.title));
}
-const pages = walkMarkdown(WIKI_DIR);
+const allPages = walkMarkdown(WIKI_DIR);
+const enPages = allPages.filter(p => p.lang === "en");
+const slPages = allPages.filter(p => p.lang === "sl");
---
@@ -38,11 +40,13 @@ const pages = walkMarkdown(WIKI_DIR);
@media (prefers-color-scheme: dark) { :root { --bg: #1a1a1a; --fg: #e6e6e6; --link: #58a6ff; --muted: #999; } }
body { max-width: 720px; margin: 2rem auto; padding: 0 1rem; font: 16px/1.6 system-ui; background: var(--bg); color: var(--fg); }
h1 { margin-bottom: .25rem; }
- p.lede { color: var(--muted); margin-bottom: 1.5rem; }
+ p.lede { color: var(--muted); margin-bottom: .5rem; }
ul { list-style: none; padding: 0; }
li { margin: .35rem 0; }
a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
+ .lang-bar { margin-bottom: 1rem; }
+ .lang-bar a { font-weight: 600; }
@@ -51,8 +55,9 @@ const pages = walkMarkdown(WIKI_DIR);
Decision pages — the why behind the architecture.
LLM Wiki pattern .
+ Slovenščina →
- {pages.map((p) => (
+ {enPages.map((p) => (
{p.title}
))}
diff --git a/astro/wiki/src/pages/sl/index.astro b/astro/wiki/src/pages/sl/index.astro
new file mode 100644
index 0000000..99ab775
--- /dev/null
+++ b/astro/wiki/src/pages/sl/index.astro
@@ -0,0 +1,64 @@
+---
+import fs from "node:fs";
+import path from "node:path";
+
+const WIKI_DIR = path.resolve("../../../docs/wiki");
+const EXCLUDE = [".git", "index.md"];
+
+function walkMarkdown(dir, prefix = "") {
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
+ const files = [];
+ for (const e of entries) {
+ if (e.name.startsWith(".") || EXCLUDE.includes(e.name)) continue;
+ const full = path.join(dir, e.name);
+ if (e.isDirectory()) {
+ files.push(...walkMarkdown(full, prefix ? `${prefix}/${e.name}` : e.name));
+ } else if (e.name.endsWith(".md")) {
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
+ const slug = rel.replace(/\.md$/, "");
+ const raw = fs.readFileSync(full, "utf-8");
+ const title = raw.match(/^#\s+(.+)$/m)?.[1] || slug;
+ if (rel.startsWith("sl/")) {
+ files.push({ slug: slug.replace(/^sl\//, ""), title, file: rel });
+ }
+ }
+ }
+ return files.sort((a, b) => a.title.localeCompare(b.title));
+}
+
+const pages = walkMarkdown(WIKI_DIR);
+---
+
+
+
+
+
+ Colibri Wiki
+
+
+
+ Colibri Wiki
+
+ Strani z odločitvami — zakaj je arhitektura takšna, kot je.
+ Vzorec LLM Wiki .
+
+ ← English
+
+
+
--
2.45.3
From 3acb9911969239b072f02e51a91335a2e4b4dd45 Mon Sep 17 00:00:00 2001
From: Sam & Claude
Date: Fri, 26 Jun 2026 12:04:37 +0200
Subject: [PATCH 10/19] =?UTF-8?q?docs(sl):=20fix=20quality-gates=20?=
=?UTF-8?q?=E2=80=94=20vrata=E2=86=92preverjanje,=20krajevni=E2=86=92lokal?=
=?UTF-8?q?ni?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
"Vrata" is a correct translation for TCP/network ports, but wrong for
software quality gates. Changes:
quality-gates.md — rewritten: "kakovostno preverjanje" not "kakovostna vrata"
vrata → preverjanje (gate → verification)
krajevna → lokalno (local)
kljuka → kavelj (door handle → git hook)
padejo → spodleti (falls → fails)
Scattered fixes in 5 other files:
cost-model.md, index.md, mother-hive.md, naming-decisions.md,
store-schema.md — vrata → preverjanje/dovoljenja
external-mcp.md — vrata → dovoljenja (permissions)
Kept: "vrata" for TCP ports (correct), "vhodna vrata" for front-door
metaphor (works in Slovenian), "vrata za poslušanje" for listening port.
Also fixed: frontmatter quoting for YAML (nested quotes, colons).
---
docs/wiki/sl/agent-events-reference.md | 2 +-
docs/wiki/sl/agent-harness.md | 4 +--
docs/wiki/sl/contracts.md | 2 +-
docs/wiki/sl/cost-model.md | 4 +--
docs/wiki/sl/deployment.md | 2 +-
docs/wiki/sl/external-mcp.md | 2 +-
docs/wiki/sl/glasspane.md | 2 +-
docs/wiki/sl/headroom-sidecar.md | 2 +-
docs/wiki/sl/index.md | 6 ++---
docs/wiki/sl/jail-confinement.md | 2 +-
docs/wiki/sl/layered-soul.md | 2 +-
docs/wiki/sl/mother-hive.md | 4 +--
docs/wiki/sl/naming-decisions.md | 4 +--
docs/wiki/sl/operator-attention.md | 4 +--
docs/wiki/sl/operator-cli.md | 2 +-
docs/wiki/sl/quality-gates.md | 36 +++++++++++++-------------
docs/wiki/sl/runtime-inventory.md | 2 +-
docs/wiki/sl/skills-catalog.md | 2 +-
docs/wiki/sl/store-schema.md | 6 ++---
docs/wiki/sl/task-board.md | 2 +-
docs/wiki/sl/terminal.md | 2 +-
docs/wiki/sl/tui.md | 2 +-
docs/wiki/sl/vault-provision.md | 2 +-
23 files changed, 49 insertions(+), 49 deletions(-)
diff --git a/docs/wiki/sl/agent-events-reference.md b/docs/wiki/sl/agent-events-reference.md
index 1fc9a56..ff1f688 100644
--- a/docs/wiki/sl/agent-events-reference.md
+++ b/docs/wiki/sl/agent-events-reference.md
@@ -1,6 +1,6 @@
---
title: Referenca agentskih dogodkov
-description: Referenca dogodkov po opremi za zot in pi, preslikave Glasspane in preverjena polja prepisa.
+description: "Referenca dogodkov po opremi za zot in pi, preslikave Glasspane in preverjena polja prepisa."
---
← [kazalo](./index.md)
diff --git a/docs/wiki/sl/agent-harness.md b/docs/wiki/sl/agent-harness.md
index b936de4..e09f1ef 100644
--- a/docs/wiki/sl/agent-harness.md
+++ b/docs/wiki/sl/agent-harness.md
@@ -1,6 +1,6 @@
---
-title: Agentska oprema: zot + Colibri
-description: Dve binarni datoteki, ne ena — zot (agent, Go) in Colibri (krmilna ravnina, Rust).
+title: "Agentska oprema: zot + Colibri"
+description: "Dve binarni datoteki, ne ena — zot (agent, Go) in Colibri (krmilna ravnina, Rust)."
---
← [kazalo](./index.md)
diff --git a/docs/wiki/sl/contracts.md b/docs/wiki/sl/contracts.md
index 092d77f..79027f3 100644
--- a/docs/wiki/sl/contracts.md
+++ b/docs/wiki/sl/contracts.md
@@ -1,6 +1,6 @@
---
title: Stabilne JSON pogodbe
-description: Jezikovno neodvisne oblike na žici, v skupni rabi med Colibri (Rust) in Clawdie agenti (TypeScript).
+description: "Jezikovno neodvisne oblike na žici, v skupni rabi med Colibri (Rust) in Clawdie agenti (TypeScript)."
---
← [kazalo](./index.md)
diff --git a/docs/wiki/sl/cost-model.md b/docs/wiki/sl/cost-model.md
index 17641c6..eea994b 100644
--- a/docs/wiki/sl/cost-model.md
+++ b/docs/wiki/sl/cost-model.md
@@ -1,6 +1,6 @@
---
title: Model stroškov
-description: Kako Colibri sledi vsakemu žetonu, meri zadetke predpomnilnika in samodejno stopnjuje med cenovnimi načini.
+description: "Kako Colibri sledi vsakemu žetonu, meri zadetke predpomnilnika in samodejno stopnjuje med cenovnimi načini."
---
← [kazalo](./index.md)
@@ -94,4 +94,4 @@ odgovori, neodvisno od tega, kako je bila zahteva izvedena.
- [task-board](./task-board.md) — razporejevalnik, ki razpošilja opravila znotraj proračunov sej
- [mother-hive](./mother-hive.md) — arhitektura MCP (druga stroškovna domena)
-- [quality-gates](./quality-gates.md) — vrata, ki preverjajo razčlenjevanje cenovnih načinov
+- [quality-gates](./quality-gates.md) — preverjanje, ki preverja razčlenjevanje cenovnih načinov
diff --git a/docs/wiki/sl/deployment.md b/docs/wiki/sl/deployment.md
index cf29329..238a47a 100644
--- a/docs/wiki/sl/deployment.md
+++ b/docs/wiki/sl/deployment.md
@@ -1,6 +1,6 @@
---
title: Namestitev
-description: Nameščevalnik gostitelja clawdie — odkrije ZFS, pripravi podatkovne zbirke, ustvari uporabnika storitve in namesti enoto rc.d/systemd.
+description: "Nameščevalnik gostitelja clawdie — odkrije ZFS, pripravi podatkovne zbirke, ustvari uporabnika storitve in namesti enoto rc.d/systemd."
---
← [kazalo](./index.md)
diff --git a/docs/wiki/sl/external-mcp.md b/docs/wiki/sl/external-mcp.md
index 0a9cc39..53a51d5 100644
--- a/docs/wiki/sl/external-mcp.md
+++ b/docs/wiki/sl/external-mcp.md
@@ -1,6 +1,6 @@
---
title: Zunanji MCP
-description: Most MCP za urejevalnike in zunanje gostitelje — branje, pisanje in vrata za zunanje klice prek Colibrijeve implementacije MCP.
+description: "Most MCP za urejevalnike in zunanje gostitelje — branje, pisanje in dovoljenja za zunanje klice prek Colibrijeve implementacije MCP."
---
← [kazalo](./index.md)
diff --git a/docs/wiki/sl/glasspane.md b/docs/wiki/sl/glasspane.md
index ced9ac8..32ab29d 100644
--- a/docs/wiki/sl/glasspane.md
+++ b/docs/wiki/sl/glasspane.md
@@ -1,6 +1,6 @@
---
title: Glasspane — nadzor stanja agentov
-description: Colibrijeva plast za opazovanje agentov. Gleda podprocese agentov prek JSONL, zlaga tok v semantični avtomat stanj in izpostavlja API posnetkov.
+description: "Colibrijeva plast za opazovanje agentov. Gleda podprocese agentov prek JSONL, zlaga tok v semantični avtomat stanj in izpostavlja API posnetkov."
---
← [kazalo](./index.md)
diff --git a/docs/wiki/sl/headroom-sidecar.md b/docs/wiki/sl/headroom-sidecar.md
index bd45985..afeee3a 100644
--- a/docs/wiki/sl/headroom-sidecar.md
+++ b/docs/wiki/sl/headroom-sidecar.md
@@ -1,6 +1,6 @@
---
title: Stranski vagon Headroom
-description: Colibri lahko neobvezno prosi krajevni stranski vagon headroom-ai, da stisne rezultate orodij, preden dosežejo proračun žetonov.
+description: "Colibri lahko neobvezno prosi krajevni stranski vagon headroom-ai, da stisne rezultate orodij, preden dosežejo proračun žetonov."
---
← [kazalo](./index.md)
diff --git a/docs/wiki/sl/index.md b/docs/wiki/sl/index.md
index 6e006d2..26ac56a 100644
--- a/docs/wiki/sl/index.md
+++ b/docs/wiki/sl/index.md
@@ -1,6 +1,6 @@
---
title: Colibri Wiki
-description: Zbirka znanja o odločitvah in arhitekturi Colibri — utemeljitve, ki jih koda ne more izraziti.
+description: "Zbirka znanja o odločitvah in arhitekturi Colibri — utemeljitve, ki jih koda ne more izraziti."
---
Zbirka znanja o Colibrijevih **odločitvah in arhitekturi** — po vzoru
@@ -62,10 +62,10 @@ clippy.
| [naming-decisions](./naming-decisions.md) | Imenik preimenovanj, nevtralnih glede na opremo / arhitekturnih — dostavljenih in v teku |
| [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 vrata pred združitvijo; zakaj je odmik prej dosegel `main` |
+| [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-smoke), 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; vrata za branje/pisanje/zunanji-klic |
+| [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 demona |
| [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) |
diff --git a/docs/wiki/sl/jail-confinement.md b/docs/wiki/sl/jail-confinement.md
index 7bfd6ce..f72f5ef 100644
--- a/docs/wiki/sl/jail-confinement.md
+++ b/docs/wiki/sl/jail-confinement.md
@@ -1,6 +1,6 @@
---
title: Omejitev ječ
-description: Trajne proti prehodnim ječam, pravilnik načina priv in ponovna uporaba omejitve zaganjalnika za strežnike MCP.
+description: "Trajne proti prehodnim ječam, pravilnik načina priv in ponovna uporaba omejitve zaganjalnika za strežnike MCP."
---
← [kazalo](./index.md)
diff --git a/docs/wiki/sl/layered-soul.md b/docs/wiki/sl/layered-soul.md
index 4440653..137ff89 100644
--- a/docs/wiki/sl/layered-soul.md
+++ b/docs/wiki/sl/layered-soul.md
@@ -1,6 +1,6 @@
---
title: Integracija plastovite duše
-description: Kako Colibri danes uporablja repozitorij layered-soul in kaj je še načrtovano.
+description: "Kako Colibri danes uporablja repozitorij layered-soul in kaj je še načrtovano."
---
← [kazalo](./index.md)
diff --git a/docs/wiki/sl/mother-hive.md b/docs/wiki/sl/mother-hive.md
index d3cd291..1c07b0f 100644
--- a/docs/wiki/sl/mother-hive.md
+++ b/docs/wiki/sl/mother-hive.md
@@ -1,6 +1,6 @@
---
title: Matični hive
-description: Kako matično vozlišče (OSA) usklajuje USB-operaterska vozlišča prek MCP prek SSH → PostgreSQL.
+description: "Kako matično vozlišče (OSA) usklajuje USB-operaterska vozlišča prek MCP prek SSH → PostgreSQL."
---
← [kazalo](./index.md)
@@ -116,4 +116,4 @@ datoteke ali `sudo`.
- [agent-harness](./agent-harness.md) — razcep zot/Colibri; samodejni zagon
- [naming-decisions](./naming-decisions.md) — `usb_nodes → hive_nodes`, preimenovanje zastavice autospawn
-- [quality-gates](./quality-gates.md) — vrata, ki bi morala ujeti odmik ob času PR
+- [quality-gates](./quality-gates.md) — preverjanje, ki bi moralo ujeti odmik ob času PR
diff --git a/docs/wiki/sl/naming-decisions.md b/docs/wiki/sl/naming-decisions.md
index 60306d7..0620a38 100644
--- a/docs/wiki/sl/naming-decisions.md
+++ b/docs/wiki/sl/naming-decisions.md
@@ -1,6 +1,6 @@
---
title: Imenik odločitev o poimenovanju
-description: Živa evidenca preimenovanj, ki so jih pognale presežene predpostavke — da je prihodnji odmik preverljiv proti enemu seznamu.
+description: "Živa evidenca preimenovanj, ki so jih pognale presežene predpostavke — da je prihodnji odmik preverljiv proti enemu seznamu."
---
← [kazalo](./index.md)
@@ -60,4 +60,4 @@ _Trenutno nobeno._
## Glej tudi
- [agent-harness](./agent-harness.md)
-- [quality-gates](./quality-gates.md) — vrata, ki bi morala te ujeti ob času PR
+- [quality-gates](./quality-gates.md) — preverjanje, ki bi moralo te ujeti ob času PR
diff --git a/docs/wiki/sl/operator-attention.md b/docs/wiki/sl/operator-attention.md
index c1523f9..ef85c03 100644
--- a/docs/wiki/sl/operator-attention.md
+++ b/docs/wiki/sl/operator-attention.md
@@ -1,6 +1,6 @@
---
-title: Operaterska pozornost — "ali ta agent potrebuje mene zdaj?"
-description: Kako Glasspane presoja, kateri agent potrebuje operaterski poseg, in to prikaže v TUI.
+title: "Operaterska pozornost — ali ta agent potrebuje mene zdaj?"
+description: "Kako Glasspane presoja, kateri agent potrebuje operaterski poseg, in to prikaže v TUI."
---
← [kazalo](./index.md)
diff --git a/docs/wiki/sl/operator-cli.md b/docs/wiki/sl/operator-cli.md
index 0bfb0a9..1433083 100644
--- a/docs/wiki/sl/operator-cli.md
+++ b/docs/wiki/sl/operator-cli.md
@@ -1,6 +1,6 @@
---
title: Operaterski CLI (`colibri`)
-description: Binarna datoteka `colibri` je operaterski vmesnik ukazne vrstice do demona — tanek tipiziran odjemalec Unix vtičnice.
+description: "Binarna datoteka `colibri` je operaterski vmesnik ukazne vrstice do demona — tanek tipiziran odjemalec Unix vtičnice."
---
← [kazalo](./index.md)
diff --git a/docs/wiki/sl/quality-gates.md b/docs/wiki/sl/quality-gates.md
index 930dade..f69d16f 100644
--- a/docs/wiki/sl/quality-gates.md
+++ b/docs/wiki/sl/quality-gates.md
@@ -1,50 +1,50 @@
---
-title: Kakovostna vrata
-description: Sprememba ni "končana", dokler krajevna vrata ne uspejo — cargo fmt, clippy, cargo test, markdown vrata, wiki-lint.
+title: Kakovostno preverjanje
+description: 'Sprememba ni končana brez uspešnega lokalnega preverjanja — cargo fmt, clippy, cargo test, markdown, wiki-lint.'
---
← [kazalo](./index.md)
## Odločitev
-Sprememba ni "končana", dokler krajevna vrata ne uspejo:
+Sprememba ni "končana", dokler ne prestane lokalnega preverjanja:
```sh
./scripts/ci-checks.sh # cargo fmt --check, clippy -D warnings, cargo test, markdown gate, wiki-lint --strict
```
-Predpotisna kljuka (`scripts/pre-push`) zažene ta ista vrata ob vsakem
+Predpotisni kavelj (`scripts/pre-push`) zažene isto preverjanje ob vsakem
`git push` na `main` — aktiviraj enkrat na klon z `./scripts/install-hooks.sh`.
-Kljuka zavrne potisk, če katerakoli vrata padejo; obidi samo v sili z
+Kavelj zavrne potisk, če katerokoli preverjanje spodleti; obidi samo v sili z
`--no-verify`.
`.forgejo/workflows/ci.yml` kodira ista preverjanja, vendar **noben izvajalnik
Forgejo Actions ni registriran**, zato nič ne uveljavlja preverjanj
-strežniško. Dokler izvajalnik ni aktiven, so krajevna vrata + predpotisna
-kljuka uveljavitvena plast. Navedeno kot obvezno v `AGENTS.md`.
+strežniško. Dokler izvajalnik ni aktiven, sta lokalno preverjanje + predpotisni
+kavelj uveljavitvena plast. Navedeno kot obvezno v `AGENTS.md`.
## Zakaj ta stran obstaja
Napaka pri prevajanju (`pi_binary` nedefiniran, iz napol dokončanega
-preimenovanja) je dosegla `main`, ker so bila vrata preskočena _in_
-neuveljavljena. Ista revizija je ugotovila, da so bila oboja vrata takrat
-dejansko rdeča na `main`:
+preimenovanja) je dosegla `main`, ker je bilo preverjanje preskočeno _in_
+neuveljavljeno. Ista revizija je ugotovila, da sta bili obe preverjanji takrat
+dejansko rdeči na `main`:
-- `clippy -D warnings` je padel na predobstoječem lintu → Rust vrata bi
- padla za vsakogar, ki bi jih zagnal.
-- markdown vrata so padla na prettier-umazanih dokumentih.
+- `clippy -D warnings` je padel na predobstoječem lintu → Rust preverjanje bi
+ padlo za vsakogar, ki bi ga zagnal.
+- markdown preverjanje je padlo na prettier-umazanih dokumentih.
-Oboje je bilo spravljeno v zeleno, zato so vrata zdaj dejansko zagonljiva.
-Nauk: vrata, ki jih nihče ne poganja (in so tako ali tako rdeča), so
-korenski vzrok, da odmik doseže `main` — bolj kot vsak posamezen spodrsljaj
-pri poimenovanju.
+Oboje je bilo spravljeno v zeleno, zato je preverjanje zdaj dejansko
+zagonljivo. Nauk: preverjanje, ki ga nihče ne poganja (in je tako ali tako
+rdeče), je korenski vzrok, da odmik doseže `main` — bolj kot vsak posamezen
+spodrsljaj pri poimenovanju.
## Odnos do tega wikija
Imenik [naming-decisions](./naming-decisions.md) + `wiki-lint --strict` sta
_pomenska_ protiutež `ci-checks.sh`: prevajalnik/clippy ujameta zlomljeno
_kodo_, ne pa dokumenta, ki še vedno opisuje staro zasnovo, ali imena, ki je
-odneslo. Wiki lint pokriva to vrzel. Zdaj je del obveznih vrat — napaka
+odneslo. Wiki lint pokriva to vrzel. Zdaj je del obveznega preverjanja — napaka
odmika blokira potisk, enako kot opozorilo clippy.
## Glej tudi
diff --git a/docs/wiki/sl/runtime-inventory.md b/docs/wiki/sl/runtime-inventory.md
index 825dfeb..f801a18 100644
--- a/docs/wiki/sl/runtime-inventory.md
+++ b/docs/wiki/sl/runtime-inventory.md
@@ -1,6 +1,6 @@
---
title: Popis izvajalnega okolja
-description: Bralnik stanja gostitelja — aditivne, bralne integracije, ki zbirajo različice OS, paketov in izvajalnega okolja.
+description: "Bralnik stanja gostitelja — aditivne, bralne integracije, ki zbirajo različice OS, paketov in izvajalnega okolja."
---
← [kazalo](./index.md)
diff --git a/docs/wiki/sl/skills-catalog.md b/docs/wiki/sl/skills-catalog.md
index 151c5b3..136c300 100644
--- a/docs/wiki/sl/skills-catalog.md
+++ b/docs/wiki/sl/skills-catalog.md
@@ -1,6 +1,6 @@
---
title: Katalog veščin
-description: Bralni izvajalni porabnik za pregledane artefakte veščin Clawdie-AI — uvozi SKILL.md v Colibrijevo tabelo skills.
+description: "Bralni izvajalni porabnik za pregledane artefakte veščin Clawdie-AI — uvozi SKILL.md v Colibrijevo tabelo skills."
---
← [kazalo](./index.md)
diff --git a/docs/wiki/sl/store-schema.md b/docs/wiki/sl/store-schema.md
index 022fb67..d47ac18 100644
--- a/docs/wiki/sl/store-schema.md
+++ b/docs/wiki/sl/store-schema.md
@@ -1,6 +1,6 @@
---
title: Shema shrambe
-description: Koordinacijska shramba Colibri — ena sama podatkovna zbirka SQLite, ki hrani tablo opravil, register agentov in veščin ter preslikavo najemnikov.
+description: "Koordinacijska shramba Colibri — ena sama podatkovna zbirka SQLite, ki hrani tablo opravil, register agentov in veščin ter preslikavo najemnikov."
---
← [kazalo](./index.md)
@@ -69,8 +69,8 @@ svojem `tenant_id` v ukazih vtičnice in kljukah za oskrbo.
### Omejitev CHECK stanja opravila je vir resnice
`tasks.status` je omejen na `('queued','claimed','started','done','failed')`.
-Enum `TaskStatus` v Rustu ga zrcali, vendar je podatkovna zbirka zadnja
-vrata. Ukaz, ki poskusi vstaviti neznano stanje, pade ob času pisanja.
+Enum `TaskStatus` v Rustu ga zrcali, vendar je podatkovna zbirka zadnje
+preverjanje. Ukaz, ki poskusi vstaviti neznano stanje, pade ob času pisanja.
→ `crates/colibri-store/src/schema.rs`
diff --git a/docs/wiki/sl/task-board.md b/docs/wiki/sl/task-board.md
index 0d6d317..7a62578 100644
--- a/docs/wiki/sl/task-board.md
+++ b/docs/wiki/sl/task-board.md
@@ -1,6 +1,6 @@
---
title: Tabla opravil + razporejevalnik
-description: Kako Colibri hrani operaterska opravila in jih razporeja med agente glede na zmožnosti.
+description: "Kako Colibri hrani operaterska opravila in jih razporeja med agente glede na zmožnosti."
---
← [kazalo](./index.md)
diff --git a/docs/wiki/sl/terminal.md b/docs/wiki/sl/terminal.md
index 572d9e7..d842bd0 100644
--- a/docs/wiki/sl/terminal.md
+++ b/docs/wiki/sl/terminal.md
@@ -1,6 +1,6 @@
---
title: Terminal — zmožnost, ne znamka
-description: Zmožnost terminala kot ozka, prenosljiva abstrakcija — Kitty, ne iTerm2; ANSI, ne lastniški protokoli.
+description: "Zmožnost terminala kot ozka, prenosljiva abstrakcija — Kitty, ne iTerm2; ANSI, ne lastniški protokoli."
---
← [kazalo](./index.md)
diff --git a/docs/wiki/sl/tui.md b/docs/wiki/sl/tui.md
index 9966637..1ad0aa6 100644
--- a/docs/wiki/sl/tui.md
+++ b/docs/wiki/sl/tui.md
@@ -1,6 +1,6 @@
---
title: Terminalska nadzorna plošča (colibri-tui)
-description: Colibrijeva živa terminalska nadzorna plošča — povezuje se na Unix vtičnico demona in prikazuje agente, stanja ter pozornostna opozorila.
+description: "Colibrijeva živa terminalska nadzorna plošča — povezuje se na Unix vtičnico demona in prikazuje agente, stanja ter pozornostna opozorila."
---
← [kazalo](./index.md)
diff --git a/docs/wiki/sl/vault-provision.md b/docs/wiki/sl/vault-provision.md
index 51429b7..0c1f048 100644
--- a/docs/wiki/sl/vault-provision.md
+++ b/docs/wiki/sl/vault-provision.md
@@ -1,6 +1,6 @@
---
title: Oskrba trezorja
-description: Oskrba datotek env, gnana z Vaultwarden, v ječe po zagonu agenta — preslikava najemnik → ječa → zbirka.
+description: "Oskrba datotek env, gnana z Vaultwarden, v ječe po zagonu agenta — preslikava najemnik → ječa → zbirka."
---
← [kazalo](./index.md)
--
2.45.3
From 9643790739a003a609257a13e6d7ffc71952e78c Mon Sep 17 00:00:00 2001
From: Sam & Claude
Date: Fri, 26 Jun 2026 12:16:27 +0200
Subject: [PATCH 11/19] skill(nginx): document SSL cert placeholder + ACME
challenge ordering hiccups
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three hiccups from the wiki.clawdie.si deployment (26.jun.2026):
1. Placeholder cert — nginx refuses to start when ssl_certificate files
dont exist yet. Create a temporary self-signed cert first, then
acme.sh --issue overwrites it with the real cert.
2. ACME challenge location ordering — nginx matches location blocks in
order. .well-known/acme-challenge/ MUST come before the
location / { return 301 https://... } redirect, otherwise ACME
validation returns 301 instead of the challenge file.
3. acme.sh --key-file + --fullchain-file — these flags write directly
to the nginx SSL paths, replacing the placeholder. The cert paths
must match between nginx config and acme.sh invocation.
New "Adding a new public static HTTPS site — full flow" section covers
the complete pattern: DNS first → placeholder cert → vhost with ordered
locations → real cert → content deploy → renewal.
Two new troubleshooting entries: BIO_new_file() failed (missing cert),
ACME challenge 404/301 (location ordering or missing directory).
---
.agent/nginx/SKILL.md | 677 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 677 insertions(+)
create mode 100644 .agent/nginx/SKILL.md
diff --git a/.agent/nginx/SKILL.md b/.agent/nginx/SKILL.md
new file mode 100644
index 0000000..04952a7
--- /dev/null
+++ b/.agent/nginx/SKILL.md
@@ -0,0 +1,677 @@
+---
+name: nginx
+description: Manage nginx web serving for the cms jail on FreeBSD. Host nginx terminates SSL and proxies to jail nginx at ${AGENT_SUBNET_BASE}.4. Use when configuring vhosts, SSL, static site serving, or Strapi reverse proxy. Triggers on "nginx", "website", "vhost", "update site", "add vhost", "ssl".
+---
+
+# Nginx
+
+Use this skill for nginx decisions on the **cms jail** (`${AGENT_SUBNET_BASE}.4`) and the **host nginx** SSL proxy layer.
+
+## Current surface model
+
+- Operator app: `ai. ` — not served by cms jail nginx
+- Shared CMS admin/API: `cms. ` — shared service surface
+- Shared code admin: `git. ` — separate service surface, not cms nginx
+- Tenant home: `. ` — served by cms jail nginx
+- Tenant site: `.. ` — served by cms jail nginx
+
+For the internal default install, ` ` is `home.arpa`.
+
+## Actual architecture (as deployed)
+
+```text
+operator at ai.
+ → controlplane app (not cms nginx)
+
+tenant/browser at . or ..
+ → host nginx (optional public SSL terminator / proxy)
+ → cms jail nginx at ${AGENT_SUBNET_BASE}.4
+ → tenant home + tenant sites (static output)
+
+operator/editor at cms.
+ → host nginx (optional public/internal proxy)
+ → cms jail nginx
+ → Strapi admin + CMS API
+```
+
+**Host nginx** terminates SSL and proxies to the jail when public exposure exists.
+**Jail nginx** is the real web server for tenant homes, tenant sites, and the shared CMS surface.
+
+This diverges from the original design (PF RDR → jail nginx handling SSL itself). The reason:
+host nginx was already running for clawdie.si, so PF RDR to the jail would break the existing
+site. Host-proxy is the correct pattern when multiple domains share the same host.
+
+## Scope
+
+This skill covers:
+
+- Host nginx SSL proxy vhosts (SSL termination, proxy_pass to jail)
+- Jail nginx server_name routing for `cms. `, `. `, and `.. `
+- SSL certificate management for public-exposed surfaces
+- ACME challenge pattern when host nginx proxies to jail
+- Strapi admin/API reverse proxy on the shared CMS surface
+- Tenant home and tenant-site static serving
+
+This skill does not replace:
+
+- `warden-pf` for PF firewall rules
+- `freebsd-admin` for host-level system changes
+
+## SSL certificate tools (two tools on this host)
+
+| Domain | Tool | Location |
+| ------------------------------------------------------- | ------- | ------------------------------------------- |
+| `clawdie.si`, `docs.clawdie.si`, `osa.smilepowered.org` | acme.sh | `/root/.acme.sh/_ecc/` |
+| `samob.smilepowered.org` | certbot | `/usr/local/etc/letsencrypt/live//` |
+
+Keep the tools separate. Do not migrate certbot domains to acme.sh without a plan.
+
+`just doctor` audits the canonical acme.sh-backed public cert paths and reports `TLS_` expiry plus `ACME_RENEWAL_CRON` presence. Treat missing renewal cron as an operational warning; do not renew or reinstall cert tooling from the doctor path.
+
+## ACME challenge pattern (when host proxies to jail)
+
+When host nginx is proxying a domain to the jail, certbot HTTP-01 renewal must
+be served from the **host**, not the jail. The jail doesn't know about certbot.
+
+Pattern in every proxied host vhost:
+
+```nginx
+server {
+ listen 80; listen [::]:80;
+ server_name ;
+
+ # certbot challenge served from host — do NOT proxy this
+ location /.well-known/acme-challenge/ {
+ root /var/www/certbot-challenge;
+ }
+
+ location / {
+ return 301 https://$server_name$request_uri;
+ }
+}
+```
+
+The directory `/var/www/certbot-challenge` must exist on the host. certbot writes
+challenge files there; nginx serves them before the HTTPS redirect fires.
+
+## Paths
+
+**Host nginx:**
+
+- vhosts: `/usr/local/etc/nginx/vhosts/*.conf`
+- certbot certs: `/usr/local/etc/letsencrypt/live//`
+- acme.sh certs: `/usr/local/etc/nginx/ssl//`
+- ACME challenge webroot: `/var/www/certbot-challenge/`
+
+**Jail nginx** — commands via `bastille cmd cms ` or `bastille console cms`:
+
+- nginx config: `/usr/local/etc/nginx/nginx.conf`
+- vhosts: `/usr/local/etc/nginx/vhosts/*.conf`
+- webroots: `/var/www//dist/`
+
+## Hosted surfaces
+
+| Surface | Typical host | Proxied from host | Served by cms jail nginx |
+| ------------- | ------------------------ | ----------------- | ------------------------ |
+| Tenant home | `. ` | maybe | yes |
+| Tenant site | `.. ` | maybe | yes |
+| CMS admin/API | `cms. ` | maybe | yes |
+| Operator app | `ai. ` | separate stack | no |
+
+**acme.sh webroot for host-proxied domains:** keep a real directory at
+`/usr/local/www//` on the host even when the domain proxies to the jail.
+acme.sh --webroot renewal writes challenge files there; the HTTP vhost serves
+`/.well-known/acme-challenge/` from it before the HTTPS redirect fires.
+
+## docs.clawdie.si shape
+
+`docs.clawdie.si` is a public static documentation site, not a reverse proxy app.
+
+Recommended site structure (inside cms jail):
+
+```text
+/usr/local/www/docs.clawdie.si/
+ index.html
+ css/
+ shared.css
+ docs/
+ index.html
+ split-brain.html
+```
+
+Recommended vhost:
+
+```nginx
+server {
+ listen 80;
+ listen [::]:80;
+ server_name docs.clawdie.si;
+ return 301 https://docs.clawdie.si$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ server_name docs.clawdie.si;
+ root /usr/local/www/docs.clawdie.si;
+ index index.html;
+
+ ssl_certificate /usr/local/etc/nginx/ssl/docs/fullchain.cer;
+ ssl_certificate_key /usr/local/etc/nginx/ssl/docs/docs.key;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_ciphers HIGH:!aNULL:!MD5;
+
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header X-XSS-Protection "1; mode=block" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+
+ location /docs/ {
+ try_files $uri $uri/ /docs/index.html =404;
+ }
+
+ location / {
+ try_files $uri $uri/ =404;
+ }
+}
+```
+
+Use this site to explain:
+
+- FreeBSD-first deployment
+- split-brain memory
+- local built-in knowledge in the `db` jail
+- upstream-aware relationship to NanoClaw
+
+Recommended baseline for all public vhosts:
+
+- `add_header X-Content-Type-Options "nosniff" always;`
+- `add_header X-Frame-Options "SAMEORIGIN" always;`
+- `add_header X-XSS-Protection "1; mode=block" always;`
+- `add_header Referrer-Policy "strict-origin-when-cross-origin" always;`
+
+## Site structure: public Clawdie site
+
+Inside the cms jail at `/usr/local/www/clawdie/`:
+
+```
+/usr/local/www/clawdie/
+ index.html # Main landing page
+ css/shared.css # Shared styles
+ docs/index.html # Documentation page
+ img/ # Public images (used on landing page)
+ guides/
+ freebsd-setup.html # FreeBSD setup guide
+ nginx-ssl.html # Nginx + SSL guide
+ stripe-agents.html # Stripe agents guide
+ tailscale-vpn.html # Tailscale VPN guide
+ screenshots/ # Diagnostic screenshots (basic auth protected)
+```
+
+## Public vs Protected Paths
+
+**Pattern:** Use `/img/` for public images, `/screenshots/` for protected content.
+
+| Path | Purpose | Auth |
+| --------------- | -------------------------------------- | ---------- |
+| `/img/` | Public images used on landing page | None |
+| `/screenshots/` | Diagnostic screenshots, wizard gallery | Basic auth |
+
+**Why separate directories?**
+
+If you include an image from `/screenshots/` on the landing page, the browser triggers an auth prompt when loading the page. This creates a bad user experience.
+
+## Controlplane dashboard (Tailscale)
+
+When exposing the operator dashboard over Tailscale, host nginx serves TLS for the MagicDNS hostname and proxies to the local controlplane API.
+
+Recommended pattern:
+
+- vhost: `/usr/local/etc/nginx/vhosts/controlplane-tailscale.conf`
+- certs: `/usr/local/etc/nginx/ssl/tailscale/.crt` + `.key` (from `tailscale cert`)
+- proxy: `http://127.0.0.1:3100`
+- header: `Authorization: Bearer op:clawdie:${OPERATOR_PASSWORD}` when running `CONTROLPLANE_AUTH_MODE=local_trusted`
+
+This is the `ai. ` surface, not the tenant home app.
+
+**Solution:**
+
+1. Public images → `/usr/local/www/clawdie/img/` inside the `cms` jail
+2. Protected galleries → `/usr/local/www/clawdie/screenshots/` inside the `cms` jail
+
+```nginx
+# nginx config pattern
+location /img/ {
+ try_files $uri $uri/ =404; # public, no auth
+}
+
+location /screenshots/ {
+ auth_basic "Diagnostics";
+ auth_basic_user_file /usr/local/etc/nginx/screenshots.htpasswd;
+ try_files $uri $uri/ =404;
+}
+```
+
+**HTML usage:**
+
+```html
+
+
+
+
+View gallery
+```
+
+## Protected paths
+
+| Path | Auth | htpasswd file | Credentials |
+| --------------- | ---------- | ------------------------------------------- | ------------------------------------------------------ |
+| `/screenshots/` | basic auth | `/usr/local/etc/nginx/screenshots.htpasswd` | in `.env` (`SCREENSHOTS_USER`, `SCREENSHOTS_PASSWORD`) |
+
+### Adding basic auth to a new path
+
+Run inside the cms jail (`bastille console cms`):
+
+```sh
+# 1. generate password hash
+openssl passwd -apr1 'your-password'
+
+# 2. write htpasswd file
+sh -c 'cat > /usr/local/etc/nginx/newpath.htpasswd << EOF
+username:$apr1$hash...
+EOF'
+chmod 640 /usr/local/etc/nginx/newpath.htpasswd
+chown root:www /usr/local/etc/nginx/newpath.htpasswd
+
+# 3. add location block to vhost
+# location /newpath/ {
+# auth_basic "Description";
+# auth_basic_user_file /usr/local/etc/nginx/newpath.htpasswd;
+# try_files $uri $uri/ =404;
+# }
+
+# 4. test and reload
+nginx -t && service nginx reload
+```
+
+Store credentials in `/home/clawdie/clawdie-ai/.env`. Never in skill files.
+
+## Safe defaults
+
+- Always run `nginx -t` before reloading
+- Never reload nginx with a broken config
+- Back up vhost configs before modifying
+- Keep CSS in shared files, not inline (except index.html which is self-contained)
+- Test changes locally before pushing to production
+
+## Workflow
+
+Be explicit about which nginx owns the surface:
+
+- **Host nginx** terminates public TLS for `clawdie.si`, `docs.clawdie.si`, and other host-level domains.
+- **cms jail nginx** serves tenant/static content behind the host proxy or internal routes.
+
+Run host nginx/acme.sh commands on the host. Run jail webroot and jail-vhost commands inside the cms jail:
+
+```sh
+bastille console cms
+# or
+bastille cmd cms sh
+```
+
+### Updating site content
+
+1. Edit the HTML file directly in the webroot (inside the jail)
+2. Changes are served immediately (static files, no build step)
+3. For structural changes, run `nginx -t` then `service nginx reload`
+
+### Adding a new public static HTTPS site — full flow (wiki.clawdie.si pattern)
+
+This is the canonical pattern for deploying a static HTTPS site on the host
+nginx, drawn from the `wiki.clawdie.si` deployment (26.jun.2026). It covers the
+three hiccups that reliably trip first-time deploys and how to avoid them.
+
+### 0. DNS first
+
+Verify the A/AAAA record resolves before touching nginx or acme.sh. The server
+cannot reach its own public IP (PF blocks loopback), so query the authoritative
+nameserver directly:
+
+```sh
+drill NS clawdie.si | grep "ANSWER" -A5
+drill wiki.clawdie.si A @x1.si.
+```
+
+Do not proceed until the authoritative NS returns the correct IP.
+
+### 1. Create webroot + placeholder cert
+
+**Hiccup #1: nginx refuses to start when the SSL cert file doesn't exist.**
+The vhost references `ssl_certificate` and `ssl_certificate_key` — if those
+files are absent, `nginx -t` fails and you can't even start the HTTP server for
+ACME validation. Fix: create a **temporary self-signed cert** first:
+
+```sh
+mkdir -p /usr/local/www/wiki.clawdie.si
+mkdir -p /usr/local/etc/nginx/ssl/wiki
+
+# Placeholder cert — lets nginx start so ACME can validate
+openssl req -x509 -nodes -days 1 -newkey ec \
+ -pkeyopt ec_paramgen_curve:prime256v1 \
+ -keyout /usr/local/etc/nginx/ssl/wiki/wiki.key \
+ -out /usr/local/etc/nginx/ssl/wiki/fullchain.cer \
+ -subj "/CN=wiki.clawdie.si"
+```
+
+### 2. Write the vhost with ACME challenge BEFORE redirect
+
+**Hiccup #2: the HTTP→HTTPS redirect catches the ACME challenge before it
+reaches the well-known location.** Nginx matches location blocks in order.
+The `.well-known/acme-challenge/` location must appear **before** the
+`location / { return 301 https://... }` redirect:
+
+```nginx
+server {
+ listen 80; listen [::]:80;
+ server_name wiki.clawdie.si;
+
+ # ACME challenge — MUST come before the redirect
+ location /.well-known/acme-challenge/ {
+ root /usr/local/www/wiki.clawdie.si;
+ }
+
+ location / {
+ return 301 https://wiki.clawdie.si$request_uri;
+ }
+}
+
+server {
+ listen 443 ssl; listen [::]:443 ssl;
+ server_name wiki.clawdie.si;
+ root /usr/local/www/wiki.clawdie.si;
+ index index.html;
+
+ ssl_certificate /usr/local/etc/nginx/ssl/wiki/fullchain.cer;
+ ssl_certificate_key /usr/local/etc/nginx/ssl/wiki/wiki.key;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_ciphers HIGH:!aNULL:!MD5;
+
+ # Security headers — baseline for all public vhosts
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header X-XSS-Protection "1; mode=block" always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+
+ location /.well-known/acme-challenge/ {
+ root /usr/local/www/wiki.clawdie.si;
+ }
+
+ location / {
+ try_files $uri $uri/ =404;
+ }
+}
+```
+
+Write to `/usr/local/etc/nginx/vhosts/.conf`, then:
+
+```sh
+nginx -t && service nginx reload
+```
+
+### 3. Issue the real cert (replaces placeholder)
+
+**Hiccup #3: acme.sh `--issue` with `--key-file` + `--fullchain-file` writes
+directly to the nginx SSL paths, overwriting the placeholder.** The cert files
+and nginx config must agree on the paths:
+
+```sh
+acme.sh --issue -d wiki.clawdie.si -w /usr/local/www/wiki.clawdie.si \
+ --key-file /usr/local/etc/nginx/ssl/wiki/wiki.key \
+ --fullchain-file /usr/local/etc/nginx/ssl/wiki/fullchain.cer
+service nginx reload
+```
+
+Verify the cert replaced the placeholder:
+
+```sh
+openssl x509 -in /usr/local/etc/nginx/ssl/wiki/fullchain.cer -noout -issuer
+# Should show "CN = R11, O = Let's Encrypt" — not the placeholder CN
+```
+
+### 4. Content deployment
+
+Static content goes to the webroot. For content built inside the CMS jail, use
+tar to cross the jail boundary:
+
+```sh
+# Build inside jail, tar to host
+bastille cmd cms sh -c 'tar -czf /tmp/wiki-dist.tar.gz -C /usr/local/www/wiki.clawdie.si .'
+cp /usr/local/bastille/jails/cms/root/tmp/wiki-dist.tar.gz /tmp/
+tar -xzf /tmp/wiki-dist.tar.gz -C /usr/local/www/wiki.clawdie.si/
+```
+
+### 5. Renewal
+
+acme.sh auto-renews via cron (check with `crontab -l`). The cert paths match
+between acme.sh and nginx, so renewal is zero-touch. Verify:
+
+```sh
+openssl x509 -in /usr/local/etc/nginx/ssl/wiki/fullchain.cer -noout -enddate
+```
+
+The public TLS/certificate steps below run on the **host**. Only jail webroot/content steps run inside the cms jail.
+
+1. **DNS check first** — verify the A record resolves before touching nginx or acme.sh.
+ The server cannot reach its own public IP (PF), so query the authoritative nameserver directly:
+
+ ```sh
+ # find NS
+ drill NS clawdie.si | grep "ANSWER" -A5
+ # query it directly
+ drill docs.clawdie.si A @x1.si.
+ ```
+
+ Do not proceed until the authoritative NS returns the correct IP.
+
+2. **Create the ACME challenge webroot on the host**:
+
+ ```sh
+ mkdir -p /usr/local/www/
+ ```
+
+ If the served content lives in the cms jail, keep this host path for HTTP-01 challenges and proxy normal traffic to the jail.
+
+3. **Fix symlink traversal permissions** — nginx worker runs as `www` and must be able
+ to stat through every directory in the symlink path. If any parent dir is `drwxrwx---`
+ (no world-execute), nginx will get `Permission denied (13)` even though the files
+ are world-readable. The site will serve 404/403 to Let's Encrypt and visitors.
+
+4. **Install a temporary HTTP-only vhost** before issuing the cert. Let's Encrypt needs
+ to reach `/.well-known/acme-challenge/` over HTTP. Without a matching server block,
+ nginx falls through to the default server and returns 404.
+
+ ```sh
+ cat > /usr/local/etc/nginx/vhosts/.conf << 'EOF'
+ server {
+ listen 80; listen [::]:80;
+ server_name ;
+ root /usr/local/www/;
+ location /.well-known/acme-challenge/ { try_files $uri =404; }
+ location / { return 301 https://$request_uri; }
+ }
+ EOF
+ nginx -t && service nginx reload
+ ```
+
+5. **Issue and install the cert**:
+
+ ```sh
+ mkdir -p /usr/local/etc/nginx/ssl/
+ acme.sh --issue -d --webroot /usr/local/www/
+ acme.sh --install-cert -d \
+ --cert-file /usr/local/etc/nginx/ssl//cert.cer \
+ --key-file /usr/local/etc/nginx/ssl//.key \
+ --fullchain-file /usr/local/etc/nginx/ssl//fullchain.cer \
+ --reloadcmd "service nginx reload"
+ ```
+
+6. **Replace temp vhost with full HTTPS vhost**, then test and reload:
+ ```sh
+ nginx -t && service nginx reload
+ ```
+
+### Strapi admin reverse proxy (optional)
+
+To expose Strapi admin via HTTPS on an internal subdomain (e.g., `cms..home.arpa`),
+run inside the cms jail:
+
+1. Create `/usr/local/etc/nginx/vhosts/cms.conf` using `references/strapi-proxy.md`
+2. Strapi runs at `http://127.0.0.1:1337` inside the cms jail
+3. Restrict access to Tailscale IPs only
+4. Run `nginx -t` then `service nginx reload`
+
+Do not expose Strapi admin publicly by default. Keep public traffic on the
+static Astro output unless there is a strong reason to do otherwise.
+
+### Strapi API microcache (nginx, recommended)
+
+To protect Strapi from bursts (agents, crawlers, SSR spikes), enable a short
+microcache in the **cms jail nginx**. This keeps content updates fast while
+avoiding rebuilds.
+
+See `references/strapi-cache.md` for the exact config and validation steps.
+
+### Astro deploy
+
+When Astro output is ready for a public site:
+
+1. Build in the `cms` jail or build locally and copy only `dist/`.
+2. Back up the current webroot unless the operator explicitly says to skip backup because of disk pressure.
+3. Sync generated output to the right webroot:
+ - landing `clawdie.si`: `/usr/local/www/clawdie-si/`
+ - docs `docs.clawdie.si`: `/usr/local/www/clawdie/`
+4. Validate the jail-local vhost with the correct `Host:` header.
+5. Validate public HTTPS.
+6. Roll back from backup if needed and available.
+
+### SSL certificate management
+
+1. Public edge certificates are managed via acme.sh on the host.
+2. Certificate paths follow: `/usr/local/etc/nginx/ssl//`
+3. Each domain has: `fullchain.cer` and `.key`
+4. `just doctor` reports certificate expiry and `ACME_RENEWAL_CRON` renewal-cron presence.
+
+## FreeBSD assumption
+
+This skill assumes the target runtime is FreeBSD inside the cms jail.
+
+Canonical paths inside the cms jail:
+
+- `/usr/local/etc/nginx/nginx.conf`
+- `/usr/local/etc/nginx/vhosts/*.conf`
+- `/usr/local/etc/nginx/ssl/`
+- `/usr/local/www/`
+
+## Validation
+
+Run inside the cms jail:
+
+```sh
+# test config
+nginx -t
+
+# reload after changes
+service nginx reload
+
+# check status
+service nginx status
+```
+
+When testing a named vhost through `127.0.0.1`, include the expected `Host:`
+header. The `cms` jail default server returns `404`, so a plain direct URL can
+be a false negative even when the named vhost works.
+
+```sh
+# clawdie.si landing vhost
+curl -sI -H 'Host: clawdie.si' http://127.0.0.1/sl/
+curl -sI -H 'Host: clawdie.si' http://127.0.0.1/en/
+
+# docs.clawdie.si vhost
+curl -sI -H 'Host: docs.clawdie.si' http://127.0.0.1/architecture/colibri/
+```
+
+Use `curl` for these checks; FreeBSD `fetch` does not provide a simple
+`--header` flag.
+
+If the Strapi cache is enabled, verify cache headers on a known public
+endpoint:
+
+```sh
+curl -sI http://127.0.0.1/ | grep -i x-cache-status
+```
+
+From the host, verify public edge traffic:
+
+```sh
+curl -sI https://clawdie.si/ | head -5
+curl -sI https://clawdie.si/sl/ | head -5
+curl -sI https://docs.clawdie.si/architecture/colibri/ | head -5
+```
+
+## Troubleshooting
+
+### nginx won't start
+
+- Check config: `nginx -t`
+- Check logs: `tail /var/log/nginx/error.log`
+- Check port conflicts: `sockstat -l | grep :80`
+
+### nginx config test fails: "cannot load certificate — BIO_new_file() failed"
+
+This means the `ssl_certificate` path in a vhost references a file that doesn't
+exist yet. **Do not remove the SSL block** — create a placeholder cert first:
+
+```sh
+openssl req -x509 -nodes -days 1 -newkey ec \
+ -pkeyopt ec_paramgen_curve:prime256v1 \
+ -keyout /usr/local/etc/nginx/ssl//.key \
+ -out /usr/local/etc/nginx/ssl//fullchain.cer \
+ -subj "/CN="
+```
+
+Then `nginx -t && service nginx reload` will succeed. Issue the real cert
+with acme.sh — it replaces the placeholder. See §"Adding a new public static
+HTTPS site" for the full flow.
+
+### ACME challenge returns 404 or 301
+
+Two common causes:
+
+1. **The `location /.well-known/acme-challenge/` block is AFTER the
+ `location / { return 301 https://... }` redirect.** Nginx matches locations
+ in order — the redirect wins. Move the well-known block before the redirect.
+2. **The well-known directory doesn't exist on the host.** acme.sh writes
+ challenge files there; if it's missing, nginx returns 404. Create it:
+ `mkdir -p /usr/local/www//.well-known/acme-challenge`
+
+### SSL certificate expired or near expiry
+
+- Check cert dates: `openssl x509 -in /usr/local/etc/nginx/ssl/clawdie/fullchain.cer -noout -dates`
+- Run `just doctor` and inspect `TLS_` plus `ACME_RENEWAL_CRON`
+- Renew or repair acme.sh on the host; do not run public edge renewal inside the cms jail
+
+### Changes not visible
+
+- Static files are served immediately — hard refresh the browser
+- Check you edited the correct webroot path inside the jail
+- Landing changes should land in `/usr/local/www/clawdie-si/`
+- Docs changes should land in `/usr/local/www/clawdie/`
+- For jail-local HTTP checks, include `-H 'Host: clawdie.si'` or the request may hit the default 404 vhost
+
+### Host nginx not proxying to jail
+
+- Verify jail is up: `sudo bastille list | grep cms`
+- Verify jail nginx is running: `sudo bastille cmd cms service nginx status`
+- Test direct jail HTTP: `curl -s -o /dev/null -w "%{http_code}" http://${AGENT_SUBNET_BASE}.4/`
+- Check host nginx proxy config: `nginx -t`
+- Check jail nginx logs: `sudo bastille cmd cms tail /var/log/nginx/error.log`
--
2.45.3
From b867eb9b99666db83625d7ec59115d4e884f9a38 Mon Sep 17 00:00:00 2001
From: Sam & Claude
Date: Fri, 26 Jun 2026 12:18:40 +0200
Subject: [PATCH 12/19] =?UTF-8?q?fix:=20skills=20directory=20structure=20?=
=?UTF-8?q?=E2=80=94=20.agent/skills/=20not=20.agent/?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Skills were copied flat (.agent/*/SKILL.md) instead of nested
(.agent/skills/*/SKILL.md). The ISO import script
(import-colibri-skills.sh) expects the nested structure.
Now matches clawdie-ai canonical layout. 52 SKILL.md files verify.
---
.agent/skills/add-discord/SKILL.md | 230 ++++
.../add/src/channels/discord.test.ts | 762 +++++++++++
.../add-discord/add/src/channels/discord.ts | 236 ++++
.agent/skills/add-discord/manifest.yaml | 20 +
.../skills/add-discord/modify/src/config.ts | 77 ++
.../modify/src/config.ts.intent.md | 25 +
.agent/skills/add-discord/modify/src/index.ts | 509 +++++++
.../add-discord/modify/src/index.ts.intent.md | 50 +
.../add-discord/modify/src/routing.test.ts | 147 +++
.../skills/add-discord/tests/discord.test.ts | 133 ++
.agent/skills/add-gmail/SKILL.md | 244 ++++
.../add-gmail/add/src/channels/gmail.test.ts | 71 +
.../add-gmail/add/src/channels/gmail.ts | 339 +++++
.agent/skills/add-gmail/manifest.yaml | 18 +
.../container/agent-runner/src/index.ts | 703 ++++++++++
.../agent-runner/src/index.ts.intent.md | 37 +
.../add-gmail/modify/src/container-runner.ts | 698 ++++++++++
.../modify/src/container-runner.ts.intent.md | 42 +
.agent/skills/add-gmail/modify/src/index.ts | 507 +++++++
.../add-gmail/modify/src/index.ts.intent.md | 40 +
.../add-gmail/modify/src/routing.test.ts | 119 ++
.agent/skills/add-gmail/tests/gmail.test.ts | 40 +
.agent/skills/add-parallel/SKILL.md | 320 +++++
.agent/skills/add-protonmail/SKILL.md | 85 ++
.../jail/agent-runner/src/protonmail-tools.ts | 72 +
.agent/skills/add-protonmail/manifest.yaml | 17 +
.../jail/agent-runner/package.json.intent.md | 37 +
.../src/ipc-mcp-stdio.ts.intent.md | 36 +
.../modify/src/jail-runner.ts.intent.md | 38 +
.agent/skills/add-slack/SKILL.md | 235 ++++
.agent/skills/add-slack/SLACK_SETUP.md | 156 +++
.../add-slack/add/src/channels/slack.test.ts | 848 ++++++++++++
.../add-slack/add/src/channels/slack.ts | 290 ++++
.agent/skills/add-slack/manifest.yaml | 21 +
.agent/skills/add-slack/modify/src/config.ts | 75 ++
.../add-slack/modify/src/config.ts.intent.md | 25 +
.agent/skills/add-slack/modify/src/index.ts | 498 +++++++
.../add-slack/modify/src/index.ts.intent.md | 70 +
.../add-slack/modify/src/routing.test.ts | 161 +++
.../modify/src/routing.test.ts.intent.md | 21 +
.agent/skills/add-slack/tests/slack.test.ts | 171 +++
.agent/skills/add-stripe/SKILL.md | 84 ++
.../add/jail/agent-runner/src/stripe-tools.ts | 275 ++++
.agent/skills/add-stripe/manifest.yaml | 16 +
.../jail/agent-runner/package.json.intent.md | 28 +
.../src/ipc-mcp-stdio.ts.intent.md | 36 +
.../modify/src/jail-runner.ts.intent.md | 37 +
.agent/skills/add-telegram-swarm/SKILL.md | 388 ++++++
.agent/skills/add-telegram/SKILL.md | 251 ++++
.../add/src/channels/telegram.test.ts | 926 +++++++++++++
.../add-telegram/add/src/channels/telegram.ts | 244 ++++
.agent/skills/add-telegram/manifest.yaml | 20 +
.../skills/add-telegram/modify/src/config.ts | 77 ++
.../modify/src/config.ts.intent.md | 25 +
.../skills/add-telegram/modify/src/index.ts | 509 +++++++
.../modify/src/index.ts.intent.md | 59 +
.../add-telegram/modify/src/routing.test.ts | 161 +++
.../add-telegram/tests/telegram.test.ts | 118 ++
.../skills/add-voice-transcription/SKILL.md | 145 ++
.../add/src/transcription.ts | 98 ++
.../add-voice-transcription/manifest.yaml | 17 +
.../modify/src/channels/whatsapp.test.ts | 963 ++++++++++++++
.../src/channels/whatsapp.test.ts.intent.md | 30 +
.../modify/src/channels/whatsapp.ts | 400 ++++++
.../modify/src/channels/whatsapp.ts.intent.md | 31 +
.../tests/voice-transcription.test.ts | 123 ++
.agent/skills/agent-setup/SKILL.md | 88 ++
.agent/skills/ansible-freebsd/SKILL.md | 306 +++++
.../references/cms-astro-strapi-plan.md | 142 ++
.../references/host-encrypted-dataset.md | 80 ++
.../ansible-freebsd/references/install.md | 41 +
.../ansible-freebsd/references/layout.md | 85 ++
.agent/skills/astro/SKILL.md | 407 ++++++
.agent/skills/astro/references/setup.md | 174 +++
.../astro/references/static-migration.md | 70 +
.agent/skills/backup-db/SKILL.md | 65 +
.agent/skills/coding-agent/SKILL.md | 166 +++
.../add/src/providers/anthropic.ts | 194 +++
.../add/src/providers/coding-agent.ts | 176 +++
.../coding-agent/add/src/providers/gemini.ts | 147 +++
.../coding-agent/add/src/providers/index.ts | 143 ++
.../coding-agent/add/src/providers/openai.ts | 225 ++++
.../add/src/providers/provider.ts | 91 ++
.agent/skills/coding-agent/manifest.yaml | 36 +
.../skills/coding-agent/modify/.env.example | 60 +
.../skills/coding-agent/modify/src/config.ts | 121 ++
.../modify/src/config.ts.intent.md | 72 +
.../modify/src/container-runner.ts | 17 +
.../modify/src/container-runner.ts.intent.md | 195 +++
.../coding-agent/tests/provider.test.ts | 283 ++++
.agent/skills/customize/SKILL.md | 120 ++
.agent/skills/db-analyze/SKILL.md | 45 +
.agent/skills/db-migrate/SKILL.md | 65 +
.agent/skills/db-sync-check/SKILL.md | 58 +
.agent/skills/db-vacuum/SKILL.md | 56 +
.agent/skills/debug/SKILL.md | 282 ++++
.agent/skills/disk-usage/SKILL.md | 61 +
.../skills/docs-deployment/CROWDIN-SETUP.md | 363 +++++
.agent/skills/docs-deployment/INTEGRATION.md | 488 +++++++
.agent/skills/docs-deployment/SKILL.md | 430 ++++++
.../templates/language-selector.html | 209 +++
.../templates/nginx-vhost-template.conf | 142 ++
.../docs-localization-pipeline/README.md | 315 +++++
.../docs-localization-pipeline/SKILL.md | 406 ++++++
.../orchestrate-pipeline.sh | 294 +++++
.../setup-clawdie-docs.sh | 377 ++++++
.agent/skills/freebsd-admin/SKILL.md | 92 ++
.../references/ansible-handoff.md | 42 +
.../references/bhyve-prerequisites.md | 101 ++
.../references/execution-modes.md | 45 +
.../references/freebsd-update-reboot.md | 95 ++
.../freebsd-admin/references/loader-conf.md | 124 ++
.../freebsd-admin/references/locale-setup.md | 95 ++
.../freebsd-admin/references/memory-budget.md | 77 ++
.../references/network-forwarding.md | 33 +
.../freebsd-admin/references/rc-conf.md | 211 +++
.../references/resolver-baseline.md | 56 +
.../references/rollback-patterns.md | 95 ++
.../references/service-identities.md | 72 +
.../references/system-changes.md | 19 +
.../freebsd-admin/references/validation.md | 26 +
.../scripts/render_forwarding_commands.sh | 8 +
.../scripts/render_host_validation.sh | 9 +
.agent/skills/git-branch-protect/SKILL.md | 65 +
.agent/skills/git-merge/SKILL.md | 88 ++
.agent/skills/git-pull/SKILL.md | 84 ++
.agent/skills/git-push-mirror/SKILL.md | 71 +
.agent/skills/git-push-upstream/SKILL.md | 71 +
.agent/skills/git-release-tag/SKILL.md | 73 +
.agent/skills/jail-status/SKILL.md | 53 +
.agent/skills/llama-cpp-embeddings/SKILL.md | 67 +
.agent/skills/network-throughput/SKILL.md | 538 ++++++++
.../references/cache-policy.md | 15 +
.../scripts/render_latest_json.sh | 14 +
.agent/{ => skills}/nginx/SKILL.md | 0
.../nginx/references/astro-migration.md | 88 ++
.../skills/nginx/references/strapi-cache.md | 57 +
.../skills/nginx/references/strapi-proxy.md | 62 +
.../skills/nginx/references/vhost-template.md | 100 ++
.agent/skills/ollama/SKILL.md | 180 +++
.agent/skills/pi-provider-smoke/SKILL.md | 112 ++
.agent/skills/pi-update/SKILL.md | 167 +++
.agent/skills/postgres-memory/SKILL.md | 120 ++
.../postgres-memory/references/install.md | 171 +++
.../postgres-memory/references/layout.md | 65 +
.../postgres-memory/references/security.md | 213 +++
.../references/troubleshooting.md | 82 ++
.../postgres-memory/references/validation.md | 37 +
.../scripts/render_install_commands.sh | 10 +
.../scripts/render_validation_commands.sh | 8 +
.agent/skills/rsync/SKILL.md | 129 ++
.agent/skills/runtime-version-sync/SKILL.md | 206 +++
.agent/skills/sanoid/SKILL.md | 66 +
.../sanoid/references/example-config.md | 26 +
.../skills/sanoid/references/policy-model.md | 174 +++
.../sanoid/references/troubleshooting.md | 13 +
.../sanoid/scripts/render_sanoid_conf.sh | 10 +
.../sanoid/scripts/validate_snapshots.sh | 9 +
.agent/skills/service-restart/SKILL.md | 61 +
.agent/skills/setup/SKILL.md | 179 +++
.../skills/setup/references/prerequisites.md | 138 ++
.agent/skills/ssh-agent-setup/SKILL.md | 204 +++
.agent/skills/strapi/SKILL.md | 223 ++++
.agent/skills/strapi/references/install.md | 164 +++
.agent/skills/strapi/references/migration.md | 77 ++
.agent/skills/system-stats/SKILL.md | 58 +
.agent/skills/telegram-admin/SKILL.md | 39 +
.../references/chat-discovery.md | 21 +
.../telegram-admin/references/registration.md | 17 +
.../telegram-admin/references/token-check.md | 21 +
.../references/troubleshooting.md | 25 +
.../scripts/render_register_command.sh | 10 +
.../scripts/render_verify_commands.sh | 9 +
.agent/skills/tmux-screenshot/SKILL.md | 392 ++++++
.../skills/tmux-screenshot/tmux-screenshot.py | 1171 +++++++++++++++++
.agent/skills/update/SKILL.md | 308 +++++
.../skills/update/scripts/fetch-upstream.sh | 85 ++
.agent/skills/x-integration/SKILL.md | 427 ++++++
.agent/skills/x-integration/agent.ts | 243 ++++
.agent/skills/x-integration/host.ts | 159 +++
.agent/skills/x-integration/lib/browser.ts | 148 +++
.agent/skills/x-integration/lib/config.ts | 62 +
.agent/skills/x-integration/scripts/like.ts | 56 +
.agent/skills/x-integration/scripts/post.ts | 66 +
.agent/skills/x-integration/scripts/quote.ts | 80 ++
.agent/skills/x-integration/scripts/reply.ts | 74 ++
.../skills/x-integration/scripts/retweet.ts | 62 +
.agent/skills/x-integration/scripts/setup.ts | 87 ++
.agent/skills/zfs-scrub/SKILL.md | 130 ++
.agent/skills/zfs-snapshot/SKILL.md | 59 +
190 files changed, 29678 insertions(+)
create mode 100644 .agent/skills/add-discord/SKILL.md
create mode 100644 .agent/skills/add-discord/add/src/channels/discord.test.ts
create mode 100644 .agent/skills/add-discord/add/src/channels/discord.ts
create mode 100644 .agent/skills/add-discord/manifest.yaml
create mode 100644 .agent/skills/add-discord/modify/src/config.ts
create mode 100644 .agent/skills/add-discord/modify/src/config.ts.intent.md
create mode 100644 .agent/skills/add-discord/modify/src/index.ts
create mode 100644 .agent/skills/add-discord/modify/src/index.ts.intent.md
create mode 100644 .agent/skills/add-discord/modify/src/routing.test.ts
create mode 100644 .agent/skills/add-discord/tests/discord.test.ts
create mode 100644 .agent/skills/add-gmail/SKILL.md
create mode 100644 .agent/skills/add-gmail/add/src/channels/gmail.test.ts
create mode 100644 .agent/skills/add-gmail/add/src/channels/gmail.ts
create mode 100644 .agent/skills/add-gmail/manifest.yaml
create mode 100644 .agent/skills/add-gmail/modify/container/agent-runner/src/index.ts
create mode 100644 .agent/skills/add-gmail/modify/container/agent-runner/src/index.ts.intent.md
create mode 100644 .agent/skills/add-gmail/modify/src/container-runner.ts
create mode 100644 .agent/skills/add-gmail/modify/src/container-runner.ts.intent.md
create mode 100644 .agent/skills/add-gmail/modify/src/index.ts
create mode 100644 .agent/skills/add-gmail/modify/src/index.ts.intent.md
create mode 100644 .agent/skills/add-gmail/modify/src/routing.test.ts
create mode 100644 .agent/skills/add-gmail/tests/gmail.test.ts
create mode 100644 .agent/skills/add-parallel/SKILL.md
create mode 100644 .agent/skills/add-protonmail/SKILL.md
create mode 100644 .agent/skills/add-protonmail/add/jail/agent-runner/src/protonmail-tools.ts
create mode 100644 .agent/skills/add-protonmail/manifest.yaml
create mode 100644 .agent/skills/add-protonmail/modify/jail/agent-runner/package.json.intent.md
create mode 100644 .agent/skills/add-protonmail/modify/jail/agent-runner/src/ipc-mcp-stdio.ts.intent.md
create mode 100644 .agent/skills/add-protonmail/modify/src/jail-runner.ts.intent.md
create mode 100644 .agent/skills/add-slack/SKILL.md
create mode 100644 .agent/skills/add-slack/SLACK_SETUP.md
create mode 100644 .agent/skills/add-slack/add/src/channels/slack.test.ts
create mode 100644 .agent/skills/add-slack/add/src/channels/slack.ts
create mode 100644 .agent/skills/add-slack/manifest.yaml
create mode 100644 .agent/skills/add-slack/modify/src/config.ts
create mode 100644 .agent/skills/add-slack/modify/src/config.ts.intent.md
create mode 100644 .agent/skills/add-slack/modify/src/index.ts
create mode 100644 .agent/skills/add-slack/modify/src/index.ts.intent.md
create mode 100644 .agent/skills/add-slack/modify/src/routing.test.ts
create mode 100644 .agent/skills/add-slack/modify/src/routing.test.ts.intent.md
create mode 100644 .agent/skills/add-slack/tests/slack.test.ts
create mode 100644 .agent/skills/add-stripe/SKILL.md
create mode 100644 .agent/skills/add-stripe/add/jail/agent-runner/src/stripe-tools.ts
create mode 100644 .agent/skills/add-stripe/manifest.yaml
create mode 100644 .agent/skills/add-stripe/modify/jail/agent-runner/package.json.intent.md
create mode 100644 .agent/skills/add-stripe/modify/jail/agent-runner/src/ipc-mcp-stdio.ts.intent.md
create mode 100644 .agent/skills/add-stripe/modify/src/jail-runner.ts.intent.md
create mode 100644 .agent/skills/add-telegram-swarm/SKILL.md
create mode 100644 .agent/skills/add-telegram/SKILL.md
create mode 100644 .agent/skills/add-telegram/add/src/channels/telegram.test.ts
create mode 100644 .agent/skills/add-telegram/add/src/channels/telegram.ts
create mode 100644 .agent/skills/add-telegram/manifest.yaml
create mode 100644 .agent/skills/add-telegram/modify/src/config.ts
create mode 100644 .agent/skills/add-telegram/modify/src/config.ts.intent.md
create mode 100644 .agent/skills/add-telegram/modify/src/index.ts
create mode 100644 .agent/skills/add-telegram/modify/src/index.ts.intent.md
create mode 100644 .agent/skills/add-telegram/modify/src/routing.test.ts
create mode 100644 .agent/skills/add-telegram/tests/telegram.test.ts
create mode 100644 .agent/skills/add-voice-transcription/SKILL.md
create mode 100644 .agent/skills/add-voice-transcription/add/src/transcription.ts
create mode 100644 .agent/skills/add-voice-transcription/manifest.yaml
create mode 100644 .agent/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts
create mode 100644 .agent/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts.intent.md
create mode 100644 .agent/skills/add-voice-transcription/modify/src/channels/whatsapp.ts
create mode 100644 .agent/skills/add-voice-transcription/modify/src/channels/whatsapp.ts.intent.md
create mode 100644 .agent/skills/add-voice-transcription/tests/voice-transcription.test.ts
create mode 100644 .agent/skills/agent-setup/SKILL.md
create mode 100644 .agent/skills/ansible-freebsd/SKILL.md
create mode 100644 .agent/skills/ansible-freebsd/references/cms-astro-strapi-plan.md
create mode 100644 .agent/skills/ansible-freebsd/references/host-encrypted-dataset.md
create mode 100644 .agent/skills/ansible-freebsd/references/install.md
create mode 100644 .agent/skills/ansible-freebsd/references/layout.md
create mode 100644 .agent/skills/astro/SKILL.md
create mode 100644 .agent/skills/astro/references/setup.md
create mode 100644 .agent/skills/astro/references/static-migration.md
create mode 100644 .agent/skills/backup-db/SKILL.md
create mode 100644 .agent/skills/coding-agent/SKILL.md
create mode 100644 .agent/skills/coding-agent/add/src/providers/anthropic.ts
create mode 100644 .agent/skills/coding-agent/add/src/providers/coding-agent.ts
create mode 100644 .agent/skills/coding-agent/add/src/providers/gemini.ts
create mode 100644 .agent/skills/coding-agent/add/src/providers/index.ts
create mode 100644 .agent/skills/coding-agent/add/src/providers/openai.ts
create mode 100644 .agent/skills/coding-agent/add/src/providers/provider.ts
create mode 100644 .agent/skills/coding-agent/manifest.yaml
create mode 100644 .agent/skills/coding-agent/modify/.env.example
create mode 100644 .agent/skills/coding-agent/modify/src/config.ts
create mode 100644 .agent/skills/coding-agent/modify/src/config.ts.intent.md
create mode 100644 .agent/skills/coding-agent/modify/src/container-runner.ts
create mode 100644 .agent/skills/coding-agent/modify/src/container-runner.ts.intent.md
create mode 100644 .agent/skills/coding-agent/tests/provider.test.ts
create mode 100644 .agent/skills/customize/SKILL.md
create mode 100644 .agent/skills/db-analyze/SKILL.md
create mode 100644 .agent/skills/db-migrate/SKILL.md
create mode 100644 .agent/skills/db-sync-check/SKILL.md
create mode 100644 .agent/skills/db-vacuum/SKILL.md
create mode 100644 .agent/skills/debug/SKILL.md
create mode 100644 .agent/skills/disk-usage/SKILL.md
create mode 100644 .agent/skills/docs-deployment/CROWDIN-SETUP.md
create mode 100644 .agent/skills/docs-deployment/INTEGRATION.md
create mode 100644 .agent/skills/docs-deployment/SKILL.md
create mode 100644 .agent/skills/docs-deployment/templates/language-selector.html
create mode 100644 .agent/skills/docs-deployment/templates/nginx-vhost-template.conf
create mode 100644 .agent/skills/docs-localization-pipeline/README.md
create mode 100644 .agent/skills/docs-localization-pipeline/SKILL.md
create mode 100755 .agent/skills/docs-localization-pipeline/orchestrate-pipeline.sh
create mode 100755 .agent/skills/docs-localization-pipeline/setup-clawdie-docs.sh
create mode 100644 .agent/skills/freebsd-admin/SKILL.md
create mode 100644 .agent/skills/freebsd-admin/references/ansible-handoff.md
create mode 100644 .agent/skills/freebsd-admin/references/bhyve-prerequisites.md
create mode 100644 .agent/skills/freebsd-admin/references/execution-modes.md
create mode 100644 .agent/skills/freebsd-admin/references/freebsd-update-reboot.md
create mode 100644 .agent/skills/freebsd-admin/references/loader-conf.md
create mode 100644 .agent/skills/freebsd-admin/references/locale-setup.md
create mode 100644 .agent/skills/freebsd-admin/references/memory-budget.md
create mode 100644 .agent/skills/freebsd-admin/references/network-forwarding.md
create mode 100644 .agent/skills/freebsd-admin/references/rc-conf.md
create mode 100644 .agent/skills/freebsd-admin/references/resolver-baseline.md
create mode 100644 .agent/skills/freebsd-admin/references/rollback-patterns.md
create mode 100644 .agent/skills/freebsd-admin/references/service-identities.md
create mode 100644 .agent/skills/freebsd-admin/references/system-changes.md
create mode 100644 .agent/skills/freebsd-admin/references/validation.md
create mode 100755 .agent/skills/freebsd-admin/scripts/render_forwarding_commands.sh
create mode 100755 .agent/skills/freebsd-admin/scripts/render_host_validation.sh
create mode 100644 .agent/skills/git-branch-protect/SKILL.md
create mode 100644 .agent/skills/git-merge/SKILL.md
create mode 100644 .agent/skills/git-pull/SKILL.md
create mode 100644 .agent/skills/git-push-mirror/SKILL.md
create mode 100644 .agent/skills/git-push-upstream/SKILL.md
create mode 100644 .agent/skills/git-release-tag/SKILL.md
create mode 100644 .agent/skills/jail-status/SKILL.md
create mode 100644 .agent/skills/llama-cpp-embeddings/SKILL.md
create mode 100644 .agent/skills/network-throughput/SKILL.md
create mode 100644 .agent/skills/nginx-glasspane/references/cache-policy.md
create mode 100755 .agent/skills/nginx-glasspane/scripts/render_latest_json.sh
rename .agent/{ => skills}/nginx/SKILL.md (100%)
create mode 100644 .agent/skills/nginx/references/astro-migration.md
create mode 100644 .agent/skills/nginx/references/strapi-cache.md
create mode 100644 .agent/skills/nginx/references/strapi-proxy.md
create mode 100644 .agent/skills/nginx/references/vhost-template.md
create mode 100644 .agent/skills/ollama/SKILL.md
create mode 100644 .agent/skills/pi-provider-smoke/SKILL.md
create mode 100644 .agent/skills/pi-update/SKILL.md
create mode 100644 .agent/skills/postgres-memory/SKILL.md
create mode 100644 .agent/skills/postgres-memory/references/install.md
create mode 100644 .agent/skills/postgres-memory/references/layout.md
create mode 100644 .agent/skills/postgres-memory/references/security.md
create mode 100644 .agent/skills/postgres-memory/references/troubleshooting.md
create mode 100644 .agent/skills/postgres-memory/references/validation.md
create mode 100755 .agent/skills/postgres-memory/scripts/render_install_commands.sh
create mode 100755 .agent/skills/postgres-memory/scripts/render_validation_commands.sh
create mode 100644 .agent/skills/rsync/SKILL.md
create mode 100644 .agent/skills/runtime-version-sync/SKILL.md
create mode 100644 .agent/skills/sanoid/SKILL.md
create mode 100644 .agent/skills/sanoid/references/example-config.md
create mode 100644 .agent/skills/sanoid/references/policy-model.md
create mode 100644 .agent/skills/sanoid/references/troubleshooting.md
create mode 100644 .agent/skills/sanoid/scripts/render_sanoid_conf.sh
create mode 100755 .agent/skills/sanoid/scripts/validate_snapshots.sh
create mode 100644 .agent/skills/service-restart/SKILL.md
create mode 100644 .agent/skills/setup/SKILL.md
create mode 100644 .agent/skills/setup/references/prerequisites.md
create mode 100644 .agent/skills/ssh-agent-setup/SKILL.md
create mode 100644 .agent/skills/strapi/SKILL.md
create mode 100644 .agent/skills/strapi/references/install.md
create mode 100644 .agent/skills/strapi/references/migration.md
create mode 100644 .agent/skills/system-stats/SKILL.md
create mode 100644 .agent/skills/telegram-admin/SKILL.md
create mode 100644 .agent/skills/telegram-admin/references/chat-discovery.md
create mode 100644 .agent/skills/telegram-admin/references/registration.md
create mode 100644 .agent/skills/telegram-admin/references/token-check.md
create mode 100644 .agent/skills/telegram-admin/references/troubleshooting.md
create mode 100755 .agent/skills/telegram-admin/scripts/render_register_command.sh
create mode 100755 .agent/skills/telegram-admin/scripts/render_verify_commands.sh
create mode 100644 .agent/skills/tmux-screenshot/SKILL.md
create mode 100644 .agent/skills/tmux-screenshot/tmux-screenshot.py
create mode 100644 .agent/skills/update/SKILL.md
create mode 100755 .agent/skills/update/scripts/fetch-upstream.sh
create mode 100644 .agent/skills/x-integration/SKILL.md
create mode 100644 .agent/skills/x-integration/agent.ts
create mode 100644 .agent/skills/x-integration/host.ts
create mode 100644 .agent/skills/x-integration/lib/browser.ts
create mode 100644 .agent/skills/x-integration/lib/config.ts
create mode 100644 .agent/skills/x-integration/scripts/like.ts
create mode 100644 .agent/skills/x-integration/scripts/post.ts
create mode 100644 .agent/skills/x-integration/scripts/quote.ts
create mode 100644 .agent/skills/x-integration/scripts/reply.ts
create mode 100644 .agent/skills/x-integration/scripts/retweet.ts
create mode 100644 .agent/skills/x-integration/scripts/setup.ts
create mode 100644 .agent/skills/zfs-scrub/SKILL.md
create mode 100644 .agent/skills/zfs-snapshot/SKILL.md
diff --git a/.agent/skills/add-discord/SKILL.md b/.agent/skills/add-discord/SKILL.md
new file mode 100644
index 0000000..96d6dd8
--- /dev/null
+++ b/.agent/skills/add-discord/SKILL.md
@@ -0,0 +1,230 @@
+---
+name: add-discord
+description: Add Discord channel support to Clawdie. Use when the user wants to connect the agent to a Discord server. Covers bot setup, permissions, skill application, and verification.
+---
+
+# Add Discord Channel
+
+This skill adds Discord support to NanoClaw using the skills engine for deterministic code changes, then walks through interactive setup.
+
+## Phase 1: Pre-flight
+
+### Check if already applied
+
+Read `.nanoclaw/state.yaml`. If `discord` is in `applied_skills`, skip to Phase 3 (Setup). The code changes are already in place.
+
+### Ask the user
+
+Use `AskUserQuestion` to collect configuration:
+
+AskUserQuestion: Should Discord replace WhatsApp or run alongside it?
+
+- **Replace WhatsApp** - Discord will be the only channel (sets DISCORD_ONLY=true)
+- **Alongside** - Both Discord and WhatsApp channels active
+
+AskUserQuestion: Do you have a Discord bot token, or do you need to create one?
+
+If they have one, collect it now. If not, we'll create one in Phase 3.
+
+## Phase 2: Apply Code Changes
+
+Run the skills engine to apply this skill's code package. The package files are in this directory alongside this SKILL.md.
+
+### Initialize skills system (if needed)
+
+If `.nanoclaw/` directory doesn't exist yet:
+
+```bash
+npx tsx scripts/apply-skill.ts --init
+```
+
+Or call `initSkillsSystem()` from `skills-engine/migrate.ts`.
+
+### Apply the skill
+
+```bash
+npx tsx scripts/apply-skill.ts .agent/skills/add-discord
+```
+
+This deterministically:
+
+- Adds `src/channels/discord.ts` (DiscordChannel class implementing Channel interface)
+- Adds `src/channels/discord.test.ts` (unit tests with discord.js mock)
+- Three-way merges Discord support into `src/index.ts` (multi-channel support, findChannel routing)
+- Three-way merges Discord config into `src/config.ts` (DISCORD_BOT_TOKEN, DISCORD_ONLY exports)
+- Three-way merges updated routing tests into `src/routing.test.ts`
+- Installs the `discord.js` npm dependency
+- Updates `.env.example` with `DISCORD_BOT_TOKEN` and `DISCORD_ONLY`
+- Records the application in `.nanoclaw/state.yaml`
+
+If the apply reports merge conflicts, read the intent files:
+
+- `modify/src/index.ts.intent.md` — what changed and invariants for index.ts
+- `modify/src/config.ts.intent.md` — what changed for config.ts
+
+### Validate code changes
+
+```bash
+npm test
+npm run build
+```
+
+All tests must pass (including the new Discord tests) and build must be clean before proceeding.
+
+## Phase 3: Setup
+
+### Create Discord Bot (if needed)
+
+If the user doesn't have a bot token, tell them:
+
+> I need you to create a Discord bot:
+>
+> 1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
+> 2. Click **New Application** and give it a name (e.g., "Andy Assistant")
+> 3. Go to the **Bot** tab on the left sidebar
+> 4. Click **Reset Token** to generate a new bot token — copy it immediately (you can only see it once)
+> 5. Under **Privileged Gateway Intents**, enable:
+> - **Message Content Intent** (required to read message text)
+> - **Server Members Intent** (optional, for member display names)
+> 6. Go to **OAuth2** > **URL Generator**:
+> - Scopes: select `bot`
+> - Bot Permissions: select `Send Messages`, `Read Message History`, `View Channels`
+> - Copy the generated URL and open it in your browser to invite the bot to your server
+
+Wait for the user to provide the token.
+
+### Configure environment
+
+Add to `.env`:
+
+```bash
+DISCORD_BOT_TOKEN=
+```
+
+If they chose to replace WhatsApp:
+
+```bash
+DISCORD_ONLY=true
+```
+
+Sync to container environment:
+
+```bash
+cp .env data/env/env
+```
+
+The container reads environment from `data/env/env`, not `.env` directly.
+
+### Build and restart
+
+```bash
+npm run build
+launchctl kickstart -k gui/$(id -u)/com.nanoclaw
+```
+
+## Phase 4: Registration
+
+### Get Channel ID
+
+Tell the user:
+
+> To get the channel ID for registration:
+>
+> 1. In Discord, go to **User Settings** > **Advanced** > Enable **Developer Mode**
+> 2. Right-click the text channel you want the bot to respond in
+> 3. Click **Copy Channel ID**
+>
+> The channel ID will be a long number like `1234567890123456`.
+
+Wait for the user to provide the channel ID (format: `dc:1234567890123456`).
+
+### Register the channel
+
+Use the IPC register flow or register directly. The channel ID, name, and folder name are needed.
+
+For a main channel (responds to all messages, uses the `main` folder):
+
+```typescript
+registerGroup('dc:', {
+ name: ' #',
+ folder: 'main',
+ trigger: `@${ASSISTANT_NAME}`,
+ added_at: new Date().toISOString(),
+ requiresTrigger: false,
+});
+```
+
+For additional channels (trigger-only):
+
+```typescript
+registerGroup('dc:', {
+ name: ' #',
+ folder: '',
+ trigger: `@${ASSISTANT_NAME}`,
+ added_at: new Date().toISOString(),
+ requiresTrigger: true,
+});
+```
+
+## Phase 5: Verify
+
+### Test the connection
+
+Tell the user:
+
+> Send a message in your registered Discord channel:
+>
+> - For main channel: Any message works
+> - For non-main: @mention the bot in Discord
+>
+> The bot should respond within a few seconds.
+
+### Check logs if needed
+
+```bash
+tail -f logs/nanoclaw.log
+```
+
+## Troubleshooting
+
+### Bot not responding
+
+1. Check `DISCORD_BOT_TOKEN` is set in `.env` AND synced to `data/env/env`
+2. Check channel is registered: `psql "$OPS_DB_URL" -c "SELECT * FROM registered_groups WHERE jid LIKE 'dc:%'"`
+3. For non-main channels: message must include trigger pattern (@mention the bot)
+4. Service is running: `launchctl list | grep nanoclaw`
+5. Verify the bot has been invited to the server (check OAuth2 URL was used)
+
+### Bot only responds to @mentions
+
+This is the default behavior for non-main channels (`requiresTrigger: true`). To change:
+
+- Update the registered group's `requiresTrigger` to `false`
+- Or register the channel as the main channel
+
+### Message Content Intent not enabled
+
+If the bot connects but can't read messages, ensure:
+
+1. Go to [Discord Developer Portal](https://discord.com/developers/applications)
+2. Select your application > **Bot** tab
+3. Under **Privileged Gateway Intents**, enable **Message Content Intent**
+4. Restart NanoClaw
+
+### Getting Channel ID
+
+If you can't copy the channel ID:
+
+- Ensure **Developer Mode** is enabled: User Settings > Advanced > Developer Mode
+- Right-click the channel name in the server sidebar > Copy Channel ID
+
+## After Setup
+
+The Discord bot supports:
+
+- Text messages in registered channels
+- Attachment descriptions (images, videos, files shown as placeholders)
+- Reply context (shows who the user is replying to)
+- @mention translation (Discord `<@botId>` → NanoClaw trigger format)
+- Message splitting for responses over 2000 characters
+- Typing indicators while the agent processes
diff --git a/.agent/skills/add-discord/add/src/channels/discord.test.ts b/.agent/skills/add-discord/add/src/channels/discord.test.ts
new file mode 100644
index 0000000..eff0b77
--- /dev/null
+++ b/.agent/skills/add-discord/add/src/channels/discord.test.ts
@@ -0,0 +1,762 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+
+// --- Mocks ---
+
+// Mock config
+vi.mock('../config.js', () => ({
+ ASSISTANT_NAME: 'Andy',
+ TRIGGER_PATTERN: /^@Andy\b/i,
+}));
+
+// Mock logger
+vi.mock('../logger.js', () => ({
+ logger: {
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+// --- discord.js mock ---
+
+type Handler = (...args: any[]) => any;
+
+const clientRef = vi.hoisted(() => ({ current: null as any }));
+
+vi.mock('discord.js', () => {
+ const Events = {
+ MessageCreate: 'messageCreate',
+ ClientReady: 'ready',
+ Error: 'error',
+ };
+
+ const GatewayIntentBits = {
+ Guilds: 1,
+ GuildMessages: 2,
+ MessageContent: 4,
+ DirectMessages: 8,
+ };
+
+ class MockClient {
+ eventHandlers = new Map();
+ user: any = { id: '999888777', tag: 'Andy#1234' };
+ private _ready = false;
+
+ constructor(_opts: any) {
+ clientRef.current = this;
+ }
+
+ on(event: string, handler: Handler) {
+ const existing = this.eventHandlers.get(event) || [];
+ existing.push(handler);
+ this.eventHandlers.set(event, existing);
+ return this;
+ }
+
+ once(event: string, handler: Handler) {
+ return this.on(event, handler);
+ }
+
+ async login(_token: string) {
+ this._ready = true;
+ // Fire the ready event
+ const readyHandlers = this.eventHandlers.get('ready') || [];
+ for (const h of readyHandlers) {
+ h({ user: this.user });
+ }
+ }
+
+ isReady() {
+ return this._ready;
+ }
+
+ channels = {
+ fetch: vi.fn().mockResolvedValue({
+ send: vi.fn().mockResolvedValue(undefined),
+ sendTyping: vi.fn().mockResolvedValue(undefined),
+ }),
+ };
+
+ destroy() {
+ this._ready = false;
+ }
+ }
+
+ // Mock TextChannel type
+ class TextChannel {}
+
+ return {
+ Client: MockClient,
+ Events,
+ GatewayIntentBits,
+ TextChannel,
+ };
+});
+
+import { DiscordChannel, DiscordChannelOpts } from './discord.js';
+
+// --- Test helpers ---
+
+function createTestOpts(
+ overrides?: Partial,
+): DiscordChannelOpts {
+ return {
+ onMessage: vi.fn(),
+ onChatMetadata: vi.fn(),
+ registeredGroups: vi.fn(() => ({
+ 'dc:1234567890123456': {
+ name: 'Test Server #general',
+ folder: 'test-server',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ },
+ })),
+ ...overrides,
+ };
+}
+
+function createMessage(overrides: {
+ channelId?: string;
+ content?: string;
+ authorId?: string;
+ authorUsername?: string;
+ authorDisplayName?: string;
+ memberDisplayName?: string;
+ isBot?: boolean;
+ guildName?: string;
+ channelName?: string;
+ messageId?: string;
+ createdAt?: Date;
+ attachments?: Map;
+ reference?: { messageId?: string };
+ mentionsBotId?: boolean;
+}) {
+ const channelId = overrides.channelId ?? '1234567890123456';
+ const authorId = overrides.authorId ?? '55512345';
+ const botId = '999888777'; // matches mock client user id
+
+ const mentionsMap = new Map();
+ if (overrides.mentionsBotId) {
+ mentionsMap.set(botId, { id: botId });
+ }
+
+ return {
+ channelId,
+ id: overrides.messageId ?? 'msg_001',
+ content: overrides.content ?? 'Hello everyone',
+ createdAt: overrides.createdAt ?? new Date('2024-01-01T00:00:00.000Z'),
+ author: {
+ id: authorId,
+ username: overrides.authorUsername ?? 'alice',
+ displayName: overrides.authorDisplayName ?? 'Alice',
+ bot: overrides.isBot ?? false,
+ },
+ member: overrides.memberDisplayName
+ ? { displayName: overrides.memberDisplayName }
+ : null,
+ guild: overrides.guildName
+ ? { name: overrides.guildName }
+ : null,
+ channel: {
+ name: overrides.channelName ?? 'general',
+ messages: {
+ fetch: vi.fn().mockResolvedValue({
+ author: { username: 'Bob', displayName: 'Bob' },
+ member: { displayName: 'Bob' },
+ }),
+ },
+ },
+ mentions: {
+ users: mentionsMap,
+ },
+ attachments: overrides.attachments ?? new Map(),
+ reference: overrides.reference ?? null,
+ };
+}
+
+function currentClient() {
+ return clientRef.current;
+}
+
+async function triggerMessage(message: any) {
+ const handlers = currentClient().eventHandlers.get('messageCreate') || [];
+ for (const h of handlers) await h(message);
+}
+
+// --- Tests ---
+
+describe('DiscordChannel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ // --- Connection lifecycle ---
+
+ describe('connection lifecycle', () => {
+ it('resolves connect() when client is ready', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+
+ await channel.connect();
+
+ expect(channel.isConnected()).toBe(true);
+ });
+
+ it('registers message handlers on connect', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+
+ await channel.connect();
+
+ expect(currentClient().eventHandlers.has('messageCreate')).toBe(true);
+ expect(currentClient().eventHandlers.has('error')).toBe(true);
+ expect(currentClient().eventHandlers.has('ready')).toBe(true);
+ });
+
+ it('disconnects cleanly', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+
+ await channel.connect();
+ expect(channel.isConnected()).toBe(true);
+
+ await channel.disconnect();
+ expect(channel.isConnected()).toBe(false);
+ });
+
+ it('isConnected() returns false before connect', () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+
+ expect(channel.isConnected()).toBe(false);
+ });
+ });
+
+ // --- Text message handling ---
+
+ describe('text message handling', () => {
+ it('delivers message for registered channel', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ content: 'Hello everyone',
+ guildName: 'Test Server',
+ channelName: 'general',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.any(String),
+ 'Test Server #general',
+ );
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ id: 'msg_001',
+ chat_jid: 'dc:1234567890123456',
+ sender: '55512345',
+ sender_name: 'Alice',
+ content: 'Hello everyone',
+ is_from_me: false,
+ }),
+ );
+ });
+
+ it('only emits metadata for unregistered channels', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ channelId: '9999999999999999',
+ content: 'Unknown channel',
+ guildName: 'Other Server',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'dc:9999999999999999',
+ expect.any(String),
+ expect.any(String),
+ );
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ });
+
+ it('ignores bot messages', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({ isBot: true, content: 'I am a bot' });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ expect(opts.onChatMetadata).not.toHaveBeenCalled();
+ });
+
+ it('uses member displayName when available (server nickname)', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ content: 'Hi',
+ memberDisplayName: 'Alice Nickname',
+ authorDisplayName: 'Alice Global',
+ guildName: 'Server',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({ sender_name: 'Alice Nickname' }),
+ );
+ });
+
+ it('falls back to author displayName when no member', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ content: 'Hi',
+ memberDisplayName: undefined,
+ authorDisplayName: 'Alice Global',
+ guildName: 'Server',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({ sender_name: 'Alice Global' }),
+ );
+ });
+
+ it('uses sender name for DM chats (no guild)', async () => {
+ const opts = createTestOpts({
+ registeredGroups: vi.fn(() => ({
+ 'dc:1234567890123456': {
+ name: 'DM',
+ folder: 'dm',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ },
+ })),
+ });
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ content: 'Hello',
+ guildName: undefined,
+ authorDisplayName: 'Alice',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.any(String),
+ 'Alice',
+ );
+ });
+
+ it('uses guild name + channel name for server messages', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ content: 'Hello',
+ guildName: 'My Server',
+ channelName: 'bot-chat',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.any(String),
+ 'My Server #bot-chat',
+ );
+ });
+ });
+
+ // --- @mention translation ---
+
+ describe('@mention translation', () => {
+ it('translates <@botId> mention to trigger format', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ content: '<@999888777> what time is it?',
+ mentionsBotId: true,
+ guildName: 'Server',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ content: '@Andy what time is it?',
+ }),
+ );
+ });
+
+ it('does not translate if message already matches trigger', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ content: '@Andy hello <@999888777>',
+ mentionsBotId: true,
+ guildName: 'Server',
+ });
+ await triggerMessage(msg);
+
+ // Should NOT prepend @Andy — already starts with trigger
+ // But the <@botId> should still be stripped
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ content: '@Andy hello',
+ }),
+ );
+ });
+
+ it('does not translate when bot is not mentioned', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ content: 'hello everyone',
+ guildName: 'Server',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ content: 'hello everyone',
+ }),
+ );
+ });
+
+ it('handles <@!botId> (nickname mention format)', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ content: '<@!999888777> check this',
+ mentionsBotId: true,
+ guildName: 'Server',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ content: '@Andy check this',
+ }),
+ );
+ });
+ });
+
+ // --- Attachments ---
+
+ describe('attachments', () => {
+ it('stores image attachment with placeholder', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const attachments = new Map([
+ ['att1', { name: 'photo.png', contentType: 'image/png' }],
+ ]);
+ const msg = createMessage({
+ content: '',
+ attachments,
+ guildName: 'Server',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ content: '[Image: photo.png]',
+ }),
+ );
+ });
+
+ it('stores video attachment with placeholder', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const attachments = new Map([
+ ['att1', { name: 'clip.mp4', contentType: 'video/mp4' }],
+ ]);
+ const msg = createMessage({
+ content: '',
+ attachments,
+ guildName: 'Server',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ content: '[Video: clip.mp4]',
+ }),
+ );
+ });
+
+ it('stores file attachment with placeholder', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const attachments = new Map([
+ ['att1', { name: 'report.pdf', contentType: 'application/pdf' }],
+ ]);
+ const msg = createMessage({
+ content: '',
+ attachments,
+ guildName: 'Server',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ content: '[File: report.pdf]',
+ }),
+ );
+ });
+
+ it('includes text content with attachments', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const attachments = new Map([
+ ['att1', { name: 'photo.jpg', contentType: 'image/jpeg' }],
+ ]);
+ const msg = createMessage({
+ content: 'Check this out',
+ attachments,
+ guildName: 'Server',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ content: 'Check this out\n[Image: photo.jpg]',
+ }),
+ );
+ });
+
+ it('handles multiple attachments', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const attachments = new Map([
+ ['att1', { name: 'a.png', contentType: 'image/png' }],
+ ['att2', { name: 'b.txt', contentType: 'text/plain' }],
+ ]);
+ const msg = createMessage({
+ content: '',
+ attachments,
+ guildName: 'Server',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ content: '[Image: a.png]\n[File: b.txt]',
+ }),
+ );
+ });
+ });
+
+ // --- Reply context ---
+
+ describe('reply context', () => {
+ it('includes reply author in content', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ content: 'I agree with that',
+ reference: { messageId: 'original_msg_id' },
+ guildName: 'Server',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ content: '[Reply to Bob] I agree with that',
+ }),
+ );
+ });
+ });
+
+ // --- sendMessage ---
+
+ describe('sendMessage', () => {
+ it('sends message via channel', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ await channel.sendMessage('dc:1234567890123456', 'Hello');
+
+ const fetchedChannel = await currentClient().channels.fetch('1234567890123456');
+ expect(currentClient().channels.fetch).toHaveBeenCalledWith('1234567890123456');
+ });
+
+ it('strips dc: prefix from JID', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ await channel.sendMessage('dc:9876543210', 'Test');
+
+ expect(currentClient().channels.fetch).toHaveBeenCalledWith('9876543210');
+ });
+
+ it('handles send failure gracefully', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ currentClient().channels.fetch.mockRejectedValueOnce(
+ new Error('Channel not found'),
+ );
+
+ // Should not throw
+ await expect(
+ channel.sendMessage('dc:1234567890123456', 'Will fail'),
+ ).resolves.toBeUndefined();
+ });
+
+ it('does nothing when client is not initialized', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+
+ // Don't connect — client is null
+ await channel.sendMessage('dc:1234567890123456', 'No client');
+
+ // No error, no API call
+ });
+
+ it('splits messages exceeding 2000 characters', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const mockChannel = {
+ send: vi.fn().mockResolvedValue(undefined),
+ sendTyping: vi.fn(),
+ };
+ currentClient().channels.fetch.mockResolvedValue(mockChannel);
+
+ const longText = 'x'.repeat(3000);
+ await channel.sendMessage('dc:1234567890123456', longText);
+
+ expect(mockChannel.send).toHaveBeenCalledTimes(2);
+ expect(mockChannel.send).toHaveBeenNthCalledWith(1, 'x'.repeat(2000));
+ expect(mockChannel.send).toHaveBeenNthCalledWith(2, 'x'.repeat(1000));
+ });
+ });
+
+ // --- ownsJid ---
+
+ describe('ownsJid', () => {
+ it('owns dc: JIDs', () => {
+ const channel = new DiscordChannel('test-token', createTestOpts());
+ expect(channel.ownsJid('dc:1234567890123456')).toBe(true);
+ });
+
+ it('does not own WhatsApp group JIDs', () => {
+ const channel = new DiscordChannel('test-token', createTestOpts());
+ expect(channel.ownsJid('12345@g.us')).toBe(false);
+ });
+
+ it('does not own Telegram JIDs', () => {
+ const channel = new DiscordChannel('test-token', createTestOpts());
+ expect(channel.ownsJid('tg:123456789')).toBe(false);
+ });
+
+ it('does not own unknown JID formats', () => {
+ const channel = new DiscordChannel('test-token', createTestOpts());
+ expect(channel.ownsJid('random-string')).toBe(false);
+ });
+ });
+
+ // --- setTyping ---
+
+ describe('setTyping', () => {
+ it('sends typing indicator when isTyping is true', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const mockChannel = {
+ send: vi.fn(),
+ sendTyping: vi.fn().mockResolvedValue(undefined),
+ };
+ currentClient().channels.fetch.mockResolvedValue(mockChannel);
+
+ await channel.setTyping('dc:1234567890123456', true);
+
+ expect(mockChannel.sendTyping).toHaveBeenCalled();
+ });
+
+ it('does nothing when isTyping is false', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ await channel.setTyping('dc:1234567890123456', false);
+
+ // channels.fetch should NOT be called
+ expect(currentClient().channels.fetch).not.toHaveBeenCalled();
+ });
+
+ it('does nothing when client is not initialized', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+
+ // Don't connect
+ await channel.setTyping('dc:1234567890123456', true);
+
+ // No error
+ });
+ });
+
+ // --- Channel properties ---
+
+ describe('channel properties', () => {
+ it('has name "discord"', () => {
+ const channel = new DiscordChannel('test-token', createTestOpts());
+ expect(channel.name).toBe('discord');
+ });
+ });
+});
diff --git a/.agent/skills/add-discord/add/src/channels/discord.ts b/.agent/skills/add-discord/add/src/channels/discord.ts
new file mode 100644
index 0000000..997d489
--- /dev/null
+++ b/.agent/skills/add-discord/add/src/channels/discord.ts
@@ -0,0 +1,236 @@
+import { Client, Events, GatewayIntentBits, Message, TextChannel } from 'discord.js';
+
+import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
+import { logger } from '../logger.js';
+import {
+ Channel,
+ OnChatMetadata,
+ OnInboundMessage,
+ RegisteredGroup,
+} from '../types.js';
+
+export interface DiscordChannelOpts {
+ onMessage: OnInboundMessage;
+ onChatMetadata: OnChatMetadata;
+ registeredGroups: () => Record;
+}
+
+export class DiscordChannel implements Channel {
+ name = 'discord';
+
+ private client: Client | null = null;
+ private opts: DiscordChannelOpts;
+ private botToken: string;
+
+ constructor(botToken: string, opts: DiscordChannelOpts) {
+ this.botToken = botToken;
+ this.opts = opts;
+ }
+
+ async connect(): Promise {
+ this.client = new Client({
+ intents: [
+ GatewayIntentBits.Guilds,
+ GatewayIntentBits.GuildMessages,
+ GatewayIntentBits.MessageContent,
+ GatewayIntentBits.DirectMessages,
+ ],
+ });
+
+ this.client.on(Events.MessageCreate, async (message: Message) => {
+ // Ignore bot messages (including own)
+ if (message.author.bot) return;
+
+ const channelId = message.channelId;
+ const chatJid = `dc:${channelId}`;
+ let content = message.content;
+ const timestamp = message.createdAt.toISOString();
+ const senderName =
+ message.member?.displayName ||
+ message.author.displayName ||
+ message.author.username;
+ const sender = message.author.id;
+ const msgId = message.id;
+
+ // Determine chat name
+ let chatName: string;
+ if (message.guild) {
+ const textChannel = message.channel as TextChannel;
+ chatName = `${message.guild.name} #${textChannel.name}`;
+ } else {
+ chatName = senderName;
+ }
+
+ // Translate Discord @bot mentions into TRIGGER_PATTERN format.
+ // Discord mentions look like <@botUserId> — these won't match
+ // TRIGGER_PATTERN (e.g., ^@Andy\b), so we prepend the trigger
+ // when the bot is @mentioned.
+ if (this.client?.user) {
+ const botId = this.client.user.id;
+ const isBotMentioned =
+ message.mentions.users.has(botId) ||
+ content.includes(`<@${botId}>`) ||
+ content.includes(`<@!${botId}>`);
+
+ if (isBotMentioned) {
+ // Strip the <@botId> mention to avoid visual clutter
+ content = content
+ .replace(new RegExp(`<@!?${botId}>`, 'g'), '')
+ .trim();
+ // Prepend trigger if not already present
+ if (!TRIGGER_PATTERN.test(content)) {
+ content = `@${ASSISTANT_NAME} ${content}`;
+ }
+ }
+ }
+
+ // Handle attachments — store placeholders so the agent knows something was sent
+ if (message.attachments.size > 0) {
+ const attachmentDescriptions = [...message.attachments.values()].map((att) => {
+ const contentType = att.contentType || '';
+ if (contentType.startsWith('image/')) {
+ return `[Image: ${att.name || 'image'}]`;
+ } else if (contentType.startsWith('video/')) {
+ return `[Video: ${att.name || 'video'}]`;
+ } else if (contentType.startsWith('audio/')) {
+ return `[Audio: ${att.name || 'audio'}]`;
+ } else {
+ return `[File: ${att.name || 'file'}]`;
+ }
+ });
+ if (content) {
+ content = `${content}\n${attachmentDescriptions.join('\n')}`;
+ } else {
+ content = attachmentDescriptions.join('\n');
+ }
+ }
+
+ // Handle reply context — include who the user is replying to
+ if (message.reference?.messageId) {
+ try {
+ const repliedTo = await message.channel.messages.fetch(
+ message.reference.messageId,
+ );
+ const replyAuthor =
+ repliedTo.member?.displayName ||
+ repliedTo.author.displayName ||
+ repliedTo.author.username;
+ content = `[Reply to ${replyAuthor}] ${content}`;
+ } catch {
+ // Referenced message may have been deleted
+ }
+ }
+
+ // Store chat metadata for discovery
+ this.opts.onChatMetadata(chatJid, timestamp, chatName);
+
+ // Only deliver full message for registered groups
+ const group = this.opts.registeredGroups()[chatJid];
+ if (!group) {
+ logger.debug(
+ { chatJid, chatName },
+ 'Message from unregistered Discord channel',
+ );
+ return;
+ }
+
+ // Deliver message — startMessageLoop() will pick it up
+ this.opts.onMessage(chatJid, {
+ id: msgId,
+ chat_jid: chatJid,
+ sender,
+ sender_name: senderName,
+ content,
+ timestamp,
+ is_from_me: false,
+ });
+
+ logger.info(
+ { chatJid, chatName, sender: senderName },
+ 'Discord message stored',
+ );
+ });
+
+ // Handle errors gracefully
+ this.client.on(Events.Error, (err) => {
+ logger.error({ err: err.message }, 'Discord client error');
+ });
+
+ return new Promise((resolve) => {
+ this.client!.once(Events.ClientReady, (readyClient) => {
+ logger.info(
+ { username: readyClient.user.tag, id: readyClient.user.id },
+ 'Discord bot connected',
+ );
+ console.log(`\n Discord bot: ${readyClient.user.tag}`);
+ console.log(
+ ` Use /chatid command or check channel IDs in Discord settings\n`,
+ );
+ resolve();
+ });
+
+ this.client!.login(this.botToken);
+ });
+ }
+
+ async sendMessage(jid: string, text: string): Promise {
+ if (!this.client) {
+ logger.warn('Discord client not initialized');
+ return;
+ }
+
+ try {
+ const channelId = jid.replace(/^dc:/, '');
+ const channel = await this.client.channels.fetch(channelId);
+
+ if (!channel || !('send' in channel)) {
+ logger.warn({ jid }, 'Discord channel not found or not text-based');
+ return;
+ }
+
+ const textChannel = channel as TextChannel;
+
+ // Discord has a 2000 character limit per message — split if needed
+ const MAX_LENGTH = 2000;
+ if (text.length <= MAX_LENGTH) {
+ await textChannel.send(text);
+ } else {
+ for (let i = 0; i < text.length; i += MAX_LENGTH) {
+ await textChannel.send(text.slice(i, i + MAX_LENGTH));
+ }
+ }
+ logger.info({ jid, length: text.length }, 'Discord message sent');
+ } catch (err) {
+ logger.error({ jid, err }, 'Failed to send Discord message');
+ }
+ }
+
+ isConnected(): boolean {
+ return this.client !== null && this.client.isReady();
+ }
+
+ ownsJid(jid: string): boolean {
+ return jid.startsWith('dc:');
+ }
+
+ async disconnect(): Promise {
+ if (this.client) {
+ this.client.destroy();
+ this.client = null;
+ logger.info('Discord bot stopped');
+ }
+ }
+
+ async setTyping(jid: string, isTyping: boolean): Promise {
+ if (!this.client || !isTyping) return;
+ try {
+ const channelId = jid.replace(/^dc:/, '');
+ const channel = await this.client.channels.fetch(channelId);
+ if (channel && 'sendTyping' in channel) {
+ await (channel as TextChannel).sendTyping();
+ }
+ } catch (err) {
+ logger.debug({ jid, err }, 'Failed to send Discord typing indicator');
+ }
+ }
+}
diff --git a/.agent/skills/add-discord/manifest.yaml b/.agent/skills/add-discord/manifest.yaml
new file mode 100644
index 0000000..f2cf2c8
--- /dev/null
+++ b/.agent/skills/add-discord/manifest.yaml
@@ -0,0 +1,20 @@
+skill: discord
+version: 1.0.0
+description: "Discord Bot integration via discord.js"
+core_version: 0.1.0
+adds:
+ - src/channels/discord.ts
+ - src/channels/discord.test.ts
+modifies:
+ - src/index.ts
+ - src/config.ts
+ - src/routing.test.ts
+structured:
+ npm_dependencies:
+ discord.js: "^14.18.0"
+ env_additions:
+ - DISCORD_BOT_TOKEN
+ - DISCORD_ONLY
+conflicts: []
+depends: []
+test: "npx vitest run src/channels/discord.test.ts"
diff --git a/.agent/skills/add-discord/modify/src/config.ts b/.agent/skills/add-discord/modify/src/config.ts
new file mode 100644
index 0000000..41cc2ce
--- /dev/null
+++ b/.agent/skills/add-discord/modify/src/config.ts
@@ -0,0 +1,77 @@
+import os from 'os';
+import path from 'path';
+
+import { readEnvFile } from './env.js';
+
+// Read config values from .env (falls back to process.env).
+// Secrets are NOT read here — they stay on disk and are loaded only
+// where needed (container-runner.ts) to avoid leaking to child processes.
+const envConfig = readEnvFile([
+ 'ASSISTANT_NAME',
+ 'ASSISTANT_HAS_OWN_NUMBER',
+ 'DISCORD_BOT_TOKEN',
+ 'DISCORD_ONLY',
+]);
+
+export const ASSISTANT_NAME =
+ process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
+export const ASSISTANT_HAS_OWN_NUMBER =
+ (process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
+export const POLL_INTERVAL = 2000;
+export const SCHEDULER_POLL_INTERVAL = 60000;
+
+// Absolute paths needed for container mounts
+const PROJECT_ROOT = process.cwd();
+const HOME_DIR = process.env.HOME || os.homedir();
+
+// Mount security: allowlist stored OUTSIDE project root, never mounted into containers
+export const MOUNT_ALLOWLIST_PATH = path.join(
+ HOME_DIR,
+ '.config',
+ (process.env.AGENT_NAME || 'clawdie') + '-cp',
+ 'mount-allowlist.json',
+);
+export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
+export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
+export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
+export const MAIN_GROUP_FOLDER = 'main';
+
+export const CONTAINER_IMAGE =
+ process.env.CONTAINER_IMAGE || (process.env.AGENT_NAME || 'clawdie') + '-cp-agent:latest';
+export const CONTAINER_TIMEOUT = parseInt(
+ process.env.CONTAINER_TIMEOUT || '1800000',
+ 10,
+);
+export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
+ process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
+ 10,
+); // 10MB default
+export const IPC_POLL_INTERVAL = 1000;
+export const IDLE_TIMEOUT = parseInt(
+ process.env.IDLE_TIMEOUT || '1800000',
+ 10,
+); // 30min default — how long to keep container alive after last result
+export const MAX_CONCURRENT_CONTAINERS = Math.max(
+ 1,
+ parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5,
+);
+
+function escapeRegex(str: string): string {
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+export const TRIGGER_PATTERN = new RegExp(
+ `^@${escapeRegex(ASSISTANT_NAME)}\\b`,
+ 'i',
+);
+
+// Timezone for scheduled tasks (cron expressions, etc.)
+// Uses system timezone by default
+export const TIMEZONE =
+ process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+// Discord configuration
+export const DISCORD_BOT_TOKEN =
+ process.env.DISCORD_BOT_TOKEN || envConfig.DISCORD_BOT_TOKEN || '';
+export const DISCORD_ONLY =
+ (process.env.DISCORD_ONLY || envConfig.DISCORD_ONLY) === 'true';
diff --git a/.agent/skills/add-discord/modify/src/config.ts.intent.md b/.agent/skills/add-discord/modify/src/config.ts.intent.md
new file mode 100644
index 0000000..624d4c7
--- /dev/null
+++ b/.agent/skills/add-discord/modify/src/config.ts.intent.md
@@ -0,0 +1,25 @@
+# Intent: src/config.ts modifications
+
+## What changed
+
+Added two new configuration exports for Discord channel support.
+
+## Key sections
+
+- **readEnvFile call**: Must include `DISCORD_BOT_TOKEN` and `DISCORD_ONLY` in the keys array. NanoClaw does NOT load `.env` into `process.env` — all `.env` values must be explicitly requested via `readEnvFile()`.
+- **DISCORD_BOT_TOKEN**: Read from `process.env` first, then `envConfig` fallback, defaults to empty string (channel disabled when empty)
+- **DISCORD_ONLY**: Boolean flag from `process.env` or `envConfig`, when `true` disables WhatsApp channel creation
+
+## Invariants
+
+- All existing config exports remain unchanged
+- New Discord keys are added to the `readEnvFile` call alongside existing keys
+- New exports are appended at the end of the file
+- No existing behavior is modified — Discord config is additive only
+- Both `process.env` and `envConfig` are checked (same pattern as `ASSISTANT_NAME`)
+
+## Must-keep
+
+- All existing exports (`ASSISTANT_NAME`, `POLL_INTERVAL`, `TRIGGER_PATTERN`, etc.)
+- The `readEnvFile` pattern — ALL config read from `.env` must go through this function
+- The `escapeRegex` helper and `TRIGGER_PATTERN` construction
diff --git a/.agent/skills/add-discord/modify/src/index.ts b/.agent/skills/add-discord/modify/src/index.ts
new file mode 100644
index 0000000..4b6f30e
--- /dev/null
+++ b/.agent/skills/add-discord/modify/src/index.ts
@@ -0,0 +1,509 @@
+import fs from 'fs';
+import path from 'path';
+
+import {
+ ASSISTANT_NAME,
+ DISCORD_BOT_TOKEN,
+ DISCORD_ONLY,
+ IDLE_TIMEOUT,
+ MAIN_GROUP_FOLDER,
+ POLL_INTERVAL,
+ TRIGGER_PATTERN,
+} from './config.js';
+import { DiscordChannel } from './channels/discord.js';
+import { WhatsAppChannel } from './channels/whatsapp.js';
+import {
+ ContainerOutput,
+ runContainerAgent,
+ writeGroupsSnapshot,
+ writeTasksSnapshot,
+} from './container-runner.js';
+import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js';
+import {
+ getAllChats,
+ getAllRegisteredGroups,
+ getAllSessions,
+ getAllTasks,
+ getMessagesSince,
+ getNewMessages,
+ getRouterState,
+ initDatabase,
+ setRegisteredGroup,
+ setRouterState,
+ setSession,
+ storeChatMetadata,
+ storeMessage,
+} from './db.js';
+import { GroupQueue } from './group-queue.js';
+import { resolveGroupFolderPath } from './group-folder.js';
+import { startIpcWatcher } from './ipc.js';
+import { findChannel, formatMessages, formatOutbound } from './router.js';
+import { startSchedulerLoop } from './task-scheduler.js';
+import { Channel, NewMessage, RegisteredGroup } from './types.js';
+import { logger } from './logger.js';
+
+// Re-export for backwards compatibility during refactor
+export { escapeXml, formatMessages } from './router.js';
+
+let lastTimestamp = '';
+let sessions: Record = {};
+let registeredGroups: Record = {};
+let lastAgentTimestamp: Record = {};
+let messageLoopRunning = false;
+
+let whatsapp: WhatsAppChannel;
+const channels: Channel[] = [];
+const queue = new GroupQueue();
+
+function loadState(): void {
+ lastTimestamp = getRouterState('last_timestamp') || '';
+ const agentTs = getRouterState('last_agent_timestamp');
+ try {
+ lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {};
+ } catch {
+ logger.warn('Corrupted last_agent_timestamp in DB, resetting');
+ lastAgentTimestamp = {};
+ }
+ sessions = getAllSessions();
+ registeredGroups = getAllRegisteredGroups();
+ logger.info(
+ { groupCount: Object.keys(registeredGroups).length },
+ 'State loaded',
+ );
+}
+
+function saveState(): void {
+ setRouterState('last_timestamp', lastTimestamp);
+ setRouterState(
+ 'last_agent_timestamp',
+ JSON.stringify(lastAgentTimestamp),
+ );
+}
+
+function registerGroup(jid: string, group: RegisteredGroup): void {
+ let groupDir: string;
+ try {
+ groupDir = resolveGroupFolderPath(group.folder);
+ } catch (err) {
+ logger.warn(
+ { jid, folder: group.folder, err },
+ 'Rejecting group registration with invalid folder',
+ );
+ return;
+ }
+
+ registeredGroups[jid] = group;
+ setRegisteredGroup(jid, group);
+
+ // Create group folder
+ fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
+
+ logger.info(
+ { jid, name: group.name, folder: group.folder },
+ 'Group registered',
+ );
+}
+
+/**
+ * Get available groups list for the agent.
+ * Returns groups ordered by most recent activity.
+ */
+export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] {
+ const chats = getAllChats();
+ const registeredJids = new Set(Object.keys(registeredGroups));
+
+ return chats
+ .filter((c) => c.jid !== '__group_sync__' && c.is_group)
+ .map((c) => ({
+ jid: c.jid,
+ name: c.name,
+ lastActivity: c.last_message_time,
+ isRegistered: registeredJids.has(c.jid),
+ }));
+}
+
+/** @internal - exported for testing */
+export function _setRegisteredGroups(groups: Record): void {
+ registeredGroups = groups;
+}
+
+/**
+ * Process all pending messages for a group.
+ * Called by the GroupQueue when it's this group's turn.
+ */
+async function processGroupMessages(chatJid: string): Promise {
+ const group = registeredGroups[chatJid];
+ if (!group) return true;
+
+ const channel = findChannel(channels, chatJid);
+ if (!channel) {
+ console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`);
+ return true;
+ }
+
+ const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
+
+ const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
+ const missedMessages = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
+
+ if (missedMessages.length === 0) return true;
+
+ // For non-main groups, check if trigger is required and present
+ if (!isMainGroup && group.requiresTrigger !== false) {
+ const hasTrigger = missedMessages.some((m) =>
+ TRIGGER_PATTERN.test(m.content.trim()),
+ );
+ if (!hasTrigger) return true;
+ }
+
+ const prompt = formatMessages(missedMessages);
+
+ // Advance cursor so the piping path in startMessageLoop won't re-fetch
+ // these messages. Save the old cursor so we can roll back on error.
+ const previousCursor = lastAgentTimestamp[chatJid] || '';
+ lastAgentTimestamp[chatJid] =
+ missedMessages[missedMessages.length - 1].timestamp;
+ saveState();
+
+ logger.info(
+ { group: group.name, messageCount: missedMessages.length },
+ 'Processing messages',
+ );
+
+ // Track idle timer for closing stdin when agent is idle
+ let idleTimer: ReturnType | null = null;
+
+ const resetIdleTimer = () => {
+ if (idleTimer) clearTimeout(idleTimer);
+ idleTimer = setTimeout(() => {
+ logger.debug({ group: group.name }, 'Idle timeout, closing container stdin');
+ queue.closeStdin(chatJid);
+ }, IDLE_TIMEOUT);
+ };
+
+ await channel.setTyping?.(chatJid, true);
+ let hadError = false;
+ let outputSentToUser = false;
+
+ const output = await runAgent(group, prompt, chatJid, async (result) => {
+ // Streaming output callback — called for each agent result
+ if (result.result) {
+ const raw = typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
+ // Strip ... blocks — agent uses these for internal reasoning
+ const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim();
+ logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`);
+ if (text) {
+ await channel.sendMessage(chatJid, text);
+ outputSentToUser = true;
+ }
+ // Only reset idle timer on actual results, not session-update markers (result: null)
+ resetIdleTimer();
+ }
+
+ if (result.status === 'success') {
+ queue.notifyIdle(chatJid);
+ }
+
+ if (result.status === 'error') {
+ hadError = true;
+ }
+ });
+
+ await channel.setTyping?.(chatJid, false);
+ if (idleTimer) clearTimeout(idleTimer);
+
+ if (output === 'error' || hadError) {
+ // If we already sent output to the user, don't roll back the cursor —
+ // the user got their response and re-processing would send duplicates.
+ if (outputSentToUser) {
+ logger.warn({ group: group.name }, 'Agent error after output was sent, skipping cursor rollback to prevent duplicates');
+ return true;
+ }
+ // Roll back cursor so retries can re-process these messages
+ lastAgentTimestamp[chatJid] = previousCursor;
+ saveState();
+ logger.warn({ group: group.name }, 'Agent error, rolled back message cursor for retry');
+ return false;
+ }
+
+ return true;
+}
+
+async function runAgent(
+ group: RegisteredGroup,
+ prompt: string,
+ chatJid: string,
+ onOutput?: (output: ContainerOutput) => Promise,
+): Promise<'success' | 'error'> {
+ const isMain = group.folder === MAIN_GROUP_FOLDER;
+ const sessionId = sessions[group.folder];
+
+ // Update tasks snapshot for container to read (filtered by group)
+ const tasks = getAllTasks();
+ writeTasksSnapshot(
+ group.folder,
+ isMain,
+ tasks.map((t) => ({
+ id: t.id,
+ groupFolder: t.group_folder,
+ prompt: t.prompt,
+ schedule_type: t.schedule_type,
+ schedule_value: t.schedule_value,
+ status: t.status,
+ next_run: t.next_run,
+ })),
+ );
+
+ // Update available groups snapshot (main group only can see all groups)
+ const availableGroups = getAvailableGroups();
+ writeGroupsSnapshot(
+ group.folder,
+ isMain,
+ availableGroups,
+ new Set(Object.keys(registeredGroups)),
+ );
+
+ // Wrap onOutput to track session ID from streamed results
+ const wrappedOnOutput = onOutput
+ ? async (output: ContainerOutput) => {
+ if (output.newSessionId) {
+ sessions[group.folder] = output.newSessionId;
+ setSession(group.folder, output.newSessionId);
+ }
+ await onOutput(output);
+ }
+ : undefined;
+
+ try {
+ const output = await runContainerAgent(
+ group,
+ {
+ prompt,
+ sessionId,
+ groupFolder: group.folder,
+ chatJid,
+ isMain,
+ assistantName: ASSISTANT_NAME,
+ },
+ (proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder),
+ wrappedOnOutput,
+ );
+
+ if (output.newSessionId) {
+ sessions[group.folder] = output.newSessionId;
+ setSession(group.folder, output.newSessionId);
+ }
+
+ if (output.status === 'error') {
+ logger.error(
+ { group: group.name, error: output.error },
+ 'Container agent error',
+ );
+ return 'error';
+ }
+
+ return 'success';
+ } catch (err) {
+ logger.error({ group: group.name, err }, 'Agent error');
+ return 'error';
+ }
+}
+
+async function startMessageLoop(): Promise {
+ if (messageLoopRunning) {
+ logger.debug('Message loop already running, skipping duplicate start');
+ return;
+ }
+ messageLoopRunning = true;
+
+ logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`);
+
+ while (true) {
+ try {
+ const jids = Object.keys(registeredGroups);
+ const { messages, newTimestamp } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME);
+
+ if (messages.length > 0) {
+ logger.info({ count: messages.length }, 'New messages');
+
+ // Advance the "seen" cursor for all messages immediately
+ lastTimestamp = newTimestamp;
+ saveState();
+
+ // Deduplicate by group
+ const messagesByGroup = new Map();
+ for (const msg of messages) {
+ const existing = messagesByGroup.get(msg.chat_jid);
+ if (existing) {
+ existing.push(msg);
+ } else {
+ messagesByGroup.set(msg.chat_jid, [msg]);
+ }
+ }
+
+ for (const [chatJid, groupMessages] of messagesByGroup) {
+ const group = registeredGroups[chatJid];
+ if (!group) continue;
+
+ const channel = findChannel(channels, chatJid);
+ if (!channel) {
+ console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`);
+ continue;
+ }
+
+ const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
+ const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
+
+ // For non-main groups, only act on trigger messages.
+ // Non-trigger messages accumulate in DB and get pulled as
+ // context when a trigger eventually arrives.
+ if (needsTrigger) {
+ const hasTrigger = groupMessages.some((m) =>
+ TRIGGER_PATTERN.test(m.content.trim()),
+ );
+ if (!hasTrigger) continue;
+ }
+
+ // Pull all messages since lastAgentTimestamp so non-trigger
+ // context that accumulated between triggers is included.
+ const allPending = getMessagesSince(
+ chatJid,
+ lastAgentTimestamp[chatJid] || '',
+ ASSISTANT_NAME,
+ );
+ const messagesToSend =
+ allPending.length > 0 ? allPending : groupMessages;
+ const formatted = formatMessages(messagesToSend);
+
+ if (queue.sendMessage(chatJid, formatted)) {
+ logger.debug(
+ { chatJid, count: messagesToSend.length },
+ 'Piped messages to active container',
+ );
+ lastAgentTimestamp[chatJid] =
+ messagesToSend[messagesToSend.length - 1].timestamp;
+ saveState();
+ // Show typing indicator while the container processes the piped message
+ channel.setTyping?.(chatJid, true)?.catch((err) =>
+ logger.warn({ chatJid, err }, 'Failed to set typing indicator'),
+ );
+ } else {
+ // No active container — enqueue for a new one
+ queue.enqueueMessageCheck(chatJid);
+ }
+ }
+ }
+ } catch (err) {
+ logger.error({ err }, 'Error in message loop');
+ }
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
+ }
+}
+
+/**
+ * Startup recovery: check for unprocessed messages in registered groups.
+ * Handles crash between advancing lastTimestamp and processing messages.
+ */
+function recoverPendingMessages(): void {
+ for (const [chatJid, group] of Object.entries(registeredGroups)) {
+ const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
+ const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
+ if (pending.length > 0) {
+ logger.info(
+ { group: group.name, pendingCount: pending.length },
+ 'Recovery: found unprocessed messages',
+ );
+ queue.enqueueMessageCheck(chatJid);
+ }
+ }
+}
+
+function ensureContainerSystemRunning(): void {
+ ensureContainerRuntimeRunning();
+ cleanupOrphans();
+}
+
+async function main(): Promise {
+ ensureContainerSystemRunning();
+ initDatabase();
+ logger.info('Database initialized');
+ loadState();
+
+ // Graceful shutdown handlers
+ const shutdown = async (signal: string) => {
+ logger.info({ signal }, 'Shutdown signal received');
+ await queue.shutdown(10000);
+ for (const ch of channels) await ch.disconnect();
+ process.exit(0);
+ };
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
+ process.on('SIGINT', () => shutdown('SIGINT'));
+
+ // Channel callbacks (shared by all channels)
+ const channelOpts = {
+ onMessage: (_chatJid: string, msg: NewMessage) => storeMessage(msg),
+ onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) =>
+ storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
+ registeredGroups: () => registeredGroups,
+ };
+
+ // Create and connect channels
+ if (DISCORD_BOT_TOKEN) {
+ const discord = new DiscordChannel(DISCORD_BOT_TOKEN, channelOpts);
+ channels.push(discord);
+ await discord.connect();
+ }
+
+ if (!DISCORD_ONLY) {
+ whatsapp = new WhatsAppChannel(channelOpts);
+ channels.push(whatsapp);
+ await whatsapp.connect();
+ }
+
+ // Start subsystems (independently of connection handler)
+ startSchedulerLoop({
+ registeredGroups: () => registeredGroups,
+ getSessions: () => sessions,
+ queue,
+ onProcess: (groupJid, proc, containerName, groupFolder) => queue.registerProcess(groupJid, proc, containerName, groupFolder),
+ sendMessage: async (jid, rawText) => {
+ const channel = findChannel(channels, jid);
+ if (!channel) {
+ console.log(`Warning: no channel owns JID ${jid}, cannot send message`);
+ return;
+ }
+ const text = formatOutbound(rawText);
+ if (text) await channel.sendMessage(jid, text);
+ },
+ });
+ startIpcWatcher({
+ sendMessage: (jid, text) => {
+ const channel = findChannel(channels, jid);
+ if (!channel) throw new Error(`No channel for JID: ${jid}`);
+ return channel.sendMessage(jid, text);
+ },
+ registeredGroups: () => registeredGroups,
+ registerGroup,
+ syncGroupMetadata: (force) => whatsapp?.syncGroupMetadata(force) ?? Promise.resolve(),
+ getAvailableGroups,
+ writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj),
+ });
+ queue.setProcessMessagesFn(processGroupMessages);
+ recoverPendingMessages();
+ startMessageLoop().catch((err) => {
+ logger.fatal({ err }, 'Message loop crashed unexpectedly');
+ process.exit(1);
+ });
+}
+
+// Guard: only run when executed directly, not when imported by tests
+const isDirectRun =
+ process.argv[1] &&
+ new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname;
+
+if (isDirectRun) {
+ main().catch((err) => {
+ logger.error({ err }, 'Failed to start NanoClaw');
+ process.exit(1);
+ });
+}
diff --git a/.agent/skills/add-discord/modify/src/index.ts.intent.md b/.agent/skills/add-discord/modify/src/index.ts.intent.md
new file mode 100644
index 0000000..29a8bf3
--- /dev/null
+++ b/.agent/skills/add-discord/modify/src/index.ts.intent.md
@@ -0,0 +1,50 @@
+# Intent: src/index.ts modifications
+
+## What changed
+
+Added Discord as a channel option alongside WhatsApp, introducing multi-channel infrastructure.
+
+## Key sections
+
+### Imports (top of file)
+
+- Added: `DiscordChannel` from `./channels/discord.js`
+- Added: `DISCORD_BOT_TOKEN`, `DISCORD_ONLY` from `./config.js`
+- Added: `findChannel` from `./router.js`
+- Added: `Channel` from `./types.js`
+
+### Multi-channel infrastructure
+
+- Added: `const channels: Channel[] = []` array to hold all active channels
+- Changed: `processGroupMessages` uses `findChannel(channels, chatJid)` instead of `whatsapp` directly
+- Changed: `startMessageLoop` uses `findChannel(channels, chatJid)` instead of `whatsapp` directly
+- Changed: `channel.setTyping?.()` instead of `whatsapp.setTyping()`
+- Changed: `channel.sendMessage()` instead of `whatsapp.sendMessage()`
+
+### getAvailableGroups()
+
+- Unchanged: uses `c.is_group` filter from base (Discord channels pass `isGroup=true` via `onChatMetadata`)
+
+### main()
+
+- Added: `channelOpts` shared callback object for all channels
+- Changed: WhatsApp conditional to `if (!DISCORD_ONLY)`
+- Added: conditional Discord creation (`if (DISCORD_BOT_TOKEN)`)
+- Changed: shutdown iterates `channels` array instead of just `whatsapp`
+- Changed: subsystems use `findChannel(channels, jid)` for message routing
+
+## Invariants
+
+- All existing message processing logic (triggers, cursors, idle timers) is preserved
+- The `runAgent` function is completely unchanged
+- State management (loadState/saveState) is unchanged
+- Recovery logic is unchanged
+- Container runtime check is unchanged (ensureContainerSystemRunning)
+
+## Must-keep
+
+- The `escapeXml` and `formatMessages` re-exports
+- The `_setRegisteredGroups` test helper
+- The `isDirectRun` guard at bottom
+- All error handling and cursor rollback logic in processGroupMessages
+- The outgoing queue flush and reconnection logic (in WhatsAppChannel, not here)
diff --git a/.agent/skills/add-discord/modify/src/routing.test.ts b/.agent/skills/add-discord/modify/src/routing.test.ts
new file mode 100644
index 0000000..6144af0
--- /dev/null
+++ b/.agent/skills/add-discord/modify/src/routing.test.ts
@@ -0,0 +1,147 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+
+import { _initTestDatabase, getAllChats, storeChatMetadata } from './db.js';
+import { getAvailableGroups, _setRegisteredGroups } from './index.js';
+
+beforeEach(() => {
+ _initTestDatabase();
+ _setRegisteredGroups({});
+});
+
+// --- JID ownership patterns ---
+
+describe('JID ownership patterns', () => {
+ // These test the patterns that will become ownsJid() on the Channel interface
+
+ it('WhatsApp group JID: ends with @g.us', () => {
+ const jid = '12345678@g.us';
+ expect(jid.endsWith('@g.us')).toBe(true);
+ });
+
+ it('Discord JID: starts with dc:', () => {
+ const jid = 'dc:1234567890123456';
+ expect(jid.startsWith('dc:')).toBe(true);
+ });
+
+ it('WhatsApp DM JID: ends with @s.whatsapp.net', () => {
+ const jid = '12345678@s.whatsapp.net';
+ expect(jid.endsWith('@s.whatsapp.net')).toBe(true);
+ });
+});
+
+// --- getAvailableGroups ---
+
+describe('getAvailableGroups', () => {
+ it('returns only groups, excludes DMs', () => {
+ storeChatMetadata('group1@g.us', '2024-01-01T00:00:01.000Z', 'Group 1', 'whatsapp', true);
+ storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false);
+ storeChatMetadata('group2@g.us', '2024-01-01T00:00:03.000Z', 'Group 2', 'whatsapp', true);
+
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(2);
+ expect(groups.map((g) => g.jid)).toContain('group1@g.us');
+ expect(groups.map((g) => g.jid)).toContain('group2@g.us');
+ expect(groups.map((g) => g.jid)).not.toContain('user@s.whatsapp.net');
+ });
+
+ it('includes Discord channel JIDs', () => {
+ storeChatMetadata('dc:1234567890123456', '2024-01-01T00:00:01.000Z', 'Discord Channel', 'discord', true);
+ storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false);
+
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(1);
+ expect(groups[0].jid).toBe('dc:1234567890123456');
+ });
+
+ it('marks registered Discord channels correctly', () => {
+ storeChatMetadata('dc:1234567890123456', '2024-01-01T00:00:01.000Z', 'DC Registered', 'discord', true);
+ storeChatMetadata('dc:9999999999999999', '2024-01-01T00:00:02.000Z', 'DC Unregistered', 'discord', true);
+
+ _setRegisteredGroups({
+ 'dc:1234567890123456': {
+ name: 'DC Registered',
+ folder: 'dc-registered',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ },
+ });
+
+ const groups = getAvailableGroups();
+ const dcReg = groups.find((g) => g.jid === 'dc:1234567890123456');
+ const dcUnreg = groups.find((g) => g.jid === 'dc:9999999999999999');
+
+ expect(dcReg?.isRegistered).toBe(true);
+ expect(dcUnreg?.isRegistered).toBe(false);
+ });
+
+ it('excludes __group_sync__ sentinel', () => {
+ storeChatMetadata('__group_sync__', '2024-01-01T00:00:00.000Z');
+ storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Group', 'whatsapp', true);
+
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(1);
+ expect(groups[0].jid).toBe('group@g.us');
+ });
+
+ it('marks registered groups correctly', () => {
+ storeChatMetadata('reg@g.us', '2024-01-01T00:00:01.000Z', 'Registered', 'whatsapp', true);
+ storeChatMetadata('unreg@g.us', '2024-01-01T00:00:02.000Z', 'Unregistered', 'whatsapp', true);
+
+ _setRegisteredGroups({
+ 'reg@g.us': {
+ name: 'Registered',
+ folder: 'registered',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ },
+ });
+
+ const groups = getAvailableGroups();
+ const reg = groups.find((g) => g.jid === 'reg@g.us');
+ const unreg = groups.find((g) => g.jid === 'unreg@g.us');
+
+ expect(reg?.isRegistered).toBe(true);
+ expect(unreg?.isRegistered).toBe(false);
+ });
+
+ it('returns groups ordered by most recent activity', () => {
+ storeChatMetadata('old@g.us', '2024-01-01T00:00:01.000Z', 'Old', 'whatsapp', true);
+ storeChatMetadata('new@g.us', '2024-01-01T00:00:05.000Z', 'New', 'whatsapp', true);
+ storeChatMetadata('mid@g.us', '2024-01-01T00:00:03.000Z', 'Mid', 'whatsapp', true);
+
+ const groups = getAvailableGroups();
+ expect(groups[0].jid).toBe('new@g.us');
+ expect(groups[1].jid).toBe('mid@g.us');
+ expect(groups[2].jid).toBe('old@g.us');
+ });
+
+ it('excludes non-group chats regardless of JID format', () => {
+ // Unknown JID format stored without is_group should not appear
+ storeChatMetadata('unknown-format-123', '2024-01-01T00:00:01.000Z', 'Unknown');
+ // Explicitly non-group with unusual JID
+ storeChatMetadata('custom:abc', '2024-01-01T00:00:02.000Z', 'Custom DM', 'custom', false);
+ // A real group for contrast
+ storeChatMetadata('group@g.us', '2024-01-01T00:00:03.000Z', 'Group', 'whatsapp', true);
+
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(1);
+ expect(groups[0].jid).toBe('group@g.us');
+ });
+
+ it('returns empty array when no chats exist', () => {
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(0);
+ });
+
+ it('mixes WhatsApp and Discord chats ordered by activity', () => {
+ storeChatMetadata('wa@g.us', '2024-01-01T00:00:01.000Z', 'WhatsApp', 'whatsapp', true);
+ storeChatMetadata('dc:555', '2024-01-01T00:00:03.000Z', 'Discord', 'discord', true);
+ storeChatMetadata('wa2@g.us', '2024-01-01T00:00:02.000Z', 'WhatsApp 2', 'whatsapp', true);
+
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(3);
+ expect(groups[0].jid).toBe('dc:555');
+ expect(groups[1].jid).toBe('wa2@g.us');
+ expect(groups[2].jid).toBe('wa@g.us');
+ });
+});
diff --git a/.agent/skills/add-discord/tests/discord.test.ts b/.agent/skills/add-discord/tests/discord.test.ts
new file mode 100644
index 0000000..a644aa7
--- /dev/null
+++ b/.agent/skills/add-discord/tests/discord.test.ts
@@ -0,0 +1,133 @@
+import { describe, expect, it } from 'vitest';
+import fs from 'fs';
+import path from 'path';
+
+describe('discord skill package', () => {
+ const skillDir = path.resolve(__dirname, '..');
+
+ it('has a valid manifest', () => {
+ const manifestPath = path.join(skillDir, 'manifest.yaml');
+ expect(fs.existsSync(manifestPath)).toBe(true);
+
+ const content = fs.readFileSync(manifestPath, 'utf-8');
+ expect(content).toContain('skill: discord');
+ expect(content).toContain('version: 1.0.0');
+ expect(content).toContain('discord.js');
+ });
+
+ it('has all files declared in adds', () => {
+ const addFile = path.join(skillDir, 'add', 'src', 'channels', 'discord.ts');
+ expect(fs.existsSync(addFile)).toBe(true);
+
+ const content = fs.readFileSync(addFile, 'utf-8');
+ expect(content).toContain('class DiscordChannel');
+ expect(content).toContain('implements Channel');
+
+ // Test file for the channel
+ const testFile = path.join(skillDir, 'add', 'src', 'channels', 'discord.test.ts');
+ expect(fs.existsSync(testFile)).toBe(true);
+
+ const testContent = fs.readFileSync(testFile, 'utf-8');
+ expect(testContent).toContain("describe('DiscordChannel'");
+ });
+
+ it('has all files declared in modifies', () => {
+ const indexFile = path.join(skillDir, 'modify', 'src', 'index.ts');
+ const configFile = path.join(skillDir, 'modify', 'src', 'config.ts');
+ const routingTestFile = path.join(skillDir, 'modify', 'src', 'routing.test.ts');
+
+ expect(fs.existsSync(indexFile)).toBe(true);
+ expect(fs.existsSync(configFile)).toBe(true);
+ expect(fs.existsSync(routingTestFile)).toBe(true);
+
+ const indexContent = fs.readFileSync(indexFile, 'utf-8');
+ expect(indexContent).toContain('DiscordChannel');
+ expect(indexContent).toContain('DISCORD_BOT_TOKEN');
+ expect(indexContent).toContain('DISCORD_ONLY');
+ expect(indexContent).toContain('findChannel');
+ expect(indexContent).toContain('channels: Channel[]');
+
+ const configContent = fs.readFileSync(configFile, 'utf-8');
+ expect(configContent).toContain('DISCORD_BOT_TOKEN');
+ expect(configContent).toContain('DISCORD_ONLY');
+ });
+
+ it('has intent files for modified files', () => {
+ expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'index.ts.intent.md'))).toBe(true);
+ expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'config.ts.intent.md'))).toBe(true);
+ });
+
+ it('modified index.ts preserves core structure', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'index.ts'),
+ 'utf-8',
+ );
+
+ // Core functions still present
+ expect(content).toContain('function loadState()');
+ expect(content).toContain('function saveState()');
+ expect(content).toContain('function registerGroup(');
+ expect(content).toContain('function getAvailableGroups()');
+ expect(content).toContain('function processGroupMessages(');
+ expect(content).toContain('function runAgent(');
+ expect(content).toContain('function startMessageLoop()');
+ expect(content).toContain('function recoverPendingMessages()');
+ expect(content).toContain('function ensureContainerSystemRunning()');
+ expect(content).toContain('async function main()');
+
+ // Test helper preserved
+ expect(content).toContain('_setRegisteredGroups');
+
+ // Direct-run guard preserved
+ expect(content).toContain('isDirectRun');
+ });
+
+ it('modified index.ts includes Discord channel creation', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'index.ts'),
+ 'utf-8',
+ );
+
+ // Multi-channel architecture
+ expect(content).toContain('const channels: Channel[] = []');
+ expect(content).toContain('channels.push(whatsapp)');
+ expect(content).toContain('channels.push(discord)');
+
+ // Conditional channel creation
+ expect(content).toContain('if (!DISCORD_ONLY)');
+ expect(content).toContain('if (DISCORD_BOT_TOKEN)');
+
+ // Shutdown disconnects all channels
+ expect(content).toContain('for (const ch of channels) await ch.disconnect()');
+ });
+
+ it('modified config.ts preserves all existing exports', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'config.ts'),
+ 'utf-8',
+ );
+
+ // All original exports preserved
+ expect(content).toContain('export const ASSISTANT_NAME');
+ expect(content).toContain('export const POLL_INTERVAL');
+ expect(content).toContain('export const TRIGGER_PATTERN');
+ expect(content).toContain('export const CONTAINER_IMAGE');
+ expect(content).toContain('export const DATA_DIR');
+ expect(content).toContain('export const TIMEZONE');
+
+ // Discord exports added
+ expect(content).toContain('export const DISCORD_BOT_TOKEN');
+ expect(content).toContain('export const DISCORD_ONLY');
+ });
+
+ it('modified routing.test.ts includes Discord JID tests', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'routing.test.ts'),
+ 'utf-8',
+ );
+
+ expect(content).toContain("Discord JID: starts with dc:");
+ expect(content).toContain("dc:1234567890123456");
+ expect(content).toContain("dc:");
+ });
+});
diff --git a/.agent/skills/add-gmail/SKILL.md b/.agent/skills/add-gmail/SKILL.md
new file mode 100644
index 0000000..cd2d25d
--- /dev/null
+++ b/.agent/skills/add-gmail/SKILL.md
@@ -0,0 +1,244 @@
+---
+name: add-gmail
+description: Add Gmail integration to NanoClaw. Can be configured as a tool (agent reads/sends emails when triggered from WhatsApp) or as a full channel (emails can trigger the agent, schedule tasks, and receive replies). Guides through GCP OAuth setup and implements the integration.
+---
+
+# Add Gmail Integration
+
+This skill adds Gmail support to NanoClaw — either as a tool (read, send, search, draft) or as a full channel that polls the inbox.
+
+## Phase 1: Pre-flight
+
+### Check if already applied
+
+Read `.nanoclaw/state.yaml`. If `gmail` is in `applied_skills`, skip to Phase 3 (Setup). The code changes are already in place.
+
+### Ask the user
+
+Use `AskUserQuestion`:
+
+AskUserQuestion: Should incoming emails be able to trigger the agent?
+
+- **Yes** — Full channel mode: the agent listens on Gmail and responds to incoming emails automatically
+- **No** — Tool-only: the agent gets full Gmail tools (read, send, search, draft) but won't monitor the inbox. No channel code is added.
+
+## Phase 2: Apply Code Changes
+
+### Initialize skills system (if needed)
+
+If `.nanoclaw/` directory doesn't exist yet:
+
+```bash
+npx tsx scripts/apply-skill.ts --init
+```
+
+### Path A: Tool-only (user chose "No")
+
+Do NOT run the full apply script. Only two source files need changes. This avoids adding dead code (`gmail.ts`, `gmail.test.ts`, index.ts channel logic, routing tests, `googleapis` dependency).
+
+#### 1. Mount Gmail credentials in container
+
+Apply the changes described in `modify/src/container-runner.ts.intent.md` to `src/container-runner.ts`: import `os`, add a conditional read-write mount of `~/.gmail-mcp` to `/home/node/.gmail-mcp` in `buildVolumeMounts()` after the session mounts.
+
+#### 2. Add Gmail MCP server to agent runner
+
+Apply the changes described in `modify/container/agent-runner/src/index.ts.intent.md` to `container/agent-runner/src/index.ts`: add `gmail` MCP server (`npx -y @gongrzhe/server-gmail-autoauth-mcp`) and `'mcp__gmail__*'` to `allowedTools`.
+
+#### 3. Record in state
+
+Add `gmail` to `.nanoclaw/state.yaml` under `applied_skills` with `mode: tool-only`.
+
+#### 4. Validate
+
+```bash
+npm run build
+```
+
+Build must be clean before proceeding. Skip to Phase 3.
+
+### Path B: Channel mode (user chose "Yes")
+
+Run the full skills engine to apply all code changes:
+
+```bash
+npx tsx scripts/apply-skill.ts .agent/skills/add-gmail
+```
+
+This deterministically:
+
+- Adds `src/channels/gmail.ts` (GmailChannel class implementing Channel interface)
+- Adds `src/channels/gmail.test.ts` (unit tests)
+- Three-way merges Gmail channel wiring into `src/index.ts` (GmailChannel creation)
+- Three-way merges Gmail credentials mount into `src/container-runner.ts` (~/.gmail-mcp -> /home/node/.gmail-mcp)
+- Three-way merges Gmail MCP server into `container/agent-runner/src/index.ts` (@gongrzhe/server-gmail-autoauth-mcp)
+- Three-way merges Gmail JID tests into `src/routing.test.ts`
+- Installs the `googleapis` npm dependency
+- Records the application in `.nanoclaw/state.yaml`
+
+If the apply reports merge conflicts, read the intent files:
+
+- `modify/src/index.ts.intent.md` — what changed and invariants for index.ts
+- `modify/src/container-runner.ts.intent.md` — what changed for container-runner.ts
+- `modify/container/agent-runner/src/index.ts.intent.md` — what changed for agent-runner
+
+#### Add email handling instructions
+
+Append the following to `groups/main/AGENT.md` (before the formatting section):
+
+```markdown
+## Email Notifications
+
+When you receive an email notification (messages starting with `[Email from ...`), inform the user about it but do NOT reply to the email unless specifically asked. You have Gmail tools available — use them only when the user explicitly asks you to reply, forward, or take action on an email.
+```
+
+#### Validate
+
+```bash
+npm test
+npm run build
+```
+
+All tests must pass (including the new gmail tests) and build must be clean before proceeding.
+
+## Phase 3: Setup
+
+### Check existing Gmail credentials
+
+```bash
+ls -la ~/.gmail-mcp/ 2>/dev/null || echo "No Gmail config found"
+```
+
+If `credentials.json` already exists, skip to "Build and restart" below.
+
+### GCP Project Setup
+
+Tell the user:
+
+> I need you to set up Google Cloud OAuth credentials:
+>
+> 1. Open https://console.cloud.google.com — create a new project or select existing
+> 2. Go to **APIs & Services > Library**, search "Gmail API", click **Enable**
+> 3. Go to **APIs & Services > Credentials**, click **+ CREATE CREDENTIALS > OAuth client ID**
+> - If prompted for consent screen: choose "External", fill in app name and email, save
+> - Application type: **Desktop app**, name: anything (e.g., "NanoClaw Gmail")
+> 4. Click **DOWNLOAD JSON** and save as `gcp-oauth.keys.json`
+>
+> Where did you save the file? (Give me the full path, or paste the file contents here)
+
+If user provides a path, copy it:
+
+```bash
+mkdir -p ~/.gmail-mcp
+cp "/path/user/provided/gcp-oauth.keys.json" ~/.gmail-mcp/gcp-oauth.keys.json
+```
+
+If user pastes JSON content, write it to `~/.gmail-mcp/gcp-oauth.keys.json`.
+
+### OAuth Authorization
+
+Tell the user:
+
+> I'm going to run Gmail authorization. A browser window will open — sign in and grant access. If you see an "app isn't verified" warning, click "Advanced" then "Go to [app name] (unsafe)" — this is normal for personal OAuth apps.
+
+Run the authorization:
+
+```bash
+npx -y @gongrzhe/server-gmail-autoauth-mcp auth
+```
+
+If that fails (some versions don't have an auth subcommand), try `timeout 60 npx -y @gongrzhe/server-gmail-autoauth-mcp || true`. Verify with `ls ~/.gmail-mcp/credentials.json`.
+
+### Build and restart
+
+Clear stale per-group agent-runner copies (they only get re-created if missing, so existing copies won't pick up the new Gmail server):
+
+```bash
+rm -r data/sessions/*/agent-runner-src 2>/dev/null || true
+```
+
+Rebuild the container (agent-runner changed):
+
+```bash
+cd container && ./build.sh
+```
+
+Then compile and restart:
+
+```bash
+npm run build
+launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
+# Linux: systemctl --user restart nanoclaw
+```
+
+## Phase 4: Verify
+
+### Test tool access (both modes)
+
+Tell the user:
+
+> Gmail is connected! Send this in your main channel:
+>
+> `@Andy check my recent emails` or `@Andy list my Gmail labels`
+
+### Test channel mode (Channel mode only)
+
+Tell the user to send themselves a test email. The agent should pick it up within a minute. Monitor: `tail -f logs/nanoclaw.log | grep -iE "(gmail|email)"`.
+
+Once verified, offer filter customization via `AskUserQuestion` — by default, only emails in the Primary inbox trigger the agent (Promotions, Social, Updates, and Forums are excluded). The user can keep this default or narrow further by sender, label, or keywords. No code changes needed for filters.
+
+### Check logs if needed
+
+```bash
+tail -f logs/nanoclaw.log
+```
+
+## Troubleshooting
+
+### Gmail connection not responding
+
+Test directly:
+
+```bash
+npx -y @gongrzhe/server-gmail-autoauth-mcp
+```
+
+### OAuth token expired
+
+Re-authorize:
+
+```bash
+rm ~/.gmail-mcp/credentials.json
+npx -y @gongrzhe/server-gmail-autoauth-mcp
+```
+
+### Container can't access Gmail
+
+- Verify `~/.gmail-mcp` is mounted: check `src/container-runner.ts` for the `.gmail-mcp` mount
+- Check container logs: `cat groups/main/logs/container-*.log | tail -50`
+
+### Emails not being detected (Channel mode only)
+
+- By default, the channel polls unread Primary inbox emails (`is:unread category:primary`)
+- Check logs for Gmail polling errors
+
+## Removal
+
+### Tool-only mode
+
+1. Remove `~/.gmail-mcp` mount from `src/container-runner.ts`
+2. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts`
+3. Remove `gmail` from `.nanoclaw/state.yaml`
+4. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true`
+5. Rebuild: `cd container && ./build.sh && cd .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)
+
+### Channel mode
+
+1. Delete `src/channels/gmail.ts` and `src/channels/gmail.test.ts`
+2. Remove `GmailChannel` import and creation from `src/index.ts`
+3. Remove `~/.gmail-mcp` mount from `src/container-runner.ts`
+4. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts`
+5. Remove Gmail JID tests from `src/routing.test.ts`
+6. Uninstall: `npm uninstall googleapis`
+7. Remove `gmail` from `.nanoclaw/state.yaml`
+8. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true`
+9. Rebuild: `cd container && ./build.sh && cd .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)
diff --git a/.agent/skills/add-gmail/add/src/channels/gmail.test.ts b/.agent/skills/add-gmail/add/src/channels/gmail.test.ts
new file mode 100644
index 0000000..52602dd
--- /dev/null
+++ b/.agent/skills/add-gmail/add/src/channels/gmail.test.ts
@@ -0,0 +1,71 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+import { GmailChannel, GmailChannelOpts } from './gmail.js';
+
+function makeOpts(overrides?: Partial): GmailChannelOpts {
+ return {
+ onMessage: vi.fn(),
+ onChatMetadata: vi.fn(),
+ registeredGroups: () => ({}),
+ ...overrides,
+ };
+}
+
+describe('GmailChannel', () => {
+ let channel: GmailChannel;
+
+ beforeEach(() => {
+ channel = new GmailChannel(makeOpts());
+ });
+
+ describe('ownsJid', () => {
+ it('returns true for gmail: prefixed JIDs', () => {
+ expect(channel.ownsJid('gmail:abc123')).toBe(true);
+ expect(channel.ownsJid('gmail:thread-id-456')).toBe(true);
+ });
+
+ it('returns false for non-gmail JIDs', () => {
+ expect(channel.ownsJid('12345@g.us')).toBe(false);
+ expect(channel.ownsJid('tg:123')).toBe(false);
+ expect(channel.ownsJid('dc:456')).toBe(false);
+ expect(channel.ownsJid('user@s.whatsapp.net')).toBe(false);
+ });
+ });
+
+ describe('name', () => {
+ it('is gmail', () => {
+ expect(channel.name).toBe('gmail');
+ });
+ });
+
+ describe('isConnected', () => {
+ it('returns false before connect', () => {
+ expect(channel.isConnected()).toBe(false);
+ });
+ });
+
+ describe('disconnect', () => {
+ it('sets connected to false', async () => {
+ await channel.disconnect();
+ expect(channel.isConnected()).toBe(false);
+ });
+ });
+
+ describe('constructor options', () => {
+ it('accepts custom poll interval', () => {
+ const ch = new GmailChannel(makeOpts(), 30000);
+ expect(ch.name).toBe('gmail');
+ });
+
+ it('defaults to unread query when no filter configured', () => {
+ const ch = new GmailChannel(makeOpts());
+ const query = (ch as unknown as { buildQuery: () => string }).buildQuery();
+ expect(query).toBe('is:unread category:primary');
+ });
+
+ it('defaults with no options provided', () => {
+ const ch = new GmailChannel(makeOpts());
+ expect(ch.name).toBe('gmail');
+ });
+ });
+});
diff --git a/.agent/skills/add-gmail/add/src/channels/gmail.ts b/.agent/skills/add-gmail/add/src/channels/gmail.ts
new file mode 100644
index 0000000..b9ade60
--- /dev/null
+++ b/.agent/skills/add-gmail/add/src/channels/gmail.ts
@@ -0,0 +1,339 @@
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+
+import { google, gmail_v1 } from 'googleapis';
+import { OAuth2Client } from 'google-auth-library';
+
+import { MAIN_GROUP_FOLDER } from '../config.js';
+import { logger } from '../logger.js';
+import {
+ Channel,
+ OnChatMetadata,
+ OnInboundMessage,
+ RegisteredGroup,
+} from '../types.js';
+
+export interface GmailChannelOpts {
+ onMessage: OnInboundMessage;
+ onChatMetadata: OnChatMetadata;
+ registeredGroups: () => Record;
+}
+
+interface ThreadMeta {
+ sender: string;
+ senderName: string;
+ subject: string;
+ messageId: string; // RFC 2822 Message-ID for In-Reply-To
+}
+
+export class GmailChannel implements Channel {
+ name = 'gmail';
+
+ private oauth2Client: OAuth2Client | null = null;
+ private gmail: gmail_v1.Gmail | null = null;
+ private opts: GmailChannelOpts;
+ private pollIntervalMs: number;
+ private pollTimer: ReturnType | null = null;
+ private processedIds = new Set();
+ private threadMeta = new Map();
+ private consecutiveErrors = 0;
+ private userEmail = '';
+
+ constructor(opts: GmailChannelOpts, pollIntervalMs = 60000) {
+ this.opts = opts;
+ this.pollIntervalMs = pollIntervalMs;
+ }
+
+ async connect(): Promise {
+ const credDir = path.join(os.homedir(), '.gmail-mcp');
+ const keysPath = path.join(credDir, 'gcp-oauth.keys.json');
+ const tokensPath = path.join(credDir, 'credentials.json');
+
+ if (!fs.existsSync(keysPath) || !fs.existsSync(tokensPath)) {
+ logger.warn(
+ 'Gmail credentials not found in ~/.gmail-mcp/. Skipping Gmail channel. Run /add-gmail to set up.',
+ );
+ return;
+ }
+
+ const keys = JSON.parse(fs.readFileSync(keysPath, 'utf-8'));
+ const tokens = JSON.parse(fs.readFileSync(tokensPath, 'utf-8'));
+
+ const clientConfig = keys.installed || keys.web || keys;
+ const { client_id, client_secret, redirect_uris } = clientConfig;
+ this.oauth2Client = new google.auth.OAuth2(
+ client_id,
+ client_secret,
+ redirect_uris?.[0],
+ );
+ this.oauth2Client.setCredentials(tokens);
+
+ // Persist refreshed tokens
+ this.oauth2Client.on('tokens', (newTokens) => {
+ try {
+ const current = JSON.parse(fs.readFileSync(tokensPath, 'utf-8'));
+ Object.assign(current, newTokens);
+ fs.writeFileSync(tokensPath, JSON.stringify(current, null, 2));
+ logger.debug('Gmail OAuth tokens refreshed');
+ } catch (err) {
+ logger.warn({ err }, 'Failed to persist refreshed Gmail tokens');
+ }
+ });
+
+ this.gmail = google.gmail({ version: 'v1', auth: this.oauth2Client });
+
+ // Verify connection
+ const profile = await this.gmail.users.getProfile({ userId: 'me' });
+ this.userEmail = profile.data.emailAddress || '';
+ logger.info({ email: this.userEmail }, 'Gmail channel connected');
+
+ // Start polling with error backoff
+ const schedulePoll = () => {
+ const backoffMs = this.consecutiveErrors > 0
+ ? Math.min(this.pollIntervalMs * Math.pow(2, this.consecutiveErrors), 30 * 60 * 1000)
+ : this.pollIntervalMs;
+ this.pollTimer = setTimeout(() => {
+ this.pollForMessages()
+ .catch((err) => logger.error({ err }, 'Gmail poll error'))
+ .finally(() => {
+ if (this.gmail) schedulePoll();
+ });
+ }, backoffMs);
+ };
+
+ // Initial poll
+ await this.pollForMessages();
+ schedulePoll();
+ }
+
+ async sendMessage(jid: string, text: string): Promise {
+ if (!this.gmail) {
+ logger.warn('Gmail not initialized');
+ return;
+ }
+
+ const threadId = jid.replace(/^gmail:/, '');
+ const meta = this.threadMeta.get(threadId);
+
+ if (!meta) {
+ logger.warn({ jid }, 'No thread metadata for reply, cannot send');
+ return;
+ }
+
+ const subject = meta.subject.startsWith('Re:')
+ ? meta.subject
+ : `Re: ${meta.subject}`;
+
+ const headers = [
+ `To: ${meta.sender}`,
+ `From: ${this.userEmail}`,
+ `Subject: ${subject}`,
+ `In-Reply-To: ${meta.messageId}`,
+ `References: ${meta.messageId}`,
+ 'Content-Type: text/plain; charset=utf-8',
+ '',
+ text,
+ ].join('\r\n');
+
+ const encodedMessage = Buffer.from(headers)
+ .toString('base64')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/, '');
+
+ try {
+ await this.gmail.users.messages.send({
+ userId: 'me',
+ requestBody: {
+ raw: encodedMessage,
+ threadId,
+ },
+ });
+ logger.info({ to: meta.sender, threadId }, 'Gmail reply sent');
+ } catch (err) {
+ logger.error({ jid, err }, 'Failed to send Gmail reply');
+ }
+ }
+
+ isConnected(): boolean {
+ return this.gmail !== null;
+ }
+
+ ownsJid(jid: string): boolean {
+ return jid.startsWith('gmail:');
+ }
+
+ async disconnect(): Promise {
+ if (this.pollTimer) {
+ clearTimeout(this.pollTimer);
+ this.pollTimer = null;
+ }
+ this.gmail = null;
+ this.oauth2Client = null;
+ logger.info('Gmail channel stopped');
+ }
+
+ // --- Private ---
+
+ private buildQuery(): string {
+ return 'is:unread category:primary';
+ }
+
+ private async pollForMessages(): Promise {
+ if (!this.gmail) return;
+
+ try {
+ const query = this.buildQuery();
+ const res = await this.gmail.users.messages.list({
+ userId: 'me',
+ q: query,
+ maxResults: 10,
+ });
+
+ const messages = res.data.messages || [];
+
+ for (const stub of messages) {
+ if (!stub.id || this.processedIds.has(stub.id)) continue;
+ this.processedIds.add(stub.id);
+
+ await this.processMessage(stub.id);
+ }
+
+ // Cap processed ID set to prevent unbounded growth
+ if (this.processedIds.size > 5000) {
+ const ids = [...this.processedIds];
+ this.processedIds = new Set(ids.slice(ids.length - 2500));
+ }
+
+ this.consecutiveErrors = 0;
+ } catch (err) {
+ this.consecutiveErrors++;
+ const backoffMs = Math.min(this.pollIntervalMs * Math.pow(2, this.consecutiveErrors), 30 * 60 * 1000);
+ logger.error({ err, consecutiveErrors: this.consecutiveErrors, nextPollMs: backoffMs }, 'Gmail poll failed');
+ }
+ }
+
+ private async processMessage(messageId: string): Promise {
+ if (!this.gmail) return;
+
+ const msg = await this.gmail.users.messages.get({
+ userId: 'me',
+ id: messageId,
+ format: 'full',
+ });
+
+ const headers = msg.data.payload?.headers || [];
+ const getHeader = (name: string) =>
+ headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())
+ ?.value || '';
+
+ const from = getHeader('From');
+ const subject = getHeader('Subject');
+ const rfc2822MessageId = getHeader('Message-ID');
+ const threadId = msg.data.threadId || messageId;
+ const timestamp = new Date(
+ parseInt(msg.data.internalDate || '0', 10),
+ ).toISOString();
+
+ // Extract sender name and email
+ const senderMatch = from.match(/^(.+?)\s*<(.+?)>$/);
+ const senderName = senderMatch ? senderMatch[1].replace(/"/g, '') : from;
+ const senderEmail = senderMatch ? senderMatch[2] : from;
+
+ // Skip emails from self (our own replies)
+ if (senderEmail === this.userEmail) return;
+
+ // Extract body text
+ const body = this.extractTextBody(msg.data.payload);
+
+ if (!body) {
+ logger.debug({ messageId, subject }, 'Skipping email with no text body');
+ return;
+ }
+
+ const chatJid = `gmail:${threadId}`;
+
+ // Cache thread metadata for replies
+ this.threadMeta.set(threadId, {
+ sender: senderEmail,
+ senderName,
+ subject,
+ messageId: rfc2822MessageId,
+ });
+
+ // Store chat metadata for group discovery
+ this.opts.onChatMetadata(chatJid, timestamp, subject, 'gmail', false);
+
+ // Find the main group to deliver the email notification
+ const groups = this.opts.registeredGroups();
+ const mainEntry = Object.entries(groups).find(
+ ([, g]) => g.folder === MAIN_GROUP_FOLDER,
+ );
+
+ if (!mainEntry) {
+ logger.debug(
+ { chatJid, subject },
+ 'No main group registered, skipping email',
+ );
+ return;
+ }
+
+ const mainJid = mainEntry[0];
+ const content = `[Email from ${senderName} <${senderEmail}>]\nSubject: ${subject}\n\n${body}`;
+
+ this.opts.onMessage(mainJid, {
+ id: messageId,
+ chat_jid: mainJid,
+ sender: senderEmail,
+ sender_name: senderName,
+ content,
+ timestamp,
+ is_from_me: false,
+ });
+
+ // Mark as read
+ try {
+ await this.gmail.users.messages.modify({
+ userId: 'me',
+ id: messageId,
+ requestBody: { removeLabelIds: ['UNREAD'] },
+ });
+ } catch (err) {
+ logger.warn({ messageId, err }, 'Failed to mark email as read');
+ }
+
+ logger.info(
+ { mainJid, from: senderName, subject },
+ 'Gmail email delivered to main group',
+ );
+ }
+
+ private extractTextBody(
+ payload: gmail_v1.Schema$MessagePart | undefined,
+ ): string {
+ if (!payload) return '';
+
+ // Direct text/plain body
+ if (payload.mimeType === 'text/plain' && payload.body?.data) {
+ return Buffer.from(payload.body.data, 'base64').toString('utf-8');
+ }
+
+ // Multipart: search parts recursively
+ if (payload.parts) {
+ // Prefer text/plain
+ for (const part of payload.parts) {
+ if (part.mimeType === 'text/plain' && part.body?.data) {
+ return Buffer.from(part.body.data, 'base64').toString('utf-8');
+ }
+ }
+ // Recurse into nested multipart
+ for (const part of payload.parts) {
+ const text = this.extractTextBody(part);
+ if (text) return text;
+ }
+ }
+
+ return '';
+ }
+}
diff --git a/.agent/skills/add-gmail/manifest.yaml b/.agent/skills/add-gmail/manifest.yaml
new file mode 100644
index 0000000..ea7c66a
--- /dev/null
+++ b/.agent/skills/add-gmail/manifest.yaml
@@ -0,0 +1,18 @@
+skill: gmail
+version: 1.0.0
+description: "Gmail integration via Google APIs"
+core_version: 0.1.0
+adds:
+ - src/channels/gmail.ts
+ - src/channels/gmail.test.ts
+modifies:
+ - src/index.ts
+ - src/container-runner.ts
+ - container/agent-runner/src/index.ts
+ - src/routing.test.ts
+structured:
+ npm_dependencies:
+ googleapis: "^144.0.0"
+conflicts: []
+depends: []
+test: "npx vitest run src/channels/gmail.test.ts"
diff --git a/.agent/skills/add-gmail/modify/container/agent-runner/src/index.ts b/.agent/skills/add-gmail/modify/container/agent-runner/src/index.ts
new file mode 100644
index 0000000..4ea26b0
--- /dev/null
+++ b/.agent/skills/add-gmail/modify/container/agent-runner/src/index.ts
@@ -0,0 +1,703 @@
+/**
+ * NanoClaw Agent Runner
+ * Runs inside a container, receives config via stdin, outputs result to stdout
+ *
+ * Input protocol:
+ * Stdin: Full ContainerInput JSON (read until EOF, like before)
+ * IPC: Follow-up messages written as JSON files to /workspace/ipc/input/
+ * Files: {type:"message", text:"..."}.json — polled and consumed
+ * Sentinel: /workspace/ipc/input/_close — signals session end
+ *
+ * Stdout protocol:
+ * Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
+ * Multiple results may be emitted (one per agent teams result).
+ * Final marker after loop ends signals completion.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import {
+ query,
+ HookCallback,
+ PreCompactHookInput,
+ PreToolUseHookInput,
+} from '@anthropic-ai/claude-agent-sdk';
+import { fileURLToPath } from 'url';
+
+interface ContainerInput {
+ prompt: string;
+ sessionId?: string;
+ groupFolder: string;
+ chatJid: string;
+ isMain: boolean;
+ isScheduledTask?: boolean;
+ assistantName?: string;
+ secrets?: Record;
+}
+
+interface ContainerOutput {
+ status: 'success' | 'error';
+ result: string | null;
+ newSessionId?: string;
+ error?: string;
+}
+
+interface SessionEntry {
+ sessionId: string;
+ fullPath: string;
+ summary: string;
+ firstPrompt: string;
+}
+
+interface SessionsIndex {
+ entries: SessionEntry[];
+}
+
+interface SDKUserMessage {
+ type: 'user';
+ message: { role: 'user'; content: string };
+ parent_tool_use_id: null;
+ session_id: string;
+}
+
+const IPC_INPUT_DIR = '/workspace/ipc/input';
+const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
+const IPC_POLL_MS = 500;
+
+/**
+ * Push-based async iterable for streaming user messages to the SDK.
+ * Keeps the iterable alive until end() is called, preventing isSingleUserTurn.
+ */
+class MessageStream {
+ private queue: SDKUserMessage[] = [];
+ private waiting: (() => void) | null = null;
+ private done = false;
+
+ push(text: string): void {
+ this.queue.push({
+ type: 'user',
+ message: { role: 'user', content: text },
+ parent_tool_use_id: null,
+ session_id: '',
+ });
+ this.waiting?.();
+ }
+
+ end(): void {
+ this.done = true;
+ this.waiting?.();
+ }
+
+ async *[Symbol.asyncIterator](): AsyncGenerator {
+ while (true) {
+ while (this.queue.length > 0) {
+ yield this.queue.shift()!;
+ }
+ if (this.done) return;
+ await new Promise((r) => {
+ this.waiting = r;
+ });
+ this.waiting = null;
+ }
+ }
+}
+
+async function readStdin(): Promise {
+ return new Promise((resolve, reject) => {
+ let data = '';
+ process.stdin.setEncoding('utf8');
+ process.stdin.on('data', (chunk) => {
+ data += chunk;
+ });
+ process.stdin.on('end', () => resolve(data));
+ process.stdin.on('error', reject);
+ });
+}
+
+const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
+const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
+
+function writeOutput(output: ContainerOutput): void {
+ console.log(OUTPUT_START_MARKER);
+ console.log(JSON.stringify(output));
+ console.log(OUTPUT_END_MARKER);
+}
+
+function log(message: string): void {
+ console.error(`[agent-runner] ${message}`);
+}
+
+function getSessionSummary(
+ sessionId: string,
+ transcriptPath: string,
+): string | null {
+ const projectDir = path.dirname(transcriptPath);
+ const indexPath = path.join(projectDir, 'sessions-index.json');
+
+ if (!fs.existsSync(indexPath)) {
+ log(`Sessions index not found at ${indexPath}`);
+ return null;
+ }
+
+ try {
+ const index: SessionsIndex = JSON.parse(
+ fs.readFileSync(indexPath, 'utf-8'),
+ );
+ const entry = index.entries.find((e) => e.sessionId === sessionId);
+ if (entry?.summary) {
+ return entry.summary;
+ }
+ } catch (err) {
+ log(
+ `Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+
+ return null;
+}
+
+/**
+ * Archive the full transcript to conversations/ before compaction.
+ */
+function createPreCompactHook(assistantName?: string): HookCallback {
+ return async (input, _toolUseId, _context) => {
+ const preCompact = input as PreCompactHookInput;
+ const transcriptPath = preCompact.transcript_path;
+ const sessionId = preCompact.session_id;
+
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) {
+ log('No transcript found for archiving');
+ return {};
+ }
+
+ try {
+ const content = fs.readFileSync(transcriptPath, 'utf-8');
+ const messages = parseTranscript(content);
+
+ if (messages.length === 0) {
+ log('No messages to archive');
+ return {};
+ }
+
+ const summary = getSessionSummary(sessionId, transcriptPath);
+ const name = summary ? sanitizeFilename(summary) : generateFallbackName();
+
+ const conversationsDir = '/workspace/group/conversations';
+ fs.mkdirSync(conversationsDir, { recursive: true });
+
+ const date = new Date().toISOString().split('T')[0];
+ const filename = `${date}-${name}.md`;
+ const filePath = path.join(conversationsDir, filename);
+
+ const markdown = formatTranscriptMarkdown(
+ messages,
+ summary,
+ assistantName,
+ );
+ fs.writeFileSync(filePath, markdown);
+
+ log(`Archived conversation to ${filePath}`);
+ } catch (err) {
+ log(
+ `Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+
+ return {};
+ };
+}
+
+// Secrets to strip from Bash tool subprocess environments.
+// These are needed by the agent for API auth but should never
+// be visible to commands Kit runs.
+const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'ZAI_API_KEY'];
+
+function createSanitizeBashHook(): HookCallback {
+ return async (input, _toolUseId, _context) => {
+ const preInput = input as PreToolUseHookInput;
+ const command = (preInput.tool_input as { command?: string })?.command;
+ if (!command) return {};
+
+ const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `;
+ return {
+ hookSpecificOutput: {
+ hookEventName: 'PreToolUse',
+ updatedInput: {
+ ...(preInput.tool_input as Record),
+ command: unsetPrefix + command,
+ },
+ },
+ };
+ };
+}
+
+function sanitizeFilename(summary: string): string {
+ return summary
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 50);
+}
+
+function generateFallbackName(): string {
+ const time = new Date();
+ return `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`;
+}
+
+interface ParsedMessage {
+ role: 'user' | 'assistant';
+ content: string;
+}
+
+function parseTranscript(content: string): ParsedMessage[] {
+ const messages: ParsedMessage[] = [];
+
+ for (const line of content.split('\n')) {
+ if (!line.trim()) continue;
+ try {
+ const entry = JSON.parse(line);
+ if (entry.type === 'user' && entry.message?.content) {
+ const text =
+ typeof entry.message.content === 'string'
+ ? entry.message.content
+ : entry.message.content
+ .map((c: { text?: string }) => c.text || '')
+ .join('');
+ if (text) messages.push({ role: 'user', content: text });
+ } else if (entry.type === 'assistant' && entry.message?.content) {
+ const textParts = entry.message.content
+ .filter((c: { type: string }) => c.type === 'text')
+ .map((c: { text: string }) => c.text);
+ const text = textParts.join('');
+ if (text) messages.push({ role: 'assistant', content: text });
+ }
+ } catch {}
+ }
+
+ return messages;
+}
+
+function formatTranscriptMarkdown(
+ messages: ParsedMessage[],
+ title?: string | null,
+ assistantName?: string,
+): string {
+ const now = new Date();
+ const months = [
+ 'jan',
+ 'feb',
+ 'mar',
+ 'apr',
+ 'maj',
+ 'jun',
+ 'jul',
+ 'avg',
+ 'sep',
+ 'okt',
+ 'nov',
+ 'dec',
+ ];
+ const formatDateTime = (d: Date) => {
+ const day = String(d.getDate()).padStart(2, '0');
+ const month = months[d.getMonth()];
+ const year = d.getFullYear();
+ const hour = String(d.getHours()).padStart(2, '0');
+ const minute = String(d.getMinutes()).padStart(2, '0');
+ return `${day}.${month}.${year} ${hour}:${minute}`;
+ };
+
+ const lines: string[] = [];
+ lines.push(`# ${title || 'Conversation'}`);
+ lines.push('');
+ lines.push(`Archived: ${formatDateTime(now)}`);
+ lines.push('');
+ lines.push('---');
+ lines.push('');
+
+ for (const msg of messages) {
+ const sender = msg.role === 'user' ? 'User' : assistantName || 'Assistant';
+ const content =
+ msg.content.length > 2000
+ ? msg.content.slice(0, 2000) + '...'
+ : msg.content;
+ lines.push(`**${sender}**: ${content}`);
+ lines.push('');
+ }
+
+ return lines.join('\n');
+}
+
+/**
+ * Check for _close sentinel.
+ */
+function shouldClose(): boolean {
+ if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) {
+ try {
+ fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
+ } catch {
+ /* ignore */
+ }
+ return true;
+ }
+ return false;
+}
+
+/**
+ * Drain all pending IPC input messages.
+ * Returns messages found, or empty array.
+ */
+function drainIpcInput(): string[] {
+ try {
+ fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
+ const files = fs
+ .readdirSync(IPC_INPUT_DIR)
+ .filter((f) => f.endsWith('.json'))
+ .sort();
+
+ const messages: string[] = [];
+ for (const file of files) {
+ const filePath = path.join(IPC_INPUT_DIR, file);
+ try {
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
+ fs.unlinkSync(filePath);
+ if (data.type === 'message' && data.text) {
+ messages.push(data.text);
+ }
+ } catch (err) {
+ log(
+ `Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ try {
+ fs.unlinkSync(filePath);
+ } catch {
+ /* ignore */
+ }
+ }
+ }
+ return messages;
+ } catch (err) {
+ log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`);
+ return [];
+ }
+}
+
+/**
+ * Wait for a new IPC message or _close sentinel.
+ * Returns the messages as a single string, or null if _close.
+ */
+function waitForIpcMessage(): Promise {
+ return new Promise((resolve) => {
+ const poll = () => {
+ if (shouldClose()) {
+ resolve(null);
+ return;
+ }
+ const messages = drainIpcInput();
+ if (messages.length > 0) {
+ resolve(messages.join('\n'));
+ return;
+ }
+ setTimeout(poll, IPC_POLL_MS);
+ };
+ poll();
+ });
+}
+
+/**
+ * Run a single query and stream results via writeOutput.
+ * Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false,
+ * allowing agent teams subagents to run to completion.
+ * Also pipes IPC messages into the stream during the query.
+ */
+async function runQuery(
+ prompt: string,
+ sessionId: string | undefined,
+ mcpServerPath: string,
+ containerInput: ContainerInput,
+ sdkEnv: Record,
+ resumeAt?: string,
+): Promise<{
+ newSessionId?: string;
+ lastAssistantUuid?: string;
+ closedDuringQuery: boolean;
+}> {
+ const stream = new MessageStream();
+ stream.push(prompt);
+
+ // Poll IPC for follow-up messages and _close sentinel during the query
+ let ipcPolling = true;
+ let closedDuringQuery = false;
+ const pollIpcDuringQuery = () => {
+ if (!ipcPolling) return;
+ if (shouldClose()) {
+ log('Close sentinel detected during query, ending stream');
+ closedDuringQuery = true;
+ stream.end();
+ ipcPolling = false;
+ return;
+ }
+ const messages = drainIpcInput();
+ for (const text of messages) {
+ log(`Piping IPC message into active query (${text.length} chars)`);
+ stream.push(text);
+ }
+ setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
+ };
+ setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
+
+ let newSessionId: string | undefined;
+ let lastAssistantUuid: string | undefined;
+ let messageCount = 0;
+ let resultCount = 0;
+
+ // Load global AGENT.md as additional system context (shared across all groups)
+ const globalClaudeMdPath = '/workspace/global/AGENT.md';
+ let globalClaudeMd: string | undefined;
+ if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) {
+ globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8');
+ }
+
+ // Discover additional directories mounted at /workspace/extra/*
+ // These are passed to the SDK so their AGENT.md files are loaded automatically
+ const extraDirs: string[] = [];
+ const extraBase = '/workspace/extra';
+ if (fs.existsSync(extraBase)) {
+ for (const entry of fs.readdirSync(extraBase)) {
+ const fullPath = path.join(extraBase, entry);
+ if (fs.statSync(fullPath).isDirectory()) {
+ extraDirs.push(fullPath);
+ }
+ }
+ }
+ if (extraDirs.length > 0) {
+ log(`Additional directories: ${extraDirs.join(', ')}`);
+ }
+
+ for await (const message of query({
+ prompt: stream,
+ options: {
+ cwd: '/workspace/group',
+ additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
+ resume: sessionId,
+ resumeSessionAt: resumeAt,
+ systemPrompt: globalClaudeMd
+ ? {
+ type: 'preset' as const,
+ preset: 'claude_code' as const,
+ append: globalClaudeMd,
+ }
+ : undefined,
+ allowedTools: [
+ 'Bash',
+ 'Read',
+ 'Write',
+ 'Edit',
+ 'Glob',
+ 'Grep',
+ 'WebSearch',
+ 'WebFetch',
+ 'Task',
+ 'TaskOutput',
+ 'TaskStop',
+ 'TeamCreate',
+ 'TeamDelete',
+ 'SendMessage',
+ 'TodoWrite',
+ 'ToolSearch',
+ 'Skill',
+ 'NotebookEdit',
+ 'mcp__nanoclaw__*',
+ 'mcp__gmail__*',
+ ],
+ env: sdkEnv,
+ permissionMode: 'bypassPermissions',
+ allowDangerouslySkipPermissions: true,
+ settingSources: ['project', 'user'],
+ mcpServers: {
+ nanoclaw: {
+ command: 'node',
+ args: [mcpServerPath],
+ env: {
+ NANOCLAW_CHAT_JID: containerInput.chatJid,
+ NANOCLAW_GROUP_FOLDER: containerInput.groupFolder,
+ NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0',
+ },
+ },
+ gmail: {
+ command: 'npx',
+ args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'],
+ },
+ },
+ hooks: {
+ PreCompact: [
+ { hooks: [createPreCompactHook(containerInput.assistantName)] },
+ ],
+ PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }],
+ },
+ },
+ })) {
+ messageCount++;
+ const msgType =
+ message.type === 'system'
+ ? `system/${(message as { subtype?: string }).subtype}`
+ : message.type;
+ log(`[msg #${messageCount}] type=${msgType}`);
+
+ if (message.type === 'assistant' && 'uuid' in message) {
+ lastAssistantUuid = (message as { uuid: string }).uuid;
+ }
+
+ if (message.type === 'system' && message.subtype === 'init') {
+ newSessionId = message.session_id;
+ log(`Session initialized: ${newSessionId}`);
+ }
+
+ if (
+ message.type === 'system' &&
+ (message as { subtype?: string }).subtype === 'task_notification'
+ ) {
+ const tn = message as {
+ task_id: string;
+ status: string;
+ summary: string;
+ };
+ log(
+ `Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`,
+ );
+ }
+
+ if (message.type === 'result') {
+ resultCount++;
+ const textResult =
+ 'result' in message ? (message as { result?: string }).result : null;
+ log(
+ `Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`,
+ );
+ writeOutput({
+ status: 'success',
+ result: textResult || null,
+ newSessionId,
+ });
+ }
+ }
+
+ ipcPolling = false;
+ log(
+ `Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`,
+ );
+ return { newSessionId, lastAssistantUuid, closedDuringQuery };
+}
+
+async function main(): Promise {
+ let containerInput: ContainerInput;
+
+ try {
+ const stdinData = await readStdin();
+ containerInput = JSON.parse(stdinData);
+ // Delete the temp file the entrypoint wrote — it contains secrets
+ try {
+ fs.unlinkSync('/workspace/ipc/input.json');
+ } catch {
+ /* may not exist */
+ }
+ log(`Received input for group: ${containerInput.groupFolder}`);
+ } catch (err) {
+ writeOutput({
+ status: 'error',
+ result: null,
+ error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`,
+ });
+ process.exit(1);
+ }
+
+ // Build SDK env: merge secrets into process.env for the SDK only.
+ // Secrets never touch process.env itself, so Bash subprocesses can't see them.
+ const sdkEnv: Record = { ...process.env };
+ for (const [key, value] of Object.entries(containerInput.secrets || {})) {
+ sdkEnv[key] = value;
+ }
+
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
+ const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
+
+ let sessionId = containerInput.sessionId;
+ fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
+
+ // Clean up stale _close sentinel from previous container runs
+ try {
+ fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
+ } catch {
+ /* ignore */
+ }
+
+ // Build initial prompt (drain any pending IPC messages too)
+ let prompt = containerInput.prompt;
+ if (containerInput.isScheduledTask) {
+ prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${prompt}`;
+ }
+ const pending = drainIpcInput();
+ if (pending.length > 0) {
+ log(`Draining ${pending.length} pending IPC messages into initial prompt`);
+ prompt += '\n' + pending.join('\n');
+ }
+
+ // Query loop: run query → wait for IPC message → run new query → repeat
+ let resumeAt: string | undefined;
+ try {
+ while (true) {
+ log(
+ `Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`,
+ );
+
+ const queryResult = await runQuery(
+ prompt,
+ sessionId,
+ mcpServerPath,
+ containerInput,
+ sdkEnv,
+ resumeAt,
+ );
+ if (queryResult.newSessionId) {
+ sessionId = queryResult.newSessionId;
+ }
+ if (queryResult.lastAssistantUuid) {
+ resumeAt = queryResult.lastAssistantUuid;
+ }
+
+ // If _close was consumed during the query, exit immediately.
+ // Don't emit a session-update marker (it would reset the host's
+ // idle timer and cause a 30-min delay before the next _close).
+ if (queryResult.closedDuringQuery) {
+ log('Close sentinel consumed during query, exiting');
+ break;
+ }
+
+ // Emit session update so host can track it
+ writeOutput({ status: 'success', result: null, newSessionId: sessionId });
+
+ log('Query ended, waiting for next IPC message...');
+
+ // Wait for the next message or _close sentinel
+ const nextMessage = await waitForIpcMessage();
+ if (nextMessage === null) {
+ log('Close sentinel received, exiting');
+ break;
+ }
+
+ log(`Got new message (${nextMessage.length} chars), starting new query`);
+ prompt = nextMessage;
+ }
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : String(err);
+ log(`Agent error: ${errorMessage}`);
+ writeOutput({
+ status: 'error',
+ result: null,
+ newSessionId: sessionId,
+ error: errorMessage,
+ });
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/.agent/skills/add-gmail/modify/container/agent-runner/src/index.ts.intent.md b/.agent/skills/add-gmail/modify/container/agent-runner/src/index.ts.intent.md
new file mode 100644
index 0000000..dc5ff4a
--- /dev/null
+++ b/.agent/skills/add-gmail/modify/container/agent-runner/src/index.ts.intent.md
@@ -0,0 +1,37 @@
+# Intent: container/agent-runner/src/index.ts modifications
+
+## What changed
+
+Added Gmail MCP server to the agent's available tools so it can read and send emails.
+
+## Key sections
+
+### mcpServers (inside runQuery → query() call)
+
+- Added: `gmail` MCP server alongside the existing `nanoclaw` server:
+ ```
+ gmail: {
+ command: 'npx',
+ args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'],
+ },
+ ```
+
+### allowedTools (inside runQuery → query() call)
+
+- Added: `'mcp__gmail__*'` to allow all Gmail MCP tools
+
+## Invariants
+
+- The `nanoclaw` MCP server configuration is unchanged
+- All existing allowed tools are preserved
+- The query loop, IPC handling, MessageStream, and all other logic is untouched
+- Hooks (PreCompact, sanitize Bash) are unchanged
+- Output protocol (markers) is unchanged
+
+## Must-keep
+
+- The `nanoclaw` MCP server with its environment variables
+- All existing allowedTools entries
+- The hook system (PreCompact, PreToolUse sanitize)
+- The IPC input/close sentinel handling
+- The MessageStream class and query loop
diff --git a/.agent/skills/add-gmail/modify/src/container-runner.ts b/.agent/skills/add-gmail/modify/src/container-runner.ts
new file mode 100644
index 0000000..88573de
--- /dev/null
+++ b/.agent/skills/add-gmail/modify/src/container-runner.ts
@@ -0,0 +1,698 @@
+/**
+ * Container Runner for NanoClaw
+ * Spawns agent execution in containers and handles IPC
+ */
+import { ChildProcess, exec, spawn } from 'child_process';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+
+import {
+ CONTAINER_IMAGE,
+ CONTAINER_MAX_OUTPUT_SIZE,
+ CONTAINER_TIMEOUT,
+ DATA_DIR,
+ GROUPS_DIR,
+ IDLE_TIMEOUT,
+ TIMEZONE,
+} from './config.js';
+import { readEnvFile } from './env.js';
+import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
+import { logger } from './logger.js';
+import {
+ CONTAINER_RUNTIME_BIN,
+ readonlyMountArgs,
+ stopContainer,
+} from './container-runtime.js';
+import { validateAdditionalMounts } from './mount-security.js';
+import { RegisteredGroup } from './types.js';
+
+// Sentinel markers for robust output parsing (must match agent-runner)
+const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
+const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
+
+export interface ContainerInput {
+ prompt: string;
+ sessionId?: string;
+ groupFolder: string;
+ chatJid: string;
+ isMain: boolean;
+ isScheduledTask?: boolean;
+ assistantName?: string;
+ secrets?: Record;
+}
+
+export interface ContainerOutput {
+ status: 'success' | 'error';
+ result: string | null;
+ newSessionId?: string;
+ error?: string;
+}
+
+interface VolumeMount {
+ hostPath: string;
+ containerPath: string;
+ readonly: boolean;
+}
+
+function buildVolumeMounts(
+ group: RegisteredGroup,
+ isMain: boolean,
+): VolumeMount[] {
+ const mounts: VolumeMount[] = [];
+ const projectRoot = process.cwd();
+ const homeDir = os.homedir();
+ const groupDir = resolveGroupFolderPath(group.folder);
+
+ if (isMain) {
+ // Main gets the project root read-only. Writable paths the agent needs
+ // (group folder, IPC, .agent/) are mounted separately below.
+ // Read-only prevents the agent from modifying host application code
+ // (src/, dist/, package.json, etc.) which would bypass the sandbox
+ // entirely on next restart.
+ mounts.push({
+ hostPath: projectRoot,
+ containerPath: '/workspace/project',
+ readonly: true,
+ });
+
+ // Main also gets its group folder as the working directory
+ mounts.push({
+ hostPath: groupDir,
+ containerPath: '/workspace/group',
+ readonly: false,
+ });
+ } else {
+ // Other groups only get their own folder
+ mounts.push({
+ hostPath: groupDir,
+ containerPath: '/workspace/group',
+ readonly: false,
+ });
+
+ // Global memory directory (read-only for non-main)
+ // Only directory mounts are supported, not file mounts
+ const globalDir = path.join(GROUPS_DIR, 'global');
+ if (fs.existsSync(globalDir)) {
+ mounts.push({
+ hostPath: globalDir,
+ containerPath: '/workspace/global',
+ readonly: true,
+ });
+ }
+ }
+
+ // Per-group Claude sessions directory (isolated from other groups)
+ // Each group gets their own .agent/ to prevent cross-group session access
+ const groupSessionsDir = path.join(
+ DATA_DIR,
+ 'sessions',
+ group.folder,
+ '.agent',
+ );
+ fs.mkdirSync(groupSessionsDir, { recursive: true });
+ const settingsFile = path.join(groupSessionsDir, 'settings.json');
+ if (!fs.existsSync(settingsFile)) {
+ fs.writeFileSync(
+ settingsFile,
+ JSON.stringify(
+ {
+ env: {
+ // Enable agent swarms (subagent orchestration)
+ // https://code.agent.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions
+ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
+ // Load AGENT.md from additional mounted directories
+ // https://code.agent.com/docs/en/memory#load-memory-from-additional-directories
+ CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
+ // Enable Claude's memory feature (persists user preferences between sessions)
+ // https://code.agent.com/docs/en/memory#manage-auto-memory
+ CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
+ },
+ },
+ null,
+ 2,
+ ) + '\n',
+ );
+ }
+
+ // Sync skills from container/skills/ into each group's .agent/skills/
+ const skillsSrc = path.join(process.cwd(), 'container', 'skills');
+ const skillsDst = path.join(groupSessionsDir, 'skills');
+ if (fs.existsSync(skillsSrc)) {
+ for (const skillDir of fs.readdirSync(skillsSrc)) {
+ const srcDir = path.join(skillsSrc, skillDir);
+ if (!fs.statSync(srcDir).isDirectory()) continue;
+ const dstDir = path.join(skillsDst, skillDir);
+ fs.cpSync(srcDir, dstDir, { recursive: true });
+ }
+ }
+ mounts.push({
+ hostPath: groupSessionsDir,
+ containerPath: '/home/node/.agent',
+ readonly: false,
+ });
+
+ // Gmail credentials directory (for Gmail MCP inside the container)
+ const gmailDir = path.join(homeDir, '.gmail-mcp');
+ if (fs.existsSync(gmailDir)) {
+ mounts.push({
+ hostPath: gmailDir,
+ containerPath: '/home/node/.gmail-mcp',
+ readonly: false, // MCP may need to refresh OAuth tokens
+ });
+ }
+
+ // Per-group IPC namespace: each group gets its own IPC directory
+ // This prevents cross-group privilege escalation via IPC
+ const groupIpcDir = resolveGroupIpcPath(group.folder);
+ fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
+ fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
+ fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
+ mounts.push({
+ hostPath: groupIpcDir,
+ containerPath: '/workspace/ipc',
+ readonly: false,
+ });
+
+ // Copy agent-runner source into a per-group writable location so agents
+ // can customize it (add tools, change behavior) without affecting other
+ // groups. Recompiled on container startup via entrypoint.sh.
+ const agentRunnerSrc = path.join(
+ projectRoot,
+ 'container',
+ 'agent-runner',
+ 'src',
+ );
+ const groupAgentRunnerDir = path.join(
+ DATA_DIR,
+ 'sessions',
+ group.folder,
+ 'agent-runner-src',
+ );
+ if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) {
+ fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
+ }
+ mounts.push({
+ hostPath: groupAgentRunnerDir,
+ containerPath: '/app/src',
+ readonly: false,
+ });
+
+ // Additional mounts validated against external allowlist (tamper-proof from containers)
+ if (group.containerConfig?.additionalMounts) {
+ const validatedMounts = validateAdditionalMounts(
+ group.containerConfig.additionalMounts,
+ group.name,
+ isMain,
+ );
+ mounts.push(...validatedMounts);
+ }
+
+ return mounts;
+}
+
+/**
+ * Read allowed secrets from .env for passing to the container via stdin.
+ * Secrets are never written to disk or mounted as files.
+ */
+function readSecrets(): Record {
+ return readEnvFile(['ZAI_API_KEY', 'ANTHROPIC_API_KEY']);
+}
+
+function buildContainerArgs(
+ mounts: VolumeMount[],
+ containerName: string,
+): string[] {
+ const args: string[] = ['run', '-i', '--rm', '--name', containerName];
+
+ // Pass host timezone so container's local time matches the user's
+ args.push('-e', `TZ=${TIMEZONE}`);
+
+ // Run as host user so bind-mounted files are accessible.
+ // Skip when running as root (uid 0), as the container's node user (uid 1000),
+ // or when getuid is unavailable (native Windows without WSL).
+ const hostUid = process.getuid?.();
+ const hostGid = process.getgid?.();
+ if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
+ args.push('--user', `${hostUid}:${hostGid}`);
+ args.push('-e', 'HOME=/home/node');
+ }
+
+ for (const mount of mounts) {
+ if (mount.readonly) {
+ args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath));
+ } else {
+ args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
+ }
+ }
+
+ args.push(CONTAINER_IMAGE);
+
+ return args;
+}
+
+export async function runContainerAgent(
+ group: RegisteredGroup,
+ input: ContainerInput,
+ onProcess: (proc: ChildProcess, containerName: string) => void,
+ onOutput?: (output: ContainerOutput) => Promise,
+): Promise {
+ const startTime = Date.now();
+
+ const groupDir = resolveGroupFolderPath(group.folder);
+ fs.mkdirSync(groupDir, { recursive: true });
+
+ const mounts = buildVolumeMounts(group, input.isMain);
+ const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
+ const containerName = `clawdie-cp-${safeName}-${Date.now()}`;
+ const containerArgs = buildContainerArgs(mounts, containerName);
+
+ logger.debug(
+ {
+ group: group.name,
+ containerName,
+ mounts: mounts.map(
+ (m) =>
+ `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
+ ),
+ containerArgs: containerArgs.join(' '),
+ },
+ 'Container mount configuration',
+ );
+
+ logger.info(
+ {
+ group: group.name,
+ containerName,
+ mountCount: mounts.length,
+ isMain: input.isMain,
+ },
+ 'Spawning container agent',
+ );
+
+ const logsDir = path.join(groupDir, 'logs');
+ fs.mkdirSync(logsDir, { recursive: true });
+
+ return new Promise((resolve) => {
+ const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, {
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+
+ onProcess(container, containerName);
+
+ let stdout = '';
+ let stderr = '';
+ let stdoutTruncated = false;
+ let stderrTruncated = false;
+
+ // Pass secrets via stdin (never written to disk or mounted as files)
+ input.secrets = readSecrets();
+ container.stdin.write(JSON.stringify(input));
+ container.stdin.end();
+ // Remove secrets from input so they don't appear in logs
+ delete input.secrets;
+
+ // Streaming output: parse OUTPUT_START/END marker pairs as they arrive
+ let parseBuffer = '';
+ let newSessionId: string | undefined;
+ let outputChain = Promise.resolve();
+
+ container.stdout.on('data', (data) => {
+ const chunk = data.toString();
+
+ // Always accumulate for logging
+ if (!stdoutTruncated) {
+ const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length;
+ if (chunk.length > remaining) {
+ stdout += chunk.slice(0, remaining);
+ stdoutTruncated = true;
+ logger.warn(
+ { group: group.name, size: stdout.length },
+ 'Container stdout truncated due to size limit',
+ );
+ } else {
+ stdout += chunk;
+ }
+ }
+
+ // Stream-parse for output markers
+ if (onOutput) {
+ parseBuffer += chunk;
+ let startIdx: number;
+ while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) {
+ const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx);
+ if (endIdx === -1) break; // Incomplete pair, wait for more data
+
+ const jsonStr = parseBuffer
+ .slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
+ .trim();
+ parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length);
+
+ try {
+ const parsed: ContainerOutput = JSON.parse(jsonStr);
+ if (parsed.newSessionId) {
+ newSessionId = parsed.newSessionId;
+ }
+ hadStreamingOutput = true;
+ // Activity detected — reset the hard timeout
+ resetTimeout();
+ // Call onOutput for all markers (including null results)
+ // so idle timers start even for "silent" query completions.
+ outputChain = outputChain.then(() => onOutput(parsed));
+ } catch (err) {
+ logger.warn(
+ { group: group.name, error: err },
+ 'Failed to parse streamed output chunk',
+ );
+ }
+ }
+ }
+ });
+
+ container.stderr.on('data', (data) => {
+ const chunk = data.toString();
+ const lines = chunk.trim().split('\n');
+ for (const line of lines) {
+ if (line) logger.debug({ container: group.folder }, line);
+ }
+ // Don't reset timeout on stderr — SDK writes debug logs continuously.
+ // Timeout only resets on actual output (OUTPUT_MARKER in stdout).
+ if (stderrTruncated) return;
+ const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length;
+ if (chunk.length > remaining) {
+ stderr += chunk.slice(0, remaining);
+ stderrTruncated = true;
+ logger.warn(
+ { group: group.name, size: stderr.length },
+ 'Container stderr truncated due to size limit',
+ );
+ } else {
+ stderr += chunk;
+ }
+ });
+
+ let timedOut = false;
+ let hadStreamingOutput = false;
+ const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
+ // Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the
+ // graceful _close sentinel has time to trigger before the hard kill fires.
+ const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000);
+
+ const killOnTimeout = () => {
+ timedOut = true;
+ logger.error(
+ { group: group.name, containerName },
+ 'Container timeout, stopping gracefully',
+ );
+ exec(stopContainer(containerName), { timeout: 15000 }, (err) => {
+ if (err) {
+ logger.warn(
+ { group: group.name, containerName, err },
+ 'Graceful stop failed, force killing',
+ );
+ container.kill('SIGKILL');
+ }
+ });
+ };
+
+ let timeout = setTimeout(killOnTimeout, timeoutMs);
+
+ // Reset the timeout whenever there's activity (streaming output)
+ const resetTimeout = () => {
+ clearTimeout(timeout);
+ timeout = setTimeout(killOnTimeout, timeoutMs);
+ };
+
+ container.on('close', (code) => {
+ clearTimeout(timeout);
+ const duration = Date.now() - startTime;
+
+ if (timedOut) {
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
+ const timeoutLog = path.join(logsDir, `container-${ts}.log`);
+ fs.writeFileSync(
+ timeoutLog,
+ [
+ `=== Container Run Log (TIMEOUT) ===`,
+ `Timestamp: ${new Date().toISOString()}`,
+ `Group: ${group.name}`,
+ `Container: ${containerName}`,
+ `Duration: ${duration}ms`,
+ `Exit Code: ${code}`,
+ `Had Streaming Output: ${hadStreamingOutput}`,
+ ].join('\n'),
+ );
+
+ // Timeout after output = idle cleanup, not failure.
+ // The agent already sent its response; this is just the
+ // container being reaped after the idle period expired.
+ if (hadStreamingOutput) {
+ logger.info(
+ { group: group.name, containerName, duration, code },
+ 'Container timed out after output (idle cleanup)',
+ );
+ outputChain.then(() => {
+ resolve({
+ status: 'success',
+ result: null,
+ newSessionId,
+ });
+ });
+ return;
+ }
+
+ logger.error(
+ { group: group.name, containerName, duration, code },
+ 'Container timed out with no output',
+ );
+
+ resolve({
+ status: 'error',
+ result: null,
+ error: `Container timed out after ${configTimeout}ms`,
+ });
+ return;
+ }
+
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const logFile = path.join(logsDir, `container-${timestamp}.log`);
+ const isVerbose =
+ process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
+
+ const logLines = [
+ `=== Container Run Log ===`,
+ `Timestamp: ${new Date().toISOString()}`,
+ `Group: ${group.name}`,
+ `IsMain: ${input.isMain}`,
+ `Duration: ${duration}ms`,
+ `Exit Code: ${code}`,
+ `Stdout Truncated: ${stdoutTruncated}`,
+ `Stderr Truncated: ${stderrTruncated}`,
+ ``,
+ ];
+
+ const isError = code !== 0;
+
+ if (isVerbose || isError) {
+ logLines.push(
+ `=== Input ===`,
+ JSON.stringify(input, null, 2),
+ ``,
+ `=== Container Args ===`,
+ containerArgs.join(' '),
+ ``,
+ `=== Mounts ===`,
+ mounts
+ .map(
+ (m) =>
+ `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
+ )
+ .join('\n'),
+ ``,
+ `=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`,
+ stderr,
+ ``,
+ `=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`,
+ stdout,
+ );
+ } else {
+ logLines.push(
+ `=== Input Summary ===`,
+ `Prompt length: ${input.prompt.length} chars`,
+ `Session ID: ${input.sessionId || 'new'}`,
+ ``,
+ `=== Mounts ===`,
+ mounts
+ .map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`)
+ .join('\n'),
+ ``,
+ );
+ }
+
+ fs.writeFileSync(logFile, logLines.join('\n'));
+ logger.debug({ logFile, verbose: isVerbose }, 'Container log written');
+
+ if (code !== 0) {
+ logger.error(
+ {
+ group: group.name,
+ code,
+ duration,
+ stderr,
+ stdout,
+ logFile,
+ },
+ 'Container exited with error',
+ );
+
+ resolve({
+ status: 'error',
+ result: null,
+ error: `Container exited with code ${code}: ${stderr.slice(-200)}`,
+ });
+ return;
+ }
+
+ // Streaming mode: wait for output chain to settle, return completion marker
+ if (onOutput) {
+ outputChain.then(() => {
+ logger.info(
+ { group: group.name, duration, newSessionId },
+ 'Container completed (streaming mode)',
+ );
+ resolve({
+ status: 'success',
+ result: null,
+ newSessionId,
+ });
+ });
+ return;
+ }
+
+ // Legacy mode: parse the last output marker pair from accumulated stdout
+ try {
+ // Extract JSON between sentinel markers for robust parsing
+ const startIdx = stdout.indexOf(OUTPUT_START_MARKER);
+ const endIdx = stdout.indexOf(OUTPUT_END_MARKER);
+
+ let jsonLine: string;
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
+ jsonLine = stdout
+ .slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
+ .trim();
+ } else {
+ // Fallback: last non-empty line (backwards compatibility)
+ const lines = stdout.trim().split('\n');
+ jsonLine = lines[lines.length - 1];
+ }
+
+ const output: ContainerOutput = JSON.parse(jsonLine);
+
+ logger.info(
+ {
+ group: group.name,
+ duration,
+ status: output.status,
+ hasResult: !!output.result,
+ },
+ 'Container completed',
+ );
+
+ resolve(output);
+ } catch (err) {
+ logger.error(
+ {
+ group: group.name,
+ stdout,
+ stderr,
+ error: err,
+ },
+ 'Failed to parse container output',
+ );
+
+ resolve({
+ status: 'error',
+ result: null,
+ error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`,
+ });
+ }
+ });
+
+ container.on('error', (err) => {
+ clearTimeout(timeout);
+ logger.error(
+ { group: group.name, containerName, error: err },
+ 'Container spawn error',
+ );
+ resolve({
+ status: 'error',
+ result: null,
+ error: `Container spawn error: ${err.message}`,
+ });
+ });
+ });
+}
+
+export function writeTasksSnapshot(
+ groupFolder: string,
+ isMain: boolean,
+ tasks: Array<{
+ id: string;
+ groupFolder: string;
+ prompt: string;
+ schedule_type: string;
+ schedule_value: string;
+ status: string;
+ next_run: string | null;
+ }>,
+): void {
+ // Write filtered tasks to the group's IPC directory
+ const groupIpcDir = resolveGroupIpcPath(groupFolder);
+ fs.mkdirSync(groupIpcDir, { recursive: true });
+
+ // Main sees all tasks, others only see their own
+ const filteredTasks = isMain
+ ? tasks
+ : tasks.filter((t) => t.groupFolder === groupFolder);
+
+ const tasksFile = path.join(groupIpcDir, 'current_tasks.json');
+ fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2));
+}
+
+export interface AvailableGroup {
+ jid: string;
+ name: string;
+ lastActivity: string;
+ isRegistered: boolean;
+}
+
+/**
+ * Write available groups snapshot for the container to read.
+ * Only main group can see all available groups (for activation).
+ * Non-main groups only see their own registration status.
+ */
+export function writeGroupsSnapshot(
+ groupFolder: string,
+ isMain: boolean,
+ groups: AvailableGroup[],
+ registeredJids: Set,
+): void {
+ const groupIpcDir = resolveGroupIpcPath(groupFolder);
+ fs.mkdirSync(groupIpcDir, { recursive: true });
+
+ // Main sees all groups; others see nothing (they can't activate groups)
+ const visibleGroups = isMain ? groups : [];
+
+ const groupsFile = path.join(groupIpcDir, 'available_groups.json');
+ fs.writeFileSync(
+ groupsFile,
+ JSON.stringify(
+ {
+ groups: visibleGroups,
+ lastSync: new Date().toISOString(),
+ },
+ null,
+ 2,
+ ),
+ );
+}
diff --git a/.agent/skills/add-gmail/modify/src/container-runner.ts.intent.md b/.agent/skills/add-gmail/modify/src/container-runner.ts.intent.md
new file mode 100644
index 0000000..69cf3b3
--- /dev/null
+++ b/.agent/skills/add-gmail/modify/src/container-runner.ts.intent.md
@@ -0,0 +1,42 @@
+# Intent: src/container-runner.ts modifications
+
+## What changed
+
+Added a volume mount for Gmail OAuth credentials (`~/.gmail-mcp/`) so the Gmail MCP server inside the container can authenticate with Google.
+
+## Key sections
+
+### buildVolumeMounts()
+
+- Added: Gmail credentials mount after the `.agent` sessions mount:
+ ```
+ const gmailDir = path.join(homeDir, '.gmail-mcp');
+ if (fs.existsSync(gmailDir)) {
+ mounts.push({
+ hostPath: gmailDir,
+ containerPath: '/home/node/.gmail-mcp',
+ readonly: false, // MCP may need to refresh OAuth tokens
+ });
+ }
+ ```
+- Uses `os.homedir()` to resolve the home directory
+- Mount is read-write because the Gmail MCP server needs to refresh OAuth tokens
+- Mount is conditional — only added if `~/.gmail-mcp/` exists on the host
+
+### Imports
+
+- Added: `os` import for `os.homedir()`
+
+## Invariants
+
+- All existing mounts are unchanged
+- Mount ordering is preserved (Gmail added after session mounts, before additional mounts)
+- The `buildContainerArgs`, `runContainerAgent`, and all other functions are untouched
+- Additional mount validation via `validateAdditionalMounts` is unchanged
+
+## Must-keep
+
+- All existing volume mounts (project root, group dir, global, sessions, IPC, agent-runner, additional)
+- The mount security model (allowlist validation for additional mounts)
+- The `readSecrets` function and stdin-based secret passing
+- Container lifecycle (spawn, timeout, output parsing)
diff --git a/.agent/skills/add-gmail/modify/src/index.ts b/.agent/skills/add-gmail/modify/src/index.ts
new file mode 100644
index 0000000..be26a17
--- /dev/null
+++ b/.agent/skills/add-gmail/modify/src/index.ts
@@ -0,0 +1,507 @@
+import fs from 'fs';
+import path from 'path';
+
+import {
+ ASSISTANT_NAME,
+ IDLE_TIMEOUT,
+ MAIN_GROUP_FOLDER,
+ POLL_INTERVAL,
+ TRIGGER_PATTERN,
+} from './config.js';
+import { GmailChannel } from './channels/gmail.js';
+import { WhatsAppChannel } from './channels/whatsapp.js';
+import {
+ ContainerOutput,
+ runContainerAgent,
+ writeGroupsSnapshot,
+ writeTasksSnapshot,
+} from './container-runner.js';
+import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js';
+import {
+ getAllChats,
+ getAllRegisteredGroups,
+ getAllSessions,
+ getAllTasks,
+ getMessagesSince,
+ getNewMessages,
+ getRouterState,
+ initDatabase,
+ setRegisteredGroup,
+ setRouterState,
+ setSession,
+ storeChatMetadata,
+ storeMessage,
+} from './db.js';
+import { GroupQueue } from './group-queue.js';
+import { resolveGroupFolderPath } from './group-folder.js';
+import { startIpcWatcher } from './ipc.js';
+import { findChannel, formatMessages, formatOutbound } from './router.js';
+import { startSchedulerLoop } from './task-scheduler.js';
+import { Channel, NewMessage, RegisteredGroup } from './types.js';
+import { logger } from './logger.js';
+
+// Re-export for backwards compatibility during refactor
+export { escapeXml, formatMessages } from './router.js';
+
+let lastTimestamp = '';
+let sessions: Record = {};
+let registeredGroups: Record = {};
+let lastAgentTimestamp: Record = {};
+let messageLoopRunning = false;
+
+let whatsapp: WhatsAppChannel;
+const channels: Channel[] = [];
+const queue = new GroupQueue();
+
+function loadState(): void {
+ lastTimestamp = getRouterState('last_timestamp') || '';
+ const agentTs = getRouterState('last_agent_timestamp');
+ try {
+ lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {};
+ } catch {
+ logger.warn('Corrupted last_agent_timestamp in DB, resetting');
+ lastAgentTimestamp = {};
+ }
+ sessions = getAllSessions();
+ registeredGroups = getAllRegisteredGroups();
+ logger.info(
+ { groupCount: Object.keys(registeredGroups).length },
+ 'State loaded',
+ );
+}
+
+function saveState(): void {
+ setRouterState('last_timestamp', lastTimestamp);
+ setRouterState(
+ 'last_agent_timestamp',
+ JSON.stringify(lastAgentTimestamp),
+ );
+}
+
+function registerGroup(jid: string, group: RegisteredGroup): void {
+ let groupDir: string;
+ try {
+ groupDir = resolveGroupFolderPath(group.folder);
+ } catch (err) {
+ logger.warn(
+ { jid, folder: group.folder, err },
+ 'Rejecting group registration with invalid folder',
+ );
+ return;
+ }
+
+ registeredGroups[jid] = group;
+ setRegisteredGroup(jid, group);
+
+ // Create group folder
+ fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
+
+ logger.info(
+ { jid, name: group.name, folder: group.folder },
+ 'Group registered',
+ );
+}
+
+/**
+ * Get available groups list for the agent.
+ * Returns groups ordered by most recent activity.
+ */
+export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] {
+ const chats = getAllChats();
+ const registeredJids = new Set(Object.keys(registeredGroups));
+
+ return chats
+ .filter((c) => c.jid !== '__group_sync__' && c.is_group)
+ .map((c) => ({
+ jid: c.jid,
+ name: c.name,
+ lastActivity: c.last_message_time,
+ isRegistered: registeredJids.has(c.jid),
+ }));
+}
+
+/** @internal - exported for testing */
+export function _setRegisteredGroups(groups: Record): void {
+ registeredGroups = groups;
+}
+
+/**
+ * Process all pending messages for a group.
+ * Called by the GroupQueue when it's this group's turn.
+ */
+async function processGroupMessages(chatJid: string): Promise {
+ const group = registeredGroups[chatJid];
+ if (!group) return true;
+
+ const channel = findChannel(channels, chatJid);
+ if (!channel) {
+ console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`);
+ return true;
+ }
+
+ const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
+
+ const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
+ const missedMessages = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
+
+ if (missedMessages.length === 0) return true;
+
+ // For non-main groups, check if trigger is required and present
+ if (!isMainGroup && group.requiresTrigger !== false) {
+ const hasTrigger = missedMessages.some((m) =>
+ TRIGGER_PATTERN.test(m.content.trim()),
+ );
+ if (!hasTrigger) return true;
+ }
+
+ const prompt = formatMessages(missedMessages);
+
+ // Advance cursor so the piping path in startMessageLoop won't re-fetch
+ // these messages. Save the old cursor so we can roll back on error.
+ const previousCursor = lastAgentTimestamp[chatJid] || '';
+ lastAgentTimestamp[chatJid] =
+ missedMessages[missedMessages.length - 1].timestamp;
+ saveState();
+
+ logger.info(
+ { group: group.name, messageCount: missedMessages.length },
+ 'Processing messages',
+ );
+
+ // Track idle timer for closing stdin when agent is idle
+ let idleTimer: ReturnType | null = null;
+
+ const resetIdleTimer = () => {
+ if (idleTimer) clearTimeout(idleTimer);
+ idleTimer = setTimeout(() => {
+ logger.debug({ group: group.name }, 'Idle timeout, closing container stdin');
+ queue.closeStdin(chatJid);
+ }, IDLE_TIMEOUT);
+ };
+
+ await channel.setTyping?.(chatJid, true);
+ let hadError = false;
+ let outputSentToUser = false;
+
+ const output = await runAgent(group, prompt, chatJid, async (result) => {
+ // Streaming output callback — called for each agent result
+ if (result.result) {
+ const raw = typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
+ // Strip ... blocks — agent uses these for internal reasoning
+ const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim();
+ logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`);
+ if (text) {
+ await channel.sendMessage(chatJid, text);
+ outputSentToUser = true;
+ }
+ // Only reset idle timer on actual results, not session-update markers (result: null)
+ resetIdleTimer();
+ }
+
+ if (result.status === 'success') {
+ queue.notifyIdle(chatJid);
+ }
+
+ if (result.status === 'error') {
+ hadError = true;
+ }
+ });
+
+ await channel.setTyping?.(chatJid, false);
+ if (idleTimer) clearTimeout(idleTimer);
+
+ if (output === 'error' || hadError) {
+ // If we already sent output to the user, don't roll back the cursor —
+ // the user got their response and re-processing would send duplicates.
+ if (outputSentToUser) {
+ logger.warn({ group: group.name }, 'Agent error after output was sent, skipping cursor rollback to prevent duplicates');
+ return true;
+ }
+ // Roll back cursor so retries can re-process these messages
+ lastAgentTimestamp[chatJid] = previousCursor;
+ saveState();
+ logger.warn({ group: group.name }, 'Agent error, rolled back message cursor for retry');
+ return false;
+ }
+
+ return true;
+}
+
+async function runAgent(
+ group: RegisteredGroup,
+ prompt: string,
+ chatJid: string,
+ onOutput?: (output: ContainerOutput) => Promise,
+): Promise<'success' | 'error'> {
+ const isMain = group.folder === MAIN_GROUP_FOLDER;
+ const sessionId = sessions[group.folder];
+
+ // Update tasks snapshot for container to read (filtered by group)
+ const tasks = getAllTasks();
+ writeTasksSnapshot(
+ group.folder,
+ isMain,
+ tasks.map((t) => ({
+ id: t.id,
+ groupFolder: t.group_folder,
+ prompt: t.prompt,
+ schedule_type: t.schedule_type,
+ schedule_value: t.schedule_value,
+ status: t.status,
+ next_run: t.next_run,
+ })),
+ );
+
+ // Update available groups snapshot (main group only can see all groups)
+ const availableGroups = getAvailableGroups();
+ writeGroupsSnapshot(
+ group.folder,
+ isMain,
+ availableGroups,
+ new Set(Object.keys(registeredGroups)),
+ );
+
+ // Wrap onOutput to track session ID from streamed results
+ const wrappedOnOutput = onOutput
+ ? async (output: ContainerOutput) => {
+ if (output.newSessionId) {
+ sessions[group.folder] = output.newSessionId;
+ setSession(group.folder, output.newSessionId);
+ }
+ await onOutput(output);
+ }
+ : undefined;
+
+ try {
+ const output = await runContainerAgent(
+ group,
+ {
+ prompt,
+ sessionId,
+ groupFolder: group.folder,
+ chatJid,
+ isMain,
+ assistantName: ASSISTANT_NAME,
+ },
+ (proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder),
+ wrappedOnOutput,
+ );
+
+ if (output.newSessionId) {
+ sessions[group.folder] = output.newSessionId;
+ setSession(group.folder, output.newSessionId);
+ }
+
+ if (output.status === 'error') {
+ logger.error(
+ { group: group.name, error: output.error },
+ 'Container agent error',
+ );
+ return 'error';
+ }
+
+ return 'success';
+ } catch (err) {
+ logger.error({ group: group.name, err }, 'Agent error');
+ return 'error';
+ }
+}
+
+async function startMessageLoop(): Promise {
+ if (messageLoopRunning) {
+ logger.debug('Message loop already running, skipping duplicate start');
+ return;
+ }
+ messageLoopRunning = true;
+
+ logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`);
+
+ while (true) {
+ try {
+ const jids = Object.keys(registeredGroups);
+ const { messages, newTimestamp } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME);
+
+ if (messages.length > 0) {
+ logger.info({ count: messages.length }, 'New messages');
+
+ // Advance the "seen" cursor for all messages immediately
+ lastTimestamp = newTimestamp;
+ saveState();
+
+ // Deduplicate by group
+ const messagesByGroup = new Map();
+ for (const msg of messages) {
+ const existing = messagesByGroup.get(msg.chat_jid);
+ if (existing) {
+ existing.push(msg);
+ } else {
+ messagesByGroup.set(msg.chat_jid, [msg]);
+ }
+ }
+
+ for (const [chatJid, groupMessages] of messagesByGroup) {
+ const group = registeredGroups[chatJid];
+ if (!group) continue;
+
+ const channel = findChannel(channels, chatJid);
+ if (!channel) {
+ console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`);
+ continue;
+ }
+
+ const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
+ const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
+
+ // For non-main groups, only act on trigger messages.
+ // Non-trigger messages accumulate in DB and get pulled as
+ // context when a trigger eventually arrives.
+ if (needsTrigger) {
+ const hasTrigger = groupMessages.some((m) =>
+ TRIGGER_PATTERN.test(m.content.trim()),
+ );
+ if (!hasTrigger) continue;
+ }
+
+ // Pull all messages since lastAgentTimestamp so non-trigger
+ // context that accumulated between triggers is included.
+ const allPending = getMessagesSince(
+ chatJid,
+ lastAgentTimestamp[chatJid] || '',
+ ASSISTANT_NAME,
+ );
+ const messagesToSend =
+ allPending.length > 0 ? allPending : groupMessages;
+ const formatted = formatMessages(messagesToSend);
+
+ if (queue.sendMessage(chatJid, formatted)) {
+ logger.debug(
+ { chatJid, count: messagesToSend.length },
+ 'Piped messages to active container',
+ );
+ lastAgentTimestamp[chatJid] =
+ messagesToSend[messagesToSend.length - 1].timestamp;
+ saveState();
+ // Show typing indicator while the container processes the piped message
+ channel.setTyping?.(chatJid, true)?.catch((err) =>
+ logger.warn({ chatJid, err }, 'Failed to set typing indicator'),
+ );
+ } else {
+ // No active container — enqueue for a new one
+ queue.enqueueMessageCheck(chatJid);
+ }
+ }
+ }
+ } catch (err) {
+ logger.error({ err }, 'Error in message loop');
+ }
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
+ }
+}
+
+/**
+ * Startup recovery: check for unprocessed messages in registered groups.
+ * Handles crash between advancing lastTimestamp and processing messages.
+ */
+function recoverPendingMessages(): void {
+ for (const [chatJid, group] of Object.entries(registeredGroups)) {
+ const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
+ const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
+ if (pending.length > 0) {
+ logger.info(
+ { group: group.name, pendingCount: pending.length },
+ 'Recovery: found unprocessed messages',
+ );
+ queue.enqueueMessageCheck(chatJid);
+ }
+ }
+}
+
+function ensureContainerSystemRunning(): void {
+ ensureContainerRuntimeRunning();
+ cleanupOrphans();
+}
+
+async function main(): Promise {
+ ensureContainerSystemRunning();
+ initDatabase();
+ logger.info('Database initialized');
+ loadState();
+
+ // Graceful shutdown handlers
+ const shutdown = async (signal: string) => {
+ logger.info({ signal }, 'Shutdown signal received');
+ await queue.shutdown(10000);
+ for (const ch of channels) await ch.disconnect();
+ process.exit(0);
+ };
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
+ process.on('SIGINT', () => shutdown('SIGINT'));
+
+ // Channel callbacks (shared by all channels)
+ const channelOpts = {
+ onMessage: (_chatJid: string, msg: NewMessage) => storeMessage(msg),
+ onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) =>
+ storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
+ registeredGroups: () => registeredGroups,
+ };
+
+ // Create and connect channels
+ whatsapp = new WhatsAppChannel(channelOpts);
+ channels.push(whatsapp);
+ await whatsapp.connect();
+
+ const gmail = new GmailChannel(channelOpts);
+ channels.push(gmail);
+ try {
+ await gmail.connect();
+ } catch (err) {
+ logger.warn({ err }, 'Gmail channel failed to connect, continuing without it');
+ }
+
+ // Start subsystems (independently of connection handler)
+ startSchedulerLoop({
+ registeredGroups: () => registeredGroups,
+ getSessions: () => sessions,
+ queue,
+ onProcess: (groupJid, proc, containerName, groupFolder) => queue.registerProcess(groupJid, proc, containerName, groupFolder),
+ sendMessage: async (jid, rawText) => {
+ const channel = findChannel(channels, jid);
+ if (!channel) {
+ console.log(`Warning: no channel owns JID ${jid}, cannot send message`);
+ return;
+ }
+ const text = formatOutbound(rawText);
+ if (text) await channel.sendMessage(jid, text);
+ },
+ });
+ startIpcWatcher({
+ sendMessage: (jid, text) => {
+ const channel = findChannel(channels, jid);
+ if (!channel) throw new Error(`No channel for JID: ${jid}`);
+ return channel.sendMessage(jid, text);
+ },
+ registeredGroups: () => registeredGroups,
+ registerGroup,
+ syncGroupMetadata: (force) => whatsapp?.syncGroupMetadata(force) ?? Promise.resolve(),
+ getAvailableGroups,
+ writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj),
+ });
+ queue.setProcessMessagesFn(processGroupMessages);
+ recoverPendingMessages();
+ startMessageLoop().catch((err) => {
+ logger.fatal({ err }, 'Message loop crashed unexpectedly');
+ process.exit(1);
+ });
+}
+
+// Guard: only run when executed directly, not when imported by tests
+const isDirectRun =
+ process.argv[1] &&
+ new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname;
+
+if (isDirectRun) {
+ main().catch((err) => {
+ logger.error({ err }, 'Failed to start NanoClaw');
+ process.exit(1);
+ });
+}
diff --git a/.agent/skills/add-gmail/modify/src/index.ts.intent.md b/.agent/skills/add-gmail/modify/src/index.ts.intent.md
new file mode 100644
index 0000000..cd700f5
--- /dev/null
+++ b/.agent/skills/add-gmail/modify/src/index.ts.intent.md
@@ -0,0 +1,40 @@
+# Intent: src/index.ts modifications
+
+## What changed
+
+Added Gmail as a channel.
+
+## Key sections
+
+### Imports (top of file)
+
+- Added: `GmailChannel` from `./channels/gmail.js`
+
+### main()
+
+- Added Gmail channel creation:
+ ```
+ const gmail = new GmailChannel(channelOpts);
+ channels.push(gmail);
+ await gmail.connect();
+ ```
+- Gmail uses the same `channelOpts` callbacks as other channels
+- Incoming emails are delivered to the main group (agent decides how to respond, user can configure)
+
+## Invariants
+
+- All existing message processing logic (triggers, cursors, idle timers) is preserved
+- The `runAgent` function is completely unchanged
+- State management (loadState/saveState) is unchanged
+- Recovery logic is unchanged
+- Container runtime check is unchanged
+- Any other channel creation is untouched
+- Shutdown iterates `channels` array (Gmail is included automatically)
+
+## Must-keep
+
+- The `escapeXml` and `formatMessages` re-exports
+- The `_setRegisteredGroups` test helper
+- The `isDirectRun` guard at bottom
+- All error handling and cursor rollback logic in processGroupMessages
+- The outgoing queue flush and reconnection logic
diff --git a/.agent/skills/add-gmail/modify/src/routing.test.ts b/.agent/skills/add-gmail/modify/src/routing.test.ts
new file mode 100644
index 0000000..837b1da
--- /dev/null
+++ b/.agent/skills/add-gmail/modify/src/routing.test.ts
@@ -0,0 +1,119 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+
+import { _initTestDatabase, getAllChats, storeChatMetadata } from './db.js';
+import { getAvailableGroups, _setRegisteredGroups } from './index.js';
+
+beforeEach(() => {
+ _initTestDatabase();
+ _setRegisteredGroups({});
+});
+
+// --- JID ownership patterns ---
+
+describe('JID ownership patterns', () => {
+ // These test the patterns that will become ownsJid() on the Channel interface
+
+ it('WhatsApp group JID: ends with @g.us', () => {
+ const jid = '12345678@g.us';
+ expect(jid.endsWith('@g.us')).toBe(true);
+ });
+
+ it('WhatsApp DM JID: ends with @s.whatsapp.net', () => {
+ const jid = '12345678@s.whatsapp.net';
+ expect(jid.endsWith('@s.whatsapp.net')).toBe(true);
+ });
+
+ it('Gmail JID: starts with gmail:', () => {
+ const jid = 'gmail:abc123def';
+ expect(jid.startsWith('gmail:')).toBe(true);
+ });
+
+ it('Gmail thread JID: starts with gmail: followed by thread ID', () => {
+ const jid = 'gmail:18d3f4a5b6c7d8e9';
+ expect(jid.startsWith('gmail:')).toBe(true);
+ });
+});
+
+// --- getAvailableGroups ---
+
+describe('getAvailableGroups', () => {
+ it('returns only groups, excludes DMs', () => {
+ storeChatMetadata('group1@g.us', '2024-01-01T00:00:01.000Z', 'Group 1', 'whatsapp', true);
+ storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false);
+ storeChatMetadata('group2@g.us', '2024-01-01T00:00:03.000Z', 'Group 2', 'whatsapp', true);
+
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(2);
+ expect(groups.map((g) => g.jid)).toContain('group1@g.us');
+ expect(groups.map((g) => g.jid)).toContain('group2@g.us');
+ expect(groups.map((g) => g.jid)).not.toContain('user@s.whatsapp.net');
+ });
+
+ it('excludes __group_sync__ sentinel', () => {
+ storeChatMetadata('__group_sync__', '2024-01-01T00:00:00.000Z');
+ storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Group', 'whatsapp', true);
+
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(1);
+ expect(groups[0].jid).toBe('group@g.us');
+ });
+
+ it('marks registered groups correctly', () => {
+ storeChatMetadata('reg@g.us', '2024-01-01T00:00:01.000Z', 'Registered', 'whatsapp', true);
+ storeChatMetadata('unreg@g.us', '2024-01-01T00:00:02.000Z', 'Unregistered', 'whatsapp', true);
+
+ _setRegisteredGroups({
+ 'reg@g.us': {
+ name: 'Registered',
+ folder: 'registered',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ },
+ });
+
+ const groups = getAvailableGroups();
+ const reg = groups.find((g) => g.jid === 'reg@g.us');
+ const unreg = groups.find((g) => g.jid === 'unreg@g.us');
+
+ expect(reg?.isRegistered).toBe(true);
+ expect(unreg?.isRegistered).toBe(false);
+ });
+
+ it('returns groups ordered by most recent activity', () => {
+ storeChatMetadata('old@g.us', '2024-01-01T00:00:01.000Z', 'Old', 'whatsapp', true);
+ storeChatMetadata('new@g.us', '2024-01-01T00:00:05.000Z', 'New', 'whatsapp', true);
+ storeChatMetadata('mid@g.us', '2024-01-01T00:00:03.000Z', 'Mid', 'whatsapp', true);
+
+ const groups = getAvailableGroups();
+ expect(groups[0].jid).toBe('new@g.us');
+ expect(groups[1].jid).toBe('mid@g.us');
+ expect(groups[2].jid).toBe('old@g.us');
+ });
+
+ it('excludes non-group chats regardless of JID format', () => {
+ // Unknown JID format stored without is_group should not appear
+ storeChatMetadata('unknown-format-123', '2024-01-01T00:00:01.000Z', 'Unknown');
+ // Explicitly non-group with unusual JID
+ storeChatMetadata('custom:abc', '2024-01-01T00:00:02.000Z', 'Custom DM', 'custom', false);
+ // A real group for contrast
+ storeChatMetadata('group@g.us', '2024-01-01T00:00:03.000Z', 'Group', 'whatsapp', true);
+
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(1);
+ expect(groups[0].jid).toBe('group@g.us');
+ });
+
+ it('returns empty array when no chats exist', () => {
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(0);
+ });
+
+ it('excludes Gmail threads from group list (Gmail threads are not groups)', () => {
+ storeChatMetadata('gmail:abc123', '2024-01-01T00:00:01.000Z', 'Email thread', 'gmail', false);
+ storeChatMetadata('group@g.us', '2024-01-01T00:00:02.000Z', 'Group', 'whatsapp', true);
+
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(1);
+ expect(groups[0].jid).toBe('group@g.us');
+ });
+});
diff --git a/.agent/skills/add-gmail/tests/gmail.test.ts b/.agent/skills/add-gmail/tests/gmail.test.ts
new file mode 100644
index 0000000..02d9721
--- /dev/null
+++ b/.agent/skills/add-gmail/tests/gmail.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect } from 'vitest';
+import fs from 'fs';
+import path from 'path';
+
+const root = process.cwd();
+const read = (f: string) => fs.readFileSync(path.join(root, f), 'utf-8');
+
+function getGmailMode(): 'tool-only' | 'channel' {
+ const p = path.join(root, '.nanoclaw/state.yaml');
+ if (!fs.existsSync(p)) return 'channel';
+ return read('.nanoclaw/state.yaml').includes('mode: tool-only') ? 'tool-only' : 'channel';
+}
+
+const mode = getGmailMode();
+const channelOnly = mode === 'tool-only';
+
+describe('add-gmail skill', () => {
+ it('container-runner mounts ~/.gmail-mcp', () => {
+ expect(read('src/container-runner.ts')).toContain('.gmail-mcp');
+ });
+
+ it('agent-runner has gmail MCP server', () => {
+ const content = read('container/agent-runner/src/index.ts');
+ expect(content).toContain('mcp__gmail__*');
+ expect(content).toContain('@gongrzhe/server-gmail-autoauth-mcp');
+ });
+
+ it.skipIf(channelOnly)('gmail channel file exists', () => {
+ expect(fs.existsSync(path.join(root, 'src/channels/gmail.ts'))).toBe(true);
+ });
+
+ it.skipIf(channelOnly)('index.ts wires up GmailChannel', () => {
+ expect(read('src/index.ts')).toContain('GmailChannel');
+ });
+
+ it.skipIf(channelOnly)('googleapis dependency installed', () => {
+ const pkg = JSON.parse(read('package.json'));
+ expect(pkg.dependencies?.googleapis || pkg.devDependencies?.googleapis).toBeDefined();
+ });
+});
diff --git a/.agent/skills/add-parallel/SKILL.md b/.agent/skills/add-parallel/SKILL.md
new file mode 100644
index 0000000..36aade5
--- /dev/null
+++ b/.agent/skills/add-parallel/SKILL.md
@@ -0,0 +1,320 @@
+---
+name: add-parallel
+description: Add Parallel AI MCP integration to Clawdie for advanced web research capabilities. Use when the user wants the agent to perform multi-step web research using Parallel AI's tools.
+---
+
+# Add Parallel AI Integration
+
+Adds Parallel AI MCP integration to NanoClaw for advanced web research capabilities.
+
+## What This Adds
+
+- **Quick Search** - Fast web lookups using Parallel Search API (free to use)
+- **Deep Research** - Comprehensive analysis using Parallel Task API (asks permission)
+- **Non-blocking Design** - Uses NanoClaw scheduler for result polling (no container blocking)
+
+## Prerequisites
+
+User must have:
+
+1. Parallel AI API key from https://platform.parallel.ai
+2. NanoClaw already set up and running
+3. Docker installed and running
+
+## Implementation Steps
+
+Run all steps automatically. Only pause for user input when explicitly needed.
+
+### 1. Get Parallel AI API Key
+
+Use `AskUserQuestion: Do you have a Parallel AI API key, or should I help you get one?`
+
+**If they have one:**
+Collect it now.
+
+**If they need one:**
+Tell them:
+
+> 1. Go to https://platform.parallel.ai
+> 2. Sign up or log in
+> 3. Navigate to API Keys section
+> 4. Create a new API key
+> 5. Copy the key and paste it here
+
+Wait for the API key.
+
+### 2. Add API Key to Environment
+
+Add `PARALLEL_API_KEY` to `.env`:
+
+```bash
+# Check if .env exists, create if not
+if [ ! -f .env ]; then
+ touch .env
+fi
+
+# Add PARALLEL_API_KEY if not already present
+if ! grep -q "PARALLEL_API_KEY=" .env; then
+ echo "PARALLEL_API_KEY=${API_KEY_FROM_USER}" >> .env
+ echo "✓ Added PARALLEL_API_KEY to .env"
+else
+ # Update existing key
+ sed -i.bak "s/^PARALLEL_API_KEY=.*/PARALLEL_API_KEY=${API_KEY_FROM_USER}/" .env
+ echo "✓ Updated PARALLEL_API_KEY in .env"
+fi
+```
+
+Verify:
+
+```bash
+grep "PARALLEL_API_KEY" .env | head -c 50
+```
+
+### 3. Update Container Runner
+
+Add `PARALLEL_API_KEY` to allowed environment variables in `src/container-runner.ts`:
+
+Find the line:
+
+```typescript
+const allowedVars = ['ZAI_API_KEY', 'ANTHROPIC_API_KEY'];
+```
+
+Replace with:
+
+```typescript
+const allowedVars = ['ZAI_API_KEY', 'ANTHROPIC_API_KEY', 'PARALLEL_API_KEY'];
+```
+
+### 4. Configure MCP Servers in Agent Runner
+
+Update `container/agent-runner/src/index.ts`:
+
+Find the section where `mcpServers` is configured (around line 237-252):
+
+```typescript
+const mcpServers: Record = {
+ nanoclaw: ipcMcp,
+};
+```
+
+Add Parallel AI MCP servers after the nanoclaw server:
+
+```typescript
+const mcpServers: Record = {
+ nanoclaw: ipcMcp,
+};
+
+// Add Parallel AI MCP servers if API key is available
+const parallelApiKey = process.env.PARALLEL_API_KEY;
+if (parallelApiKey) {
+ mcpServers['parallel-search'] = {
+ type: 'http', // REQUIRED: Must specify type for HTTP MCP servers
+ url: 'https://search-mcp.parallel.ai/mcp',
+ headers: {
+ Authorization: `Bearer ${parallelApiKey}`,
+ },
+ };
+ mcpServers['parallel-task'] = {
+ type: 'http', // REQUIRED: Must specify type for HTTP MCP servers
+ url: 'https://task-mcp.parallel.ai/mcp',
+ headers: {
+ Authorization: `Bearer ${parallelApiKey}`,
+ },
+ };
+ log('Parallel AI MCP servers configured');
+} else {
+ log('PARALLEL_API_KEY not set, skipping Parallel AI integration');
+}
+```
+
+Also update the `allowedTools` array to include Parallel MCP tools (around line 242-248):
+
+```typescript
+allowedTools: [
+ 'Bash',
+ 'Read', 'Write', 'Edit', 'Glob', 'Grep',
+ 'WebSearch', 'WebFetch',
+ 'mcp__nanoclaw__*',
+ 'mcp__parallel-search__*',
+ 'mcp__parallel-task__*'
+],
+```
+
+### 5. Add Usage Instructions to AGENT.md
+
+Add Parallel AI usage instructions to `groups/main/AGENT.md`:
+
+Find the "## What You Can Do" section and add after the existing bullet points:
+
+```markdown
+- Use Parallel AI for web research and deep learning tasks
+```
+
+Then add a new section after "## What You Can Do":
+
+```markdown
+## Web Research Tools
+
+You have access to two Parallel AI research tools:
+
+### Quick Web Search (`mcp__parallel-search__search`)
+
+**When to use:** Freely use for factual lookups, current events, definitions, recent information, or verifying facts.
+
+**Examples:**
+
+- "Who invented the transistor?"
+- "What's the latest news about quantum computing?"
+- "When was the UN founded?"
+- "What are the top programming languages in 2026?"
+
+**Speed:** Fast (2-5 seconds)
+**Cost:** Low
+**Permission:** Not needed - use whenever it helps answer the question
+
+### Deep Research (`mcp__parallel-task__create_task_run`)
+
+**When to use:** Comprehensive analysis, learning about complex topics, comparing concepts, historical overviews, or structured research.
+
+**Examples:**
+
+- "Explain the development of quantum mechanics from 1900-1930"
+- "Compare the literary styles of Hemingway and Faulkner"
+- "Research the evolution of jazz from bebop to fusion"
+- "Analyze the causes of the French Revolution"
+
+**Speed:** Slower (1-20 minutes depending on depth)
+**Cost:** Higher (varies by processor tier)
+**Permission:** ALWAYS use `AskUserQuestion` before using this tool
+
+**How to ask permission:**
+```
+
+AskUserQuestion: I can do deep research on [topic] using Parallel's Task API. This will take 2-5 minutes and provide comprehensive analysis with citations. Should I proceed?
+
+```
+
+**After permission - DO NOT BLOCK! Use scheduler instead:**
+
+1. Create the task using `mcp__parallel-task__create_task_run`
+2. Get the `run_id` from the response
+3. Create a polling scheduled task using `mcp__nanoclaw__schedule_task`:
+```
+
+Prompt: "Check Parallel AI task run [run_id] and send results when ready.
+
+1. Use the Parallel Task MCP to check the task status
+2. If status is 'completed', extract the results
+3. Send results to user with mcp**nanoclaw**send_message
+4. Use mcp**nanoclaw**complete_scheduled_task to mark this task as done
+
+If status is still 'running' or 'pending', do nothing (task will run again in 30s).
+If status is 'failed', send error message and complete the task."
+
+Schedule: interval every 30 seconds
+Context mode: isolated
+
+```
+4. Send acknowledgment with tracking link
+5. Exit immediately - scheduler handles the rest
+
+### Choosing Between Them
+
+**Use Search when:**
+- Question needs a quick fact or recent information
+- Simple definition or clarification
+- Verifying specific details
+- Current events or news
+
+**Use Deep Research (with permission) when:**
+- User wants to learn about a complex topic
+- Question requires analysis or comparison
+- Historical context or evolution of concepts
+- Structured, comprehensive understanding needed
+- User explicitly asks to "research" or "explain in depth"
+
+**Default behavior:** Prefer search for most questions. Only suggest deep research when the topic genuinely requires comprehensive analysis.
+```
+
+### 6. Rebuild Container
+
+Build the container with updated agent runner:
+
+```bash
+./container/build.sh
+```
+
+Verify the build:
+
+```bash
+echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK"
+```
+
+### 7. Restart Service
+
+Rebuild the main app and restart:
+
+```bash
+npm run build
+launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
+# Linux: systemctl --user restart nanoclaw
+```
+
+Wait 3 seconds for service to start, then verify:
+
+```bash
+sleep 3
+launchctl list | grep nanoclaw # macOS
+# Linux: systemctl --user status nanoclaw
+```
+
+### 8. Test Integration
+
+Tell the user to test:
+
+> Send a message to your assistant: `@[YourAssistantName] what's the latest news about AI?`
+>
+> The assistant should use Parallel Search API to find current information.
+>
+> Then try: `@[YourAssistantName] can you research the history of artificial intelligence?`
+>
+> The assistant should ask for permission before using the Task API.
+
+Check logs to verify MCP servers loaded:
+
+```bash
+tail -20 logs/nanoclaw.log
+```
+
+Look for: `Parallel AI MCP servers configured`
+
+## Troubleshooting
+
+**Container hangs or times out:**
+
+- Check that `type: 'http'` is specified in MCP server config
+- Verify API key is correct in .env
+- Check container logs: `cat groups/main/logs/container-*.log | tail -50`
+
+**MCP servers not loading:**
+
+- Ensure PARALLEL_API_KEY is in .env
+- Verify container-runner.ts includes PARALLEL_API_KEY in allowedVars
+- Check agent-runner logs for "Parallel AI MCP servers configured" message
+
+**Task polling not working:**
+
+- Verify scheduled task was created: `psql "$OPS_DB_URL" -c "SELECT * FROM scheduled_tasks"`
+- Check task runs: `tail -f logs/nanoclaw.log | grep "scheduled task"`
+- Ensure task prompt includes proper Parallel MCP tool names
+
+## Uninstalling
+
+To remove Parallel AI integration:
+
+1. Remove from .env: `sed -i.bak '/PARALLEL_API_KEY/d' .env`
+2. Revert changes to container-runner.ts and agent-runner/src/index.ts
+3. Remove Web Research Tools section from groups/main/AGENT.md
+4. Rebuild: `./container/build.sh && npm run build`
+5. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)
diff --git a/.agent/skills/add-protonmail/SKILL.md b/.agent/skills/add-protonmail/SKILL.md
new file mode 100644
index 0000000..28f6074
--- /dev/null
+++ b/.agent/skills/add-protonmail/SKILL.md
@@ -0,0 +1,85 @@
+---
+name: add-protonmail
+description: Add a send_email tool so the agent can send email from a ProtonMail custom domain address using an SMTP submission token. Use when the user wants the agent to send emails.
+---
+
+# Skill: add-protonmail
+
+Adds a `send_email` tool to your agent so it can send emails from your ProtonMail
+custom domain address (e.g. `hello@clawdie.si`) using an SMTP submission token.
+
+No daemon required. No Proton Bridge. Just an SMTP token and nodemailer.
+
+## Prerequisites
+
+- A ProtonMail account with a custom domain (e.g. `hello@clawdie.si`)
+- SMTP submission enabled for your domain in Proton settings
+
+## Setup
+
+### 1. Generate an SMTP token
+
+1. Log in to Proton Mail → Settings → All Settings → Proton Mail → SMTP submission
+2. Enable SMTP submission for your domain
+3. Click **Generate token** for your address
+4. Copy the token (looks like a long random string)
+
+> Note: SMTP tokens are different from your login password. They are
+> address-specific and can be revoked without affecting your account.
+
+### 2. Add to .env
+
+```
+PROTONMAIL_SMTP_USER=hello@clawdie.si
+PROTONMAIL_SMTP_TOKEN=your_smtp_token_here
+```
+
+### 3. Apply the skill
+
+```bash
+npx tsx scripts/apply-skill.ts .agent/skills/add-protonmail
+```
+
+Then rebuild the jail agent runner:
+
+```bash
+cd jail/agent-runner && npm install && npm run build
+```
+
+## Tools the agent gets
+
+| Tool | Description |
+| ------------ | ------------------------------------------ |
+| `send_email` | Send an email from your ProtonMail address |
+
+The tool accepts: `to`, `subject`, `body`, and optional `cc` and `reply_to`.
+
+## Example conversations
+
+> "Send John an email confirming his order"
+> → Agent calls `send_email(to: "john@example.com", subject: "Order confirmed", body: "Hi John...")`
+
+> "Email the team about the outage"
+> → Agent composes and sends via `send_email`
+
+> "Reply to Sarah's question about pricing"
+> → Agent drafts reply and sends via `send_email`
+
+## Limitations
+
+This skill is **send-only**. The agent cannot read the ProtonMail inbox or
+receive emails automatically. For full inbound + outbound support, see the
+Proton Bridge option in the skill roadmap.
+
+## Upgrading to full inbound channel
+
+Once Proton Bridge headless mode on FreeBSD is confirmed, this skill will be
+extended to add a `ProtonmailChannel` that polls via IMAP and delivers incoming
+emails to the agent automatically — the same pattern as `add-gmail`.
+
+## Security
+
+- SMTP token is separate from your login password
+- Tokens can be revoked individually without affecting your account
+- `PROTONMAIL_SMTP_TOKEN` is passed to the jail via encrypted stdin, never written to disk
+- Emails sent via SMTP are stored in Sent with zero-access encryption
diff --git a/.agent/skills/add-protonmail/add/jail/agent-runner/src/protonmail-tools.ts b/.agent/skills/add-protonmail/add/jail/agent-runner/src/protonmail-tools.ts
new file mode 100644
index 0000000..b7f64e7
--- /dev/null
+++ b/.agent/skills/add-protonmail/add/jail/agent-runner/src/protonmail-tools.ts
@@ -0,0 +1,72 @@
+/**
+ * ProtonMail SMTP tools for Clawdie agent.
+ * Registered into the existing ipc-mcp-stdio server when PROTONMAIL_SMTP credentials are set.
+ * Uses ProtonMail SMTP submission tokens (custom domain addresses only).
+ * Send-only — no inbound polling.
+ */
+import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
+import type { Transporter } from 'nodemailer';
+import { z } from 'zod';
+
+export function registerProtonmailTools(server: McpServer): void {
+ const user = process.env.PROTONMAIL_SMTP_USER;
+ const token = process.env.PROTONMAIL_SMTP_TOKEN;
+ if (!user || !token) return;
+
+ let transporterInstance: Transporter | null = null;
+
+ const getTransporter = async (): Promise => {
+ if (!transporterInstance) {
+ const nodemailer = await import('nodemailer');
+ transporterInstance = nodemailer.default.createTransport({
+ host: 'smtp.protonmail.ch',
+ port: 587,
+ secure: false, // STARTTLS
+ auth: { user, pass: token },
+ });
+ }
+ return transporterInstance;
+ };
+
+ server.tool(
+ 'send_email',
+ `Send an email from ${user} via ProtonMail. Use for notifications, replies, and outbound communication.`,
+ {
+ to: z.string().describe('Recipient email address (e.g. "john@example.com" or "Name ")'),
+ subject: z.string().describe('Email subject line'),
+ body: z.string().describe('Email body as plain text'),
+ cc: z.string().optional().describe('CC address (optional)'),
+ reply_to: z
+ .string()
+ .optional()
+ .describe('Reply-To address if different from sender (optional)'),
+ },
+ async (args) => {
+ try {
+ const transporter = await getTransporter();
+ const info = await transporter.sendMail({
+ from: user,
+ to: args.to,
+ subject: args.subject,
+ text: args.body,
+ ...(args.cc ? { cc: args.cc } : {}),
+ ...(args.reply_to ? { replyTo: args.reply_to } : {}),
+ });
+ return {
+ content: [{
+ type: 'text' as const,
+ text: `Email sent to ${args.to}\nSubject: ${args.subject}\nMessage ID: ${info.messageId}`,
+ }],
+ };
+ } catch (err) {
+ return {
+ content: [{
+ type: 'text' as const,
+ text: `Failed to send email: ${err instanceof Error ? err.message : String(err)}`,
+ }],
+ isError: true,
+ };
+ }
+ },
+ );
+}
diff --git a/.agent/skills/add-protonmail/manifest.yaml b/.agent/skills/add-protonmail/manifest.yaml
new file mode 100644
index 0000000..f8db084
--- /dev/null
+++ b/.agent/skills/add-protonmail/manifest.yaml
@@ -0,0 +1,17 @@
+skill: protonmail
+version: 1.0.0
+description: "ProtonMail email sending via SMTP token — send emails from your custom domain"
+core_version: 0.4.0
+adds:
+ - jail/agent-runner/src/protonmail-tools.ts
+modifies:
+ - src/jail-runner.ts
+ - jail/agent-runner/src/ipc-mcp-stdio.ts
+ - jail/agent-runner/package.json
+structured:
+ npm_dependencies:
+ nodemailer: "^6.9.0"
+ "@types/nodemailer": "^6.4.0"
+conflicts: []
+depends: []
+test: ""
diff --git a/.agent/skills/add-protonmail/modify/jail/agent-runner/package.json.intent.md b/.agent/skills/add-protonmail/modify/jail/agent-runner/package.json.intent.md
new file mode 100644
index 0000000..aa172bd
--- /dev/null
+++ b/.agent/skills/add-protonmail/modify/jail/agent-runner/package.json.intent.md
@@ -0,0 +1,37 @@
+# Intent: jail/agent-runner/package.json modifications
+
+## What changed
+
+Added `nodemailer` as a runtime dependency and `@types/nodemailer` as a dev
+dependency.
+
+## Key sections
+
+### dependencies
+
+- Added: `"nodemailer": "^6.9.0"`
+
+### devDependencies
+
+- Added: `"@types/nodemailer": "^6.4.0"`
+
+```json
+{
+ "dependencies": {
+ "@modelcontextprotocol/sdk": "^1.12.1",
+ "cron-parser": "^5.0.0",
+ "nodemailer": "^6.9.0",
+ "zod": "^4.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.7",
+ "@types/nodemailer": "^6.4.0",
+ "typescript": "^5.7.3"
+ }
+}
+```
+
+## Invariants
+
+- All existing dependencies remain unchanged
+- package name, version, scripts are unchanged
diff --git a/.agent/skills/add-protonmail/modify/jail/agent-runner/src/ipc-mcp-stdio.ts.intent.md b/.agent/skills/add-protonmail/modify/jail/agent-runner/src/ipc-mcp-stdio.ts.intent.md
new file mode 100644
index 0000000..25b8596
--- /dev/null
+++ b/.agent/skills/add-protonmail/modify/jail/agent-runner/src/ipc-mcp-stdio.ts.intent.md
@@ -0,0 +1,36 @@
+# Intent: jail/agent-runner/src/ipc-mcp-stdio.ts modifications
+
+## What changed
+
+Imported and registered the ProtonMail MCP tools so the agent can send emails
+from the configured ProtonMail address. Tools are only registered when both
+`PROTONMAIL_SMTP_USER` and `PROTONMAIL_SMTP_TOKEN` are present.
+
+## Key sections
+
+### Imports (top of file)
+
+- Added: `import { registerProtonmailTools } from './protonmail-tools.js';`
+
+### After all existing server.tool() calls, before the transport connection
+
+- Added: `registerProtonmailTools(server);`
+
+```typescript
+// Add near the bottom, before:
+// const transport = new StdioServerTransport();
+
+registerProtonmailTools(server);
+
+// Existing:
+const transport = new StdioServerTransport();
+await server.connect(transport);
+```
+
+## Invariants
+
+- All existing tools (send_message, schedule_task, list_tasks, etc.) are unchanged
+- Server name and version are unchanged
+- IPC directory constants are unchanged
+- Transport connection is unchanged
+- If either ProtonMail env var is absent, `registerProtonmailTools` is a no-op
diff --git a/.agent/skills/add-protonmail/modify/src/jail-runner.ts.intent.md b/.agent/skills/add-protonmail/modify/src/jail-runner.ts.intent.md
new file mode 100644
index 0000000..cac75dd
--- /dev/null
+++ b/.agent/skills/add-protonmail/modify/src/jail-runner.ts.intent.md
@@ -0,0 +1,38 @@
+# Intent: src/jail-runner.ts modifications
+
+## What changed
+
+Added `PROTONMAIL_SMTP_USER` and `PROTONMAIL_SMTP_TOKEN` to the secrets
+allowlist so ProtonMail credentials are passed to the jail agent securely
+via stdin — never via environment variables or files.
+
+## Key sections
+
+### readSecrets() function (around line 216)
+
+- Added: `'PROTONMAIL_SMTP_USER'` and `'PROTONMAIL_SMTP_TOKEN'` to the `readEnvFile` array
+
+```typescript
+// Before:
+return readEnvFile([
+ 'ANTHROPIC_API_KEY',
+ ...
+ 'KIMI_API_KEY',
+]);
+
+// After:
+return readEnvFile([
+ 'ANTHROPIC_API_KEY',
+ ...
+ 'KIMI_API_KEY',
+ 'PROTONMAIL_SMTP_USER',
+ 'PROTONMAIL_SMTP_TOKEN',
+]);
+```
+
+## Invariants
+
+- All existing API keys remain in the list unchanged
+- `readEnvFile()` signature and behavior are unchanged
+- Secrets are still passed via stdin JSON, never written to disk or env
+- If either ProtonMail env var is absent from `.env`, it is silently skipped
diff --git a/.agent/skills/add-slack/SKILL.md b/.agent/skills/add-slack/SKILL.md
new file mode 100644
index 0000000..f3706ae
--- /dev/null
+++ b/.agent/skills/add-slack/SKILL.md
@@ -0,0 +1,235 @@
+---
+name: add-slack
+description: Add Slack as a channel. Can replace WhatsApp entirely or run alongside it. Uses Socket Mode (no public URL needed).
+---
+
+# Add Slack Channel
+
+This skill adds Slack support to NanoClaw using the skills engine for deterministic code changes, then walks through interactive setup.
+
+## Phase 1: Pre-flight
+
+### Check if already applied
+
+Read `.nanoclaw/state.yaml`. If `slack` is in `applied_skills`, skip to Phase 3 (Setup). The code changes are already in place.
+
+### Ask the user
+
+1. **Mode**: Replace WhatsApp or add alongside it?
+ - Replace → will set `SLACK_ONLY=true`
+ - Alongside → both channels active (default)
+
+2. **Do they already have a Slack app configured?** If yes, collect the Bot Token and App Token now. If no, we'll create one in Phase 3.
+
+## Phase 2: Apply Code Changes
+
+Run the skills engine to apply this skill's code package. The package files are in this directory alongside this SKILL.md.
+
+### Initialize skills system (if needed)
+
+If `.nanoclaw/` directory doesn't exist yet:
+
+```bash
+npx tsx scripts/apply-skill.ts --init
+```
+
+Or call `initSkillsSystem()` from `skills-engine/migrate.ts`.
+
+### Apply the skill
+
+```bash
+npx tsx scripts/apply-skill.ts .agent/skills/add-slack
+```
+
+This deterministically:
+
+- Adds `src/channels/slack.ts` (SlackChannel class implementing Channel interface)
+- Adds `src/channels/slack.test.ts` (46 unit tests)
+- Three-way merges Slack support into `src/index.ts` (multi-channel support, conditional channel creation)
+- Three-way merges Slack config into `src/config.ts` (SLACK_ONLY export)
+- Three-way merges updated routing tests into `src/routing.test.ts`
+- Installs the `@slack/bolt` npm dependency
+- Updates `.env.example` with `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, and `SLACK_ONLY`
+- Records the application in `.nanoclaw/state.yaml`
+
+If the apply reports merge conflicts, read the intent files:
+
+- `modify/src/index.ts.intent.md` — what changed and invariants for index.ts
+- `modify/src/config.ts.intent.md` — what changed for config.ts
+- `modify/src/routing.test.ts.intent.md` — what changed for routing tests
+
+### Validate code changes
+
+```bash
+npm test
+npm run build
+```
+
+All tests must pass (including the new slack tests) and build must be clean before proceeding.
+
+## Phase 3: Setup
+
+### Create Slack App (if needed)
+
+If the user doesn't have a Slack app, share [SLACK_SETUP.md](SLACK_SETUP.md) which has step-by-step instructions with screenshots guidance, troubleshooting, and a token reference table.
+
+Quick summary of what's needed:
+
+1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps)
+2. Enable Socket Mode and generate an App-Level Token (`xapp-...`)
+3. Subscribe to bot events: `message.channels`, `message.groups`, `message.im`
+4. Add OAuth scopes: `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`
+5. Install to workspace and copy the Bot Token (`xoxb-...`)
+
+Wait for the user to provide both tokens.
+
+### Configure environment
+
+Add to `.env`:
+
+```bash
+SLACK_BOT_TOKEN=xoxb-your-bot-token
+SLACK_APP_TOKEN=xapp-your-app-token
+```
+
+If they chose to replace WhatsApp:
+
+```bash
+SLACK_ONLY=true
+```
+
+Sync to container environment:
+
+```bash
+mkdir -p data/env && cp .env data/env/env
+```
+
+The container reads environment from `data/env/env`, not `.env` directly.
+
+### Build and restart
+
+```bash
+npm run build
+launchctl kickstart -k gui/$(id -u)/com.nanoclaw
+```
+
+## Phase 4: Registration
+
+### Get Channel ID
+
+Tell the user:
+
+> 1. Add the bot to a Slack channel (right-click channel → **View channel details** → **Integrations** → **Add apps**)
+> 2. In that channel, the channel ID is in the URL when you open it in a browser: `https://app.slack.com/client/T.../C0123456789` — the `C...` part is the channel ID
+> 3. Alternatively, right-click the channel name → **Copy link** — the channel ID is the last path segment
+>
+> The JID format for NanoClaw is: `slack:C0123456789`
+
+Wait for the user to provide the channel ID.
+
+### Register the channel
+
+Use the IPC register flow or register directly. The channel ID, name, and folder name are needed.
+
+For a main channel (responds to all messages, uses the `main` folder):
+
+```typescript
+registerGroup('slack:', {
+ name: '',
+ folder: 'main',
+ trigger: `@${ASSISTANT_NAME}`,
+ added_at: new Date().toISOString(),
+ requiresTrigger: false,
+});
+```
+
+For additional channels (trigger-only):
+
+```typescript
+registerGroup('slack:', {
+ name: '',
+ folder: '',
+ trigger: `@${ASSISTANT_NAME}`,
+ added_at: new Date().toISOString(),
+ requiresTrigger: true,
+});
+```
+
+## Phase 5: Verify
+
+### Test the connection
+
+Tell the user:
+
+> Send a message in your registered Slack channel:
+>
+> - For main channel: Any message works
+> - For non-main: `@ hello` (using the configured trigger word)
+>
+> The bot should respond within a few seconds.
+
+### Check logs if needed
+
+```bash
+tail -f logs/nanoclaw.log
+```
+
+## Troubleshooting
+
+### Bot not responding
+
+1. Check `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` are set in `.env` AND synced to `data/env/env`
+2. Check channel is registered: `psql "$OPS_DB_URL" -c "SELECT * FROM registered_groups WHERE jid LIKE 'slack:%'"`
+3. For non-main channels: message must include trigger pattern
+4. Service is running: `launchctl list | grep nanoclaw`
+
+### Bot connected but not receiving messages
+
+1. Verify Socket Mode is enabled in the Slack app settings
+2. Verify the bot is subscribed to the correct events (`message.channels`, `message.groups`, `message.im`)
+3. Verify the bot has been added to the channel
+4. Check that the bot has the required OAuth scopes
+
+### Bot not seeing messages in channels
+
+By default, bots only see messages in channels they've been explicitly added to. Make sure to:
+
+1. Add the bot to each channel you want it to monitor
+2. Check the bot has `channels:history` and/or `groups:history` scopes
+
+### "missing_scope" errors
+
+If the bot logs `missing_scope` errors:
+
+1. Go to **OAuth & Permissions** in your Slack app settings
+2. Add the missing scope listed in the error message
+3. **Reinstall the app** to your workspace — scope changes require reinstallation
+4. Copy the new Bot Token (it changes on reinstall) and update `.env`
+5. Sync: `mkdir -p data/env && cp .env data/env/env`
+6. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
+
+### Getting channel ID
+
+If the channel ID is hard to find:
+
+- In Slack desktop: right-click channel → **Copy link** → extract the `C...` ID from the URL
+- In Slack web: the URL shows `https://app.slack.com/client/TXXXXXXX/C0123456789`
+- Via API: `curl -s -H "Authorization: Bearer $SLACK_BOT_TOKEN" "https://slack.com/api/conversations.list" | jq '.channels[] | {id, name}'`
+
+## After Setup
+
+The Slack channel supports:
+
+- **Public channels** — Bot must be added to the channel
+- **Private channels** — Bot must be invited to the channel
+- **Direct messages** — Users can DM the bot directly
+- **Multi-channel** — Can run alongside WhatsApp (default) or replace it (`SLACK_ONLY=true`)
+
+## Known Limitations
+
+- **Threads are flattened** — Threaded replies are delivered to the agent as regular channel messages. The agent sees them but has no awareness they originated in a thread. Responses always go to the channel, not back into the thread. Users in a thread will need to check the main channel for the bot's reply. Full thread-aware routing (respond in-thread) requires pipeline-wide changes: database schema, `NewMessage` type, `Channel.sendMessage` interface, and routing logic.
+- **No typing indicator** — Slack's Bot API does not expose a typing indicator endpoint. The `setTyping()` method is a no-op. Users won't see "bot is typing..." while the agent works.
+- **Message splitting is naive** — Long messages are split at a fixed 4000-character boundary, which may break mid-word or mid-sentence. A smarter split (on paragraph or sentence boundaries) would improve readability.
+- **No file/image handling** — The bot only processes text content. File uploads, images, and rich message blocks are not forwarded to the agent.
+- **Channel metadata sync is unbounded** — `syncChannelMetadata()` paginates through all channels the bot is a member of, but has no upper bound or timeout. Workspaces with thousands of channels may experience slow startup.
+- **Workspace admin policies not detected** — If the Slack workspace restricts bot app installation, the setup will fail at the "Install to Workspace" step with no programmatic detection or guidance. See SLACK_SETUP.md troubleshooting section.
diff --git a/.agent/skills/add-slack/SLACK_SETUP.md b/.agent/skills/add-slack/SLACK_SETUP.md
new file mode 100644
index 0000000..a410f92
--- /dev/null
+++ b/.agent/skills/add-slack/SLACK_SETUP.md
@@ -0,0 +1,156 @@
+# Slack App Setup for NanoClaw
+
+Step-by-step guide to creating and configuring a Slack app for use with NanoClaw.
+
+## Prerequisites
+
+- A Slack workspace where you have admin permissions (or permission to install apps)
+- Your NanoClaw instance with the `/add-slack` skill applied
+
+## Step 1: Create the Slack App
+
+1. Go to [api.slack.com/apps](https://api.slack.com/apps)
+2. Click **Create New App**
+3. Choose **From scratch**
+4. Enter an app name (e.g., your `ASSISTANT_NAME` value, or any name you like)
+5. Select the workspace you want to install it in
+6. Click **Create App**
+
+## Step 2: Enable Socket Mode
+
+Socket Mode lets the bot connect to Slack without needing a public URL. This is what makes it work from your local machine.
+
+1. In the sidebar, click **Socket Mode**
+2. Toggle **Enable Socket Mode** to **On**
+3. When prompted for a token name, enter something like `nanoclaw`
+4. Click **Generate**
+5. **Copy the App-Level Token** — it starts with `xapp-`. Save this somewhere safe; you'll need it later.
+
+## Step 3: Subscribe to Events
+
+This tells Slack which messages to forward to your bot.
+
+1. In the sidebar, click **Event Subscriptions**
+2. Toggle **Enable Events** to **On**
+3. Under **Subscribe to bot events**, click **Add Bot User Event** and add these three events:
+
+| Event | What it does |
+| ------------------ | -------------------------------------------------- |
+| `message.channels` | Receive messages in public channels the bot is in |
+| `message.groups` | Receive messages in private channels the bot is in |
+| `message.im` | Receive direct messages to the bot |
+
+4. Click **Save Changes** at the bottom of the page
+
+## Step 4: Set Bot Permissions (OAuth Scopes)
+
+These scopes control what the bot is allowed to do.
+
+1. In the sidebar, click **OAuth & Permissions**
+2. Scroll down to **Scopes** > **Bot Token Scopes**
+3. Click **Add an OAuth Scope** and add each of these:
+
+| Scope | Why it's needed |
+| ------------------ | ----------------------------------------- |
+| `chat:write` | Send messages to channels and DMs |
+| `channels:history` | Read messages in public channels |
+| `groups:history` | Read messages in private channels |
+| `im:history` | Read direct messages |
+| `channels:read` | List channels (for metadata sync) |
+| `groups:read` | List private channels (for metadata sync) |
+| `users:read` | Look up user display names |
+
+## Step 5: Install to Workspace
+
+1. In the sidebar, click **Install App**
+2. Click **Install to Workspace**
+3. Review the permissions and click **Allow**
+4. **Copy the Bot User OAuth Token** — it starts with `xoxb-`. Save this somewhere safe.
+
+## Step 6: Configure NanoClaw
+
+Add both tokens to your `.env` file:
+
+```
+SLACK_BOT_TOKEN=xoxb-your-bot-token-here
+SLACK_APP_TOKEN=xapp-your-app-token-here
+```
+
+If you want Slack to replace WhatsApp entirely (no WhatsApp channel), also add:
+
+```
+SLACK_ONLY=true
+```
+
+Then sync the environment to the container:
+
+```bash
+mkdir -p data/env && cp .env data/env/env
+```
+
+## Step 7: Add the Bot to Channels
+
+The bot only receives messages from channels it has been explicitly added to.
+
+1. Open the Slack channel you want the bot to monitor
+2. Click the channel name at the top to open channel details
+3. Go to **Integrations** > **Add apps**
+4. Search for your bot name and add it
+
+Repeat for each channel you want the bot in.
+
+## Step 8: Get Channel IDs for Registration
+
+You need the Slack channel ID to register it with NanoClaw.
+
+**Option A — From the URL:**
+Open the channel in Slack on the web. The URL looks like:
+
+```
+https://app.slack.com/client/TXXXXXXX/C0123456789
+```
+
+The `C0123456789` part is the channel ID.
+
+**Option B — Right-click:**
+Right-click the channel name in Slack > **Copy link** > the channel ID is the last path segment.
+
+**Option C — Via API:**
+
+```bash
+curl -s -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
+ "https://slack.com/api/conversations.list" | jq '.channels[] | {id, name}'
+```
+
+The NanoClaw JID format is `slack:` followed by the channel ID, e.g., `slack:C0123456789`.
+
+## Token Reference
+
+| Token | Prefix | Where to find it |
+| -------------------- | ------- | -------------------------------------------------------------------------- |
+| Bot User OAuth Token | `xoxb-` | **OAuth & Permissions** > **Bot User OAuth Token** |
+| App-Level Token | `xapp-` | **Basic Information** > **App-Level Tokens** (or during Socket Mode setup) |
+
+## Troubleshooting
+
+**Bot not receiving messages:**
+
+- Verify Socket Mode is enabled (Step 2)
+- Verify all three events are subscribed (Step 3)
+- Verify the bot has been added to the channel (Step 7)
+
+**"missing_scope" errors:**
+
+- Go back to **OAuth & Permissions** and add the missing scope
+- After adding scopes, you must **reinstall the app** to your workspace (Slack will show a banner prompting you to do this)
+
+**Bot can't send messages:**
+
+- Verify the `chat:write` scope is added
+- Verify the bot has been added to the target channel
+
+**Token not working:**
+
+- Bot tokens start with `xoxb-` — if yours doesn't, you may have copied the wrong token
+- App tokens start with `xapp-` — these are generated in the Socket Mode or Basic Information pages
+- If you regenerated a token, update `.env` and re-sync: `cp .env data/env/env`
diff --git a/.agent/skills/add-slack/add/src/channels/slack.test.ts b/.agent/skills/add-slack/add/src/channels/slack.test.ts
new file mode 100644
index 0000000..4c841d1
--- /dev/null
+++ b/.agent/skills/add-slack/add/src/channels/slack.test.ts
@@ -0,0 +1,848 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+
+// --- Mocks ---
+
+// Mock config
+vi.mock('../config.js', () => ({
+ ASSISTANT_NAME: 'Jonesy',
+ TRIGGER_PATTERN: /^@Jonesy\b/i,
+}));
+
+// Mock logger
+vi.mock('../logger.js', () => ({
+ logger: {
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+// Mock db
+vi.mock('../db.js', () => ({
+ updateChatName: vi.fn(),
+}));
+
+// --- @slack/bolt mock ---
+
+type Handler = (...args: any[]) => any;
+
+const appRef = vi.hoisted(() => ({ current: null as any }));
+
+vi.mock('@slack/bolt', () => ({
+ App: class MockApp {
+ eventHandlers = new Map();
+ token: string;
+ appToken: string;
+
+ client = {
+ auth: {
+ test: vi.fn().mockResolvedValue({ user_id: 'U_BOT_123' }),
+ },
+ chat: {
+ postMessage: vi.fn().mockResolvedValue(undefined),
+ },
+ conversations: {
+ list: vi.fn().mockResolvedValue({
+ channels: [],
+ response_metadata: {},
+ }),
+ },
+ users: {
+ info: vi.fn().mockResolvedValue({
+ user: { real_name: 'Alice Smith', name: 'alice' },
+ }),
+ },
+ };
+
+ constructor(opts: any) {
+ this.token = opts.token;
+ this.appToken = opts.appToken;
+ appRef.current = this;
+ }
+
+ event(name: string, handler: Handler) {
+ this.eventHandlers.set(name, handler);
+ }
+
+ async start() {}
+ async stop() {}
+ },
+ LogLevel: { ERROR: 'error' },
+}));
+
+// Mock env
+vi.mock('../env.js', () => ({
+ readEnvFile: vi.fn().mockReturnValue({
+ SLACK_BOT_TOKEN: 'xoxb-test-token',
+ SLACK_APP_TOKEN: 'xapp-test-token',
+ }),
+}));
+
+import { SlackChannel, SlackChannelOpts } from './slack.js';
+import { updateChatName } from '../db.js';
+import { readEnvFile } from '../env.js';
+
+// --- Test helpers ---
+
+function createTestOpts(
+ overrides?: Partial,
+): SlackChannelOpts {
+ return {
+ onMessage: vi.fn(),
+ onChatMetadata: vi.fn(),
+ registeredGroups: vi.fn(() => ({
+ 'slack:C0123456789': {
+ name: 'Test Channel',
+ folder: 'test-channel',
+ trigger: '@Jonesy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ },
+ })),
+ ...overrides,
+ };
+}
+
+function createMessageEvent(overrides: {
+ channel?: string;
+ channelType?: string;
+ user?: string;
+ text?: string;
+ ts?: string;
+ threadTs?: string;
+ subtype?: string;
+ botId?: string;
+}) {
+ return {
+ channel: overrides.channel ?? 'C0123456789',
+ channel_type: overrides.channelType ?? 'channel',
+ user: overrides.user ?? 'U_USER_456',
+ text: 'text' in overrides ? overrides.text : 'Hello everyone',
+ ts: overrides.ts ?? '1704067200.000000',
+ thread_ts: overrides.threadTs,
+ subtype: overrides.subtype,
+ bot_id: overrides.botId,
+ };
+}
+
+function currentApp() {
+ return appRef.current;
+}
+
+async function triggerMessageEvent(event: ReturnType) {
+ const handler = currentApp().eventHandlers.get('message');
+ if (handler) await handler({ event });
+}
+
+// --- Tests ---
+
+describe('SlackChannel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ // --- Connection lifecycle ---
+
+ describe('connection lifecycle', () => {
+ it('resolves connect() when app starts', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+
+ await channel.connect();
+
+ expect(channel.isConnected()).toBe(true);
+ });
+
+ it('registers message event handler on construction', () => {
+ const opts = createTestOpts();
+ new SlackChannel(opts);
+
+ expect(currentApp().eventHandlers.has('message')).toBe(true);
+ });
+
+ it('gets bot user ID on connect', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+
+ await channel.connect();
+
+ expect(currentApp().client.auth.test).toHaveBeenCalled();
+ });
+
+ it('disconnects cleanly', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+
+ await channel.connect();
+ expect(channel.isConnected()).toBe(true);
+
+ await channel.disconnect();
+ expect(channel.isConnected()).toBe(false);
+ });
+
+ it('isConnected() returns false before connect', () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+
+ expect(channel.isConnected()).toBe(false);
+ });
+ });
+
+ // --- Message handling ---
+
+ describe('message handling', () => {
+ it('delivers message for registered channel', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ const event = createMessageEvent({ text: 'Hello everyone' });
+ await triggerMessageEvent(event);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'slack:C0123456789',
+ expect.any(String),
+ undefined,
+ 'slack',
+ true,
+ );
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'slack:C0123456789',
+ expect.objectContaining({
+ id: '1704067200.000000',
+ chat_jid: 'slack:C0123456789',
+ sender: 'U_USER_456',
+ content: 'Hello everyone',
+ is_from_me: false,
+ }),
+ );
+ });
+
+ it('only emits metadata for unregistered channels', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ const event = createMessageEvent({ channel: 'C9999999999' });
+ await triggerMessageEvent(event);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'slack:C9999999999',
+ expect.any(String),
+ undefined,
+ 'slack',
+ true,
+ );
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ });
+
+ it('skips non-text subtypes (channel_join, etc.)', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ const event = createMessageEvent({ subtype: 'channel_join' });
+ await triggerMessageEvent(event);
+
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ expect(opts.onChatMetadata).not.toHaveBeenCalled();
+ });
+
+ it('allows bot_message subtype through', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ const event = createMessageEvent({
+ subtype: 'bot_message',
+ botId: 'B_OTHER_BOT',
+ text: 'Bot message',
+ });
+ await triggerMessageEvent(event);
+
+ expect(opts.onChatMetadata).toHaveBeenCalled();
+ });
+
+ it('skips messages with no text', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ const event = createMessageEvent({ text: undefined as any });
+ await triggerMessageEvent(event);
+
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ });
+
+ it('detects bot messages by bot_id', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ const event = createMessageEvent({
+ subtype: 'bot_message',
+ botId: 'B_MY_BOT',
+ text: 'Bot response',
+ });
+ await triggerMessageEvent(event);
+
+ // Has bot_id so should be marked as bot message
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'slack:C0123456789',
+ expect.objectContaining({
+ is_from_me: true,
+ is_bot_message: true,
+ sender_name: 'Jonesy',
+ }),
+ );
+ });
+
+ it('detects bot messages by matching bot user ID', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ const event = createMessageEvent({ user: 'U_BOT_123', text: 'Self message' });
+ await triggerMessageEvent(event);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'slack:C0123456789',
+ expect.objectContaining({
+ is_from_me: true,
+ is_bot_message: true,
+ }),
+ );
+ });
+
+ it('identifies IM channel type as non-group', async () => {
+ const opts = createTestOpts({
+ registeredGroups: vi.fn(() => ({
+ 'slack:D0123456789': {
+ name: 'DM',
+ folder: 'dm',
+ trigger: '@Jonesy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ },
+ })),
+ });
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ const event = createMessageEvent({
+ channel: 'D0123456789',
+ channelType: 'im',
+ });
+ await triggerMessageEvent(event);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'slack:D0123456789',
+ expect.any(String),
+ undefined,
+ 'slack',
+ false, // IM is not a group
+ );
+ });
+
+ it('converts ts to ISO timestamp', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ const event = createMessageEvent({ ts: '1704067200.000000' });
+ await triggerMessageEvent(event);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'slack:C0123456789',
+ expect.objectContaining({
+ timestamp: '2024-01-01T00:00:00.000Z',
+ }),
+ );
+ });
+
+ it('resolves user name from Slack API', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ const event = createMessageEvent({ user: 'U_USER_456', text: 'Hello' });
+ await triggerMessageEvent(event);
+
+ expect(currentApp().client.users.info).toHaveBeenCalledWith({
+ user: 'U_USER_456',
+ });
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'slack:C0123456789',
+ expect.objectContaining({
+ sender_name: 'Alice Smith',
+ }),
+ );
+ });
+
+ it('caches user names to avoid repeated API calls', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ // First message — API call
+ await triggerMessageEvent(createMessageEvent({ user: 'U_USER_456', text: 'First' }));
+ // Second message — should use cache
+ await triggerMessageEvent(createMessageEvent({
+ user: 'U_USER_456',
+ text: 'Second',
+ ts: '1704067201.000000',
+ }));
+
+ expect(currentApp().client.users.info).toHaveBeenCalledTimes(1);
+ });
+
+ it('falls back to user ID when API fails', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ currentApp().client.users.info.mockRejectedValueOnce(new Error('API error'));
+
+ const event = createMessageEvent({ user: 'U_UNKNOWN', text: 'Hi' });
+ await triggerMessageEvent(event);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'slack:C0123456789',
+ expect.objectContaining({
+ sender_name: 'U_UNKNOWN',
+ }),
+ );
+ });
+
+ it('flattens threaded replies into channel messages', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ const event = createMessageEvent({
+ ts: '1704067201.000000',
+ threadTs: '1704067200.000000', // parent message ts — this is a reply
+ text: 'Thread reply',
+ });
+ await triggerMessageEvent(event);
+
+ // Threaded replies are delivered as regular channel messages
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'slack:C0123456789',
+ expect.objectContaining({
+ content: 'Thread reply',
+ }),
+ );
+ });
+
+ it('delivers thread parent messages normally', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ const event = createMessageEvent({
+ ts: '1704067200.000000',
+ threadTs: '1704067200.000000', // same as ts — this IS the parent
+ text: 'Thread parent',
+ });
+ await triggerMessageEvent(event);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'slack:C0123456789',
+ expect.objectContaining({
+ content: 'Thread parent',
+ }),
+ );
+ });
+
+ it('delivers messages without thread_ts normally', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ const event = createMessageEvent({ text: 'Normal message' });
+ await triggerMessageEvent(event);
+
+ expect(opts.onMessage).toHaveBeenCalled();
+ });
+ });
+
+ // --- @mention translation ---
+
+ describe('@mention translation', () => {
+ it('prepends trigger when bot is @mentioned via Slack format', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect(); // sets botUserId to 'U_BOT_123'
+
+ const event = createMessageEvent({
+ text: 'Hey <@U_BOT_123> what do you think?',
+ user: 'U_USER_456',
+ });
+ await triggerMessageEvent(event);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'slack:C0123456789',
+ expect.objectContaining({
+ content: '@Jonesy Hey <@U_BOT_123> what do you think?',
+ }),
+ );
+ });
+
+ it('does not prepend trigger when trigger pattern already matches', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ const event = createMessageEvent({
+ text: '@Jonesy <@U_BOT_123> hello',
+ user: 'U_USER_456',
+ });
+ await triggerMessageEvent(event);
+
+ // Content should be unchanged since it already matches TRIGGER_PATTERN
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'slack:C0123456789',
+ expect.objectContaining({
+ content: '@Jonesy <@U_BOT_123> hello',
+ }),
+ );
+ });
+
+ it('does not translate mentions in bot messages', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ const event = createMessageEvent({
+ text: 'Echo: <@U_BOT_123>',
+ subtype: 'bot_message',
+ botId: 'B_MY_BOT',
+ });
+ await triggerMessageEvent(event);
+
+ // Bot messages skip mention translation
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'slack:C0123456789',
+ expect.objectContaining({
+ content: 'Echo: <@U_BOT_123>',
+ }),
+ );
+ });
+
+ it('does not translate mentions for other users', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ const event = createMessageEvent({
+ text: 'Hey <@U_OTHER_USER> look at this',
+ user: 'U_USER_456',
+ });
+ await triggerMessageEvent(event);
+
+ // Mention is for a different user, not the bot
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'slack:C0123456789',
+ expect.objectContaining({
+ content: 'Hey <@U_OTHER_USER> look at this',
+ }),
+ );
+ });
+ });
+
+ // --- sendMessage ---
+
+ describe('sendMessage', () => {
+ it('sends message via Slack client', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ await channel.sendMessage('slack:C0123456789', 'Hello');
+
+ expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({
+ channel: 'C0123456789',
+ text: 'Hello',
+ });
+ });
+
+ it('strips slack: prefix from JID', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ await channel.sendMessage('slack:D9876543210', 'DM message');
+
+ expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({
+ channel: 'D9876543210',
+ text: 'DM message',
+ });
+ });
+
+ it('queues message when disconnected', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+
+ // Don't connect — should queue
+ await channel.sendMessage('slack:C0123456789', 'Queued message');
+
+ expect(currentApp().client.chat.postMessage).not.toHaveBeenCalled();
+ });
+
+ it('queues message on send failure', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ currentApp().client.chat.postMessage.mockRejectedValueOnce(
+ new Error('Network error'),
+ );
+
+ // Should not throw
+ await expect(
+ channel.sendMessage('slack:C0123456789', 'Will fail'),
+ ).resolves.toBeUndefined();
+ });
+
+ it('splits long messages at 4000 character boundary', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ // Create a message longer than 4000 chars
+ const longText = 'A'.repeat(4500);
+ await channel.sendMessage('slack:C0123456789', longText);
+
+ // Should be split into 2 messages: 4000 + 500
+ expect(currentApp().client.chat.postMessage).toHaveBeenCalledTimes(2);
+ expect(currentApp().client.chat.postMessage).toHaveBeenNthCalledWith(1, {
+ channel: 'C0123456789',
+ text: 'A'.repeat(4000),
+ });
+ expect(currentApp().client.chat.postMessage).toHaveBeenNthCalledWith(2, {
+ channel: 'C0123456789',
+ text: 'A'.repeat(500),
+ });
+ });
+
+ it('sends exactly-4000-char messages as a single message', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ const text = 'B'.repeat(4000);
+ await channel.sendMessage('slack:C0123456789', text);
+
+ expect(currentApp().client.chat.postMessage).toHaveBeenCalledTimes(1);
+ expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({
+ channel: 'C0123456789',
+ text,
+ });
+ });
+
+ it('splits messages into 3 parts when over 8000 chars', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+ await channel.connect();
+
+ const longText = 'C'.repeat(8500);
+ await channel.sendMessage('slack:C0123456789', longText);
+
+ // 4000 + 4000 + 500 = 3 messages
+ expect(currentApp().client.chat.postMessage).toHaveBeenCalledTimes(3);
+ });
+
+ it('flushes queued messages on connect', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+
+ // Queue messages while disconnected
+ await channel.sendMessage('slack:C0123456789', 'First queued');
+ await channel.sendMessage('slack:C0123456789', 'Second queued');
+
+ expect(currentApp().client.chat.postMessage).not.toHaveBeenCalled();
+
+ // Connect triggers flush
+ await channel.connect();
+
+ expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({
+ channel: 'C0123456789',
+ text: 'First queued',
+ });
+ expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({
+ channel: 'C0123456789',
+ text: 'Second queued',
+ });
+ });
+ });
+
+ // --- ownsJid ---
+
+ describe('ownsJid', () => {
+ it('owns slack: JIDs', () => {
+ const channel = new SlackChannel(createTestOpts());
+ expect(channel.ownsJid('slack:C0123456789')).toBe(true);
+ });
+
+ it('owns slack: DM JIDs', () => {
+ const channel = new SlackChannel(createTestOpts());
+ expect(channel.ownsJid('slack:D0123456789')).toBe(true);
+ });
+
+ it('does not own WhatsApp group JIDs', () => {
+ const channel = new SlackChannel(createTestOpts());
+ expect(channel.ownsJid('12345@g.us')).toBe(false);
+ });
+
+ it('does not own WhatsApp DM JIDs', () => {
+ const channel = new SlackChannel(createTestOpts());
+ expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(false);
+ });
+
+ it('does not own Telegram JIDs', () => {
+ const channel = new SlackChannel(createTestOpts());
+ expect(channel.ownsJid('tg:123456')).toBe(false);
+ });
+
+ it('does not own unknown JID formats', () => {
+ const channel = new SlackChannel(createTestOpts());
+ expect(channel.ownsJid('random-string')).toBe(false);
+ });
+ });
+
+ // --- syncChannelMetadata ---
+
+ describe('syncChannelMetadata', () => {
+ it('calls conversations.list and updates chat names', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+
+ currentApp().client.conversations.list.mockResolvedValue({
+ channels: [
+ { id: 'C001', name: 'general', is_member: true },
+ { id: 'C002', name: 'random', is_member: true },
+ { id: 'C003', name: 'external', is_member: false },
+ ],
+ response_metadata: {},
+ });
+
+ await channel.connect();
+
+ // connect() calls syncChannelMetadata internally
+ expect(updateChatName).toHaveBeenCalledWith('slack:C001', 'general');
+ expect(updateChatName).toHaveBeenCalledWith('slack:C002', 'random');
+ // Non-member channels are skipped
+ expect(updateChatName).not.toHaveBeenCalledWith('slack:C003', 'external');
+ });
+
+ it('handles API errors gracefully', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+
+ currentApp().client.conversations.list.mockRejectedValue(
+ new Error('API error'),
+ );
+
+ // Should not throw
+ await expect(channel.connect()).resolves.toBeUndefined();
+ });
+ });
+
+ // --- setTyping ---
+
+ describe('setTyping', () => {
+ it('resolves without error (no-op)', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+
+ // Should not throw — Slack has no bot typing indicator API
+ await expect(
+ channel.setTyping('slack:C0123456789', true),
+ ).resolves.toBeUndefined();
+ });
+
+ it('accepts false without error', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+
+ await expect(
+ channel.setTyping('slack:C0123456789', false),
+ ).resolves.toBeUndefined();
+ });
+ });
+
+ // --- Constructor error handling ---
+
+ describe('constructor', () => {
+ it('throws when SLACK_BOT_TOKEN is missing', () => {
+ vi.mocked(readEnvFile).mockReturnValueOnce({
+ SLACK_BOT_TOKEN: '',
+ SLACK_APP_TOKEN: 'xapp-test-token',
+ });
+
+ expect(() => new SlackChannel(createTestOpts())).toThrow(
+ 'SLACK_BOT_TOKEN and SLACK_APP_TOKEN must be set in .env',
+ );
+ });
+
+ it('throws when SLACK_APP_TOKEN is missing', () => {
+ vi.mocked(readEnvFile).mockReturnValueOnce({
+ SLACK_BOT_TOKEN: 'xoxb-test-token',
+ SLACK_APP_TOKEN: '',
+ });
+
+ expect(() => new SlackChannel(createTestOpts())).toThrow(
+ 'SLACK_BOT_TOKEN and SLACK_APP_TOKEN must be set in .env',
+ );
+ });
+ });
+
+ // --- syncChannelMetadata pagination ---
+
+ describe('syncChannelMetadata pagination', () => {
+ it('paginates through multiple pages of channels', async () => {
+ const opts = createTestOpts();
+ const channel = new SlackChannel(opts);
+
+ // First page returns a cursor; second page returns no cursor
+ currentApp().client.conversations.list
+ .mockResolvedValueOnce({
+ channels: [
+ { id: 'C001', name: 'general', is_member: true },
+ ],
+ response_metadata: { next_cursor: 'cursor_page2' },
+ })
+ .mockResolvedValueOnce({
+ channels: [
+ { id: 'C002', name: 'random', is_member: true },
+ ],
+ response_metadata: {},
+ });
+
+ await channel.connect();
+
+ // Should have called conversations.list twice (once per page)
+ expect(currentApp().client.conversations.list).toHaveBeenCalledTimes(2);
+ expect(currentApp().client.conversations.list).toHaveBeenNthCalledWith(2,
+ expect.objectContaining({ cursor: 'cursor_page2' }),
+ );
+
+ // Both channels from both pages stored
+ expect(updateChatName).toHaveBeenCalledWith('slack:C001', 'general');
+ expect(updateChatName).toHaveBeenCalledWith('slack:C002', 'random');
+ });
+ });
+
+ // --- Channel properties ---
+
+ describe('channel properties', () => {
+ it('has name "slack"', () => {
+ const channel = new SlackChannel(createTestOpts());
+ expect(channel.name).toBe('slack');
+ });
+ });
+});
diff --git a/.agent/skills/add-slack/add/src/channels/slack.ts b/.agent/skills/add-slack/add/src/channels/slack.ts
new file mode 100644
index 0000000..81cc1ac
--- /dev/null
+++ b/.agent/skills/add-slack/add/src/channels/slack.ts
@@ -0,0 +1,290 @@
+import { App, LogLevel } from '@slack/bolt';
+import type { GenericMessageEvent, BotMessageEvent } from '@slack/types';
+
+import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
+import { updateChatName } from '../db.js';
+import { readEnvFile } from '../env.js';
+import { logger } from '../logger.js';
+import {
+ Channel,
+ OnInboundMessage,
+ OnChatMetadata,
+ RegisteredGroup,
+} from '../types.js';
+
+// Slack's chat.postMessage API limits text to ~4000 characters per call.
+// Messages exceeding this are split into sequential chunks.
+const MAX_MESSAGE_LENGTH = 4000;
+
+// The message subtypes we process. Bolt delivers all subtypes via app.event('message');
+// we filter to regular messages (GenericMessageEvent, subtype undefined) and bot messages
+// (BotMessageEvent, subtype 'bot_message') so we can track our own output.
+type HandledMessageEvent = GenericMessageEvent | BotMessageEvent;
+
+export interface SlackChannelOpts {
+ onMessage: OnInboundMessage;
+ onChatMetadata: OnChatMetadata;
+ registeredGroups: () => Record;
+}
+
+export class SlackChannel implements Channel {
+ name = 'slack';
+
+ private app: App;
+ private botUserId: string | undefined;
+ private connected = false;
+ private outgoingQueue: Array<{ jid: string; text: string }> = [];
+ private flushing = false;
+ private userNameCache = new Map();
+
+ private opts: SlackChannelOpts;
+
+ constructor(opts: SlackChannelOpts) {
+ this.opts = opts;
+
+ // Read tokens from .env (not process.env — keeps secrets off the environment
+ // so they don't leak to child processes, matching NanoClaw's security pattern)
+ const env = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN']);
+ const botToken = env.SLACK_BOT_TOKEN;
+ const appToken = env.SLACK_APP_TOKEN;
+
+ if (!botToken || !appToken) {
+ throw new Error(
+ 'SLACK_BOT_TOKEN and SLACK_APP_TOKEN must be set in .env',
+ );
+ }
+
+ this.app = new App({
+ token: botToken,
+ appToken,
+ socketMode: true,
+ logLevel: LogLevel.ERROR,
+ });
+
+ this.setupEventHandlers();
+ }
+
+ private setupEventHandlers(): void {
+ // Use app.event('message') instead of app.message() to capture all
+ // message subtypes including bot_message (needed to track our own output)
+ this.app.event('message', async ({ event }) => {
+ // Bolt's event type is the full MessageEvent union (17+ subtypes).
+ // We filter on subtype first, then narrow to the two types we handle.
+ const subtype = (event as { subtype?: string }).subtype;
+ if (subtype && subtype !== 'bot_message') return;
+
+ // After filtering, event is either GenericMessageEvent or BotMessageEvent
+ const msg = event as HandledMessageEvent;
+
+ if (!msg.text) return;
+
+ // Threaded replies are flattened into the channel conversation.
+ // The agent sees them alongside channel-level messages; responses
+ // always go to the channel, not back into the thread.
+
+ const jid = `slack:${msg.channel}`;
+ const timestamp = new Date(parseFloat(msg.ts) * 1000).toISOString();
+ const isGroup = msg.channel_type !== 'im';
+
+ // Always report metadata for group discovery
+ this.opts.onChatMetadata(jid, timestamp, undefined, 'slack', isGroup);
+
+ // Only deliver full messages for registered groups
+ const groups = this.opts.registeredGroups();
+ if (!groups[jid]) return;
+
+ const isBotMessage =
+ !!msg.bot_id || msg.user === this.botUserId;
+
+ let senderName: string;
+ if (isBotMessage) {
+ senderName = ASSISTANT_NAME;
+ } else {
+ senderName =
+ (await this.resolveUserName(msg.user)) ||
+ msg.user ||
+ 'unknown';
+ }
+
+ // Translate Slack <@UBOTID> mentions into TRIGGER_PATTERN format.
+ // Slack encodes @mentions as <@U12345>, which won't match TRIGGER_PATTERN
+ // (e.g., ^@\b), so we prepend the trigger when the bot is @mentioned.
+ let content = msg.text;
+ if (this.botUserId && !isBotMessage) {
+ const mentionPattern = `<@${this.botUserId}>`;
+ if (content.includes(mentionPattern) && !TRIGGER_PATTERN.test(content)) {
+ content = `@${ASSISTANT_NAME} ${content}`;
+ }
+ }
+
+ this.opts.onMessage(jid, {
+ id: msg.ts,
+ chat_jid: jid,
+ sender: msg.user || msg.bot_id || '',
+ sender_name: senderName,
+ content,
+ timestamp,
+ is_from_me: isBotMessage,
+ is_bot_message: isBotMessage,
+ });
+ });
+ }
+
+ async connect(): Promise {
+ await this.app.start();
+
+ // Get bot's own user ID for self-message detection.
+ // Resolve this BEFORE setting connected=true so that messages arriving
+ // during startup can correctly detect bot-sent messages.
+ try {
+ const auth = await this.app.client.auth.test();
+ this.botUserId = auth.user_id as string;
+ logger.info({ botUserId: this.botUserId }, 'Connected to Slack');
+ } catch (err) {
+ logger.warn(
+ { err },
+ 'Connected to Slack but failed to get bot user ID',
+ );
+ }
+
+ this.connected = true;
+
+ // Flush any messages queued before connection
+ await this.flushOutgoingQueue();
+
+ // Sync channel names on startup
+ await this.syncChannelMetadata();
+ }
+
+ async sendMessage(jid: string, text: string): Promise {
+ const channelId = jid.replace(/^slack:/, '');
+
+ if (!this.connected) {
+ this.outgoingQueue.push({ jid, text });
+ logger.info(
+ { jid, queueSize: this.outgoingQueue.length },
+ 'Slack disconnected, message queued',
+ );
+ return;
+ }
+
+ try {
+ // Slack limits messages to ~4000 characters; split if needed
+ if (text.length <= MAX_MESSAGE_LENGTH) {
+ await this.app.client.chat.postMessage({ channel: channelId, text });
+ } else {
+ for (let i = 0; i < text.length; i += MAX_MESSAGE_LENGTH) {
+ await this.app.client.chat.postMessage({
+ channel: channelId,
+ text: text.slice(i, i + MAX_MESSAGE_LENGTH),
+ });
+ }
+ }
+ logger.info({ jid, length: text.length }, 'Slack message sent');
+ } catch (err) {
+ this.outgoingQueue.push({ jid, text });
+ logger.warn(
+ { jid, err, queueSize: this.outgoingQueue.length },
+ 'Failed to send Slack message, queued',
+ );
+ }
+ }
+
+ isConnected(): boolean {
+ return this.connected;
+ }
+
+ ownsJid(jid: string): boolean {
+ return jid.startsWith('slack:');
+ }
+
+ async disconnect(): Promise {
+ this.connected = false;
+ await this.app.stop();
+ }
+
+ // Slack does not expose a typing indicator API for bots.
+ // This no-op satisfies the Channel interface so the orchestrator
+ // doesn't need channel-specific branching.
+ async setTyping(_jid: string, _isTyping: boolean): Promise {
+ // no-op: Slack Bot API has no typing indicator endpoint
+ }
+
+ /**
+ * Sync channel metadata from Slack.
+ * Fetches channels the bot is a member of and stores their names in the DB.
+ */
+ async syncChannelMetadata(): Promise {
+ try {
+ logger.info('Syncing channel metadata from Slack...');
+ let cursor: string | undefined;
+ let count = 0;
+
+ do {
+ const result = await this.app.client.conversations.list({
+ types: 'public_channel,private_channel',
+ exclude_archived: true,
+ limit: 200,
+ cursor,
+ });
+
+ for (const ch of result.channels || []) {
+ if (ch.id && ch.name && ch.is_member) {
+ updateChatName(`slack:${ch.id}`, ch.name);
+ count++;
+ }
+ }
+
+ cursor = result.response_metadata?.next_cursor || undefined;
+ } while (cursor);
+
+ logger.info({ count }, 'Slack channel metadata synced');
+ } catch (err) {
+ logger.error({ err }, 'Failed to sync Slack channel metadata');
+ }
+ }
+
+ private async resolveUserName(
+ userId: string,
+ ): Promise {
+ if (!userId) return undefined;
+
+ const cached = this.userNameCache.get(userId);
+ if (cached) return cached;
+
+ try {
+ const result = await this.app.client.users.info({ user: userId });
+ const name = result.user?.real_name || result.user?.name;
+ if (name) this.userNameCache.set(userId, name);
+ return name;
+ } catch (err) {
+ logger.debug({ userId, err }, 'Failed to resolve Slack user name');
+ return undefined;
+ }
+ }
+
+ private async flushOutgoingQueue(): Promise {
+ if (this.flushing || this.outgoingQueue.length === 0) return;
+ this.flushing = true;
+ try {
+ logger.info(
+ { count: this.outgoingQueue.length },
+ 'Flushing Slack outgoing queue',
+ );
+ while (this.outgoingQueue.length > 0) {
+ const item = this.outgoingQueue.shift()!;
+ const channelId = item.jid.replace(/^slack:/, '');
+ await this.app.client.chat.postMessage({
+ channel: channelId,
+ text: item.text,
+ });
+ logger.info(
+ { jid: item.jid, length: item.text.length },
+ 'Queued Slack message sent',
+ );
+ }
+ } finally {
+ this.flushing = false;
+ }
+ }
+}
diff --git a/.agent/skills/add-slack/manifest.yaml b/.agent/skills/add-slack/manifest.yaml
new file mode 100644
index 0000000..8320bb3
--- /dev/null
+++ b/.agent/skills/add-slack/manifest.yaml
@@ -0,0 +1,21 @@
+skill: slack
+version: 1.0.0
+description: "Slack Bot integration via @slack/bolt with Socket Mode"
+core_version: 0.1.0
+adds:
+ - src/channels/slack.ts
+ - src/channels/slack.test.ts
+modifies:
+ - src/index.ts
+ - src/config.ts
+ - src/routing.test.ts
+structured:
+ npm_dependencies:
+ "@slack/bolt": "^4.6.0"
+ env_additions:
+ - SLACK_BOT_TOKEN
+ - SLACK_APP_TOKEN
+ - SLACK_ONLY
+conflicts: []
+depends: []
+test: "npx vitest run src/channels/slack.test.ts"
diff --git a/.agent/skills/add-slack/modify/src/config.ts b/.agent/skills/add-slack/modify/src/config.ts
new file mode 100644
index 0000000..257adcb
--- /dev/null
+++ b/.agent/skills/add-slack/modify/src/config.ts
@@ -0,0 +1,75 @@
+import path from 'path';
+
+import { readEnvFile } from './env.js';
+
+// Read config values from .env (falls back to process.env).
+// Secrets are NOT read here — they stay on disk and are loaded only
+// where needed (container-runner.ts) to avoid leaking to child processes.
+const envConfig = readEnvFile([
+ 'ASSISTANT_NAME',
+ 'ASSISTANT_HAS_OWN_NUMBER',
+ 'SLACK_ONLY',
+]);
+
+export const ASSISTANT_NAME =
+ process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
+export const ASSISTANT_HAS_OWN_NUMBER =
+ (process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
+export const POLL_INTERVAL = 2000;
+export const SCHEDULER_POLL_INTERVAL = 60000;
+
+// Absolute paths needed for container mounts
+const PROJECT_ROOT = process.cwd();
+const HOME_DIR = process.env.HOME || '/Users/user';
+
+// Mount security: allowlist stored OUTSIDE project root, never mounted into containers
+export const MOUNT_ALLOWLIST_PATH = path.join(
+ HOME_DIR,
+ '.config',
+ (process.env.AGENT_NAME || 'clawdie') + '-cp',
+ 'mount-allowlist.json',
+);
+export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
+export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
+export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
+export const MAIN_GROUP_FOLDER = 'main';
+
+export const CONTAINER_IMAGE =
+ process.env.CONTAINER_IMAGE || (process.env.AGENT_NAME || 'clawdie') + '-cp-agent:latest';
+export const CONTAINER_TIMEOUT = parseInt(
+ process.env.CONTAINER_TIMEOUT || '1800000',
+ 10,
+);
+export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
+ process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
+ 10,
+); // 10MB default
+export const IPC_POLL_INTERVAL = 1000;
+export const IDLE_TIMEOUT = parseInt(
+ process.env.IDLE_TIMEOUT || '1800000',
+ 10,
+); // 30min default — how long to keep container alive after last result
+export const MAX_CONCURRENT_CONTAINERS = Math.max(
+ 1,
+ parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5,
+);
+
+function escapeRegex(str: string): string {
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+export const TRIGGER_PATTERN = new RegExp(
+ `^@${escapeRegex(ASSISTANT_NAME)}\\b`,
+ 'i',
+);
+
+// Timezone for scheduled tasks (cron expressions, etc.)
+// Uses system timezone by default
+export const TIMEZONE =
+ process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+// Slack configuration
+// SLACK_BOT_TOKEN and SLACK_APP_TOKEN are read directly by SlackChannel
+// from .env via readEnvFile() to keep secrets off process.env.
+export const SLACK_ONLY =
+ (process.env.SLACK_ONLY || envConfig.SLACK_ONLY) === 'true';
diff --git a/.agent/skills/add-slack/modify/src/config.ts.intent.md b/.agent/skills/add-slack/modify/src/config.ts.intent.md
new file mode 100644
index 0000000..e92c690
--- /dev/null
+++ b/.agent/skills/add-slack/modify/src/config.ts.intent.md
@@ -0,0 +1,25 @@
+# Intent: src/config.ts modifications
+
+## What changed
+
+Added SLACK_ONLY configuration export for Slack channel support.
+
+## Key sections
+
+- **readEnvFile call**: Must include `SLACK_ONLY` in the keys array. NanoClaw does NOT load `.env` into `process.env` — all `.env` values must be explicitly requested via `readEnvFile()`.
+- **SLACK_ONLY**: Boolean flag from `process.env` or `envConfig`, when `true` disables WhatsApp channel creation
+- **Note**: SLACK_BOT_TOKEN and SLACK_APP_TOKEN are NOT read here. They are read directly by SlackChannel via `readEnvFile()` in `slack.ts` to keep secrets off the config module entirely (same pattern as ANTHROPIC_API_KEY in container-runner.ts).
+
+## Invariants
+
+- All existing config exports remain unchanged
+- New Slack key is added to the `readEnvFile` call alongside existing keys
+- New export is appended at the end of the file
+- No existing behavior is modified — Slack config is additive only
+- Both `process.env` and `envConfig` are checked (same pattern as `ASSISTANT_NAME`)
+
+## Must-keep
+
+- All existing exports (`ASSISTANT_NAME`, `POLL_INTERVAL`, `TRIGGER_PATTERN`, etc.)
+- The `readEnvFile` pattern — ALL config read from `.env` must go through this function
+- The `escapeRegex` helper and `TRIGGER_PATTERN` construction
diff --git a/.agent/skills/add-slack/modify/src/index.ts b/.agent/skills/add-slack/modify/src/index.ts
new file mode 100644
index 0000000..50212e1
--- /dev/null
+++ b/.agent/skills/add-slack/modify/src/index.ts
@@ -0,0 +1,498 @@
+import fs from 'fs';
+import path from 'path';
+
+import {
+ ASSISTANT_NAME,
+ DATA_DIR,
+ IDLE_TIMEOUT,
+ MAIN_GROUP_FOLDER,
+ POLL_INTERVAL,
+ SLACK_ONLY,
+ TRIGGER_PATTERN,
+} from './config.js';
+import { WhatsAppChannel } from './channels/whatsapp.js';
+import { SlackChannel } from './channels/slack.js';
+import {
+ ContainerOutput,
+ runContainerAgent,
+ writeGroupsSnapshot,
+ writeTasksSnapshot,
+} from './container-runner.js';
+import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js';
+import {
+ getAllChats,
+ getAllRegisteredGroups,
+ getAllSessions,
+ getAllTasks,
+ getMessagesSince,
+ getNewMessages,
+ getRouterState,
+ initDatabase,
+ setRegisteredGroup,
+ setRouterState,
+ setSession,
+ storeChatMetadata,
+ storeMessage,
+} from './db.js';
+import { GroupQueue } from './group-queue.js';
+import { startIpcWatcher } from './ipc.js';
+import { findChannel, formatMessages, formatOutbound } from './router.js';
+import { startSchedulerLoop } from './task-scheduler.js';
+import { Channel, NewMessage, RegisteredGroup } from './types.js';
+import { logger } from './logger.js';
+import { readEnvFile } from './env.js';
+
+// Re-export for backwards compatibility during refactor
+export { escapeXml, formatMessages } from './router.js';
+
+let lastTimestamp = '';
+let sessions: Record = {};
+let registeredGroups: Record = {};
+let lastAgentTimestamp: Record = {};
+let messageLoopRunning = false;
+
+let whatsapp: WhatsAppChannel;
+let slack: SlackChannel | undefined;
+const channels: Channel[] = [];
+const queue = new GroupQueue();
+
+function loadState(): void {
+ lastTimestamp = getRouterState('last_timestamp') || '';
+ const agentTs = getRouterState('last_agent_timestamp');
+ try {
+ lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {};
+ } catch {
+ logger.warn('Corrupted last_agent_timestamp in DB, resetting');
+ lastAgentTimestamp = {};
+ }
+ sessions = getAllSessions();
+ registeredGroups = getAllRegisteredGroups();
+ logger.info(
+ { groupCount: Object.keys(registeredGroups).length },
+ 'State loaded',
+ );
+}
+
+function saveState(): void {
+ setRouterState('last_timestamp', lastTimestamp);
+ setRouterState(
+ 'last_agent_timestamp',
+ JSON.stringify(lastAgentTimestamp),
+ );
+}
+
+function registerGroup(jid: string, group: RegisteredGroup): void {
+ registeredGroups[jid] = group;
+ setRegisteredGroup(jid, group);
+
+ // Create group folder
+ const groupDir = path.join(DATA_DIR, '..', 'groups', group.folder);
+ fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
+
+ logger.info(
+ { jid, name: group.name, folder: group.folder },
+ 'Group registered',
+ );
+}
+
+/**
+ * Get available groups list for the agent.
+ * Returns groups ordered by most recent activity.
+ */
+export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] {
+ const chats = getAllChats();
+ const registeredJids = new Set(Object.keys(registeredGroups));
+
+ return chats
+ .filter((c) => c.jid !== '__group_sync__' && c.is_group)
+ .map((c) => ({
+ jid: c.jid,
+ name: c.name,
+ lastActivity: c.last_message_time,
+ isRegistered: registeredJids.has(c.jid),
+ }));
+}
+
+/** @internal - exported for testing */
+export function _setRegisteredGroups(groups: Record): void {
+ registeredGroups = groups;
+}
+
+/**
+ * Process all pending messages for a group.
+ * Called by the GroupQueue when it's this group's turn.
+ */
+async function processGroupMessages(chatJid: string): Promise {
+ const group = registeredGroups[chatJid];
+ if (!group) return true;
+
+ const channel = findChannel(channels, chatJid);
+ if (!channel) {
+ console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`);
+ return true;
+ }
+
+ const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
+
+ const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
+ const missedMessages = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
+
+ if (missedMessages.length === 0) return true;
+
+ // For non-main groups, check if trigger is required and present
+ if (!isMainGroup && group.requiresTrigger !== false) {
+ const hasTrigger = missedMessages.some((m) =>
+ TRIGGER_PATTERN.test(m.content.trim()),
+ );
+ if (!hasTrigger) return true;
+ }
+
+ const prompt = formatMessages(missedMessages);
+
+ // Advance cursor so the piping path in startMessageLoop won't re-fetch
+ // these messages. Save the old cursor so we can roll back on error.
+ const previousCursor = lastAgentTimestamp[chatJid] || '';
+ lastAgentTimestamp[chatJid] =
+ missedMessages[missedMessages.length - 1].timestamp;
+ saveState();
+
+ logger.info(
+ { group: group.name, messageCount: missedMessages.length },
+ 'Processing messages',
+ );
+
+ // Track idle timer for closing stdin when agent is idle
+ let idleTimer: ReturnType | null = null;
+
+ const resetIdleTimer = () => {
+ if (idleTimer) clearTimeout(idleTimer);
+ idleTimer = setTimeout(() => {
+ logger.debug({ group: group.name }, 'Idle timeout, closing container stdin');
+ queue.closeStdin(chatJid);
+ }, IDLE_TIMEOUT);
+ };
+
+ await channel.setTyping?.(chatJid, true);
+ let hadError = false;
+ let outputSentToUser = false;
+
+ const output = await runAgent(group, prompt, chatJid, async (result) => {
+ // Streaming output callback — called for each agent result
+ if (result.result) {
+ const raw = typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
+ // Strip ... blocks — agent uses these for internal reasoning
+ const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim();
+ logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`);
+ if (text) {
+ await channel.sendMessage(chatJid, text);
+ outputSentToUser = true;
+ }
+ // Only reset idle timer on actual results, not session-update markers (result: null)
+ resetIdleTimer();
+ }
+
+ if (result.status === 'error') {
+ hadError = true;
+ }
+ });
+
+ await channel.setTyping?.(chatJid, false);
+ if (idleTimer) clearTimeout(idleTimer);
+
+ if (output === 'error' || hadError) {
+ // If we already sent output to the user, don't roll back the cursor —
+ // the user got their response and re-processing would send duplicates.
+ if (outputSentToUser) {
+ logger.warn({ group: group.name }, 'Agent error after output was sent, skipping cursor rollback to prevent duplicates');
+ return true;
+ }
+ // Roll back cursor so retries can re-process these messages
+ lastAgentTimestamp[chatJid] = previousCursor;
+ saveState();
+ logger.warn({ group: group.name }, 'Agent error, rolled back message cursor for retry');
+ return false;
+ }
+
+ return true;
+}
+
+async function runAgent(
+ group: RegisteredGroup,
+ prompt: string,
+ chatJid: string,
+ onOutput?: (output: ContainerOutput) => Promise,
+): Promise<'success' | 'error'> {
+ const isMain = group.folder === MAIN_GROUP_FOLDER;
+ const sessionId = sessions[group.folder];
+
+ // Update tasks snapshot for container to read (filtered by group)
+ const tasks = getAllTasks();
+ writeTasksSnapshot(
+ group.folder,
+ isMain,
+ tasks.map((t) => ({
+ id: t.id,
+ groupFolder: t.group_folder,
+ prompt: t.prompt,
+ schedule_type: t.schedule_type,
+ schedule_value: t.schedule_value,
+ status: t.status,
+ next_run: t.next_run,
+ })),
+ );
+
+ // Update available groups snapshot (main group only can see all groups)
+ const availableGroups = getAvailableGroups();
+ writeGroupsSnapshot(
+ group.folder,
+ isMain,
+ availableGroups,
+ new Set(Object.keys(registeredGroups)),
+ );
+
+ // Wrap onOutput to track session ID from streamed results
+ const wrappedOnOutput = onOutput
+ ? async (output: ContainerOutput) => {
+ if (output.newSessionId) {
+ sessions[group.folder] = output.newSessionId;
+ setSession(group.folder, output.newSessionId);
+ }
+ await onOutput(output);
+ }
+ : undefined;
+
+ try {
+ const output = await runContainerAgent(
+ group,
+ {
+ prompt,
+ sessionId,
+ groupFolder: group.folder,
+ chatJid,
+ isMain,
+ },
+ (proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder),
+ wrappedOnOutput,
+ );
+
+ if (output.newSessionId) {
+ sessions[group.folder] = output.newSessionId;
+ setSession(group.folder, output.newSessionId);
+ }
+
+ if (output.status === 'error') {
+ logger.error(
+ { group: group.name, error: output.error },
+ 'Container agent error',
+ );
+ return 'error';
+ }
+
+ return 'success';
+ } catch (err) {
+ logger.error({ group: group.name, err }, 'Agent error');
+ return 'error';
+ }
+}
+
+async function startMessageLoop(): Promise {
+ if (messageLoopRunning) {
+ logger.debug('Message loop already running, skipping duplicate start');
+ return;
+ }
+ messageLoopRunning = true;
+
+ logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`);
+
+ while (true) {
+ try {
+ const jids = Object.keys(registeredGroups);
+ const { messages, newTimestamp } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME);
+
+ if (messages.length > 0) {
+ logger.info({ count: messages.length }, 'New messages');
+
+ // Advance the "seen" cursor for all messages immediately
+ lastTimestamp = newTimestamp;
+ saveState();
+
+ // Deduplicate by group
+ const messagesByGroup = new Map();
+ for (const msg of messages) {
+ const existing = messagesByGroup.get(msg.chat_jid);
+ if (existing) {
+ existing.push(msg);
+ } else {
+ messagesByGroup.set(msg.chat_jid, [msg]);
+ }
+ }
+
+ for (const [chatJid, groupMessages] of messagesByGroup) {
+ const group = registeredGroups[chatJid];
+ if (!group) continue;
+
+ const channel = findChannel(channels, chatJid);
+ if (!channel) {
+ console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`);
+ continue;
+ }
+
+ const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
+ const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
+
+ // For non-main groups, only act on trigger messages.
+ // Non-trigger messages accumulate in DB and get pulled as
+ // context when a trigger eventually arrives.
+ if (needsTrigger) {
+ const hasTrigger = groupMessages.some((m) =>
+ TRIGGER_PATTERN.test(m.content.trim()),
+ );
+ if (!hasTrigger) continue;
+ }
+
+ // Pull all messages since lastAgentTimestamp so non-trigger
+ // context that accumulated between triggers is included.
+ const allPending = getMessagesSince(
+ chatJid,
+ lastAgentTimestamp[chatJid] || '',
+ ASSISTANT_NAME,
+ );
+ const messagesToSend =
+ allPending.length > 0 ? allPending : groupMessages;
+ const formatted = formatMessages(messagesToSend);
+
+ if (queue.sendMessage(chatJid, formatted)) {
+ logger.debug(
+ { chatJid, count: messagesToSend.length },
+ 'Piped messages to active container',
+ );
+ lastAgentTimestamp[chatJid] =
+ messagesToSend[messagesToSend.length - 1].timestamp;
+ saveState();
+ // Show typing indicator while the container processes the piped message
+ channel.setTyping?.(chatJid, true);
+ } else {
+ // No active container — enqueue for a new one
+ queue.enqueueMessageCheck(chatJid);
+ }
+ }
+ }
+ } catch (err) {
+ logger.error({ err }, 'Error in message loop');
+ }
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
+ }
+}
+
+/**
+ * Startup recovery: check for unprocessed messages in registered groups.
+ * Handles crash between advancing lastTimestamp and processing messages.
+ */
+function recoverPendingMessages(): void {
+ for (const [chatJid, group] of Object.entries(registeredGroups)) {
+ const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
+ const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
+ if (pending.length > 0) {
+ logger.info(
+ { group: group.name, pendingCount: pending.length },
+ 'Recovery: found unprocessed messages',
+ );
+ queue.enqueueMessageCheck(chatJid);
+ }
+ }
+}
+
+function ensureContainerSystemRunning(): void {
+ ensureContainerRuntimeRunning();
+ cleanupOrphans();
+}
+
+async function main(): Promise {
+ ensureContainerSystemRunning();
+ initDatabase();
+ logger.info('Database initialized');
+ loadState();
+
+ // Graceful shutdown handlers
+ const shutdown = async (signal: string) => {
+ logger.info({ signal }, 'Shutdown signal received');
+ await queue.shutdown(10000);
+ for (const ch of channels) await ch.disconnect();
+ process.exit(0);
+ };
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
+ process.on('SIGINT', () => shutdown('SIGINT'));
+
+ // Channel callbacks (shared by all channels)
+ const channelOpts = {
+ onMessage: (_chatJid: string, msg: NewMessage) => storeMessage(msg),
+ onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) =>
+ storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
+ registeredGroups: () => registeredGroups,
+ };
+
+ // Create and connect channels
+ // Check if Slack tokens are configured
+ const slackEnv = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN']);
+ const hasSlackTokens = !!(slackEnv.SLACK_BOT_TOKEN && slackEnv.SLACK_APP_TOKEN);
+
+ if (!SLACK_ONLY) {
+ whatsapp = new WhatsAppChannel(channelOpts);
+ channels.push(whatsapp);
+ await whatsapp.connect();
+ }
+
+ if (hasSlackTokens) {
+ slack = new SlackChannel(channelOpts);
+ channels.push(slack);
+ await slack.connect();
+ }
+
+ // Start subsystems (independently of connection handler)
+ startSchedulerLoop({
+ registeredGroups: () => registeredGroups,
+ getSessions: () => sessions,
+ queue,
+ onProcess: (groupJid, proc, containerName, groupFolder) => queue.registerProcess(groupJid, proc, containerName, groupFolder),
+ sendMessage: async (jid, rawText) => {
+ const channel = findChannel(channels, jid);
+ if (!channel) {
+ console.log(`Warning: no channel owns JID ${jid}, cannot send message`);
+ return;
+ }
+ const text = formatOutbound(rawText);
+ if (text) await channel.sendMessage(jid, text);
+ },
+ });
+ startIpcWatcher({
+ sendMessage: (jid, text) => {
+ const channel = findChannel(channels, jid);
+ if (!channel) throw new Error(`No channel for JID: ${jid}`);
+ return channel.sendMessage(jid, text);
+ },
+ registeredGroups: () => registeredGroups,
+ registerGroup,
+ syncGroupMetadata: async (force) => {
+ // Sync metadata across all active channels
+ if (whatsapp) await whatsapp.syncGroupMetadata(force);
+ if (slack) await slack.syncChannelMetadata();
+ },
+ getAvailableGroups,
+ writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj),
+ });
+ queue.setProcessMessagesFn(processGroupMessages);
+ recoverPendingMessages();
+ startMessageLoop();
+}
+
+// Guard: only run when executed directly, not when imported by tests
+const isDirectRun =
+ process.argv[1] &&
+ new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname;
+
+if (isDirectRun) {
+ main().catch((err) => {
+ logger.error({ err }, 'Failed to start NanoClaw');
+ process.exit(1);
+ });
+}
diff --git a/.agent/skills/add-slack/modify/src/index.ts.intent.md b/.agent/skills/add-slack/modify/src/index.ts.intent.md
new file mode 100644
index 0000000..a7deec7
--- /dev/null
+++ b/.agent/skills/add-slack/modify/src/index.ts.intent.md
@@ -0,0 +1,70 @@
+# Intent: src/index.ts modifications
+
+## What changed
+
+Refactored from single WhatsApp channel to multi-channel architecture supporting Slack alongside WhatsApp.
+
+## Key sections
+
+### Imports (top of file)
+
+- Added: `SlackChannel` from `./channels/slack.js`
+- Added: `SLACK_ONLY` from `./config.js`
+- Added: `readEnvFile` from `./env.js`
+- Existing: `findChannel` from `./router.js` and `Channel` type from `./types.js` are already present
+
+### Module-level state
+
+- Kept: `let whatsapp: WhatsAppChannel` — still needed for `syncGroupMetadata` reference
+- Added: `let slack: SlackChannel | undefined` — direct reference for `syncChannelMetadata`
+- Kept: `const channels: Channel[] = []` — array of all active channels
+
+### processGroupMessages()
+
+- Uses `findChannel(channels, chatJid)` lookup (already exists in base)
+- Uses `channel.setTyping?.()` and `channel.sendMessage()` (already exists in base)
+
+### startMessageLoop()
+
+- Uses `findChannel(channels, chatJid)` per group (already exists in base)
+- Uses `channel.setTyping?.()` for typing indicators (already exists in base)
+
+### main()
+
+- Added: Reads Slack tokens via `readEnvFile()` to check if Slack is configured
+- Added: conditional WhatsApp creation (`if (!SLACK_ONLY)`)
+- Added: conditional Slack creation (`if (hasSlackTokens)`)
+- Changed: scheduler `sendMessage` uses `findChannel()` → `channel.sendMessage()`
+- Changed: IPC `syncGroupMetadata` syncs both WhatsApp and Slack metadata
+- Changed: IPC `sendMessage` uses `findChannel()` → `channel.sendMessage()`
+
+### Shutdown handler
+
+- Changed from `await whatsapp.disconnect()` to `for (const ch of channels) await ch.disconnect()`
+- Disconnects all active channels (WhatsApp, Slack, or any future channels) on SIGTERM/SIGINT
+
+## Invariants
+
+- All existing message processing logic (triggers, cursors, idle timers) is preserved
+- The `runAgent` function is completely unchanged
+- State management (loadState/saveState) is unchanged
+- Recovery logic is unchanged
+- Container runtime check is unchanged (ensureContainerSystemRunning)
+
+## Design decisions
+
+### Double readEnvFile for Slack tokens
+
+`main()` in index.ts reads `SLACK_BOT_TOKEN`/`SLACK_APP_TOKEN` via `readEnvFile()` to check
+whether Slack is configured (controls whether to instantiate SlackChannel). The SlackChannel
+constructor reads them again independently. This is intentional — index.ts needs to decide
+_whether_ to create the channel, while SlackChannel needs the actual token values. Keeping
+both reads follows the security pattern of not passing secrets through intermediate variables.
+
+## Must-keep
+
+- The `escapeXml` and `formatMessages` re-exports
+- The `_setRegisteredGroups` test helper
+- The `isDirectRun` guard at bottom
+- All error handling and cursor rollback logic in processGroupMessages
+- The outgoing queue flush and reconnection logic (in each channel, not here)
diff --git a/.agent/skills/add-slack/modify/src/routing.test.ts b/.agent/skills/add-slack/modify/src/routing.test.ts
new file mode 100644
index 0000000..3a7f7ff
--- /dev/null
+++ b/.agent/skills/add-slack/modify/src/routing.test.ts
@@ -0,0 +1,161 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+
+import { _initTestDatabase, getAllChats, storeChatMetadata } from './db.js';
+import { getAvailableGroups, _setRegisteredGroups } from './index.js';
+
+beforeEach(() => {
+ _initTestDatabase();
+ _setRegisteredGroups({});
+});
+
+// --- JID ownership patterns ---
+
+describe('JID ownership patterns', () => {
+ // These test the patterns that will become ownsJid() on the Channel interface
+
+ it('WhatsApp group JID: ends with @g.us', () => {
+ const jid = '12345678@g.us';
+ expect(jid.endsWith('@g.us')).toBe(true);
+ });
+
+ it('WhatsApp DM JID: ends with @s.whatsapp.net', () => {
+ const jid = '12345678@s.whatsapp.net';
+ expect(jid.endsWith('@s.whatsapp.net')).toBe(true);
+ });
+
+ it('Slack channel JID: starts with slack:', () => {
+ const jid = 'slack:C0123456789';
+ expect(jid.startsWith('slack:')).toBe(true);
+ });
+
+ it('Slack DM JID: starts with slack:D', () => {
+ const jid = 'slack:D0123456789';
+ expect(jid.startsWith('slack:')).toBe(true);
+ });
+});
+
+// --- getAvailableGroups ---
+
+describe('getAvailableGroups', () => {
+ it('returns only groups, excludes DMs', () => {
+ storeChatMetadata('group1@g.us', '2024-01-01T00:00:01.000Z', 'Group 1', 'whatsapp', true);
+ storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false);
+ storeChatMetadata('group2@g.us', '2024-01-01T00:00:03.000Z', 'Group 2', 'whatsapp', true);
+
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(2);
+ expect(groups.map((g) => g.jid)).toContain('group1@g.us');
+ expect(groups.map((g) => g.jid)).toContain('group2@g.us');
+ expect(groups.map((g) => g.jid)).not.toContain('user@s.whatsapp.net');
+ });
+
+ it('excludes __group_sync__ sentinel', () => {
+ storeChatMetadata('__group_sync__', '2024-01-01T00:00:00.000Z');
+ storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Group', 'whatsapp', true);
+
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(1);
+ expect(groups[0].jid).toBe('group@g.us');
+ });
+
+ it('marks registered groups correctly', () => {
+ storeChatMetadata('reg@g.us', '2024-01-01T00:00:01.000Z', 'Registered', 'whatsapp', true);
+ storeChatMetadata('unreg@g.us', '2024-01-01T00:00:02.000Z', 'Unregistered', 'whatsapp', true);
+
+ _setRegisteredGroups({
+ 'reg@g.us': {
+ name: 'Registered',
+ folder: 'registered',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ },
+ });
+
+ const groups = getAvailableGroups();
+ const reg = groups.find((g) => g.jid === 'reg@g.us');
+ const unreg = groups.find((g) => g.jid === 'unreg@g.us');
+
+ expect(reg?.isRegistered).toBe(true);
+ expect(unreg?.isRegistered).toBe(false);
+ });
+
+ it('returns groups ordered by most recent activity', () => {
+ storeChatMetadata('old@g.us', '2024-01-01T00:00:01.000Z', 'Old', 'whatsapp', true);
+ storeChatMetadata('new@g.us', '2024-01-01T00:00:05.000Z', 'New', 'whatsapp', true);
+ storeChatMetadata('mid@g.us', '2024-01-01T00:00:03.000Z', 'Mid', 'whatsapp', true);
+
+ const groups = getAvailableGroups();
+ expect(groups[0].jid).toBe('new@g.us');
+ expect(groups[1].jid).toBe('mid@g.us');
+ expect(groups[2].jid).toBe('old@g.us');
+ });
+
+ it('excludes non-group chats regardless of JID format', () => {
+ // Unknown JID format stored without is_group should not appear
+ storeChatMetadata('unknown-format-123', '2024-01-01T00:00:01.000Z', 'Unknown');
+ // Explicitly non-group with unusual JID
+ storeChatMetadata('custom:abc', '2024-01-01T00:00:02.000Z', 'Custom DM', 'custom', false);
+ // A real group for contrast
+ storeChatMetadata('group@g.us', '2024-01-01T00:00:03.000Z', 'Group', 'whatsapp', true);
+
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(1);
+ expect(groups[0].jid).toBe('group@g.us');
+ });
+
+ it('returns empty array when no chats exist', () => {
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(0);
+ });
+
+ it('includes Slack channel JIDs', () => {
+ storeChatMetadata('slack:C0123456789', '2024-01-01T00:00:01.000Z', 'Slack Channel', 'slack', true);
+ storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false);
+
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(1);
+ expect(groups[0].jid).toBe('slack:C0123456789');
+ });
+
+ it('returns Slack DM JIDs as groups when is_group is true', () => {
+ storeChatMetadata('slack:D0123456789', '2024-01-01T00:00:01.000Z', 'Slack DM', 'slack', true);
+
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(1);
+ expect(groups[0].jid).toBe('slack:D0123456789');
+ expect(groups[0].name).toBe('Slack DM');
+ });
+
+ it('marks registered Slack channels correctly', () => {
+ storeChatMetadata('slack:C0123456789', '2024-01-01T00:00:01.000Z', 'Slack Registered', 'slack', true);
+ storeChatMetadata('slack:C9999999999', '2024-01-01T00:00:02.000Z', 'Slack Unregistered', 'slack', true);
+
+ _setRegisteredGroups({
+ 'slack:C0123456789': {
+ name: 'Slack Registered',
+ folder: 'slack-registered',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ },
+ });
+
+ const groups = getAvailableGroups();
+ const slackReg = groups.find((g) => g.jid === 'slack:C0123456789');
+ const slackUnreg = groups.find((g) => g.jid === 'slack:C9999999999');
+
+ expect(slackReg?.isRegistered).toBe(true);
+ expect(slackUnreg?.isRegistered).toBe(false);
+ });
+
+ it('mixes WhatsApp and Slack chats ordered by activity', () => {
+ storeChatMetadata('wa@g.us', '2024-01-01T00:00:01.000Z', 'WhatsApp', 'whatsapp', true);
+ storeChatMetadata('slack:C100', '2024-01-01T00:00:03.000Z', 'Slack', 'slack', true);
+ storeChatMetadata('wa2@g.us', '2024-01-01T00:00:02.000Z', 'WhatsApp 2', 'whatsapp', true);
+
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(3);
+ expect(groups[0].jid).toBe('slack:C100');
+ expect(groups[1].jid).toBe('wa2@g.us');
+ expect(groups[2].jid).toBe('wa@g.us');
+ });
+});
diff --git a/.agent/skills/add-slack/modify/src/routing.test.ts.intent.md b/.agent/skills/add-slack/modify/src/routing.test.ts.intent.md
new file mode 100644
index 0000000..4f310be
--- /dev/null
+++ b/.agent/skills/add-slack/modify/src/routing.test.ts.intent.md
@@ -0,0 +1,21 @@
+# Intent: src/routing.test.ts modifications
+
+## What changed
+
+Added Slack JID pattern tests and Slack-specific getAvailableGroups tests.
+
+## Key sections
+
+- **JID ownership patterns**: Added Slack channel JID (`slack:C...`) and Slack DM JID (`slack:D...`) pattern tests
+- **getAvailableGroups**: Added tests for Slack channel inclusion, Slack DM handling, registered Slack channels, and mixed WhatsApp + Slack ordering
+
+## Invariants
+
+- All existing WhatsApp JID pattern tests remain unchanged
+- All existing getAvailableGroups tests remain unchanged
+- New tests follow the same patterns as existing tests
+
+## Must-keep
+
+- All existing WhatsApp tests (group JID, DM JID patterns)
+- All existing getAvailableGroups tests (DM exclusion, sentinel exclusion, registration, ordering, non-group exclusion, empty array)
diff --git a/.agent/skills/add-slack/tests/slack.test.ts b/.agent/skills/add-slack/tests/slack.test.ts
new file mode 100644
index 0000000..7e8d946
--- /dev/null
+++ b/.agent/skills/add-slack/tests/slack.test.ts
@@ -0,0 +1,171 @@
+import { describe, expect, it } from 'vitest';
+import fs from 'fs';
+import path from 'path';
+
+describe('slack skill package', () => {
+ const skillDir = path.resolve(__dirname, '..');
+
+ it('has a valid manifest', () => {
+ const manifestPath = path.join(skillDir, 'manifest.yaml');
+ expect(fs.existsSync(manifestPath)).toBe(true);
+
+ const content = fs.readFileSync(manifestPath, 'utf-8');
+ expect(content).toContain('skill: slack');
+ expect(content).toContain('version: 1.0.0');
+ expect(content).toContain('@slack/bolt');
+ });
+
+ it('has all files declared in adds', () => {
+ const addFile = path.join(skillDir, 'add', 'src', 'channels', 'slack.ts');
+ expect(fs.existsSync(addFile)).toBe(true);
+
+ const content = fs.readFileSync(addFile, 'utf-8');
+ expect(content).toContain('class SlackChannel');
+ expect(content).toContain('implements Channel');
+
+ // Test file for the channel
+ const testFile = path.join(skillDir, 'add', 'src', 'channels', 'slack.test.ts');
+ expect(fs.existsSync(testFile)).toBe(true);
+
+ const testContent = fs.readFileSync(testFile, 'utf-8');
+ expect(testContent).toContain("describe('SlackChannel'");
+ });
+
+ it('has all files declared in modifies', () => {
+ const indexFile = path.join(skillDir, 'modify', 'src', 'index.ts');
+ const configFile = path.join(skillDir, 'modify', 'src', 'config.ts');
+ const routingTestFile = path.join(skillDir, 'modify', 'src', 'routing.test.ts');
+
+ expect(fs.existsSync(indexFile)).toBe(true);
+ expect(fs.existsSync(configFile)).toBe(true);
+ expect(fs.existsSync(routingTestFile)).toBe(true);
+
+ const indexContent = fs.readFileSync(indexFile, 'utf-8');
+ expect(indexContent).toContain('SlackChannel');
+ expect(indexContent).toContain('SLACK_ONLY');
+ expect(indexContent).toContain('findChannel');
+ expect(indexContent).toContain('channels: Channel[]');
+
+ const configContent = fs.readFileSync(configFile, 'utf-8');
+ expect(configContent).toContain('SLACK_ONLY');
+ });
+
+ it('has intent files for modified files', () => {
+ expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'index.ts.intent.md'))).toBe(true);
+ expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'config.ts.intent.md'))).toBe(true);
+ expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'routing.test.ts.intent.md'))).toBe(true);
+ });
+
+ it('has setup documentation', () => {
+ expect(fs.existsSync(path.join(skillDir, 'SKILL.md'))).toBe(true);
+ expect(fs.existsSync(path.join(skillDir, 'SLACK_SETUP.md'))).toBe(true);
+ });
+
+ it('modified index.ts preserves core structure', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'index.ts'),
+ 'utf-8',
+ );
+
+ // Core functions still present
+ expect(content).toContain('function loadState()');
+ expect(content).toContain('function saveState()');
+ expect(content).toContain('function registerGroup(');
+ expect(content).toContain('function getAvailableGroups()');
+ expect(content).toContain('function processGroupMessages(');
+ expect(content).toContain('function runAgent(');
+ expect(content).toContain('function startMessageLoop()');
+ expect(content).toContain('function recoverPendingMessages()');
+ expect(content).toContain('function ensureContainerSystemRunning()');
+ expect(content).toContain('async function main()');
+
+ // Test helper preserved
+ expect(content).toContain('_setRegisteredGroups');
+
+ // Direct-run guard preserved
+ expect(content).toContain('isDirectRun');
+ });
+
+ it('modified index.ts includes Slack channel creation', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'index.ts'),
+ 'utf-8',
+ );
+
+ // Multi-channel architecture
+ expect(content).toContain('const channels: Channel[] = []');
+ expect(content).toContain('channels.push(whatsapp)');
+ expect(content).toContain('channels.push(slack)');
+
+ // Conditional channel creation
+ expect(content).toContain('if (!SLACK_ONLY)');
+ expect(content).toContain('new SlackChannel(channelOpts)');
+
+ // Shutdown disconnects all channels
+ expect(content).toContain('for (const ch of channels) await ch.disconnect()');
+ });
+
+ it('modified config.ts preserves all existing exports', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'config.ts'),
+ 'utf-8',
+ );
+
+ // All original exports preserved
+ expect(content).toContain('export const ASSISTANT_NAME');
+ expect(content).toContain('export const POLL_INTERVAL');
+ expect(content).toContain('export const TRIGGER_PATTERN');
+ expect(content).toContain('export const CONTAINER_IMAGE');
+ expect(content).toContain('export const DATA_DIR');
+ expect(content).toContain('export const TIMEZONE');
+
+ // Slack config added
+ expect(content).toContain('export const SLACK_ONLY');
+ });
+
+ it('modified routing.test.ts includes Slack JID tests', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'routing.test.ts'),
+ 'utf-8',
+ );
+
+ // Slack JID pattern tests
+ expect(content).toContain('slack:C');
+ expect(content).toContain('slack:D');
+
+ // Mixed ordering test
+ expect(content).toContain('mixes WhatsApp and Slack');
+
+ // All original WhatsApp tests preserved
+ expect(content).toContain('@g.us');
+ expect(content).toContain('@s.whatsapp.net');
+ expect(content).toContain('__group_sync__');
+ });
+
+ it('slack.ts implements required Channel interface methods', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'add', 'src', 'channels', 'slack.ts'),
+ 'utf-8',
+ );
+
+ // Channel interface methods
+ expect(content).toContain('async connect()');
+ expect(content).toContain('async sendMessage(');
+ expect(content).toContain('isConnected()');
+ expect(content).toContain('ownsJid(');
+ expect(content).toContain('async disconnect()');
+ expect(content).toContain('async setTyping(');
+
+ // Security pattern: reads tokens from .env, not process.env
+ expect(content).toContain('readEnvFile');
+ expect(content).not.toContain('process.env.SLACK_BOT_TOKEN');
+ expect(content).not.toContain('process.env.SLACK_APP_TOKEN');
+
+ // Key behaviors
+ expect(content).toContain('socketMode: true');
+ expect(content).toContain('MAX_MESSAGE_LENGTH');
+ expect(content).toContain('thread_ts');
+ expect(content).toContain('TRIGGER_PATTERN');
+ expect(content).toContain('userNameCache');
+ });
+});
diff --git a/.agent/skills/add-stripe/SKILL.md b/.agent/skills/add-stripe/SKILL.md
new file mode 100644
index 0000000..9342654
--- /dev/null
+++ b/.agent/skills/add-stripe/SKILL.md
@@ -0,0 +1,84 @@
+---
+name: add-stripe
+description: Reference guide for Stripe integration in Clawdie. Stripe ships in core on main — use this skill to understand the Stripe tool surface, webhook handling, and payment flows.
+---
+
+# Skill: add-stripe
+
+Current `main` already ships Stripe in core. This file remains as historical
+reference for older branches and for understanding the Stripe tool surface.
+
+## Prerequisites
+
+- A Stripe account
+- A **Restricted API Key** (RAK) with only the permissions you need
+
+## Setup
+
+### 1. Create a Restricted API Key
+
+1. Go to Stripe Dashboard → Developers → API Keys → Restricted Keys
+2. Create a new key with only what your agent needs:
+ - **Customers**: Read
+ - **Payment Intents**: Read
+ - **Payment Links**: Write (if creating links)
+ - **Invoices**: Read
+ - **Subscriptions**: Read
+ - **Refunds**: Write (if issuing refunds)
+ - **Balance**: Read
+3. Copy the key (starts with `rk_live_` or `rk_test_`)
+
+### 2. Add to .env
+
+```
+STRIPE_SECRET_KEY=rk_test_your_key_here
+STRIPE_ENABLE_REFUNDS=NO
+```
+
+Current onboarding can configure a restricted test key (`rk_test_...`) or skip Stripe for now.
+
+### 3. Current main behavior
+
+There is no manual apply-skill step on current `main`. Stripe tools are built
+into `jail/agent-runner` and become available when `STRIPE_SECRET_KEY` is set.
+
+## Tools the agent gets
+
+| Tool | Description |
+| ----------------------------- | ---------------------------------------------------------------------------------- |
+| `stripe_get_balance` | Current account balance |
+| `stripe_list_customers` | List customers, filter by email |
+| `stripe_get_customer` | Get a customer by ID |
+| `stripe_list_payment_intents` | Recent payment intents |
+| `stripe_create_payment_link` | Create a payment link for a price |
+| `stripe_list_invoices` | List invoices, filter by customer/status |
+| `stripe_create_refund` | Issue a refund by payment intent or charge (only when `STRIPE_ENABLE_REFUNDS=YES`) |
+| `stripe_list_subscriptions` | List subscriptions, filter by customer/status |
+
+Tools are registered only when `STRIPE_SECRET_KEY` is set. Refunds remain off by
+default until `STRIPE_ENABLE_REFUNDS=YES`.
+
+## Example conversations
+
+> "How much did we make this month?"
+> → Agent calls `stripe_get_balance` and lists recent payment intents
+
+> "Create a payment link for the Pro plan"
+> → Agent calls `stripe_create_payment_link` with the price ID
+
+> "John wants a refund for last month's invoice"
+> → Agent calls `stripe_list_customers` to find John, then `stripe_list_invoices`,
+> then `stripe_create_refund`
+
+## Security
+
+- Use a Restricted API Key — never your full secret key
+- Limit permissions to what the agent actually needs
+- `STRIPE_SECRET_KEY` is passed in the jailed stdin payload, never written as a mounted file
+- Refunds stay disabled until you deliberately enable them
+
+## Upgrading to full channel
+
+Future versions of this skill may add inbound webhook handling so the agent
+can react to Stripe events (payment succeeded, subscription cancelled, etc.)
+automatically. For now, all interactions are agent-initiated.
diff --git a/.agent/skills/add-stripe/add/jail/agent-runner/src/stripe-tools.ts b/.agent/skills/add-stripe/add/jail/agent-runner/src/stripe-tools.ts
new file mode 100644
index 0000000..7e694c0
--- /dev/null
+++ b/.agent/skills/add-stripe/add/jail/agent-runner/src/stripe-tools.ts
@@ -0,0 +1,275 @@
+/**
+ * Stripe MCP tools for Clawdie agent.
+ * Registered into the existing ipc-mcp-stdio server when STRIPE_SECRET_KEY is set.
+ * Uses a Restricted API Key — never the full secret key.
+ */
+import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
+import type Stripe from 'stripe';
+import { z } from 'zod';
+
+export function registerStripeTools(server: McpServer): void {
+ const key = process.env.STRIPE_SECRET_KEY;
+ if (!key) return;
+
+ let stripeInstance: Stripe | null = null;
+
+ const getStripe = async (): Promise => {
+ if (!stripeInstance) {
+ const { default: StripeClass } = await import('stripe') as { default: typeof Stripe };
+ stripeInstance = new StripeClass(key);
+ }
+ return stripeInstance;
+ };
+
+ server.tool(
+ 'stripe_get_balance',
+ 'Get the current Stripe account balance (available and pending)',
+ {},
+ async () => {
+ try {
+ const stripe = await getStripe();
+ const balance = await stripe.balance.retrieve();
+ const lines = balance.available.map(
+ (b) => `${b.currency.toUpperCase()} available: ${(b.amount / 100).toFixed(2)}`,
+ );
+ const pending = balance.pending.map(
+ (b) => `${b.currency.toUpperCase()} pending: ${(b.amount / 100).toFixed(2)}`,
+ );
+ return {
+ content: [{ type: 'text' as const, text: [...lines, ...pending].join('\n') }],
+ };
+ } catch (err) {
+ return {
+ content: [{ type: 'text' as const, text: `Stripe error: ${err instanceof Error ? err.message : String(err)}` }],
+ isError: true,
+ };
+ }
+ },
+ );
+
+ server.tool(
+ 'stripe_list_customers',
+ 'List Stripe customers. Optionally filter by email.',
+ {
+ email: z.string().optional().describe('Filter by exact email address'),
+ limit: z.number().int().min(1).max(100).default(10).optional().describe('Max results, default 10'),
+ },
+ async (args) => {
+ try {
+ const stripe = await getStripe();
+ const params: Stripe.CustomerListParams = { limit: args.limit ?? 10 };
+ if (args.email) params.email = args.email;
+ const customers = await stripe.customers.list(params);
+ const lines = customers.data.map(
+ (c) => `${c.id} | ${c.email || 'no email'} | ${c.name || 'no name'} | created: ${new Date(c.created * 1000).toISOString()}`,
+ );
+ return {
+ content: [{ type: 'text' as const, text: lines.length > 0 ? lines.join('\n') : 'No customers found.' }],
+ };
+ } catch (err) {
+ return {
+ content: [{ type: 'text' as const, text: `Stripe error: ${err instanceof Error ? err.message : String(err)}` }],
+ isError: true,
+ };
+ }
+ },
+ );
+
+ server.tool(
+ 'stripe_get_customer',
+ 'Get a Stripe customer by their customer ID',
+ {
+ customer_id: z.string().describe('Stripe customer ID (cus_...)'),
+ },
+ async (args) => {
+ try {
+ const stripe = await getStripe();
+ const customer = await stripe.customers.retrieve(args.customer_id);
+ return {
+ content: [{ type: 'text' as const, text: JSON.stringify(customer, null, 2) }],
+ };
+ } catch (err) {
+ return {
+ content: [{ type: 'text' as const, text: `Stripe error: ${err instanceof Error ? err.message : String(err)}` }],
+ isError: true,
+ };
+ }
+ },
+ );
+
+ server.tool(
+ 'stripe_list_payment_intents',
+ 'List recent Stripe payment intents, optionally filtered by customer',
+ {
+ customer_id: z.string().optional().describe('Filter by customer ID (cus_...)'),
+ limit: z.number().int().min(1).max(100).default(10).optional().describe('Max results, default 10'),
+ },
+ async (args) => {
+ try {
+ const stripe = await getStripe();
+ const params: Stripe.PaymentIntentListParams = { limit: args.limit ?? 10 };
+ if (args.customer_id) params.customer = args.customer_id;
+ const intents = await stripe.paymentIntents.list(params);
+ const lines = intents.data.map(
+ (pi) =>
+ `${pi.id} | ${((pi.amount ?? 0) / 100).toFixed(2)} ${(pi.currency ?? '').toUpperCase()} | ${pi.status} | ${new Date(pi.created * 1000).toISOString()}`,
+ );
+ return {
+ content: [{ type: 'text' as const, text: lines.length > 0 ? lines.join('\n') : 'No payment intents found.' }],
+ };
+ } catch (err) {
+ return {
+ content: [{ type: 'text' as const, text: `Stripe error: ${err instanceof Error ? err.message : String(err)}` }],
+ isError: true,
+ };
+ }
+ },
+ );
+
+ server.tool(
+ 'stripe_create_payment_link',
+ 'Create a Stripe payment link for a product price. Returns the URL to share with customers.',
+ {
+ price_id: z.string().describe('Stripe price ID (price_...) — find in Products → Prices'),
+ quantity: z.number().int().min(1).default(1).optional().describe('Quantity, default 1'),
+ },
+ async (args) => {
+ try {
+ const stripe = await getStripe();
+ const link = await stripe.paymentLinks.create({
+ line_items: [{ price: args.price_id, quantity: args.quantity ?? 1 }],
+ });
+ return {
+ content: [{ type: 'text' as const, text: `Payment link created:\nURL: ${link.url}\nID: ${link.id}\nActive: ${link.active}` }],
+ };
+ } catch (err) {
+ return {
+ content: [{ type: 'text' as const, text: `Stripe error: ${err instanceof Error ? err.message : String(err)}` }],
+ isError: true,
+ };
+ }
+ },
+ );
+
+ server.tool(
+ 'stripe_list_invoices',
+ 'List Stripe invoices, optionally filtered by customer or status',
+ {
+ customer_id: z.string().optional().describe('Filter by customer ID (cus_...)'),
+ status: z
+ .enum(['draft', 'open', 'paid', 'uncollectible', 'void'])
+ .optional()
+ .describe('Filter by invoice status'),
+ limit: z.number().int().min(1).max(100).default(10).optional().describe('Max results, default 10'),
+ },
+ async (args) => {
+ try {
+ const stripe = await getStripe();
+ const params: Stripe.InvoiceListParams = { limit: args.limit ?? 10 };
+ if (args.customer_id) params.customer = args.customer_id;
+ if (args.status) params.status = args.status;
+ const invoices = await stripe.invoices.list(params);
+ const lines = invoices.data.map(
+ (inv) =>
+ `${inv.id} | ${inv.customer_email || inv.customer} | ${((inv.amount_due ?? 0) / 100).toFixed(2)} ${(inv.currency ?? '').toUpperCase()} | ${inv.status} | ${new Date(inv.created * 1000).toISOString()}${inv.hosted_invoice_url ? ` | ${inv.hosted_invoice_url}` : ''}`,
+ );
+ return {
+ content: [{ type: 'text' as const, text: lines.length > 0 ? lines.join('\n') : 'No invoices found.' }],
+ };
+ } catch (err) {
+ return {
+ content: [{ type: 'text' as const, text: `Stripe error: ${err instanceof Error ? err.message : String(err)}` }],
+ isError: true,
+ };
+ }
+ },
+ );
+
+ server.tool(
+ 'stripe_create_refund',
+ 'Issue a full or partial refund for a payment intent or charge',
+ {
+ payment_intent_id: z
+ .string()
+ .optional()
+ .describe('Payment intent ID to refund (pi_...). Provide this OR charge_id.'),
+ charge_id: z
+ .string()
+ .optional()
+ .describe('Charge ID to refund (ch_...). Provide this OR payment_intent_id.'),
+ amount: z
+ .number()
+ .int()
+ .optional()
+ .describe('Amount in smallest currency unit (e.g. cents). Omit for full refund.'),
+ reason: z
+ .enum(['duplicate', 'fraudulent', 'requested_by_customer'])
+ .optional()
+ .describe('Reason for refund'),
+ },
+ async (args) => {
+ if (!args.payment_intent_id && !args.charge_id) {
+ return {
+ content: [{ type: 'text' as const, text: 'Provide either payment_intent_id or charge_id.' }],
+ isError: true,
+ };
+ }
+ try {
+ const stripe = await getStripe();
+ const params: Stripe.RefundCreateParams = {};
+ if (args.payment_intent_id) params.payment_intent = args.payment_intent_id;
+ if (args.charge_id) params.charge = args.charge_id;
+ if (args.amount) params.amount = args.amount;
+ if (args.reason) params.reason = args.reason;
+ const refund = await stripe.refunds.create(params);
+ return {
+ content: [{
+ type: 'text' as const,
+ text: `Refund created:\nID: ${refund.id}\nAmount: ${((refund.amount ?? 0) / 100).toFixed(2)} ${(refund.currency ?? '').toUpperCase()}\nStatus: ${refund.status}`,
+ }],
+ };
+ } catch (err) {
+ return {
+ content: [{ type: 'text' as const, text: `Stripe error: ${err instanceof Error ? err.message : String(err)}` }],
+ isError: true,
+ };
+ }
+ },
+ );
+
+ server.tool(
+ 'stripe_list_subscriptions',
+ 'List Stripe subscriptions, optionally filtered by customer or status',
+ {
+ customer_id: z.string().optional().describe('Filter by customer ID (cus_...)'),
+ status: z
+ .enum(['active', 'past_due', 'unpaid', 'canceled', 'incomplete', 'trialing', 'all'])
+ .optional()
+ .describe('Filter by status. Default: active'),
+ limit: z.number().int().min(1).max(100).default(10).optional().describe('Max results, default 10'),
+ },
+ async (args) => {
+ try {
+ const stripe = await getStripe();
+ const params: Stripe.SubscriptionListParams = {
+ limit: args.limit ?? 10,
+ status: (args.status as Stripe.SubscriptionListParams['status']) ?? 'active',
+ };
+ if (args.customer_id) params.customer = args.customer_id;
+ const subs = await stripe.subscriptions.list(params);
+ const lines = subs.data.map(
+ (s) =>
+ `${s.id} | customer: ${typeof s.customer === 'string' ? s.customer : s.customer?.id} | ${s.status} | renews: ${new Date(s.current_period_end * 1000).toISOString()}${s.cancel_at_period_end ? ' (cancels at period end)' : ''}`,
+ );
+ return {
+ content: [{ type: 'text' as const, text: lines.length > 0 ? lines.join('\n') : 'No subscriptions found.' }],
+ };
+ } catch (err) {
+ return {
+ content: [{ type: 'text' as const, text: `Stripe error: ${err instanceof Error ? err.message : String(err)}` }],
+ isError: true,
+ };
+ }
+ },
+ );
+}
diff --git a/.agent/skills/add-stripe/manifest.yaml b/.agent/skills/add-stripe/manifest.yaml
new file mode 100644
index 0000000..8cd2013
--- /dev/null
+++ b/.agent/skills/add-stripe/manifest.yaml
@@ -0,0 +1,16 @@
+skill: stripe
+version: 1.0.0
+description: "Stripe payments, customers, invoices, subscriptions, refunds, and payment links"
+core_version: 0.4.0
+adds:
+ - jail/agent-runner/src/stripe-tools.ts
+modifies:
+ - src/jail-runner.ts
+ - jail/agent-runner/src/ipc-mcp-stdio.ts
+ - jail/agent-runner/package.json
+structured:
+ npm_dependencies:
+ stripe: "^17.0.0"
+conflicts: []
+depends: []
+test: ""
diff --git a/.agent/skills/add-stripe/modify/jail/agent-runner/package.json.intent.md b/.agent/skills/add-stripe/modify/jail/agent-runner/package.json.intent.md
new file mode 100644
index 0000000..14fede7
--- /dev/null
+++ b/.agent/skills/add-stripe/modify/jail/agent-runner/package.json.intent.md
@@ -0,0 +1,28 @@
+# Intent: jail/agent-runner/package.json modifications
+
+## What changed
+
+Added `stripe` as a runtime dependency.
+
+## Key sections
+
+### dependencies
+
+- Added: `"stripe": "^17.0.0"`
+
+```json
+{
+ "dependencies": {
+ "@modelcontextprotocol/sdk": "^1.12.1",
+ "cron-parser": "^5.0.0",
+ "stripe": "^17.0.0",
+ "zod": "^4.0.0"
+ }
+}
+```
+
+## Invariants
+
+- All existing dependencies remain unchanged
+- devDependencies are unchanged
+- package name, version, scripts are unchanged
diff --git a/.agent/skills/add-stripe/modify/jail/agent-runner/src/ipc-mcp-stdio.ts.intent.md b/.agent/skills/add-stripe/modify/jail/agent-runner/src/ipc-mcp-stdio.ts.intent.md
new file mode 100644
index 0000000..41f66fb
--- /dev/null
+++ b/.agent/skills/add-stripe/modify/jail/agent-runner/src/ipc-mcp-stdio.ts.intent.md
@@ -0,0 +1,36 @@
+# Intent: jail/agent-runner/src/ipc-mcp-stdio.ts modifications
+
+## What changed
+
+Imported and registered the Stripe MCP tools so the agent can access Stripe
+operations (customers, invoices, payment links, refunds, subscriptions, balance).
+Tools are only registered when `STRIPE_SECRET_KEY` is present in the environment.
+
+## Key sections
+
+### Imports (top of file)
+
+- Added: `import { registerStripeTools } from './stripe-tools.js';`
+
+### After all existing server.tool() calls, before the transport connection
+
+- Added: `registerStripeTools(server);`
+
+```typescript
+// Add near the bottom, before:
+// const transport = new StdioServerTransport();
+
+registerStripeTools(server);
+
+// Existing:
+const transport = new StdioServerTransport();
+await server.connect(transport);
+```
+
+## Invariants
+
+- All existing tools (send_message, schedule_task, list_tasks, etc.) are unchanged
+- Server name and version are unchanged
+- IPC directory constants are unchanged
+- Transport connection is unchanged
+- If `STRIPE_SECRET_KEY` is absent, `registerStripeTools` is a no-op
diff --git a/.agent/skills/add-stripe/modify/src/jail-runner.ts.intent.md b/.agent/skills/add-stripe/modify/src/jail-runner.ts.intent.md
new file mode 100644
index 0000000..df034ac
--- /dev/null
+++ b/.agent/skills/add-stripe/modify/src/jail-runner.ts.intent.md
@@ -0,0 +1,37 @@
+# Intent: src/jail-runner.ts modifications
+
+## What changed
+
+Added `STRIPE_SECRET_KEY` to the secrets allowlist so the Stripe API key is
+passed to the jail agent securely via stdin — never via environment variables
+or files.
+
+## Key sections
+
+### readSecrets() function (around line 216)
+
+- Added: `'STRIPE_SECRET_KEY'` to the `readEnvFile` array
+
+```typescript
+// Before:
+return readEnvFile([
+ 'ANTHROPIC_API_KEY',
+ ...
+ 'KIMI_API_KEY',
+]);
+
+// After:
+return readEnvFile([
+ 'ANTHROPIC_API_KEY',
+ ...
+ 'KIMI_API_KEY',
+ 'STRIPE_SECRET_KEY',
+]);
+```
+
+## Invariants
+
+- All existing API keys remain in the list unchanged
+- `readEnvFile()` signature and behavior are unchanged
+- Secrets are still passed via stdin JSON, never written to disk or env
+- If `STRIPE_SECRET_KEY` is absent from `.env`, it is silently skipped
diff --git a/.agent/skills/add-telegram-swarm/SKILL.md b/.agent/skills/add-telegram-swarm/SKILL.md
new file mode 100644
index 0000000..2dfa795
--- /dev/null
+++ b/.agent/skills/add-telegram-swarm/SKILL.md
@@ -0,0 +1,388 @@
+---
+name: add-telegram-swarm
+description: Add Agent Swarm (Teams) support to Telegram. Each subagent gets its own bot identity in the group. Requires Telegram channel to be set up first (use /add-telegram). Triggers on "agent swarm", "agent teams telegram", "telegram swarm", "bot pool".
+---
+
+# Add Agent Swarm to Telegram
+
+This skill adds Agent Teams (Swarm) support to an existing Telegram channel. Each subagent in a team gets its own bot identity in the Telegram group, so users can visually distinguish which agent is speaking.
+
+**Prerequisite**: Telegram must already be set up via the `/add-telegram` skill. If `src/telegram.ts` does not exist or `TELEGRAM_BOT_TOKEN` is not configured, tell the user to run `/add-telegram` first.
+
+## How It Works
+
+- The **main bot** receives messages and sends lead agent responses (already set up by `/add-telegram`)
+- **Pool bots** are send-only — each gets a Grammy `Api` instance (no polling)
+- When a subagent calls `send_message` with a `sender` parameter, the host assigns a pool bot and renames it to match the sender's role
+- Messages appear in Telegram from different bot identities
+
+```
+Subagent calls send_message(text: "Found 3 results", sender: "Researcher")
+ → MCP writes IPC file with sender field
+ → Host IPC watcher picks it up
+ → Assigns pool bot #2 to "Researcher" (round-robin, stable per-group)
+ → Renames pool bot #2 to "Researcher" via setMyName
+ → Sends message via pool bot #2's Api instance
+ → Appears in Telegram from "Researcher" bot
+```
+
+## Prerequisites
+
+### 1. Create Pool Bots
+
+Tell the user:
+
+> I need you to create 3-5 Telegram bots to use as the agent pool. These will be renamed dynamically to match agent roles.
+>
+> 1. Open Telegram and search for `@BotFather`
+> 2. Send `/newbot` for each bot:
+> - Give them any placeholder name (e.g., "Bot 1", "Bot 2")
+> - Usernames like `myproject_swarm_1_bot`, `myproject_swarm_2_bot`, etc.
+> 3. Copy all the tokens
+> 4. Add all bots to your Telegram group(s) where you want agent teams
+
+Wait for user to provide the tokens.
+
+### 2. Disable Group Privacy for Pool Bots
+
+Tell the user:
+
+> **Important**: Each pool bot needs Group Privacy disabled so it can send messages in groups.
+>
+> For each pool bot in `@BotFather`:
+>
+> 1. Send `/mybots` and select the bot
+> 2. Go to **Bot Settings** > **Group Privacy** > **Turn off**
+>
+> Then add all pool bots to your Telegram group(s).
+
+## Implementation
+
+### Step 1: Update Configuration
+
+Read `src/config.ts` and add the bot pool config near the other Telegram exports:
+
+```typescript
+export const TELEGRAM_BOT_POOL = (process.env.TELEGRAM_BOT_POOL || '')
+ .split(',')
+ .map((t) => t.trim())
+ .filter(Boolean);
+```
+
+### Step 2: Add Bot Pool to Telegram Module
+
+Read `src/telegram.ts` and add the following:
+
+1. **Update imports** — add `Api` to the Grammy import:
+
+```typescript
+import { Api, Bot } from 'grammy';
+```
+
+2. **Add pool state** after the existing `let bot` declaration:
+
+```typescript
+// Bot pool for agent teams: send-only Api instances (no polling)
+const poolApis: Api[] = [];
+// Maps "{groupFolder}:{senderName}" → pool Api index for stable assignment
+const senderBotMap = new Map();
+let nextPoolIndex = 0;
+```
+
+3. **Add pool functions** — place these before the `isTelegramConnected` function:
+
+```typescript
+/**
+ * Initialize send-only Api instances for the bot pool.
+ * Each pool bot can send messages but doesn't poll for updates.
+ */
+export async function initBotPool(tokens: string[]): Promise {
+ for (const token of tokens) {
+ try {
+ const api = new Api(token);
+ const me = await api.getMe();
+ poolApis.push(api);
+ logger.info(
+ { username: me.username, id: me.id, poolSize: poolApis.length },
+ 'Pool bot initialized',
+ );
+ } catch (err) {
+ logger.error({ err }, 'Failed to initialize pool bot');
+ }
+ }
+ if (poolApis.length > 0) {
+ logger.info({ count: poolApis.length }, 'Telegram bot pool ready');
+ }
+}
+
+/**
+ * Send a message via a pool bot assigned to the given sender name.
+ * Assigns bots round-robin on first use; subsequent messages from the
+ * same sender in the same group always use the same bot.
+ * On first assignment, renames the bot to match the sender's role.
+ */
+export async function sendPoolMessage(
+ chatId: string,
+ text: string,
+ sender: string,
+ groupFolder: string,
+): Promise {
+ if (poolApis.length === 0) {
+ // No pool bots — fall back to main bot
+ await sendTelegramMessage(chatId, text);
+ return;
+ }
+
+ const key = `${groupFolder}:${sender}`;
+ let idx = senderBotMap.get(key);
+ if (idx === undefined) {
+ idx = nextPoolIndex % poolApis.length;
+ nextPoolIndex++;
+ senderBotMap.set(key, idx);
+ // Rename the bot to match the sender's role, then wait for Telegram to propagate
+ try {
+ await poolApis[idx].setMyName(sender);
+ await new Promise((r) => setTimeout(r, 2000));
+ logger.info({ sender, groupFolder, poolIndex: idx }, 'Assigned and renamed pool bot');
+ } catch (err) {
+ logger.warn({ sender, err }, 'Failed to rename pool bot (sending anyway)');
+ }
+ }
+
+ const api = poolApis[idx];
+ try {
+ const numericId = chatId.replace(/^tg:/, '');
+ const MAX_LENGTH = 4096;
+ if (text.length <= MAX_LENGTH) {
+ await api.sendMessage(numericId, text);
+ } else {
+ for (let i = 0; i < text.length; i += MAX_LENGTH) {
+ await api.sendMessage(numericId, text.slice(i, i + MAX_LENGTH));
+ }
+ }
+ logger.info({ chatId, sender, poolIndex: idx, length: text.length }, 'Pool message sent');
+ } catch (err) {
+ logger.error({ chatId, sender, err }, 'Failed to send pool message');
+ }
+}
+```
+
+### Step 3: Add sender Parameter to MCP Tool
+
+Read `container/agent-runner/src/ipc-mcp-stdio.ts` and update the `send_message` tool to accept an optional `sender` parameter:
+
+Change the tool's schema from:
+
+```typescript
+{ text: z.string().describe('The message text to send') },
+```
+
+To:
+
+```typescript
+{
+ text: z.string().describe('The message text to send'),
+ sender: z.string().optional().describe('Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.'),
+},
+```
+
+And update the handler to include `sender` in the IPC data:
+
+```typescript
+async (args) => {
+ const data: Record = {
+ type: 'message',
+ chatJid,
+ text: args.text,
+ sender: args.sender || undefined,
+ groupFolder,
+ timestamp: new Date().toISOString(),
+ };
+
+ writeIpcFile(MESSAGES_DIR, data);
+
+ return { content: [{ type: 'text' as const, text: 'Message sent.' }] };
+ },
+```
+
+### Step 4: Update Host IPC Routing
+
+Read `src/ipc.ts` and make these changes:
+
+1. **Add imports** — add `sendPoolMessage` and `initBotPool` from the Telegram swarm module, and `TELEGRAM_BOT_POOL` from config.
+
+2. **Update IPC message routing** — in `src/ipc.ts`, find where the `sendMessage` dependency is called to deliver IPC messages (inside `processIpcFiles`). The `sendMessage` is passed in via the `IpcDeps` parameter. Wrap it to route Telegram swarm messages through the bot pool:
+
+```typescript
+if (data.sender && data.chatJid.startsWith('tg:')) {
+ await sendPoolMessage(
+ data.chatJid,
+ data.text,
+ data.sender,
+ sourceGroup,
+ );
+} else {
+ await deps.sendMessage(data.chatJid, data.text);
+}
+```
+
+Note: The assistant name prefix is handled by `formatOutbound()` in the router — Telegram channels have `prefixAssistantName = false` so no prefix is added for `tg:` JIDs.
+
+3. **Initialize pool in `main()` in `src/index.ts`** — after creating the Telegram channel, add:
+
+```typescript
+if (TELEGRAM_BOT_POOL.length > 0) {
+ await initBotPool(TELEGRAM_BOT_POOL);
+}
+```
+
+### Step 5: Update AGENT.md Files
+
+#### 5a. Add global message formatting rules
+
+Read `groups/global/AGENT.md` and add a Message Formatting section:
+
+````markdown
+## Message Formatting
+
+NEVER use markdown. Only use WhatsApp/Telegram formatting:
+- *single asterisks* for bold (NEVER **double asterisks**)
+- _underscores_ for italic
+- • bullet points
+- ```triple backticks``` for code
+
+No ## headings. No [links](url). No **double stars**.
+````
+
+#### 5b. Update existing group AGENT.md headings
+
+In any group AGENT.md that has a "WhatsApp Formatting" section (e.g. `groups/main/AGENT.md`), rename the heading to reflect multi-channel support:
+
+```
+## WhatsApp Formatting (and other messaging apps)
+```
+
+#### 5c. Add Agent Teams instructions to Telegram groups
+
+For each Telegram group that will use agent teams, create or update its `groups/{folder}/AGENT.md` with these instructions. Read the existing AGENT.md first (or `groups/global/AGENT.md` as a base) and add the Agent Teams section:
+
+````markdown
+## Agent Teams
+
+When creating a team to tackle a complex task, follow these rules:
+
+### CRITICAL: Follow the user's prompt exactly
+
+Create *exactly* the team the user asked for — same number of agents, same roles, same names. Do NOT add extra agents, rename roles, or use generic names like "Researcher 1". If the user says "a marine biologist, a physicist, and Alexander Hamilton", create exactly those three agents with those exact names.
+
+### Team member instructions
+
+Each team member MUST be instructed to:
+
+1. *Share progress in the group* via `mcp__nanoclaw__send_message` with a `sender` parameter matching their exact role/character name (e.g., `sender: "Marine Biologist"` or `sender: "Alexander Hamilton"`). This makes their messages appear from a dedicated bot in the Telegram group.
+2. *Also communicate with teammates* via `SendMessage` as normal for coordination.
+3. Keep group messages *short* — 2-4 sentences max per message. Break longer content into multiple `send_message` calls. No walls of text.
+4. Use the `sender` parameter consistently — always the same name so the bot identity stays stable.
+5. NEVER use markdown formatting. Use ONLY WhatsApp/Telegram formatting: single *asterisks* for bold (NOT **double**), _underscores_ for italic, • for bullets, ```backticks``` for code. No ## headings, no [links](url), no **double asterisks**.
+
+### Example team creation prompt
+
+When creating a teammate, include instructions like:
+
+\```
+You are the Marine Biologist. When you have findings or updates for the user, send them to the group using mcp__nanoclaw__send_message with sender set to "Marine Biologist". Keep each message short (2-4 sentences max). Use emojis for strong reactions. ONLY use single *asterisks* for bold (never **double**), _underscores_ for italic, • for bullets. No markdown. Also communicate with teammates via SendMessage.
+\```
+
+### Lead agent behavior
+
+As the lead agent who created the team:
+
+- You do NOT need to react to or relay every teammate message. The user sees those directly from the teammate bots.
+- Send your own messages only to comment, share thoughts, synthesize, or direct the team.
+- When processing an internal update from a teammate that doesn't need a user-facing response, wrap your *entire* output in `` tags.
+- Focus on high-level coordination and the final synthesis.
+````
+
+### Step 6: Update Environment
+
+Add pool tokens to `.env`:
+
+```bash
+TELEGRAM_BOT_POOL=TOKEN1,TOKEN2,TOKEN3,...
+```
+
+**Important**: Sync to all required locations:
+
+```bash
+cp .env data/env/env
+```
+
+Also add `TELEGRAM_BOT_POOL` to the launchd plist (`~/Library/LaunchAgents/com.nanoclaw.plist`) in the `EnvironmentVariables` dict if using launchd.
+
+### Step 7: Rebuild and Restart
+
+```bash
+npm run build
+./container/build.sh # Required — MCP tool changed
+# macOS:
+launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
+launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
+# Linux:
+# systemctl --user restart nanoclaw
+```
+
+Must use `unload/load` (macOS) or `restart` (Linux) because the service env vars changed.
+
+### Step 8: Test
+
+Tell the user:
+
+> Send a message in your Telegram group asking for a multi-agent task, e.g.:
+> "Assemble a team of a researcher and a coder to build me a hello world app"
+>
+> You should see:
+>
+> - The lead agent (main bot) acknowledging and creating the team
+> - Each subagent messaging from a different bot, renamed to their role
+> - Short, scannable messages from each agent
+>
+> Check logs: `tail -f logs/nanoclaw.log | grep -i pool`
+
+## Architecture Notes
+
+- Pool bots use Grammy's `Api` class — lightweight, no polling, just send
+- Bot names are set via `setMyName` — changes are global to the bot, not per-chat
+- A 2-second delay after `setMyName` allows Telegram to propagate the name change before the first message
+- Sender→bot mapping is stable within a group (keyed as `{groupFolder}:{senderName}`)
+- Mapping resets on service restart — pool bots get reassigned fresh
+- If pool runs out, bots are reused (round-robin wraps)
+
+## Troubleshooting
+
+### Pool bots not sending messages
+
+1. Verify tokens: `curl -s "https://api.telegram.org/botTOKEN/getMe"`
+2. Check pool initialized: `grep "Pool bot" logs/nanoclaw.log`
+3. Ensure all pool bots are members of the Telegram group
+4. Check Group Privacy is disabled for each pool bot
+
+### Bot names not updating
+
+Telegram caches bot names client-side. The 2-second delay after `setMyName` helps, but users may need to restart their Telegram client to see updated names immediately.
+
+### Subagents not using send_message
+
+Check the group's `AGENT.md` has the Agent Teams instructions. The lead agent reads this when creating teammates and must include the `send_message` + `sender` instructions in each teammate's prompt.
+
+## Removal
+
+To remove Agent Swarm support while keeping basic Telegram:
+
+1. Remove `TELEGRAM_BOT_POOL` from `src/config.ts`
+2. Remove pool code from `src/telegram.ts` (`poolApis`, `senderBotMap`, `initBotPool`, `sendPoolMessage`)
+3. Remove pool routing from IPC handler in `src/index.ts` (revert to plain `sendMessage`)
+4. Remove `initBotPool` call from `main()`
+5. Remove `sender` param from MCP tool in `container/agent-runner/src/ipc-mcp-stdio.ts`
+6. Remove Agent Teams section from group AGENT.md files
+7. Remove `TELEGRAM_BOT_POOL` from `.env`, `data/env/env`, and launchd plist/systemd unit
+8. Rebuild: `npm run build && ./container/build.sh && launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist` (macOS) or `npm run build && ./container/build.sh && systemctl --user restart nanoclaw` (Linux)
diff --git a/.agent/skills/add-telegram/SKILL.md b/.agent/skills/add-telegram/SKILL.md
new file mode 100644
index 0000000..eda8903
--- /dev/null
+++ b/.agent/skills/add-telegram/SKILL.md
@@ -0,0 +1,251 @@
+---
+name: add-telegram
+description: Add Telegram as a channel. Can replace WhatsApp entirely or run alongside it. Also configurable as a control-only channel (triggers actions) or passive channel (receives notifications only).
+---
+
+# Add Telegram Channel
+
+This skill adds Telegram support to NanoClaw using the skills engine for deterministic code changes, then walks through interactive setup.
+
+## Phase 1: Pre-flight
+
+### Check if already applied
+
+Read `.nanoclaw/state.yaml`. If `telegram` is in `applied_skills`, skip to Phase 3 (Setup). The code changes are already in place.
+
+### Ask the user
+
+Use `AskUserQuestion` to collect configuration:
+
+AskUserQuestion: Should Telegram replace WhatsApp or run alongside it?
+
+- **Replace WhatsApp** - Telegram will be the only channel (sets TELEGRAM_ONLY=true)
+- **Alongside** - Both Telegram and WhatsApp channels active
+
+AskUserQuestion: Do you have a Telegram bot token, or do you need to create one?
+
+If they have one, collect it now. If not, we'll create one in Phase 3.
+
+## Phase 2: Apply Code Changes
+
+Run the skills engine to apply this skill's code package. The package files are in this directory alongside this SKILL.md.
+
+### Initialize skills system (if needed)
+
+If `.nanoclaw/` directory doesn't exist yet:
+
+```bash
+npx tsx scripts/apply-skill.ts --init
+```
+
+Or call `initSkillsSystem()` from `skills-engine/migrate.ts`.
+
+### Apply the skill
+
+```bash
+npx tsx scripts/apply-skill.ts .agent/skills/add-telegram
+```
+
+This deterministically:
+
+- Adds `src/channels/telegram.ts` (TelegramChannel class implementing Channel interface)
+- Adds `src/channels/telegram.test.ts` (46 unit tests)
+- Three-way merges Telegram support into `src/index.ts` (multi-channel support, findChannel routing)
+- Three-way merges Telegram config into `src/config.ts` (TELEGRAM_BOT_TOKEN, TELEGRAM_ONLY exports)
+- Three-way merges updated routing tests into `src/routing.test.ts`
+- Installs the `grammy` npm dependency
+- Updates `.env.example` with `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ONLY`
+- Records the application in `.nanoclaw/state.yaml`
+
+If the apply reports merge conflicts, read the intent files:
+
+- `modify/src/index.ts.intent.md` — what changed and invariants for index.ts
+- `modify/src/config.ts.intent.md` — what changed for config.ts
+
+### Validate code changes
+
+```bash
+npm test
+npm run build
+```
+
+All tests must pass (including the new telegram tests) and build must be clean before proceeding.
+
+## Phase 3: Setup
+
+### Create Telegram Bot (if needed)
+
+If the user doesn't have a bot token, tell them:
+
+> I need you to create a Telegram bot:
+>
+> 1. Open Telegram and search for `@BotFather`
+> 2. Send `/newbot` and follow prompts:
+> - Bot name: Something friendly (e.g., "Andy Assistant")
+> - Bot username: Must end with "bot" (e.g., "andy_ai_bot")
+> 3. Copy the bot token (looks like `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`)
+
+Wait for the user to provide the token.
+
+### Configure environment
+
+Add to `.env`:
+
+```bash
+TELEGRAM_BOT_TOKEN=
+```
+
+If they chose to replace WhatsApp:
+
+```bash
+TELEGRAM_ONLY=true
+```
+
+Sync to container environment:
+
+```bash
+mkdir -p data/env && cp .env data/env/env
+```
+
+The container reads environment from `data/env/env`, not `.env` directly.
+
+### Disable Group Privacy (for group chats)
+
+Tell the user:
+
+> **Important for group chats**: By default, Telegram bots only see @mentions and commands in groups. To let the bot see all messages:
+>
+> 1. Open Telegram and search for `@BotFather`
+> 2. Send `/mybots` and select your bot
+> 3. Go to **Bot Settings** > **Group Privacy** > **Turn off**
+>
+> This is optional if you only want trigger-based responses via @mentioning the bot.
+
+### Build and restart
+
+```bash
+npm run build
+launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
+# Linux: systemctl --user restart nanoclaw
+```
+
+## Phase 4: Registration
+
+### Get Chat ID
+
+Tell the user:
+
+> 1. Open your bot in Telegram (search for its username)
+> 2. Send `/chatid` — it will reply with the chat ID
+> 3. For groups: add the bot to the group first, then send `/chatid` in the group
+
+Wait for the user to provide the chat ID (format: `tg:123456789` or `tg:-1001234567890`).
+
+### Register the chat
+
+Use the IPC register flow or register directly. The chat ID, name, and folder name are needed.
+
+For a main chat (responds to all messages, uses the `main` folder):
+
+```typescript
+registerGroup('tg:', {
+ name: '',
+ folder: 'main',
+ trigger: `@${ASSISTANT_NAME}`,
+ added_at: new Date().toISOString(),
+ requiresTrigger: false,
+});
+```
+
+For additional chats (trigger-only):
+
+```typescript
+registerGroup('tg:', {
+ name: '',
+ folder: '',
+ trigger: `@${ASSISTANT_NAME}`,
+ added_at: new Date().toISOString(),
+ requiresTrigger: true,
+});
+```
+
+## Phase 5: Verify
+
+### Test the connection
+
+Tell the user:
+
+> Send a message to your registered Telegram chat:
+>
+> - For main chat: Any message works
+> - For non-main: `@Andy hello` or @mention the bot
+>
+> The bot should respond within a few seconds.
+
+### Check logs if needed
+
+```bash
+tail -f logs/nanoclaw.log
+```
+
+## Troubleshooting
+
+### Bot not responding
+
+Check:
+
+1. `TELEGRAM_BOT_TOKEN` is set in `.env` AND synced to `data/env/env`
+2. Chat is registered in the ops database (check with: `psql "$OPS_DB_URL" -c "SELECT * FROM registered_groups WHERE jid LIKE 'tg:%'"`)
+3. For non-main chats: message includes trigger pattern
+4. Service is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux)
+
+### Bot only responds to @mentions in groups
+
+Group Privacy is enabled (default). Fix:
+
+1. `@BotFather` > `/mybots` > select bot > **Bot Settings** > **Group Privacy** > **Turn off**
+2. Remove and re-add the bot to the group (required for the change to take effect)
+
+### Getting chat ID
+
+If `/chatid` doesn't work:
+
+- Verify token: `curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe"`
+- Check bot is started: `tail -f logs/nanoclaw.log`
+
+## After Setup
+
+If running `npm run dev` while the service is active:
+
+```bash
+# macOS:
+launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
+npm run dev
+# When done testing:
+launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
+# Linux:
+# systemctl --user stop nanoclaw
+# npm run dev
+# systemctl --user start nanoclaw
+```
+
+## Agent Swarms (Teams)
+
+After completing the Telegram setup, use `AskUserQuestion`:
+
+AskUserQuestion: Would you like to add Agent Swarm support? Without it, Agent Teams still work — they just operate behind the scenes. With Swarm support, each subagent appears as a different bot in the Telegram group so you can see who's saying what and have interactive team sessions.
+
+If they say yes, invoke the `/add-telegram-swarm` skill.
+
+## Removal
+
+To remove Telegram integration:
+
+1. Delete `src/channels/telegram.ts`
+2. Remove `TelegramChannel` import and creation from `src/index.ts`
+3. Remove `channels` array and revert to using `whatsapp` directly in `processGroupMessages`, scheduler deps, and IPC deps
+4. Revert `getAvailableGroups()` filter to only include `@g.us` chats
+5. Remove Telegram config (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_ONLY`) from `src/config.ts`
+6. Remove Telegram registrations from ops database: `psql "$OPS_DB_URL" -c "DELETE FROM registered_groups WHERE jid LIKE 'tg:%'"`
+7. Uninstall: `npm uninstall grammy`
+8. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux)
diff --git a/.agent/skills/add-telegram/add/src/channels/telegram.test.ts b/.agent/skills/add-telegram/add/src/channels/telegram.test.ts
new file mode 100644
index 0000000..950b607
--- /dev/null
+++ b/.agent/skills/add-telegram/add/src/channels/telegram.test.ts
@@ -0,0 +1,926 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+
+// --- Mocks ---
+
+// Mock config
+vi.mock('../config.js', () => ({
+ ASSISTANT_NAME: 'Andy',
+ TRIGGER_PATTERN: /^@Andy\b/i,
+}));
+
+// Mock logger
+vi.mock('../logger.js', () => ({
+ logger: {
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+// --- Grammy mock ---
+
+type Handler = (...args: any[]) => any;
+
+const botRef = vi.hoisted(() => ({ current: null as any }));
+
+vi.mock('grammy', () => ({
+ Bot: class MockBot {
+ token: string;
+ commandHandlers = new Map();
+ filterHandlers = new Map();
+ errorHandler: Handler | null = null;
+
+ api = {
+ sendMessage: vi.fn().mockResolvedValue(undefined),
+ sendChatAction: vi.fn().mockResolvedValue(undefined),
+ };
+
+ constructor(token: string) {
+ this.token = token;
+ botRef.current = this;
+ }
+
+ command(name: string, handler: Handler) {
+ this.commandHandlers.set(name, handler);
+ }
+
+ on(filter: string, handler: Handler) {
+ const existing = this.filterHandlers.get(filter) || [];
+ existing.push(handler);
+ this.filterHandlers.set(filter, existing);
+ }
+
+ catch(handler: Handler) {
+ this.errorHandler = handler;
+ }
+
+ start(opts: { onStart: (botInfo: any) => void }) {
+ opts.onStart({ username: 'andy_ai_bot', id: 12345 });
+ }
+
+ stop() {}
+ },
+}));
+
+import { TelegramChannel, TelegramChannelOpts } from './telegram.js';
+
+// --- Test helpers ---
+
+function createTestOpts(
+ overrides?: Partial,
+): TelegramChannelOpts {
+ return {
+ onMessage: vi.fn(),
+ onChatMetadata: vi.fn(),
+ registeredGroups: vi.fn(() => ({
+ 'tg:100200300': {
+ name: 'Test Group',
+ folder: 'test-group',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ },
+ })),
+ ...overrides,
+ };
+}
+
+function createTextCtx(overrides: {
+ chatId?: number;
+ chatType?: string;
+ chatTitle?: string;
+ text: string;
+ fromId?: number;
+ firstName?: string;
+ username?: string;
+ messageId?: number;
+ date?: number;
+ entities?: any[];
+}) {
+ const chatId = overrides.chatId ?? 100200300;
+ const chatType = overrides.chatType ?? 'group';
+ return {
+ chat: {
+ id: chatId,
+ type: chatType,
+ title: overrides.chatTitle ?? 'Test Group',
+ },
+ from: {
+ id: overrides.fromId ?? 99001,
+ first_name: overrides.firstName ?? 'Alice',
+ username: overrides.username ?? 'alice_user',
+ },
+ message: {
+ text: overrides.text,
+ date: overrides.date ?? Math.floor(Date.now() / 1000),
+ message_id: overrides.messageId ?? 1,
+ entities: overrides.entities ?? [],
+ },
+ me: { username: 'andy_ai_bot' },
+ reply: vi.fn(),
+ };
+}
+
+function createMediaCtx(overrides: {
+ chatId?: number;
+ chatType?: string;
+ fromId?: number;
+ firstName?: string;
+ date?: number;
+ messageId?: number;
+ caption?: string;
+ extra?: Record;
+}) {
+ const chatId = overrides.chatId ?? 100200300;
+ return {
+ chat: {
+ id: chatId,
+ type: overrides.chatType ?? 'group',
+ title: 'Test Group',
+ },
+ from: {
+ id: overrides.fromId ?? 99001,
+ first_name: overrides.firstName ?? 'Alice',
+ username: 'alice_user',
+ },
+ message: {
+ date: overrides.date ?? Math.floor(Date.now() / 1000),
+ message_id: overrides.messageId ?? 1,
+ caption: overrides.caption,
+ ...(overrides.extra || {}),
+ },
+ me: { username: 'andy_ai_bot' },
+ };
+}
+
+function currentBot() {
+ return botRef.current;
+}
+
+async function triggerTextMessage(ctx: ReturnType) {
+ const handlers = currentBot().filterHandlers.get('message:text') || [];
+ for (const h of handlers) await h(ctx);
+}
+
+async function triggerMediaMessage(
+ filter: string,
+ ctx: ReturnType,
+) {
+ const handlers = currentBot().filterHandlers.get(filter) || [];
+ for (const h of handlers) await h(ctx);
+}
+
+// --- Tests ---
+
+describe('TelegramChannel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ // --- Connection lifecycle ---
+
+ describe('connection lifecycle', () => {
+ it('resolves connect() when bot starts', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+
+ await channel.connect();
+
+ expect(channel.isConnected()).toBe(true);
+ });
+
+ it('registers command and message handlers on connect', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+
+ await channel.connect();
+
+ expect(currentBot().commandHandlers.has('chatid')).toBe(true);
+ expect(currentBot().commandHandlers.has('ping')).toBe(true);
+ expect(currentBot().filterHandlers.has('message:text')).toBe(true);
+ expect(currentBot().filterHandlers.has('message:photo')).toBe(true);
+ expect(currentBot().filterHandlers.has('message:video')).toBe(true);
+ expect(currentBot().filterHandlers.has('message:voice')).toBe(true);
+ expect(currentBot().filterHandlers.has('message:audio')).toBe(true);
+ expect(currentBot().filterHandlers.has('message:document')).toBe(true);
+ expect(currentBot().filterHandlers.has('message:sticker')).toBe(true);
+ expect(currentBot().filterHandlers.has('message:location')).toBe(true);
+ expect(currentBot().filterHandlers.has('message:contact')).toBe(true);
+ });
+
+ it('registers error handler on connect', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+
+ await channel.connect();
+
+ expect(currentBot().errorHandler).not.toBeNull();
+ });
+
+ it('disconnects cleanly', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+
+ await channel.connect();
+ expect(channel.isConnected()).toBe(true);
+
+ await channel.disconnect();
+ expect(channel.isConnected()).toBe(false);
+ });
+
+ it('isConnected() returns false before connect', () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+
+ expect(channel.isConnected()).toBe(false);
+ });
+ });
+
+ // --- Text message handling ---
+
+ describe('text message handling', () => {
+ it('delivers message for registered group', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createTextCtx({ text: 'Hello everyone' });
+ await triggerTextMessage(ctx);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.any(String),
+ 'Test Group',
+ 'telegram',
+ true,
+ );
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.objectContaining({
+ id: '1',
+ chat_jid: 'tg:100200300',
+ sender: '99001',
+ sender_name: 'Alice',
+ content: 'Hello everyone',
+ is_from_me: false,
+ }),
+ );
+ });
+
+ it('only emits metadata for unregistered chats', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createTextCtx({ chatId: 999999, text: 'Unknown chat' });
+ await triggerTextMessage(ctx);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'tg:999999',
+ expect.any(String),
+ 'Test Group',
+ 'telegram',
+ true,
+ );
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ });
+
+ it('skips command messages (starting with /)', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createTextCtx({ text: '/start' });
+ await triggerTextMessage(ctx);
+
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ expect(opts.onChatMetadata).not.toHaveBeenCalled();
+ });
+
+ it('extracts sender name from first_name', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createTextCtx({ text: 'Hi', firstName: 'Bob' });
+ await triggerTextMessage(ctx);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.objectContaining({ sender_name: 'Bob' }),
+ );
+ });
+
+ it('falls back to username when first_name missing', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createTextCtx({ text: 'Hi' });
+ ctx.from.first_name = undefined as any;
+ await triggerTextMessage(ctx);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.objectContaining({ sender_name: 'alice_user' }),
+ );
+ });
+
+ it('falls back to user ID when name and username missing', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createTextCtx({ text: 'Hi', fromId: 42 });
+ ctx.from.first_name = undefined as any;
+ ctx.from.username = undefined as any;
+ await triggerTextMessage(ctx);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.objectContaining({ sender_name: '42' }),
+ );
+ });
+
+ it('uses sender name as chat name for private chats', async () => {
+ const opts = createTestOpts({
+ registeredGroups: vi.fn(() => ({
+ 'tg:100200300': {
+ name: 'Private',
+ folder: 'private',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ },
+ })),
+ });
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createTextCtx({
+ text: 'Hello',
+ chatType: 'private',
+ firstName: 'Alice',
+ });
+ await triggerTextMessage(ctx);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.any(String),
+ 'Alice', // Private chats use sender name
+ 'telegram',
+ false,
+ );
+ });
+
+ it('uses chat title as name for group chats', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createTextCtx({
+ text: 'Hello',
+ chatType: 'supergroup',
+ chatTitle: 'Project Team',
+ });
+ await triggerTextMessage(ctx);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.any(String),
+ 'Project Team',
+ 'telegram',
+ true,
+ );
+ });
+
+ it('converts message.date to ISO timestamp', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const unixTime = 1704067200; // 2024-01-01T00:00:00.000Z
+ const ctx = createTextCtx({ text: 'Hello', date: unixTime });
+ await triggerTextMessage(ctx);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.objectContaining({
+ timestamp: '2024-01-01T00:00:00.000Z',
+ }),
+ );
+ });
+ });
+
+ // --- @mention translation ---
+
+ describe('@mention translation', () => {
+ it('translates @bot_username mention to trigger format', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createTextCtx({
+ text: '@andy_ai_bot what time is it?',
+ entities: [{ type: 'mention', offset: 0, length: 12 }],
+ });
+ await triggerTextMessage(ctx);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.objectContaining({
+ content: '@Andy @andy_ai_bot what time is it?',
+ }),
+ );
+ });
+
+ it('does not translate if message already matches trigger', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createTextCtx({
+ text: '@Andy @andy_ai_bot hello',
+ entities: [{ type: 'mention', offset: 6, length: 12 }],
+ });
+ await triggerTextMessage(ctx);
+
+ // Should NOT double-prepend — already starts with @Andy
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.objectContaining({
+ content: '@Andy @andy_ai_bot hello',
+ }),
+ );
+ });
+
+ it('does not translate mentions of other bots', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createTextCtx({
+ text: '@some_other_bot hi',
+ entities: [{ type: 'mention', offset: 0, length: 15 }],
+ });
+ await triggerTextMessage(ctx);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.objectContaining({
+ content: '@some_other_bot hi', // No translation
+ }),
+ );
+ });
+
+ it('handles mention in middle of message', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createTextCtx({
+ text: 'hey @andy_ai_bot check this',
+ entities: [{ type: 'mention', offset: 4, length: 12 }],
+ });
+ await triggerTextMessage(ctx);
+
+ // Bot is mentioned, message doesn't match trigger → prepend trigger
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.objectContaining({
+ content: '@Andy hey @andy_ai_bot check this',
+ }),
+ );
+ });
+
+ it('handles message with no entities', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createTextCtx({ text: 'plain message' });
+ await triggerTextMessage(ctx);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.objectContaining({
+ content: 'plain message',
+ }),
+ );
+ });
+
+ it('ignores non-mention entities', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createTextCtx({
+ text: 'check https://example.com',
+ entities: [{ type: 'url', offset: 6, length: 19 }],
+ });
+ await triggerTextMessage(ctx);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.objectContaining({
+ content: 'check https://example.com',
+ }),
+ );
+ });
+ });
+
+ // --- Non-text messages ---
+
+ describe('non-text messages', () => {
+ it('stores photo with placeholder', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createMediaCtx({});
+ await triggerMediaMessage('message:photo', ctx);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.objectContaining({ content: '[Photo]' }),
+ );
+ });
+
+ it('stores photo with caption', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createMediaCtx({ caption: 'Look at this' });
+ await triggerMediaMessage('message:photo', ctx);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.objectContaining({ content: '[Photo] Look at this' }),
+ );
+ });
+
+ it('stores video with placeholder', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createMediaCtx({});
+ await triggerMediaMessage('message:video', ctx);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.objectContaining({ content: '[Video]' }),
+ );
+ });
+
+ it('stores voice message with placeholder', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createMediaCtx({});
+ await triggerMediaMessage('message:voice', ctx);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.objectContaining({ content: '[Voice message]' }),
+ );
+ });
+
+ it('stores audio with placeholder', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createMediaCtx({});
+ await triggerMediaMessage('message:audio', ctx);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.objectContaining({ content: '[Audio]' }),
+ );
+ });
+
+ it('stores document with filename', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createMediaCtx({
+ extra: { document: { file_name: 'report.pdf' } },
+ });
+ await triggerMediaMessage('message:document', ctx);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.objectContaining({ content: '[Document: report.pdf]' }),
+ );
+ });
+
+ it('stores document with fallback name when filename missing', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createMediaCtx({ extra: { document: {} } });
+ await triggerMediaMessage('message:document', ctx);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.objectContaining({ content: '[Document: file]' }),
+ );
+ });
+
+ it('stores sticker with emoji', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createMediaCtx({
+ extra: { sticker: { emoji: '😂' } },
+ });
+ await triggerMediaMessage('message:sticker', ctx);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.objectContaining({ content: '[Sticker 😂]' }),
+ );
+ });
+
+ it('stores location with placeholder', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createMediaCtx({});
+ await triggerMediaMessage('message:location', ctx);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.objectContaining({ content: '[Location]' }),
+ );
+ });
+
+ it('stores contact with placeholder', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createMediaCtx({});
+ await triggerMediaMessage('message:contact', ctx);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'tg:100200300',
+ expect.objectContaining({ content: '[Contact]' }),
+ );
+ });
+
+ it('ignores non-text messages from unregistered chats', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const ctx = createMediaCtx({ chatId: 999999 });
+ await triggerMediaMessage('message:photo', ctx);
+
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ });
+ });
+
+ // --- sendMessage ---
+
+ describe('sendMessage', () => {
+ it('sends message via bot API', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ await channel.sendMessage('tg:100200300', 'Hello');
+
+ expect(currentBot().api.sendMessage).toHaveBeenCalledWith(
+ '100200300',
+ 'Hello',
+ );
+ });
+
+ it('strips tg: prefix from JID', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ await channel.sendMessage('tg:-1001234567890', 'Group message');
+
+ expect(currentBot().api.sendMessage).toHaveBeenCalledWith(
+ '-1001234567890',
+ 'Group message',
+ );
+ });
+
+ it('splits messages exceeding 4096 characters', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const longText = 'x'.repeat(5000);
+ await channel.sendMessage('tg:100200300', longText);
+
+ expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(2);
+ expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith(
+ 1,
+ '100200300',
+ 'x'.repeat(4096),
+ );
+ expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith(
+ 2,
+ '100200300',
+ 'x'.repeat(904),
+ );
+ });
+
+ it('sends exactly one message at 4096 characters', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const exactText = 'y'.repeat(4096);
+ await channel.sendMessage('tg:100200300', exactText);
+
+ expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(1);
+ });
+
+ it('handles send failure gracefully', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ currentBot().api.sendMessage.mockRejectedValueOnce(
+ new Error('Network error'),
+ );
+
+ // Should not throw
+ await expect(
+ channel.sendMessage('tg:100200300', 'Will fail'),
+ ).resolves.toBeUndefined();
+ });
+
+ it('does nothing when bot is not initialized', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+
+ // Don't connect — bot is null
+ await channel.sendMessage('tg:100200300', 'No bot');
+
+ // No error, no API call
+ });
+ });
+
+ // --- ownsJid ---
+
+ describe('ownsJid', () => {
+ it('owns tg: JIDs', () => {
+ const channel = new TelegramChannel('test-token', createTestOpts());
+ expect(channel.ownsJid('tg:123456')).toBe(true);
+ });
+
+ it('owns tg: JIDs with negative IDs (groups)', () => {
+ const channel = new TelegramChannel('test-token', createTestOpts());
+ expect(channel.ownsJid('tg:-1001234567890')).toBe(true);
+ });
+
+ it('does not own WhatsApp group JIDs', () => {
+ const channel = new TelegramChannel('test-token', createTestOpts());
+ expect(channel.ownsJid('12345@g.us')).toBe(false);
+ });
+
+ it('does not own WhatsApp DM JIDs', () => {
+ const channel = new TelegramChannel('test-token', createTestOpts());
+ expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(false);
+ });
+
+ it('does not own unknown JID formats', () => {
+ const channel = new TelegramChannel('test-token', createTestOpts());
+ expect(channel.ownsJid('random-string')).toBe(false);
+ });
+ });
+
+ // --- setTyping ---
+
+ describe('setTyping', () => {
+ it('sends typing action when isTyping is true', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ await channel.setTyping('tg:100200300', true);
+
+ expect(currentBot().api.sendChatAction).toHaveBeenCalledWith(
+ '100200300',
+ 'typing',
+ );
+ });
+
+ it('does nothing when isTyping is false', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ await channel.setTyping('tg:100200300', false);
+
+ expect(currentBot().api.sendChatAction).not.toHaveBeenCalled();
+ });
+
+ it('does nothing when bot is not initialized', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+
+ // Don't connect
+ await channel.setTyping('tg:100200300', true);
+
+ // No error, no API call
+ });
+
+ it('handles typing indicator failure gracefully', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ currentBot().api.sendChatAction.mockRejectedValueOnce(
+ new Error('Rate limited'),
+ );
+
+ await expect(
+ channel.setTyping('tg:100200300', true),
+ ).resolves.toBeUndefined();
+ });
+ });
+
+ // --- Bot commands ---
+
+ describe('bot commands', () => {
+ it('/chatid replies with chat ID and metadata', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const handler = currentBot().commandHandlers.get('chatid')!;
+ const ctx = {
+ chat: { id: 100200300, type: 'group' as const },
+ from: { first_name: 'Alice' },
+ reply: vi.fn(),
+ };
+
+ await handler(ctx);
+
+ expect(ctx.reply).toHaveBeenCalledWith(
+ expect.stringContaining('tg:100200300'),
+ expect.objectContaining({ parse_mode: 'Markdown' }),
+ );
+ });
+
+ it('/chatid shows chat type', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const handler = currentBot().commandHandlers.get('chatid')!;
+ const ctx = {
+ chat: { id: 555, type: 'private' as const },
+ from: { first_name: 'Bob' },
+ reply: vi.fn(),
+ };
+
+ await handler(ctx);
+
+ expect(ctx.reply).toHaveBeenCalledWith(
+ expect.stringContaining('private'),
+ expect.any(Object),
+ );
+ });
+
+ it('/ping replies with bot status', async () => {
+ const opts = createTestOpts();
+ const channel = new TelegramChannel('test-token', opts);
+ await channel.connect();
+
+ const handler = currentBot().commandHandlers.get('ping')!;
+ const ctx = { reply: vi.fn() };
+
+ await handler(ctx);
+
+ expect(ctx.reply).toHaveBeenCalledWith('Andy is online.');
+ });
+ });
+
+ // --- Channel properties ---
+
+ describe('channel properties', () => {
+ it('has name "telegram"', () => {
+ const channel = new TelegramChannel('test-token', createTestOpts());
+ expect(channel.name).toBe('telegram');
+ });
+ });
+});
diff --git a/.agent/skills/add-telegram/add/src/channels/telegram.ts b/.agent/skills/add-telegram/add/src/channels/telegram.ts
new file mode 100644
index 0000000..43a6266
--- /dev/null
+++ b/.agent/skills/add-telegram/add/src/channels/telegram.ts
@@ -0,0 +1,244 @@
+import { Bot } from 'grammy';
+
+import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
+import { logger } from '../logger.js';
+import {
+ Channel,
+ OnChatMetadata,
+ OnInboundMessage,
+ RegisteredGroup,
+} from '../types.js';
+
+export interface TelegramChannelOpts {
+ onMessage: OnInboundMessage;
+ onChatMetadata: OnChatMetadata;
+ registeredGroups: () => Record;
+}
+
+export class TelegramChannel implements Channel {
+ name = 'telegram';
+
+ private bot: Bot | null = null;
+ private opts: TelegramChannelOpts;
+ private botToken: string;
+
+ constructor(botToken: string, opts: TelegramChannelOpts) {
+ this.botToken = botToken;
+ this.opts = opts;
+ }
+
+ async connect(): Promise {
+ this.bot = new Bot(this.botToken);
+
+ // Command to get chat ID (useful for registration)
+ this.bot.command('chatid', (ctx) => {
+ const chatId = ctx.chat.id;
+ const chatType = ctx.chat.type;
+ const chatName =
+ chatType === 'private'
+ ? ctx.from?.first_name || 'Private'
+ : (ctx.chat as any).title || 'Unknown';
+
+ ctx.reply(
+ `Chat ID: \`tg:${chatId}\`\nName: ${chatName}\nType: ${chatType}`,
+ { parse_mode: 'Markdown' },
+ );
+ });
+
+ // Command to check bot status
+ this.bot.command('ping', (ctx) => {
+ ctx.reply(`${ASSISTANT_NAME} is online.`);
+ });
+
+ this.bot.on('message:text', async (ctx) => {
+ // Skip commands
+ if (ctx.message.text.startsWith('/')) return;
+
+ const chatJid = `tg:${ctx.chat.id}`;
+ let content = ctx.message.text;
+ const timestamp = new Date(ctx.message.date * 1000).toISOString();
+ const senderName =
+ ctx.from?.first_name ||
+ ctx.from?.username ||
+ ctx.from?.id.toString() ||
+ 'Unknown';
+ const sender = ctx.from?.id.toString() || '';
+ const msgId = ctx.message.message_id.toString();
+
+ // Determine chat name
+ const chatName =
+ ctx.chat.type === 'private'
+ ? senderName
+ : (ctx.chat as any).title || chatJid;
+
+ // Translate Telegram @bot_username mentions into TRIGGER_PATTERN format.
+ // Telegram @mentions (e.g., @andy_ai_bot) won't match TRIGGER_PATTERN
+ // (e.g., ^@Andy\b), so we prepend the trigger when the bot is @mentioned.
+ const botUsername = ctx.me?.username?.toLowerCase();
+ if (botUsername) {
+ const entities = ctx.message.entities || [];
+ const isBotMentioned = entities.some((entity) => {
+ if (entity.type === 'mention') {
+ const mentionText = content
+ .substring(entity.offset, entity.offset + entity.length)
+ .toLowerCase();
+ return mentionText === `@${botUsername}`;
+ }
+ return false;
+ });
+ if (isBotMentioned && !TRIGGER_PATTERN.test(content)) {
+ content = `@${ASSISTANT_NAME} ${content}`;
+ }
+ }
+
+ // Store chat metadata for discovery
+ const isGroup = ctx.chat.type === 'group' || ctx.chat.type === 'supergroup';
+ this.opts.onChatMetadata(chatJid, timestamp, chatName, 'telegram', isGroup);
+
+ // Only deliver full message for registered groups
+ const group = this.opts.registeredGroups()[chatJid];
+ if (!group) {
+ logger.debug(
+ { chatJid, chatName },
+ 'Message from unregistered Telegram chat',
+ );
+ return;
+ }
+
+ // Deliver message — startMessageLoop() will pick it up
+ this.opts.onMessage(chatJid, {
+ id: msgId,
+ chat_jid: chatJid,
+ sender,
+ sender_name: senderName,
+ content,
+ timestamp,
+ is_from_me: false,
+ });
+
+ logger.info(
+ { chatJid, chatName, sender: senderName },
+ 'Telegram message stored',
+ );
+ });
+
+ // Handle non-text messages with placeholders so the agent knows something was sent
+ const storeNonText = (ctx: any, placeholder: string) => {
+ const chatJid = `tg:${ctx.chat.id}`;
+ const group = this.opts.registeredGroups()[chatJid];
+ if (!group) return;
+
+ const timestamp = new Date(ctx.message.date * 1000).toISOString();
+ const senderName =
+ ctx.from?.first_name ||
+ ctx.from?.username ||
+ ctx.from?.id?.toString() ||
+ 'Unknown';
+ const caption = ctx.message.caption ? ` ${ctx.message.caption}` : '';
+
+ const isGroup = ctx.chat.type === 'group' || ctx.chat.type === 'supergroup';
+ this.opts.onChatMetadata(chatJid, timestamp, undefined, 'telegram', isGroup);
+ this.opts.onMessage(chatJid, {
+ id: ctx.message.message_id.toString(),
+ chat_jid: chatJid,
+ sender: ctx.from?.id?.toString() || '',
+ sender_name: senderName,
+ content: `${placeholder}${caption}`,
+ timestamp,
+ is_from_me: false,
+ });
+ };
+
+ this.bot.on('message:photo', (ctx) => storeNonText(ctx, '[Photo]'));
+ this.bot.on('message:video', (ctx) => storeNonText(ctx, '[Video]'));
+ this.bot.on('message:voice', (ctx) =>
+ storeNonText(ctx, '[Voice message]'),
+ );
+ this.bot.on('message:audio', (ctx) => storeNonText(ctx, '[Audio]'));
+ this.bot.on('message:document', (ctx) => {
+ const name = ctx.message.document?.file_name || 'file';
+ storeNonText(ctx, `[Document: ${name}]`);
+ });
+ this.bot.on('message:sticker', (ctx) => {
+ const emoji = ctx.message.sticker?.emoji || '';
+ storeNonText(ctx, `[Sticker ${emoji}]`);
+ });
+ this.bot.on('message:location', (ctx) => storeNonText(ctx, '[Location]'));
+ this.bot.on('message:contact', (ctx) => storeNonText(ctx, '[Contact]'));
+
+ // Handle errors gracefully
+ this.bot.catch((err) => {
+ logger.error({ err: err.message }, 'Telegram bot error');
+ });
+
+ // Start polling — returns a Promise that resolves when started
+ return new Promise((resolve) => {
+ this.bot!.start({
+ onStart: (botInfo) => {
+ logger.info(
+ { username: botInfo.username, id: botInfo.id },
+ 'Telegram bot connected',
+ );
+ console.log(`\n Telegram bot: @${botInfo.username}`);
+ console.log(
+ ` Send /chatid to the bot to get a chat's registration ID\n`,
+ );
+ resolve();
+ },
+ });
+ });
+ }
+
+ async sendMessage(jid: string, text: string): Promise {
+ if (!this.bot) {
+ logger.warn('Telegram bot not initialized');
+ return;
+ }
+
+ try {
+ const numericId = jid.replace(/^tg:/, '');
+
+ // Telegram has a 4096 character limit per message — split if needed
+ const MAX_LENGTH = 4096;
+ if (text.length <= MAX_LENGTH) {
+ await this.bot.api.sendMessage(numericId, text);
+ } else {
+ for (let i = 0; i < text.length; i += MAX_LENGTH) {
+ await this.bot.api.sendMessage(
+ numericId,
+ text.slice(i, i + MAX_LENGTH),
+ );
+ }
+ }
+ logger.info({ jid, length: text.length }, 'Telegram message sent');
+ } catch (err) {
+ logger.error({ jid, err }, 'Failed to send Telegram message');
+ }
+ }
+
+ isConnected(): boolean {
+ return this.bot !== null;
+ }
+
+ ownsJid(jid: string): boolean {
+ return jid.startsWith('tg:');
+ }
+
+ async disconnect(): Promise {
+ if (this.bot) {
+ this.bot.stop();
+ this.bot = null;
+ logger.info('Telegram bot stopped');
+ }
+ }
+
+ async setTyping(jid: string, isTyping: boolean): Promise {
+ if (!this.bot || !isTyping) return;
+ try {
+ const numericId = jid.replace(/^tg:/, '');
+ await this.bot.api.sendChatAction(numericId, 'typing');
+ } catch (err) {
+ logger.debug({ jid, err }, 'Failed to send Telegram typing indicator');
+ }
+ }
+}
diff --git a/.agent/skills/add-telegram/manifest.yaml b/.agent/skills/add-telegram/manifest.yaml
new file mode 100644
index 0000000..fe7a36a
--- /dev/null
+++ b/.agent/skills/add-telegram/manifest.yaml
@@ -0,0 +1,20 @@
+skill: telegram
+version: 1.0.0
+description: "Telegram Bot API integration via Grammy"
+core_version: 0.1.0
+adds:
+ - src/channels/telegram.ts
+ - src/channels/telegram.test.ts
+modifies:
+ - src/index.ts
+ - src/config.ts
+ - src/routing.test.ts
+structured:
+ npm_dependencies:
+ grammy: "^1.39.3"
+ env_additions:
+ - TELEGRAM_BOT_TOKEN
+ - TELEGRAM_ONLY
+conflicts: []
+depends: []
+test: "npx vitest run src/channels/telegram.test.ts"
diff --git a/.agent/skills/add-telegram/modify/src/config.ts b/.agent/skills/add-telegram/modify/src/config.ts
new file mode 100644
index 0000000..b0de64c
--- /dev/null
+++ b/.agent/skills/add-telegram/modify/src/config.ts
@@ -0,0 +1,77 @@
+import os from 'os';
+import path from 'path';
+
+import { readEnvFile } from './env.js';
+
+// Read config values from .env (falls back to process.env).
+// Secrets are NOT read here — they stay on disk and are loaded only
+// where needed (container-runner.ts) to avoid leaking to child processes.
+const envConfig = readEnvFile([
+ 'ASSISTANT_NAME',
+ 'ASSISTANT_HAS_OWN_NUMBER',
+ 'TELEGRAM_BOT_TOKEN',
+ 'TELEGRAM_ONLY',
+]);
+
+export const ASSISTANT_NAME =
+ process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
+export const ASSISTANT_HAS_OWN_NUMBER =
+ (process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
+export const POLL_INTERVAL = 2000;
+export const SCHEDULER_POLL_INTERVAL = 60000;
+
+// Absolute paths needed for container mounts
+const PROJECT_ROOT = process.cwd();
+const HOME_DIR = process.env.HOME || os.homedir();
+
+// Mount security: allowlist stored OUTSIDE project root, never mounted into containers
+export const MOUNT_ALLOWLIST_PATH = path.join(
+ HOME_DIR,
+ '.config',
+ (process.env.AGENT_NAME || 'clawdie') + '-cp',
+ 'mount-allowlist.json',
+);
+export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
+export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
+export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
+export const MAIN_GROUP_FOLDER = 'main';
+
+export const CONTAINER_IMAGE =
+ process.env.CONTAINER_IMAGE || (process.env.AGENT_NAME || 'clawdie') + '-cp-agent:latest';
+export const CONTAINER_TIMEOUT = parseInt(
+ process.env.CONTAINER_TIMEOUT || '1800000',
+ 10,
+);
+export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
+ process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
+ 10,
+); // 10MB default
+export const IPC_POLL_INTERVAL = 1000;
+export const IDLE_TIMEOUT = parseInt(
+ process.env.IDLE_TIMEOUT || '1800000',
+ 10,
+); // 30min default — how long to keep container alive after last result
+export const MAX_CONCURRENT_CONTAINERS = Math.max(
+ 1,
+ parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5,
+);
+
+function escapeRegex(str: string): string {
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+export const TRIGGER_PATTERN = new RegExp(
+ `^@${escapeRegex(ASSISTANT_NAME)}\\b`,
+ 'i',
+);
+
+// Timezone for scheduled tasks (cron expressions, etc.)
+// Uses system timezone by default
+export const TIMEZONE =
+ process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+// Telegram configuration
+export const TELEGRAM_BOT_TOKEN =
+ process.env.TELEGRAM_BOT_TOKEN || envConfig.TELEGRAM_BOT_TOKEN || '';
+export const TELEGRAM_ONLY =
+ (process.env.TELEGRAM_ONLY || envConfig.TELEGRAM_ONLY) === 'true';
diff --git a/.agent/skills/add-telegram/modify/src/config.ts.intent.md b/.agent/skills/add-telegram/modify/src/config.ts.intent.md
new file mode 100644
index 0000000..1057ae4
--- /dev/null
+++ b/.agent/skills/add-telegram/modify/src/config.ts.intent.md
@@ -0,0 +1,25 @@
+# Intent: src/config.ts modifications
+
+## What changed
+
+Added two new configuration exports for Telegram channel support.
+
+## Key sections
+
+- **readEnvFile call**: Must include `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ONLY` in the keys array. NanoClaw does NOT load `.env` into `process.env` — all `.env` values must be explicitly requested via `readEnvFile()`.
+- **TELEGRAM_BOT_TOKEN**: Read from `process.env` first, then `envConfig` fallback, defaults to empty string (channel disabled when empty)
+- **TELEGRAM_ONLY**: Boolean flag from `process.env` or `envConfig`, when `true` disables WhatsApp channel creation
+
+## Invariants
+
+- All existing config exports remain unchanged
+- New Telegram keys are added to the `readEnvFile` call alongside existing keys
+- New exports are appended at the end of the file
+- No existing behavior is modified — Telegram config is additive only
+- Both `process.env` and `envConfig` are checked (same pattern as `ASSISTANT_NAME`)
+
+## Must-keep
+
+- All existing exports (`ASSISTANT_NAME`, `POLL_INTERVAL`, `TRIGGER_PATTERN`, etc.)
+- The `readEnvFile` pattern — ALL config read from `.env` must go through this function
+- The `escapeRegex` helper and `TRIGGER_PATTERN` construction
diff --git a/.agent/skills/add-telegram/modify/src/index.ts b/.agent/skills/add-telegram/modify/src/index.ts
new file mode 100644
index 0000000..b91e244
--- /dev/null
+++ b/.agent/skills/add-telegram/modify/src/index.ts
@@ -0,0 +1,509 @@
+import fs from 'fs';
+import path from 'path';
+
+import {
+ ASSISTANT_NAME,
+ IDLE_TIMEOUT,
+ MAIN_GROUP_FOLDER,
+ POLL_INTERVAL,
+ TELEGRAM_BOT_TOKEN,
+ TELEGRAM_ONLY,
+ TRIGGER_PATTERN,
+} from './config.js';
+import { TelegramChannel } from './channels/telegram.js';
+import { WhatsAppChannel } from './channels/whatsapp.js';
+import {
+ ContainerOutput,
+ runContainerAgent,
+ writeGroupsSnapshot,
+ writeTasksSnapshot,
+} from './container-runner.js';
+import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js';
+import {
+ getAllChats,
+ getAllRegisteredGroups,
+ getAllSessions,
+ getAllTasks,
+ getMessagesSince,
+ getNewMessages,
+ getRouterState,
+ initDatabase,
+ setRegisteredGroup,
+ setRouterState,
+ setSession,
+ storeChatMetadata,
+ storeMessage,
+} from './db.js';
+import { GroupQueue } from './group-queue.js';
+import { resolveGroupFolderPath } from './group-folder.js';
+import { startIpcWatcher } from './ipc.js';
+import { findChannel, formatMessages, formatOutbound } from './router.js';
+import { startSchedulerLoop } from './task-scheduler.js';
+import { Channel, NewMessage, RegisteredGroup } from './types.js';
+import { logger } from './logger.js';
+
+// Re-export for backwards compatibility during refactor
+export { escapeXml, formatMessages } from './router.js';
+
+let lastTimestamp = '';
+let sessions: Record = {};
+let registeredGroups: Record = {};
+let lastAgentTimestamp: Record = {};
+let messageLoopRunning = false;
+
+let whatsapp: WhatsAppChannel;
+const channels: Channel[] = [];
+const queue = new GroupQueue();
+
+function loadState(): void {
+ lastTimestamp = getRouterState('last_timestamp') || '';
+ const agentTs = getRouterState('last_agent_timestamp');
+ try {
+ lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {};
+ } catch {
+ logger.warn('Corrupted last_agent_timestamp in DB, resetting');
+ lastAgentTimestamp = {};
+ }
+ sessions = getAllSessions();
+ registeredGroups = getAllRegisteredGroups();
+ logger.info(
+ { groupCount: Object.keys(registeredGroups).length },
+ 'State loaded',
+ );
+}
+
+function saveState(): void {
+ setRouterState('last_timestamp', lastTimestamp);
+ setRouterState(
+ 'last_agent_timestamp',
+ JSON.stringify(lastAgentTimestamp),
+ );
+}
+
+function registerGroup(jid: string, group: RegisteredGroup): void {
+ let groupDir: string;
+ try {
+ groupDir = resolveGroupFolderPath(group.folder);
+ } catch (err) {
+ logger.warn(
+ { jid, folder: group.folder, err },
+ 'Rejecting group registration with invalid folder',
+ );
+ return;
+ }
+
+ registeredGroups[jid] = group;
+ setRegisteredGroup(jid, group);
+
+ // Create group folder
+ fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
+
+ logger.info(
+ { jid, name: group.name, folder: group.folder },
+ 'Group registered',
+ );
+}
+
+/**
+ * Get available groups list for the agent.
+ * Returns groups ordered by most recent activity.
+ */
+export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] {
+ const chats = getAllChats();
+ const registeredJids = new Set(Object.keys(registeredGroups));
+
+ return chats
+ .filter((c) => c.jid !== '__group_sync__' && c.is_group)
+ .map((c) => ({
+ jid: c.jid,
+ name: c.name,
+ lastActivity: c.last_message_time,
+ isRegistered: registeredJids.has(c.jid),
+ }));
+}
+
+/** @internal - exported for testing */
+export function _setRegisteredGroups(groups: Record): void {
+ registeredGroups = groups;
+}
+
+/**
+ * Process all pending messages for a group.
+ * Called by the GroupQueue when it's this group's turn.
+ */
+async function processGroupMessages(chatJid: string): Promise {
+ const group = registeredGroups[chatJid];
+ if (!group) return true;
+
+ const channel = findChannel(channels, chatJid);
+ if (!channel) {
+ console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`);
+ return true;
+ }
+
+ const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
+
+ const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
+ const missedMessages = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
+
+ if (missedMessages.length === 0) return true;
+
+ // For non-main groups, check if trigger is required and present
+ if (!isMainGroup && group.requiresTrigger !== false) {
+ const hasTrigger = missedMessages.some((m) =>
+ TRIGGER_PATTERN.test(m.content.trim()),
+ );
+ if (!hasTrigger) return true;
+ }
+
+ const prompt = formatMessages(missedMessages);
+
+ // Advance cursor so the piping path in startMessageLoop won't re-fetch
+ // these messages. Save the old cursor so we can roll back on error.
+ const previousCursor = lastAgentTimestamp[chatJid] || '';
+ lastAgentTimestamp[chatJid] =
+ missedMessages[missedMessages.length - 1].timestamp;
+ saveState();
+
+ logger.info(
+ { group: group.name, messageCount: missedMessages.length },
+ 'Processing messages',
+ );
+
+ // Track idle timer for closing stdin when agent is idle
+ let idleTimer: ReturnType | null = null;
+
+ const resetIdleTimer = () => {
+ if (idleTimer) clearTimeout(idleTimer);
+ idleTimer = setTimeout(() => {
+ logger.debug({ group: group.name }, 'Idle timeout, closing container stdin');
+ queue.closeStdin(chatJid);
+ }, IDLE_TIMEOUT);
+ };
+
+ await channel.setTyping?.(chatJid, true);
+ let hadError = false;
+ let outputSentToUser = false;
+
+ const output = await runAgent(group, prompt, chatJid, async (result) => {
+ // Streaming output callback — called for each agent result
+ if (result.result) {
+ const raw = typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
+ // Strip ... blocks — agent uses these for internal reasoning
+ const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim();
+ logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`);
+ if (text) {
+ await channel.sendMessage(chatJid, text);
+ outputSentToUser = true;
+ }
+ // Only reset idle timer on actual results, not session-update markers (result: null)
+ resetIdleTimer();
+ }
+
+ if (result.status === 'success') {
+ queue.notifyIdle(chatJid);
+ }
+
+ if (result.status === 'error') {
+ hadError = true;
+ }
+ });
+
+ await channel.setTyping?.(chatJid, false);
+ if (idleTimer) clearTimeout(idleTimer);
+
+ if (output === 'error' || hadError) {
+ // If we already sent output to the user, don't roll back the cursor —
+ // the user got their response and re-processing would send duplicates.
+ if (outputSentToUser) {
+ logger.warn({ group: group.name }, 'Agent error after output was sent, skipping cursor rollback to prevent duplicates');
+ return true;
+ }
+ // Roll back cursor so retries can re-process these messages
+ lastAgentTimestamp[chatJid] = previousCursor;
+ saveState();
+ logger.warn({ group: group.name }, 'Agent error, rolled back message cursor for retry');
+ return false;
+ }
+
+ return true;
+}
+
+async function runAgent(
+ group: RegisteredGroup,
+ prompt: string,
+ chatJid: string,
+ onOutput?: (output: ContainerOutput) => Promise,
+): Promise<'success' | 'error'> {
+ const isMain = group.folder === MAIN_GROUP_FOLDER;
+ const sessionId = sessions[group.folder];
+
+ // Update tasks snapshot for container to read (filtered by group)
+ const tasks = getAllTasks();
+ writeTasksSnapshot(
+ group.folder,
+ isMain,
+ tasks.map((t) => ({
+ id: t.id,
+ groupFolder: t.group_folder,
+ prompt: t.prompt,
+ schedule_type: t.schedule_type,
+ schedule_value: t.schedule_value,
+ status: t.status,
+ next_run: t.next_run,
+ })),
+ );
+
+ // Update available groups snapshot (main group only can see all groups)
+ const availableGroups = getAvailableGroups();
+ writeGroupsSnapshot(
+ group.folder,
+ isMain,
+ availableGroups,
+ new Set(Object.keys(registeredGroups)),
+ );
+
+ // Wrap onOutput to track session ID from streamed results
+ const wrappedOnOutput = onOutput
+ ? async (output: ContainerOutput) => {
+ if (output.newSessionId) {
+ sessions[group.folder] = output.newSessionId;
+ setSession(group.folder, output.newSessionId);
+ }
+ await onOutput(output);
+ }
+ : undefined;
+
+ try {
+ const output = await runContainerAgent(
+ group,
+ {
+ prompt,
+ sessionId,
+ groupFolder: group.folder,
+ chatJid,
+ isMain,
+ assistantName: ASSISTANT_NAME,
+ },
+ (proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder),
+ wrappedOnOutput,
+ );
+
+ if (output.newSessionId) {
+ sessions[group.folder] = output.newSessionId;
+ setSession(group.folder, output.newSessionId);
+ }
+
+ if (output.status === 'error') {
+ logger.error(
+ { group: group.name, error: output.error },
+ 'Container agent error',
+ );
+ return 'error';
+ }
+
+ return 'success';
+ } catch (err) {
+ logger.error({ group: group.name, err }, 'Agent error');
+ return 'error';
+ }
+}
+
+async function startMessageLoop(): Promise {
+ if (messageLoopRunning) {
+ logger.debug('Message loop already running, skipping duplicate start');
+ return;
+ }
+ messageLoopRunning = true;
+
+ logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`);
+
+ while (true) {
+ try {
+ const jids = Object.keys(registeredGroups);
+ const { messages, newTimestamp } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME);
+
+ if (messages.length > 0) {
+ logger.info({ count: messages.length }, 'New messages');
+
+ // Advance the "seen" cursor for all messages immediately
+ lastTimestamp = newTimestamp;
+ saveState();
+
+ // Deduplicate by group
+ const messagesByGroup = new Map();
+ for (const msg of messages) {
+ const existing = messagesByGroup.get(msg.chat_jid);
+ if (existing) {
+ existing.push(msg);
+ } else {
+ messagesByGroup.set(msg.chat_jid, [msg]);
+ }
+ }
+
+ for (const [chatJid, groupMessages] of messagesByGroup) {
+ const group = registeredGroups[chatJid];
+ if (!group) continue;
+
+ const channel = findChannel(channels, chatJid);
+ if (!channel) {
+ console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`);
+ continue;
+ }
+
+ const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
+ const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
+
+ // For non-main groups, only act on trigger messages.
+ // Non-trigger messages accumulate in DB and get pulled as
+ // context when a trigger eventually arrives.
+ if (needsTrigger) {
+ const hasTrigger = groupMessages.some((m) =>
+ TRIGGER_PATTERN.test(m.content.trim()),
+ );
+ if (!hasTrigger) continue;
+ }
+
+ // Pull all messages since lastAgentTimestamp so non-trigger
+ // context that accumulated between triggers is included.
+ const allPending = getMessagesSince(
+ chatJid,
+ lastAgentTimestamp[chatJid] || '',
+ ASSISTANT_NAME,
+ );
+ const messagesToSend =
+ allPending.length > 0 ? allPending : groupMessages;
+ const formatted = formatMessages(messagesToSend);
+
+ if (queue.sendMessage(chatJid, formatted)) {
+ logger.debug(
+ { chatJid, count: messagesToSend.length },
+ 'Piped messages to active container',
+ );
+ lastAgentTimestamp[chatJid] =
+ messagesToSend[messagesToSend.length - 1].timestamp;
+ saveState();
+ // Show typing indicator while the container processes the piped message
+ channel.setTyping?.(chatJid, true)?.catch((err) =>
+ logger.warn({ chatJid, err }, 'Failed to set typing indicator'),
+ );
+ } else {
+ // No active container — enqueue for a new one
+ queue.enqueueMessageCheck(chatJid);
+ }
+ }
+ }
+ } catch (err) {
+ logger.error({ err }, 'Error in message loop');
+ }
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
+ }
+}
+
+/**
+ * Startup recovery: check for unprocessed messages in registered groups.
+ * Handles crash between advancing lastTimestamp and processing messages.
+ */
+function recoverPendingMessages(): void {
+ for (const [chatJid, group] of Object.entries(registeredGroups)) {
+ const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
+ const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
+ if (pending.length > 0) {
+ logger.info(
+ { group: group.name, pendingCount: pending.length },
+ 'Recovery: found unprocessed messages',
+ );
+ queue.enqueueMessageCheck(chatJid);
+ }
+ }
+}
+
+function ensureContainerSystemRunning(): void {
+ ensureContainerRuntimeRunning();
+ cleanupOrphans();
+}
+
+async function main(): Promise {
+ ensureContainerSystemRunning();
+ initDatabase();
+ logger.info('Database initialized');
+ loadState();
+
+ // Graceful shutdown handlers
+ const shutdown = async (signal: string) => {
+ logger.info({ signal }, 'Shutdown signal received');
+ await queue.shutdown(10000);
+ for (const ch of channels) await ch.disconnect();
+ process.exit(0);
+ };
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
+ process.on('SIGINT', () => shutdown('SIGINT'));
+
+ // Channel callbacks (shared by all channels)
+ const channelOpts = {
+ onMessage: (_chatJid: string, msg: NewMessage) => storeMessage(msg),
+ onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) =>
+ storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
+ registeredGroups: () => registeredGroups,
+ };
+
+ // Create and connect channels
+ if (TELEGRAM_BOT_TOKEN) {
+ const telegram = new TelegramChannel(TELEGRAM_BOT_TOKEN, channelOpts);
+ channels.push(telegram);
+ await telegram.connect();
+ }
+
+ if (!TELEGRAM_ONLY) {
+ whatsapp = new WhatsAppChannel(channelOpts);
+ channels.push(whatsapp);
+ await whatsapp.connect();
+ }
+
+ // Start subsystems (independently of connection handler)
+ startSchedulerLoop({
+ registeredGroups: () => registeredGroups,
+ getSessions: () => sessions,
+ queue,
+ onProcess: (groupJid, proc, containerName, groupFolder) => queue.registerProcess(groupJid, proc, containerName, groupFolder),
+ sendMessage: async (jid, rawText) => {
+ const channel = findChannel(channels, jid);
+ if (!channel) {
+ console.log(`Warning: no channel owns JID ${jid}, cannot send message`);
+ return;
+ }
+ const text = formatOutbound(rawText);
+ if (text) await channel.sendMessage(jid, text);
+ },
+ });
+ startIpcWatcher({
+ sendMessage: (jid, text) => {
+ const channel = findChannel(channels, jid);
+ if (!channel) throw new Error(`No channel for JID: ${jid}`);
+ return channel.sendMessage(jid, text);
+ },
+ registeredGroups: () => registeredGroups,
+ registerGroup,
+ syncGroupMetadata: (force) => whatsapp?.syncGroupMetadata(force) ?? Promise.resolve(),
+ getAvailableGroups,
+ writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj),
+ });
+ queue.setProcessMessagesFn(processGroupMessages);
+ recoverPendingMessages();
+ startMessageLoop().catch((err) => {
+ logger.fatal({ err }, 'Message loop crashed unexpectedly');
+ process.exit(1);
+ });
+}
+
+// Guard: only run when executed directly, not when imported by tests
+const isDirectRun =
+ process.argv[1] &&
+ new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname;
+
+if (isDirectRun) {
+ main().catch((err) => {
+ logger.error({ err }, 'Failed to start NanoClaw');
+ process.exit(1);
+ });
+}
diff --git a/.agent/skills/add-telegram/modify/src/index.ts.intent.md b/.agent/skills/add-telegram/modify/src/index.ts.intent.md
new file mode 100644
index 0000000..16d2d1f
--- /dev/null
+++ b/.agent/skills/add-telegram/modify/src/index.ts.intent.md
@@ -0,0 +1,59 @@
+# Intent: src/index.ts modifications
+
+## What changed
+
+Refactored from single WhatsApp channel to multi-channel architecture using the `Channel` interface.
+
+## Key sections
+
+### Imports (top of file)
+
+- Added: `TelegramChannel` from `./channels/telegram.js`
+- Added: `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ONLY` from `./config.js`
+- Added: `findChannel` from `./router.js`
+- Added: `Channel` type from `./types.js`
+
+### Module-level state
+
+- Added: `const channels: Channel[] = []` — array of all active channels
+- Kept: `let whatsapp: WhatsAppChannel` — still needed for `syncGroupMetadata` reference
+
+### processGroupMessages()
+
+- Added: `findChannel(channels, chatJid)` lookup at the start
+- Changed: `whatsapp.setTyping()` → `channel.setTyping?.()` (optional chaining)
+- Changed: `whatsapp.sendMessage()` → `channel.sendMessage()` in output callback
+
+### getAvailableGroups()
+
+- Unchanged: uses `c.is_group` filter from base (Telegram channels pass `isGroup=true` via `onChatMetadata`)
+
+### startMessageLoop()
+
+- Added: `findChannel(channels, chatJid)` lookup per group in message processing
+- Changed: `whatsapp.setTyping()` → `channel.setTyping?.()` for typing indicators
+
+### main()
+
+- Changed: shutdown disconnects all channels via `for (const ch of channels)`
+- Added: shared `channelOpts` object for channel callbacks
+- Added: conditional WhatsApp creation (`if (!TELEGRAM_ONLY)`)
+- Added: conditional Telegram creation (`if (TELEGRAM_BOT_TOKEN)`)
+- Changed: scheduler `sendMessage` uses `findChannel()` → `channel.sendMessage()`
+- Changed: IPC `sendMessage` uses `findChannel()` → `channel.sendMessage()`
+
+## Invariants
+
+- All existing message processing logic (triggers, cursors, idle timers) is preserved
+- The `runAgent` function is completely unchanged
+- State management (loadState/saveState) is unchanged
+- Recovery logic is unchanged
+- Container runtime check is unchanged (ensureContainerSystemRunning)
+
+## Must-keep
+
+- The `escapeXml` and `formatMessages` re-exports
+- The `_setRegisteredGroups` test helper
+- The `isDirectRun` guard at bottom
+- All error handling and cursor rollback logic in processGroupMessages
+- The outgoing queue flush and reconnection logic (in WhatsAppChannel, not here)
diff --git a/.agent/skills/add-telegram/modify/src/routing.test.ts b/.agent/skills/add-telegram/modify/src/routing.test.ts
new file mode 100644
index 0000000..5b44063
--- /dev/null
+++ b/.agent/skills/add-telegram/modify/src/routing.test.ts
@@ -0,0 +1,161 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+
+import { _initTestDatabase, getAllChats, storeChatMetadata } from './db.js';
+import { getAvailableGroups, _setRegisteredGroups } from './index.js';
+
+beforeEach(() => {
+ _initTestDatabase();
+ _setRegisteredGroups({});
+});
+
+// --- JID ownership patterns ---
+
+describe('JID ownership patterns', () => {
+ // These test the patterns that will become ownsJid() on the Channel interface
+
+ it('WhatsApp group JID: ends with @g.us', () => {
+ const jid = '12345678@g.us';
+ expect(jid.endsWith('@g.us')).toBe(true);
+ });
+
+ it('WhatsApp DM JID: ends with @s.whatsapp.net', () => {
+ const jid = '12345678@s.whatsapp.net';
+ expect(jid.endsWith('@s.whatsapp.net')).toBe(true);
+ });
+
+ it('Telegram JID: starts with tg:', () => {
+ const jid = 'tg:123456789';
+ expect(jid.startsWith('tg:')).toBe(true);
+ });
+
+ it('Telegram group JID: starts with tg: and has negative ID', () => {
+ const jid = 'tg:-1001234567890';
+ expect(jid.startsWith('tg:')).toBe(true);
+ });
+});
+
+// --- getAvailableGroups ---
+
+describe('getAvailableGroups', () => {
+ it('returns only groups, excludes DMs', () => {
+ storeChatMetadata('group1@g.us', '2024-01-01T00:00:01.000Z', 'Group 1', 'whatsapp', true);
+ storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false);
+ storeChatMetadata('group2@g.us', '2024-01-01T00:00:03.000Z', 'Group 2', 'whatsapp', true);
+
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(2);
+ expect(groups.map((g) => g.jid)).toContain('group1@g.us');
+ expect(groups.map((g) => g.jid)).toContain('group2@g.us');
+ expect(groups.map((g) => g.jid)).not.toContain('user@s.whatsapp.net');
+ });
+
+ it('excludes __group_sync__ sentinel', () => {
+ storeChatMetadata('__group_sync__', '2024-01-01T00:00:00.000Z');
+ storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Group', 'whatsapp', true);
+
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(1);
+ expect(groups[0].jid).toBe('group@g.us');
+ });
+
+ it('marks registered groups correctly', () => {
+ storeChatMetadata('reg@g.us', '2024-01-01T00:00:01.000Z', 'Registered', 'whatsapp', true);
+ storeChatMetadata('unreg@g.us', '2024-01-01T00:00:02.000Z', 'Unregistered', 'whatsapp', true);
+
+ _setRegisteredGroups({
+ 'reg@g.us': {
+ name: 'Registered',
+ folder: 'registered',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ },
+ });
+
+ const groups = getAvailableGroups();
+ const reg = groups.find((g) => g.jid === 'reg@g.us');
+ const unreg = groups.find((g) => g.jid === 'unreg@g.us');
+
+ expect(reg?.isRegistered).toBe(true);
+ expect(unreg?.isRegistered).toBe(false);
+ });
+
+ it('returns groups ordered by most recent activity', () => {
+ storeChatMetadata('old@g.us', '2024-01-01T00:00:01.000Z', 'Old', 'whatsapp', true);
+ storeChatMetadata('new@g.us', '2024-01-01T00:00:05.000Z', 'New', 'whatsapp', true);
+ storeChatMetadata('mid@g.us', '2024-01-01T00:00:03.000Z', 'Mid', 'whatsapp', true);
+
+ const groups = getAvailableGroups();
+ expect(groups[0].jid).toBe('new@g.us');
+ expect(groups[1].jid).toBe('mid@g.us');
+ expect(groups[2].jid).toBe('old@g.us');
+ });
+
+ it('excludes non-group chats regardless of JID format', () => {
+ // Unknown JID format stored without is_group should not appear
+ storeChatMetadata('unknown-format-123', '2024-01-01T00:00:01.000Z', 'Unknown');
+ // Explicitly non-group with unusual JID
+ storeChatMetadata('custom:abc', '2024-01-01T00:00:02.000Z', 'Custom DM', 'custom', false);
+ // A real group for contrast
+ storeChatMetadata('group@g.us', '2024-01-01T00:00:03.000Z', 'Group', 'whatsapp', true);
+
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(1);
+ expect(groups[0].jid).toBe('group@g.us');
+ });
+
+ it('returns empty array when no chats exist', () => {
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(0);
+ });
+
+ it('includes Telegram chat JIDs', () => {
+ storeChatMetadata('tg:100200300', '2024-01-01T00:00:01.000Z', 'Telegram Chat', 'telegram', true);
+ storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false);
+
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(1);
+ expect(groups[0].jid).toBe('tg:100200300');
+ });
+
+ it('returns Telegram group JIDs with negative IDs', () => {
+ storeChatMetadata('tg:-1001234567890', '2024-01-01T00:00:01.000Z', 'TG Group', 'telegram', true);
+
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(1);
+ expect(groups[0].jid).toBe('tg:-1001234567890');
+ expect(groups[0].name).toBe('TG Group');
+ });
+
+ it('marks registered Telegram chats correctly', () => {
+ storeChatMetadata('tg:100200300', '2024-01-01T00:00:01.000Z', 'TG Registered', 'telegram', true);
+ storeChatMetadata('tg:999999', '2024-01-01T00:00:02.000Z', 'TG Unregistered', 'telegram', true);
+
+ _setRegisteredGroups({
+ 'tg:100200300': {
+ name: 'TG Registered',
+ folder: 'tg-registered',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ },
+ });
+
+ const groups = getAvailableGroups();
+ const tgReg = groups.find((g) => g.jid === 'tg:100200300');
+ const tgUnreg = groups.find((g) => g.jid === 'tg:999999');
+
+ expect(tgReg?.isRegistered).toBe(true);
+ expect(tgUnreg?.isRegistered).toBe(false);
+ });
+
+ it('mixes WhatsApp and Telegram chats ordered by activity', () => {
+ storeChatMetadata('wa@g.us', '2024-01-01T00:00:01.000Z', 'WhatsApp', 'whatsapp', true);
+ storeChatMetadata('tg:100', '2024-01-01T00:00:03.000Z', 'Telegram', 'telegram', true);
+ storeChatMetadata('wa2@g.us', '2024-01-01T00:00:02.000Z', 'WhatsApp 2', 'whatsapp', true);
+
+ const groups = getAvailableGroups();
+ expect(groups).toHaveLength(3);
+ expect(groups[0].jid).toBe('tg:100');
+ expect(groups[1].jid).toBe('wa2@g.us');
+ expect(groups[2].jid).toBe('wa@g.us');
+ });
+});
diff --git a/.agent/skills/add-telegram/tests/telegram.test.ts b/.agent/skills/add-telegram/tests/telegram.test.ts
new file mode 100644
index 0000000..50dd599
--- /dev/null
+++ b/.agent/skills/add-telegram/tests/telegram.test.ts
@@ -0,0 +1,118 @@
+import { describe, expect, it } from 'vitest';
+import fs from 'fs';
+import path from 'path';
+
+describe('telegram skill package', () => {
+ const skillDir = path.resolve(__dirname, '..');
+
+ it('has a valid manifest', () => {
+ const manifestPath = path.join(skillDir, 'manifest.yaml');
+ expect(fs.existsSync(manifestPath)).toBe(true);
+
+ const content = fs.readFileSync(manifestPath, 'utf-8');
+ expect(content).toContain('skill: telegram');
+ expect(content).toContain('version: 1.0.0');
+ expect(content).toContain('grammy');
+ });
+
+ it('has all files declared in adds', () => {
+ const addFile = path.join(skillDir, 'add', 'src', 'channels', 'telegram.ts');
+ expect(fs.existsSync(addFile)).toBe(true);
+
+ const content = fs.readFileSync(addFile, 'utf-8');
+ expect(content).toContain('class TelegramChannel');
+ expect(content).toContain('implements Channel');
+
+ // Test file for the channel
+ const testFile = path.join(skillDir, 'add', 'src', 'channels', 'telegram.test.ts');
+ expect(fs.existsSync(testFile)).toBe(true);
+
+ const testContent = fs.readFileSync(testFile, 'utf-8');
+ expect(testContent).toContain("describe('TelegramChannel'");
+ });
+
+ it('has all files declared in modifies', () => {
+ const indexFile = path.join(skillDir, 'modify', 'src', 'index.ts');
+ const configFile = path.join(skillDir, 'modify', 'src', 'config.ts');
+ const routingTestFile = path.join(skillDir, 'modify', 'src', 'routing.test.ts');
+
+ expect(fs.existsSync(indexFile)).toBe(true);
+ expect(fs.existsSync(configFile)).toBe(true);
+ expect(fs.existsSync(routingTestFile)).toBe(true);
+
+ const indexContent = fs.readFileSync(indexFile, 'utf-8');
+ expect(indexContent).toContain('TelegramChannel');
+ expect(indexContent).toContain('TELEGRAM_BOT_TOKEN');
+ expect(indexContent).toContain('TELEGRAM_ONLY');
+ expect(indexContent).toContain('findChannel');
+ expect(indexContent).toContain('channels: Channel[]');
+
+ const configContent = fs.readFileSync(configFile, 'utf-8');
+ expect(configContent).toContain('TELEGRAM_BOT_TOKEN');
+ expect(configContent).toContain('TELEGRAM_ONLY');
+ });
+
+ it('has intent files for modified files', () => {
+ expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'index.ts.intent.md'))).toBe(true);
+ expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'config.ts.intent.md'))).toBe(true);
+ });
+
+ it('modified index.ts preserves core structure', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'index.ts'),
+ 'utf-8',
+ );
+
+ // Core functions still present
+ expect(content).toContain('function loadState()');
+ expect(content).toContain('function saveState()');
+ expect(content).toContain('function registerGroup(');
+ expect(content).toContain('function getAvailableGroups()');
+ expect(content).toContain('function processGroupMessages(');
+ expect(content).toContain('function runAgent(');
+ expect(content).toContain('function startMessageLoop()');
+ expect(content).toContain('function recoverPendingMessages()');
+ expect(content).toContain('function ensureContainerSystemRunning()');
+ expect(content).toContain('async function main()');
+
+ // Test helper preserved
+ expect(content).toContain('_setRegisteredGroups');
+
+ // Direct-run guard preserved
+ expect(content).toContain('isDirectRun');
+ });
+
+ it('modified index.ts includes Telegram channel creation', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'index.ts'),
+ 'utf-8',
+ );
+
+ // Multi-channel architecture
+ expect(content).toContain('const channels: Channel[] = []');
+ expect(content).toContain('channels.push(whatsapp)');
+ expect(content).toContain('channels.push(telegram)');
+
+ // Conditional channel creation
+ expect(content).toContain('if (!TELEGRAM_ONLY)');
+ expect(content).toContain('if (TELEGRAM_BOT_TOKEN)');
+
+ // Shutdown disconnects all channels
+ expect(content).toContain('for (const ch of channels) await ch.disconnect()');
+ });
+
+ it('modified config.ts preserves all existing exports', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'config.ts'),
+ 'utf-8',
+ );
+
+ // All original exports preserved
+ expect(content).toContain('export const ASSISTANT_NAME');
+ expect(content).toContain('export const POLL_INTERVAL');
+ expect(content).toContain('export const TRIGGER_PATTERN');
+ expect(content).toContain('export const CONTAINER_IMAGE');
+ expect(content).toContain('export const DATA_DIR');
+ expect(content).toContain('export const TIMEZONE');
+ });
+});
diff --git a/.agent/skills/add-voice-transcription/SKILL.md b/.agent/skills/add-voice-transcription/SKILL.md
new file mode 100644
index 0000000..9997ff4
--- /dev/null
+++ b/.agent/skills/add-voice-transcription/SKILL.md
@@ -0,0 +1,145 @@
+---
+name: add-voice-transcription
+description: Add voice message transcription to NanoClaw using OpenAI's Whisper API. Automatically transcribes WhatsApp voice notes so the agent can read and respond to them.
+---
+
+# Add Voice Transcription
+
+This skill adds automatic voice message transcription to NanoClaw's WhatsApp channel using OpenAI's Whisper API. When a voice note arrives, it is downloaded, transcribed, and delivered to the agent as `[Voice: ]`.
+
+## Phase 1: Pre-flight
+
+### Check if already applied
+
+Read `.nanoclaw/state.yaml`. If `voice-transcription` is in `applied_skills`, skip to Phase 3 (Configure). The code changes are already in place.
+
+### Ask the user
+
+Use `AskUserQuestion` to collect information:
+
+AskUserQuestion: Do you have an OpenAI API key for Whisper transcription?
+
+If yes, collect it now. If no, direct them to create one at https://platform.openai.com/api-keys.
+
+## Phase 2: Apply Code Changes
+
+Run the skills engine to apply this skill's code package.
+
+### Initialize skills system (if needed)
+
+If `.nanoclaw/` directory doesn't exist yet:
+
+```bash
+npx tsx scripts/apply-skill.ts --init
+```
+
+### Apply the skill
+
+```bash
+npx tsx scripts/apply-skill.ts .agent/skills/add-voice-transcription
+```
+
+This deterministically:
+
+- Adds `src/transcription.ts` (voice transcription module using OpenAI Whisper)
+- Three-way merges voice handling into `src/channels/whatsapp.ts` (isVoiceMessage check, transcribeAudioMessage call)
+- Three-way merges transcription tests into `src/channels/whatsapp.test.ts` (mock + 3 test cases)
+- Installs the `openai` npm dependency
+- Updates `.env.example` with `OPENAI_API_KEY`
+- Records the application in `.nanoclaw/state.yaml`
+
+If the apply reports merge conflicts, read the intent files:
+
+- `modify/src/channels/whatsapp.ts.intent.md` — what changed and invariants for whatsapp.ts
+- `modify/src/channels/whatsapp.test.ts.intent.md` — what changed for whatsapp.test.ts
+
+### Validate code changes
+
+```bash
+npm test
+npm run build
+```
+
+All tests must pass (including the 3 new voice transcription tests) and build must be clean before proceeding.
+
+## Phase 3: Configure
+
+### Get OpenAI API key (if needed)
+
+If the user doesn't have an API key:
+
+> I need you to create an OpenAI API key:
+>
+> 1. Go to https://platform.openai.com/api-keys
+> 2. Click "Create new secret key"
+> 3. Give it a name (e.g., "NanoClaw Transcription")
+> 4. Copy the key (starts with `sk-`)
+>
+> Cost: ~$0.006 per minute of audio (~$0.003 per typical 30-second voice note)
+
+Wait for the user to provide the key.
+
+### Add to environment
+
+Add to `.env`:
+
+```bash
+OPENAI_API_KEY=
+```
+
+Sync to container environment:
+
+```bash
+mkdir -p data/env && cp .env data/env/env
+```
+
+The container reads environment from `data/env/env`, not `.env` directly.
+
+### Build and restart
+
+```bash
+npm run build
+launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
+# Linux: systemctl --user restart nanoclaw
+```
+
+## Phase 4: Verify
+
+### Test with a voice note
+
+Tell the user:
+
+> Send a voice note in any registered WhatsApp chat. The agent should receive it as `[Voice: ]` and respond to its content.
+
+### Check logs if needed
+
+```bash
+tail -f logs/nanoclaw.log | grep -i voice
+```
+
+Look for:
+
+- `Transcribed voice message` — successful transcription with character count
+- `OPENAI_API_KEY not set` — key missing from `.env`
+- `OpenAI transcription failed` — API error (check key validity, billing)
+- `Failed to download audio message` — media download issue
+
+## Troubleshooting
+
+### Voice notes show "[Voice Message - transcription unavailable]"
+
+1. Check `OPENAI_API_KEY` is set in `.env` AND synced to `data/env/env`
+2. Verify key works: `curl -s https://api.openai.com/v1/models -H "Authorization: Bearer $OPENAI_API_KEY" | head -c 200`
+3. Check OpenAI billing — Whisper requires a funded account
+
+### Voice notes show "[Voice Message - transcription failed]"
+
+Check logs for the specific error. Common causes:
+
+- Network timeout — transient, will work on next message
+- Invalid API key — regenerate at https://platform.openai.com/api-keys
+- Rate limiting — wait and retry
+
+### Agent doesn't respond to voice notes
+
+Verify the chat is registered and the agent is running. Voice transcription only runs for registered groups.
diff --git a/.agent/skills/add-voice-transcription/add/src/transcription.ts b/.agent/skills/add-voice-transcription/add/src/transcription.ts
new file mode 100644
index 0000000..91c5e7f
--- /dev/null
+++ b/.agent/skills/add-voice-transcription/add/src/transcription.ts
@@ -0,0 +1,98 @@
+import { downloadMediaMessage } from '@whiskeysockets/baileys';
+import { WAMessage, WASocket } from '@whiskeysockets/baileys';
+
+import { readEnvFile } from './env.js';
+
+interface TranscriptionConfig {
+ model: string;
+ enabled: boolean;
+ fallbackMessage: string;
+}
+
+const DEFAULT_CONFIG: TranscriptionConfig = {
+ model: 'whisper-1',
+ enabled: true,
+ fallbackMessage: '[Voice Message - transcription unavailable]',
+};
+
+async function transcribeWithOpenAI(
+ audioBuffer: Buffer,
+ config: TranscriptionConfig,
+): Promise {
+ const env = readEnvFile(['OPENAI_API_KEY']);
+ const apiKey = env.OPENAI_API_KEY;
+
+ if (!apiKey) {
+ console.warn('OPENAI_API_KEY not set in .env');
+ return null;
+ }
+
+ try {
+ const openaiModule = await import('openai');
+ const OpenAI = openaiModule.default;
+ const toFile = openaiModule.toFile;
+
+ const openai = new OpenAI({ apiKey });
+
+ const file = await toFile(audioBuffer, 'voice.ogg', {
+ type: 'audio/ogg',
+ });
+
+ const transcription = await openai.audio.transcriptions.create({
+ file: file,
+ model: config.model,
+ response_format: 'text',
+ });
+
+ // When response_format is 'text', the API returns a plain string
+ return transcription as unknown as string;
+ } catch (err) {
+ console.error('OpenAI transcription failed:', err);
+ return null;
+ }
+}
+
+export async function transcribeAudioMessage(
+ msg: WAMessage,
+ sock: WASocket,
+): Promise {
+ const config = DEFAULT_CONFIG;
+
+ if (!config.enabled) {
+ return config.fallbackMessage;
+ }
+
+ try {
+ const buffer = (await downloadMediaMessage(
+ msg,
+ 'buffer',
+ {},
+ {
+ logger: console as any,
+ reuploadRequest: sock.updateMediaMessage,
+ },
+ )) as Buffer;
+
+ if (!buffer || buffer.length === 0) {
+ console.error('Failed to download audio message');
+ return config.fallbackMessage;
+ }
+
+ console.log(`Downloaded audio message: ${buffer.length} bytes`);
+
+ const transcript = await transcribeWithOpenAI(buffer, config);
+
+ if (!transcript) {
+ return config.fallbackMessage;
+ }
+
+ return transcript.trim();
+ } catch (err) {
+ console.error('Transcription error:', err);
+ return config.fallbackMessage;
+ }
+}
+
+export function isVoiceMessage(msg: WAMessage): boolean {
+ return msg.message?.audioMessage?.ptt === true;
+}
diff --git a/.agent/skills/add-voice-transcription/manifest.yaml b/.agent/skills/add-voice-transcription/manifest.yaml
new file mode 100644
index 0000000..cb4d587
--- /dev/null
+++ b/.agent/skills/add-voice-transcription/manifest.yaml
@@ -0,0 +1,17 @@
+skill: voice-transcription
+version: 1.0.0
+description: "Voice message transcription via OpenAI Whisper"
+core_version: 0.1.0
+adds:
+ - src/transcription.ts
+modifies:
+ - src/channels/whatsapp.ts
+ - src/channels/whatsapp.test.ts
+structured:
+ npm_dependencies:
+ openai: "^4.77.0"
+ env_additions:
+ - OPENAI_API_KEY
+conflicts: []
+depends: []
+test: "npx vitest run src/channels/whatsapp.test.ts"
diff --git a/.agent/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts b/.agent/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts
new file mode 100644
index 0000000..30e79b0
--- /dev/null
+++ b/.agent/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts
@@ -0,0 +1,963 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import { EventEmitter } from 'events';
+
+// --- Mocks ---
+
+// Mock config
+vi.mock('../config.js', () => ({
+ STORE_DIR: '/tmp/clawdie-cp-test-store',
+ ASSISTANT_NAME: 'Andy',
+ ASSISTANT_HAS_OWN_NUMBER: false,
+}));
+
+// Mock logger
+vi.mock('../logger.js', () => ({
+ logger: {
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+// Mock db
+vi.mock('../db.js', () => ({
+ getLastGroupSync: vi.fn(() => null),
+ setLastGroupSync: vi.fn(),
+ updateChatName: vi.fn(),
+}));
+
+// Mock transcription
+vi.mock('../transcription.js', () => ({
+ isVoiceMessage: vi.fn((msg: any) => msg.message?.audioMessage?.ptt === true),
+ transcribeAudioMessage: vi.fn().mockResolvedValue('Hello this is a voice message'),
+}));
+
+// Mock fs
+vi.mock('fs', async () => {
+ const actual = await vi.importActual('fs');
+ return {
+ ...actual,
+ default: {
+ ...actual,
+ existsSync: vi.fn(() => true),
+ mkdirSync: vi.fn(),
+ },
+ };
+});
+
+// Mock child_process (used for osascript notification)
+vi.mock('child_process', () => ({
+ exec: vi.fn(),
+}));
+
+// Build a fake WASocket that's an EventEmitter with the methods we need
+function createFakeSocket() {
+ const ev = new EventEmitter();
+ const sock = {
+ ev: {
+ on: (event: string, handler: (...args: unknown[]) => void) => {
+ ev.on(event, handler);
+ },
+ },
+ user: {
+ id: '1234567890:1@s.whatsapp.net',
+ lid: '9876543210:1@lid',
+ },
+ sendMessage: vi.fn().mockResolvedValue(undefined),
+ sendPresenceUpdate: vi.fn().mockResolvedValue(undefined),
+ groupFetchAllParticipating: vi.fn().mockResolvedValue({}),
+ end: vi.fn(),
+ // Expose the event emitter for triggering events in tests
+ _ev: ev,
+ };
+ return sock;
+}
+
+let fakeSocket: ReturnType;
+
+// Mock Baileys
+vi.mock('@whiskeysockets/baileys', () => {
+ return {
+ default: vi.fn(() => fakeSocket),
+ Browsers: { macOS: vi.fn(() => ['macOS', 'Chrome', '']) },
+ DisconnectReason: {
+ loggedOut: 401,
+ badSession: 500,
+ connectionClosed: 428,
+ connectionLost: 408,
+ connectionReplaced: 440,
+ timedOut: 408,
+ restartRequired: 515,
+ },
+ makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys),
+ useMultiFileAuthState: vi.fn().mockResolvedValue({
+ state: {
+ creds: {},
+ keys: {},
+ },
+ saveCreds: vi.fn(),
+ }),
+ };
+});
+
+import { WhatsAppChannel, WhatsAppChannelOpts } from './whatsapp.js';
+import { getLastGroupSync, updateChatName, setLastGroupSync } from '../db.js';
+import { transcribeAudioMessage } from '../transcription.js';
+
+// --- Test helpers ---
+
+function createTestOpts(overrides?: Partial): WhatsAppChannelOpts {
+ return {
+ onMessage: vi.fn(),
+ onChatMetadata: vi.fn(),
+ registeredGroups: vi.fn(() => ({
+ 'registered@g.us': {
+ name: 'Test Group',
+ folder: 'test-group',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ },
+ })),
+ ...overrides,
+ };
+}
+
+function triggerConnection(state: string, extra?: Record) {
+ fakeSocket._ev.emit('connection.update', { connection: state, ...extra });
+}
+
+function triggerDisconnect(statusCode: number) {
+ fakeSocket._ev.emit('connection.update', {
+ connection: 'close',
+ lastDisconnect: {
+ error: { output: { statusCode } },
+ },
+ });
+}
+
+async function triggerMessages(messages: unknown[]) {
+ fakeSocket._ev.emit('messages.upsert', { messages });
+ // Flush microtasks so the async messages.upsert handler completes
+ await new Promise((r) => setTimeout(r, 0));
+}
+
+// --- Tests ---
+
+describe('WhatsAppChannel', () => {
+ beforeEach(() => {
+ fakeSocket = createFakeSocket();
+ vi.mocked(getLastGroupSync).mockReturnValue(null);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ /**
+ * Helper: start connect, flush microtasks so event handlers are registered,
+ * then trigger the connection open event. Returns the resolved promise.
+ */
+ async function connectChannel(channel: WhatsAppChannel): Promise {
+ const p = channel.connect();
+ // Flush microtasks so connectInternal completes its await and registers handlers
+ await new Promise((r) => setTimeout(r, 0));
+ triggerConnection('open');
+ return p;
+ }
+
+ // --- Connection lifecycle ---
+
+ describe('connection lifecycle', () => {
+ it('resolves connect() when connection opens', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ expect(channel.isConnected()).toBe(true);
+ });
+
+ it('sets up LID to phone mapping on open', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // The channel should have mapped the LID from sock.user
+ // We can verify by sending a message from a LID JID
+ // and checking the translated JID in the callback
+ });
+
+ it('flushes outgoing queue on reconnect', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // Disconnect
+ (channel as any).connected = false;
+
+ // Queue a message while disconnected
+ await channel.sendMessage('test@g.us', 'Queued message');
+ expect(fakeSocket.sendMessage).not.toHaveBeenCalled();
+
+ // Reconnect
+ (channel as any).connected = true;
+ await (channel as any).flushOutgoingQueue();
+
+ // Group messages get prefixed when flushed
+ expect(fakeSocket.sendMessage).toHaveBeenCalledWith(
+ 'test@g.us',
+ { text: 'Andy: Queued message' },
+ );
+ });
+
+ it('disconnects cleanly', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await channel.disconnect();
+ expect(channel.isConnected()).toBe(false);
+ expect(fakeSocket.end).toHaveBeenCalled();
+ });
+ });
+
+ // --- QR code and auth ---
+
+ describe('authentication', () => {
+ it('exits process when QR code is emitted (no auth state)', async () => {
+ vi.useFakeTimers();
+ const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ // Start connect but don't await (it won't resolve - process exits)
+ channel.connect().catch(() => {});
+
+ // Flush microtasks so connectInternal registers handlers
+ await vi.advanceTimersByTimeAsync(0);
+
+ // Emit QR code event
+ fakeSocket._ev.emit('connection.update', { qr: 'some-qr-data' });
+
+ // Advance timer past the 1000ms setTimeout before exit
+ await vi.advanceTimersByTimeAsync(1500);
+
+ expect(mockExit).toHaveBeenCalledWith(1);
+ mockExit.mockRestore();
+ vi.useRealTimers();
+ });
+ });
+
+ // --- Reconnection behavior ---
+
+ describe('reconnection', () => {
+ it('reconnects on non-loggedOut disconnect', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ expect(channel.isConnected()).toBe(true);
+
+ // Disconnect with a non-loggedOut reason (e.g., connectionClosed = 428)
+ triggerDisconnect(428);
+
+ expect(channel.isConnected()).toBe(false);
+ // The channel should attempt to reconnect (calls connectInternal again)
+ });
+
+ it('exits on loggedOut disconnect', async () => {
+ const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // Disconnect with loggedOut reason (401)
+ triggerDisconnect(401);
+
+ expect(channel.isConnected()).toBe(false);
+ expect(mockExit).toHaveBeenCalledWith(0);
+ mockExit.mockRestore();
+ });
+
+ it('retries reconnection after 5s on failure', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // Disconnect with stream error 515
+ triggerDisconnect(515);
+
+ // The channel sets a 5s retry — just verify it doesn't crash
+ await new Promise((r) => setTimeout(r, 100));
+ });
+ });
+
+ // --- Message handling ---
+
+ describe('message handling', () => {
+ it('delivers message for registered group', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-1',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: { conversation: 'Hello Andy' },
+ pushName: 'Alice',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.any(String),
+ undefined,
+ 'whatsapp',
+ true,
+ );
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({
+ id: 'msg-1',
+ content: 'Hello Andy',
+ sender_name: 'Alice',
+ is_from_me: false,
+ }),
+ );
+ });
+
+ it('only emits metadata for unregistered groups', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-2',
+ remoteJid: 'unregistered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: { conversation: 'Hello' },
+ pushName: 'Bob',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'unregistered@g.us',
+ expect.any(String),
+ undefined,
+ 'whatsapp',
+ true,
+ );
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ });
+
+ it('ignores status@broadcast messages', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-3',
+ remoteJid: 'status@broadcast',
+ fromMe: false,
+ },
+ message: { conversation: 'Status update' },
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onChatMetadata).not.toHaveBeenCalled();
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ });
+
+ it('ignores messages with no content', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-4',
+ remoteJid: 'registered@g.us',
+ fromMe: false,
+ },
+ message: null,
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ });
+
+ it('extracts text from extendedTextMessage', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-5',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: {
+ extendedTextMessage: { text: 'A reply message' },
+ },
+ pushName: 'Charlie',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({ content: 'A reply message' }),
+ );
+ });
+
+ it('extracts caption from imageMessage', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-6',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: {
+ imageMessage: { caption: 'Check this photo', mimetype: 'image/jpeg' },
+ },
+ pushName: 'Diana',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({ content: 'Check this photo' }),
+ );
+ });
+
+ it('extracts caption from videoMessage', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-7',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: {
+ videoMessage: { caption: 'Watch this', mimetype: 'video/mp4' },
+ },
+ pushName: 'Eve',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({ content: 'Watch this' }),
+ );
+ });
+
+ it('transcribes voice messages', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-8',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: {
+ audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true },
+ },
+ pushName: 'Frank',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(transcribeAudioMessage).toHaveBeenCalled();
+ expect(opts.onMessage).toHaveBeenCalledTimes(1);
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({ content: '[Voice: Hello this is a voice message]' }),
+ );
+ });
+
+ it('falls back when transcription returns null', async () => {
+ vi.mocked(transcribeAudioMessage).mockResolvedValueOnce(null);
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-8b',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: {
+ audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true },
+ },
+ pushName: 'Frank',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onMessage).toHaveBeenCalledTimes(1);
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({ content: '[Voice Message - transcription unavailable]' }),
+ );
+ });
+
+ it('falls back when transcription throws', async () => {
+ vi.mocked(transcribeAudioMessage).mockRejectedValueOnce(new Error('API error'));
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-8c',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: {
+ audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true },
+ },
+ pushName: 'Frank',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onMessage).toHaveBeenCalledTimes(1);
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({ content: '[Voice Message - transcription failed]' }),
+ );
+ });
+
+ it('uses sender JID when pushName is absent', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-9',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: { conversation: 'No push name' },
+ // pushName is undefined
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({ sender_name: '5551234' }),
+ );
+ });
+ });
+
+ // --- LID ↔ JID translation ---
+
+ describe('LID to JID translation', () => {
+ it('translates known LID to phone JID', async () => {
+ const opts = createTestOpts({
+ registeredGroups: vi.fn(() => ({
+ '1234567890@s.whatsapp.net': {
+ name: 'Self Chat',
+ folder: 'self-chat',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ },
+ })),
+ });
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // The socket has lid '9876543210:1@lid' → phone '1234567890@s.whatsapp.net'
+ // Send a message from the LID
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-lid',
+ remoteJid: '9876543210@lid',
+ fromMe: false,
+ },
+ message: { conversation: 'From LID' },
+ pushName: 'Self',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ // Should be translated to phone JID
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ '1234567890@s.whatsapp.net',
+ expect.any(String),
+ undefined,
+ 'whatsapp',
+ false,
+ );
+ });
+
+ it('passes through non-LID JIDs unchanged', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-normal',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: { conversation: 'Normal JID' },
+ pushName: 'Grace',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.any(String),
+ undefined,
+ 'whatsapp',
+ true,
+ );
+ });
+
+ it('passes through unknown LID JIDs unchanged', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-unknown-lid',
+ remoteJid: '0000000000@lid',
+ fromMe: false,
+ },
+ message: { conversation: 'Unknown LID' },
+ pushName: 'Unknown',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ // Unknown LID passes through unchanged
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ '0000000000@lid',
+ expect.any(String),
+ undefined,
+ 'whatsapp',
+ false,
+ );
+ });
+ });
+
+ // --- Outgoing message queue ---
+
+ describe('outgoing message queue', () => {
+ it('sends message directly when connected', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await channel.sendMessage('test@g.us', 'Hello');
+ // Group messages get prefixed with assistant name
+ expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', { text: 'Andy: Hello' });
+ });
+
+ it('prefixes direct chat messages on shared number', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await channel.sendMessage('123@s.whatsapp.net', 'Hello');
+ // Shared number: DMs also get prefixed (needed for self-chat distinction)
+ expect(fakeSocket.sendMessage).toHaveBeenCalledWith('123@s.whatsapp.net', { text: 'Andy: Hello' });
+ });
+
+ it('queues message when disconnected', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ // Don't connect — channel starts disconnected
+ await channel.sendMessage('test@g.us', 'Queued');
+ expect(fakeSocket.sendMessage).not.toHaveBeenCalled();
+ });
+
+ it('queues message on send failure', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // Make sendMessage fail
+ fakeSocket.sendMessage.mockRejectedValueOnce(new Error('Network error'));
+
+ await channel.sendMessage('test@g.us', 'Will fail');
+
+ // Should not throw, message queued for retry
+ // The queue should have the message
+ });
+
+ it('flushes multiple queued messages in order', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ // Queue messages while disconnected
+ await channel.sendMessage('test@g.us', 'First');
+ await channel.sendMessage('test@g.us', 'Second');
+ await channel.sendMessage('test@g.us', 'Third');
+
+ // Connect — flush happens automatically on open
+ await connectChannel(channel);
+
+ // Give the async flush time to complete
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(fakeSocket.sendMessage).toHaveBeenCalledTimes(3);
+ // Group messages get prefixed
+ expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(1, 'test@g.us', { text: 'Andy: First' });
+ expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(2, 'test@g.us', { text: 'Andy: Second' });
+ expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(3, 'test@g.us', { text: 'Andy: Third' });
+ });
+ });
+
+ // --- Group metadata sync ---
+
+ describe('group metadata sync', () => {
+ it('syncs group metadata on first connection', async () => {
+ fakeSocket.groupFetchAllParticipating.mockResolvedValue({
+ 'group1@g.us': { subject: 'Group One' },
+ 'group2@g.us': { subject: 'Group Two' },
+ });
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // Wait for async sync to complete
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled();
+ expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Group One');
+ expect(updateChatName).toHaveBeenCalledWith('group2@g.us', 'Group Two');
+ expect(setLastGroupSync).toHaveBeenCalled();
+ });
+
+ it('skips sync when synced recently', async () => {
+ // Last sync was 1 hour ago (within 24h threshold)
+ vi.mocked(getLastGroupSync).mockReturnValue(
+ new Date(Date.now() - 60 * 60 * 1000).toISOString(),
+ );
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(fakeSocket.groupFetchAllParticipating).not.toHaveBeenCalled();
+ });
+
+ it('forces sync regardless of cache', async () => {
+ vi.mocked(getLastGroupSync).mockReturnValue(
+ new Date(Date.now() - 60 * 60 * 1000).toISOString(),
+ );
+
+ fakeSocket.groupFetchAllParticipating.mockResolvedValue({
+ 'group@g.us': { subject: 'Forced Group' },
+ });
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await channel.syncGroupMetadata(true);
+
+ expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled();
+ expect(updateChatName).toHaveBeenCalledWith('group@g.us', 'Forced Group');
+ });
+
+ it('handles group sync failure gracefully', async () => {
+ fakeSocket.groupFetchAllParticipating.mockRejectedValue(
+ new Error('Network timeout'),
+ );
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // Should not throw
+ await expect(channel.syncGroupMetadata(true)).resolves.toBeUndefined();
+ });
+
+ it('skips groups with no subject', async () => {
+ fakeSocket.groupFetchAllParticipating.mockResolvedValue({
+ 'group1@g.us': { subject: 'Has Subject' },
+ 'group2@g.us': { subject: '' },
+ 'group3@g.us': {},
+ });
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // Clear any calls from the automatic sync on connect
+ vi.mocked(updateChatName).mockClear();
+
+ await channel.syncGroupMetadata(true);
+
+ expect(updateChatName).toHaveBeenCalledTimes(1);
+ expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Has Subject');
+ });
+ });
+
+ // --- JID ownership ---
+
+ describe('ownsJid', () => {
+ it('owns @g.us JIDs (WhatsApp groups)', () => {
+ const channel = new WhatsAppChannel(createTestOpts());
+ expect(channel.ownsJid('12345@g.us')).toBe(true);
+ });
+
+ it('owns @s.whatsapp.net JIDs (WhatsApp DMs)', () => {
+ const channel = new WhatsAppChannel(createTestOpts());
+ expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(true);
+ });
+
+ it('does not own Telegram JIDs', () => {
+ const channel = new WhatsAppChannel(createTestOpts());
+ expect(channel.ownsJid('tg:12345')).toBe(false);
+ });
+
+ it('does not own unknown JID formats', () => {
+ const channel = new WhatsAppChannel(createTestOpts());
+ expect(channel.ownsJid('random-string')).toBe(false);
+ });
+ });
+
+ // --- Typing indicator ---
+
+ describe('setTyping', () => {
+ it('sends composing presence when typing', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await channel.setTyping('test@g.us', true);
+ expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith('composing', 'test@g.us');
+ });
+
+ it('sends paused presence when stopping', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await channel.setTyping('test@g.us', false);
+ expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith('paused', 'test@g.us');
+ });
+
+ it('handles typing indicator failure gracefully', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ fakeSocket.sendPresenceUpdate.mockRejectedValueOnce(new Error('Failed'));
+
+ // Should not throw
+ await expect(channel.setTyping('test@g.us', true)).resolves.toBeUndefined();
+ });
+ });
+
+ // --- Channel properties ---
+
+ describe('channel properties', () => {
+ it('has name "whatsapp"', () => {
+ const channel = new WhatsAppChannel(createTestOpts());
+ expect(channel.name).toBe('whatsapp');
+ });
+
+ it('does not expose prefixAssistantName (prefix handled internally)', () => {
+ const channel = new WhatsAppChannel(createTestOpts());
+ expect('prefixAssistantName' in channel).toBe(false);
+ });
+ });
+});
diff --git a/.agent/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts.intent.md b/.agent/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts.intent.md
new file mode 100644
index 0000000..1607ddb
--- /dev/null
+++ b/.agent/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts.intent.md
@@ -0,0 +1,30 @@
+# Intent: src/channels/whatsapp.test.ts modifications
+
+## What changed
+
+Added mock for the transcription module and 3 new test cases for voice message handling.
+
+## Key sections
+
+### Mocks (top of file)
+
+- Added: `vi.mock('../transcription.js', ...)` with `isVoiceMessage` and `transcribeAudioMessage` mocks
+- Added: `import { transcribeAudioMessage } from '../transcription.js'` for test assertions
+
+### Test cases (inside "message handling" describe block)
+
+- Changed: "handles message with no extractable text (e.g. voice note without caption)" → "transcribes voice messages"
+ - Now expects `[Voice: Hello this is a voice message]` instead of empty content
+- Added: "falls back when transcription returns null" — expects `[Voice Message - transcription unavailable]`
+- Added: "falls back when transcription throws" — expects `[Voice Message - transcription failed]`
+
+## Invariants (must-keep)
+
+- All existing test cases for text, extendedTextMessage, imageMessage, videoMessage unchanged
+- All connection lifecycle tests unchanged
+- All LID translation tests unchanged
+- All outgoing queue tests unchanged
+- All group metadata sync tests unchanged
+- All ownsJid and setTyping tests unchanged
+- All existing mocks (config, logger, db, fs, child_process, baileys) unchanged
+- Test helpers (createTestOpts, triggerConnection, triggerDisconnect, triggerMessages, connectChannel) unchanged
diff --git a/.agent/skills/add-voice-transcription/modify/src/channels/whatsapp.ts b/.agent/skills/add-voice-transcription/modify/src/channels/whatsapp.ts
new file mode 100644
index 0000000..9fc832d
--- /dev/null
+++ b/.agent/skills/add-voice-transcription/modify/src/channels/whatsapp.ts
@@ -0,0 +1,400 @@
+import { exec } from 'child_process';
+import fs from 'fs';
+import path from 'path';
+
+import makeWASocket, {
+ Browsers,
+ DisconnectReason,
+ WASocket,
+ fetchLatestWaWebVersion,
+ makeCacheableSignalKeyStore,
+ useMultiFileAuthState,
+} from '@whiskeysockets/baileys';
+
+import {
+ ASSISTANT_HAS_OWN_NUMBER,
+ ASSISTANT_NAME,
+ STORE_DIR,
+} from '../config.js';
+import { getLastGroupSync, setLastGroupSync, updateChatName } from '../db.js';
+import { logger } from '../logger.js';
+import { isVoiceMessage, transcribeAudioMessage } from '../transcription.js';
+import {
+ Channel,
+ OnInboundMessage,
+ OnChatMetadata,
+ RegisteredGroup,
+} from '../types.js';
+
+const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
+
+export interface WhatsAppChannelOpts {
+ onMessage: OnInboundMessage;
+ onChatMetadata: OnChatMetadata;
+ registeredGroups: () => Record;
+}
+
+export class WhatsAppChannel implements Channel {
+ name = 'whatsapp';
+
+ private sock!: WASocket;
+ private connected = false;
+ private lidToPhoneMap: Record = {};
+ private outgoingQueue: Array<{ jid: string; text: string }> = [];
+ private flushing = false;
+ private groupSyncTimerStarted = false;
+
+ private opts: WhatsAppChannelOpts;
+
+ constructor(opts: WhatsAppChannelOpts) {
+ this.opts = opts;
+ }
+
+ async connect(): Promise {
+ return new Promise((resolve, reject) => {
+ this.connectInternal(resolve).catch(reject);
+ });
+ }
+
+ private async connectInternal(onFirstOpen?: () => void): Promise {
+ const authDir = path.join(STORE_DIR, 'auth');
+ fs.mkdirSync(authDir, { recursive: true });
+
+ const { state, saveCreds } = await useMultiFileAuthState(authDir);
+
+ const { version } = await fetchLatestWaWebVersion({}).catch((err) => {
+ logger.warn(
+ { err },
+ 'Failed to fetch latest WA Web version, using default',
+ );
+ return { version: undefined };
+ });
+ this.sock = makeWASocket({
+ version,
+ auth: {
+ creds: state.creds,
+ keys: makeCacheableSignalKeyStore(state.keys, logger),
+ },
+ printQRInTerminal: false,
+ logger,
+ browser: Browsers.macOS('Chrome'),
+ });
+
+ this.sock.ev.on('connection.update', (update) => {
+ const { connection, lastDisconnect, qr } = update;
+
+ if (qr) {
+ const msg =
+ 'WhatsApp authentication required. Run /setup to re-authenticate.';
+ logger.error(msg);
+ exec(
+ `osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`,
+ );
+ setTimeout(() => process.exit(1), 1000);
+ }
+
+ if (connection === 'close') {
+ this.connected = false;
+ const reason = (
+ lastDisconnect?.error as { output?: { statusCode?: number } }
+ )?.output?.statusCode;
+ const shouldReconnect = reason !== DisconnectReason.loggedOut;
+ logger.info(
+ {
+ reason,
+ shouldReconnect,
+ queuedMessages: this.outgoingQueue.length,
+ },
+ 'Connection closed',
+ );
+
+ if (shouldReconnect) {
+ logger.info('Reconnecting...');
+ this.connectInternal().catch((err) => {
+ logger.error({ err }, 'Failed to reconnect, retrying in 5s');
+ setTimeout(() => {
+ this.connectInternal().catch((err2) => {
+ logger.error({ err: err2 }, 'Reconnection retry failed');
+ });
+ }, 5000);
+ });
+ } else {
+ logger.info('Logged out. Run /setup to re-authenticate.');
+ process.exit(0);
+ }
+ } else if (connection === 'open') {
+ this.connected = true;
+ logger.info('Connected to WhatsApp');
+
+ // Announce availability so WhatsApp relays subsequent presence updates (typing indicators)
+ this.sock.sendPresenceUpdate('available').catch((err) => {
+ logger.warn({ err }, 'Failed to send presence update');
+ });
+
+ // Build LID to phone mapping from auth state for self-chat translation
+ if (this.sock.user) {
+ const phoneUser = this.sock.user.id.split(':')[0];
+ const lidUser = this.sock.user.lid?.split(':')[0];
+ if (lidUser && phoneUser) {
+ this.lidToPhoneMap[lidUser] = `${phoneUser}@s.whatsapp.net`;
+ logger.debug({ lidUser, phoneUser }, 'LID to phone mapping set');
+ }
+ }
+
+ // Flush any messages queued while disconnected
+ this.flushOutgoingQueue().catch((err) =>
+ logger.error({ err }, 'Failed to flush outgoing queue'),
+ );
+
+ // Sync group metadata on startup (respects 24h cache)
+ this.syncGroupMetadata().catch((err) =>
+ logger.error({ err }, 'Initial group sync failed'),
+ );
+ // Set up daily sync timer (only once)
+ if (!this.groupSyncTimerStarted) {
+ this.groupSyncTimerStarted = true;
+ setInterval(() => {
+ this.syncGroupMetadata().catch((err) =>
+ logger.error({ err }, 'Periodic group sync failed'),
+ );
+ }, GROUP_SYNC_INTERVAL_MS);
+ }
+
+ // Signal first connection to caller
+ if (onFirstOpen) {
+ onFirstOpen();
+ onFirstOpen = undefined;
+ }
+ }
+ });
+
+ this.sock.ev.on('creds.update', saveCreds);
+
+ this.sock.ev.on('messages.upsert', async ({ messages }) => {
+ for (const msg of messages) {
+ if (!msg.message) continue;
+ const rawJid = msg.key.remoteJid;
+ if (!rawJid || rawJid === 'status@broadcast') continue;
+
+ // Translate LID JID to phone JID if applicable
+ const chatJid = await this.translateJid(rawJid);
+
+ const timestamp = new Date(
+ Number(msg.messageTimestamp) * 1000,
+ ).toISOString();
+
+ // Always notify about chat metadata for group discovery
+ const isGroup = chatJid.endsWith('@g.us');
+ this.opts.onChatMetadata(
+ chatJid,
+ timestamp,
+ undefined,
+ 'whatsapp',
+ isGroup,
+ );
+
+ // Only deliver full message for registered groups
+ const groups = this.opts.registeredGroups();
+ if (groups[chatJid]) {
+ const content =
+ msg.message?.conversation ||
+ msg.message?.extendedTextMessage?.text ||
+ msg.message?.imageMessage?.caption ||
+ msg.message?.videoMessage?.caption ||
+ '';
+
+ // Skip protocol messages with no text content (encryption keys, read receipts, etc.)
+ // but allow voice messages through for transcription
+ if (!content && !isVoiceMessage(msg)) continue;
+
+ const sender = msg.key.participant || msg.key.remoteJid || '';
+ const senderName = msg.pushName || sender.split('@')[0];
+
+ const fromMe = msg.key.fromMe || false;
+ // Detect bot messages: with own number, fromMe is reliable
+ // since only the bot sends from that number.
+ // With shared number, bot messages carry the assistant name prefix
+ // (even in DMs/self-chat) so we check for that.
+ const isBotMessage = ASSISTANT_HAS_OWN_NUMBER
+ ? fromMe
+ : content.startsWith(`${ASSISTANT_NAME}:`);
+
+ // Transcribe voice messages before storing
+ let finalContent = content;
+ if (isVoiceMessage(msg)) {
+ try {
+ const transcript = await transcribeAudioMessage(msg, this.sock);
+ if (transcript) {
+ finalContent = `[Voice: ${transcript}]`;
+ logger.info(
+ { chatJid, length: transcript.length },
+ 'Transcribed voice message',
+ );
+ } else {
+ finalContent = '[Voice Message - transcription unavailable]';
+ }
+ } catch (err) {
+ logger.error({ err }, 'Voice transcription error');
+ finalContent = '[Voice Message - transcription failed]';
+ }
+ }
+
+ this.opts.onMessage(chatJid, {
+ id: msg.key.id || '',
+ chat_jid: chatJid,
+ sender,
+ sender_name: senderName,
+ content: finalContent,
+ timestamp,
+ is_from_me: fromMe,
+ is_bot_message: isBotMessage,
+ });
+ }
+ }
+ });
+ }
+
+ async sendMessage(jid: string, text: string): Promise {
+ // Prefix bot messages with assistant name so users know who's speaking.
+ // On a shared number, prefix is also needed in DMs (including self-chat)
+ // to distinguish bot output from user messages.
+ // Skip only when the assistant has its own dedicated phone number.
+ const prefixed = ASSISTANT_HAS_OWN_NUMBER
+ ? text
+ : `${ASSISTANT_NAME}: ${text}`;
+
+ if (!this.connected) {
+ this.outgoingQueue.push({ jid, text: prefixed });
+ logger.info(
+ { jid, length: prefixed.length, queueSize: this.outgoingQueue.length },
+ 'WA disconnected, message queued',
+ );
+ return;
+ }
+ try {
+ await this.sock.sendMessage(jid, { text: prefixed });
+ logger.info({ jid, length: prefixed.length }, 'Message sent');
+ } catch (err) {
+ // If send fails, queue it for retry on reconnect
+ this.outgoingQueue.push({ jid, text: prefixed });
+ logger.warn(
+ { jid, err, queueSize: this.outgoingQueue.length },
+ 'Failed to send, message queued',
+ );
+ }
+ }
+
+ isConnected(): boolean {
+ return this.connected;
+ }
+
+ ownsJid(jid: string): boolean {
+ return jid.endsWith('@g.us') || jid.endsWith('@s.whatsapp.net');
+ }
+
+ async disconnect(): Promise {
+ this.connected = false;
+ this.sock?.end(undefined);
+ }
+
+ async setTyping(jid: string, isTyping: boolean): Promise {
+ try {
+ const status = isTyping ? 'composing' : 'paused';
+ logger.debug({ jid, status }, 'Sending presence update');
+ await this.sock.sendPresenceUpdate(status, jid);
+ } catch (err) {
+ logger.debug({ jid, err }, 'Failed to update typing status');
+ }
+ }
+
+ /**
+ * Sync group metadata from WhatsApp.
+ * Fetches all participating groups and stores their names in the database.
+ * Called on startup, daily, and on-demand via IPC.
+ */
+ async syncGroupMetadata(force = false): Promise {
+ if (!force) {
+ const lastSync = getLastGroupSync();
+ if (lastSync) {
+ const lastSyncTime = new Date(lastSync).getTime();
+ if (Date.now() - lastSyncTime < GROUP_SYNC_INTERVAL_MS) {
+ logger.debug({ lastSync }, 'Skipping group sync - synced recently');
+ return;
+ }
+ }
+ }
+
+ try {
+ logger.info('Syncing group metadata from WhatsApp...');
+ const groups = await this.sock.groupFetchAllParticipating();
+
+ let count = 0;
+ for (const [jid, metadata] of Object.entries(groups)) {
+ if (metadata.subject) {
+ updateChatName(jid, metadata.subject);
+ count++;
+ }
+ }
+
+ setLastGroupSync();
+ logger.info({ count }, 'Group metadata synced');
+ } catch (err) {
+ logger.error({ err }, 'Failed to sync group metadata');
+ }
+ }
+
+ private async translateJid(jid: string): Promise {
+ if (!jid.endsWith('@lid')) return jid;
+ const lidUser = jid.split('@')[0].split(':')[0];
+
+ // Check local cache first
+ const cached = this.lidToPhoneMap[lidUser];
+ if (cached) {
+ logger.debug(
+ { lidJid: jid, phoneJid: cached },
+ 'Translated LID to phone JID (cached)',
+ );
+ return cached;
+ }
+
+ // Query Baileys' signal repository for the mapping
+ try {
+ const pn = await this.sock.signalRepository?.lidMapping?.getPNForLID(jid);
+ if (pn) {
+ const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`;
+ this.lidToPhoneMap[lidUser] = phoneJid;
+ logger.info(
+ { lidJid: jid, phoneJid },
+ 'Translated LID to phone JID (signalRepository)',
+ );
+ return phoneJid;
+ }
+ } catch (err) {
+ logger.debug({ err, jid }, 'Failed to resolve LID via signalRepository');
+ }
+
+ return jid;
+ }
+
+ private async flushOutgoingQueue(): Promise {
+ if (this.flushing || this.outgoingQueue.length === 0) return;
+ this.flushing = true;
+ try {
+ logger.info(
+ { count: this.outgoingQueue.length },
+ 'Flushing outgoing message queue',
+ );
+ while (this.outgoingQueue.length > 0) {
+ const item = this.outgoingQueue.shift()!;
+ // Send directly — queued items are already prefixed by sendMessage
+ await this.sock.sendMessage(item.jid, { text: item.text });
+ logger.info(
+ { jid: item.jid, length: item.text.length },
+ 'Queued message sent',
+ );
+ }
+ } finally {
+ this.flushing = false;
+ }
+ }
+}
diff --git a/.agent/skills/add-voice-transcription/modify/src/channels/whatsapp.ts.intent.md b/.agent/skills/add-voice-transcription/modify/src/channels/whatsapp.ts.intent.md
new file mode 100644
index 0000000..67a5719
--- /dev/null
+++ b/.agent/skills/add-voice-transcription/modify/src/channels/whatsapp.ts.intent.md
@@ -0,0 +1,31 @@
+# Intent: src/channels/whatsapp.ts modifications
+
+## What changed
+
+Added voice message transcription support. When a WhatsApp voice note (PTT audio) arrives, it is downloaded and transcribed via OpenAI Whisper before being stored as message content.
+
+## Key sections
+
+### Imports (top of file)
+
+- Added: `isVoiceMessage`, `transcribeAudioMessage` from `../transcription.js`
+
+### messages.upsert handler (inside connectInternal)
+
+- Added: `let finalContent = content` variable to allow voice transcription to override text content
+- Added: `isVoiceMessage(msg)` check after content extraction
+- Added: try/catch block calling `transcribeAudioMessage(msg, this.sock)`
+ - Success: `finalContent = '[Voice: ]'`
+ - Null result: `finalContent = '[Voice Message - transcription unavailable]'`
+ - Error: `finalContent = '[Voice Message - transcription failed]'`
+- Changed: `this.opts.onMessage()` call uses `finalContent` instead of `content`
+
+## Invariants (must-keep)
+
+- All existing message handling (conversation, extendedTextMessage, imageMessage, videoMessage) unchanged
+- Connection lifecycle (connect, reconnect, disconnect) unchanged
+- LID translation logic unchanged
+- Outgoing message queue unchanged
+- Group metadata sync unchanged
+- sendMessage prefix logic unchanged
+- setTyping, ownsJid, isConnected — all unchanged
diff --git a/.agent/skills/add-voice-transcription/tests/voice-transcription.test.ts b/.agent/skills/add-voice-transcription/tests/voice-transcription.test.ts
new file mode 100644
index 0000000..76ebd0d
--- /dev/null
+++ b/.agent/skills/add-voice-transcription/tests/voice-transcription.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, it } from 'vitest';
+import fs from 'fs';
+import path from 'path';
+
+describe('voice-transcription skill package', () => {
+ const skillDir = path.resolve(__dirname, '..');
+
+ it('has a valid manifest', () => {
+ const manifestPath = path.join(skillDir, 'manifest.yaml');
+ expect(fs.existsSync(manifestPath)).toBe(true);
+
+ const content = fs.readFileSync(manifestPath, 'utf-8');
+ expect(content).toContain('skill: voice-transcription');
+ expect(content).toContain('version: 1.0.0');
+ expect(content).toContain('openai');
+ expect(content).toContain('OPENAI_API_KEY');
+ });
+
+ it('has all files declared in adds', () => {
+ const transcriptionFile = path.join(skillDir, 'add', 'src', 'transcription.ts');
+ expect(fs.existsSync(transcriptionFile)).toBe(true);
+
+ const content = fs.readFileSync(transcriptionFile, 'utf-8');
+ expect(content).toContain('transcribeAudioMessage');
+ expect(content).toContain('isVoiceMessage');
+ expect(content).toContain('transcribeWithOpenAI');
+ expect(content).toContain('downloadMediaMessage');
+ expect(content).toContain('readEnvFile');
+ });
+
+ it('has all files declared in modifies', () => {
+ const whatsappFile = path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts');
+ const whatsappTestFile = path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts');
+
+ expect(fs.existsSync(whatsappFile)).toBe(true);
+ expect(fs.existsSync(whatsappTestFile)).toBe(true);
+ });
+
+ it('has intent files for modified files', () => {
+ expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts.intent.md'))).toBe(true);
+ expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts.intent.md'))).toBe(true);
+ });
+
+ it('modified whatsapp.ts preserves core structure', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts'),
+ 'utf-8',
+ );
+
+ // Core class and methods preserved
+ expect(content).toContain('class WhatsAppChannel');
+ expect(content).toContain('implements Channel');
+ expect(content).toContain('async connect()');
+ expect(content).toContain('async sendMessage(');
+ expect(content).toContain('isConnected()');
+ expect(content).toContain('ownsJid(');
+ expect(content).toContain('async disconnect()');
+ expect(content).toContain('async setTyping(');
+ expect(content).toContain('async syncGroupMetadata(');
+ expect(content).toContain('private async translateJid(');
+ expect(content).toContain('private async flushOutgoingQueue(');
+
+ // Core imports preserved
+ expect(content).toContain('ASSISTANT_HAS_OWN_NUMBER');
+ expect(content).toContain('ASSISTANT_NAME');
+ expect(content).toContain('STORE_DIR');
+ });
+
+ it('modified whatsapp.ts includes transcription integration', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts'),
+ 'utf-8',
+ );
+
+ // Transcription imports
+ expect(content).toContain("import { isVoiceMessage, transcribeAudioMessage } from '../transcription.js'");
+
+ // Voice message handling
+ expect(content).toContain('isVoiceMessage(msg)');
+ expect(content).toContain('transcribeAudioMessage(msg, this.sock)');
+ expect(content).toContain('finalContent');
+ expect(content).toContain('[Voice:');
+ expect(content).toContain('[Voice Message - transcription unavailable]');
+ expect(content).toContain('[Voice Message - transcription failed]');
+ });
+
+ it('modified whatsapp.test.ts includes transcription mock and tests', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts'),
+ 'utf-8',
+ );
+
+ // Transcription mock
+ expect(content).toContain("vi.mock('../transcription.js'");
+ expect(content).toContain('isVoiceMessage');
+ expect(content).toContain('transcribeAudioMessage');
+
+ // Voice transcription test cases
+ expect(content).toContain('transcribes voice messages');
+ expect(content).toContain('falls back when transcription returns null');
+ expect(content).toContain('falls back when transcription throws');
+ expect(content).toContain('[Voice: Hello this is a voice message]');
+ });
+
+ it('modified whatsapp.test.ts preserves all existing test sections', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts'),
+ 'utf-8',
+ );
+
+ // All existing test describe blocks preserved
+ expect(content).toContain("describe('connection lifecycle'");
+ expect(content).toContain("describe('authentication'");
+ expect(content).toContain("describe('reconnection'");
+ expect(content).toContain("describe('message handling'");
+ expect(content).toContain("describe('LID to JID translation'");
+ expect(content).toContain("describe('outgoing message queue'");
+ expect(content).toContain("describe('group metadata sync'");
+ expect(content).toContain("describe('ownsJid'");
+ expect(content).toContain("describe('setTyping'");
+ expect(content).toContain("describe('channel properties'");
+ });
+});
diff --git a/.agent/skills/agent-setup/SKILL.md b/.agent/skills/agent-setup/SKILL.md
new file mode 100644
index 0000000..726ba58
--- /dev/null
+++ b/.agent/skills/agent-setup/SKILL.md
@@ -0,0 +1,88 @@
+---
+name: agent-setup
+description: Set up the current Clawdie runtime on FreeBSD using the host orchestrator plus db/git/cms/worker jails. Use when bringing up a fresh FreeBSD host or aligning an install with current main.
+---
+
+# Agent FreeBSD Setup
+
+Use this skill for the active FreeBSD deployment model on current `main`.
+
+## Current architecture
+
+Clawdie no longer uses a dedicated operator jail.
+
+Current layout:
+
+- host: orchestrator, service management, setup flow
+- `{agent}-db` jail: PostgreSQL for Agent System Skills and User/Agent Memory
+- `{agent}-git` jail: local bare repositories
+- `{agent}-cms` jail: nginx + Astro (Strapi is optional when used)
+- `{agent}-worker` jail: baseline sandbox at `${AGENT_SUBNET_BASE}.101`
+
+## Canonical addresses
+
+- `warden0`: `${AGENT_SUBNET_BASE}.1/24`
+- `.2`: reserved compatibility slot (legacy controlplane naming)
+- `{agent}-db`: `${AGENT_SUBNET_BASE}.3`
+- `{agent}-cms`: `${AGENT_SUBNET_BASE}.4`
+- `{agent}-ollama` or `{agent}-llamacpp`: `${AGENT_SUBNET_BASE}.5` (optional)
+- `{agent}-git`: `${AGENT_SUBNET_BASE}.6` (optional)
+- `{agent}-worker`: `${AGENT_SUBNET_BASE}.101`
+- browser/gui: `${AGENT_SUBNET_BASE}.150` (reserved)
+
+Notes:
+
+- Service jails should be **agent-prefixed** to avoid name collisions when a
+ second agent is added later.
+- If you intentionally want to share a local LLM jail across agents, keep a
+ single `ollama`/`llamacpp` jail and point multiple agents at it explicitly.
+- For the “add a second agent later” procedure, follow `docs/public/reference/multi-agent.md`.
+
+## Canonical setup flow
+
+```sh
+./setup.sh
+just install
+```
+
+## Host prerequisites
+
+```sh
+freebsd-version
+zpool status zroot
+sysctl net.inet.ip.forwarding
+sysctl kern.racct.enable
+```
+
+Install the baseline:
+
+```sh
+sudo pkg install -y bash git bsddialog bastille node24 npm tmux python311 uv ripgrep fd-find rsync postgresql18-client py311-pillow dejavu rust sanoid
+npm install -g @earendil-works/pi-coding-agent
+```
+
+## tmux baseline for `pi`
+
+```sh
+cat >> ~/.tmux.conf << 'EOF'
+set -g extended-keys on
+set -g extended-keys-format csi-u
+EOF
+tmux source-file ~/.tmux.conf 2>/dev/null || true
+```
+
+## ZFS and networking
+
+Delegate to:
+
+- `/warden-zfs` for dataset layout and snapshots
+- `/bastille-network` for `warden0`
+- `/warden-pf` for NAT and port forwards
+
+## Safe defaults
+
+- keep SSH on the host
+- keep Ansible aimed at the host, not inside jails
+- keep `db` mandatory for split-brain
+- keep `git` and `cms` as first-class service jails
+- keep `.home.arpa` for internal naming and `.invalid` as the public placeholder
diff --git a/.agent/skills/ansible-freebsd/SKILL.md b/.agent/skills/ansible-freebsd/SKILL.md
new file mode 100644
index 0000000..d63853f
--- /dev/null
+++ b/.agent/skills/ansible-freebsd/SKILL.md
@@ -0,0 +1,306 @@
+---
+name: ansible-freebsd
+description: Use Ansible for repeatable FreeBSD host and Bastille jail operations in Clawdie. Use when turning FreeBSD admin work into playbooks, inventories, and roles, including nginx, host networking, git/db/cms jail bootstrap, and Astro/Strapi deployment.
+---
+
+# Ansible FreeBSD
+
+Use this skill when a FreeBSD host workflow should become repeatable Ansible
+automation instead of ad hoc shell commands.
+
+## Addressing note
+
+All IPs are derived from `AGENT_SUBNET_BASE` (set in `.env`). Default for agent 1
+is `10.0.1`. Each additional agent gets its own `/24`: agent 2 → `10.0.2`, etc.
+
+Fixed layout within each agent subnet:
+
+- `.1` = warden0 gateway
+- `.2` = `{agent}-controlplane` (reserved)
+- `.3` = `{agent}-db` (PostgreSQL)
+- `.4` = `{agent}-cms` (Astro + Strapi)
+- `.5` = `{agent}-ollama` or `{agent}-llamacpp` (optional local LLM)
+- `.6` = `{agent}-git` (optional, `FEATURE_GIT=true`)
+
+Examples below use `10.0.1.x` (agent 1). Use `{{ agent_subnet_base }}.x` in playbooks.
+
+This skill complements existing host/jail skills:
+
+- `freebsd-admin` defines host-level intent and safety rules
+- `warden-bootstrap` defines Bastille jail bootstrap shape
+- `nginx` defines web-serving behavior
+- `astro` and `strapi` define the cms application architecture
+
+This skill turns those operational patterns into Ansible structure.
+
+## Scope
+
+This skill covers:
+
+- Ansible inventory layout for the FreeBSD host
+- host playbooks for packages, `sysrc`, `sysctl`, services, and config files
+- Bastille jail creation orchestration through explicit commands
+- `git` jail bootstrap for local code storage (optional, `FEATURE_GIT=true`)
+- `{agent}-cms` jail bootstrap for Astro + Strapi on `${AGENT_SUBNET_BASE}.4`
+- nginx deployment handoff for static Astro output
+
+This skill does not replace:
+
+- `freebsd-admin` for host policy decisions
+- `warden-pf` for firewall design
+- `warden-zfs` for dataset design
+- `nginx` for site architecture decisions
+
+## Core rules
+
+- keep Ansible boring and explicit
+- prefer idempotent tasks, but do not force fake abstractions over Bastille or ZFS
+- shell out for `bastille`, `zfs`, and `bastille cmd` when that is the clearest path
+- keep host tasks and jail tasks clearly separated
+- validate after each major phase
+- connect Ansible to the FreeBSD host by SSH, not to Bastille jails by default
+
+## Initial repository shape
+
+Create infrastructure under:
+
+```text
+infra/ansible/
+ inventories/
+ production/
+ hosts.yaml
+ group_vars/
+ host_vars/
+ playbooks/
+ host-preflight.yaml
+ base-host.yaml
+ host-nginx.yaml
+ host-pf-baseline.yaml
+ db-memory-bootstrap.yaml
+ git-jail-bootstrap.yaml
+ jail-cms-create.yaml
+ jail-cms-bootstrap.yaml
+ cms-strapi-bootstrap.yaml
+ cms-astro-bootstrap.yaml
+ cms-deploy.yaml
+ roles/
+ freebsd_base/
+ freebsd_network/
+ nginx_host/
+ bastille_jail/
+ cms_jail/
+```
+
+Start small. The first useful milestone is not full automation. It is:
+
+1. host preflight
+2. `cms` jail creation
+3. `cms` jail package bootstrap
+4. optional `git` jail bootstrap for local code storage
+
+## Workflow
+
+1. Read `references/install.md` before assuming Ansible is available on the host
+2. Read `references/layout.md`
+3. Read `references/cms-astro-strapi-plan.md` if the target is the `cms` jail on `${AGENT_SUBNET_BASE}.4`
+4. Read `references/host-encrypted-dataset.md` before assuming screenshot encryption can be bootstrapped automatically
+5. Reuse existing skill assumptions instead of inventing new architecture
+6. Create or update only the smallest playbooks needed for the current step
+7. Keep commands auditable and close to existing manual docs
+
+## Recommended first Ansible targets
+
+### `host-preflight.yaml`
+
+Validate:
+
+- FreeBSD host
+- Bastille installed
+- `15.0-RELEASE` available
+- `warden0` exists with `{{ agent_subnet_base }}.1/24`
+- forwarding enabled or clearly missing
+- encrypted screenshot dataset exists and is mounted correctly
+
+Important:
+
+- `host-preflight.yaml` does not create the encrypted ZFS dataset
+- use `references/host-encrypted-dataset.md` for the one-time host bootstrap
+
+### `base-host.yaml`
+
+Apply the first repeatable host baseline for current `main`:
+
+- host package baseline: `bash`, `bsddialog`, `bastille`, `git`, `tmux`,
+ `python311`, `uv`, `ripgrep`, `fd-find`, `rsync`, `postgresql18-client`,
+ `node24`, `npm`, `py311-pillow`, `dejavu`
+- `gateway_enable="YES"`
+- immediate forwarding enablement
+- Bastille resolver baseline file
+- host validation after change
+
+This playbook is the default Ansible handoff target for `freebsd-admin`.
+
+### `jail-cms-create.yaml`
+
+Create the `cms` jail with the proven pattern:
+
+```text
+bastille create -T -B -g ${AGENT_SUBNET_BASE}.1 ${AGENT_NAME}-cms 15.0-RELEASE {{ agent_subnet_base }}.4/24 warden0
+```
+
+Then:
+
+- set hostname to `cms..home.arpa`
+- restart jail
+- validate route and reachability to `db` at `${AGENT_SUBNET_BASE}.3`
+
+### `jail-cms-bootstrap.yaml`
+
+Inside `cms`:
+
+- install `bash`, `nginx`, `node24`, `npm`, `git`, `rsync`, `postgresql18-client`
+- ensure `clawdie` user exists
+- create `/home/clawdie/strapi`
+- create `/home/clawdie/clawdie-docs`
+
+### `db-memory-bootstrap.yaml`
+
+Bootstrap the `db` jail as the PostgreSQL 18 memory backend:
+
+- install `postgresql18-server`, `postgresql18-client`, `postgresql18-pgvector`, and `postgresql18-contrib`
+- initialize `data` directory if missing
+- start PostgreSQL
+- create the `clawdie` database
+- create and password the split-brain PostgreSQL roles plus `strapi_cms`
+- enable `pgcrypto`, `uuid-ossp`, and `vector`
+- set `listen_addresses` and `pg_hba.conf`
+- validate with `sockstat`, `show listen_addresses`, and `\\dx`
+
+### `git-jail-bootstrap.yaml`
+
+Bootstrap a dedicated `git` jail for plain local git storage:
+
+- create the `git` jail when `FEATURE_GIT=true`
+- assign the reserved address from `AGENT_SUBNET_BASE` (`.6` by default, configurable)
+- install `git`
+- create `/srv/git`
+- optionally create `Clawdie-AI.git`
+- validate hostname and package presence
+
+This is intentionally the plain-git first stage:
+
+- no Gitea
+- no public web UI
+- no CI
+
+See [GIT-STORAGE.md](../../../docs/public/operate/git-storage.md) for
+the current scope and workflow. The installer may reserve `Local Gitea` as a
+future mode, but this playbook currently bootstraps only bare local git
+storage.
+
+### `host-nginx.yaml`
+
+Apply the basic host nginx baseline:
+
+- install package
+- persist service enablement
+- validate config
+- reload and check status
+
+### `host-pf-baseline.yaml`
+
+Apply the minimal Warden PF include and validation path:
+
+- write one Warden include
+- ensure `pf.conf` includes it
+- validate with `pfctl -nf`
+- reload PF and inspect NAT/filter state
+
+### `cms-strapi-bootstrap.yaml`
+
+Coordinate:
+
+- PostgreSQL setup in `db`
+- prefer `db-memory-bootstrap.yaml` first when the `db` jail is not already prepared
+- `pg_hba.conf` entry for `{{ agent_subnet_base }}.3/32`
+- Strapi install in `cms`
+- initial env/config
+
+### `cms-astro-bootstrap.yaml`
+
+Inside `cms`:
+
+- create Astro project
+- install required packages
+- prepare build/deploy script
+
+### `cms-deploy.yaml`
+
+Deploy Astro output to the `cms` jail webroot:
+
+- back up `/srv/www/`
+- sync built output from `cms`
+- validate local jail nginx first, then the chosen edge mode
+
+## Safe defaults
+
+- prefer FreeBSD `pkg` install for host Ansible unless there is a clear reason not to
+- treat `uv tool install ansible` as a fallback path that may fail on FreeBSD/Python packaging
+- keep one SSH trust path: control machine -> FreeBSD host
+- manage `cms`, `db`, and other jails from the host through `bastille`, `bastille cmd`, and jail root paths
+- do not enable jail `sshd` just to satisfy Ansible unless there is a real separate-ops need
+- keep host nginx optional; default Clawdie web serving lives in `cms`
+- keep `db` at `${AGENT_SUBNET_BASE}.3` for PostgreSQL only
+- keep `git` optional (`FEATURE_GIT=true`), does not occupy a fixed address
+- keep `cms` at `${AGENT_SUBNET_BASE}.4` for Astro + Strapi only
+- do not expose Strapi admin publicly by default
+- keep rollback simple with snapshots and webroot backup
+
+## SSH model
+
+Default model:
+
+- Ansible SSHes into the FreeBSD host
+- the host manages Bastille jails locally
+- jail tasks run through `bastille`, `bastille cmd`, package commands, and direct file placement in jail roots
+
+Why:
+
+- avoids SSH key sprawl across jails
+- avoids enabling `sshd` in service jails unnecessarily
+- keeps jails as host-managed runtime units rather than pretending they are separate servers
+
+Optional model (separate ops boundary):
+
+- enable `sshd` inside service jails and install the operator key with
+ `infra/ansible/playbooks/jails-ssh-baseline.yaml`
+- optionally expose jail SSH via PF with predictable host ports (derived from
+ the jail IP last octet): `.2 → :2222`, `.3 → :2223`, `.4 → :2224`, `.5 → :2225`, `.6 → :2226`
+ (see `infra/ansible/playbooks/host-pf-baseline.yaml` + `jails_ssh_expose_via_pf`)
+
+This is intentionally opt-in; the default Clawdie model remains host-managed
+jails without direct SSH access.
+
+## When to use Ansible here
+
+Use this skill when:
+
+- the same FreeBSD admin action will be repeated
+- jail creation should become reproducible
+- `cms` setup is stabilizing into known phases
+- nginx/site deploys should stop depending on manual copy-paste
+
+When the task starts as host-level policy or troubleshooting intent, read
+`freebsd-admin` first, then use this skill to codify the proven host change.
+
+Do not use Ansible just to hide one command. Use it when the workflow benefits
+from repeatability, validation, and versioned structure.
+
+## File naming convention
+
+Use `.yaml` consistently for all Ansible files in this repository:
+
+- inventories
+- playbooks
+- vars files
+- role task files
diff --git a/.agent/skills/ansible-freebsd/references/cms-astro-strapi-plan.md b/.agent/skills/ansible-freebsd/references/cms-astro-strapi-plan.md
new file mode 100644
index 0000000..6354edb
--- /dev/null
+++ b/.agent/skills/ansible-freebsd/references/cms-astro-strapi-plan.md
@@ -0,0 +1,142 @@
+# CMS Jail Astro / Strapi Ansible Plan
+
+Use this reference when implementing Ansible for the Astro + Strapi deployment
+path in the `cms` jail at `{{ agent_subnet_base }}.4`.
+
+## Architecture baseline
+
+- cms jail nginx is the default Clawdie-owned web server
+- `db` jail at `{{ agent_subnet_base }}.3` runs PostgreSQL only
+- `cms` jail at `{{ agent_subnet_base }}.4` runs nginx + Strapi + Astro
+- public delivery is an edge-mode choice, not a host-nginx requirement
+- `cms..home.arpa` can proxy to Strapi admin and remain Tailscale-only
+
+## Implementation phases
+
+### Phase 1. Host preflight
+
+Create `playbooks/host-preflight.yaml`.
+
+Validate:
+
+- FreeBSD host
+- Bastille present
+- release `15.0-RELEASE` available
+- `warden0` exists
+- `warden0` has `{{ agent_subnet_base }}.1/24`
+- `gateway_enable` and `net.inet.ip.forwarding`
+
+Output should clearly identify missing prerequisites before any jail work.
+
+### Phase 2. Create the cms jail
+
+Create `playbooks/jail-cms-create.yaml`.
+
+Tasks:
+
+- optional snapshot before creation
+- detect whether `cms` already exists
+- create with:
+ - thick jail
+ - VNET
+ - bridge `warden0`
+ - gateway `{{ agent_subnet_base }}.1`
+ - IP `{{ agent_subnet_base }}.4/24`
+- set hostname to `cms..home.arpa`
+- restart jail
+- validate:
+ - `hostname`
+ - `ifconfig`
+ - `netstat -rn`
+ - ping `{{ agent_subnet_base }}.3`
+
+### Phase 3. Bootstrap cms packages
+
+Create `playbooks/jail-cms-bootstrap.yaml`.
+
+Inside `cms`:
+
+- install `node24`
+- install `npm`
+- install `git`
+- install `postgresql18-client`
+- ensure `clawdie` user and home
+- create:
+ - `/home/clawdie/strapi`
+ - `/home/clawdie/clawdie-docs`
+
+### Phase 4. Bootstrap Strapi
+
+Create `playbooks/cms-strapi-bootstrap.yaml`.
+
+Host-side work against `db` and `cms`:
+
+- create `strapi_cms` database in `db`
+- create `strapi_cms`
+- set password from Ansible vars
+- grant privileges
+- add `pg_hba.conf` line for `{{ agent_subnet_base }}.4/32`
+- reload PostgreSQL
+- install Strapi in `/home/clawdie/strapi`
+- write Strapi env/config
+
+Validation:
+
+- `psql` from `cms` to `db`
+- Strapi starts on `{{ agent_subnet_base }}.4:1337`
+
+### Phase 5. Bootstrap Astro
+
+Create `playbooks/cms-astro-bootstrap.yaml`.
+
+Inside `cms`:
+
+- create Astro project at `/home/clawdie/clawdie-docs`
+- install Astro dependencies
+- prepare deploy script
+- configure Strapi endpoint as `http://localhost:1337/api`
+
+### Phase 6. Deploy to the cms jail web stack
+
+Create `playbooks/cms-deploy.yaml`.
+
+Tasks:
+
+- verify Astro project builds
+- back up `/srv/www/` to `/srv/www.bak/`
+- sync built output into `/srv/www/`
+- validate local cms nginx first
+- optionally configure one edge mode later
+
+## Variable model
+
+Use a small starting variable set:
+
+```yaml
+cms_jail_name: cms
+cms_jail_ip: {{ agent_subnet_base }}.4
+cms_hostname: cms..home.arpa
+db_jail_ip: {{ agent_subnet_base }}.3
+warden_bridge: warden0
+warden_gateway: {{ agent_subnet_base }}.1
+astro_site_path: /home/clawdie/clawdie-docs
+strapi_path: /home/clawdie/strapi
+clawdie_webroot: /srv/www
+```
+
+## First milestone
+
+The first successful Ansible milestone is:
+
+1. `host-preflight.yaml`
+2. `jail-cms-create.yaml`
+3. `jail-cms-bootstrap.yaml`
+
+Do not try to automate nginx, Strapi, Astro, and deployment all at once.
+
+## Success criteria
+
+- `cms` jail reproducibly created on `{{ agent_subnet_base }}.4`
+- `cms` can reach `db` on `{{ agent_subnet_base }}.3`
+- package bootstrap is repeatable
+- later app bootstrap can build on stable infrastructure
diff --git a/.agent/skills/ansible-freebsd/references/host-encrypted-dataset.md b/.agent/skills/ansible-freebsd/references/host-encrypted-dataset.md
new file mode 100644
index 0000000..fdf06f5
--- /dev/null
+++ b/.agent/skills/ansible-freebsd/references/host-encrypted-dataset.md
@@ -0,0 +1,80 @@
+# Host Encrypted Dataset
+
+Use this one-time host bootstrap to create the encrypted ZFS dataset used for
+private screenshot storage.
+
+Do not try to create this dataset from `host-preflight.yaml`.
+
+`host-preflight.yaml` only validates that:
+
+- `zroot//encrypted` exists
+- its mountpoint is `/encrypted`
+- `encrypted/screenshots/` exists and is writable by the operator user
+
+## Why Manual Bootstrap
+
+This dataset uses passphrase-based ZFS encryption.
+
+Creating it correctly requires an explicit host-side operator step:
+
+- choose a passphrase interactively
+- confirm the mountpoint
+- set ownership for the operator user
+
+That is not a good fit for non-interactive Ansible preflight.
+
+## One-Time Creation
+
+Run on the FreeBSD host as `root`.
+
+Replace:
+
+- `` with the actual ZFS prefix, for example `clawdie-runtime`
+- `` with the repo path, for example `/home/clawdija/clawdie-ai`
+- `` with the operator user, for example `clawdie`
+
+```sh
+zfs list zroot//encrypted >/dev/null 2>&1 || \
+zfs create \
+ -o encryption=on \
+ -o keyformat=passphrase \
+ -o keylocation=prompt \
+ -o mountpoint=/encrypted \
+ zroot//encrypted
+```
+
+Create the private screenshots directory and make it writable by the operator:
+
+```sh
+install -d -o -g wheel -m 0750 /encrypted/screenshots
+```
+
+## Validation
+
+Check dataset and mountpoint:
+
+```sh
+zfs list zroot//encrypted
+zfs get mountpoint zroot//encrypted
+```
+
+Check ownership:
+
+```sh
+ls -ld /encrypted
+ls -ld /encrypted/screenshots
+```
+
+Expected:
+
+- dataset exists
+- mountpoint is `/encrypted`
+- `encrypted/screenshots` is owned by the operator user
+
+## Operational Rule
+
+If the encrypted dataset is missing or mounted elsewhere:
+
+- do not run screenshot capture expecting private copies
+- fix the host dataset first
+- rerun `host-preflight.yaml`
diff --git a/.agent/skills/ansible-freebsd/references/install.md b/.agent/skills/ansible-freebsd/references/install.md
new file mode 100644
index 0000000..ceb80be
--- /dev/null
+++ b/.agent/skills/ansible-freebsd/references/install.md
@@ -0,0 +1,41 @@
+# Installing Ansible on the FreeBSD Host
+
+Use this reference before building or running Clawdie Ansible playbooks.
+
+## Recommended default
+
+Prefer the native FreeBSD package:
+
+```sh
+sudo pkg update
+sudo pkg install sysutils/ansible
+ansible --version
+ansible-playbook --version
+```
+
+Why:
+
+- boring host path
+- avoids Python build surprises
+- fits the host-managed Clawdie model
+- avoids inventing a jail-local Ansible control node
+
+## Fallback path
+
+`uv` can work, but it is not the default on FreeBSD:
+
+```sh
+sudo uv tool install --python 3.11 ansible
+```
+
+Only use that path if native `pkg` is blocked and the host Python line is
+already understood.
+
+## Operational rule
+
+Keep one SSH trust path:
+
+- Ansible SSHes into the FreeBSD host
+- the host manages Bastille jails locally
+
+Do not install or enable jail `sshd` just to satisfy Ansible.
diff --git a/.agent/skills/ansible-freebsd/references/layout.md b/.agent/skills/ansible-freebsd/references/layout.md
new file mode 100644
index 0000000..d136e6f
--- /dev/null
+++ b/.agent/skills/ansible-freebsd/references/layout.md
@@ -0,0 +1,85 @@
+# Ansible Layout for Clawdie FreeBSD Operations
+
+Use this reference when creating the first Ansible structure in the repository.
+
+## Goal
+
+Keep the layout obvious for both humans and agents.
+
+```text
+infra/ansible/
+ inventories/
+ production/
+ hosts.yaml
+ group_vars/
+ host_vars/
+ playbooks/
+ roles/
+```
+
+Use `.yaml` consistently throughout this tree.
+
+## Inventory model
+
+Recommended starting model:
+
+- one production host
+- no premature multi-host complexity
+- jail operations still target the host and shell into Bastille/bastille cmd where needed
+- do not model `cms` or `db` as separate SSH inventory hosts by default
+
+Example inventory shape:
+
+```yaml
+all:
+ hosts:
+ clawdie_host:
+ ansible_host: your-hostname-or-ip
+ ansible_user: your-admin-user
+```
+
+Optional:
+
+```yaml
+ ansible_ssh_private_key_file: ~/.ssh/clawdie-ansible
+```
+
+The important rule is that Ansible reaches the host, and the host reaches the
+jails.
+
+## Playbook boundaries
+
+Keep playbooks phase-oriented:
+
+- host preflight
+- host networking
+- host nginx
+- jail creation
+- jail bootstrap
+- application bootstrap
+- deployment
+
+Do not create one giant playbook for everything.
+
+## Role boundaries
+
+Keep roles generic enough to reuse, but not abstract to the point of hiding
+what the host actually does.
+
+Good first roles:
+
+- `freebsd_base`
+- `freebsd_network`
+- `nginx_host`
+- `bastille_jail`
+- `cms_jail`
+
+## Implementation style
+
+Prefer:
+
+- `community.general.sysrc`
+- package/service/file/template modules where they fit
+- explicit `command` or `shell` for `bastille`, `zfs`, and `bastille cmd`
+
+The goal is boring reliability, not maximum abstraction.
diff --git a/.agent/skills/astro/SKILL.md b/.agent/skills/astro/SKILL.md
new file mode 100644
index 0000000..ff10ba4
--- /dev/null
+++ b/.agent/skills/astro/SKILL.md
@@ -0,0 +1,407 @@
+---
+name: astro
+description: Manage the Astro static site that powers Clawdie web surfaces. Use when building pages, configuring Strapi integration, serving from the cms jail web stack, or managing the frontend project. Triggers on "astro", "frontend", "build site", "static site", "clawdie.invalid build".
+---
+
+# Astro
+
+Use this skill for the static site generator that builds tenant homes and tenant sites from Strapi and repo content.
+
+This skill is also responsible for the transition from the current plain HTML
+site to the future Astro + Strapi setup in the `cms` jail.
+
+## Scope
+
+This skill covers:
+
+- Astro project setup and configuration
+- Strapi content integration (REST API)
+- Page and layout creation
+- Build and deployment to the `cms` jail webroot
+- Tenant home (`. `) and tenant-site (`.. `) web output
+- Design system porting from current static HTML
+- Skill page generation from SKILL.md files
+- Build automation and webhooks
+
+This skill does not replace:
+
+- `strapi` for content management
+- `nginx` for web server configuration
+
+## Architecture
+
+```
+cms jail (${AGENT_SUBNET_BASE}.4)
+ ├── /home/clawdie/clawdie-si/ ← public clawdie.si landing site
+ │ ├── source: bootstrap/cms/clawdie-si/
+ │ └── dist/ → /usr/local/www/clawdie-si/
+ │
+ ├── /home/clawdie/clawdie-docs/ ← docs.clawdie.si Starlight site
+ │ ├── source: bootstrap/cms/clawdie-docs/
+ │ ├── docs source of truth: repo docs/public/
+ │ └── dist/ → /usr/local/www/clawdie/
+ │
+ └── optional Strapi runtime (localhost:1337), not public by default
+```
+
+Astro output is pure static HTML/CSS/JS served by nginx in the `cms` jail and
+fronted by the selected edge mode. The current landing/docs sites are
+repo-native Astro projects; Strapi remains optional and should not be assumed
+for a landing-page deploy.
+
+For Starlight docs, `docs/public/` is the source of truth. The
+`bootstrap/cms/clawdie-docs/src/content/docs/` tree is generated by the
+`prebuild` sync and should not be hand-edited or committed just because a local
+build regenerated it.
+
+## Surface model
+
+- Operator app: `ai. ` — separate stack, not Astro here
+- Shared CMS host: `cms. ` — shared admin/API, not a tenant app
+- Tenant home: `. ` — Astro/static app
+- Tenant site: `.. ` — Astro/static app
+
+Default internal base: `home.arpa`.
+
+## Migration goal
+
+The immediate goal is not a redesign. It is to let the agent transform the
+current live static site into an Astro-based clone first, then gradually move
+editable content into Strapi.
+
+Recommended sequence:
+
+1. replicate the current site structure and design in Astro
+2. keep public exposure decisions separate from the content migration
+3. classify content into Astro-managed vs Strapi-managed
+4. move selected content into Strapi in batches
+5. serve Astro output from the `cms` jail webroot under tenant-home and tenant-site hostnames
+
+For the first FreeBSD deployment, keep the Astro site deliberately minimal:
+
+- do not add `sharp`
+- do not rely on Astro image optimization
+- keep images in `public/` or serve prebuilt static assets directly
+- treat `examples/astro-cv/` as a prototype/reference, not the bootstrap source
+
+## Canonical paths
+
+Repo paths:
+
+- Landing source: `bootstrap/cms/clawdie-si/`
+- Docs source: `bootstrap/cms/clawdie-docs/`
+- Public docs source of truth: `docs/public/`
+
+Inside the `cms` jail:
+
+- Landing project: `/home/clawdie/clawdie-si/`
+- Landing build output: `/home/clawdie/clawdie-si/dist/`
+- Landing deploy target: `/usr/local/www/clawdie-si/`
+- Docs project: `/home/clawdie/clawdie-docs/`
+- Docs build output: `/home/clawdie/clawdie-docs/dist/`
+- Docs deploy target: `/usr/local/www/clawdie/`
+- Strapi endpoint, when enabled: `http://localhost:1337/api`
+
+Deployment scripts:
+
+- Landing: `bootstrap/cms/clawdie-si/scripts/deploy.mjs`
+- Docs: `bootstrap/cms/clawdie-docs/scripts/deploy-docs.mjs`
+
+Build defaults:
+
+- Use `passthroughImageService()`; do not require `sharp`.
+- Keep images under `public/` as static assets.
+- Use Node 24 where possible; Node 22 has been sufficient for current Astro builds, but Node 24 is the target runtime.
+
+## Design system
+
+Port the existing public Clawdie visual identity:
+
+### Fonts
+
+- Headings: `Cormorant Garamond` (300, 400, 600 weights)
+- Monospace: `DM Mono` (300, 400 weights)
+
+### Colors (dark theme)
+
+```css
+--cream: #0d1117; /* page background */
+--amber: #00b4d8; /* accent / links */
+--amber-dark: #0096b7; /* hover states */
+--charcoal: #e2e8f0; /* headings */
+--grey: #8b949e; /* secondary text */
+--grey-light: #21262d; /* borders / dividers */
+--paper: #161b22; /* card backgrounds */
+--ink: #c9d1d9; /* body text */
+```
+
+### Components to port
+
+- Hero statement block (dark background, accent text)
+- Status note (left border, label)
+- Comparison grid (two-column, cloud vs clawdie)
+- Ecosystem cards (grid, featured variant)
+- Principle rows (numbered, icon + text)
+- CTA block (centered, button row)
+- Divider (gradient line)
+- Top nav (monospace, uppercase)
+
+## Workflow
+
+### Phase 1: Project setup
+
+1. Read `references/setup.md`
+2. Read `references/static-migration.md`
+3. Create Astro project with Strapi integration
+4. Port design system CSS
+5. Create base layout matching current site
+
+### Phase 1.5: Static-site inventory
+
+Before changing content sources:
+
+1. inspect the current live/static pages
+2. map each route to an Astro page
+3. identify shared layout pieces (nav, footer, hero, cards, guides)
+4. reproduce the current visual design as closely as practical
+
+### Phase 2: Page templates
+
+Create Astro page templates:
+
+| Template | Source | Route |
+| --------------------------- | --------------------------- | ------------------------ |
+| `pages/index.astro` | Strapi: Page (tenant home) | `/` on `. ` |
+| `pages/docs/index.astro` | Strapi: Page (docs) | `/docs/` |
+| `pages/guides/[slug].astro` | Strapi: Guide collection | `/guides/{slug}` |
+| `pages/skills/[slug].astro` | Local SKILL.md files | `/skills/{slug}` |
+| `pages/skills/index.astro` | Generated from skills list | `/skills/` |
+| `pages/blog/[slug].astro` | Strapi: BlogPost collection | `/blog/{slug}` |
+
+### Phase 3: Skill page generation
+
+Skills are imported at build time from the local filesystem:
+
+```astro
+// src/pages/skills/[slug].astro
+import { getCollection } from 'astro:content';
+
+// Read .agent/skills/*/SKILL.md files
+// Parse YAML frontmatter for metadata
+// Render markdown body as HTML
+```
+
+This keeps skills in sync with code — no manual CMS updates needed.
+
+### Phase 3.5: Content split
+
+Move only the right content into Strapi.
+
+Good Strapi candidates:
+
+- homepage editable sections
+- guides
+- docs landing pages
+- future blog/project pages
+
+Keep in Astro/repo:
+
+- skill pages generated from `SKILL.md`
+- highly technical generated docs
+- content that should always follow repository state
+
+### Phase 4: Build and deploy
+
+For the public landing page:
+
+```sh
+cd /home/clawdie/clawdie-si
+npm run build
+npm run deploy
+```
+
+For documentation:
+
+```sh
+cd /home/clawdie/clawdie-docs
+npm run build
+npm run deploy
+```
+
+Before deploying from an agent session:
+
+1. Confirm the repo is on `main` and clean.
+2. Build locally or in the jail first.
+3. Snapshot or back up the target webroot unless the operator explicitly says to skip it because of disk pressure.
+4. Deploy only `dist/` output; never delete the project source.
+5. Validate via jail-local HTTP with the correct `Host:` header and public HTTPS.
+
+### Phase 5: Automation
+
+Build triggers (choose one):
+
+**Option A: Strapi webhook**
+
+- Strapi fires webhook on content publish
+- Host script receives webhook and runs build
+- Best for content-driven updates
+
+**Option B: Cron rebuild**
+
+- `cron` runs `astro build && rsync` every 15 minutes
+- Simple, no webhook infrastructure needed
+- Good enough for low-frequency updates
+
+**Option C: Manual build**
+
+- Operator or agent runs `npm run build` when needed
+- Simplest, full control
+
+Recommended: Start with Option C, move to B, then A as needed.
+
+Keep hostname routing and nginx server_name policy in the `nginx` skill and setup code. Astro owns page output, not host classification.
+
+## FreeBSD jail install snags
+
+### sharp has no pre-built binary for FreeBSD
+
+`sharp` is an optional Astro image optimization library. It tries to build from
+source on FreeBSD, which fails because `node-addon-api` is not bundled.
+
+**Symptoms:**
+
+```
+npm error sharp: Attempting to build from source via node-gyp
+npm error sharp: Please add node-addon-api to your dependencies
+```
+
+**Fix — two steps:**
+
+1. Install without native scripts:
+
+ ```sh
+ npm install --ignore-scripts
+ ```
+
+2. Configure Astro to use the passthrough image service (no optimization):
+ ```js
+ // astro.config.mjs
+ import { defineConfig, passthroughImageService } from 'astro/config';
+ export default defineConfig({
+ image: { service: passthroughImageService() },
+ // ... rest of config
+ });
+ ```
+
+This is fine for static/docs sites. Keep images in `public/` as static assets.
+If you actually need image optimization (WebP conversion, resize), `vips` is
+available via pkg (`pkg install vips`) but getting sharp to link against it
+requires additional build steps.
+
+### npm config "python" warning
+
+You may see `Unknown env config "python"` during npm install. This is a harmless
+warning from a stale global npm config. Ignore it.
+
+### Starlight confirmed working on FreeBSD + Node 22
+
+With `--ignore-scripts` + `passthroughImageService()`, Starlight builds cleanly.
+Scaffold command: `npm create astro@latest . -- --template starlight --no-install --no-git`
+
+## Safe defaults
+
+- Always ZFS snapshot before deploying a new build
+- Keep the previous build as `.bak/` fallback before deploying new dist
+- Test build locally before deploying: `npm run preview`
+- Never delete the Astro project source — only the `dist/` output is expendable
+- Run `nginx -t` inside the cms jail after nginx config changes
+- Aim for route and visual parity before changing too much content structure
+- Always use `npm install --ignore-scripts` on FreeBSD
+- Always configure `passthroughImageService()` — never rely on sharp
+
+## Build commands
+
+Landing site:
+
+```sh
+cd bootstrap/cms/clawdie-si
+npm run build
+npm run preview
+npm run deploy
+```
+
+Docs site:
+
+```sh
+cd bootstrap/cms/clawdie-docs
+npm run build
+npm run preview
+npm run deploy
+```
+
+Inside the jail the project paths are usually `/home/clawdie/clawdie-si` and
+`/home/clawdie/clawdie-docs`; in the repository they live under
+`bootstrap/cms/`.
+
+## Deployment verification
+
+When testing the `cms` jail nginx directly, always send the public host header.
+The default jail vhost intentionally returns `404`, so a plain
+`http://127.0.0.1/sl/` check is a false negative for `clawdie.si`.
+
+Landing site checks:
+
+```sh
+sudo bastille cmd cms service nginx onestatus
+sudo bastille cmd cms curl -sI -H 'Host: clawdie.si' http://127.0.0.1/sl/
+sudo bastille cmd cms curl -sI -H 'Host: clawdie.si' http://127.0.0.1/en/
+curl -sI https://clawdie.si/sl/
+curl -sI https://clawdie.si/en/
+curl -s https://clawdie.si/sl/ | grep -F 'Ustvarjaj · Poseduj · Skaliraj'
+```
+
+Expected results:
+
+- jail-local `curl -H 'Host: clawdie.si'` returns `200`
+- public HTTPS `/sl/` and `/en/` return `200`
+- `/` may redirect to `/en/` depending on the landing nginx config
+
+Do not use FreeBSD `fetch` for the host-header check; `fetch` does not support a
+simple `--header` option. Use `curl`, which is installed in the `cms` jail.
+
+## Troubleshooting
+
+### Build fails with Strapi fetch error
+
+- Check Strapi is running: `curl -s http://10.0.0.3:1337/api`
+- Check network: can host reach jail IP?
+- Check API permissions in Strapi admin
+- Astro can build with fallback content if Strapi is down (graceful degradation)
+
+### Styles look wrong after build
+
+- Check CSS variables are ported correctly
+- Compare with the checked-in bridge HTML under `html/clawdie/`
+- Check Google Fonts imports in layout
+
+### Skill pages missing or stale
+
+- Skills are read at build time from `.agent/skills/*/SKILL.md`
+- Rebuild to pick up new or updated skills
+- Check file path pattern matches in Astro config
+
+### Deploy doesn't update site
+
+- Static files are served immediately by nginx — hard refresh browser.
+- Check rsync output for errors.
+- Landing: verify files landed in `/usr/local/www/clawdie-si/` inside `cms`.
+- Docs: verify files landed in `/usr/local/www/clawdie/` inside `cms`.
+- Check file permissions with `ls -la` on the target webroot.
+- If jail-local direct HTTP returns `404`, retry with the expected host header: `curl -sI -H 'Host: clawdie.si' http://127.0.0.1/sl/`.
+- If docs build dirties `bootstrap/cms/clawdie-docs/src/content/docs/`, remember that tree is generated from `docs/public/`; do not commit it unless intentionally changing generated-source policy.
+
+### Sharp or image build errors
+
+- Use `npm install --ignore-scripts` — never let sharp try to build from source
+- Add `passthroughImageService()` to `astro.config.mjs` — see FreeBSD snags above
+- Keep images under `public/` as static assets, skip Astro image optimization
diff --git a/.agent/skills/astro/references/setup.md b/.agent/skills/astro/references/setup.md
new file mode 100644
index 0000000..81128d8
--- /dev/null
+++ b/.agent/skills/astro/references/setup.md
@@ -0,0 +1,174 @@
+# Astro Project Setup
+
+## Prerequisites
+
+- `cms` jail running at ${AGENT_SUBNET_BASE}.4 with Node.js 24
+- Strapi running in cms jail (or plan to set up later)
+- nginx running inside `cms` and serving `/srv/www/`
+
+## Step 1: Create project (inside cms jail)
+
+```sh
+sudo bastille cmd cms su - clawdie -c "cd /home/clawdie && npm create astro@latest clawdie-docs -- --template minimal --no-install"
+sudo bastille cmd cms su - clawdie -c "cd /home/clawdie/clawdie-docs && npm install"
+```
+
+## Step 2: Install integrations
+
+```sh
+npm install @astrojs/mdx
+npm install gray-matter # for parsing SKILL.md frontmatter
+npm install marked # for rendering markdown
+```
+
+## Step 3: Configure Astro
+
+Edit `astro.config.mjs`:
+
+```js
+import { defineConfig } from 'astro/config';
+import mdx from '@astrojs/mdx';
+
+export default defineConfig({
+ site: 'https://clawdie.invalid',
+ output: 'static',
+ integrations: [mdx()],
+ build: {
+ assets: 'assets',
+ },
+});
+```
+
+## Step 4: Create base layout
+
+Create `src/layouts/Base.astro` with:
+
+- Google Fonts imports (Cormorant Garamond, DM Mono)
+- CSS variables from current design system
+- Hex background pattern SVG
+- Top nav component
+- Footer component
+
+Port directly from the current `/usr/local/www/clawdie/index.html` styles.
+
+## Step 5: Create Strapi fetch utility
+
+Create `src/lib/strapi.ts`:
+
+```ts
+const STRAPI_URL = import.meta.env.STRAPI_URL || 'http://localhost:1337';
+
+export async function fetchAPI(path: string) {
+ const res = await fetch(`${STRAPI_URL}/api${path}`);
+ if (!res.ok) {
+ console.error(`Strapi fetch failed: ${path} (${res.status})`);
+ return null;
+ }
+ const json = await res.json();
+ return json.data;
+}
+
+export async function getPage(slug: string) {
+ return fetchAPI(`/pages?filters[slug][$eq]=${slug}&populate=*`);
+}
+
+export async function getGuides() {
+ return fetchAPI('/guides?sort=order:asc&populate=*');
+}
+
+export async function getGuide(slug: string) {
+ return fetchAPI(`/guides?filters[slug][$eq]=${slug}&populate=*`);
+}
+```
+
+## Step 6: Create skill loader
+
+Create `src/lib/skills.ts`:
+
+```ts
+import fs from 'node:fs';
+import path from 'node:path';
+import matter from 'gray-matter';
+import { marked } from 'marked';
+
+// nullfs-mounted from host into cms jail
+const SKILLS_DIR = '/mnt/skills';
+
+export interface Skill {
+ name: string;
+ slug: string;
+ description: string;
+ content: string;
+ html: string;
+}
+
+export function getAllSkills(): Skill[] {
+ const dirs = fs.readdirSync(SKILLS_DIR);
+ return dirs
+ .filter(d => fs.existsSync(path.join(SKILLS_DIR, d, 'SKILL.md')))
+ .map(d => {
+ const raw = fs.readFileSync(path.join(SKILLS_DIR, d, 'SKILL.md'), 'utf-8');
+ const { data, content } = matter(raw);
+ return {
+ name: data.name || d,
+ slug: d,
+ description: data.description || '',
+ content,
+ html: marked(content) as string,
+ };
+ })
+ .sort((a, b) => a.name.localeCompare(b.name));
+}
+
+export function getSkill(slug: string): Skill | undefined {
+ return getAllSkills().find(s => s.slug === slug);
+}
+```
+
+## Step 7: Create deploy script
+
+Add to `package.json`:
+
+```json
+{
+ "scripts": {
+ "deploy": "npm run build && rsync -av --delete dist/ /srv/www/"
+ }
+}
+```
+
+## Step 8: Environment file
+
+Create `.env` inside cms jail:
+
+```
+STRAPI_URL=http://localhost:1337
+SITE_URL=https://clawdie.invalid
+NODE_OPTIONS=--max-old-space-size=512
+```
+
+## Step 9: Test build
+
+```sh
+npm run build
+ls dist/
+```
+
+Verify output contains `index.html` and expected pages.
+
+## Step 10: First deploy
+
+```sh
+# snapshot before deploy
+sudo zfs snapshot -nv zroot/ROOT/default@pre-astro-deploy
+sudo zfs snapshot zroot/ROOT/default@pre-astro-deploy
+
+# backup current jail webroot
+cp -r /srv/www /srv/www.bak
+
+# deploy
+npm run deploy
+
+# verify
+curl -sI http://127.0.0.1 | head -5
+```
diff --git a/.agent/skills/astro/references/static-migration.md b/.agent/skills/astro/references/static-migration.md
new file mode 100644
index 0000000..ec2ddce
--- /dev/null
+++ b/.agent/skills/astro/references/static-migration.md
@@ -0,0 +1,70 @@
+# Static HTML to Astro / Strapi Migration
+
+Use this reference when converting the current Clawdie public site from
+hand-edited static HTML into an Astro site with Strapi-backed editable content.
+
+## Core principle
+
+Clone first, restructure second.
+
+Do not start with a redesign. First reproduce the current public site closely
+enough that switching the build source does not feel like launching a different
+website.
+
+## Migration phases
+
+### 1. Inventory the current site
+
+Read the current static pages in `/usr/local/www/clawdie/` and classify them:
+
+- page should become an Astro template backed by Strapi
+- page should stay Astro/repo-managed
+- page can remain simple static output
+
+## 2. Capture the design system
+
+Extract and preserve:
+
+- typography
+- colors
+- spacing
+- layout rhythm
+- navigation structure
+- footer and CTA blocks
+
+## 3. Recreate route parity
+
+Target the same routes first:
+
+- `/`
+- `/docs/`
+- `/guides/*`
+
+Route parity matters before deeper content modeling.
+
+## 4. Move content selectively
+
+Good candidates for Strapi:
+
+- homepage sections needing editorial updates
+- guides
+- docs landing pages
+
+Bad candidates for Strapi:
+
+- skill pages from repository `SKILL.md`
+- generated technical references
+- content tightly coupled to code state
+
+## 5. Decouple content migration from edge ownership
+
+Build and serve the site inside `cms` first. Public edge choice comes later.
+
+That means the cutover is:
+
+- old source: hand-edited HTML bridge pages
+- new source: Astro build output served from `/srv/www/` inside `cms`
+- public delivery: existing reverse proxy, host PF redirect, direct jail IP, or
+ internal-only
+
+This keeps content migration separate from the operator's host web stack.
diff --git a/.agent/skills/backup-db/SKILL.md b/.agent/skills/backup-db/SKILL.md
new file mode 100644
index 0000000..8e7b500
--- /dev/null
+++ b/.agent/skills/backup-db/SKILL.md
@@ -0,0 +1,65 @@
+---
+name: backup-db
+description: Create a full PostgreSQL database backup (pg_dump) and store in project-relative backup directory
+compatibility: FreeBSD 15.x
+invoke_patterns:
+ - "Back up the database"
+ - "Back up * database"
+ - "Database backup"
+ - "Dump * database"
+ - "Create backup"
+ - "Back up before migration"
+estimated_tokens: 800-1200
+---
+
+# backup-db
+
+Full PostgreSQL dump using `pg_dump`. Backs up to `data/backups/` under project root. Never uses `/tmp/`.
+
+## Usage
+
+```bash
+# Full dump, compressed
+bastille cmd db pg_dump -U clawdie -Fc clawdie_ai_public \
+ > /home/clawdie/clawdie-ai/data/backups/clawdie_ai_public_$(date +%Y-%m-%d_%H%M%S).dump
+
+# Verify backup
+bastille cmd db pg_restore --list /home/clawdie/clawdie-ai/data/backups/.dump | head -20
+```
+
+## Output
+
+```
+Backup created: data/backups/clawdie_ai_public_2026-04-07_103045.dump
+Size: 2.3 GB
+Compression ratio: 4.2:1
+Duration: 18m 3s
+Verify: OK (1842 objects listed)
+```
+
+## Retention
+
+- Keep 7 daily backups minimum
+- Before any migration: always create a backup first
+- Before any schema change: same
+
+## Directory
+
+```
+data/backups/
+├── clawdie_ai_public_2026-04-07_103045.dump
+├── clawdie_2026-04-07_103200.dump
+└── ...
+```
+
+## When to Use
+
+- Before running `db-migrate`
+- Before any risky schema change
+- On-demand from CEO or operator instruction
+
+## Escalate If
+
+- Backup file size is 0 or unexpectedly small → escalate to CEO
+- Backup takes >60 min (possible lock contention) → escalate to DBA
+- Disk full before backup completes → escalate immediately
diff --git a/.agent/skills/coding-agent/SKILL.md b/.agent/skills/coding-agent/SKILL.md
new file mode 100644
index 0000000..a8f3618
--- /dev/null
+++ b/.agent/skills/coding-agent/SKILL.md
@@ -0,0 +1,166 @@
+---
+name: coding-agent
+description: Configure or debug pi (the LLM coding agent subprocess) in Clawdie. Use when changing the AI provider, model, or pi settings; when pi fails to respond; or when setting up pi for the first time. pi is @earendil-works/pi-coding-agent from the Codeberg pi project, installed at /opt/npm/bin/pi inside the clawdie-controlplane jail.
+---
+
+# Coding Agent (pi) Configuration
+
+Clawdie uses **pi** (`@earendil-works/pi-coding-agent`) as its LLM execution engine. For each incoming message, `src/agent-runner.ts` spawns `/opt/npm/bin/pi` as a subprocess in `--print` (non-interactive) mode and streams the response back.
+
+**Current setup:** ZAI provider (`PI_TUI_PROVIDER=zai`), model GLM-5.1 (`PI_TUI_MODEL=GLM-5.1`), binary at `/opt/npm/bin/pi` inside the `clawdie-controlplane` jail.
+
+## .env Variables
+
+```bash
+PI_TUI_BIN=/opt/npm/bin/pi # Path to pi binary (inside jail)
+PI_TUI_PROVIDER=zai # LLM provider (zai, anthropic, openai, openrouter, ollama)
+PI_TUI_MODEL=GLM-5.1 # Model name as pi expects it
+ZAI_API_KEY= # API key for the ZAI provider
+OPENROUTER_API_KEY= # If switching to OpenRouter
+ANTHROPIC_API_KEY= # If switching to Anthropic/Claude
+```
+
+## Checking Current pi Setup
+
+```bash
+# Verify binary exists and version
+sudo bastille cmd clawdie-controlplane /opt/npm/bin/pi --version
+
+# Check pi auth config (inside jail as root)
+sudo bastille cmd clawdie-controlplane cat /root/.pi/agent/auth.json
+
+# Check pi settings (default provider/model)
+sudo bastille cmd clawdie-controlplane cat /root/.pi/agent/settings.json
+
+# Verify skills symlink
+sudo bastille cmd clawdie-controlplane ls /root/.pi/agent/skills/
+```
+
+## Switching Provider or Model
+
+Edit `.env` (on the host — nullfs-mounted into jail):
+
+```bash
+# Example: switch to OpenRouter with Qwen
+PI_TUI_PROVIDER=openrouter
+PI_TUI_MODEL=qwen/qwen3-14b-instruct
+OPENROUTER_API_KEY=sk-or-v1-...
+```
+
+Then restart the agent:
+
+```bash
+sudo bastille cmd clawdie-controlplane service clawdie restart
+```
+
+Send a test message on Telegram and check `logs/clawdie.log` for the new provider name in spawn logs.
+
+## Installing / Reinstalling pi
+
+**IMPORTANT:** pi is published as `@earendil-works/pi-coding-agent`.
+Older docs or installs may mention legacy package names; use the `pi-update`
+skill for package-rename migrations. In the controlplane jail, install from the
+host's pre-built copy so the jail does not need to rebuild the multi-package pi
+project:
+
+```bash
+# Set npm prefix first (required — fresh jail defaults to /usr/local)
+sudo bastille cmd clawdie-controlplane npm config set prefix /opt/npm
+
+# Install from host's already-built copy
+sudo bastille cmd clawdie-controlplane \
+ npm install -g /usr/home/clawdie/.npm-global/lib/node_modules/@earendil-works/pi-coding-agent
+
+# Verify
+sudo bastille cmd clawdie-controlplane /opt/npm/bin/pi --version
+```
+
+After reinstall, copy auth and settings:
+
+```bash
+sudo bastille cmd clawdie-controlplane mkdir -p /root/.pi/agent
+sudo bastille cmd clawdie-controlplane \
+ cp /usr/home/clawdie/.pi/agent/auth.json /root/.pi/agent/auth.json
+sudo bastille cmd clawdie-controlplane \
+ cp /usr/home/clawdie/.pi/agent/settings.json /root/.pi/agent/settings.json
+sudo bastille cmd clawdie-controlplane \
+ ln -sf /usr/home/clawdie/clawdie-ai/.agent/skills /root/.pi/agent/skills
+```
+
+## Troubleshooting pi
+
+### pi not found
+
+```bash
+sudo bastille cmd clawdie-controlplane ls /opt/npm/bin/pi
+# If missing: reinstall (see above)
+```
+
+### pi exits with no output
+
+Read the per-run log:
+
+```bash
+ls -t groups/*/logs/agent-*.log | head -3
+tail -100 groups/Samo/logs/agent-.log
+```
+
+Common causes:
+
+- Wrong `PI_TUI_MODEL` name — check provider's model list
+- API key expired or missing in `/root/.pi/agent/auth.json`
+- `PI_TUI_BIN` points to wrong path
+
+### Adding a new provider to pi auth
+
+```bash
+# Edit /root/.pi/agent/auth.json inside jail
+sudo bastille cmd clawdie-controlplane \
+ sh -c 'cat /root/.pi/agent/auth.json'
+# Then edit on host (nullfs):
+# nano /usr/local/bastille/jails/clawdie-controlplane/root/root/.pi/agent/auth.json
+```
+
+Format:
+
+```json
+{
+ "zai": { "type": "api_key", "key": "..." },
+ "openrouter": { "type": "api_key", "key": "sk-or-v1-..." },
+ "anthropic": { "type": "api_key", "key": "sk-ant-..." }
+}
+```
+
+## Architecture
+
+```
+Telegram message
+ │
+ ▼
+src/index.ts (main loop)
+ │
+ ▼
+src/agent-runner.ts
+ │ spawns subprocess
+ ▼
+/opt/npm/bin/pi --print --provider zai --model GLM-5.1 ...
+ │
+ ▼
+ZAI API (GLM-5.1)
+ │
+ ▼
+pi stdout → agent-runner collects → Telegram reply
+```
+
+## Verify End-to-End
+
+```bash
+# Check agent is running
+sudo bastille cmd clawdie-controlplane service clawdie status
+
+# Watch logs while sending a test message on Telegram
+tail -f logs/clawdie.log
+
+# Look for: "Spawning pi agent" then "pi agent completed"
+grep -E 'Spawning pi|pi agent' logs/clawdie.log | tail -5
+```
diff --git a/.agent/skills/coding-agent/add/src/providers/anthropic.ts b/.agent/skills/coding-agent/add/src/providers/anthropic.ts
new file mode 100644
index 0000000..4996a87
--- /dev/null
+++ b/.agent/skills/coding-agent/add/src/providers/anthropic.ts
@@ -0,0 +1,194 @@
+import Anthropic from '@anthropic-ai/sdk';
+import {
+ AIProvider,
+ ProviderOptions,
+ ProviderResult,
+ StreamChunk,
+ ProviderConfig,
+ ProviderError,
+ ProviderUnavailableError,
+ ProviderRateLimitError,
+ ToolDefinition,
+ ToolCall,
+} from './provider.js';
+
+export class AnthropicProvider implements AIProvider {
+ readonly name = 'Anthropic';
+ readonly type = 'claude' as const;
+
+ private client: Anthropic;
+ private config: ProviderConfig;
+ private defaultModel: string;
+
+ constructor(config: ProviderConfig = {}) {
+ this.config = config;
+ this.defaultModel = config.model || 'claude-sonnet-4-20250514';
+
+ this.client = new Anthropic({
+ apiKey: config.apiKey || process.env.ANTHROPIC_API_KEY,
+ baseURL: config.baseUrl,
+ timeout: config.timeout || 120000,
+ maxRetries: config.maxRetries || 2,
+ });
+ }
+
+ async execute(
+ prompt: string,
+ options?: ProviderOptions,
+ ): Promise {
+ if (!this.isAvailable()) {
+ throw new ProviderUnavailableError(this.name, 'API key not configured');
+ }
+
+ try {
+ const response = await this.client.messages.create({
+ model: options?.model || this.defaultModel,
+ max_tokens: options?.maxTokens || 4096,
+ temperature: options?.temperature,
+ system: options?.systemPrompt,
+ stop_sequences: options?.stopSequences,
+ messages: [{ role: 'user', content: prompt }],
+ tools: options?.tools ? this.convertTools(options.tools) : undefined,
+ });
+
+ return this.parseResponse(response);
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+
+ async *stream(
+ prompt: string,
+ options?: ProviderOptions,
+ ): AsyncIterable {
+ if (!this.isAvailable()) {
+ throw new ProviderUnavailableError(this.name, 'API key not configured');
+ }
+
+ try {
+ const stream = this.client.messages.stream({
+ model: options?.model || this.defaultModel,
+ max_tokens: options?.maxTokens || 4096,
+ temperature: options?.temperature,
+ system: options?.systemPrompt,
+ stop_sequences: options?.stopSequences,
+ messages: [{ role: 'user', content: prompt }],
+ tools: options?.tools ? this.convertTools(options.tools) : undefined,
+ });
+
+ let inputTokens = 0;
+ let outputTokens = 0;
+
+ for await (const event of stream) {
+ if (
+ event.type === 'content_block_delta' &&
+ event.delta.type === 'text_delta'
+ ) {
+ yield { type: 'text', delta: event.delta.text };
+ } else if (
+ event.type === 'content_block_start' &&
+ event.content_block.type === 'tool_use'
+ ) {
+ yield {
+ type: 'tool_use',
+ toolCall: {
+ id: event.content_block.id,
+ name: event.content_block.name,
+ arguments: event.content_block.input as Record,
+ },
+ };
+ } else if (event.type === 'message_start') {
+ inputTokens = event.message.usage.input_tokens;
+ } else if (event.type === 'message_delta') {
+ outputTokens = event.usage?.output_tokens || 0;
+ }
+ }
+
+ yield { type: 'usage', usage: { inputTokens, outputTokens } };
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+
+ isAvailable(): boolean {
+ return !!(
+ this.config.apiKey ||
+ process.env.ANTHROPIC_API_KEY ||
+ process.env.AGENT_CODE_OAUTH_TOKEN
+ );
+ }
+
+ async getModels(): Promise {
+ return [
+ 'claude-opus-4-20250514',
+ 'claude-sonnet-4-20250514',
+ 'claude-haiku-3-5-20241022',
+ 'claude-3-5-sonnet-20241022',
+ 'claude-3-5-haiku-20241022',
+ 'claude-3-opus-20240229',
+ 'claude-3-sonnet-20240229',
+ 'claude-3-haiku-20240307',
+ ];
+ }
+
+ getDefaultModel(): string {
+ return this.defaultModel;
+ }
+
+ private convertTools(tools: ToolDefinition[]): Anthropic.Tool[] {
+ return tools.map((tool) => ({
+ name: tool.name,
+ description: tool.description,
+ input_schema: tool.parameters as Anthropic.Tool['input_schema'],
+ }));
+ }
+
+ private parseResponse(response: Anthropic.Message): ProviderResult {
+ let content = '';
+ const toolCalls: ToolCall[] = [];
+
+ for (const block of response.content) {
+ if (block.type === 'text') {
+ content += block.text;
+ } else if (block.type === 'tool_use') {
+ toolCalls.push({
+ id: block.id,
+ name: block.name,
+ arguments: block.input as Record,
+ });
+ }
+ }
+
+ return {
+ content,
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
+ usage: {
+ inputTokens: response.usage.input_tokens,
+ outputTokens: response.usage.output_tokens,
+ },
+ stopReason: response.stop_reason as ProviderResult['stopReason'],
+ };
+ }
+
+ private handleError(error: unknown): Error {
+ if (error instanceof Anthropic.APIError) {
+ if (error.status === 429) {
+ const retryAfter = error.headers?.['retry-after'];
+ return new ProviderRateLimitError(
+ this.name,
+ retryAfter ? parseInt(retryAfter, 10) : undefined,
+ );
+ }
+ return new ProviderError(
+ error.message,
+ this.name,
+ error.code,
+ error.status,
+ );
+ }
+ if (error instanceof Error) {
+ return new ProviderError(error.message, this.name);
+ }
+ return new ProviderError('Unknown error', this.name);
+ }
+}
diff --git a/.agent/skills/coding-agent/add/src/providers/coding-agent.ts b/.agent/skills/coding-agent/add/src/providers/coding-agent.ts
new file mode 100644
index 0000000..ddf77ac
--- /dev/null
+++ b/.agent/skills/coding-agent/add/src/providers/coding-agent.ts
@@ -0,0 +1,176 @@
+import { spawn, ChildProcess } from 'child_process';
+import {
+ AIProvider,
+ ProviderOptions,
+ ProviderResult,
+ StreamChunk,
+ ProviderConfig,
+ ProviderError,
+ ProviderUnavailableError,
+} from './provider.js';
+
+export class CodingAgentProvider implements AIProvider {
+ readonly name = 'coding-agent';
+ readonly type = 'claude' as const; // coding-agent uses Claude by default
+
+ private config: ProviderConfig;
+ private defaultModel: string;
+ private process: ChildProcess | null = null;
+
+ constructor(config: ProviderConfig = {}) {
+ this.config = config;
+ this.defaultModel = config.model || 'claude-sonnet-4-20250514';
+ }
+
+ async execute(
+ prompt: string,
+ options?: ProviderOptions,
+ ): Promise {
+ if (!this.isAvailable()) {
+ throw new ProviderUnavailableError(
+ this.name,
+ 'coding-agent binary not found in PATH',
+ );
+ }
+
+ return new Promise((resolve, reject) => {
+ const args = this.buildArgs(prompt, options);
+
+ this.process = spawn('coding-agent', args, {
+ cwd: this.config.baseUrl || process.cwd(),
+ env: {
+ ...process.env,
+ ANTHROPIC_API_KEY:
+ this.config.apiKey || process.env.ANTHROPIC_API_KEY,
+ AGENT_CODE_OAUTH_TOKEN: process.env.AGENT_CODE_OAUTH_TOKEN,
+ },
+ });
+
+ let stdout = '';
+ let stderr = '';
+
+ this.process.stdout?.on('data', (data) => {
+ stdout += data.toString();
+ });
+
+ this.process.stderr?.on('data', (data) => {
+ stderr += data.toString();
+ });
+
+ this.process.on('close', (code) => {
+ if (code === 0) {
+ resolve({
+ content: stdout.trim(),
+ usage: undefined, // coding-agent doesn't report token usage
+ });
+ } else {
+ reject(
+ new ProviderError(
+ `coding-agent exited with code ${code}: ${stderr}`,
+ this.name,
+ 'PROCESS_ERROR',
+ code ?? undefined,
+ ),
+ );
+ }
+ });
+
+ this.process.on('error', (error) => {
+ reject(
+ new ProviderError(
+ `Failed to spawn coding-agent: ${error.message}`,
+ this.name,
+ ),
+ );
+ });
+
+ // Send prompt via stdin
+ this.process.stdin?.write(prompt);
+ this.process.stdin?.end();
+ });
+ }
+
+ async *stream(
+ prompt: string,
+ options?: ProviderOptions,
+ ): AsyncIterable {
+ // coding-agent doesn't support streaming in the same way
+ // Execute and yield the complete result
+ const result = await this.execute(prompt, options);
+
+ // Yield text in chunks for consistency
+ const chunkSize = 100;
+ for (let i = 0; i < result.content.length; i += chunkSize) {
+ yield {
+ type: 'text',
+ delta: result.content.slice(i, i + chunkSize),
+ };
+ }
+
+ if (result.usage) {
+ yield { type: 'usage', usage: result.usage };
+ }
+ }
+
+ isAvailable(): boolean {
+ // Check if coding-agent binary exists
+ try {
+ const result = spawn('which', ['coding-agent'], { shell: true });
+ return result.exitCode === 0;
+ } catch {
+ return false;
+ }
+ }
+
+ async getModels(): Promise {
+ // coding-agent uses Claude models
+ return [
+ 'claude-opus-4-20250514',
+ 'claude-sonnet-4-20250514',
+ 'claude-haiku-3-5-20241022',
+ ];
+ }
+
+ getDefaultModel(): string {
+ return this.defaultModel;
+ }
+
+ private buildArgs(prompt: string, options?: ProviderOptions): string[] {
+ const args: string[] = [
+ '--non-interactive',
+ '--model',
+ options?.model || this.defaultModel,
+ ];
+
+ if (options?.systemPrompt) {
+ args.push('--system', options.systemPrompt);
+ }
+
+ if (options?.maxTokens) {
+ args.push('--max-tokens', options.maxTokens.toString());
+ }
+
+ if (options?.temperature !== undefined) {
+ args.push('--temperature', options.temperature.toString());
+ }
+
+ // Read from stdin
+ args.push('--stdin');
+
+ return args;
+ }
+
+ kill(): void {
+ if (this.process) {
+ this.process.kill();
+ this.process = null;
+ }
+ }
+}
+
+// Factory function for convenience
+export function createCodingAgentProvider(
+ config?: ProviderConfig,
+): CodingAgentProvider {
+ return new CodingAgentProvider(config);
+}
diff --git a/.agent/skills/coding-agent/add/src/providers/gemini.ts b/.agent/skills/coding-agent/add/src/providers/gemini.ts
new file mode 100644
index 0000000..08602fe
--- /dev/null
+++ b/.agent/skills/coding-agent/add/src/providers/gemini.ts
@@ -0,0 +1,147 @@
+import { GoogleGenerativeAI } from '@google/generative-ai';
+import {
+ AIProvider,
+ ProviderOptions,
+ ProviderResult,
+ StreamChunk,
+ ProviderConfig,
+ ProviderError,
+ ProviderUnavailableError,
+ ToolDefinition,
+ ToolCall,
+} from './provider.js';
+
+export class GeminiProvider implements AIProvider {
+ readonly name = 'Gemini';
+ readonly type = 'gemini' as const;
+
+ private client: GoogleGenerativeAI;
+ private config: ProviderConfig;
+ private defaultModel: string;
+
+ constructor(config: ProviderConfig = {}) {
+ this.config = config;
+ this.defaultModel = config.model || process.env.GEMINI_MODEL || 'gemini-pro';
+
+ this.client = new GoogleGenerativeAI(
+ config.apiKey || process.env.GOOGLE_API_KEY || ''
+ );
+ }
+
+ async execute(prompt: string, options?: ProviderOptions): Promise {
+ if (!this.isAvailable()) {
+ throw new ProviderUnavailableError(this.name, 'API key not configured');
+ }
+
+ try {
+ const model = this.client.getGenerativeModel({
+ model: options?.model || this.defaultModel,
+ });
+
+ const result = await model.generateContent({
+ contents: [{ role: 'user', parts: [{ text: prompt }] }],
+ generationConfig: {
+ maxOutputTokens: options?.maxTokens || 4096,
+ temperature: options?.temperature,
+ stopSequences: options?.stopSequences,
+ },
+ systemInstruction: options?.systemPrompt
+ ? { parts: [{ text: options.systemPrompt }] }
+ : undefined,
+ });
+
+ return this.parseResponse(result);
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+
+ async *stream(prompt: string, options?: ProviderOptions): AsyncIterable {
+ if (!this.isAvailable()) {
+ throw new ProviderUnavailableError(this.name, 'API key not configured');
+ }
+
+ try {
+ const model = this.client.getGenerativeModel({
+ model: options?.model || this.defaultModel,
+ });
+
+ const result = await model.generateContentStream({
+ contents: [{ role: 'user', parts: [{ text: prompt }] }],
+ generationConfig: {
+ maxOutputTokens: options?.maxTokens || 4096,
+ temperature: options?.temperature,
+ stopSequences: options?.stopSequences,
+ },
+ systemInstruction: options?.systemPrompt
+ ? { parts: [{ text: options.systemPrompt }] }
+ : undefined,
+ });
+
+ let inputTokens = 0;
+ let outputTokens = 0;
+
+ for await (const chunk of result.stream) {
+ const text = chunk.text();
+ if (text) {
+ yield { type: 'text', delta: text };
+ }
+
+ if (chunk.usageMetadata) {
+ inputTokens = chunk.usageMetadata.promptTokenCount;
+ outputTokens = chunk.usageMetadata.candidatesTokenCount;
+ }
+ }
+
+ yield { type: 'usage', usage: { inputTokens, outputTokens } };
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+
+ isAvailable(): boolean {
+ return !!(this.config.apiKey || process.env.GOOGLE_API_KEY);
+ }
+
+ async getModels(): Promise {
+ return [
+ 'gemini-pro',
+ 'gemini-pro-vision',
+ 'gemini-1.5-pro',
+ 'gemini-1.5-flash',
+ ];
+ }
+
+ getDefaultModel(): string {
+ return this.defaultModel;
+ }
+
+ private parseResponse(result: Awaited any ? ReturnType['generateContent']> : never>): ProviderResult {
+ const response = result.response;
+ const content = response.text();
+
+ return {
+ content,
+ usage: response.usageMetadata
+ ? {
+ inputTokens: response.usageMetadata.promptTokenCount,
+ outputTokens: response.usageMetadata.candidatesTokenCount,
+ }
+ : undefined,
+ stopReason: response.candidates?.[0]?.finishReason as ProviderResult['stopReason'],
+ };
+ }
+
+ private handleError(error: unknown): Error {
+ if (error instanceof Error) {
+ if (error.message.includes('429') || error.message.includes('quota')) {
+ return new ProviderError(error.message, this.name, 'RATE_LIMIT', 429);
+ }
+ return new ProviderError(error.message, this.name);
+ }
+ return new ProviderError('Unknown error', this.name);
+ }
+}
+
+// Note: Tool use support in Gemini is more limited than Claude/OpenAI
+// Full tool/function calling requires additional configuration
diff --git a/.agent/skills/coding-agent/add/src/providers/index.ts b/.agent/skills/coding-agent/add/src/providers/index.ts
new file mode 100644
index 0000000..7bd82f7
--- /dev/null
+++ b/.agent/skills/coding-agent/add/src/providers/index.ts
@@ -0,0 +1,143 @@
+import {
+ AIProvider,
+ ProviderType,
+ ExecutionMode,
+ ProviderConfig,
+ ProviderError,
+} from './provider.js';
+import { AnthropicProvider } from './anthropic.js';
+import { OpenAIProvider } from './openai.js';
+import { GeminiProvider } from './gemini.js';
+import { CodingAgentProvider } from './coding-agent.js';
+
+export {
+ ProviderError,
+ ProviderUnavailableError,
+ ProviderRateLimitError,
+ type AIProvider,
+ type ProviderOptions,
+ type ProviderResult,
+ type StreamChunk,
+ type ToolDefinition,
+ type ToolCall,
+ type ProviderType,
+ type ExecutionMode,
+ type ProviderConfig,
+};
+
+export { AnthropicProvider } from './anthropic.js';
+export { OpenAIProvider } from './openai.js';
+export { GeminiProvider } from './gemini.js';
+export { CodingAgentProvider } from './coding-agent.js';
+
+const PROVIDERS: Record AIProvider> = {
+ claude: AnthropicProvider,
+ openai: OpenAIProvider,
+ gemini: GeminiProvider,
+};
+
+export function createProvider(
+ type: ProviderType,
+ config: ProviderConfig = {}
+): AIProvider {
+ const ProviderClass = PROVIDERS[type];
+ if (!ProviderClass) {
+ throw new ProviderError(`Unknown provider type: ${type}`, 'factory');
+ }
+ return new ProviderClass(config);
+}
+
+export function createProviderFromEnv(): AIProvider {
+ const providerType = (process.env.AI_PROVIDER || 'claude') as ProviderType;
+
+ if (!['claude', 'openai', 'gemini'].includes(providerType)) {
+ throw new ProviderError(
+ `Invalid AI_PROVIDER: ${providerType}. Must be claude, openai, or gemini`,
+ 'factory'
+ );
+ }
+
+ const config: ProviderConfig = {
+ model: process.env.CLAUDE_MODEL || process.env.OPENAI_MODEL || process.env.GEMINI_MODEL,
+ };
+
+ switch (providerType) {
+ case 'claude':
+ config.apiKey = process.env.ANTHROPIC_API_KEY;
+ break;
+ case 'openai':
+ config.apiKey = process.env.OPENAI_API_KEY;
+ config.organizationId = process.env.OPENAI_ORG_ID;
+ break;
+ case 'gemini':
+ config.apiKey = process.env.GOOGLE_API_KEY;
+ break;
+ }
+
+ return createProvider(providerType, config);
+}
+
+export function createExecutionProvider(
+ mode: ExecutionMode = 'container',
+ providerType?: ProviderType
+): AIProvider {
+ const actualMode = mode || (process.env.EXECUTION_MODE as ExecutionMode) || 'container';
+ const actualType = providerType || (process.env.AI_PROVIDER as ProviderType) || 'claude';
+
+ switch (actualMode) {
+ case 'tui':
+ return new CodingAgentProvider({
+ model: process.env.CLAUDE_MODEL,
+ });
+
+ case 'direct':
+ return createProviderFromEnv();
+
+ case 'container':
+ default:
+ // Container mode uses the provider config passed to the container
+ return createProvider(actualType, {
+ apiKey: process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY || process.env.GOOGLE_API_KEY,
+ model: process.env.CLAUDE_MODEL || process.env.OPENAI_MODEL || process.env.GEMINI_MODEL,
+ });
+ }
+}
+
+export function getAvailableProviders(): ProviderType[] {
+ const available: ProviderType[] = [];
+
+ if (process.env.ANTHROPIC_API_KEY || process.env.AGENT_CODE_OAUTH_TOKEN) {
+ available.push('claude');
+ }
+
+ if (process.env.OPENAI_API_KEY) {
+ available.push('openai');
+ }
+
+ if (process.env.GOOGLE_API_KEY) {
+ available.push('gemini');
+ }
+
+ return available;
+}
+
+export function getDefaultProvider(): ProviderType {
+ const available = getAvailableProviders();
+
+ if (available.includes('claude')) {
+ return 'claude';
+ }
+
+ if (available.includes('openai')) {
+ return 'openai';
+ }
+
+ if (available.includes('gemini')) {
+ return 'gemini';
+ }
+
+ throw new ProviderError(
+ 'No AI provider configured. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY',
+ 'factory'
+ );
+}
diff --git a/.agent/skills/coding-agent/add/src/providers/openai.ts b/.agent/skills/coding-agent/add/src/providers/openai.ts
new file mode 100644
index 0000000..c5ca8da
--- /dev/null
+++ b/.agent/skills/coding-agent/add/src/providers/openai.ts
@@ -0,0 +1,225 @@
+import OpenAI from 'openai';
+import {
+ AIProvider,
+ ProviderOptions,
+ ProviderResult,
+ StreamChunk,
+ ProviderConfig,
+ ProviderError,
+ ProviderUnavailableError,
+ ProviderRateLimitError,
+ ToolDefinition,
+ ToolCall,
+} from './provider.js';
+
+export class OpenAIProvider implements AIProvider {
+ readonly name = 'OpenAI';
+ readonly type = 'openai' as const;
+
+ private client: OpenAI;
+ private config: ProviderConfig;
+ private defaultModel: string;
+
+ constructor(config: ProviderConfig = {}) {
+ this.config = config;
+ this.defaultModel = config.model || process.env.OPENAI_MODEL || 'gpt-4o';
+
+ this.client = new OpenAI({
+ apiKey: config.apiKey || process.env.OPENAI_API_KEY,
+ baseURL: config.baseUrl,
+ organization: config.organizationId || process.env.OPENAI_ORG_ID,
+ timeout: config.timeout || 120000,
+ maxRetries: config.maxRetries || 2,
+ });
+ }
+
+ async execute(
+ prompt: string,
+ options?: ProviderOptions,
+ ): Promise {
+ if (!this.isAvailable()) {
+ throw new ProviderUnavailableError(this.name, 'API key not configured');
+ }
+
+ try {
+ const response = await this.client.chat.completions.create({
+ model: options?.model || this.defaultModel,
+ max_tokens: options?.maxTokens || 4096,
+ temperature: options?.temperature,
+ messages: options?.systemPrompt
+ ? [
+ { role: 'system', content: options.systemPrompt },
+ { role: 'user', content: prompt },
+ ]
+ : [{ role: 'user', content: prompt }],
+ tools: options?.tools ? this.convertTools(options.tools) : undefined,
+ stop: options?.stopSequences,
+ });
+
+ return this.parseResponse(response);
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+
+ async *stream(
+ prompt: string,
+ options?: ProviderOptions,
+ ): AsyncIterable