Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
156a43e29b | ||
|
|
3d922437e4 |
No files matched your search
@@ -1,5 +1,4 @@
|
||||
node_modules
|
||||
**/node_modules/
|
||||
.env
|
||||
docker-compose.extra.yml
|
||||
dist
|
||||
|
||||
@@ -8,10 +8,6 @@
|
||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
||||
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
||||
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
|
||||
- Core channel docs: `docs/channels/`
|
||||
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
|
||||
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
|
||||
|
||||
## Docs Linking (Mintlify)
|
||||
- Docs are hosted on Mintlify (docs.clawd.bot).
|
||||
|
||||
+1
-11
@@ -6,19 +6,9 @@ Docs: https://docs.clawd.bot
|
||||
|
||||
### Changes
|
||||
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
||||
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels.
|
||||
- TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07.
|
||||
- Docs: clarify allowlist input types and onboarding behavior for messaging channels.
|
||||
|
||||
### Fixes
|
||||
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
|
||||
- Docs: make docs:list fail fast with a clear error if the docs directory is missing.
|
||||
- macOS: load menu session previews asynchronously so items populate while the menu is open.
|
||||
- macOS: use label colors for session preview text so previews render in menu subviews.
|
||||
- macOS: suppress usage error text in the menubar cost view.
|
||||
- macOS: fix onboarding action link labels for channels vs skills. (#1197) — thanks @chriseidhof.
|
||||
- Telegram: honor pairing allowlists for native slash commands.
|
||||
- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195) — thanks @gumadeiras.
|
||||
- Plugins: auto-select exclusive slots when enabling/installing plugins. (#1181) — thanks @sebslight.
|
||||
|
||||
## 2026.1.18-4
|
||||
|
||||
|
||||
@@ -474,25 +474,25 @@ Core contributors:
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
|
||||
<a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a>
|
||||
<a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
|
||||
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
|
||||
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
|
||||
<a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a>
|
||||
<a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a>
|
||||
<a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a>
|
||||
<a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a>
|
||||
<a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
|
||||
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
|
||||
<a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a>
|
||||
<a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a>
|
||||
<a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a>
|
||||
<a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a>
|
||||
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
|
||||
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a>
|
||||
<a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a>
|
||||
<a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a>
|
||||
<a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a>
|
||||
<a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a>
|
||||
<a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a>
|
||||
<a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a>
|
||||
<a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a>
|
||||
<a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a>
|
||||
<a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a>
|
||||
<a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
|
||||
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
|
||||
<a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
|
||||
<a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a>
|
||||
<a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a>
|
||||
<a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a>
|
||||
<a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a>
|
||||
<a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a>
|
||||
<a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
|
||||
<a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a>
|
||||
<a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a>
|
||||
<a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a>
|
||||
<a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a>
|
||||
<a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
|
||||
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
@@ -12,9 +12,6 @@ final class ConnectionModeCoordinator {
|
||||
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
|
||||
switch mode {
|
||||
case .unconfigured:
|
||||
if let error = await NodeServiceManager.stop() {
|
||||
NodesStore.shared.lastError = "Node service stop failed: \(error)"
|
||||
}
|
||||
await RemoteTunnelManager.shared.stopAll()
|
||||
WebChatManager.shared.resetTunnels()
|
||||
GatewayProcessManager.shared.stop()
|
||||
@@ -23,9 +20,6 @@ final class ConnectionModeCoordinator {
|
||||
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
|
||||
|
||||
case .local:
|
||||
if let error = await NodeServiceManager.stop() {
|
||||
NodesStore.shared.lastError = "Node service stop failed: \(error)"
|
||||
}
|
||||
await RemoteTunnelManager.shared.stopAll()
|
||||
WebChatManager.shared.resetTunnels()
|
||||
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
|
||||
@@ -56,9 +50,6 @@ final class ConnectionModeCoordinator {
|
||||
WebChatManager.shared.resetTunnels()
|
||||
|
||||
do {
|
||||
if let error = await NodeServiceManager.start() {
|
||||
NodesStore.shared.lastError = "Node service start failed: \(error)"
|
||||
}
|
||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
try await ControlChannel.shared.configure(mode: .remote(
|
||||
|
||||
@@ -14,7 +14,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
private weak var statusItem: NSStatusItem?
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var nodesLoadTask: Task<Void, Never>?
|
||||
private var previewTasks: [Task<Void, Never>] = []
|
||||
private var isMenuOpen = false
|
||||
private var lastKnownMenuWidth: CGFloat?
|
||||
private var menuOpenWidth: CGFloat?
|
||||
@@ -88,7 +87,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
self.menuOpenWidth = nil
|
||||
self.loadTask?.cancel()
|
||||
self.nodesLoadTask?.cancel()
|
||||
self.cancelPreviewTasks()
|
||||
}
|
||||
|
||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||
@@ -109,7 +107,6 @@ extension MenuSessionsInjector {
|
||||
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
|
||||
|
||||
private func inject(into menu: NSMenu) {
|
||||
self.cancelPreviewTasks()
|
||||
// Remove any previous injected items.
|
||||
for item in menu.items where item.tag == self.tag {
|
||||
menu.removeItem(item)
|
||||
@@ -457,46 +454,15 @@ extension MenuSessionsInjector {
|
||||
item.tag = self.tag
|
||||
item.isEnabled = false
|
||||
let view = AnyView(SessionMenuPreviewView(
|
||||
sessionKey: sessionKey,
|
||||
width: width,
|
||||
maxItems: 10,
|
||||
maxLines: maxLines,
|
||||
title: title,
|
||||
items: [],
|
||||
status: .loading))
|
||||
let hosting = NSHostingView(rootView: view)
|
||||
hosting.frame.size.width = max(1, width)
|
||||
let size = hosting.fittingSize
|
||||
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
||||
item.view = hosting
|
||||
|
||||
let task = Task { [weak hosting] in
|
||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: sessionKey, maxItems: 10)
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
guard let hosting else { return }
|
||||
let nextView = AnyView(SessionMenuPreviewView(
|
||||
width: width,
|
||||
maxLines: maxLines,
|
||||
title: title,
|
||||
items: snapshot.items,
|
||||
status: snapshot.status))
|
||||
hosting.rootView = nextView
|
||||
hosting.invalidateIntrinsicContentSize()
|
||||
hosting.frame.size.width = max(1, width)
|
||||
let size = hosting.fittingSize
|
||||
hosting.frame.size.height = size.height
|
||||
}
|
||||
}
|
||||
self.previewTasks.append(task)
|
||||
title: title))
|
||||
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
|
||||
return item
|
||||
}
|
||||
|
||||
private func cancelPreviewTasks() {
|
||||
for task in self.previewTasks {
|
||||
task.cancel()
|
||||
}
|
||||
self.previewTasks.removeAll()
|
||||
}
|
||||
|
||||
private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem {
|
||||
let view = AnyView(
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
|
||||
@@ -443,6 +443,7 @@ actor MacNodeRuntime {
|
||||
let approvals = ExecApprovalsStore.resolve(agentId: agentId)
|
||||
let security = approvals.agent.security
|
||||
let ask = approvals.agent.ask
|
||||
let askFallback = approvals.agent.askFallback
|
||||
let autoAllowSkills = approvals.agent.autoAllowSkills
|
||||
let sessionKey = (params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -488,7 +489,7 @@ actor MacNodeRuntime {
|
||||
|
||||
var approvedByAsk = false
|
||||
if requiresAsk {
|
||||
let decision = await ExecApprovalsPromptPresenter.prompt(
|
||||
let decision: ExecApprovalDecision? = await ExecApprovalsPromptPresenter.prompt(
|
||||
ExecApprovalPromptRequest(
|
||||
command: displayCommand,
|
||||
cwd: params.cwd,
|
||||
@@ -499,7 +500,7 @@ actor MacNodeRuntime {
|
||||
resolvedPath: resolution?.resolvedPath))
|
||||
|
||||
switch decision {
|
||||
case .deny:
|
||||
case .deny?:
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
@@ -512,7 +513,41 @@ actor MacNodeRuntime {
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: user denied")
|
||||
case .allowAlways:
|
||||
case nil:
|
||||
if askFallback == .full {
|
||||
approvedByAsk = true
|
||||
} else if askFallback == .allowlist {
|
||||
if allowlistMatch != nil || skillAllow {
|
||||
approvedByAsk = true
|
||||
} else {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
reason: "approval-required"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: approval required")
|
||||
}
|
||||
} else {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
reason: "approval-required"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: approval required")
|
||||
}
|
||||
case .allowAlways?:
|
||||
approvedByAsk = true
|
||||
if security == .allowlist {
|
||||
let pattern = resolution?.resolvedPath ??
|
||||
@@ -523,7 +558,7 @@ actor MacNodeRuntime {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
||||
}
|
||||
}
|
||||
case .allowOnce:
|
||||
case .allowOnce?:
|
||||
approvedByAsk = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
enum NodeServiceManager {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "node.service")
|
||||
|
||||
static func start() async -> String? {
|
||||
let result = await self.runServiceCommandResult(
|
||||
["node", "start"],
|
||||
timeout: 20,
|
||||
quiet: false)
|
||||
if let error = self.errorMessage(from: result, treatNotLoadedAsError: true) {
|
||||
self.logger.error("node service start failed: \(error, privacy: .public)")
|
||||
return error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func stop() async -> String? {
|
||||
let result = await self.runServiceCommandResult(
|
||||
["node", "stop"],
|
||||
timeout: 15,
|
||||
quiet: false)
|
||||
if let error = self.errorMessage(from: result, treatNotLoadedAsError: false) {
|
||||
self.logger.error("node service stop failed: \(error, privacy: .public)")
|
||||
return error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension NodeServiceManager {
|
||||
private struct CommandResult {
|
||||
let success: Bool
|
||||
let payload: Data?
|
||||
let message: String?
|
||||
let parsed: ParsedServiceJson?
|
||||
}
|
||||
|
||||
private struct ParsedServiceJson {
|
||||
let text: String
|
||||
let object: [String: Any]
|
||||
let ok: Bool?
|
||||
let result: String?
|
||||
let message: String?
|
||||
let error: String?
|
||||
let hints: [String]
|
||||
}
|
||||
|
||||
private static func runServiceCommandResult(
|
||||
_ args: [String],
|
||||
timeout: Double,
|
||||
quiet: Bool) async -> CommandResult
|
||||
{
|
||||
let command = CommandResolver.clawdbotCommand(
|
||||
subcommand: "service",
|
||||
extraArgs: self.withJsonFlag(args),
|
||||
// Service management must always run locally, even if remote mode is configured.
|
||||
configRoot: ["gateway": ["mode": "local"]])
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
||||
let parsed = self.parseServiceJson(from: response.stdout) ?? self.parseServiceJson(from: response.stderr)
|
||||
let ok = parsed?.ok
|
||||
let message = parsed?.error ?? parsed?.message
|
||||
let payload = parsed?.text.data(using: .utf8)
|
||||
?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8)
|
||||
let success = ok ?? response.success
|
||||
if success {
|
||||
return CommandResult(success: true, payload: payload, message: nil, parsed: parsed)
|
||||
}
|
||||
|
||||
if quiet {
|
||||
return CommandResult(success: false, payload: payload, message: message, parsed: parsed)
|
||||
}
|
||||
|
||||
let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout)
|
||||
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
|
||||
let fullMessage = detail.map { "Node service command failed (\(exit)): \($0)" }
|
||||
?? "Node service command failed (\(exit))"
|
||||
self.logger.error("\(fullMessage, privacy: .public)")
|
||||
return CommandResult(success: false, payload: payload, message: detail, parsed: parsed)
|
||||
}
|
||||
|
||||
private static func errorMessage(from result: CommandResult, treatNotLoadedAsError: Bool) -> String? {
|
||||
if !result.success {
|
||||
return result.message ?? "Node service command failed"
|
||||
}
|
||||
guard let parsed = result.parsed else { return nil }
|
||||
if parsed.ok == false {
|
||||
return self.mergeHints(message: parsed.error ?? parsed.message, hints: parsed.hints)
|
||||
}
|
||||
if treatNotLoadedAsError, parsed.result == "not-loaded" {
|
||||
let base = parsed.message ?? "Node service not loaded."
|
||||
return self.mergeHints(message: base, hints: parsed.hints)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func withJsonFlag(_ args: [String]) -> [String] {
|
||||
if args.contains("--json") { return args }
|
||||
return args + ["--json"]
|
||||
}
|
||||
|
||||
private static func parseServiceJson(from raw: String) -> ParsedServiceJson? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let start = trimmed.firstIndex(of: "{"),
|
||||
let end = trimmed.lastIndex(of: "}")
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let jsonText = String(trimmed[start...end])
|
||||
guard let data = jsonText.data(using: .utf8) else { return nil }
|
||||
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
let ok = object["ok"] as? Bool
|
||||
let result = object["result"] as? String
|
||||
let message = object["message"] as? String
|
||||
let error = object["error"] as? String
|
||||
let hints = (object["hints"] as? [String]) ?? []
|
||||
return ParsedServiceJson(
|
||||
text: jsonText,
|
||||
object: object,
|
||||
ok: ok,
|
||||
result: result,
|
||||
message: message,
|
||||
error: error,
|
||||
hints: hints)
|
||||
}
|
||||
|
||||
private static func mergeHints(message: String?, hints: [String]) -> String? {
|
||||
let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let nonEmpty = trimmed?.isEmpty == false ? trimmed : nil
|
||||
guard !hints.isEmpty else { return nonEmpty }
|
||||
let hintText = hints.prefix(2).joined(separator: " · ")
|
||||
if let nonEmpty {
|
||||
return "\(nonEmpty) (\(hintText))"
|
||||
}
|
||||
return hintText
|
||||
}
|
||||
|
||||
private static func summarize(_ text: String) -> String? {
|
||||
let lines = text
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard let last = lines.last else { return nil }
|
||||
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
|
||||
}
|
||||
}
|
||||
@@ -210,7 +210,6 @@ extension OnboardingView {
|
||||
title: String,
|
||||
subtitle: String,
|
||||
systemImage: String,
|
||||
buttonTitle: String,
|
||||
action: @escaping () -> Void) -> some View
|
||||
{
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
@@ -223,7 +222,7 @@ extension OnboardingView {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Button(buttonTitle, action: action)
|
||||
Button("Open Settings → Skills", action: action)
|
||||
.buttonStyle(.link)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
@@ -695,8 +695,7 @@ extension OnboardingView {
|
||||
self.featureActionRow(
|
||||
title: "Connect WhatsApp or Telegram",
|
||||
subtitle: "Open Settings → Channels to link channels and monitor status.",
|
||||
systemImage: "link",
|
||||
buttonTitle: "Open Settings → Channels")
|
||||
systemImage: "link")
|
||||
{
|
||||
self.openSettings(tab: .channels)
|
||||
}
|
||||
@@ -712,8 +711,7 @@ extension OnboardingView {
|
||||
self.featureActionRow(
|
||||
title: "Give your agent more powers",
|
||||
subtitle: "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.",
|
||||
systemImage: "sparkles",
|
||||
buttonTitle: "Open Settings → Skills")
|
||||
systemImage: "sparkles")
|
||||
{
|
||||
self.openSettings(tab: .skills)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ import ClawdbotKit
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
struct SessionPreviewItem: Identifiable, Sendable {
|
||||
private struct SessionPreviewItem: Identifiable, Sendable {
|
||||
let id: String
|
||||
let role: PreviewRole
|
||||
let text: String
|
||||
}
|
||||
|
||||
enum PreviewRole: String, Sendable {
|
||||
private enum PreviewRole: String, Sendable {
|
||||
case user
|
||||
case assistant
|
||||
case tool
|
||||
@@ -27,7 +27,7 @@ enum PreviewRole: String, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
actor SessionPreviewCache {
|
||||
private actor SessionPreviewCache {
|
||||
static let shared = SessionPreviewCache()
|
||||
|
||||
private struct CacheEntry {
|
||||
@@ -52,33 +52,25 @@ actor SessionPreviewCache {
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension SessionPreviewCache {
|
||||
func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
|
||||
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt)
|
||||
}
|
||||
|
||||
func _testReset() {
|
||||
self.entries = [:]
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
struct SessionMenuPreviewSnapshot: Sendable {
|
||||
let items: [SessionPreviewItem]
|
||||
let status: SessionMenuPreviewView.LoadStatus
|
||||
}
|
||||
|
||||
struct SessionMenuPreviewView: View {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
||||
private static let previewTimeoutSeconds: Double = 4
|
||||
|
||||
let sessionKey: String
|
||||
let width: CGFloat
|
||||
let maxItems: Int
|
||||
let maxLines: Int
|
||||
let title: String
|
||||
let items: [SessionPreviewItem]
|
||||
let status: LoadStatus
|
||||
|
||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||
@State private var items: [SessionPreviewItem] = []
|
||||
@State private var status: LoadStatus = .loading
|
||||
|
||||
enum LoadStatus: Equatable {
|
||||
private struct PreviewTimeoutError: LocalizedError {
|
||||
var errorDescription: String? { "preview timeout" }
|
||||
}
|
||||
|
||||
private enum LoadStatus: Equatable {
|
||||
case loading
|
||||
case ready
|
||||
case empty
|
||||
@@ -86,17 +78,15 @@ struct SessionMenuPreviewView: View {
|
||||
}
|
||||
|
||||
private var primaryColor: Color {
|
||||
if self.isHighlighted {
|
||||
return Color(nsColor: .selectedMenuItemTextColor)
|
||||
}
|
||||
return Color(nsColor: .labelColor)
|
||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
|
||||
}
|
||||
|
||||
private var secondaryColor: Color {
|
||||
if self.isHighlighted {
|
||||
return Color(nsColor: .selectedMenuItemTextColor).opacity(0.85)
|
||||
}
|
||||
return Color(nsColor: .secondaryLabelColor)
|
||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
|
||||
}
|
||||
|
||||
private var previewLimit: Int {
|
||||
min(max(self.maxItems * 3, 20), 120)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -110,19 +100,21 @@ struct SessionMenuPreviewView: View {
|
||||
|
||||
switch self.status {
|
||||
case .loading:
|
||||
self.placeholder("Loading preview…")
|
||||
Text("Loading preview…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
case .empty:
|
||||
self.placeholder("No recent messages")
|
||||
Text("No recent messages")
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
case let .error(message):
|
||||
self.placeholder(message)
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
case .ready:
|
||||
if self.items.isEmpty {
|
||||
self.placeholder("No recent messages")
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(self.items) { item in
|
||||
self.previewRow(item)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(self.items) { item in
|
||||
self.previewRow(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +123,9 @@ struct SessionMenuPreviewView: View {
|
||||
.padding(.leading, 16)
|
||||
.padding(.trailing, 11)
|
||||
.frame(width: max(1, self.width), alignment: .leading)
|
||||
.task(id: self.sessionKey) {
|
||||
await self.loadPreview()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -162,66 +157,55 @@ struct SessionMenuPreviewView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func placeholder(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.primaryColor)
|
||||
}
|
||||
private func loadPreview() async {
|
||||
if let cached = await SessionPreviewCache.shared.cachedItems(for: self.sessionKey, maxAge: 12) {
|
||||
await MainActor.run {
|
||||
self.items = cached
|
||||
self.status = cached.isEmpty ? .empty : .ready
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum SessionMenuPreviewLoader {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
||||
private static let previewTimeoutSeconds: Double = 4
|
||||
private static let cacheMaxAgeSeconds: TimeInterval = 30
|
||||
|
||||
private struct PreviewTimeoutError: LocalizedError {
|
||||
var errorDescription: String? { "preview timeout" }
|
||||
}
|
||||
|
||||
static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot {
|
||||
if let cached = await SessionPreviewCache.shared.cachedItems(for: sessionKey, maxAge: cacheMaxAgeSeconds) {
|
||||
return Self.snapshot(from: cached)
|
||||
await MainActor.run {
|
||||
self.status = .loading
|
||||
}
|
||||
|
||||
do {
|
||||
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
||||
let timeoutMs = Int(Self.previewTimeoutSeconds * 1000)
|
||||
let payload = try await AsyncTimeout.withTimeout(
|
||||
seconds: self.previewTimeoutSeconds,
|
||||
seconds: Self.previewTimeoutSeconds,
|
||||
onTimeout: { PreviewTimeoutError() },
|
||||
operation: {
|
||||
try await GatewayConnection.shared.chatHistory(
|
||||
sessionKey: sessionKey,
|
||||
limit: self.previewLimit(for: maxItems),
|
||||
sessionKey: self.sessionKey,
|
||||
limit: self.previewLimit,
|
||||
timeoutMs: timeoutMs)
|
||||
})
|
||||
let built = Self.previewItems(from: payload, maxItems: maxItems)
|
||||
await SessionPreviewCache.shared.store(items: built, for: sessionKey)
|
||||
return Self.snapshot(from: built)
|
||||
let built = Self.previewItems(from: payload, maxItems: self.maxItems)
|
||||
await SessionPreviewCache.shared.store(items: built, for: self.sessionKey)
|
||||
await MainActor.run {
|
||||
self.items = built
|
||||
self.status = built.isEmpty ? .empty : .ready
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return SessionMenuPreviewSnapshot(items: [], status: .loading)
|
||||
return
|
||||
} catch {
|
||||
let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey)
|
||||
if let fallback {
|
||||
return Self.snapshot(from: fallback)
|
||||
let fallback = await SessionPreviewCache.shared.lastItems(for: self.sessionKey)
|
||||
await MainActor.run {
|
||||
if let fallback {
|
||||
self.items = fallback
|
||||
self.status = fallback.isEmpty ? .empty : .ready
|
||||
} else {
|
||||
self.status = .error("Preview unavailable")
|
||||
}
|
||||
}
|
||||
let errorDescription = String(describing: error)
|
||||
Self.logger.warning(
|
||||
"Session preview failed session=\(sessionKey, privacy: .public) " +
|
||||
"Session preview failed session=\(self.sessionKey, privacy: .public) " +
|
||||
"error=\(errorDescription, privacy: .public)")
|
||||
return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable"))
|
||||
}
|
||||
}
|
||||
|
||||
private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot {
|
||||
SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
||||
}
|
||||
|
||||
private static func previewLimit(for maxItems: Int) -> Int {
|
||||
min(max(maxItems * 3, 20), 120)
|
||||
}
|
||||
|
||||
private static func previewItems(
|
||||
from payload: ClawdbotChatHistoryPayload,
|
||||
maxItems: Int) -> [SessionPreviewItem]
|
||||
|
||||
@@ -75,6 +75,18 @@ struct UsageRow: Identifiable {
|
||||
extension GatewayUsageSummary {
|
||||
func primaryRows() -> [UsageRow] {
|
||||
self.providers.compactMap { provider in
|
||||
if let error = provider.error, provider.windows.isEmpty {
|
||||
return UsageRow(
|
||||
id: provider.provider,
|
||||
providerId: provider.provider,
|
||||
displayName: provider.displayName,
|
||||
plan: provider.plan,
|
||||
windowLabel: nil,
|
||||
usedPercent: nil,
|
||||
resetAt: nil,
|
||||
error: error)
|
||||
}
|
||||
|
||||
guard let window = provider.windows.max(by: { $0.usedPercent < $1.usedPercent }) else {
|
||||
return nil
|
||||
}
|
||||
@@ -87,7 +99,7 @@ extension GatewayUsageSummary {
|
||||
windowLabel: window.label,
|
||||
usedPercent: window.usedPercent,
|
||||
resetAt: window.resetAt.map { Date(timeIntervalSince1970: $0 / 1000) },
|
||||
error: nil)
|
||||
error: provider.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,12 +37,14 @@ struct UsageMenuLabelView: View {
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
Text(self.row.detailText())
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.secondaryTextColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.layoutPriority(2)
|
||||
if !self.row.hasError {
|
||||
Text(self.row.detailText())
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.secondaryTextColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.layoutPriority(2)
|
||||
}
|
||||
|
||||
if self.showsChevron {
|
||||
Image(systemName: "chevron.right")
|
||||
@@ -52,6 +54,15 @@ struct UsageMenuLabelView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if let error = self.row.error?.nonEmpty {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.secondaryTextColor)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.tail)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.leading, self.paddingLeading)
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite(.serialized)
|
||||
struct SessionMenuPreviewTests {
|
||||
@Test func loaderReturnsCachedItems() async {
|
||||
await SessionPreviewCache.shared._testReset()
|
||||
let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")]
|
||||
await SessionPreviewCache.shared._testSet(items: items, for: "main")
|
||||
|
||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||
#expect(snapshot.status == .ready)
|
||||
#expect(snapshot.items.count == 1)
|
||||
#expect(snapshot.items.first?.text == "Hi")
|
||||
}
|
||||
|
||||
@Test func loaderReturnsEmptyWhenCachedEmpty() async {
|
||||
await SessionPreviewCache.shared._testReset()
|
||||
await SessionPreviewCache.shared._testSet(items: [], for: "main")
|
||||
|
||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||
#expect(snapshot.status == .empty)
|
||||
#expect(snapshot.items.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -287,7 +287,7 @@ ack reaction after the bot replies.
|
||||
|
||||
- `dm.enabled`: set `false` to ignore all DMs (default `true`).
|
||||
- `dm.policy`: DM access control (`pairing` recommended). `"open"` requires `dm.allowFrom=["*"]`.
|
||||
- `dm.allowFrom`: DM allowlist (user ids or names). Used by `dm.policy="allowlist"` and for `dm.policy="open"` validation. The wizard accepts usernames and resolves them to ids when the bot can search members.
|
||||
- `dm.allowFrom`: DM allowlist (user ids or names). Used by `dm.policy="allowlist"` and for `dm.policy="open"` validation.
|
||||
- `dm.groupEnabled`: enable group DMs (default `false`).
|
||||
- `dm.groupChannels`: optional allowlist for group DM channel ids or slugs.
|
||||
- `groupPolicy`: controls guild channel handling (`open|disabled|allowlist`); `allowlist` requires channel allowlists.
|
||||
|
||||
@@ -244,7 +244,7 @@ Provider options:
|
||||
- `channels.imessage.service`: `imessage | sms | auto`.
|
||||
- `channels.imessage.region`: SMS region.
|
||||
- `channels.imessage.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.imessage.allowFrom`: DM allowlist (handles, emails, E.164 numbers, or `chat_id:*`). `open` requires `"*"`. iMessage has no usernames; use handles or chat targets.
|
||||
- `channels.imessage.allowFrom`: DM allowlist (handles or `chat_id:*`). `open` requires `"*"`.
|
||||
- `channels.imessage.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.imessage.groupAllowFrom`: group sender allowlist.
|
||||
- `channels.imessage.historyLimit` / `channels.imessage.accounts.*.historyLimit`: max group messages to include as context (0 disables).
|
||||
|
||||
@@ -70,7 +70,7 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis
|
||||
- `clawdbot pairing list matrix`
|
||||
- `clawdbot pairing approve matrix <CODE>`
|
||||
- Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`.
|
||||
- `channels.matrix.dm.allowFrom` accepts user IDs or display names. The wizard resolves display names to user IDs when directory search is available.
|
||||
- `channels.matrix.dm.allowFrom` accepts user IDs or display names (resolved at startup when directory search is available).
|
||||
|
||||
## Rooms (groups)
|
||||
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset.
|
||||
@@ -121,7 +121,7 @@ Provider options:
|
||||
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
||||
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
||||
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible.
|
||||
- `channels.matrix.dm.allowFrom`: DM allowlist. `open` requires `"*"`.
|
||||
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
|
||||
- `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms.
|
||||
- `channels.matrix.rooms`: per-room settings and allowlist.
|
||||
|
||||
@@ -76,7 +76,7 @@ Disable with:
|
||||
|
||||
**DM access**
|
||||
- Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved.
|
||||
- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names. The wizard resolves names to IDs via Microsoft Graph when credentials allow.
|
||||
- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names (resolved at startup when Graph allows).
|
||||
|
||||
**Group access**
|
||||
- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). Use `channels.defaults.groupPolicy` to override the default when unset.
|
||||
@@ -413,7 +413,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
|
||||
- `channels.msteams.webhook.port` (default `3978`)
|
||||
- `channels.msteams.webhook.path` (default `/api/messages`)
|
||||
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
||||
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available.
|
||||
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs or UPNs).
|
||||
- `channels.msteams.textChunkLimit`: outbound text chunk size.
|
||||
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
|
||||
- `channels.msteams.requireMention`: require @mention in channels/groups (default true).
|
||||
|
||||
@@ -120,7 +120,7 @@ Provider options:
|
||||
- `channels.signal.ignoreStories`: ignore stories from the daemon.
|
||||
- `channels.signal.sendReadReceipts`: forward read receipts.
|
||||
- `channels.signal.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.signal.allowFrom`: DM allowlist (E.164 or `uuid:<id>`). `open` requires `"*"`. Signal has no usernames; use phone/UUID ids.
|
||||
- `channels.signal.allowFrom`: DM allowlist (E.164 or `uuid:<id>`). `open` requires `"*"`.
|
||||
- `channels.signal.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.signal.groupAllowFrom`: group sender allowlist.
|
||||
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
|
||||
|
||||
@@ -378,7 +378,7 @@ For fine-grained control, use these tags in agent responses:
|
||||
- Default: `channels.slack.dm.policy="pairing"` — unknown DM senders get a pairing code (expires after 1 hour).
|
||||
- Approve via: `clawdbot pairing approve slack <code>`.
|
||||
- To allow anyone: set `channels.slack.dm.policy="open"` and `channels.slack.dm.allowFrom=["*"]`.
|
||||
- `channels.slack.dm.allowFrom` accepts user IDs, @handles, or emails (resolved at startup when tokens allow). The wizard accepts usernames and resolves them to ids during setup when tokens allow.
|
||||
- `channels.slack.dm.allowFrom` accepts user IDs, @handles, or emails (resolved at startup when tokens allow).
|
||||
|
||||
## Group policy
|
||||
- `channels.slack.groupPolicy` controls channel handling (`open|disabled|allowlist`).
|
||||
|
||||
@@ -305,7 +305,7 @@ Use the global setting when all Telegram bots/accounts should behave the same. U
|
||||
- `clawdbot pairing list telegram`
|
||||
- `clawdbot pairing approve telegram <CODE>`
|
||||
- Pairing is the default token exchange used for Telegram DMs. Details: [Pairing](/start/pairing)
|
||||
- `channels.telegram.allowFrom` accepts numeric user IDs (recommended) or `@username` entries. It is **not** the bot username; use the human sender’s ID. The wizard accepts `@username` and resolves it to the numeric ID when possible.
|
||||
- `channels.telegram.allowFrom` accepts numeric user IDs (recommended) or `@username` entries. It is **not** the bot username; use the human sender’s ID.
|
||||
|
||||
#### Finding your Telegram user ID
|
||||
Safer (no third-party bot):
|
||||
|
||||
@@ -308,7 +308,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
||||
## Config quick map
|
||||
- `channels.whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled).
|
||||
- `channels.whatsapp.selfChatMode` (same-phone setup; bot uses your personal WhatsApp number).
|
||||
- `channels.whatsapp.allowFrom` (DM allowlist). WhatsApp uses E.164 phone numbers (no usernames).
|
||||
- `channels.whatsapp.allowFrom` (DM allowlist).
|
||||
- `channels.whatsapp.mediaMaxMb` (inbound media save cap).
|
||||
- `channels.whatsapp.ackReaction` (auto-reaction on message receipt: `{emoji, direct, group}`).
|
||||
- `channels.whatsapp.accounts.<accountId>.*` (per-account settings + optional `authDir`).
|
||||
|
||||
@@ -92,7 +92,7 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
|
||||
- `clawdbot pairing list zalo`
|
||||
- `clawdbot pairing approve zalo <CODE>`
|
||||
- Pairing is the default token exchange. Details: [Pairing](/start/pairing)
|
||||
- `channels.zalo.allowFrom` accepts numeric user IDs (no username lookup available).
|
||||
- `channels.zalo.allowFrom` accepts numeric user IDs.
|
||||
|
||||
## Long-polling vs webhook
|
||||
- Default: long-polling (no public URL required).
|
||||
@@ -147,7 +147,7 @@ Provider options:
|
||||
- `channels.zalo.botToken`: bot token from Zalo Bot Platform.
|
||||
- `channels.zalo.tokenFile`: read token from file path.
|
||||
- `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs.
|
||||
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`.
|
||||
- `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5).
|
||||
- `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required).
|
||||
- `channels.zalo.webhookSecret`: webhook secret (8-256 chars).
|
||||
|
||||
@@ -66,7 +66,7 @@ clawdbot directory groups list --channel zalouser --query "work"
|
||||
|
||||
## Access control (DMs)
|
||||
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
|
||||
`channels.zalouser.allowFrom` accepts user IDs or names. The wizard resolves names to IDs via `zca friend find` when available.
|
||||
`channels.zalouser.allowFrom` accepts user IDs or names (resolved at startup when available).
|
||||
|
||||
Approve via:
|
||||
- `clawdbot pairing list zalouser`
|
||||
|
||||
+1
-3
@@ -7,7 +7,6 @@ read_when:
|
||||
# `clawdbot agent`
|
||||
|
||||
Run an agent turn via the Gateway (use `--local` for embedded).
|
||||
Use `--agent <id>` to target a configured agent directly.
|
||||
|
||||
Related:
|
||||
- Agent send tool: [Agent send](/tools/agent-send)
|
||||
@@ -16,7 +15,6 @@ Related:
|
||||
|
||||
```bash
|
||||
clawdbot agent --to +15555550123 --message "status update" --deliver
|
||||
clawdbot agent --agent ops --message "Summarize logs"
|
||||
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
|
||||
clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
|
||||
```
|
||||
|
||||
@@ -9,7 +9,7 @@ read_when:
|
||||
|
||||
Clawdbot standardizes timestamps so the model sees a **single reference time**.
|
||||
|
||||
## Message envelopes (UTC by default)
|
||||
## Message envelopes (UTC)
|
||||
|
||||
Inbound messages are wrapped in an envelope like:
|
||||
|
||||
@@ -17,46 +17,7 @@ Inbound messages are wrapped in an envelope like:
|
||||
[Provider ... 2026-01-05T21:26Z] message text
|
||||
```
|
||||
|
||||
The timestamp in the envelope is **UTC by default**, with minutes precision.
|
||||
|
||||
You can override this with:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "user", // "utc" | "local" | "user" | IANA timezone
|
||||
envelopeTimestamp: "on", // "on" | "off"
|
||||
envelopeElapsed: "on" // "on" | "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
||||
- Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset.
|
||||
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
|
||||
- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style).
|
||||
|
||||
### Examples
|
||||
|
||||
**UTC (default):**
|
||||
|
||||
```
|
||||
[Signal Alice +1555 2026-01-18T05:19Z] hello
|
||||
```
|
||||
|
||||
**Fixed timezone:**
|
||||
|
||||
```
|
||||
[Signal Alice +1555 2026-01-18 06:19 GMT+1] hello
|
||||
```
|
||||
|
||||
**Elapsed time:**
|
||||
|
||||
```
|
||||
[Signal Alice +1555 +2m 2026-01-18T05:19Z] follow-up
|
||||
```
|
||||
The timestamp in the envelope is **always UTC**, with minutes precision.
|
||||
|
||||
## Tool payloads (raw provider data + normalized fields)
|
||||
|
||||
|
||||
+4
-44
@@ -7,10 +7,10 @@ read_when:
|
||||
|
||||
# Date & Time
|
||||
|
||||
Clawdbot defaults to **UTC for transport timestamps** and **user-local time only in the system prompt**.
|
||||
Provider timestamps are preserved so tools keep their native semantics.
|
||||
Clawdbot uses **UTC for transport timestamps** and **user-local time only in the system prompt**.
|
||||
We avoid rewriting provider timestamps so tools keep their native semantics.
|
||||
|
||||
## Message envelopes (UTC by default)
|
||||
## Message envelopes (UTC)
|
||||
|
||||
Inbound messages are wrapped with a UTC timestamp (minute precision):
|
||||
|
||||
@@ -18,47 +18,7 @@ Inbound messages are wrapped with a UTC timestamp (minute precision):
|
||||
[Provider ... 2026-01-05T21:26Z] message text
|
||||
```
|
||||
|
||||
This envelope timestamp is **UTC by default**, regardless of the host timezone.
|
||||
|
||||
You can override this behavior:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "utc", // "utc" | "local" | "user" | IANA timezone
|
||||
envelopeTimestamp: "on", // "on" | "off"
|
||||
envelopeElapsed: "on" // "on" | "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `envelopeTimezone: "local"` uses the host timezone.
|
||||
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
||||
- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone.
|
||||
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
|
||||
- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style).
|
||||
|
||||
### Examples
|
||||
|
||||
**UTC (default):**
|
||||
|
||||
```
|
||||
[WhatsApp +1555 2026-01-18T05:19Z] hello
|
||||
```
|
||||
|
||||
**User timezone:**
|
||||
|
||||
```
|
||||
[WhatsApp +1555 2026-01-18 00:19 CST] hello
|
||||
```
|
||||
|
||||
**Elapsed time enabled:**
|
||||
|
||||
```
|
||||
[WhatsApp +1555 +30s 2026-01-18T05:19Z] follow-up
|
||||
```
|
||||
This envelope timestamp is **always UTC**, regardless of the host timezone.
|
||||
|
||||
## System prompt: Current Date & Time
|
||||
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
---
|
||||
summary: Node + tsx "__name is not a function" crash notes and workarounds
|
||||
read_when:
|
||||
- Debugging Node-only dev scripts or watch mode failures
|
||||
- Investigating tsx/esbuild loader crashes in Clawdbot
|
||||
---
|
||||
|
||||
# Node + tsx "__name is not a function" crash
|
||||
|
||||
## Summary
|
||||
Running Clawdbot via Node with `tsx` fails at startup with:
|
||||
|
||||
```
|
||||
[clawdbot] Failed to start CLI: TypeError: __name is not a function
|
||||
at createSubsystemLogger (.../src/logging/subsystem.ts:203:25)
|
||||
at .../src/agents/auth-profiles/constants.ts:25:20
|
||||
```
|
||||
|
||||
This began after switching dev scripts from Bun to `tsx` (commit `2871657e`, 2026-01-06). The same runtime path worked with Bun.
|
||||
|
||||
## Environment
|
||||
- Node: v25.x (observed on v25.3.0)
|
||||
- tsx: 4.21.0
|
||||
- OS: macOS (repro also likely on other platforms that run Node 25)
|
||||
|
||||
## Repro (Node-only)
|
||||
```bash
|
||||
# in repo root
|
||||
node --version
|
||||
pnpm install
|
||||
node --import tsx src/entry.ts status
|
||||
```
|
||||
|
||||
## Minimal repro in repo
|
||||
```bash
|
||||
node --import tsx scripts/repro/tsx-name-repro.ts
|
||||
```
|
||||
|
||||
## Node version check
|
||||
- Node 25.3.0: fails
|
||||
- Node 22.22.0 (Homebrew `node@22`): fails
|
||||
- Node 24: not installed here yet; needs verification
|
||||
|
||||
## Notes / hypothesis
|
||||
- `tsx` uses esbuild to transform TS/ESM. esbuild’s `keepNames` emits a `__name` helper and wraps function definitions with `__name(...)`.
|
||||
- The crash indicates `__name` exists but is not a function at runtime, which implies the helper is missing or overwritten for this module in the Node 25 loader path.
|
||||
- Similar `__name` helper issues have been reported in other esbuild consumers when the helper is missing or rewritten.
|
||||
|
||||
## Regression history
|
||||
- `2871657e` (2026-01-06): scripts changed from Bun to tsx to make Bun optional.
|
||||
- Before that (Bun path), `pnpm clawdbot status` and `gateway:watch` worked.
|
||||
|
||||
## Workarounds
|
||||
- Use Bun for dev scripts (current temporary revert).
|
||||
- Use Node + tsc watch, then run compiled output:
|
||||
```bash
|
||||
pnpm exec tsc --watch --preserveWatchOutput
|
||||
node --watch dist/entry.js status
|
||||
```
|
||||
- Confirmed locally: `pnpm exec tsc -p tsconfig.json` + `node dist/entry.js status` works on Node 25.
|
||||
- Disable esbuild keepNames in the TS loader if possible (prevents `__name` helper insertion); tsx does not currently expose this.
|
||||
- Test Node LTS (22/24) with `tsx` to see if the issue is Node 25–specific.
|
||||
|
||||
## References
|
||||
- https://opennext.js.org/cloudflare/howtos/keep_names
|
||||
- https://esbuild.github.io/api/#keep-names
|
||||
- https://github.com/evanw/esbuild/issues/1031
|
||||
|
||||
## Next steps
|
||||
- Repro on Node 22/24 to confirm Node 25 regression.
|
||||
- Test `tsx` nightly or pin to earlier version if a known regression exists.
|
||||
- If reproduces on Node LTS, file a minimal repro upstream with the `__name` stack trace.
|
||||
@@ -5,6 +5,13 @@ read_when:
|
||||
---
|
||||
# Security 🔒
|
||||
|
||||
Running an AI agent with shell access on your machine is... *spicy*. Here’s how to not get pwned.
|
||||
|
||||
Clawdbot is both a product and an experiment: you’re wiring frontier-model behavior into real messaging surfaces and real tools. **There is no “perfectly secure” setup.** The goal is to be deliberate about:
|
||||
- who can talk to your bot
|
||||
- where the bot is allowed to act
|
||||
- what the bot can touch
|
||||
|
||||
## Quick check: `clawdbot security audit`
|
||||
|
||||
Run this regularly (especially after changing config or exposing network surfaces):
|
||||
@@ -22,13 +29,6 @@ It flags common footguns (Gateway auth exposure, browser control exposure, eleva
|
||||
- Turn `logging.redactSensitive="off"` back to `"tools"`.
|
||||
- Tighten local perms (`~/.clawdbot` → `700`, config file → `600`, plus common state files like `credentials/*.json`, `agents/*/agent/auth-profiles.json`, and `agents/*/sessions/sessions.json`).
|
||||
|
||||
Running an AI agent with shell access on your machine is... *spicy*. Here’s how to not get pwned.
|
||||
|
||||
Clawdbot is both a product and an experiment: you’re wiring frontier-model behavior into real messaging surfaces and real tools. **There is no “perfectly secure” setup.** The goal is to be deliberate about:
|
||||
- who can talk to your bot
|
||||
- where the bot is allowed to act
|
||||
- what the bot can touch
|
||||
|
||||
### What the audit checks (high level)
|
||||
|
||||
- **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot?
|
||||
|
||||
@@ -12,7 +12,6 @@ Clawdbot ships two installer scripts (served from `clawd.bot`):
|
||||
|
||||
- `https://clawd.bot/install.sh` — “recommended” installer (global npm install by default; can also install from a GitHub checkout)
|
||||
- `https://clawd.bot/install-cli.sh` — non-root-friendly CLI installer (installs into a prefix with its own Node)
|
||||
- `https://clawd.bot/install.ps1` — Windows PowerShell installer (npm by default; optional git install)
|
||||
|
||||
To see the current flags/behavior, run:
|
||||
|
||||
@@ -20,12 +19,6 @@ To see the current flags/behavior, run:
|
||||
curl -fsSL https://clawd.bot/install.sh | bash -s -- --help
|
||||
```
|
||||
|
||||
Windows (PowerShell) help:
|
||||
|
||||
```powershell
|
||||
& ([scriptblock]::Create((iwr -useb https://clawd.bot/install.ps1))) -?
|
||||
```
|
||||
|
||||
If the installer completes but `clawdbot` is not found in a new terminal, it’s usually a Node/npm PATH issue. See: [Install](/install#nodejs--npm-path-sanity).
|
||||
|
||||
## install.sh (recommended)
|
||||
@@ -80,37 +73,3 @@ Help:
|
||||
```bash
|
||||
curl -fsSL https://clawd.bot/install-cli.sh | bash -s -- --help
|
||||
```
|
||||
|
||||
## install.ps1 (Windows PowerShell)
|
||||
|
||||
What it does (high level):
|
||||
|
||||
- Ensure Node.js **22+** (winget/Chocolatey/Scoop or manual).
|
||||
- Choose install method:
|
||||
- `npm` (default): `npm install -g clawdbot@latest`
|
||||
- `git`: clone/build a source checkout and install a wrapper script
|
||||
- Runs `clawdbot doctor --non-interactive` on upgrades and git installs (best effort).
|
||||
|
||||
Examples:
|
||||
|
||||
```powershell
|
||||
iwr -useb https://clawd.bot/install.ps1 | iex
|
||||
```
|
||||
|
||||
```powershell
|
||||
iwr -useb https://clawd.bot/install.ps1 | iex -InstallMethod git
|
||||
```
|
||||
|
||||
```powershell
|
||||
iwr -useb https://clawd.bot/install.ps1 | iex -InstallMethod git -GitDir "C:\\clawdbot"
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
|
||||
- `CLAWDBOT_INSTALL_METHOD=git|npm`
|
||||
- `CLAWDBOT_GIT_DIR=...`
|
||||
|
||||
Git requirement:
|
||||
|
||||
If you choose `-InstallMethod git` and Git is missing, the installer will print the
|
||||
Git for Windows link (`https://git-scm.com/download/win`) and exit.
|
||||
@@ -5,7 +5,9 @@ read_when:
|
||||
---
|
||||
# Clawdbot macOS IPC architecture
|
||||
|
||||
**Current model:** a local Unix socket connects the **node service** to the **macOS app** for exec approvals + `system.run`. There is no `clawdbot-mac` CLI; agent actions still flow through the Gateway WebSocket and `node.invoke`. UI automation uses PeekabooBridge.
|
||||
**Current model:** there is **no local control socket** and no `clawdbot-mac` CLI. All agent actions go through the Gateway WebSocket and `node.invoke`. UI automation still uses PeekabooBridge.
|
||||
|
||||
**Planned model:** add a local Unix socket between the **node service** and the **macOS app**. The app owns `system.run` (UI/TCC context); the node service forwards exec requests over IPC.
|
||||
|
||||
## Goals
|
||||
- Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript).
|
||||
@@ -13,11 +15,11 @@ read_when:
|
||||
- Predictable permissions: always the same signed bundle ID, launched by launchd, so TCC grants stick.
|
||||
|
||||
## How it works
|
||||
### Gateway + node bridge
|
||||
### Gateway + node bridge (current)
|
||||
- The app runs the Gateway (local mode) and connects to it as a node.
|
||||
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
|
||||
|
||||
### Node service + app IPC
|
||||
### Node service + app IPC (planned)
|
||||
- A headless node service connects to the Gateway bridge.
|
||||
- `system.run` requests are forwarded to the macOS app over a local Unix socket.
|
||||
- The app performs the exec in UI context, prompts if needed, and returns output.
|
||||
@@ -36,6 +38,10 @@ Agent -> Gateway -> Bridge -> Node Service (TS)
|
||||
- Security: bridge hosts require an allowed TeamID; DEBUG-only same-UID escape hatch is guarded by `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (Peekaboo convention).
|
||||
- See: [PeekabooBridge usage](/platforms/mac/peekaboo) for details.
|
||||
|
||||
### Mach/XPC
|
||||
- Not required for automation; `node.invoke` + PeekabooBridge cover current needs.
|
||||
- Planned IPC keeps Unix sockets (no XPC helper).
|
||||
|
||||
## Operational flows
|
||||
- Restart/rebuild: `SIGN_IDENTITY="Apple Development: <Developer Name> (<TEAMID>)" scripts/restart-mac.sh`
|
||||
- Kills existing instances
|
||||
@@ -48,4 +54,4 @@ Agent -> Gateway -> Bridge -> Node Service (TS)
|
||||
- PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development.
|
||||
- All communication remains local-only; no network sockets are exposed.
|
||||
- TCC prompts originate only from the GUI app bundle; keep the signed bundle ID stable across rebuilds.
|
||||
- IPC hardening: socket mode `0600`, token, peer-UID checks, HMAC challenge/response, short TTL.
|
||||
- Planned IPC hardening: socket mode `0600`, token, peer-UID checks, HMAC challenge/response, short TTL.
|
||||
@@ -17,17 +17,19 @@ capabilities to the agent as a node.
|
||||
Speech Recognition, Automation/AppleScript).
|
||||
- Runs or connects to the Gateway (local or remote).
|
||||
- Exposes macOS‑only tools (Canvas, Camera, Screen Recording, `system.run`).
|
||||
- Starts the local node host service in **remote** mode (launchd), and stops it in **local** mode.
|
||||
- Optionally hosts **PeekabooBridge** for UI automation.
|
||||
- Installs the global CLI (`clawdbot`) via npm/pnpm on request (bun not recommended for the Gateway runtime).
|
||||
|
||||
Planned:
|
||||
- Run a headless **node service** locally (launchd).
|
||||
- Keep `system.run` in the app (UI/TCC context), with the node service forwarding via IPC.
|
||||
|
||||
## Local vs remote mode
|
||||
|
||||
- **Local** (default): the app attaches to a running local Gateway if present;
|
||||
otherwise it enables the launchd service via `clawdbot daemon`.
|
||||
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
|
||||
a local process.
|
||||
The app starts the local **node host service** so the remote Gateway can reach this Mac.
|
||||
The app does not spawn the Gateway as a child process.
|
||||
|
||||
## Launchd control
|
||||
@@ -56,9 +58,9 @@ The macOS app presents itself as a node. Common commands:
|
||||
|
||||
The node reports a `permissions` map so agents can decide what’s allowed.
|
||||
|
||||
Node service + app IPC:
|
||||
- When the headless node service is running (remote mode), it connects to the Gateway bridge.
|
||||
- `system.run` executes in the macOS app (UI/TCC context) over a local Unix socket; prompts + output stay in-app.
|
||||
Planned split:
|
||||
- Node service advertises the node surface to the Gateway.
|
||||
- macOS app performs `system.run` in UI context over IPC.
|
||||
|
||||
Diagram (SCI):
|
||||
```
|
||||
|
||||
@@ -14,15 +14,13 @@ runtime on the current machine.
|
||||
- Required: `--message <text>`
|
||||
- Session selection:
|
||||
- `--to <dest>` derives the session key (group/channel targets preserve isolation; direct chats collapse to `main`), **or**
|
||||
- `--session-id <id>` reuses an existing session by id, **or**
|
||||
- `--agent <id>` targets a configured agent directly (uses that agent's `main` session key)
|
||||
- `--session-id <id>` reuses an existing session by id
|
||||
- Runs the same embedded agent runtime as normal inbound replies.
|
||||
- Thinking/verbose flags persist into the session store.
|
||||
- Output:
|
||||
- default: prints reply text (plus `MEDIA:<url>` lines)
|
||||
- `--json`: prints structured payload + metadata
|
||||
- Optional delivery back to a channel with `--deliver` + `--channel` (target formats match `clawdbot message --target`).
|
||||
- Use `--reply-channel`/`--reply-to`/`--reply-account` to override delivery without changing the session.
|
||||
|
||||
If the Gateway is unreachable, the CLI **falls back** to the embedded local run.
|
||||
|
||||
@@ -30,21 +28,16 @@ If the Gateway is unreachable, the CLI **falls back** to the embedded local run.
|
||||
|
||||
```bash
|
||||
clawdbot agent --to +15555550123 --message "status update"
|
||||
clawdbot agent --agent ops --message "Summarize logs"
|
||||
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
|
||||
clawdbot agent --to +15555550123 --message "Trace logs" --verbose on --json
|
||||
clawdbot agent --to +15555550123 --message "Summon reply" --deliver
|
||||
clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
- `--local`: run locally (requires model provider API keys in your shell)
|
||||
- `--deliver`: send the reply to the chosen channel
|
||||
- `--channel`: delivery channel (`whatsapp|telegram|discord|slack|signal|imessage`, default: `whatsapp`)
|
||||
- `--reply-to`: delivery target override
|
||||
- `--reply-channel`: delivery channel override
|
||||
- `--reply-account`: delivery account id override
|
||||
- `--deliver`: send the reply to the chosen channel (requires `--to`)
|
||||
- `--channel`: `whatsapp|telegram|discord|slack|signal|imessage` (default: `whatsapp`)
|
||||
- `--thinking <off|minimal|low|medium|high|xhigh>`: persist thinking level (GPT-5.2 + Codex models only)
|
||||
- `--verbose <on|full|off>`: persist verbose level
|
||||
- `--timeout <seconds>`: override agent timeout
|
||||
|
||||
@@ -836,20 +836,10 @@ async function processMessage(
|
||||
const fromLabel = message.isGroup
|
||||
? `group:${peerId}`
|
||||
: message.senderName || `user:${message.senderId}`;
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "BlueBubbles",
|
||||
from: fromLabel,
|
||||
timestamp: message.timestamp,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: rawBody,
|
||||
});
|
||||
let chatGuidForActions = chatGuid;
|
||||
|
||||
@@ -466,34 +466,25 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
isThreadRoot: event.isThreadRoot,
|
||||
});
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "matrix",
|
||||
peer: {
|
||||
kind: isDirectMessage ? "dm" : "channel",
|
||||
id: isDirectMessage ? senderId : roomId,
|
||||
},
|
||||
});
|
||||
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
||||
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Matrix",
|
||||
from: envelopeFrom,
|
||||
timestamp: event.getTs() ?? undefined,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: textWithId,
|
||||
});
|
||||
|
||||
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "matrix",
|
||||
peer: {
|
||||
kind: isDirectMessage ? "dm" : "channel",
|
||||
id: isDirectMessage ? senderId : roomId,
|
||||
},
|
||||
});
|
||||
|
||||
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: bodyText,
|
||||
@@ -526,10 +517,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
OriginatingTo: `room:${roomId}`,
|
||||
});
|
||||
|
||||
void core.channel.session.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
void core.channel.session.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
}).catch((err) => {
|
||||
logger.warn(
|
||||
{ error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
type WizardPrompter,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
|
||||
import { listMatrixDirectoryPeersLive } from "./directory-live.js";
|
||||
import { resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
|
||||
import type { CoreConfig, DmPolicy } from "./types.js";
|
||||
@@ -50,86 +49,40 @@ async function promptMatrixAllowFrom(params: {
|
||||
}): Promise<CoreConfig> {
|
||||
const { cfg, prompter } = params;
|
||||
const existingAllowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? [];
|
||||
const account = resolveMatrixAccount({ cfg });
|
||||
const canResolve = Boolean(account.configured);
|
||||
const entry = await prompter.text({
|
||||
message: "Matrix allowFrom (user id)",
|
||||
placeholder: "@user:server",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "Required";
|
||||
if (!raw.startsWith("@")) return "Matrix user IDs should start with @";
|
||||
if (!raw.includes(":")) return "Matrix user IDs should include a server (:@server)";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const normalized = String(entry).trim();
|
||||
const merged = [
|
||||
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
|
||||
normalized,
|
||||
];
|
||||
const unique = [...new Set(merged)];
|
||||
|
||||
const parseInput = (raw: string) =>
|
||||
raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const isFullUserId = (value: string) => value.startsWith("@") && value.includes(":");
|
||||
|
||||
while (true) {
|
||||
const entry = await prompter.text({
|
||||
message: "Matrix allowFrom (username or user id)",
|
||||
placeholder: "@user:server",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
const parts = parseInput(String(entry));
|
||||
const resolvedIds: string[] = [];
|
||||
let unresolved: string[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (isFullUserId(part)) {
|
||||
resolvedIds.push(part);
|
||||
continue;
|
||||
}
|
||||
if (!canResolve) {
|
||||
unresolved.push(part);
|
||||
continue;
|
||||
}
|
||||
const results = await listMatrixDirectoryPeersLive({
|
||||
cfg,
|
||||
query: part,
|
||||
limit: 5,
|
||||
}).catch(() => []);
|
||||
const match = results.find((result) => result.id);
|
||||
if (match?.id) {
|
||||
resolvedIds.push(match.id);
|
||||
if (results.length > 1) {
|
||||
await prompter.note(
|
||||
`Multiple matches for "${part}", using ${match.id}.`,
|
||||
"Matrix allowlist",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
unresolved.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
if (unresolved.length > 0) {
|
||||
await prompter.note(
|
||||
`Could not resolve: ${unresolved.join(", ")}. Use full @user:server IDs.`,
|
||||
"Matrix allowlist",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const unique = [
|
||||
...new Set([
|
||||
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
|
||||
...resolvedIds,
|
||||
]),
|
||||
];
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
matrix: {
|
||||
...cfg.channels?.matrix,
|
||||
enabled: true,
|
||||
dm: {
|
||||
...cfg.channels?.matrix?.dm,
|
||||
policy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
matrix: {
|
||||
...cfg.channels?.matrix,
|
||||
enabled: true,
|
||||
dm: {
|
||||
...cfg.channels?.matrix?.dm,
|
||||
policy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") {
|
||||
@@ -168,7 +121,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
allowFromKey: "channels.matrix.dm.allowFrom",
|
||||
getCurrent: (cfg) => (cfg as CoreConfig).channels?.matrix?.dm?.policy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setMatrixDmPolicy(cfg as CoreConfig, policy),
|
||||
promptAllowFrom: promptMatrixAllowFrom,
|
||||
};
|
||||
|
||||
export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -406,20 +406,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
|
||||
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
||||
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Teams",
|
||||
from: envelopeFrom,
|
||||
timestamp,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: rawBody,
|
||||
});
|
||||
let combinedBody = body;
|
||||
@@ -431,16 +421,15 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (entry) =>
|
||||
core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Teams",
|
||||
from: conversationType,
|
||||
timestamp: entry.timestamp,
|
||||
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
|
||||
envelope: envelopeOptions,
|
||||
}),
|
||||
});
|
||||
}
|
||||
formatEntry: (entry) =>
|
||||
core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Teams",
|
||||
from: conversationType,
|
||||
timestamp: entry.timestamp,
|
||||
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
@@ -466,8 +455,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
...mediaPayload,
|
||||
});
|
||||
|
||||
void core.channel.session.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
void core.channel.session.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
}).catch((err) => {
|
||||
|
||||
@@ -16,7 +16,6 @@ import { resolveMSTeamsCredentials } from "./token.js";
|
||||
import {
|
||||
parseMSTeamsTeamEntry,
|
||||
resolveMSTeamsChannelAllowlist,
|
||||
resolveMSTeamsUserAllowlist,
|
||||
} from "./resolve-allowlist.js";
|
||||
|
||||
const channel = "msteams" as const;
|
||||
@@ -39,97 +38,6 @@ function setMSTeamsDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
|
||||
};
|
||||
}
|
||||
|
||||
function setMSTeamsAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
msteams: {
|
||||
...cfg.channels?.msteams,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseAllowFromInput(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function looksLikeGuid(value: string): boolean {
|
||||
return /^[0-9a-fA-F-]{16,}$/.test(value);
|
||||
}
|
||||
|
||||
async function promptMSTeamsAllowFrom(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
}): Promise<ClawdbotConfig> {
|
||||
const existing = params.cfg.channels?.msteams?.allowFrom ?? [];
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Allowlist MS Teams DMs by display name, UPN/email, or user id.",
|
||||
"We resolve names to user IDs via Microsoft Graph when credentials allow.",
|
||||
"Examples:",
|
||||
"- alex@example.com",
|
||||
"- Alex Johnson",
|
||||
"- 00000000-0000-0000-0000-000000000000",
|
||||
].join("\n"),
|
||||
"MS Teams allowlist",
|
||||
);
|
||||
|
||||
while (true) {
|
||||
const entry = await params.prompter.text({
|
||||
message: "MS Teams allowFrom (usernames or ids)",
|
||||
placeholder: "alex@example.com, Alex Johnson",
|
||||
initialValue: existing[0] ? String(existing[0]) : undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
const parts = parseAllowFromInput(String(entry));
|
||||
if (parts.length === 0) {
|
||||
await params.prompter.note("Enter at least one user.", "MS Teams allowlist");
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = await resolveMSTeamsUserAllowlist({
|
||||
cfg: params.cfg,
|
||||
entries: parts,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!resolved) {
|
||||
const ids = parts.filter((part) => looksLikeGuid(part));
|
||||
if (ids.length !== parts.length) {
|
||||
await params.prompter.note(
|
||||
"Graph lookup unavailable. Use user IDs only.",
|
||||
"MS Teams allowlist",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const unique = [
|
||||
...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids]),
|
||||
];
|
||||
return setMSTeamsAllowFrom(params.cfg, unique);
|
||||
}
|
||||
|
||||
const unresolved = resolved.filter((item) => !item.resolved || !item.id);
|
||||
if (unresolved.length > 0) {
|
||||
await params.prompter.note(
|
||||
`Could not resolve: ${unresolved.map((item) => item.input).join(", ")}`,
|
||||
"MS Teams allowlist",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ids = resolved.map((item) => item.id as string);
|
||||
const unique = [
|
||||
...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids]),
|
||||
];
|
||||
return setMSTeamsAllowFrom(params.cfg, unique);
|
||||
}
|
||||
}
|
||||
|
||||
async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
@@ -198,7 +106,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
allowFromKey: "channels.msteams.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.msteams?.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setMSTeamsDmPolicy(cfg, policy),
|
||||
promptAllowFrom: promptMSTeamsAllowFrom,
|
||||
};
|
||||
|
||||
export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
|
||||
@@ -530,20 +530,10 @@ async function processMessageWithPipeline(params: {
|
||||
}
|
||||
|
||||
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Zalo",
|
||||
from: fromLabel,
|
||||
timestamp: date ? date * 1000 : undefined,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
@@ -570,6 +560,9 @@ async function processMessageWithPipeline(params: {
|
||||
OriginatingTo: `zalo:${chatId}`,
|
||||
});
|
||||
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
void core.channel.session.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
|
||||
@@ -207,17 +207,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
allowFromKey: "channels.zalo.allowFrom",
|
||||
getCurrent: (cfg) => (cfg.channels?.zalo?.dmPolicy ?? "pairing") as "pairing",
|
||||
setPolicy: (cfg, policy) => setZaloDmPolicy(cfg as ClawdbotConfig, policy),
|
||||
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
|
||||
const id =
|
||||
accountId && normalizeAccountId(accountId)
|
||||
? normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID
|
||||
: resolveDefaultZaloAccountId(cfg as ClawdbotConfig);
|
||||
return promptZaloAllowFrom({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
prompter,
|
||||
accountId: id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
|
||||
@@ -274,20 +274,10 @@ async function processMessage(
|
||||
});
|
||||
|
||||
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Zalo Personal",
|
||||
from: fromLabel,
|
||||
timestamp: timestamp ? timestamp * 1000 : undefined,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
@@ -311,6 +301,9 @@ async function processMessage(
|
||||
OriginatingTo: `zalouser:${chatId}`,
|
||||
});
|
||||
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
void core.channel.session.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
checkZcaAuthenticated,
|
||||
} from "./accounts.js";
|
||||
import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from "./zca.js";
|
||||
import type { ZcaFriend, ZcaGroup } from "./types.js";
|
||||
import type { ZcaGroup } from "./types.js";
|
||||
|
||||
const channel = "zalouser" as const;
|
||||
|
||||
@@ -67,73 +67,25 @@ async function promptZalouserAllowFrom(params: {
|
||||
const { cfg, prompter, accountId } = params;
|
||||
const resolved = resolveZalouserAccountSync({ cfg, accountId });
|
||||
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
||||
const parseInput = (raw: string) =>
|
||||
raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const resolveUserId = async (input: string): Promise<string | null> => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/^\d+$/.test(trimmed)) return trimmed;
|
||||
const ok = await checkZcaInstalled();
|
||||
if (!ok) return null;
|
||||
const result = await runZca(["friend", "find", trimmed], {
|
||||
profile: resolved.profile,
|
||||
timeout: 15000,
|
||||
});
|
||||
if (!result.ok) return null;
|
||||
const parsed = parseJsonOutput<ZcaFriend[]>(result.stdout);
|
||||
const rows = Array.isArray(parsed) ? parsed : [];
|
||||
const match = rows[0];
|
||||
if (!match?.userId) return null;
|
||||
if (rows.length > 1) {
|
||||
await prompter.note(
|
||||
`Multiple matches for "${trimmed}", using ${match.displayName ?? match.userId}.`,
|
||||
"Zalo Personal allowlist",
|
||||
);
|
||||
}
|
||||
return String(match.userId);
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const entry = await prompter.text({
|
||||
message: "Zalouser allowFrom (username or user id)",
|
||||
placeholder: "Alice, 123456789",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
const parts = parseInput(String(entry));
|
||||
const results = await Promise.all(parts.map((part) => resolveUserId(part)));
|
||||
const unresolved = parts.filter((_, idx) => !results[idx]);
|
||||
if (unresolved.length > 0) {
|
||||
await prompter.note(
|
||||
`Could not resolve: ${unresolved.join(", ")}. Use numeric user ids or ensure zca is available.`,
|
||||
"Zalo Personal allowlist",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const merged = [
|
||||
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
|
||||
...(results.filter(Boolean) as string[]),
|
||||
];
|
||||
const unique = [...new Set(merged)];
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
enabled: true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
const entry = await prompter.text({
|
||||
message: "Zalouser allowFrom (user id)",
|
||||
placeholder: "123456789",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "Required";
|
||||
if (!/^\d+$/.test(raw)) return "Use a numeric Zalo user id";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const normalized = String(entry).trim();
|
||||
const merged = [
|
||||
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
|
||||
normalized,
|
||||
];
|
||||
const unique = [...new Set(merged)];
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
@@ -141,19 +93,32 @@ async function promptZalouserAllowFrom(params: {
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...(cfg.channels?.zalouser?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
|
||||
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...(cfg.channels?.zalouser?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
|
||||
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
|
||||
function setZalouserGroupPolicy(
|
||||
@@ -272,17 +237,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
allowFromKey: "channels.zalouser.allowFrom",
|
||||
getCurrent: (cfg) => ((cfg as ClawdbotConfig).channels?.zalouser?.dmPolicy ?? "pairing") as "pairing",
|
||||
setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as ClawdbotConfig, policy),
|
||||
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
|
||||
const id =
|
||||
accountId && normalizeAccountId(accountId)
|
||||
? normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID
|
||||
: resolveDefaultZalouserAccountId(cfg as ClawdbotConfig);
|
||||
return promptZalouserAllowFrom({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
prompter,
|
||||
accountId: id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
|
||||
+18
-18
@@ -65,27 +65,27 @@
|
||||
"dist/whatsapp/**"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "node scripts/run-node.mjs",
|
||||
"dev": "bun src/entry.ts",
|
||||
"postinstall": "node scripts/postinstall.js",
|
||||
"prepack": "pnpm build",
|
||||
"docs:list": "node scripts/docs-list.js",
|
||||
"docs:bin": "node scripts/build-docs-list.mjs",
|
||||
"docs:list": "bun scripts/docs-list.ts",
|
||||
"docs:bin": "bun build scripts/docs-list.ts --compile --outfile bin/docs-list",
|
||||
"docs:dev": "cd docs && mint dev",
|
||||
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
|
||||
"build": "tsc -p tsconfig.json && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts",
|
||||
"plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts",
|
||||
"release:check": "node --import tsx scripts/release-check.ts",
|
||||
"build": "tsc -p tsconfig.json && bun scripts/canvas-a2ui-copy.ts && bun scripts/copy-hook-metadata.ts && bun scripts/write-build-info.ts",
|
||||
"plugins:sync": "bun scripts/sync-plugin-versions.ts",
|
||||
"release:check": "bun scripts/release-check.ts",
|
||||
"ui:install": "node scripts/ui.js install",
|
||||
"ui:dev": "node scripts/ui.js dev",
|
||||
"ui:build": "node scripts/ui.js build",
|
||||
"start": "node scripts/run-node.mjs",
|
||||
"clawdbot": "node scripts/run-node.mjs",
|
||||
"gateway:watch": "node scripts/watch-node.mjs gateway --force",
|
||||
"gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway",
|
||||
"gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway --reset",
|
||||
"tui": "node scripts/run-node.mjs tui",
|
||||
"tui:dev": "CLAWDBOT_PROFILE=dev node scripts/run-node.mjs tui",
|
||||
"clawdbot:rpc": "node scripts/run-node.mjs agent --mode rpc --json",
|
||||
"start": "bun src/entry.ts",
|
||||
"clawdbot": "bun src/entry.ts",
|
||||
"gateway:watch": "bun --watch src/entry.ts gateway --force",
|
||||
"gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 bun src/entry.ts --dev gateway",
|
||||
"gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 bun src/entry.ts --dev gateway --reset",
|
||||
"tui": "bun src/entry.ts tui",
|
||||
"tui:dev": "CLAWDBOT_PROFILE=dev bun src/entry.ts tui",
|
||||
"clawdbot:rpc": "bun src/entry.ts agent --mode rpc --json",
|
||||
"ios:gen": "cd apps/ios && xcodegen generate",
|
||||
"ios:open": "cd apps/ios && xcodegen generate && open Clawdbot.xcodeproj",
|
||||
"ios:build": "bash -lc 'cd apps/ios && xcodegen generate && xcodebuild -project Clawdbot.xcodeproj -scheme Clawdbot -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'",
|
||||
@@ -108,7 +108,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "pnpm --dir ui test",
|
||||
"test:force": "node --import tsx scripts/test-force.ts",
|
||||
"test:force": "bun scripts/test-force.ts",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||
"test:live": "CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts",
|
||||
@@ -126,11 +126,11 @@
|
||||
"test:install:smoke": "bash scripts/test-install-sh-docker.sh",
|
||||
"test:install:e2e:openai": "CLAWDBOT_E2E_MODELS=openai bash scripts/test-install-sh-e2e-docker.sh",
|
||||
"test:install:e2e:anthropic": "CLAWDBOT_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh",
|
||||
"protocol:gen": "node --import tsx scripts/protocol-gen.ts",
|
||||
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
|
||||
"protocol:gen": "bun scripts/protocol-gen.ts",
|
||||
"protocol:gen:swift": "bun scripts/protocol-gen-swift.ts",
|
||||
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift",
|
||||
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
||||
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500"
|
||||
"check:loc": "tsx scripts/check-ts-max-loc.ts --max 500"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
Generated
+8
-8
@@ -265,13 +265,7 @@ importers:
|
||||
specifier: 40.0.0
|
||||
version: 40.0.0
|
||||
|
||||
extensions/memory-core:
|
||||
dependencies:
|
||||
clawdbot:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
extensions/memory-lancedb:
|
||||
extensions/memory:
|
||||
dependencies:
|
||||
'@lancedb/lancedb':
|
||||
specifier: ^0.15.0
|
||||
@@ -283,6 +277,12 @@ importers:
|
||||
specifier: ^4.104.0
|
||||
version: 4.104.0(ws@8.19.0)(zod@3.25.76)
|
||||
|
||||
extensions/memory-core:
|
||||
dependencies:
|
||||
clawdbot:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
extensions/msteams:
|
||||
dependencies:
|
||||
'@microsoft/agents-hosting':
|
||||
@@ -6625,7 +6625,7 @@ snapshots:
|
||||
|
||||
'@types/node-fetch@2.6.13':
|
||||
dependencies:
|
||||
'@types/node': 25.0.9
|
||||
'@types/node': 18.19.130
|
||||
form-data: 4.0.5
|
||||
|
||||
'@types/node@10.17.60': {}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const binDir = path.join(root, "bin");
|
||||
const scriptPath = path.join(root, "scripts", "docs-list.js");
|
||||
const binPath = path.join(binDir, "docs-list");
|
||||
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
const wrapper = `#!/usr/bin/env node\nimport { spawnSync } from "node:child_process";\nimport path from "node:path";\nimport { fileURLToPath } from "node:url";\n\nconst here = path.dirname(fileURLToPath(import.meta.url));\nconst script = path.join(here, "..", "scripts", "docs-list.js");\n\nconst result = spawnSync(process.execPath, [script], { stdio: "inherit" });\nprocess.exit(result.status ?? 1);\n`;
|
||||
|
||||
fs.writeFileSync(binPath, wrapper, { mode: 0o755 });
|
||||
@@ -49,16 +49,6 @@ for file in "${files[@]}"; do
|
||||
fi
|
||||
done
|
||||
|
||||
# Prevent staging node_modules even if a path is forced.
|
||||
for file in "${files[@]}"; do
|
||||
case "$file" in
|
||||
*node_modules* | */node_modules | */node_modules/* | node_modules)
|
||||
printf 'Error: node_modules paths are not allowed: %s\n' "$file" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
last_commit_error=''
|
||||
|
||||
run_git_commit() {
|
||||
|
||||
@@ -1,33 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
process.stdout.on('error', (error) => {
|
||||
if (error?.code === 'EPIPE') {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EPIPE') {
|
||||
process.exit(0);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
const DOCS_DIR = join(process.cwd(), 'docs');
|
||||
if (!existsSync(DOCS_DIR)) {
|
||||
console.error('docs:list: missing docs directory. Run from repo root.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!statSync(DOCS_DIR).isDirectory()) {
|
||||
console.error('docs:list: docs path is not a directory.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const EXCLUDED_DIRS = new Set(['archive', 'research']);
|
||||
|
||||
/**
|
||||
* @param {unknown[]} values
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function compactStrings(values) {
|
||||
const result = [];
|
||||
function compactStrings(values: unknown[]): string[] {
|
||||
const result: string[] = [];
|
||||
for (const value of values) {
|
||||
if (value === null || value === undefined) {
|
||||
continue;
|
||||
@@ -40,14 +28,9 @@ function compactStrings(values) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dir
|
||||
* @param {string} base
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function walkMarkdownFiles(dir, base = dir) {
|
||||
function walkMarkdownFiles(dir: string, base: string = dir): string[] {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) {
|
||||
continue;
|
||||
@@ -65,11 +48,11 @@ function walkMarkdownFiles(dir, base = dir) {
|
||||
return files.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} fullPath
|
||||
* @returns {{ summary: string | null; readWhen: string[]; error?: string }}
|
||||
*/
|
||||
function extractMetadata(fullPath) {
|
||||
function extractMetadata(fullPath: string): {
|
||||
summary: string | null;
|
||||
readWhen: string[];
|
||||
error?: string;
|
||||
} {
|
||||
const content = readFileSync(fullPath, 'utf8');
|
||||
|
||||
if (!content.startsWith('---')) {
|
||||
@@ -84,9 +67,9 @@ function extractMetadata(fullPath) {
|
||||
const frontMatter = content.slice(3, endIndex).trim();
|
||||
const lines = frontMatter.split('\n');
|
||||
|
||||
let summaryLine = null;
|
||||
const readWhen = [];
|
||||
let collectingField = null;
|
||||
let summaryLine: string | null = null;
|
||||
const readWhen: string[] = [];
|
||||
let collectingField: 'read_when' | null = null;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
@@ -102,7 +85,7 @@ function extractMetadata(fullPath) {
|
||||
const inline = line.slice('read_when:'.length).trim();
|
||||
if (inline.startsWith('[') && inline.endsWith(']')) {
|
||||
try {
|
||||
const parsed = JSON.parse(inline.replace(/'/g, '"'));
|
||||
const parsed = JSON.parse(inline.replace(/'/g, '"')) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
readWhen.push(...compactStrings(parsed));
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env -S node --import tsx
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import "../../src/logging/subsystem.js";
|
||||
|
||||
console.log("tsx-name-repro: loaded logging/subsystem");
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from "node:child_process";
|
||||
import process from "node:process";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const env = { ...process.env };
|
||||
const cwd = process.cwd();
|
||||
|
||||
const build = spawn("pnpm", ["exec", "tsc", "-p", "tsconfig.json"], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
build.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
if (code !== 0 && code !== null) {
|
||||
process.exit(code);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeProcess = spawn(process.execPath, ["dist/entry.js", ...args], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
nodeProcess.on("exit", (exitCode, exitSignal) => {
|
||||
if (exitSignal) {
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
process.exit(exitCode ?? 1);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env -S node --import tsx
|
||||
#!/usr/bin/env bun
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
+41
-12
@@ -45,8 +45,19 @@ function which(cmd) {
|
||||
}
|
||||
|
||||
function resolveRunner() {
|
||||
// CLAWDBOT_PREFER_PNPM=1 forces pnpm (useful in Docker on architectures where Bun fails)
|
||||
const preferPnpm = process.env.CLAWDBOT_PREFER_PNPM === "1";
|
||||
if (!preferPnpm) {
|
||||
const bun = which("bun");
|
||||
if (bun) return { cmd: bun, kind: "bun" };
|
||||
}
|
||||
const pnpm = which("pnpm");
|
||||
if (pnpm) return { cmd: pnpm, kind: "pnpm" };
|
||||
if (preferPnpm) {
|
||||
// Fallback to bun if pnpm not found even when preferring pnpm
|
||||
const bun = which("bun");
|
||||
if (bun) return { cmd: bun, kind: "bun" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -96,7 +107,9 @@ if (!action) {
|
||||
|
||||
const runner = resolveRunner();
|
||||
if (!runner) {
|
||||
process.stderr.write("Missing UI runner: install pnpm, then retry.\n");
|
||||
process.stderr.write(
|
||||
"Missing UI runner: install bun or pnpm, then retry.\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -116,16 +129,32 @@ if (action !== "install" && !script) {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (action === "install") run(runner.cmd, ["install", ...rest]);
|
||||
else {
|
||||
if (!depsInstalled(action === "test" ? "test" : "build")) {
|
||||
const installEnv =
|
||||
action === "build"
|
||||
? { ...process.env, NODE_ENV: "production" }
|
||||
: process.env;
|
||||
const installArgs =
|
||||
action === "build" ? ["install", "--prod"] : ["install"];
|
||||
runSync(runner.cmd, installArgs, installEnv);
|
||||
if (runner.kind === "bun") {
|
||||
if (action === "install") run(runner.cmd, ["install", ...rest]);
|
||||
else {
|
||||
if (!depsInstalled(action === "test" ? "test" : "build")) {
|
||||
const installEnv =
|
||||
action === "build"
|
||||
? { ...process.env, NODE_ENV: "production" }
|
||||
: process.env;
|
||||
const installArgs =
|
||||
action === "build" ? ["install", "--production"] : ["install"];
|
||||
runSync(runner.cmd, installArgs, installEnv);
|
||||
}
|
||||
run(runner.cmd, ["run", script, ...rest]);
|
||||
}
|
||||
} else {
|
||||
if (action === "install") run(runner.cmd, ["install", ...rest]);
|
||||
else {
|
||||
if (!depsInstalled(action === "test" ? "test" : "build")) {
|
||||
const installEnv =
|
||||
action === "build"
|
||||
? { ...process.env, NODE_ENV: "production" }
|
||||
: process.env;
|
||||
const installArgs =
|
||||
action === "build" ? ["install", "--prod"] : ["install"];
|
||||
runSync(runner.cmd, installArgs, installEnv);
|
||||
}
|
||||
run(runner.cmd, ["run", script, ...rest]);
|
||||
}
|
||||
run(runner.cmd, ["run", script, ...rest]);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import process from "node:process";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const env = { ...process.env };
|
||||
const cwd = process.cwd();
|
||||
|
||||
const initialBuild = spawnSync("pnpm", ["exec", "tsc", "-p", "tsconfig.json"], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (initialBuild.status !== 0) {
|
||||
process.exit(initialBuild.status ?? 1);
|
||||
}
|
||||
|
||||
const tsc = spawn("pnpm", ["exec", "tsc", "--watch", "--preserveWatchOutput"], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
const nodeProcess = spawn(process.execPath, ["--watch", "dist/entry.js", ...args], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
let exiting = false;
|
||||
|
||||
function cleanup(code = 0) {
|
||||
if (exiting) return;
|
||||
exiting = true;
|
||||
nodeProcess.kill("SIGTERM");
|
||||
tsc.kill("SIGTERM");
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => cleanup(130));
|
||||
process.on("SIGTERM", () => cleanup(143));
|
||||
|
||||
tsc.on("exit", (code) => {
|
||||
if (exiting) return;
|
||||
cleanup(code ?? 1);
|
||||
});
|
||||
|
||||
nodeProcess.on("exit", (code, signal) => {
|
||||
if (signal || exiting) return;
|
||||
cleanup(code ?? 1);
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import path from "node:path";
|
||||
import { type Api, completeSimple, type Model } from "@mariozechner/pi-ai";
|
||||
import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import {
|
||||
ANTHROPIC_SETUP_TOKEN_PREFIX,
|
||||
validateAnthropicSetupToken,
|
||||
@@ -22,8 +21,7 @@ import { getApiKeyForModel } from "./model-auth.js";
|
||||
import { normalizeProviderId, parseModelRef } from "./model-selection.js";
|
||||
import { ensureClawdbotModelsJson } from "./models-config.js";
|
||||
|
||||
const LIVE =
|
||||
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
|
||||
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
|
||||
const SETUP_TOKEN_RAW = process.env.CLAWDBOT_LIVE_SETUP_TOKEN?.trim() ?? "";
|
||||
const SETUP_TOKEN_VALUE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_VALUE?.trim() ?? "";
|
||||
const SETUP_TOKEN_PROFILE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE?.trim() ?? "";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { createSubsystemLogger } from "../../logging.js";
|
||||
|
||||
export const AUTH_STORE_VERSION = 1;
|
||||
export const AUTH_PROFILE_FILENAME = "auth-profiles.json";
|
||||
|
||||
@@ -4,20 +4,22 @@ import path from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const execSyncMock = vi.fn();
|
||||
const execSyncMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execSync: execSyncMock,
|
||||
}));
|
||||
|
||||
describe("cli credentials", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.resetModules();
|
||||
execSyncMock.mockReset();
|
||||
delete process.env.CODEX_HOME;
|
||||
const { resetCliCredentialCachesForTest } = await import("./cli-credentials.js");
|
||||
resetCliCredentialCachesForTest();
|
||||
});
|
||||
|
||||
it("updates the Claude Code keychain item in place", async () => {
|
||||
@@ -42,14 +44,11 @@ describe("cli credentials", () => {
|
||||
|
||||
const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js");
|
||||
|
||||
const ok = writeClaudeCliKeychainCredentials(
|
||||
{
|
||||
access: "new-access",
|
||||
refresh: "new-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
{ execSync: execSyncMock },
|
||||
);
|
||||
const ok = writeClaudeCliKeychainCredentials({
|
||||
access: "new-access",
|
||||
refresh: "new-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(commands.some((cmd) => cmd.includes("delete-generic-password"))).toBe(false);
|
||||
@@ -131,13 +130,11 @@ describe("cli credentials", () => {
|
||||
allowKeychainPrompt: true,
|
||||
ttlMs: 15 * 60 * 1000,
|
||||
platform: "darwin",
|
||||
execSync: execSyncMock,
|
||||
});
|
||||
const second = readClaudeCliCredentialsCached({
|
||||
allowKeychainPrompt: false,
|
||||
ttlMs: 15 * 60 * 1000,
|
||||
platform: "darwin",
|
||||
execSync: execSyncMock,
|
||||
});
|
||||
|
||||
expect(first).toBeTruthy();
|
||||
@@ -164,7 +161,6 @@ describe("cli credentials", () => {
|
||||
allowKeychainPrompt: true,
|
||||
ttlMs: 15 * 60 * 1000,
|
||||
platform: "darwin",
|
||||
execSync: execSyncMock,
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(15 * 60 * 1000 + 1);
|
||||
@@ -173,7 +169,6 @@ describe("cli credentials", () => {
|
||||
allowKeychainPrompt: true,
|
||||
ttlMs: 15 * 60 * 1000,
|
||||
platform: "darwin",
|
||||
execSync: execSyncMock,
|
||||
});
|
||||
|
||||
expect(first).toBeTruthy();
|
||||
@@ -201,7 +196,7 @@ describe("cli credentials", () => {
|
||||
});
|
||||
|
||||
const { readCodexCliCredentials } = await import("./cli-credentials.js");
|
||||
const creds = readCodexCliCredentials({ platform: "darwin", execSync: execSyncMock });
|
||||
const creds = readCodexCliCredentials({ platform: "darwin" });
|
||||
|
||||
expect(creds).toMatchObject({
|
||||
access: "keychain-access",
|
||||
@@ -231,7 +226,7 @@ describe("cli credentials", () => {
|
||||
);
|
||||
|
||||
const { readCodexCliCredentials } = await import("./cli-credentials.js");
|
||||
const creds = readCodexCliCredentials({ execSync: execSyncMock });
|
||||
const creds = readCodexCliCredentials();
|
||||
|
||||
expect(creds).toMatchObject({
|
||||
access: "file-access",
|
||||
|
||||
@@ -6,7 +6,7 @@ import path from "node:path";
|
||||
import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai";
|
||||
|
||||
import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
|
||||
const log = createSubsystemLogger("agents/auth-profiles");
|
||||
@@ -28,12 +28,6 @@ let claudeCliCache: CachedValue<ClaudeCliCredential> | null = null;
|
||||
let codexCliCache: CachedValue<CodexCliCredential> | null = null;
|
||||
let qwenCliCache: CachedValue<QwenCliCredential> | null = null;
|
||||
|
||||
export function resetCliCredentialCachesForTest(): void {
|
||||
claudeCliCache = null;
|
||||
codexCliCache = null;
|
||||
qwenCliCache = null;
|
||||
}
|
||||
|
||||
export type ClaudeCliCredential =
|
||||
| {
|
||||
type: "oauth";
|
||||
@@ -75,8 +69,6 @@ type ClaudeCliWriteOptions = ClaudeCliFileOptions & {
|
||||
writeFile?: (credentials: OAuthCredentials, options?: ClaudeCliFileOptions) => boolean;
|
||||
};
|
||||
|
||||
type ExecSyncFn = typeof execSync;
|
||||
|
||||
function resolveClaudeCliCredentialsPath(homeDir?: string) {
|
||||
const baseDir = homeDir ?? resolveUserPath("~");
|
||||
return path.join(baseDir, CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH);
|
||||
@@ -108,24 +100,19 @@ function computeCodexKeychainAccount(codexHome: string) {
|
||||
|
||||
function readCodexKeychainCredentials(options?: {
|
||||
platform?: NodeJS.Platform;
|
||||
execSync?: ExecSyncFn;
|
||||
}): CodexCliCredential | null {
|
||||
const platform = options?.platform ?? process.platform;
|
||||
if (platform !== "darwin") return null;
|
||||
const execSyncImpl = options?.execSync ?? execSync;
|
||||
|
||||
const codexHome = resolveCodexHomePath();
|
||||
const account = computeCodexKeychainAccount(codexHome);
|
||||
|
||||
try {
|
||||
const secret = execSyncImpl(
|
||||
`security find-generic-password -s "Codex Auth" -a "${account}" -w`,
|
||||
{
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
},
|
||||
).trim();
|
||||
const secret = execSync(`security find-generic-password -s "Codex Auth" -a "${account}" -w`, {
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
|
||||
const parsed = JSON.parse(secret) as Record<string, unknown>;
|
||||
const tokens = parsed.tokens as Record<string, unknown> | undefined;
|
||||
@@ -183,11 +170,9 @@ function readQwenCliCredentials(options?: { homeDir?: string }): QwenCliCredenti
|
||||
};
|
||||
}
|
||||
|
||||
function readClaudeCliKeychainCredentials(
|
||||
execSyncImpl: ExecSyncFn = execSync,
|
||||
): ClaudeCliCredential | null {
|
||||
function readClaudeCliKeychainCredentials(): ClaudeCliCredential | null {
|
||||
try {
|
||||
const result = execSyncImpl(
|
||||
const result = execSync(
|
||||
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w`,
|
||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
@@ -228,11 +213,10 @@ export function readClaudeCliCredentials(options?: {
|
||||
allowKeychainPrompt?: boolean;
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
execSync?: ExecSyncFn;
|
||||
}): ClaudeCliCredential | null {
|
||||
const platform = options?.platform ?? process.platform;
|
||||
if (platform === "darwin" && options?.allowKeychainPrompt !== false) {
|
||||
const keychainCreds = readClaudeCliKeychainCredentials(options?.execSync);
|
||||
const keychainCreds = readClaudeCliKeychainCredentials();
|
||||
if (keychainCreds) {
|
||||
log.info("read anthropic credentials from claude cli keychain", {
|
||||
type: keychainCreds.type,
|
||||
@@ -279,7 +263,6 @@ export function readClaudeCliCredentialsCached(options?: {
|
||||
ttlMs?: number;
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
execSync?: ExecSyncFn;
|
||||
}): ClaudeCliCredential | null {
|
||||
const ttlMs = options?.ttlMs ?? 0;
|
||||
const now = Date.now();
|
||||
@@ -296,7 +279,6 @@ export function readClaudeCliCredentialsCached(options?: {
|
||||
allowKeychainPrompt: options?.allowKeychainPrompt,
|
||||
platform: options?.platform,
|
||||
homeDir: options?.homeDir,
|
||||
execSync: options?.execSync,
|
||||
});
|
||||
if (ttlMs > 0) {
|
||||
claudeCliCache = { value, readAt: now, cacheKey };
|
||||
@@ -304,13 +286,9 @@ export function readClaudeCliCredentialsCached(options?: {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function writeClaudeCliKeychainCredentials(
|
||||
newCredentials: OAuthCredentials,
|
||||
options?: { execSync?: ExecSyncFn },
|
||||
): boolean {
|
||||
const execSyncImpl = options?.execSync ?? execSync;
|
||||
export function writeClaudeCliKeychainCredentials(newCredentials: OAuthCredentials): boolean {
|
||||
try {
|
||||
const existingResult = execSyncImpl(
|
||||
const existingResult = execSync(
|
||||
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w 2>/dev/null`,
|
||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
@@ -330,7 +308,7 @@ export function writeClaudeCliKeychainCredentials(
|
||||
|
||||
const newValue = JSON.stringify(existingData);
|
||||
|
||||
execSyncImpl(
|
||||
execSync(
|
||||
`security add-generic-password -U -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -a "${CLAUDE_CLI_KEYCHAIN_ACCOUNT}" -w '${newValue.replace(/'/g, "'\"'\"'")}'`,
|
||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
@@ -407,11 +385,9 @@ export function writeClaudeCliCredentials(
|
||||
|
||||
export function readCodexCliCredentials(options?: {
|
||||
platform?: NodeJS.Platform;
|
||||
execSync?: ExecSyncFn;
|
||||
}): CodexCliCredential | null {
|
||||
const keychain = readCodexKeychainCredentials({
|
||||
platform: options?.platform,
|
||||
execSync: options?.execSync,
|
||||
});
|
||||
if (keychain) return keychain;
|
||||
|
||||
@@ -449,7 +425,6 @@ export function readCodexCliCredentials(options?: {
|
||||
export function readCodexCliCredentialsCached(options?: {
|
||||
ttlMs?: number;
|
||||
platform?: NodeJS.Platform;
|
||||
execSync?: ExecSyncFn;
|
||||
}): CodexCliCredential | null {
|
||||
const ttlMs = options?.ttlMs ?? 0;
|
||||
const now = Date.now();
|
||||
@@ -462,10 +437,7 @@ export function readCodexCliCredentialsCached(options?: {
|
||||
) {
|
||||
return codexCliCache.value;
|
||||
}
|
||||
const value = readCodexCliCredentials({
|
||||
platform: options?.platform,
|
||||
execSync: options?.execSync,
|
||||
});
|
||||
const value = readCodexCliCredentials({ platform: options?.platform });
|
||||
if (ttlMs > 0) {
|
||||
codexCliCache = { value, readAt: now, cacheKey };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js";
|
||||
import type { ThinkLevel } from "../auto-reply/thinking.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { shouldLogVerbose } from "../globals.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
@@ -165,7 +164,7 @@ export async function runCliAgent(params: {
|
||||
log.info(
|
||||
`cli exec: provider=${params.provider} model=${normalizedModel} promptChars=${params.prompt.length}`,
|
||||
);
|
||||
const logOutputText = isTruthyEnvValue(process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT);
|
||||
const logOutputText = process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT === "1";
|
||||
if (logOutputText) {
|
||||
const logArgs: string[] = [];
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { completeSimple, getModel } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
|
||||
const GEMINI_KEY = process.env.GEMINI_API_KEY ?? "";
|
||||
const LIVE =
|
||||
isTruthyEnvValue(process.env.GEMINI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
|
||||
const LIVE = process.env.GEMINI_LIVE_TEST === "1" || process.env.LIVE === "1";
|
||||
|
||||
const describeLive = LIVE && GEMINI_KEY ? describe : describe.skip;
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { completeSimple, type Model } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
|
||||
const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? "";
|
||||
const MINIMAX_BASE_URL = process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/anthropic";
|
||||
const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.1";
|
||||
const LIVE =
|
||||
isTruthyEnvValue(process.env.MINIMAX_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
|
||||
const LIVE = process.env.MINIMAX_LIVE_TEST === "1" || process.env.LIVE === "1";
|
||||
|
||||
const describeLive = LIVE && MINIMAX_KEY ? describe : describe.skip;
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-age
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
||||
import {
|
||||
collectAnthropicApiKeys,
|
||||
@@ -15,10 +14,9 @@ import { getApiKeyForModel } from "./model-auth.js";
|
||||
import { ensureClawdbotModelsJson } from "./models-config.js";
|
||||
import { isRateLimitErrorMessage } from "./pi-embedded-helpers/errors.js";
|
||||
|
||||
const LIVE =
|
||||
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
|
||||
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
|
||||
const DIRECT_ENABLED = Boolean(process.env.CLAWDBOT_LIVE_MODELS?.trim());
|
||||
const REQUIRE_PROFILE_KEYS = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS);
|
||||
const REQUIRE_PROFILE_KEYS = process.env.CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS === "1";
|
||||
|
||||
const describeLive = LIVE ? describe : describe.skip;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isImageDimensionErrorMessage, parseImageDimensionError } from "./pi-emb
|
||||
describe("image dimension errors", () => {
|
||||
it("parses anthropic image dimension errors", () => {
|
||||
const raw =
|
||||
'400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}';
|
||||
"400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels\"}}";
|
||||
const parsed = parseImageDimensionError(raw);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.maxDimensionPx).toBe(2000);
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("isCloudCodeAssistFormatError", () => {
|
||||
expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false);
|
||||
expect(
|
||||
isCloudCodeAssistFormatError(
|
||||
'400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}',
|
||||
"400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels\"}}",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { getModel, streamSimple } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { applyExtraParamsToAgent } from "./pi-embedded-runner.js";
|
||||
|
||||
const OPENAI_KEY = process.env.OPENAI_API_KEY ?? "";
|
||||
const LIVE =
|
||||
isTruthyEnvValue(process.env.OPENAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
|
||||
const LIVE = process.env.OPENAI_LIVE_TEST === "1" || process.env.LIVE === "1";
|
||||
|
||||
const describeLive = LIVE && OPENAI_KEY ? describe : describe.skip;
|
||||
|
||||
|
||||
+1
@@ -89,6 +89,7 @@ let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAg
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
vi.resetModules();
|
||||
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
||||
});
|
||||
|
||||
|
||||
+1
@@ -91,6 +91,7 @@ let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAg
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.useRealTimers();
|
||||
vi.resetModules();
|
||||
mockPiAi();
|
||||
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
||||
}, 20_000);
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("detectImageReferences", () => {
|
||||
expect(refs[0]?.raw).toBe("~/Pictures/vacation.png");
|
||||
expect(refs[0]?.type).toBe("path");
|
||||
// Resolved path should expand ~
|
||||
expect(refs[0]?.resolved?.startsWith("~")).toBe(false);
|
||||
expect(refs[0]?.resolved).not.toContain("~");
|
||||
});
|
||||
|
||||
it("detects multiple image references in a prompt", () => {
|
||||
|
||||
@@ -2,9 +2,8 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
|
||||
const RAW_STREAM_ENABLED = isTruthyEnvValue(process.env.CLAWDBOT_RAW_STREAM);
|
||||
const RAW_STREAM_ENABLED = process.env.CLAWDBOT_RAW_STREAM === "1";
|
||||
const RAW_STREAM_PATH =
|
||||
process.env.CLAWDBOT_RAW_STREAM_PATH?.trim() ||
|
||||
path.join(resolveStateDir(), "logs", "raw-stream.jsonl");
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveSkillInvocationPolicy } from "./frontmatter.js";
|
||||
|
||||
describe("resolveSkillInvocationPolicy", () => {
|
||||
it("defaults to enabled behaviors", () => {
|
||||
const policy = resolveSkillInvocationPolicy({});
|
||||
expect(policy.userInvocable).toBe(true);
|
||||
expect(policy.disableModelInvocation).toBe(false);
|
||||
});
|
||||
|
||||
it("parses frontmatter boolean strings", () => {
|
||||
const policy = resolveSkillInvocationPolicy({
|
||||
"user-invocable": "no",
|
||||
"disable-model-invocation": "yes",
|
||||
});
|
||||
expect(policy.userInvocable).toBe(false);
|
||||
expect(policy.disableModelInvocation).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import JSON5 from "json5";
|
||||
import type { Skill } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { parseFrontmatterBlock } from "../../markdown/frontmatter.js";
|
||||
import { parseBooleanValue } from "../../utils/boolean.js";
|
||||
import type {
|
||||
ClawdbotSkillMetadata,
|
||||
ParsedSkillFrontmatter,
|
||||
@@ -60,8 +59,16 @@ function getFrontmatterValue(frontmatter: ParsedSkillFrontmatter, key: string):
|
||||
}
|
||||
|
||||
function parseFrontmatterBool(value: string | undefined, fallback: boolean): boolean {
|
||||
const parsed = parseBooleanValue(value);
|
||||
return parsed === undefined ? fallback : parsed;
|
||||
if (!value) return fallback;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) return fallback;
|
||||
if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") {
|
||||
return false;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function resolveClawdbotMetadata(
|
||||
|
||||
@@ -39,7 +39,6 @@ vi.mock("../config/sessions.js", () => ({
|
||||
resolveAgentIdFromSessionKey: () => "main",
|
||||
resolveStorePath: () => "/tmp/sessions.json",
|
||||
resolveMainSessionKey: () => "agent:main:main",
|
||||
readSessionUpdatedAt: vi.fn(() => undefined),
|
||||
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("buildAgentSystemPrompt", () => {
|
||||
expect(prompt).toContain("- Read: Read file contents");
|
||||
expect(prompt).toContain("- Exec: Run shell commands");
|
||||
expect(prompt).toContain(
|
||||
"- If exactly one skill clearly applies: read its SKILL.md at <location> with `Read`, then follow it.",
|
||||
"Use `Read` to load the SKILL.md at the location listed for that skill.",
|
||||
);
|
||||
expect(prompt).toContain("Clawdbot docs: /tmp/clawd/docs");
|
||||
expect(prompt).toContain(
|
||||
@@ -188,7 +188,7 @@ describe("buildAgentSystemPrompt", () => {
|
||||
|
||||
expect(prompt).toContain("## Skills");
|
||||
expect(prompt).toContain(
|
||||
"- If exactly one skill clearly applies: read its SKILL.md at <location> with `read`, then follow it.",
|
||||
"Use `read` to load the SKILL.md at the location listed for that skill.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -20,12 +20,8 @@ function buildSkillsSection(params: {
|
||||
const trimmed = params.skillsPrompt?.trim();
|
||||
if (!trimmed || params.isMinimal) return [];
|
||||
return [
|
||||
"## Skills (mandatory)",
|
||||
"Before replying: scan <available_skills> <description> entries.",
|
||||
`- If exactly one skill clearly applies: read its SKILL.md at <location> with \`${params.readToolName}\`, then follow it.`,
|
||||
"- If multiple could apply: choose the most specific one, then read/follow it.",
|
||||
"- If none clearly apply: do not read any SKILL.md.",
|
||||
"Constraints: never read more than one skill up front; only read after selecting.",
|
||||
"## Skills",
|
||||
`Skills provide task-specific instructions. Use \`${params.readToolName}\` to load the SKILL.md at the location listed for that skill.`,
|
||||
trimmed,
|
||||
"",
|
||||
];
|
||||
@@ -113,7 +109,11 @@ function buildMessagingSection(params: {
|
||||
];
|
||||
}
|
||||
|
||||
function buildDocsSection(params: { docsPath?: string; isMinimal: boolean; readToolName: string }) {
|
||||
function buildDocsSection(params: {
|
||||
docsPath?: string;
|
||||
isMinimal: boolean;
|
||||
readToolName: string;
|
||||
}) {
|
||||
const docsPath = params.docsPath?.trim();
|
||||
if (!docsPath || params.isMinimal) return [];
|
||||
return [
|
||||
|
||||
@@ -58,12 +58,7 @@ async function resizeImageBase64IfNeeded(params: {
|
||||
const height = meta?.height;
|
||||
const overBytes = buf.byteLength > params.maxBytes;
|
||||
const hasDimensions = typeof width === "number" && typeof height === "number";
|
||||
if (
|
||||
hasDimensions &&
|
||||
!overBytes &&
|
||||
width <= params.maxDimensionPx &&
|
||||
height <= params.maxDimensionPx
|
||||
) {
|
||||
if (hasDimensions && !overBytes && width <= params.maxDimensionPx && height <= params.maxDimensionPx) {
|
||||
return {
|
||||
base64: params.base64,
|
||||
mimeType: params.mimeType,
|
||||
@@ -72,10 +67,7 @@ async function resizeImageBase64IfNeeded(params: {
|
||||
height,
|
||||
};
|
||||
}
|
||||
if (
|
||||
hasDimensions &&
|
||||
(width > params.maxDimensionPx || height > params.maxDimensionPx || overBytes)
|
||||
) {
|
||||
if (hasDimensions && (width > params.maxDimensionPx || height > params.maxDimensionPx || overBytes)) {
|
||||
log.warn("Image exceeds limits; resizing", {
|
||||
label: params.label,
|
||||
width,
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
getChannelPlugin,
|
||||
normalizeChannelId as normalizeAnyChannelId,
|
||||
} from "../../channels/plugins/index.js";
|
||||
import { normalizeChannelId as normalizeChatChannelId } from "../../channels/registry.js";
|
||||
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
|
||||
const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP";
|
||||
@@ -25,7 +21,7 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget
|
||||
const id = rest.join(":").trim();
|
||||
if (!id) return null;
|
||||
if (!channelRaw) return null;
|
||||
const normalizedChannel = normalizeAnyChannelId(channelRaw) ?? normalizeChatChannelId(channelRaw);
|
||||
const normalizedChannel = normalizeChannelId(channelRaw);
|
||||
const channel = normalizedChannel ?? channelRaw.toLowerCase();
|
||||
const kindTarget = (() => {
|
||||
if (!normalizedChannel) return id;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { completeSimple, getModel } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
|
||||
const ZAI_KEY = process.env.ZAI_API_KEY ?? process.env.Z_AI_API_KEY ?? "";
|
||||
const LIVE = isTruthyEnvValue(process.env.ZAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
|
||||
const LIVE = process.env.ZAI_LIVE_TEST === "1" || process.env.LIVE === "1";
|
||||
|
||||
const describeLive = LIVE && ZAI_KEY ? describe : describe.skip;
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
formatAgentEnvelope,
|
||||
formatInboundEnvelope,
|
||||
resolveEnvelopeFormatOptions,
|
||||
} from "./envelope.js";
|
||||
import { formatAgentEnvelope, formatInboundEnvelope } from "./envelope.js";
|
||||
|
||||
describe("formatAgentEnvelope", () => {
|
||||
it("includes channel, from, ip, host, and timestamp", () => {
|
||||
@@ -42,46 +38,6 @@ describe("formatAgentEnvelope", () => {
|
||||
expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello");
|
||||
});
|
||||
|
||||
it("formats timestamps in local timezone when configured", () => {
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = "America/Los_Angeles";
|
||||
|
||||
const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z (19:04 PST)
|
||||
const body = formatAgentEnvelope({
|
||||
channel: "WebChat",
|
||||
timestamp: ts,
|
||||
envelope: { timezone: "local" },
|
||||
body: "hello",
|
||||
});
|
||||
|
||||
process.env.TZ = originalTz;
|
||||
|
||||
expect(body).toMatch(/\[WebChat 2025-01-01 19:04 [^\]]+\] hello/);
|
||||
});
|
||||
|
||||
it("formats timestamps in user timezone when configured", () => {
|
||||
const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z (04:04 CET)
|
||||
const body = formatAgentEnvelope({
|
||||
channel: "WebChat",
|
||||
timestamp: ts,
|
||||
envelope: { timezone: "user", userTimezone: "Europe/Vienna" },
|
||||
body: "hello",
|
||||
});
|
||||
|
||||
expect(body).toMatch(/\[WebChat 2025-01-02 04:04 [^\]]+\] hello/);
|
||||
});
|
||||
|
||||
it("omits timestamps when configured", () => {
|
||||
const ts = Date.UTC(2025, 0, 2, 3, 4);
|
||||
const body = formatAgentEnvelope({
|
||||
channel: "WebChat",
|
||||
timestamp: ts,
|
||||
envelope: { includeTimestamp: false },
|
||||
body: "hello",
|
||||
});
|
||||
expect(body).toBe("[WebChat] hello");
|
||||
});
|
||||
|
||||
it("handles missing optional fields", () => {
|
||||
const body = formatAgentEnvelope({ channel: "Telegram", body: "hi" });
|
||||
expect(body).toBe("[Telegram] hi");
|
||||
@@ -121,53 +77,4 @@ describe("formatInboundEnvelope", () => {
|
||||
});
|
||||
expect(body).toBe("[iMessage +1555] hello");
|
||||
});
|
||||
|
||||
it("includes elapsed time when previousTimestamp is provided", () => {
|
||||
const now = Date.now();
|
||||
const twoMinutesAgo = now - 2 * 60 * 1000;
|
||||
const body = formatInboundEnvelope({
|
||||
channel: "Telegram",
|
||||
from: "Alice",
|
||||
body: "follow-up message",
|
||||
timestamp: now,
|
||||
previousTimestamp: twoMinutesAgo,
|
||||
chatType: "direct",
|
||||
envelope: { includeTimestamp: false },
|
||||
});
|
||||
expect(body).toContain("Alice +2m");
|
||||
expect(body).toContain("follow-up message");
|
||||
});
|
||||
|
||||
it("omits elapsed time when disabled", () => {
|
||||
const now = Date.now();
|
||||
const body = formatInboundEnvelope({
|
||||
channel: "Telegram",
|
||||
from: "Alice",
|
||||
body: "follow-up message",
|
||||
timestamp: now,
|
||||
previousTimestamp: now - 2 * 60 * 1000,
|
||||
chatType: "direct",
|
||||
envelope: { includeElapsed: false, includeTimestamp: false },
|
||||
});
|
||||
expect(body).toBe("[Telegram Alice] follow-up message");
|
||||
});
|
||||
|
||||
it("resolves envelope options from config", () => {
|
||||
const options = resolveEnvelopeFormatOptions({
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "user",
|
||||
envelopeTimestamp: "off",
|
||||
envelopeElapsed: "off",
|
||||
userTimezone: "Europe/Vienna",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(options).toEqual({
|
||||
timezone: "user",
|
||||
includeTimestamp: false,
|
||||
includeElapsed: false,
|
||||
userTimezone: "Europe/Vienna",
|
||||
});
|
||||
});
|
||||
});
|
||||
+9
-158
@@ -1,7 +1,5 @@
|
||||
import { resolveUserTimezone } from "../agents/date-time.js";
|
||||
import { normalizeChatType } from "../channels/chat-type.js";
|
||||
import { resolveSenderLabel, type SenderLabelParams } from "../channels/sender-label.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
|
||||
export type AgentEnvelopeParams = {
|
||||
channel: string;
|
||||
@@ -10,172 +8,31 @@ export type AgentEnvelopeParams = {
|
||||
host?: string;
|
||||
ip?: string;
|
||||
body: string;
|
||||
previousTimestamp?: number | Date;
|
||||
envelope?: EnvelopeFormatOptions;
|
||||
};
|
||||
|
||||
export type EnvelopeFormatOptions = {
|
||||
/**
|
||||
* "utc" (default), "local", "user", or an explicit IANA timezone string.
|
||||
*/
|
||||
timezone?: string;
|
||||
/**
|
||||
* Include absolute timestamps in the envelope (default: true).
|
||||
*/
|
||||
includeTimestamp?: boolean;
|
||||
/**
|
||||
* Include elapsed time suffix when previousTimestamp is provided (default: true).
|
||||
*/
|
||||
includeElapsed?: boolean;
|
||||
/**
|
||||
* Optional user timezone used when timezone="user".
|
||||
*/
|
||||
userTimezone?: string;
|
||||
};
|
||||
function formatTimestamp(ts?: number | Date): string | undefined {
|
||||
if (!ts) return undefined;
|
||||
const date = ts instanceof Date ? ts : new Date(ts);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
|
||||
type NormalizedEnvelopeOptions = {
|
||||
timezone: string;
|
||||
includeTimestamp: boolean;
|
||||
includeElapsed: boolean;
|
||||
userTimezone?: string;
|
||||
};
|
||||
|
||||
type ResolvedEnvelopeTimezone =
|
||||
| { mode: "utc" }
|
||||
| { mode: "local" }
|
||||
| { mode: "iana"; timeZone: string };
|
||||
|
||||
export function resolveEnvelopeFormatOptions(cfg?: ClawdbotConfig): EnvelopeFormatOptions {
|
||||
const defaults = cfg?.agents?.defaults;
|
||||
return {
|
||||
timezone: defaults?.envelopeTimezone,
|
||||
includeTimestamp: defaults?.envelopeTimestamp !== "off",
|
||||
includeElapsed: defaults?.envelopeElapsed !== "off",
|
||||
userTimezone: defaults?.userTimezone,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEnvelopeOptions(options?: EnvelopeFormatOptions): NormalizedEnvelopeOptions {
|
||||
const includeTimestamp = options?.includeTimestamp !== false;
|
||||
const includeElapsed = options?.includeElapsed !== false;
|
||||
return {
|
||||
timezone: options?.timezone?.trim() || "utc",
|
||||
includeTimestamp,
|
||||
includeElapsed,
|
||||
userTimezone: options?.userTimezone,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveExplicitTimezone(value: string): string | undefined {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
|
||||
return value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEnvelopeTimezone {
|
||||
const trimmed = options.timezone?.trim();
|
||||
if (!trimmed) return { mode: "utc" };
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" };
|
||||
if (lowered === "local" || lowered === "host") return { mode: "local" };
|
||||
if (lowered === "user") {
|
||||
return { mode: "iana", timeZone: resolveUserTimezone(options.userTimezone) };
|
||||
}
|
||||
const explicit = resolveExplicitTimezone(trimmed);
|
||||
return explicit ? { mode: "iana", timeZone: explicit } : { mode: "utc" };
|
||||
}
|
||||
|
||||
function formatUtcTimestamp(date: Date): string {
|
||||
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(date.getUTCHours()).padStart(2, "0");
|
||||
const min = String(date.getUTCMinutes()).padStart(2, "0");
|
||||
|
||||
// Compact ISO-like UTC timestamp with minutes precision.
|
||||
// Example: 2025-01-02T03:04Z
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`;
|
||||
}
|
||||
|
||||
function formatZonedTimestamp(date: Date, timeZone?: string): string | undefined {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
timeZoneName: "short",
|
||||
}).formatToParts(date);
|
||||
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
|
||||
const yyyy = pick("year");
|
||||
const mm = pick("month");
|
||||
const dd = pick("day");
|
||||
const hh = pick("hour");
|
||||
const min = pick("minute");
|
||||
const tz = [...parts]
|
||||
.reverse()
|
||||
.find((part) => part.type === "timeZoneName")
|
||||
?.value?.trim();
|
||||
if (!yyyy || !mm || !dd || !hh || !min) return undefined;
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`;
|
||||
}
|
||||
|
||||
function formatTimestamp(
|
||||
ts: number | Date | undefined,
|
||||
options?: EnvelopeFormatOptions,
|
||||
): string | undefined {
|
||||
if (!ts) return undefined;
|
||||
const resolved = normalizeEnvelopeOptions(options);
|
||||
if (!resolved.includeTimestamp) return undefined;
|
||||
const date = ts instanceof Date ? ts : new Date(ts);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
const zone = resolveEnvelopeTimezone(resolved);
|
||||
if (zone.mode === "utc") return formatUtcTimestamp(date);
|
||||
if (zone.mode === "local") return formatZonedTimestamp(date);
|
||||
return formatZonedTimestamp(date, zone.timeZone);
|
||||
}
|
||||
|
||||
function formatElapsedTime(currentMs: number, previousMs: number): string | undefined {
|
||||
const elapsedMs = currentMs - previousMs;
|
||||
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return undefined;
|
||||
|
||||
const seconds = Math.floor(elapsedMs / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
|
||||
const channel = params.channel?.trim() || "Channel";
|
||||
const parts: string[] = [channel];
|
||||
const resolved = normalizeEnvelopeOptions(params.envelope);
|
||||
const elapsed =
|
||||
resolved.includeElapsed && params.timestamp && params.previousTimestamp
|
||||
? formatElapsedTime(
|
||||
params.timestamp instanceof Date ? params.timestamp.getTime() : params.timestamp,
|
||||
params.previousTimestamp instanceof Date
|
||||
? params.previousTimestamp.getTime()
|
||||
: params.previousTimestamp,
|
||||
)
|
||||
: undefined;
|
||||
if (params.from?.trim()) {
|
||||
const from = params.from.trim();
|
||||
parts.push(elapsed ? `${from} +${elapsed}` : from);
|
||||
} else if (elapsed) {
|
||||
parts.push(`+${elapsed}`);
|
||||
}
|
||||
if (params.from?.trim()) parts.push(params.from.trim());
|
||||
if (params.host?.trim()) parts.push(params.host.trim());
|
||||
if (params.ip?.trim()) parts.push(params.ip.trim());
|
||||
const ts = formatTimestamp(params.timestamp, resolved);
|
||||
const ts = formatTimestamp(params.timestamp);
|
||||
if (ts) parts.push(ts);
|
||||
const header = `[${parts.join(" ")}]`;
|
||||
return `${header} ${params.body}`;
|
||||
@@ -189,8 +46,6 @@ export function formatInboundEnvelope(params: {
|
||||
chatType?: string;
|
||||
senderLabel?: string;
|
||||
sender?: SenderLabelParams;
|
||||
previousTimestamp?: number | Date;
|
||||
envelope?: EnvelopeFormatOptions;
|
||||
}): string {
|
||||
const chatType = normalizeChatType(params.chatType);
|
||||
const isDirect = !chatType || chatType === "direct";
|
||||
@@ -200,8 +55,6 @@ export function formatInboundEnvelope(params: {
|
||||
channel: params.channel,
|
||||
from: params.from,
|
||||
timestamp: params.timestamp,
|
||||
previousTimestamp: params.previousTimestamp,
|
||||
envelope: params.envelope,
|
||||
body,
|
||||
});
|
||||
}
|
||||
@@ -232,13 +85,11 @@ export function formatThreadStarterEnvelope(params: {
|
||||
author?: string;
|
||||
timestamp?: number | Date;
|
||||
body: string;
|
||||
envelope?: EnvelopeFormatOptions;
|
||||
}): string {
|
||||
return formatAgentEnvelope({
|
||||
channel: params.channel,
|
||||
from: params.author,
|
||||
timestamp: params.timestamp,
|
||||
envelope: params.envelope,
|
||||
body: params.body,
|
||||
});
|
||||
}
|
||||
@@ -18,10 +18,7 @@ import {
|
||||
updateSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import {
|
||||
emitAgentEvent,
|
||||
registerAgentRunContext,
|
||||
} from "../../infra/agent-events.js";
|
||||
import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import {
|
||||
isMarkdownCapableMessageChannel,
|
||||
@@ -32,20 +29,11 @@ import type { TemplateContext } from "../templating.js";
|
||||
import type { VerboseLevel } from "../thinking.js";
|
||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import {
|
||||
buildThreadingToolContext,
|
||||
resolveEnforceFinalTag,
|
||||
} from "./agent-runner-utils.js";
|
||||
import {
|
||||
createBlockReplyPayloadKey,
|
||||
type BlockReplyPipeline,
|
||||
} from "./block-reply-pipeline.js";
|
||||
import { buildThreadingToolContext, resolveEnforceFinalTag } from "./agent-runner-utils.js";
|
||||
import { createBlockReplyPayloadKey, type BlockReplyPipeline } from "./block-reply-pipeline.js";
|
||||
import type { FollowupRun } from "./queue.js";
|
||||
import { parseReplyDirectives } from "./reply-directives.js";
|
||||
import {
|
||||
applyReplyTagsToPayload,
|
||||
isRenderablePayload,
|
||||
} from "./reply-payloads.js";
|
||||
import { applyReplyTagsToPayload, isRenderablePayload } from "./reply-payloads.js";
|
||||
import type { TypingSignaler } from "./typing-mode.js";
|
||||
|
||||
export type AgentRunLoopResult =
|
||||
@@ -108,12 +96,9 @@ export async function runAgentTurnWithFallback(params: {
|
||||
while (true) {
|
||||
try {
|
||||
const allowPartialStream = !(
|
||||
params.followupRun.run.reasoningLevel === "stream" &&
|
||||
params.opts?.onReasoningStream
|
||||
params.followupRun.run.reasoningLevel === "stream" && params.opts?.onReasoningStream
|
||||
);
|
||||
const normalizeStreamingText = (
|
||||
payload: ReplyPayload,
|
||||
): { text?: string; skip: boolean } => {
|
||||
const normalizeStreamingText = (payload: ReplyPayload): { text?: string; skip: boolean } => {
|
||||
if (!allowPartialStream) return { skip: true };
|
||||
let text = payload.text;
|
||||
if (!params.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
||||
@@ -137,9 +122,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
if (!sanitized.trim()) return { skip: true };
|
||||
return { text: sanitized, skip: false };
|
||||
};
|
||||
const handlePartialForTyping = async (
|
||||
payload: ReplyPayload,
|
||||
): Promise<string | undefined> => {
|
||||
const handlePartialForTyping = async (payload: ReplyPayload): Promise<string | undefined> => {
|
||||
const { text, skip } = normalizeStreamingText(payload);
|
||||
if (skip || !text) return undefined;
|
||||
await params.typingSignals.signalTextDelta(text);
|
||||
@@ -174,10 +157,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
startedAt,
|
||||
},
|
||||
});
|
||||
const cliSessionId = getCliSessionId(
|
||||
params.getActiveSessionEntry(),
|
||||
provider,
|
||||
);
|
||||
const cliSessionId = getCliSessionId(params.getActiveSessionEntry(), provider);
|
||||
return runCliAgent({
|
||||
sessionId: params.followupRun.run.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
@@ -227,8 +207,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
return runEmbeddedPiAgent({
|
||||
sessionId: params.followupRun.run.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
messageProvider:
|
||||
params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||
messageProvider: params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||
agentAccountId: params.sessionCtx.AccountId,
|
||||
// Provider threading context for tool auto-injection
|
||||
...buildThreadingToolContext({
|
||||
@@ -244,10 +223,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
prompt: params.commandBody,
|
||||
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
|
||||
ownerNumbers: params.followupRun.run.ownerNumbers,
|
||||
enforceFinalTag: resolveEnforceFinalTag(
|
||||
params.followupRun.run,
|
||||
provider,
|
||||
),
|
||||
enforceFinalTag: resolveEnforceFinalTag(params.followupRun.run, provider),
|
||||
provider,
|
||||
model,
|
||||
authProfileId,
|
||||
@@ -264,9 +240,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
params.sessionCtx.Provider,
|
||||
);
|
||||
if (!channel) return "markdown";
|
||||
return isMarkdownCapableMessageChannel(channel)
|
||||
? "markdown"
|
||||
: "plain";
|
||||
return isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain";
|
||||
})(),
|
||||
bashElevated: params.followupRun.run.bashElevated,
|
||||
timeoutMs: params.followupRun.run.timeoutMs,
|
||||
@@ -276,11 +250,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
onPartialReply: allowPartialStream
|
||||
? async (payload) => {
|
||||
const textForTyping = await handlePartialForTyping(payload);
|
||||
if (
|
||||
!params.opts?.onPartialReply ||
|
||||
textForTyping === undefined
|
||||
)
|
||||
return;
|
||||
if (!params.opts?.onPartialReply || textForTyping === undefined) return;
|
||||
await params.opts.onPartialReply({
|
||||
text: textForTyping,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
@@ -291,8 +261,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
await params.typingSignals.signalMessageStart();
|
||||
},
|
||||
onReasoningStream:
|
||||
params.typingSignals.shouldStartOnReasoning ||
|
||||
params.opts?.onReasoningStream
|
||||
params.typingSignals.shouldStartOnReasoning || params.opts?.onReasoningStream
|
||||
? async (payload) => {
|
||||
await params.typingSignals.signalReasoningDelta();
|
||||
await params.opts?.onReasoningStream?.({
|
||||
@@ -305,16 +274,14 @@ export async function runAgentTurnWithFallback(params: {
|
||||
// Trigger typing when tools start executing.
|
||||
// Must await to ensure typing indicator starts before tool summaries are emitted.
|
||||
if (evt.stream === "tool") {
|
||||
const phase =
|
||||
typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
if (phase === "start" || phase === "update") {
|
||||
await params.typingSignals.signalToolStart();
|
||||
}
|
||||
}
|
||||
// Track auto-compaction completion
|
||||
if (evt.stream === "compaction") {
|
||||
const phase =
|
||||
typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
const willRetry = Boolean(evt.data.willRetry);
|
||||
if (phase === "end" && !willRetry) {
|
||||
autoCompactionCompleted = true;
|
||||
@@ -338,22 +305,14 @@ export async function runAgentTurnWithFallback(params: {
|
||||
params.sessionCtx.MessageSid,
|
||||
);
|
||||
// Let through payloads with audioAsVoice flag even if empty (need to track it)
|
||||
if (
|
||||
!isRenderablePayload(taggedPayload) &&
|
||||
!payload.audioAsVoice
|
||||
)
|
||||
return;
|
||||
const parsed = parseReplyDirectives(
|
||||
taggedPayload.text ?? "",
|
||||
{
|
||||
currentMessageId: params.sessionCtx.MessageSid,
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
},
|
||||
);
|
||||
if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return;
|
||||
const parsed = parseReplyDirectives(taggedPayload.text ?? "", {
|
||||
currentMessageId: params.sessionCtx.MessageSid,
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
});
|
||||
const cleaned = parsed.text || undefined;
|
||||
const hasRenderableMedia =
|
||||
Boolean(taggedPayload.mediaUrl) ||
|
||||
(taggedPayload.mediaUrls?.length ?? 0) > 0;
|
||||
Boolean(taggedPayload.mediaUrl) || (taggedPayload.mediaUrls?.length ?? 0) > 0;
|
||||
// Skip empty payloads unless they have audioAsVoice flag (need to track it)
|
||||
if (
|
||||
!cleaned &&
|
||||
@@ -367,35 +326,25 @@ export async function runAgentTurnWithFallback(params: {
|
||||
const blockPayload: ReplyPayload = params.applyReplyToMode({
|
||||
...taggedPayload,
|
||||
text: cleaned,
|
||||
audioAsVoice: Boolean(
|
||||
parsed.audioAsVoice || payload.audioAsVoice,
|
||||
),
|
||||
audioAsVoice: Boolean(parsed.audioAsVoice || payload.audioAsVoice),
|
||||
replyToId: taggedPayload.replyToId ?? parsed.replyToId,
|
||||
replyToTag: taggedPayload.replyToTag || parsed.replyToTag,
|
||||
replyToCurrent:
|
||||
taggedPayload.replyToCurrent || parsed.replyToCurrent,
|
||||
replyToCurrent: taggedPayload.replyToCurrent || parsed.replyToCurrent,
|
||||
});
|
||||
|
||||
void params.typingSignals
|
||||
.signalTextDelta(cleaned ?? taggedPayload.text)
|
||||
.catch((err) => {
|
||||
logVerbose(
|
||||
`block reply typing signal failed: ${String(err)}`,
|
||||
);
|
||||
logVerbose(`block reply typing signal failed: ${String(err)}`);
|
||||
});
|
||||
|
||||
// Use pipeline if available (block streaming enabled), otherwise send directly
|
||||
if (
|
||||
params.blockStreamingEnabled &&
|
||||
params.blockReplyPipeline
|
||||
) {
|
||||
if (params.blockStreamingEnabled && params.blockReplyPipeline) {
|
||||
params.blockReplyPipeline.enqueue(blockPayload);
|
||||
} else {
|
||||
// Send directly when flushing before tool execution (no streaming).
|
||||
// Track sent key to avoid duplicate in final payloads.
|
||||
directlySentBlockKeys.add(
|
||||
createBlockReplyPayloadKey(blockPayload),
|
||||
);
|
||||
directlySentBlockKeys.add(createBlockReplyPayloadKey(blockPayload));
|
||||
await params.opts?.onBlockReply?.(blockPayload);
|
||||
}
|
||||
}
|
||||
@@ -448,17 +397,10 @@ export async function runAgentTurnWithFallback(params: {
|
||||
(await params.resetSessionAfterCompactionFailure(embeddedError.message))
|
||||
) {
|
||||
didResetAfterCompactionFailure = true;
|
||||
return {
|
||||
kind: "final",
|
||||
payload: {
|
||||
text: "⚠️ Context limit exceeded. I've reset our conversation to start fresh - please try again.\n\nTo prevent this, increase your compaction buffer by setting `agents.defaults.compaction.reserveTokensFloor` to 4000 or higher in your config.",
|
||||
},
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (embeddedError?.kind === "role_ordering") {
|
||||
const didReset = await params.resetSessionAfterRoleOrderingConflict(
|
||||
embeddedError.message,
|
||||
);
|
||||
const didReset = await params.resetSessionAfterRoleOrderingConflict(embeddedError.message);
|
||||
if (didReset) {
|
||||
return {
|
||||
kind: "final",
|
||||
@@ -476,10 +418,8 @@ export async function runAgentTurnWithFallback(params: {
|
||||
isContextOverflowError(message) ||
|
||||
/context.*overflow|too large|context window/i.test(message);
|
||||
const isCompactionFailure = isCompactionFailureError(message);
|
||||
const isSessionCorruption =
|
||||
/function call turn comes immediately after/i.test(message);
|
||||
const isRoleOrderingError =
|
||||
/incorrect role information|roles must alternate/i.test(message);
|
||||
const isSessionCorruption = /function call turn comes immediately after/i.test(message);
|
||||
const isRoleOrderingError = /incorrect role information|roles must alternate/i.test(message);
|
||||
|
||||
if (
|
||||
isCompactionFailure &&
|
||||
@@ -487,16 +427,10 @@ export async function runAgentTurnWithFallback(params: {
|
||||
(await params.resetSessionAfterCompactionFailure(message))
|
||||
) {
|
||||
didResetAfterCompactionFailure = true;
|
||||
return {
|
||||
kind: "final",
|
||||
payload: {
|
||||
text: "⚠️ Context limit exceeded during compaction. I've reset our conversation to start fresh - please try again.\n\nTo prevent this, increase your compaction buffer by setting `agents.defaults.compaction.reserveTokensFloor` to 4000 or higher in your config.",
|
||||
},
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (isRoleOrderingError) {
|
||||
const didReset =
|
||||
await params.resetSessionAfterRoleOrderingConflict(message);
|
||||
const didReset = await params.resetSessionAfterRoleOrderingConflict(message);
|
||||
if (didReset) {
|
||||
return {
|
||||
kind: "final",
|
||||
@@ -523,8 +457,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
try {
|
||||
// Delete transcript file if it exists
|
||||
if (corruptedSessionId) {
|
||||
const transcriptPath =
|
||||
resolveSessionTranscriptPath(corruptedSessionId);
|
||||
const transcriptPath = resolveSessionTranscriptPath(corruptedSessionId);
|
||||
try {
|
||||
fs.unlinkSync(transcriptPath);
|
||||
} catch {
|
||||
@@ -574,7 +507,6 @@ export async function runAgentTurnWithFallback(params: {
|
||||
fallbackModel,
|
||||
didLogHeartbeatStrip,
|
||||
autoCompactionCompleted,
|
||||
directlySentBlockKeys:
|
||||
directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined,
|
||||
directlySentBlockKeys: directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined,
|
||||
};
|
||||
}
|
||||
+32
-32
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import * as sessions from "../../config/sessions.js";
|
||||
import type { TypingMode } from "../../config/types.js";
|
||||
@@ -121,10 +121,6 @@ function createMinimalRun(params?: {
|
||||
}
|
||||
|
||||
describe("runReplyAgent typing (heartbeat)", () => {
|
||||
beforeEach(() => {
|
||||
runEmbeddedPiAgentMock.mockReset();
|
||||
});
|
||||
|
||||
it("retries after compaction failure by resetting the session", async () => {
|
||||
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-compaction-reset-"));
|
||||
@@ -141,12 +137,18 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
|
||||
await fs.writeFile(transcriptPath, "ok", "utf-8");
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async () => {
|
||||
throw new Error(
|
||||
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
|
||||
);
|
||||
});
|
||||
runEmbeddedPiAgentMock
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new Error(
|
||||
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
|
||||
);
|
||||
})
|
||||
.mockImplementationOnce(async () => ({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {},
|
||||
}));
|
||||
|
||||
const callsBefore = runEmbeddedPiAgentMock.mock.calls.length;
|
||||
const { run } = createMinimalRun({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
@@ -155,12 +157,9 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
});
|
||||
const res = await run();
|
||||
|
||||
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
|
||||
expect(runEmbeddedPiAgentMock.mock.calls.length - callsBefore).toBe(2);
|
||||
const payload = Array.isArray(res) ? res[0] : res;
|
||||
expect(payload).toMatchObject({
|
||||
text: expect.stringContaining("Context limit exceeded during compaction"),
|
||||
});
|
||||
expect(payload.text?.toLowerCase()).toContain("reset");
|
||||
expect(payload).toMatchObject({ text: "ok" });
|
||||
expect(sessionStore.main.sessionId).not.toBe(sessionId);
|
||||
|
||||
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||
@@ -173,7 +172,6 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("retries after context overflow payload by resetting the session", async () => {
|
||||
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-overflow-reset-"));
|
||||
@@ -190,18 +188,24 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
|
||||
await fs.writeFile(transcriptPath, "ok", "utf-8");
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({
|
||||
payloads: [{ text: "Context overflow: prompt too large", isError: true }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
error: {
|
||||
kind: "context_overflow",
|
||||
message:
|
||||
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
|
||||
runEmbeddedPiAgentMock
|
||||
.mockImplementationOnce(async () => ({
|
||||
payloads: [{ text: "Context overflow: prompt too large", isError: true }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
error: {
|
||||
kind: "context_overflow",
|
||||
message:
|
||||
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
}))
|
||||
.mockImplementationOnce(async () => ({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { durationMs: 1 },
|
||||
}));
|
||||
|
||||
const callsBefore = runEmbeddedPiAgentMock.mock.calls.length;
|
||||
const { run } = createMinimalRun({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
@@ -210,12 +214,9 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
});
|
||||
const res = await run();
|
||||
|
||||
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
|
||||
expect(runEmbeddedPiAgentMock.mock.calls.length - callsBefore).toBe(2);
|
||||
const payload = Array.isArray(res) ? res[0] : res;
|
||||
expect(payload).toMatchObject({
|
||||
text: expect.stringContaining("Context limit exceeded"),
|
||||
});
|
||||
expect(payload.text?.toLowerCase()).toContain("reset");
|
||||
expect(payload).toMatchObject({ text: "ok" });
|
||||
expect(sessionStore.main.sessionId).not.toBe(sessionId);
|
||||
|
||||
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||
@@ -228,7 +229,6 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("resets the session after role ordering payloads", async () => {
|
||||
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-role-ordering-"));
|
||||
|
||||
@@ -161,14 +161,11 @@ export async function runReplyAgent(params: {
|
||||
const steered = queueEmbeddedPiMessage(followupRun.run.sessionId, followupRun.prompt);
|
||||
if (steered && !shouldFollowup) {
|
||||
if (activeSessionEntry && activeSessionStore && sessionKey) {
|
||||
const updatedAt = Date.now();
|
||||
activeSessionEntry.updatedAt = updatedAt;
|
||||
activeSessionEntry.updatedAt = Date.now();
|
||||
activeSessionStore[sessionKey] = activeSessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStoreEntry({
|
||||
storePath,
|
||||
sessionKey,
|
||||
update: async () => ({ updatedAt }),
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = activeSessionEntry as SessionEntry;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -180,14 +177,11 @@ export async function runReplyAgent(params: {
|
||||
if (isActive && (shouldFollowup || resolvedQueue.mode === "steer")) {
|
||||
enqueueFollowupRun(queueKey, followupRun, resolvedQueue);
|
||||
if (activeSessionEntry && activeSessionStore && sessionKey) {
|
||||
const updatedAt = Date.now();
|
||||
activeSessionEntry.updatedAt = updatedAt;
|
||||
activeSessionEntry.updatedAt = Date.now();
|
||||
activeSessionStore[sessionKey] = activeSessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStoreEntry({
|
||||
storePath,
|
||||
sessionKey,
|
||||
update: async () => ({ updatedAt }),
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = activeSessionEntry as SessionEntry;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -334,18 +328,12 @@ export async function runReplyAgent(params: {
|
||||
sessionKey &&
|
||||
activeSessionEntry.groupActivationNeedsSystemIntro
|
||||
) {
|
||||
const updatedAt = Date.now();
|
||||
activeSessionEntry.groupActivationNeedsSystemIntro = false;
|
||||
activeSessionEntry.updatedAt = updatedAt;
|
||||
activeSessionEntry.updatedAt = Date.now();
|
||||
activeSessionStore[sessionKey] = activeSessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStoreEntry({
|
||||
storePath,
|
||||
sessionKey,
|
||||
update: async () => ({
|
||||
groupActivationNeedsSystemIntro: false,
|
||||
updatedAt,
|
||||
}),
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = activeSessionEntry as SessionEntry;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { describe, it } from "vitest";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
|
||||
const LIVE =
|
||||
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
|
||||
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
|
||||
const CDP_URL = process.env.CLAWDBOT_LIVE_BROWSER_CDP_URL?.trim() || "";
|
||||
const describeLive = LIVE && CDP_URL ? describe : describe.skip;
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { toBoolean } from "./utils.js";
|
||||
|
||||
describe("toBoolean", () => {
|
||||
it("parses yes/no and 1/0", () => {
|
||||
expect(toBoolean("yes")).toBe(true);
|
||||
expect(toBoolean("1")).toBe(true);
|
||||
expect(toBoolean("no")).toBe(false);
|
||||
expect(toBoolean("0")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns undefined for on/off strings", () => {
|
||||
expect(toBoolean("on")).toBeUndefined();
|
||||
expect(toBoolean("off")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes through boolean values", () => {
|
||||
expect(toBoolean(true)).toBe(true);
|
||||
expect(toBoolean(false)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
||||
import { parseBooleanValue } from "../../utils/boolean.js";
|
||||
|
||||
/**
|
||||
* Extract profile name from query string or body and get profile context.
|
||||
@@ -55,10 +54,13 @@ export function toNumber(value: unknown) {
|
||||
}
|
||||
|
||||
export function toBoolean(value: unknown) {
|
||||
return parseBooleanValue(value, {
|
||||
truthy: ["true", "1", "yes"],
|
||||
falsy: ["false", "0", "no"],
|
||||
});
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") {
|
||||
const v = value.trim().toLowerCase();
|
||||
if (v === "true" || v === "1" || v === "yes") return true;
|
||||
if (v === "false" || v === "0" || v === "no") return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function toStringArray(value: unknown): string[] | undefined {
|
||||
|
||||
@@ -1 +1 @@
|
||||
c51e9080b032ccb0dd153452854f2ffdaca8e1db14d7b98ed56cca8f0f1a5257
|
||||
e8286176aaf2c0daf5f4b30dd221c3d7aa1aec8329d6865cac3d9636fd7f08f6
|
||||
+17439
-15246
File diff suppressed because it is too large.
Load diff
@@ -7,7 +7,6 @@ import type { Duplex } from "node:stream";
|
||||
|
||||
import chokidar from "chokidar";
|
||||
import { type WebSocket, WebSocketServer } from "ws";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { detectMime } from "../media/mime.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { ensureDir, resolveUserPath } from "../utils.js";
|
||||
@@ -172,7 +171,7 @@ async function resolveFilePath(rootReal: string, urlPath: string) {
|
||||
}
|
||||
|
||||
function isDisabledByEnv() {
|
||||
if (isTruthyEnvValue(process.env.CLAWDBOT_SKIP_CANVAS_HOST)) return true;
|
||||
if (process.env.CLAWDBOT_SKIP_CANVAS_HOST === "1") return true;
|
||||
if (process.env.NODE_ENV === "test") return true;
|
||||
if (process.env.VITEST) return true;
|
||||
return false;
|
||||
|
||||
@@ -69,11 +69,6 @@ export type ChannelOnboardingDmPolicy = {
|
||||
allowFromKey: string;
|
||||
getCurrent: (cfg: ClawdbotConfig) => DmPolicy;
|
||||
setPolicy: (cfg: ClawdbotConfig, policy: DmPolicy) => ClawdbotConfig;
|
||||
promptAllowFrom?: (params: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId?: string;
|
||||
}) => Promise<ClawdbotConfig>;
|
||||
};
|
||||
|
||||
export type ChannelOnboardingAdapter = {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
resolveDiscordAccount,
|
||||
} from "../../../discord/accounts.js";
|
||||
import { normalizeDiscordSlug } from "../../../discord/monitor/allow-list.js";
|
||||
import { resolveDiscordUserAllowlist } from "../../../discord/resolve-users.js";
|
||||
import {
|
||||
resolveDiscordChannelAllowlist,
|
||||
type DiscordChannelResolution,
|
||||
@@ -149,113 +148,6 @@ function setDiscordGuildChannelAllowlist(
|
||||
};
|
||||
}
|
||||
|
||||
function setDiscordAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
discord: {
|
||||
...cfg.channels?.discord,
|
||||
dm: {
|
||||
...cfg.channels?.discord?.dm,
|
||||
enabled: cfg.channels?.discord?.dm?.enabled ?? true,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseDiscordAllowFromInput(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function promptDiscordAllowFrom(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId?: string;
|
||||
}): Promise<ClawdbotConfig> {
|
||||
const accountId =
|
||||
params.accountId && normalizeAccountId(params.accountId)
|
||||
? normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID
|
||||
: resolveDefaultDiscordAccountId(params.cfg);
|
||||
const resolved = resolveDiscordAccount({ cfg: params.cfg, accountId });
|
||||
const token = resolved.token;
|
||||
const existing = params.cfg.channels?.discord?.dm?.allowFrom ?? [];
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Allowlist Discord DMs by username (we resolve to user ids).",
|
||||
"Examples:",
|
||||
"- 123456789012345678",
|
||||
"- @alice",
|
||||
"- alice#1234",
|
||||
"Multiple entries: comma-separated.",
|
||||
`Docs: ${formatDocsLink("/discord", "discord")}`,
|
||||
].join("\n"),
|
||||
"Discord allowlist",
|
||||
);
|
||||
|
||||
const parseInputs = (value: string) => parseDiscordAllowFromInput(value);
|
||||
const parseId = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const mention = trimmed.match(/^<@!?(\d+)>$/);
|
||||
if (mention) return mention[1];
|
||||
const prefixed = trimmed.replace(/^(user:|discord:)/i, "");
|
||||
if (/^\d+$/.test(prefixed)) return prefixed;
|
||||
return null;
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const entry = await params.prompter.text({
|
||||
message: "Discord allowFrom (usernames or ids)",
|
||||
placeholder: "@alice, 123456789012345678",
|
||||
initialValue: existing[0] ? String(existing[0]) : undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
const parts = parseInputs(String(entry));
|
||||
if (!token) {
|
||||
const ids = parts.map(parseId).filter(Boolean) as string[];
|
||||
if (ids.length !== parts.length) {
|
||||
await params.prompter.note(
|
||||
"Bot token missing; use numeric user ids (or mention form) only.",
|
||||
"Discord allowlist",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const unique = [...new Set([...existing.map((v) => String(v).trim()), ...ids])].filter(
|
||||
Boolean,
|
||||
);
|
||||
return setDiscordAllowFrom(params.cfg, unique);
|
||||
}
|
||||
|
||||
const results = await resolveDiscordUserAllowlist({
|
||||
token,
|
||||
entries: parts,
|
||||
}).catch(() => null);
|
||||
if (!results) {
|
||||
await params.prompter.note("Failed to resolve usernames. Try again.", "Discord allowlist");
|
||||
continue;
|
||||
}
|
||||
const unresolved = results.filter((res) => !res.resolved || !res.id);
|
||||
if (unresolved.length > 0) {
|
||||
await params.prompter.note(
|
||||
`Could not resolve: ${unresolved.map((res) => res.input).join(", ")}`,
|
||||
"Discord allowlist",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const ids = results.map((res) => res.id as string);
|
||||
const unique = [
|
||||
...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids]),
|
||||
];
|
||||
return setDiscordAllowFrom(params.cfg, unique);
|
||||
}
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Discord",
|
||||
channel,
|
||||
@@ -263,7 +155,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
allowFromKey: "channels.discord.dm.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.discord?.dm?.policy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setDiscordDmPolicy(cfg, policy),
|
||||
promptAllowFrom: promptDiscordAllowFrom,
|
||||
};
|
||||
|
||||
export const discordOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
|
||||
@@ -6,10 +6,8 @@ import {
|
||||
resolveDefaultIMessageAccountId,
|
||||
resolveIMessageAccount,
|
||||
} from "../../../imessage/accounts.js";
|
||||
import { normalizeIMessageHandle } from "../../../imessage/targets.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
|
||||
import { formatDocsLink } from "../../../terminal/links.js";
|
||||
import type { WizardPrompter } from "../../../wizard/prompts.js";
|
||||
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js";
|
||||
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
|
||||
|
||||
@@ -31,105 +29,6 @@ function setIMessageDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
|
||||
};
|
||||
}
|
||||
|
||||
function setIMessageAllowFrom(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
allowFrom: string[],
|
||||
): ClawdbotConfig {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
imessage: {
|
||||
...cfg.channels?.imessage,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
imessage: {
|
||||
...cfg.channels?.imessage,
|
||||
accounts: {
|
||||
...cfg.channels?.imessage?.accounts,
|
||||
[accountId]: {
|
||||
...cfg.channels?.imessage?.accounts?.[accountId],
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseIMessageAllowFromInput(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function promptIMessageAllowFrom(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId?: string;
|
||||
}): Promise<ClawdbotConfig> {
|
||||
const accountId =
|
||||
params.accountId && normalizeAccountId(params.accountId)
|
||||
? normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID
|
||||
: resolveDefaultIMessageAccountId(params.cfg);
|
||||
const resolved = resolveIMessageAccount({ cfg: params.cfg, accountId });
|
||||
const existing = resolved.config.allowFrom ?? [];
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Allowlist iMessage DMs by handle or chat target.",
|
||||
"Examples:",
|
||||
"- +15555550123",
|
||||
"- user@example.com",
|
||||
"- chat_id:123",
|
||||
"- chat_guid:... or chat_identifier:...",
|
||||
"Multiple entries: comma-separated.",
|
||||
`Docs: ${formatDocsLink("/imessage", "imessage")}`,
|
||||
].join("\n"),
|
||||
"iMessage allowlist",
|
||||
);
|
||||
const entry = await params.prompter.text({
|
||||
message: "iMessage allowFrom (handle or chat_id)",
|
||||
placeholder: "+15555550123, user@example.com, chat_id:123",
|
||||
initialValue: existing[0] ? String(existing[0]) : undefined,
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "Required";
|
||||
const parts = parseIMessageAllowFromInput(raw);
|
||||
for (const part of parts) {
|
||||
if (part === "*") continue;
|
||||
if (part.toLowerCase().startsWith("chat_id:")) {
|
||||
const id = part.slice("chat_id:".length).trim();
|
||||
if (!/^\d+$/.test(id)) return `Invalid chat_id: ${part}`;
|
||||
continue;
|
||||
}
|
||||
if (part.toLowerCase().startsWith("chat_guid:")) {
|
||||
if (!part.slice("chat_guid:".length).trim()) return "Invalid chat_guid entry";
|
||||
continue;
|
||||
}
|
||||
if (part.toLowerCase().startsWith("chat_identifier:")) {
|
||||
if (!part.slice("chat_identifier:".length).trim()) return "Invalid chat_identifier entry";
|
||||
continue;
|
||||
}
|
||||
if (!normalizeIMessageHandle(part)) return `Invalid handle: ${part}`;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const parts = parseIMessageAllowFromInput(String(entry));
|
||||
const unique = [...new Set(parts)];
|
||||
return setIMessageAllowFrom(params.cfg, accountId, unique);
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "iMessage",
|
||||
channel,
|
||||
@@ -137,7 +36,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
allowFromKey: "channels.imessage.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.imessage?.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setIMessageDmPolicy(cfg, policy),
|
||||
promptAllowFrom: promptIMessageAllowFrom,
|
||||
};
|
||||
|
||||
export const imessageOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
resolveSignalAccount,
|
||||
} from "../../../signal/accounts.js";
|
||||
import { formatDocsLink } from "../../../terminal/links.js";
|
||||
import { normalizeE164 } from "../../../utils.js";
|
||||
import type { WizardPrompter } from "../../../wizard/prompts.js";
|
||||
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js";
|
||||
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
|
||||
|
||||
@@ -32,107 +30,6 @@ function setSignalDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
|
||||
};
|
||||
}
|
||||
|
||||
function setSignalAllowFrom(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
allowFrom: string[],
|
||||
): ClawdbotConfig {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
signal: {
|
||||
...cfg.channels?.signal,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
signal: {
|
||||
...cfg.channels?.signal,
|
||||
accounts: {
|
||||
...cfg.channels?.signal?.accounts,
|
||||
[accountId]: {
|
||||
...cfg.channels?.signal?.accounts?.[accountId],
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseSignalAllowFromInput(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function isUuidLike(value: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
||||
}
|
||||
|
||||
async function promptSignalAllowFrom(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId?: string;
|
||||
}): Promise<ClawdbotConfig> {
|
||||
const accountId =
|
||||
params.accountId && normalizeAccountId(params.accountId)
|
||||
? normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID
|
||||
: resolveDefaultSignalAccountId(params.cfg);
|
||||
const resolved = resolveSignalAccount({ cfg: params.cfg, accountId });
|
||||
const existing = resolved.config.allowFrom ?? [];
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Allowlist Signal DMs by sender id.",
|
||||
"Examples:",
|
||||
"- +15555550123",
|
||||
"- uuid:123e4567-e89b-12d3-a456-426614174000",
|
||||
"Multiple entries: comma-separated.",
|
||||
`Docs: ${formatDocsLink("/signal", "signal")}`,
|
||||
].join("\n"),
|
||||
"Signal allowlist",
|
||||
);
|
||||
const entry = await params.prompter.text({
|
||||
message: "Signal allowFrom (E.164 or uuid)",
|
||||
placeholder: "+15555550123, uuid:123e4567-e89b-12d3-a456-426614174000",
|
||||
initialValue: existing[0] ? String(existing[0]) : undefined,
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "Required";
|
||||
const parts = parseSignalAllowFromInput(raw);
|
||||
for (const part of parts) {
|
||||
if (part === "*") continue;
|
||||
if (part.toLowerCase().startsWith("uuid:")) {
|
||||
if (!part.slice("uuid:".length).trim()) return "Invalid uuid entry";
|
||||
continue;
|
||||
}
|
||||
if (isUuidLike(part)) continue;
|
||||
if (!normalizeE164(part)) return `Invalid entry: ${part}`;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const parts = parseSignalAllowFromInput(String(entry));
|
||||
const normalized = parts
|
||||
.map((part) => {
|
||||
if (part === "*") return "*";
|
||||
if (part.toLowerCase().startsWith("uuid:")) return `uuid:${part.slice(5).trim()}`;
|
||||
if (isUuidLike(part)) return `uuid:${part}`;
|
||||
return normalizeE164(part);
|
||||
})
|
||||
.filter(Boolean);
|
||||
const unique = [...new Set(normalized)];
|
||||
return setSignalAllowFrom(params.cfg, accountId, unique);
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Signal",
|
||||
channel,
|
||||
@@ -140,7 +37,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
allowFromKey: "channels.signal.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.signal?.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setSignalDmPolicy(cfg, policy),
|
||||
promptAllowFrom: promptSignalAllowFrom,
|
||||
};
|
||||
|
||||
export const signalOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
resolveSlackAccount,
|
||||
} from "../../../slack/accounts.js";
|
||||
import { resolveSlackChannelAllowlist } from "../../../slack/resolve-channels.js";
|
||||
import { resolveSlackUserAllowlist } from "../../../slack/resolve-users.js";
|
||||
import { formatDocsLink } from "../../../terminal/links.js";
|
||||
import type { WizardPrompter } from "../../../wizard/prompts.js";
|
||||
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js";
|
||||
@@ -201,111 +200,6 @@ function setSlackChannelAllowlist(
|
||||
};
|
||||
}
|
||||
|
||||
function setSlackAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
slack: {
|
||||
...cfg.channels?.slack,
|
||||
dm: {
|
||||
...cfg.channels?.slack?.dm,
|
||||
enabled: cfg.channels?.slack?.dm?.enabled ?? true,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseSlackAllowFromInput(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function promptSlackAllowFrom(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId?: string;
|
||||
}): Promise<ClawdbotConfig> {
|
||||
const accountId =
|
||||
params.accountId && normalizeAccountId(params.accountId)
|
||||
? normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID
|
||||
: resolveDefaultSlackAccountId(params.cfg);
|
||||
const resolved = resolveSlackAccount({ cfg: params.cfg, accountId });
|
||||
const token = resolved.config.userToken ?? resolved.config.botToken ?? "";
|
||||
const existing = params.cfg.channels?.slack?.dm?.allowFrom ?? [];
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Allowlist Slack DMs by username (we resolve to user ids).",
|
||||
"Examples:",
|
||||
"- U12345678",
|
||||
"- @alice",
|
||||
"Multiple entries: comma-separated.",
|
||||
`Docs: ${formatDocsLink("/slack", "slack")}`,
|
||||
].join("\n"),
|
||||
"Slack allowlist",
|
||||
);
|
||||
const parseInputs = (value: string) => parseSlackAllowFromInput(value);
|
||||
const parseId = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const mention = trimmed.match(/^<@([A-Z0-9]+)>$/i);
|
||||
if (mention) return mention[1]?.toUpperCase();
|
||||
const prefixed = trimmed.replace(/^(slack:|user:)/i, "");
|
||||
if (/^[A-Z][A-Z0-9]+$/i.test(prefixed)) return prefixed.toUpperCase();
|
||||
return null;
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const entry = await params.prompter.text({
|
||||
message: "Slack allowFrom (usernames or ids)",
|
||||
placeholder: "@alice, U12345678",
|
||||
initialValue: existing[0] ? String(existing[0]) : undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
const parts = parseInputs(String(entry));
|
||||
if (!token) {
|
||||
const ids = parts.map(parseId).filter(Boolean) as string[];
|
||||
if (ids.length !== parts.length) {
|
||||
await params.prompter.note(
|
||||
"Slack token missing; use user ids (or mention form) only.",
|
||||
"Slack allowlist",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const unique = [...new Set([...existing.map((v) => String(v).trim()), ...ids])].filter(
|
||||
Boolean,
|
||||
);
|
||||
return setSlackAllowFrom(params.cfg, unique);
|
||||
}
|
||||
|
||||
const results = await resolveSlackUserAllowlist({
|
||||
token,
|
||||
entries: parts,
|
||||
}).catch(() => null);
|
||||
if (!results) {
|
||||
await params.prompter.note("Failed to resolve usernames. Try again.", "Slack allowlist");
|
||||
continue;
|
||||
}
|
||||
const unresolved = results.filter((res) => !res.resolved || !res.id);
|
||||
if (unresolved.length > 0) {
|
||||
await params.prompter.note(
|
||||
`Could not resolve: ${unresolved.map((res) => res.input).join(", ")}`,
|
||||
"Slack allowlist",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const ids = results.map((res) => res.id as string);
|
||||
const unique = [
|
||||
...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids]),
|
||||
];
|
||||
return setSlackAllowFrom(params.cfg, unique);
|
||||
}
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Slack",
|
||||
channel,
|
||||
@@ -313,7 +207,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
allowFromKey: "channels.slack.dm.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.slack?.dm?.policy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setSlackDmPolicy(cfg, policy),
|
||||
promptAllowFrom: promptSlackAllowFrom,
|
||||
};
|
||||
|
||||
export const slackOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
|
||||
@@ -65,59 +65,21 @@ async function promptTelegramAllowFrom(params: {
|
||||
const resolved = resolveTelegramAccount({ cfg, accountId });
|
||||
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
||||
await noteTelegramUserIdHelp(prompter);
|
||||
|
||||
const token = resolved.token;
|
||||
if (!token) {
|
||||
await prompter.note("Telegram token missing; username lookup is unavailable.", "Telegram");
|
||||
}
|
||||
|
||||
const resolveTelegramUserId = async (raw: string): Promise<string | null> => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
const stripped = trimmed.replace(/^(telegram|tg):/i, "").trim();
|
||||
if (/^\d+$/.test(stripped)) return stripped;
|
||||
if (!token) return null;
|
||||
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
|
||||
const url = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(username)}`;
|
||||
const res = await fetch(url);
|
||||
const data = (await res.json().catch(() => null)) as
|
||||
| { ok?: boolean; result?: { id?: number | string } }
|
||||
| null;
|
||||
const id = data?.ok ? data?.result?.id : undefined;
|
||||
if (typeof id === "number" || typeof id === "string") return String(id);
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseInput = (value: string) =>
|
||||
value
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
let resolvedIds: string[] = [];
|
||||
while (resolvedIds.length === 0) {
|
||||
const entry = await prompter.text({
|
||||
message: "Telegram allowFrom (username or user id)",
|
||||
placeholder: "@username",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
const parts = parseInput(String(entry));
|
||||
const results = await Promise.all(parts.map((part) => resolveTelegramUserId(part)));
|
||||
const unresolved = parts.filter((_, idx) => !results[idx]);
|
||||
if (unresolved.length > 0) {
|
||||
await prompter.note(
|
||||
`Could not resolve: ${unresolved.join(", ")}. Use @username or numeric id.`,
|
||||
"Telegram allowlist",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
resolvedIds = results.filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
const entry = await prompter.text({
|
||||
message: "Telegram allowFrom (user id)",
|
||||
placeholder: "123456789",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "Required";
|
||||
if (!/^\d+$/.test(raw)) return "Use a numeric Telegram user id";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const normalized = String(entry).trim();
|
||||
const merged = [
|
||||
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
|
||||
...resolvedIds,
|
||||
normalized,
|
||||
];
|
||||
const unique = [...new Set(merged)];
|
||||
|
||||
@@ -157,22 +119,6 @@ async function promptTelegramAllowFrom(params: {
|
||||
};
|
||||
}
|
||||
|
||||
async function promptTelegramAllowFromForAccount(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId?: string;
|
||||
}): Promise<ClawdbotConfig> {
|
||||
const accountId =
|
||||
params.accountId && normalizeAccountId(params.accountId)
|
||||
? normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID
|
||||
: resolveDefaultTelegramAccountId(params.cfg);
|
||||
return promptTelegramAllowFrom({
|
||||
cfg: params.cfg,
|
||||
prompter: params.prompter,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Telegram",
|
||||
channel,
|
||||
@@ -180,7 +126,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
allowFromKey: "channels.telegram.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setTelegramDmPolicy(cfg, policy),
|
||||
promptAllowFrom: promptTelegramAllowFromForAccount,
|
||||
};
|
||||
|
||||
export const telegramOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildParseArgv,
|
||||
getFlagValue,
|
||||
getCommandPath,
|
||||
getPrimaryCommand,
|
||||
hasHelpOrVersion,
|
||||
hasFlag,
|
||||
} from "./argv.js";
|
||||
|
||||
describe("argv helpers", () => {
|
||||
it("detects help/version flags", () => {
|
||||
expect(hasHelpOrVersion(["node", "clawdbot", "--help"])).toBe(true);
|
||||
expect(hasHelpOrVersion(["node", "clawdbot", "-V"])).toBe(true);
|
||||
expect(hasHelpOrVersion(["node", "clawdbot", "status"])).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts command path ignoring flags and terminator", () => {
|
||||
expect(getCommandPath(["node", "clawdbot", "status", "--json"], 2)).toEqual([
|
||||
"status",
|
||||
]);
|
||||
expect(getCommandPath(["node", "clawdbot", "agents", "list"], 2)).toEqual([
|
||||
"agents",
|
||||
"list",
|
||||
]);
|
||||
expect(getCommandPath(["node", "clawdbot", "status", "--", "ignored"], 2)).toEqual([
|
||||
"status",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns primary command", () => {
|
||||
expect(getPrimaryCommand(["node", "clawdbot", "agents", "list"])).toBe("agents");
|
||||
expect(getPrimaryCommand(["node", "clawdbot"])).toBeNull();
|
||||
});
|
||||
|
||||
it("parses boolean flags and ignores terminator", () => {
|
||||
expect(hasFlag(["node", "clawdbot", "status", "--json"], "--json")).toBe(true);
|
||||
expect(hasFlag(["node", "clawdbot", "--", "--json"], "--json")).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts flag values with equals and missing values", () => {
|
||||
expect(getFlagValue(["node", "clawdbot", "status", "--timeout", "5000"], "--timeout")).toBe(
|
||||
"5000",
|
||||
);
|
||||
expect(getFlagValue(["node", "clawdbot", "status", "--timeout=2500"], "--timeout")).toBe(
|
||||
"2500",
|
||||
);
|
||||
expect(getFlagValue(["node", "clawdbot", "status", "--timeout"], "--timeout")).toBeNull();
|
||||
expect(getFlagValue(["node", "clawdbot", "status", "--timeout", "--json"], "--timeout")).toBe(
|
||||
null,
|
||||
);
|
||||
expect(getFlagValue(["node", "clawdbot", "--", "--timeout=99"], "--timeout")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds parse argv from raw args", () => {
|
||||
const nodeArgv = buildParseArgv({
|
||||
programName: "clawdbot",
|
||||
rawArgs: ["node", "clawdbot", "status"],
|
||||
});
|
||||
expect(nodeArgv).toEqual(["node", "clawdbot", "status"]);
|
||||
|
||||
const directArgv = buildParseArgv({
|
||||
programName: "clawdbot",
|
||||
rawArgs: ["clawdbot", "status"],
|
||||
});
|
||||
expect(directArgv).toEqual(["node", "clawdbot", "status"]);
|
||||
|
||||
const bunArgv = buildParseArgv({
|
||||
programName: "clawdbot",
|
||||
rawArgs: ["bun", "src/entry.ts", "status"],
|
||||
});
|
||||
expect(bunArgv).toEqual(["bun", "src/entry.ts", "status"]);
|
||||
});
|
||||
|
||||
it("builds parse argv from fallback args", () => {
|
||||
const fallbackArgv = buildParseArgv({
|
||||
programName: "clawdbot",
|
||||
fallbackArgv: ["status"],
|
||||
});
|
||||
expect(fallbackArgv).toEqual(["node", "clawdbot", "status"]);
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
const HELP_FLAGS = new Set(["-h", "--help"]);
|
||||
const VERSION_FLAGS = new Set(["-v", "-V", "--version"]);
|
||||
const FLAG_TERMINATOR = "--";
|
||||
|
||||
export function hasHelpOrVersion(argv: string[]): boolean {
|
||||
return argv.some((arg) => HELP_FLAGS.has(arg) || VERSION_FLAGS.has(arg));
|
||||
}
|
||||
|
||||
function isValueToken(arg: string | undefined): boolean {
|
||||
if (!arg) return false;
|
||||
if (arg === FLAG_TERMINATOR) return false;
|
||||
if (!arg.startsWith("-")) return true;
|
||||
return /^-\d+(?:\.\d+)?$/.test(arg);
|
||||
}
|
||||
|
||||
export function hasFlag(argv: string[], name: string): boolean {
|
||||
const args = argv.slice(2);
|
||||
for (const arg of args) {
|
||||
if (arg === FLAG_TERMINATOR) break;
|
||||
if (arg === name) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getFlagValue(argv: string[], name: string): string | null | undefined {
|
||||
const args = argv.slice(2);
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (arg === FLAG_TERMINATOR) break;
|
||||
if (arg === name) {
|
||||
const next = args[i + 1];
|
||||
return isValueToken(next) ? next : null;
|
||||
}
|
||||
if (arg.startsWith(`${name}=`)) {
|
||||
const value = arg.slice(name.length + 1);
|
||||
return value ? value : null;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getCommandPath(argv: string[], depth = 2): string[] {
|
||||
const args = argv.slice(2);
|
||||
const path: string[] = [];
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (!arg) continue;
|
||||
if (arg === "--") break;
|
||||
if (arg.startsWith("-")) continue;
|
||||
path.push(arg);
|
||||
if (path.length >= depth) break;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function getPrimaryCommand(argv: string[]): string | null {
|
||||
const [primary] = getCommandPath(argv, 1);
|
||||
return primary ?? null;
|
||||
}
|
||||
|
||||
export function buildParseArgv(params: {
|
||||
programName?: string;
|
||||
rawArgs?: string[];
|
||||
fallbackArgv?: string[];
|
||||
}): string[] {
|
||||
const baseArgv =
|
||||
params.rawArgs && params.rawArgs.length > 0
|
||||
? params.rawArgs
|
||||
: params.fallbackArgv && params.fallbackArgv.length > 0
|
||||
? params.fallbackArgv
|
||||
: process.argv;
|
||||
const programName = params.programName ?? "";
|
||||
const normalizedArgv =
|
||||
programName && baseArgv[0] === programName
|
||||
? baseArgv.slice(1)
|
||||
: baseArgv[0]?.endsWith("clawdbot")
|
||||
? baseArgv.slice(1)
|
||||
: baseArgv;
|
||||
const executable = normalizedArgv[0]?.split(/[/\\]/).pop() ?? "";
|
||||
const looksLikeNode =
|
||||
normalizedArgv.length >= 2 && (executable === "node" || executable === "bun");
|
||||
if (looksLikeNode) return normalizedArgv;
|
||||
return ["node", programName || "clawdbot", ...normalizedArgv];
|
||||
}
|
||||
|
||||
export function isReadOnlyCommand(argv: string[]): boolean {
|
||||
const path = getCommandPath(argv, 2);
|
||||
if (path.length === 0) return false;
|
||||
const [primary, secondary] = path;
|
||||
if (primary === "health" || primary === "status" || primary === "sessions") return true;
|
||||
if (primary === "memory" && secondary === "status") return true;
|
||||
if (primary === "agents" && secondary === "list") return true;
|
||||
return false;
|
||||
}
|
||||
@@ -14,13 +14,14 @@ import {
|
||||
import { browserAct } from "../browser/client-actions-core.js";
|
||||
import { danger } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { parseBooleanValue } from "../utils/boolean.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
import { registerBrowserCookiesAndStorageCommands } from "./browser-cli-state.cookies-storage.js";
|
||||
|
||||
function parseOnOff(raw: string): boolean | null {
|
||||
const parsed = parseBooleanValue(raw);
|
||||
return parsed === undefined ? null : parsed;
|
||||
const v = raw.trim().toLowerCase();
|
||||
if (v === "on" || v === "true" || v === "1") return true;
|
||||
if (v === "off" || v === "false" || v === "0") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerBrowserStateCommands(
|
||||
|
||||
Loaded 100 of 241 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user