Compare commits

..
Author SHA1 Message Date
Peter Steinberger adbc4d093e fix: handle repeated compaction failures (#1187) (thanks @fayrose) 2026-01-18 22:02:22 +00:00
Lauren Rosenberg 0c1ac41085 fix: add reserveTokensFloor suggestion to compaction error messages
When context limit is exceeded, the error message now suggests
setting agents.defaults.compaction.reserveTokensFloor to 4000
or higher to prevent future occurrences.
2026-01-18 21:54:04 +00:00
Lauren Rosenberg a8c204e9fa style: apply prettier formatting 2026-01-18 21:54:04 +00:00
Lauren Rosenberg 1ac6f3ce92 fix: return user-facing error when session reset after compaction failure
Previously, when auto-compaction failed due to context overflow, the system
would reset the session and silently continue the execution loop without
sending any response to the user. This made it appear as if messages were
being ignored.

This change ensures users receive a clear error message explaining that
the context limit was exceeded and the conversation has been reset,
consistent with how role ordering conflicts are already handled.

Fixes the silent failure case where message + compaction exceeds context limits.
2026-01-18 21:54:04 +00:00
Peter Steinberger b621d4550b chore: tighten skills prompt rules 2026-01-18 21:30:27 +00:00
Peter Steinberger 769b286cf2 fix: make docs list node-safe 2026-01-18 19:37:13 +00:00
Peter Steinberger 690bb192e6 style: format code 2026-01-18 19:36:46 +00:00
Peter Steinberger 601a052216 fix: unblock bundled plugin load 2026-01-18 19:34:21 +00:00
Peter Steinberger bf3021d266 fix: stabilize logging imports and tests 2026-01-18 19:34:08 +00:00
Peter Steinberger 145b2e5f52 fix: menu preview label colors 2026-01-18 19:04:01 +00:00
Peter Steinberger 4b73dc95c4 fix: normalize envelope options 2026-01-18 18:59:34 +00:00
Peter Steinberger e17cb408a5 fix(cli): load pairing channels after plugins 2026-01-18 18:56:40 +00:00
Peter Steinberger 3cf92152c3 fix: appease tsc in test helpers 2026-01-18 18:54:59 +00:00
Peter Steinberger c50cde2170 fix: clean up lint in gateway tests 2026-01-18 18:51:43 +00:00
Peter Steinberger 7e0bebd669 docs: update clawtributors 2026-01-18 18:51:08 +00:00
Peter Steinberger 7c49326191 fix: satisfy oxlint spread rule 2026-01-18 18:50:52 +00:00
Peter SteinbergerandShiva Prasad 744d1329cb feat: make inbound envelopes configurable
Co-authored-by: Shiva Prasad <shiv19@users.noreply.github.com>
2026-01-18 18:50:37 +00:00
Peter Steinberger 42e6ff4611 feat(cli): show Telegram bot username in status 2026-01-18 18:48:25 +00:00
Peter Steinberger 5f21bf735a chore: switch repo scripts to node 2026-01-18 18:46:18 +00:00
Peter Steinberger ee380e9ab9 fix: run cli scripts via node build runner 2026-01-18 18:43:39 +00:00
Peter Steinberger ab340c82fb fix: stabilize tests and logging 2026-01-18 18:43:31 +00:00
Peter Steinberger 57dd0505a3 Merge pull request #1181 from sebslight/plugins/exclusive-slots
Plugins: auto-select exclusive slots
2026-01-18 18:40:38 +00:00
Peter Steinberger d6f9f1c79a Merge pull request #1182 from zerone0x/fix/issue-1115-filter-openrouter-auto
fix(configure): filter openrouter/auto from model selection list
2026-01-18 18:32:47 +00:00
Peter Steinberger a08a772ffc fix: add model picker regression for openrouter auto (#1182) (thanks @zerone0x) 2026-01-18 18:32:25 +00:00
zerone0xandClaude 2622b1936b fix(configure): filter openrouter/auto from model selection list
The openrouter/auto model is OpenRouter's internal routing feature,
not a callable model. While it's valid as a default (set automatically
during OpenRouter auth flow), showing it in the configure wizard's
model selection causes "Unknown model: openrouter/auto" errors when
users select it manually.

Add a HIDDEN_ROUTER_MODELS set to filter out such internal models
from the selection list while preserving existing functionality.

Fixes #1115

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-18 18:31:55 +00:00
Peter Steinberger c0457e0cc4 fix(mac): load menu session previews 2026-01-18 18:28:48 +00:00
Peter Steinberger ee2f0a175a docs: add Windows installer git mode 2026-01-18 18:26:20 +00:00
Sebastian Slight cef6b16d14 Plugins: auto-select exclusive slots 2026-01-18 11:26:50 -05:00
119 changed files with 2308 additions and 732 deletions

No files matched your search

+5 -1
View File
@@ -6,9 +6,13 @@ Docs: https://docs.clawd.bot
### Changes
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
- Agents: make inbound message envelopes configurable (timezone/timestamp/elapsed) and surface elapsed gaps. (#1150) — thanks @shiv19.
### Fixes
- Plugins: auto-select exclusive slots when enabling/installing plugins. (#1181) — thanks @sebslight.
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
- Agents: retry compaction reset once, then surface a user-facing error on repeat failure. (#1187) — thanks @fayrose.
- 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.
## 2026.1.18-4
+21 -21
View File
@@ -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/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>
<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>
</p>
@@ -14,6 +14,7 @@ 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?
@@ -87,6 +88,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
self.menuOpenWidth = nil
self.loadTask?.cancel()
self.nodesLoadTask?.cancel()
self.cancelPreviewTasks()
}
func menuNeedsUpdate(_ menu: NSMenu) {
@@ -107,6 +109,7 @@ 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)
@@ -454,15 +457,46 @@ extension MenuSessionsInjector {
item.tag = self.tag
item.isEnabled = false
let view = AnyView(SessionMenuPreviewView(
sessionKey: sessionKey,
width: width,
maxItems: 10,
maxLines: maxLines,
title: title))
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
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)
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) {
@@ -3,13 +3,13 @@ import ClawdbotKit
import OSLog
import SwiftUI
private struct SessionPreviewItem: Identifiable, Sendable {
struct SessionPreviewItem: Identifiable, Sendable {
let id: String
let role: PreviewRole
let text: String
}
private enum PreviewRole: String, Sendable {
enum PreviewRole: String, Sendable {
case user
case assistant
case tool
@@ -27,7 +27,7 @@ private enum PreviewRole: String, Sendable {
}
}
private actor SessionPreviewCache {
actor SessionPreviewCache {
static let shared = SessionPreviewCache()
private struct CacheEntry {
@@ -52,25 +52,33 @@ private actor SessionPreviewCache {
}
}
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
@Environment(\.menuItemHighlighted) private var isHighlighted
@State private var items: [SessionPreviewItem] = []
@State private var status: LoadStatus = .loading
private struct PreviewTimeoutError: LocalizedError {
var errorDescription: String? { "preview timeout" }
#if DEBUG
extension SessionPreviewCache {
func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt)
}
private enum LoadStatus: Equatable {
func _testReset() {
self.entries = [:]
}
}
#endif
struct SessionMenuPreviewSnapshot: Sendable {
let items: [SessionPreviewItem]
let status: SessionMenuPreviewView.LoadStatus
}
struct SessionMenuPreviewView: View {
let width: CGFloat
let maxLines: Int
let title: String
let items: [SessionPreviewItem]
let status: LoadStatus
@Environment(\.menuItemHighlighted) private var isHighlighted
enum LoadStatus: Equatable {
case loading
case ready
case empty
@@ -78,15 +86,17 @@ struct SessionMenuPreviewView: View {
}
private var primaryColor: Color {
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
if self.isHighlighted {
return Color(nsColor: .selectedMenuItemTextColor)
}
return Color(nsColor: .labelColor)
}
private var secondaryColor: Color {
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
}
private var previewLimit: Int {
min(max(self.maxItems * 3, 20), 120)
if self.isHighlighted {
return Color(nsColor: .selectedMenuItemTextColor).opacity(0.85)
}
return Color(nsColor: .secondaryLabelColor)
}
var body: some View {
@@ -100,21 +110,19 @@ struct SessionMenuPreviewView: View {
switch self.status {
case .loading:
Text("Loading preview…")
.font(.caption)
.foregroundStyle(self.secondaryColor)
self.placeholder("Loading preview…")
case .empty:
Text("No recent messages")
.font(.caption)
.foregroundStyle(self.secondaryColor)
self.placeholder("No recent messages")
case let .error(message):
Text(message)
.font(.caption)
.foregroundStyle(self.secondaryColor)
self.placeholder(message)
case .ready:
VStack(alignment: .leading, spacing: 6) {
ForEach(self.items) { item in
self.previewRow(item)
if self.items.isEmpty {
self.placeholder("No recent messages")
} else {
VStack(alignment: .leading, spacing: 6) {
ForEach(self.items) { item in
self.previewRow(item)
}
}
}
}
@@ -123,9 +131,6 @@ 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
@@ -157,55 +162,66 @@ struct SessionMenuPreviewView: View {
}
}
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
}
@ViewBuilder
private func placeholder(_ text: String) -> some View {
Text(text)
.font(.caption)
.foregroundStyle(self.primaryColor)
}
await MainActor.run {
self.status = .loading
}
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)
}
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: self.sessionKey,
limit: self.previewLimit,
sessionKey: sessionKey,
limit: self.previewLimit(for: maxItems),
timeoutMs: timeoutMs)
})
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
}
let built = Self.previewItems(from: payload, maxItems: maxItems)
await SessionPreviewCache.shared.store(items: built, for: sessionKey)
return Self.snapshot(from: built)
} catch is CancellationError {
return
return SessionMenuPreviewSnapshot(items: [], status: .loading)
} catch {
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 fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey)
if let fallback {
return Self.snapshot(from: fallback)
}
let errorDescription = String(describing: error)
Self.logger.warning(
"Session preview failed session=\(self.sessionKey, privacy: .public) " +
"Session preview failed session=\(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]
@@ -0,0 +1,26 @@
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)
}
}
+41 -2
View File
@@ -9,7 +9,7 @@ read_when:
Clawdbot standardizes timestamps so the model sees a **single reference time**.
## Message envelopes (UTC)
## Message envelopes (UTC by default)
Inbound messages are wrapped in an envelope like:
@@ -17,7 +17,46 @@ Inbound messages are wrapped in an envelope like:
[Provider ... 2026-01-05T21:26Z] message text
```
The timestamp in the envelope is **always UTC**, with minutes precision.
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
```
## Tool payloads (raw provider data + normalized fields)
+44 -4
View File
@@ -7,10 +7,10 @@ read_when:
# Date & Time
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.
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.
## Message envelopes (UTC)
## Message envelopes (UTC by default)
Inbound messages are wrapped with a UTC timestamp (minute precision):
@@ -18,7 +18,47 @@ Inbound messages are wrapped with a UTC timestamp (minute precision):
[Provider ... 2026-01-05T21:26Z] message text
```
This envelope timestamp is **always UTC**, regardless of the host timezone.
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
```
## System prompt: Current Date & Time
+72
View File
@@ -0,0 +1,72 @@
---
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.
+41
View File
@@ -12,6 +12,7 @@ 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:
@@ -19,6 +20,12 @@ 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)
@@ -73,3 +80,37 @@ 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.
+10
View File
@@ -836,10 +836,20 @@ 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;
+23 -17
View File
@@ -466,25 +466,34 @@ 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 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 groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: bodyText,
@@ -517,13 +526,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
OriginatingTo: `room:${roomId}`,
});
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,
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 },
@@ -406,10 +406,20 @@ 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;
@@ -421,15 +431,16 @@ 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}]` : ""}`,
}),
});
}
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,
}),
});
}
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: combinedBody,
@@ -455,11 +466,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
...mediaPayload,
});
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
void core.channel.session.recordSessionMetaFromInbound({
storePath,
void core.channel.session.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
}).catch((err) => {
+10 -3
View File
@@ -530,10 +530,20 @@ 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,
});
@@ -560,9 +570,6 @@ 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,
+10 -3
View File
@@ -274,10 +274,20 @@ 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,
});
@@ -301,9 +311,6 @@ 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,
+18 -18
View File
@@ -65,27 +65,27 @@
"dist/whatsapp/**"
],
"scripts": {
"dev": "bun src/entry.ts",
"dev": "node scripts/run-node.mjs",
"postinstall": "node scripts/postinstall.js",
"prepack": "pnpm build",
"docs:list": "bun scripts/docs-list.ts",
"docs:bin": "bun build scripts/docs-list.ts --compile --outfile bin/docs-list",
"docs:list": "node scripts/docs-list.js",
"docs:bin": "node scripts/build-docs-list.mjs",
"docs:dev": "cd docs && mint dev",
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
"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",
"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",
"ui:install": "node scripts/ui.js install",
"ui:dev": "node scripts/ui.js dev",
"ui:build": "node scripts/ui.js build",
"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",
"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",
"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": "bun scripts/test-force.ts",
"test:force": "node --import tsx 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": "bun scripts/protocol-gen.ts",
"protocol:gen:swift": "bun scripts/protocol-gen-swift.ts",
"protocol:gen": "node --import tsx scripts/protocol-gen.ts",
"protocol:gen:swift": "node --import tsx 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": "tsx scripts/check-ts-max-loc.ts --max 500"
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500"
},
"keywords": [],
"author": "",
+15
View File
@@ -0,0 +1,15 @@
#!/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 });
+24 -15
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env bun
#!/usr/bin/env node
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path';
process.stdout.on('error', (error) => {
if ((error as NodeJS.ErrnoException).code === 'EPIPE') {
if (error?.code === 'EPIPE') {
process.exit(0);
}
throw error;
@@ -14,8 +14,12 @@ const DOCS_DIR = join(process.cwd(), 'docs');
const EXCLUDED_DIRS = new Set(['archive', 'research']);
function compactStrings(values: unknown[]): string[] {
const result: string[] = [];
/**
* @param {unknown[]} values
* @returns {string[]}
*/
function compactStrings(values) {
const result = [];
for (const value of values) {
if (value === null || value === undefined) {
continue;
@@ -28,9 +32,14 @@ function compactStrings(values: unknown[]): string[] {
return result;
}
function walkMarkdownFiles(dir: string, base: string = dir): string[] {
/**
* @param {string} dir
* @param {string} base
* @returns {string[]}
*/
function walkMarkdownFiles(dir, base = dir) {
const entries = readdirSync(dir, { withFileTypes: true });
const files: string[] = [];
const files = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) {
continue;
@@ -48,11 +57,11 @@ function walkMarkdownFiles(dir: string, base: string = dir): string[] {
return files.sort((a, b) => a.localeCompare(b));
}
function extractMetadata(fullPath: string): {
summary: string | null;
readWhen: string[];
error?: string;
} {
/**
* @param {string} fullPath
* @returns {{ summary: string | null; readWhen: string[]; error?: string }}
*/
function extractMetadata(fullPath) {
const content = readFileSync(fullPath, 'utf8');
if (!content.startsWith('---')) {
@@ -67,9 +76,9 @@ function extractMetadata(fullPath: string): {
const frontMatter = content.slice(3, endIndex).trim();
const lines = frontMatter.split('\n');
let summaryLine: string | null = null;
const readWhen: string[] = [];
let collectingField: 'read_when' | null = null;
let summaryLine = null;
const readWhen = [];
let collectingField = null;
for (const rawLine of lines) {
const line = rawLine.trim();
@@ -85,7 +94,7 @@ function extractMetadata(fullPath: string): {
const inline = line.slice('read_when:'.length).trim();
if (inline.startsWith('[') && inline.endsWith(']')) {
try {
const parsed = JSON.parse(inline.replace(/'/g, '"')) as unknown;
const parsed = JSON.parse(inline.replace(/'/g, '"'));
if (Array.isArray(parsed)) {
readWhen.push(...compactStrings(parsed));
}
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env bun
#!/usr/bin/env -S node --import tsx
import { execSync } from "node:child_process";
import { readdirSync, readFileSync } from "node:fs";
+3
View File
@@ -0,0 +1,3 @@
import "../../src/logging/subsystem.js";
console.log("tsx-name-repro: loaded logging/subsystem");
+38
View File
@@ -0,0 +1,38 @@
#!/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 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env bun
#!/usr/bin/env -S node --import tsx
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
+12 -41
View File
@@ -45,19 +45,8 @@ 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;
}
@@ -107,9 +96,7 @@ if (!action) {
const runner = resolveRunner();
if (!runner) {
process.stderr.write(
"Missing UI runner: install bun or pnpm, then retry.\n",
);
process.stderr.write("Missing UI runner: install pnpm, then retry.\n");
process.exit(1);
}
@@ -129,32 +116,16 @@ if (action !== "install" && !script) {
process.exit(2);
}
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]);
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]);
}
+52
View File
@@ -0,0 +1,52 @@
#!/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);
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { createSubsystemLogger } from "../../logging.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
export const AUTH_STORE_VERSION = 1;
export const AUTH_PROFILE_FILENAME = "auth-profiles.json";
+19 -14
View File
@@ -4,22 +4,20 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const execSyncMock = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", () => ({
execSync: execSyncMock,
}));
const execSyncMock = vi.fn();
describe("cli credentials", () => {
beforeEach(() => {
vi.resetModules();
vi.useFakeTimers();
});
afterEach(() => {
afterEach(async () => {
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 () => {
@@ -44,11 +42,14 @@ describe("cli credentials", () => {
const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js");
const ok = writeClaudeCliKeychainCredentials({
access: "new-access",
refresh: "new-refresh",
expires: Date.now() + 60_000,
});
const ok = writeClaudeCliKeychainCredentials(
{
access: "new-access",
refresh: "new-refresh",
expires: Date.now() + 60_000,
},
{ execSync: execSyncMock },
);
expect(ok).toBe(true);
expect(commands.some((cmd) => cmd.includes("delete-generic-password"))).toBe(false);
@@ -130,11 +131,13 @@ 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();
@@ -161,6 +164,7 @@ describe("cli credentials", () => {
allowKeychainPrompt: true,
ttlMs: 15 * 60 * 1000,
platform: "darwin",
execSync: execSyncMock,
});
vi.advanceTimersByTime(15 * 60 * 1000 + 1);
@@ -169,6 +173,7 @@ describe("cli credentials", () => {
allowKeychainPrompt: true,
ttlMs: 15 * 60 * 1000,
platform: "darwin",
execSync: execSyncMock,
});
expect(first).toBeTruthy();
@@ -196,7 +201,7 @@ describe("cli credentials", () => {
});
const { readCodexCliCredentials } = await import("./cli-credentials.js");
const creds = readCodexCliCredentials({ platform: "darwin" });
const creds = readCodexCliCredentials({ platform: "darwin", execSync: execSyncMock });
expect(creds).toMatchObject({
access: "keychain-access",
@@ -226,7 +231,7 @@ describe("cli credentials", () => {
);
const { readCodexCliCredentials } = await import("./cli-credentials.js");
const creds = readCodexCliCredentials();
const creds = readCodexCliCredentials({ execSync: execSyncMock });
expect(creds).toMatchObject({
access: "file-access",
+41 -13
View File
@@ -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.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveUserPath } from "../utils.js";
const log = createSubsystemLogger("agents/auth-profiles");
@@ -28,6 +28,12 @@ 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";
@@ -69,6 +75,8 @@ 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);
@@ -100,19 +108,24 @@ 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 = execSync(`security find-generic-password -s "Codex Auth" -a "${account}" -w`, {
encoding: "utf8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim();
const secret = execSyncImpl(
`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;
@@ -170,9 +183,11 @@ function readQwenCliCredentials(options?: { homeDir?: string }): QwenCliCredenti
};
}
function readClaudeCliKeychainCredentials(): ClaudeCliCredential | null {
function readClaudeCliKeychainCredentials(
execSyncImpl: ExecSyncFn = execSync,
): ClaudeCliCredential | null {
try {
const result = execSync(
const result = execSyncImpl(
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w`,
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
);
@@ -213,10 +228,11 @@ 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();
const keychainCreds = readClaudeCliKeychainCredentials(options?.execSync);
if (keychainCreds) {
log.info("read anthropic credentials from claude cli keychain", {
type: keychainCreds.type,
@@ -263,6 +279,7 @@ export function readClaudeCliCredentialsCached(options?: {
ttlMs?: number;
platform?: NodeJS.Platform;
homeDir?: string;
execSync?: ExecSyncFn;
}): ClaudeCliCredential | null {
const ttlMs = options?.ttlMs ?? 0;
const now = Date.now();
@@ -279,6 +296,7 @@ export function readClaudeCliCredentialsCached(options?: {
allowKeychainPrompt: options?.allowKeychainPrompt,
platform: options?.platform,
homeDir: options?.homeDir,
execSync: options?.execSync,
});
if (ttlMs > 0) {
claudeCliCache = { value, readAt: now, cacheKey };
@@ -286,9 +304,13 @@ export function readClaudeCliCredentialsCached(options?: {
return value;
}
export function writeClaudeCliKeychainCredentials(newCredentials: OAuthCredentials): boolean {
export function writeClaudeCliKeychainCredentials(
newCredentials: OAuthCredentials,
options?: { execSync?: ExecSyncFn },
): boolean {
const execSyncImpl = options?.execSync ?? execSync;
try {
const existingResult = execSync(
const existingResult = execSyncImpl(
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w 2>/dev/null`,
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
);
@@ -308,7 +330,7 @@ export function writeClaudeCliKeychainCredentials(newCredentials: OAuthCredentia
const newValue = JSON.stringify(existingData);
execSync(
execSyncImpl(
`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"] },
);
@@ -385,9 +407,11 @@ 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;
@@ -425,6 +449,7 @@ 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();
@@ -437,7 +462,10 @@ export function readCodexCliCredentialsCached(options?: {
) {
return codexCliCache.value;
}
const value = readCodexCliCredentials({ platform: options?.platform });
const value = readCodexCliCredentials({
platform: options?.platform,
execSync: options?.execSync,
});
if (ttlMs > 0) {
codexCliCache = { value, readAt: now, cacheKey };
}
@@ -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);
});
@@ -89,7 +89,6 @@ let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAg
beforeEach(async () => {
vi.useRealTimers();
vi.resetModules();
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
});
@@ -91,7 +91,6 @@ 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).not.toContain("~");
expect(refs[0]?.resolved?.startsWith("~")).toBe(false);
});
it("detects multiple image references in a prompt", () => {
@@ -39,6 +39,7 @@ vi.mock("../config/sessions.js", () => ({
resolveAgentIdFromSessionKey: () => "main",
resolveStorePath: () => "/tmp/sessions.json",
resolveMainSessionKey: () => "agent:main:main",
readSessionUpdatedAt: vi.fn(() => undefined),
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
}));
+2 -2
View File
@@ -94,7 +94,7 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("- Read: Read file contents");
expect(prompt).toContain("- Exec: Run shell commands");
expect(prompt).toContain(
"Use `Read` to load the SKILL.md at the location listed for that skill.",
"- If exactly one skill clearly applies: read its SKILL.md at <location> with `Read`, then follow it.",
);
expect(prompt).toContain("Clawdbot docs: /tmp/clawd/docs");
expect(prompt).toContain(
@@ -188,7 +188,7 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("## Skills");
expect(prompt).toContain(
"Use `read` to load the SKILL.md at the location listed for that skill.",
"- If exactly one skill clearly applies: read its SKILL.md at <location> with `read`, then follow it.",
);
});
+7 -7
View File
@@ -20,8 +20,12 @@ function buildSkillsSection(params: {
const trimmed = params.skillsPrompt?.trim();
if (!trimmed || params.isMinimal) return [];
return [
"## Skills",
`Skills provide task-specific instructions. Use \`${params.readToolName}\` to load the SKILL.md at the location listed for that skill.`,
"## 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.",
trimmed,
"",
];
@@ -109,11 +113,7 @@ 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 [
+10 -2
View File
@@ -58,7 +58,12 @@ 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,
@@ -67,7 +72,10 @@ 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,
+6 -2
View File
@@ -1,4 +1,8 @@
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import {
getChannelPlugin,
normalizeChannelId as normalizeAnyChannelId,
} from "../../channels/plugins/index.js";
import { normalizeChannelId as normalizeChatChannelId } from "../../channels/registry.js";
import type { ClawdbotConfig } from "../../config/config.js";
const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP";
@@ -21,7 +25,7 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget
const id = rest.join(":").trim();
if (!id) return null;
if (!channelRaw) return null;
const normalizedChannel = normalizeChannelId(channelRaw);
const normalizedChannel = normalizeAnyChannelId(channelRaw) ?? normalizeChatChannelId(channelRaw);
const channel = normalizedChannel ?? channelRaw.toLowerCase();
const kindTarget = (() => {
if (!normalizedChannel) return id;
+94 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { formatAgentEnvelope, formatInboundEnvelope } from "./envelope.js";
import {
formatAgentEnvelope,
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
} from "./envelope.js";
describe("formatAgentEnvelope", () => {
it("includes channel, from, ip, host, and timestamp", () => {
@@ -38,6 +42,46 @@ 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");
@@ -77,4 +121,53 @@ 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",
});
});
});
+158 -9
View File
@@ -1,5 +1,7 @@
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;
@@ -8,31 +10,172 @@ export type AgentEnvelopeParams = {
host?: string;
ip?: string;
body: string;
previousTimestamp?: number | Date;
envelope?: EnvelopeFormatOptions;
};
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;
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;
};
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];
if (params.from?.trim()) parts.push(params.from.trim());
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.host?.trim()) parts.push(params.host.trim());
if (params.ip?.trim()) parts.push(params.ip.trim());
const ts = formatTimestamp(params.timestamp);
const ts = formatTimestamp(params.timestamp, resolved);
if (ts) parts.push(ts);
const header = `[${parts.join(" ")}]`;
return `${header} ${params.body}`;
@@ -46,6 +189,8 @@ 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";
@@ -55,6 +200,8 @@ export function formatInboundEnvelope(params: {
channel: params.channel,
from: params.from,
timestamp: params.timestamp,
previousTimestamp: params.previousTimestamp,
envelope: params.envelope,
body,
});
}
@@ -85,11 +232,13 @@ 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,
});
}
+114 -40
View File
@@ -18,7 +18,10 @@ 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,
@@ -29,11 +32,20 @@ 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 =
@@ -96,9 +108,12 @@ 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")) {
@@ -122,7 +137,9 @@ 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);
@@ -157,7 +174,10 @@ 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,
@@ -207,7 +227,8 @@ 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({
@@ -223,7 +244,10 @@ 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,
@@ -240,7 +264,9 @@ 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,
@@ -250,7 +276,11 @@ 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,
@@ -261,7 +291,8 @@ 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?.({
@@ -274,14 +305,16 @@ 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;
@@ -305,14 +338,22 @@ 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 &&
@@ -326,25 +367,35 @@ 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);
}
}
@@ -390,17 +441,27 @@ export async function runAgentTurnWithFallback(params: {
// Some embedded runs surface context overflow as an error payload instead of throwing.
// Treat those as a session-level failure and auto-recover by starting a fresh session.
const embeddedError = runResult.meta?.error;
if (
embeddedError &&
isContextOverflowError(embeddedError.message) &&
!didResetAfterCompactionFailure &&
(await params.resetSessionAfterCompactionFailure(embeddedError.message))
) {
didResetAfterCompactionFailure = true;
continue;
if (embeddedError && isContextOverflowError(embeddedError.message)) {
if (
!didResetAfterCompactionFailure &&
(await params.resetSessionAfterCompactionFailure(embeddedError.message))
) {
didResetAfterCompactionFailure = true;
continue;
}
if (didResetAfterCompactionFailure) {
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 20000 or higher in your config.",
},
};
}
}
if (embeddedError?.kind === "role_ordering") {
const didReset = await params.resetSessionAfterRoleOrderingConflict(embeddedError.message);
const didReset = await params.resetSessionAfterRoleOrderingConflict(
embeddedError.message,
);
if (didReset) {
return {
kind: "final",
@@ -418,8 +479,10 @@ 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 &&
@@ -429,8 +492,17 @@ export async function runAgentTurnWithFallback(params: {
didResetAfterCompactionFailure = true;
continue;
}
if (isCompactionFailure && didResetAfterCompactionFailure) {
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 20000 or higher in your config.",
},
};
}
if (isRoleOrderingError) {
const didReset = await params.resetSessionAfterRoleOrderingConflict(message);
const didReset =
await params.resetSessionAfterRoleOrderingConflict(message);
if (didReset) {
return {
kind: "final",
@@ -457,7 +529,8 @@ 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 {
@@ -507,6 +580,7 @@ export async function runAgentTurnWithFallback(params: {
fallbackModel,
didLogHeartbeatStrip,
autoCompactionCompleted,
directlySentBlockKeys: directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined,
directlySentBlockKeys:
directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined,
};
}
@@ -172,6 +172,53 @@ describe("runReplyAgent typing (heartbeat)", () => {
}
}
});
it("returns a user-facing error after repeated compaction failure", async () => {
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-compaction-repeat-"));
process.env.CLAWDBOT_STATE_DIR = stateDir;
try {
const sessionId = "session";
const storePath = path.join(stateDir, "sessions", "sessions.json");
const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId);
const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath };
const sessionStore = { main: sessionEntry };
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
await fs.writeFile(transcriptPath, "ok", "utf-8");
runEmbeddedPiAgentMock.mockImplementation(async () => {
throw new Error(
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
);
});
const callsBefore = runEmbeddedPiAgentMock.mock.calls.length;
const { run } = createMinimalRun({
sessionEntry,
sessionStore,
sessionKey: "main",
storePath,
});
const res = await run();
expect(runEmbeddedPiAgentMock.mock.calls.length - callsBefore).toBe(2);
const payload = Array.isArray(res) ? res[0] : res;
expect(payload.text).toContain("Context limit exceeded");
expect(payload.text?.toLowerCase()).toContain("reset");
expect(sessionStore.main.sessionId).not.toBe(sessionId);
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId);
} finally {
if (prevStateDir) {
process.env.CLAWDBOT_STATE_DIR = prevStateDir;
} else {
delete process.env.CLAWDBOT_STATE_DIR;
}
}
});
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-"));
+16 -26
View File
@@ -1,19 +1,17 @@
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
const callGatewayFromCli = vi.fn(
async (method: string, _opts: unknown, params?: unknown) => {
if (method.endsWith(".get")) {
return {
path: "/tmp/exec-approvals.json",
exists: true,
hash: "hash-1",
file: { version: 1, agents: {} },
};
}
return { method, params };
},
);
const callGatewayFromCli = vi.fn(async (method: string, _opts: unknown, params?: unknown) => {
if (method.endsWith(".get")) {
return {
path: "/tmp/exec-approvals.json",
exists: true,
hash: "hash-1",
file: { version: 1, agents: {} },
};
}
return { method, params };
});
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
@@ -31,9 +29,7 @@ vi.mock("./gateway-rpc.js", () => ({
}));
vi.mock("./nodes-cli/rpc.js", async () => {
const actual = await vi.importActual<typeof import("./nodes-cli/rpc.js")>(
"./nodes-cli/rpc.js",
);
const actual = await vi.importActual<typeof import("./nodes-cli/rpc.js")>("./nodes-cli/rpc.js");
return {
...actual,
resolveNodeId: vi.fn(async () => "node-1"),
@@ -57,11 +53,7 @@ describe("exec approvals CLI", () => {
await program.parseAsync(["approvals", "get"], { from: "user" });
expect(callGatewayFromCli).toHaveBeenCalledWith(
"exec.approvals.get",
expect.anything(),
{},
);
expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.get", expect.anything(), {});
expect(runtimeErrors).toHaveLength(0);
});
@@ -77,11 +69,9 @@ describe("exec approvals CLI", () => {
await program.parseAsync(["approvals", "get", "--node", "macbook"], { from: "user" });
expect(callGatewayFromCli).toHaveBeenCalledWith(
"exec.approvals.node.get",
expect.anything(),
{ nodeId: "node-1" },
);
expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.node.get", expect.anything(), {
nodeId: "node-1",
});
expect(runtimeErrors).toHaveLength(0);
});
});
+3 -5
View File
@@ -151,15 +151,13 @@ export function registerExecApprovalsCli(program: Command) {
});
nodesCallOpts(setCmd);
const allowlist = approvals
.command("allowlist")
.description("Edit the per-agent allowlist");
const allowlist = approvals.command("allowlist").description("Edit the per-agent allowlist");
const allowlistAdd = allowlist
.command("add <pattern>")
.description("Add a glob pattern to an allowlist")
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
.option("--agent <id>", "Agent id (defaults to \"default\")")
.option("--agent <id>", 'Agent id (defaults to "default")')
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
const trimmed = pattern.trim();
if (!trimmed) {
@@ -196,7 +194,7 @@ export function registerExecApprovalsCli(program: Command) {
.command("remove <pattern>")
.description("Remove a glob pattern from an allowlist")
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
.option("--agent <id>", "Agent id (defaults to \"default\")")
.option("--agent <id>", 'Agent id (defaults to "default")')
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
const trimmed = pattern.trim();
if (!trimmed) {
+6 -4
View File
@@ -87,12 +87,14 @@ describe("gateway SIGTERM", () => {
const out: string[] = [];
const err: string[] = [];
const bunBin = process.env.BUN_INSTALL
? path.join(process.env.BUN_INSTALL, "bin", "bun")
: "bun";
child = spawn(
process.execPath,
bunBin,
[
"--import",
"tsx",
"src/index.ts",
"src/entry.ts",
"gateway",
"--port",
String(port),
+2 -6
View File
@@ -156,9 +156,7 @@ export function registerMemoryCli(program: Command) {
for (const result of allResults) {
const { agentId, status, embeddingProbe, indexError } = result;
if (opts.index) {
const line = indexError
? `Memory index failed: ${indexError}`
: "Memory index complete.";
const line = indexError ? `Memory index failed: ${indexError}` : "Memory index complete.";
defaultRuntime.log(line);
}
const lines = [
@@ -167,9 +165,7 @@ export function registerMemoryCli(program: Command) {
`(requested: ${status.requestedProvider})`,
)}`,
`${label("Model")} ${info(status.model)}`,
status.sources?.length
? `${label("Sources")} ${info(status.sources.join(", "))}`
: null,
status.sources?.length ? `${label("Sources")} ${info(status.sources.join(", "))}` : null,
`${label("Indexed")} ${success(`${status.files} files · ${status.chunks} chunks`)}`,
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
`${label("Store")} ${info(status.dbPath)}`,
+8 -6
View File
@@ -116,12 +116,14 @@ export function registerNodesStatusCommands(nodes: Command) {
const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null;
const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null;
const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null;
const versions = formatNodeVersions(obj as {
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
});
const versions = formatNodeVersions(
obj as {
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
},
);
const parts: string[] = ["Node:", displayName, nodeId];
if (ip) parts.push(ip);
+13
View File
@@ -39,6 +39,19 @@ vi.mock("../config/config.js", () => ({
}));
describe("pairing cli", () => {
it("evaluates pairing channels when registering the CLI (not at import)", async () => {
listPairingChannels.mockClear();
const { registerPairingCli } = await import("./pairing-cli.js");
expect(listPairingChannels).not.toHaveBeenCalled();
const program = new Command();
program.name("test");
registerPairingCli(program);
expect(listPairingChannels).toHaveBeenCalledTimes(1);
});
it("labels Telegram ids as telegramUserId", async () => {
const { registerPairingCli } = await import("./pairing-cli.js");
listChannelPairingRequests.mockResolvedValueOnce([
+9 -10
View File
@@ -11,10 +11,8 @@ import {
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
const CHANNELS: PairingChannel[] = listPairingChannels();
/** Parse channel, allowing extension channels not in core registry. */
function parseChannel(raw: unknown): PairingChannel {
function parseChannel(raw: unknown, channels: PairingChannel[]): PairingChannel {
const value = (
typeof raw === "string"
? raw
@@ -28,7 +26,7 @@ function parseChannel(raw: unknown): PairingChannel {
const normalized = normalizeChannelId(value);
if (normalized) {
if (!CHANNELS.includes(normalized as PairingChannel)) {
if (!channels.includes(normalized as PairingChannel)) {
throw new Error(`Channel ${normalized} does not support pairing`);
}
return normalized as PairingChannel;
@@ -45,6 +43,7 @@ async function notifyApproved(channel: PairingChannel, id: string) {
}
export function registerPairingCli(program: Command) {
const channels = listPairingChannels();
const pairing = program
.command("pairing")
.description("Secure DM pairing (approve inbound requests)")
@@ -57,17 +56,17 @@ export function registerPairingCli(program: Command) {
pairing
.command("list")
.description("List pending pairing requests")
.option("--channel <channel>", `Channel (${CHANNELS.join(", ")})`)
.argument("[channel]", `Channel (${CHANNELS.join(", ")})`)
.option("--channel <channel>", `Channel (${channels.join(", ")})`)
.argument("[channel]", `Channel (${channels.join(", ")})`)
.option("--json", "Print JSON", false)
.action(async (channelArg, opts) => {
const channelRaw = opts.channel ?? channelArg;
if (!channelRaw) {
throw new Error(
`Channel required. Use --channel <channel> or pass it as the first argument (expected one of: ${CHANNELS.join(", ")})`,
`Channel required. Use --channel <channel> or pass it as the first argument (expected one of: ${channels.join(", ")})`,
);
}
const channel = parseChannel(channelRaw);
const channel = parseChannel(channelRaw, channels);
const requests = await listChannelPairingRequests(channel);
if (opts.json) {
console.log(JSON.stringify({ channel, requests }, null, 2));
@@ -87,7 +86,7 @@ export function registerPairingCli(program: Command) {
pairing
.command("approve")
.description("Approve a pairing code and allow that sender")
.option("--channel <channel>", `Channel (${CHANNELS.join(", ")})`)
.option("--channel <channel>", `Channel (${channels.join(", ")})`)
.argument("<codeOrChannel>", "Pairing code (or channel when using 2 args)")
.argument("[code]", "Pairing code (when channel is passed as the 1st arg)")
.option("--notify", "Notify the requester on the same channel", false)
@@ -104,7 +103,7 @@ export function registerPairingCli(program: Command) {
`Too many arguments. Use: clawdbot pairing approve --channel <channel> <code>`,
);
}
const channel = parseChannel(channelRaw);
const channel = parseChannel(channelRaw, channels);
const approved = await approveChannelPairingCode({
channel,
code: String(resolvedCode),
@@ -176,10 +176,7 @@ export async function applyAuthChoicePluginProvider(
if (result.defaultModel) {
if (params.setDefaultModel) {
nextConfig = applyDefaultModel(nextConfig, result.defaultModel);
await params.prompter.note(
`Default model set to ${result.defaultModel}`,
"Model configured",
);
await params.prompter.note(`Default model set to ${result.defaultModel}`, "Model configured");
} else if (params.agentId) {
agentModelOverride = result.defaultModel;
await params.prompter.note(
@@ -417,6 +417,22 @@ describe("channels command", () => {
expect(lines.join("\n")).toMatch(/Telegram Bot API privacy mode/i);
});
it("includes Telegram bot username from probe data", () => {
const lines = formatGatewayChannelsStatusLines({
channelAccounts: {
telegram: [
{
accountId: "default",
enabled: true,
configured: true,
probe: { ok: true, bot: { username: "clawdbot_bot" } },
},
],
},
});
expect(lines.join("\n")).toMatch(/bot:@clawdbot_bot/);
});
it("surfaces Telegram group membership audit issues in channels status output", () => {
const lines = formatGatewayChannelsStatusLines({
channelAccounts: {
+12
View File
@@ -51,6 +51,18 @@ export function formatGatewayChannelsStatusLines(payload: Record<string, unknown
if (typeof account.mode === "string" && account.mode.length > 0) {
bits.push(`mode:${account.mode}`);
}
const botUsername = (() => {
const bot = account.bot as { username?: string | null } | undefined;
const probeBot = (account.probe as { bot?: { username?: string | null } } | undefined)?.bot;
const raw = bot?.username ?? probeBot?.username ?? "";
if (typeof raw !== "string") return "";
const trimmed = raw.trim();
if (!trimmed) return "";
return trimmed.startsWith("@") ? trimmed : `@${trimmed}`;
})();
if (botUsername) {
bits.push(`bot:${botUsername}`);
}
if (typeof account.dmPolicy === "string" && account.dmPolicy.length > 0) {
bits.push(`dm:${account.dmPolicy}`);
}
+5 -5
View File
@@ -1,6 +1,9 @@
import type { ClawdbotConfig } from "../config/config.js";
import { resolveGatewayPort } from "../config/config.js";
import { resolveGatewayLaunchAgentLabel, resolveNodeLaunchAgentLabel } from "../daemon/constants.js";
import {
resolveGatewayLaunchAgentLabel,
resolveNodeLaunchAgentLabel,
} from "../daemon/constants.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import {
isLaunchAgentListed,
@@ -44,10 +47,7 @@ async function maybeRepairLaunchAgentBootstrap(params: {
const plistExists = await launchAgentPlistExists(params.env);
if (!plistExists) return false;
note(
"LaunchAgent is listed but not loaded in launchd.",
`${params.title} LaunchAgent`,
);
note("LaunchAgent is listed but not loaded in launchd.", `${params.title} LaunchAgent`);
const shouldFix = await params.prompter.confirmSkipInNonInteractive({
message: `Repair ${params.title} LaunchAgent bootstrap now?`,
+1
View File
@@ -24,6 +24,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
vi.mock("../config/sessions.js", () => ({
resolveStorePath: () => "/tmp/sessions.json",
loadSessionStore: () => testStore,
readSessionUpdatedAt: vi.fn(() => undefined),
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
updateLastRoute: vi.fn().mockResolvedValue(undefined),
}));
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { makePrompter } from "./onboarding/__tests__/test-utils.js";
import { promptDefaultModel } from "./model-picker.js";
const loadModelCatalog = vi.hoisted(() => vi.fn());
vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog,
}));
const ensureAuthProfileStore = vi.hoisted(() =>
vi.fn(() => ({
version: 1,
profiles: {},
})),
);
const listProfilesForProvider = vi.hoisted(() => vi.fn(() => []));
vi.mock("../agents/auth-profiles.js", () => ({
ensureAuthProfileStore,
listProfilesForProvider,
}));
const resolveEnvApiKey = vi.hoisted(() => vi.fn(() => undefined));
const getCustomProviderApiKey = vi.hoisted(() => vi.fn(() => undefined));
vi.mock("../agents/model-auth.js", () => ({
resolveEnvApiKey,
getCustomProviderApiKey,
}));
describe("promptDefaultModel", () => {
it("filters internal router models from the selection list", async () => {
loadModelCatalog.mockResolvedValue([
{
provider: "openrouter",
id: "auto",
name: "OpenRouter Auto",
},
{
provider: "openrouter",
id: "meta-llama/llama-3.3-70b:free",
name: "Llama 3.3 70B",
},
]);
const select = vi.fn(async (params) => {
const first = params.options[0];
return first?.value ?? "";
});
const prompter = makePrompter({ select });
const config = { agents: { defaults: {} } } as ClawdbotConfig;
await promptDefaultModel({
config,
prompter,
allowKeep: false,
includeManual: false,
ignoreAllowlist: true,
});
const options = select.mock.calls[0]?.[0]?.options ?? [];
expect(options.some((opt) => opt.value === "openrouter/auto")).toBe(false);
expect(options.some((opt) => opt.value === "openrouter/meta-llama/llama-3.3-70b:free")).toBe(
true,
);
});
});
+7
View File
@@ -17,6 +17,11 @@ const KEEP_VALUE = "__keep__";
const MANUAL_VALUE = "__manual__";
const PROVIDER_FILTER_THRESHOLD = 30;
// Models that are internal routing features and should not be shown in selection lists.
// These may be valid as defaults (e.g., set automatically during auth flow) but are not
// directly callable via API and would cause "Unknown model" errors if selected manually.
const HIDDEN_ROUTER_MODELS = new Set(["openrouter/auto"]);
type PromptDefaultModelParams = {
config: ClawdbotConfig;
prompter: WizardPrompter;
@@ -183,6 +188,8 @@ export async function promptDefaultModel(
}) => {
const key = modelKey(entry.provider, entry.id);
if (seen.has(key)) return;
// Skip internal router models that can't be directly called via API.
if (HIDDEN_ROUTER_MODELS.has(key)) return;
const hints: string[] = [];
if (entry.name && entry.name !== entry.id) hints.push(entry.name);
if (entry.contextWindow) hints.push(`ctx ${formatTokenK(entry.contextWindow)}`);
@@ -3,7 +3,7 @@ import { createServer } from "node:net";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {
@@ -50,7 +50,6 @@ describe("onboard (non-interactive): remote gateway config", () => {
process.env.HOME = tempHome;
delete process.env.CLAWDBOT_STATE_DIR;
delete process.env.CLAWDBOT_CONFIG_PATH;
vi.resetModules();
const port = await getFreePort();
const token = "tok_remote_123";
@@ -85,8 +84,8 @@ describe("onboard (non-interactive): remote gateway config", () => {
runtime,
);
const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js");
const cfg = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8")) as {
const { resolveConfigPath } = await import("../config/config.js");
const cfg = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8")) as {
gateway?: { mode?: string; remote?: { url?: string; token?: string } };
};
+1
View File
@@ -100,6 +100,7 @@ vi.mock("../config/sessions.js", () => ({
loadSessionStore: mocks.loadSessionStore,
resolveMainSessionKey: mocks.resolveMainSessionKey,
resolveStorePath: mocks.resolveStorePath,
readSessionUpdatedAt: vi.fn(() => undefined),
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../channels/plugins/index.js", () => ({
+4
View File
@@ -8,6 +8,7 @@ describe("applyPluginAutoEnable", () => {
channels: { slack: { botToken: "x" } },
plugins: { allow: ["telegram"] },
},
env: {},
});
expect(result.config.plugins?.entries?.slack?.enabled).toBe(true);
@@ -21,6 +22,7 @@ describe("applyPluginAutoEnable", () => {
channels: { slack: { botToken: "x" } },
plugins: { entries: { slack: { enabled: false } } },
},
env: {},
});
expect(result.config.plugins?.entries?.slack?.enabled).toBe(false);
@@ -39,6 +41,7 @@ describe("applyPluginAutoEnable", () => {
},
},
},
env: {},
});
expect(result.config.plugins?.entries?.["google-antigravity-auth"]?.enabled).toBe(true);
@@ -50,6 +53,7 @@ describe("applyPluginAutoEnable", () => {
channels: { slack: { botToken: "x" } },
plugins: { enabled: false },
},
env: {},
});
expect(result.config.plugins?.entries?.slack?.enabled).toBeUndefined();
+9 -6
View File
@@ -45,10 +45,7 @@ function recordHasKeys(value: unknown): boolean {
return isRecord(value) && Object.keys(value).length > 0;
}
function accountsHaveKeys(
value: unknown,
keys: string[],
): boolean {
function accountsHaveKeys(value: unknown, keys: string[]): boolean {
if (!isRecord(value)) return false;
for (const account of Object.values(value)) {
if (!isRecord(account)) continue;
@@ -59,7 +56,10 @@ function accountsHaveKeys(
return false;
}
function resolveChannelConfig(cfg: ClawdbotConfig, channelId: string): Record<string, unknown> | null {
function resolveChannelConfig(
cfg: ClawdbotConfig,
channelId: string,
): Record<string, unknown> | null {
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[channelId];
return isRecord(entry) ? entry : null;
@@ -234,7 +234,10 @@ function isProviderConfigured(cfg: ClawdbotConfig, providerId: string): boolean
return false;
}
function resolveConfiguredPlugins(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): PluginEnableChange[] {
function resolveConfiguredPlugins(
cfg: ClawdbotConfig,
env: NodeJS.ProcessEnv,
): PluginEnableChange[] {
const changes: PluginEnableChange[] = [];
for (const channelId of CHANNEL_PLUGIN_IDS) {
if (isChannelConfigured(cfg, channelId, env)) {
+8
View File
@@ -172,6 +172,9 @@ const FIELD_LABELS: Record<string, string> = {
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
"agents.defaults.workspace": "Workspace",
"agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars",
"agents.defaults.envelopeTimezone": "Envelope Timezone",
"agents.defaults.envelopeTimestamp": "Envelope Timestamp",
"agents.defaults.envelopeElapsed": "Envelope Elapsed",
"agents.defaults.memorySearch": "Memory Search",
"agents.defaults.memorySearch.enabled": "Enable Memory Search",
"agents.defaults.memorySearch.sources": "Memory Search Sources",
@@ -371,6 +374,11 @@ const FIELD_HELP: Record<string, string> = {
"auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).",
"agents.defaults.bootstrapMaxChars":
"Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).",
"agents.defaults.envelopeTimezone":
'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).',
"agents.defaults.envelopeTimestamp":
'Include absolute timestamps in message envelopes ("on" or "off").',
"agents.defaults.envelopeElapsed": 'Include elapsed time in message envelopes ("on" or "off").',
"agents.defaults.models": "Configured model catalog (keys are full provider/model IDs).",
"agents.defaults.memorySearch":
"Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).",
+12
View File
@@ -155,6 +155,18 @@ export function loadSessionStore(
return structuredClone(store);
}
export function readSessionUpdatedAt(params: {
storePath: string;
sessionKey: string;
}): number | undefined {
try {
const store = loadSessionStore(params.storePath);
return store[params.sessionKey]?.updatedAt;
} catch {
return undefined;
}
}
async function saveSessionStoreUnlocked(
storePath: string,
store: Record<string, SessionEntry>,
+12
View File
@@ -105,6 +105,18 @@ export type AgentDefaultsConfig = {
userTimezone?: string;
/** Time format in system prompt: auto (OS preference), 12-hour, or 24-hour. */
timeFormat?: "auto" | "12" | "24";
/**
* Envelope timestamp timezone: "utc" (default), "local", "user", or an IANA timezone string.
*/
envelopeTimezone?: string;
/**
* Include absolute timestamps in message envelopes ("on" | "off", default: "on").
*/
envelopeTimestamp?: "on" | "off";
/**
* Include elapsed time in message envelopes ("on" | "off", default: "on").
*/
envelopeElapsed?: "on" | "off";
/** Optional display-only context window override (used for % in status UIs). */
contextTokens?: number;
/** Optional CLI backends for text-only fallback (claude-cli, etc.). */
+4 -1
View File
@@ -20,7 +20,10 @@ export function parseClawdbotVersion(raw: string | null | undefined): ClawdbotVe
};
}
export function compareClawdbotVersions(a: string | null | undefined, b: string | null | undefined): number | null {
export function compareClawdbotVersions(
a: string | null | undefined,
b: string | null | undefined,
): number | null {
const parsedA = parseClawdbotVersion(a);
const parsedB = parseClawdbotVersion(b);
if (!parsedA || !parsedB) return null;
+3
View File
@@ -42,6 +42,9 @@ export const AgentDefaultsSchema = z
bootstrapMaxChars: z.number().int().positive().optional(),
userTimezone: z.string().optional(),
timeFormat: z.union([z.literal("auto"), z.literal("12"), z.literal("24")]).optional(),
envelopeTimezone: z.string().optional(),
envelopeTimestamp: z.union([z.literal("on"), z.literal("off")]).optional(),
envelopeElapsed: z.union([z.literal("on"), z.literal("off")]).optional(),
contextTokens: z.number().int().positive().optional(),
cliBackends: z.record(z.string(), CliBackendSchema).optional(),
memorySearch: MemorySearchSchema,
+5 -8
View File
@@ -40,7 +40,7 @@ async function withLaunchctlStub(
' fs.appendFileSync(logPath, JSON.stringify(args) + "\\n", "utf8");',
"}",
'if (args[0] === "list") {',
" const output = process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT || \"\";",
' const output = process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT || "";',
" process.stdout.write(output);",
"}",
"process.exit(0);",
@@ -107,13 +107,10 @@ describe("launchd runtime parsing", () => {
describe("launchctl list detection", () => {
it("detects the resolved label in launchctl list", async () => {
await withLaunchctlStub(
{ listOutput: "123 0 com.clawdbot.gateway\n" },
async ({ env }) => {
const listed = await isLaunchAgentListed({ env });
expect(listed).toBe(true);
},
);
await withLaunchctlStub({ listOutput: "123 0 com.clawdbot.gateway\n" }, async ({ env }) => {
const listed = await isLaunchAgentListed({ env });
expect(listed).toBe(true);
});
});
it("returns false when the label is missing", async () => {
+1 -3
View File
@@ -176,9 +176,7 @@ export async function isLaunchAgentListed(args: {
const label = resolveLaunchAgentLabel({ env: args.env });
const res = await execLaunchctl(["list"]);
if (res.code !== 0) return false;
return res.stdout
.split(/\r?\n/)
.some((line) => line.trim().split(/\s+/).at(-1) === label);
return res.stdout.split(/\r?\n/).some((line) => line.trim().split(/\s+/).at(-1) === label);
}
export async function launchAgentPlistExists(
@@ -3,6 +3,8 @@ import { ChannelType, MessageType } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js";
const sendMock = vi.fn();
const reactMock = vi.fn();
const updateLastRouteMock = vi.fn();
@@ -42,7 +44,7 @@ beforeEach(() => {
});
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
vi.resetModules();
__resetDiscordChannelInfoCacheForTest();
});
describe("discord tool result dispatch", () => {
+21 -5
View File
@@ -8,7 +8,11 @@ import {
extractShortModelName,
type ResponsePrefixContext,
} from "../../auto-reply/reply/response-prefix-template.js";
import { formatInboundEnvelope, formatThreadStarterEnvelope } from "../../auto-reply/envelope.js";
import {
formatInboundEnvelope,
formatThreadStarterEnvelope,
resolveEnvelopeFormatOptions,
} from "../../auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
import {
buildPendingHistoryContextFromMap,
@@ -18,6 +22,7 @@ import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.j
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import {
readSessionUpdatedAt,
recordSessionMetaFromInbound,
resolveStorePath,
updateLastRoute,
@@ -137,6 +142,14 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
].filter((entry): entry is string => Boolean(entry));
const groupSystemPrompt =
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
let combinedBody = formatInboundEnvelope({
channel: "Discord",
from: fromLabel,
@@ -144,6 +157,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
body: text,
chatType: isDirectMessage ? "direct" : "channel",
senderLabel,
previousTimestamp,
envelope: envelopeOptions,
});
const shouldIncludeChannelHistory =
!isDirectMessage && !(isGuildMessage && channelConfig?.autoThread && !threadChannel);
@@ -161,10 +176,13 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
body: `${entry.body} [id:${entry.messageId ?? "unknown"} channel:${message.channelId}]`,
chatType: "channel",
senderLabel: entry.sender,
envelope: envelopeOptions,
}),
});
}
const replyContext = resolveReplyContext(message, resolveDiscordMessageText);
const replyContext = resolveReplyContext(message, resolveDiscordMessageText, {
envelope: envelopeOptions,
});
if (replyContext) {
combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`;
}
@@ -186,6 +204,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
author: starter.author,
timestamp: starter.timestamp,
body: starter.text,
envelope: envelopeOptions,
});
threadStarterBody = starterEnvelope;
}
@@ -268,9 +287,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget,
});
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
void recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
+4
View File
@@ -44,6 +44,10 @@ const DISCORD_CHANNEL_INFO_CACHE = new Map<
{ value: DiscordChannelInfo | null; expiresAt: number }
>();
export function __resetDiscordChannelInfoCacheForTest() {
DISCORD_CHANNEL_INFO_CACHE.clear();
}
export async function resolveDiscordChannelInfo(
client: Client,
channelId: string,
+3 -1
View File
@@ -1,11 +1,12 @@
import type { Guild, Message, User } from "@buape/carbon";
import { formatAgentEnvelope } from "../../auto-reply/envelope.js";
import { formatAgentEnvelope, type EnvelopeFormatOptions } from "../../auto-reply/envelope.js";
import { formatDiscordUserTag, resolveTimestampMs } from "./format.js";
export function resolveReplyContext(
message: Message,
resolveDiscordMessageText: (message: Message, options?: { includeForwarded?: boolean }) => string,
options?: { envelope?: EnvelopeFormatOptions },
): string | null {
const referenced = message.referencedMessage;
if (!referenced?.author) return null;
@@ -20,6 +21,7 @@ export function resolveReplyContext(
from: fromLabel,
timestamp: resolveTimestampMs(referenced.timestamp),
body,
envelope: options?.envelope,
});
}
+11 -9
View File
@@ -27,9 +27,10 @@ describe("runBootOnce", () => {
it("skips when BOOT.md is missing", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-"));
await expect(
runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }),
).resolves.toEqual({ status: "skipped", reason: "missing" });
await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
status: "skipped",
reason: "missing",
});
expect(agentCommand).not.toHaveBeenCalled();
await fs.rm(workspaceDir, { recursive: true, force: true });
});
@@ -37,9 +38,10 @@ describe("runBootOnce", () => {
it("skips when BOOT.md is empty", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-"));
await fs.writeFile(path.join(workspaceDir, "BOOT.md"), " \n", "utf-8");
await expect(
runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }),
).resolves.toEqual({ status: "skipped", reason: "empty" });
await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
status: "skipped",
reason: "empty",
});
expect(agentCommand).not.toHaveBeenCalled();
await fs.rm(workspaceDir, { recursive: true, force: true });
});
@@ -50,9 +52,9 @@ describe("runBootOnce", () => {
await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8");
agentCommand.mockResolvedValue(undefined);
await expect(
runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }),
).resolves.toEqual({ status: "ran" });
await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
status: "ran",
});
expect(agentCommand).toHaveBeenCalledTimes(1);
const call = agentCommand.mock.calls[0]?.[0];
+3 -4
View File
@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { WebSocket } from "ws";
import { rawDataToString } from "../infra/ws.js";
@@ -141,7 +141,6 @@ describe("gateway wizard (e2e)", () => {
process.env.HOME = tempHome;
delete process.env.CLAWDBOT_STATE_DIR;
delete process.env.CLAWDBOT_CONFIG_PATH;
vi.resetModules();
const wizardToken = `wiz-${randomUUID()}`;
const port = await getFreeGatewayPort();
@@ -187,8 +186,8 @@ describe("gateway wizard (e2e)", () => {
expect(didSendToken).toBe(true);
expect(next.status).toBe("done");
const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js");
const parsed = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8"));
const { resolveConfigPath } = await import("../config/config.js");
const parsed = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8"));
const token = (parsed as Record<string, unknown>)?.gateway as
| Record<string, unknown>
| undefined;
+12 -1
View File
@@ -4,6 +4,7 @@ import path from "node:path";
import { resolveThinkingDefault } from "../../agents/model-selection.js";
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js";
import { agentCommand } from "../../commands/agent.js";
import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
@@ -300,10 +301,20 @@ export const chatHandlers: GatewayRequestHandlers = {
};
respond(true, ackPayload, undefined, { runId: clientRunId });
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const envelopedMessage = formatInboundEnvelope({
channel: "WebChat",
from: p.sessionKey,
timestamp: now,
body: parsedMessage,
chatType: "direct",
previousTimestamp: entry?.updatedAt,
envelope: envelopeOptions,
});
const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined;
void agentCommand(
{
message: parsedMessage,
message: envelopedMessage,
images: parsedImages.length > 0 ? parsedImages : undefined,
sessionId,
sessionKey: p.sessionKey,
@@ -2,6 +2,8 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { PluginRegistry } from "../plugins/registry.js";
import {
agentCommand,
connectOk,
@@ -14,6 +16,33 @@ import {
installGatewayTestHooks();
const registryState = vi.hoisted(() => ({
registry: {
plugins: [],
tools: [],
channels: [],
providers: [],
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
services: [],
diagnostics: [],
} as PluginRegistry,
}));
vi.mock("./server-plugins.js", async () => {
const { setActivePluginRegistry } = await import("../plugins/runtime.js");
return {
loadGatewayPlugins: (params: { baseMethods: string[] }) => {
setActivePluginRegistry(registryState.registry);
return {
pluginRegistry: registryState.registry,
gatewayMethods: params.baseMethods ?? [],
};
},
};
});
const BASE_IMAGE_PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII=";
@@ -22,8 +51,96 @@ function expectChannels(call: Record<string, unknown>, channel: string) {
expect(call.messageChannel).toBe(channel);
}
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
plugins: [],
tools: [],
channels,
providers: [],
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
services: [],
diagnostics: [],
});
const createStubChannelPlugin = (params: {
id: ChannelPlugin["id"];
label: string;
resolveAllowFrom?: (cfg: Record<string, unknown>) => string[];
}): ChannelPlugin => ({
id: params.id,
meta: {
id: params.id,
label: params.label,
selectionLabel: params.label,
docsPath: `/channels/${params.id}`,
blurb: "test stub.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
resolveAllowFrom: params.resolveAllowFrom
? ({ cfg }) => params.resolveAllowFrom?.(cfg as Record<string, unknown>) ?? []
: undefined,
},
outbound: {
deliveryMode: "direct",
resolveTarget: ({ to, allowFrom }) => {
const trimmed = to?.trim() ?? "";
if (trimmed) return { ok: true, to: trimmed };
const first = allowFrom?.[0];
if (first) return { ok: true, to: String(first) };
return {
ok: false,
error: new Error(`missing target for ${params.id}`),
};
},
sendText: async () => ({ channel: params.id, messageId: "msg-test" }),
sendMedia: async () => ({ channel: params.id, messageId: "msg-test" }),
},
});
const defaultRegistry = createRegistry([
{
pluginId: "whatsapp",
source: "test",
plugin: createStubChannelPlugin({
id: "whatsapp",
label: "WhatsApp",
resolveAllowFrom: (cfg) => {
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.whatsapp as Record<string, unknown> | undefined;
const allow = entry?.allowFrom;
return Array.isArray(allow) ? allow.map((value) => String(value)) : [];
},
}),
},
{
pluginId: "telegram",
source: "test",
plugin: createStubChannelPlugin({ id: "telegram", label: "Telegram" }),
},
{
pluginId: "discord",
source: "test",
plugin: createStubChannelPlugin({ id: "discord", label: "Discord" }),
},
{
pluginId: "slack",
source: "test",
plugin: createStubChannelPlugin({ id: "slack", label: "Slack" }),
},
{
pluginId: "signal",
source: "test",
plugin: createStubChannelPlugin({ id: "signal", label: "Signal" }),
},
]);
describe("gateway server agent", () => {
test("agent marks implicit delivery when lastTo is stale", async () => {
registryState.registry = defaultRegistry;
testState.allowFrom = ["+436769770569"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
@@ -63,6 +180,7 @@ describe("gateway server agent", () => {
});
test("agent forwards sessionKey to agentCommand", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
@@ -97,6 +215,7 @@ describe("gateway server agent", () => {
});
test("agent forwards accountId to agentCommand", async () => {
registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
@@ -136,6 +255,7 @@ describe("gateway server agent", () => {
});
test("agent avoids lastAccountId when explicit to is provided", async () => {
registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
@@ -175,6 +295,7 @@ describe("gateway server agent", () => {
});
test("agent keeps explicit accountId when explicit to is provided", async () => {
registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
@@ -215,6 +336,7 @@ describe("gateway server agent", () => {
});
test("agent falls back to lastAccountId for implicit delivery", async () => {
registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
@@ -253,6 +375,7 @@ describe("gateway server agent", () => {
});
test("agent forwards image attachments as images[]", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
@@ -299,6 +422,7 @@ describe("gateway server agent", () => {
});
test("agent falls back to whatsapp when delivery requested and no last channel exists", async () => {
registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
@@ -335,6 +459,7 @@ describe("gateway server agent", () => {
});
test("agent routes main last-channel whatsapp", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
@@ -374,6 +499,7 @@ describe("gateway server agent", () => {
});
test("agent routes main last-channel telegram", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
@@ -412,6 +538,7 @@ describe("gateway server agent", () => {
});
test("agent routes main last-channel discord", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
@@ -450,6 +577,7 @@ describe("gateway server agent", () => {
});
test("agent routes main last-channel slack", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
@@ -488,6 +616,7 @@ describe("gateway server agent", () => {
});
test("agent routes main last-channel signal", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
+124
View File
@@ -1,4 +1,6 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { PluginRegistry } from "../plugins/registry.js";
import {
connectOk,
installGatewayTestHooks,
@@ -10,6 +12,125 @@ const loadConfigHelpers = async () => await import("../config/config.js");
installGatewayTestHooks();
const registryState = vi.hoisted(() => ({
registry: {
plugins: [],
tools: [],
channels: [],
providers: [],
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
services: [],
diagnostics: [],
} as PluginRegistry,
}));
vi.mock("./server-plugins.js", async () => {
const { setActivePluginRegistry } = await import("../plugins/runtime.js");
return {
loadGatewayPlugins: (params: { baseMethods: string[] }) => {
setActivePluginRegistry(registryState.registry);
return {
pluginRegistry: registryState.registry,
gatewayMethods: params.baseMethods ?? [],
};
},
};
});
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
plugins: [],
tools: [],
channels,
providers: [],
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
services: [],
diagnostics: [],
});
const createStubChannelPlugin = (params: {
id: ChannelPlugin["id"];
label: string;
summary?: Record<string, unknown>;
logoutCleared?: boolean;
}): ChannelPlugin => ({
id: params.id,
meta: {
id: params.id,
label: params.label,
selectionLabel: params.label,
docsPath: `/channels/${params.id}`,
blurb: "test stub.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
isConfigured: async () => false,
},
status: {
buildChannelSummary: async () => ({
configured: false,
...params.summary,
}),
},
gateway: {
logoutAccount: async () => ({
cleared: params.logoutCleared ?? false,
envToken: false,
}),
},
});
const telegramPlugin: ChannelPlugin = {
...createStubChannelPlugin({
id: "telegram",
label: "Telegram",
summary: { tokenSource: "none", lastProbeAt: null },
logoutCleared: true,
}),
gateway: {
logoutAccount: async ({ cfg }) => {
const { writeConfigFile } = await import("../config/config.js");
const nextTelegram = cfg.channels?.telegram ? { ...cfg.channels.telegram } : {};
delete nextTelegram.botToken;
await writeConfigFile({
...cfg,
channels: {
...cfg.channels,
telegram: nextTelegram,
},
});
return { cleared: true, envToken: false, loggedOut: true };
},
},
};
const defaultRegistry = createRegistry([
{
pluginId: "whatsapp",
source: "test",
plugin: createStubChannelPlugin({ id: "whatsapp", label: "WhatsApp" }),
},
{
pluginId: "telegram",
source: "test",
plugin: telegramPlugin,
},
{
pluginId: "signal",
source: "test",
plugin: createStubChannelPlugin({
id: "signal",
label: "Signal",
summary: { lastProbeAt: null },
}),
},
]);
const servers: Array<Awaited<ReturnType<typeof startServerWithClient>>> = [];
afterEach(async () => {
@@ -28,6 +149,7 @@ afterEach(async () => {
describe("gateway server channels", () => {
test("channels.status returns snapshot without probe", async () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
registryState.registry = defaultRegistry;
const result = await startServerWithClient();
servers.push(result);
const { ws } = result;
@@ -59,6 +181,7 @@ describe("gateway server channels", () => {
});
test("channels.logout reports no session when missing", async () => {
registryState.registry = defaultRegistry;
const result = await startServerWithClient();
servers.push(result);
const { ws } = result;
@@ -74,6 +197,7 @@ describe("gateway server channels", () => {
test("channels.logout clears telegram bot token from config", async () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
registryState.registry = defaultRegistry;
const { readConfigFileSnapshot, writeConfigFile } = await loadConfigHelpers();
await writeConfigFile({
channels: {
+78 -23
View File
@@ -1,7 +1,12 @@
import { createServer } from "node:net";
import { describe, expect, test } from "vitest";
import { resolveCanvasHostUrl } from "../infra/canvas-host-url.js";
import { getChannelPlugin } from "../channels/plugins/index.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import type { ChannelOutboundAdapter } from "../channels/plugins/types.js";
import type { PluginRegistry } from "../plugins/registry.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
import { createOutboundTestPlugin } from "../test-utils/channel-plugins.js";
import {
connectOk,
getFreePort,
@@ -16,6 +21,49 @@ import {
installGatewayTestHooks();
const whatsappOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
sendText: async ({ deps, to, text }) => {
if (!deps?.sendWhatsApp) {
throw new Error("Missing sendWhatsApp dep");
}
return { channel: "whatsapp", ...(await deps.sendWhatsApp(to, text, {})) };
},
sendMedia: async ({ deps, to, text, mediaUrl }) => {
if (!deps?.sendWhatsApp) {
throw new Error("Missing sendWhatsApp dep");
}
return { channel: "whatsapp", ...(await deps.sendWhatsApp(to, text, { mediaUrl })) };
},
};
const whatsappPlugin = createOutboundTestPlugin({
id: "whatsapp",
outbound: whatsappOutbound,
label: "WhatsApp",
});
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
plugins: [],
tools: [],
channels,
providers: [],
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
services: [],
diagnostics: [],
});
const whatsappRegistry = createRegistry([
{
pluginId: "whatsapp",
source: "test",
plugin: whatsappPlugin,
},
]);
const emptyRegistry = createRegistry([]);
describe("gateway server misc", () => {
test("hello-ok advertises the gateway port for canvas host", async () => {
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
@@ -47,31 +95,38 @@ describe("gateway server misc", () => {
});
test("send dedupes by idempotencyKey", { timeout: 60_000 }, async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const prevRegistry = getActivePluginRegistry() ?? emptyRegistry;
try {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
setActivePluginRegistry(whatsappRegistry);
expect(getChannelPlugin("whatsapp")).toBeDefined();
const idem = "same-key";
const res1P = onceMessage(ws, (o) => o.type === "res" && o.id === "a1");
const res2P = onceMessage(ws, (o) => o.type === "res" && o.id === "a2");
const sendReq = (id: string) =>
ws.send(
JSON.stringify({
type: "req",
id,
method: "send",
params: { to: "+15550000000", message: "hi", idempotencyKey: idem },
}),
);
sendReq("a1");
sendReq("a2");
const idem = "same-key";
const res1P = onceMessage(ws, (o) => o.type === "res" && o.id === "a1");
const res2P = onceMessage(ws, (o) => o.type === "res" && o.id === "a2");
const sendReq = (id: string) =>
ws.send(
JSON.stringify({
type: "req",
id,
method: "send",
params: { to: "+15550000000", message: "hi", idempotencyKey: idem },
}),
);
sendReq("a1");
sendReq("a2");
const res1 = await res1P;
const res2 = await res2P;
expect(res1.ok).toBe(true);
expect(res2.ok).toBe(true);
expect(res1.payload).toEqual(res2.payload);
ws.close();
await server.close();
const res1 = await res1P;
const res2 = await res2P;
expect(res1.ok).toBe(true);
expect(res2.ok).toBe(true);
expect(res1.payload).toEqual(res2.payload);
ws.close();
await server.close();
} finally {
setActivePluginRegistry(prevRegistry);
}
});
test("refuses to start when port already bound", async () => {
+58 -42
View File
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { vi } from "vitest";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
export type BridgeClientInfo = {
nodeId: string;
@@ -91,6 +92,7 @@ export const testState = {
agentConfig: undefined as Record<string, unknown> | undefined,
agentsConfig: undefined as Record<string, unknown> | undefined,
bindingsConfig: undefined as Array<Record<string, unknown>> | undefined,
channelsConfig: undefined as Record<string, unknown> | undefined,
sessionStorePath: undefined as string | undefined,
sessionConfig: undefined as Record<string, unknown> | undefined,
allowFrom: undefined as string[] | undefined,
@@ -259,49 +261,63 @@ vi.mock("../config/config.js", async () => {
config: testState.migrationConfig ?? (raw as Record<string, unknown>),
changes: testState.migrationChanges,
}),
loadConfig: () => ({
agents: (() => {
const defaults = {
model: "anthropic/claude-opus-4-5",
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
...testState.agentConfig,
};
if (testState.agentsConfig) {
return { ...testState.agentsConfig, defaults };
}
return { defaults };
})(),
bindings: testState.bindingsConfig,
channels: {
whatsapp: {
allowFrom: testState.allowFrom,
loadConfig: () => {
const base = {
agents: (() => {
const defaults = {
model: "anthropic/claude-opus-4-5",
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
...testState.agentConfig,
};
if (testState.agentsConfig) {
return { ...testState.agentsConfig, defaults };
}
return { defaults };
})(),
bindings: testState.bindingsConfig,
channels: (() => {
const baseChannels =
testState.channelsConfig && typeof testState.channelsConfig === "object"
? { ...testState.channelsConfig }
: {};
const existing = baseChannels.whatsapp;
const mergedWhatsApp: Record<string, unknown> =
existing && typeof existing === "object" && !Array.isArray(existing)
? { ...existing }
: {};
if (testState.allowFrom !== undefined) {
mergedWhatsApp.allowFrom = testState.allowFrom;
}
baseChannels.whatsapp = mergedWhatsApp;
return baseChannels;
})(),
session: {
mainKey: "main",
store: testState.sessionStorePath,
...testState.sessionConfig,
},
},
session: {
mainKey: "main",
store: testState.sessionStorePath,
...testState.sessionConfig,
},
gateway: (() => {
const gateway: Record<string, unknown> = {};
if (testState.gatewayBind) gateway.bind = testState.gatewayBind;
if (testState.gatewayAuth) gateway.auth = testState.gatewayAuth;
return Object.keys(gateway).length > 0 ? gateway : undefined;
})(),
canvasHost: (() => {
const canvasHost: Record<string, unknown> = {};
if (typeof testState.canvasHostPort === "number")
canvasHost.port = testState.canvasHostPort;
return Object.keys(canvasHost).length > 0 ? canvasHost : undefined;
})(),
hooks: testState.hooksConfig,
cron: (() => {
const cron: Record<string, unknown> = {};
if (typeof testState.cronEnabled === "boolean") cron.enabled = testState.cronEnabled;
if (typeof testState.cronStorePath === "string") cron.store = testState.cronStorePath;
return Object.keys(cron).length > 0 ? cron : undefined;
})(),
}),
gateway: (() => {
const gateway: Record<string, unknown> = {};
if (testState.gatewayBind) gateway.bind = testState.gatewayBind;
if (testState.gatewayAuth) gateway.auth = testState.gatewayAuth;
return Object.keys(gateway).length > 0 ? gateway : undefined;
})(),
canvasHost: (() => {
const canvasHost: Record<string, unknown> = {};
if (typeof testState.canvasHostPort === "number")
canvasHost.port = testState.canvasHostPort;
return Object.keys(canvasHost).length > 0 ? canvasHost : undefined;
})(),
hooks: testState.hooksConfig,
cron: (() => {
const cron: Record<string, unknown> = {};
if (typeof testState.cronEnabled === "boolean") cron.enabled = testState.cronEnabled;
if (typeof testState.cronStorePath === "string") cron.store = testState.cronStorePath;
return Object.keys(cron).length > 0 ? cron : undefined;
})(),
} as ReturnType<typeof actual.loadConfig>;
return applyPluginAutoEnable({ config: base }).config;
},
parseConfigJson5: (raw: string) => {
try {
return { ok: true, parsed: JSON.parse(raw) as unknown };
+1
View File
@@ -99,6 +99,7 @@ export function installGatewayTestHooks() {
testState.agentConfig = undefined;
testState.agentsConfig = undefined;
testState.bindingsConfig = undefined;
testState.channelsConfig = undefined;
testState.allowFrom = undefined;
testIsNixMode.value = false;
cronIsolatedRun.mockClear();
+10 -29
View File
@@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path";
import JSZip from "jszip";
import * as tar from "tar";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
const tempDirs: string[] = [];
@@ -15,22 +15,6 @@ function makeTempDir() {
return dir;
}
async function withStateDir<T>(stateDir: string, fn: () => Promise<T>) {
const prev = process.env.CLAWDBOT_STATE_DIR;
process.env.CLAWDBOT_STATE_DIR = stateDir;
vi.resetModules();
try {
return await fn();
} finally {
if (prev === undefined) {
delete process.env.CLAWDBOT_STATE_DIR;
} else {
process.env.CLAWDBOT_STATE_DIR = prev;
}
vi.resetModules();
}
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
try {
@@ -72,10 +56,9 @@ describe("installHooksFromArchive", () => {
const buffer = await zip.generateAsync({ type: "nodebuffer" });
fs.writeFileSync(archivePath, buffer);
const result = await withStateDir(stateDir, async () => {
const { installHooksFromArchive } = await import("./install.js");
return await installHooksFromArchive({ archivePath });
});
const hooksDir = path.join(stateDir, "hooks");
const { installHooksFromArchive } = await import("./install.js");
const result = await installHooksFromArchive({ archivePath, hooksDir });
expect(result.ok).toBe(true);
if (!result.ok) return;
@@ -121,10 +104,9 @@ describe("installHooksFromArchive", () => {
);
await tar.c({ cwd: workDir, file: archivePath }, ["package"]);
const result = await withStateDir(stateDir, async () => {
const { installHooksFromArchive } = await import("./install.js");
return await installHooksFromArchive({ archivePath });
});
const hooksDir = path.join(stateDir, "hooks");
const { installHooksFromArchive } = await import("./install.js");
const result = await installHooksFromArchive({ archivePath, hooksDir });
expect(result.ok).toBe(true);
if (!result.ok) return;
@@ -155,10 +137,9 @@ describe("installHooksFromPath", () => {
);
fs.writeFileSync(path.join(hookDir, "handler.ts"), "export default async () => {};\n");
const result = await withStateDir(stateDir, async () => {
const { installHooksFromPath } = await import("./install.js");
return await installHooksFromPath({ path: hookDir });
});
const hooksDir = path.join(stateDir, "hooks");
const { installHooksFromPath } = await import("./install.js");
const result = await installHooksFromPath({ path: hookDir, hooksDir });
expect(result.ok).toBe(true);
if (!result.ok) return;
@@ -38,6 +38,7 @@ vi.mock("../pairing/pairing-store.js", () => ({
vi.mock("../config/sessions.js", () => ({
resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
readSessionUpdatedAt: vi.fn(() => undefined),
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
}));
@@ -38,6 +38,7 @@ vi.mock("../pairing/pairing-store.js", () => ({
vi.mock("../config/sessions.js", () => ({
resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
readSessionUpdatedAt: vi.fn(() => undefined),
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
}));
+17 -4
View File
@@ -11,7 +11,11 @@ import {
} from "../../auto-reply/reply/response-prefix-template.js";
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import { hasControlCommand } from "../../auto-reply/command-detection.js";
import { formatInboundEnvelope, formatInboundFromLabel } from "../../auto-reply/envelope.js";
import {
formatInboundEnvelope,
formatInboundFromLabel,
resolveEnvelopeFormatOptions,
} from "../../auto-reply/envelope.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
@@ -33,6 +37,7 @@ import {
resolveChannelGroupRequireMention,
} from "../../config/group-policy.js";
import {
readSessionUpdatedAt,
recordSessionMetaFromInbound,
resolveStorePath,
updateLastRoute,
@@ -401,6 +406,14 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
directLabel: senderNormalized,
directId: sender,
});
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = formatInboundEnvelope({
channel: "iMessage",
from: fromLabel,
@@ -408,6 +421,8 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
body: bodyText,
chatType: isGroup ? "group" : "direct",
sender: { name: senderNormalized, id: sender },
previousTimestamp,
envelope: envelopeOptions,
});
let combinedBody = body;
if (isGroup && historyKey && historyLimit > 0) {
@@ -424,6 +439,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
body: `${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
chatType: "group",
senderLabel: entry.sender,
envelope: envelopeOptions,
}),
});
}
@@ -461,9 +477,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
OriginatingTo: imessageTo,
});
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
void recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
+9 -9
View File
@@ -1,6 +1,8 @@
import os from "node:os";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as logging from "../logging.js";
const createService = vi.fn();
const shutdown = vi.fn();
@@ -23,14 +25,6 @@ vi.mock("../logger.js", () => {
};
});
vi.mock("../logging.js", async () => {
const actual = await vi.importActual<typeof import("../logging.js")>("../logging.js");
return {
...actual,
getLogger: () => ({ info: (...args: unknown[]) => getLoggerInfo(...args) }),
};
});
vi.mock("@homebridge/ciao", () => {
return {
Protocol: { TCP: "tcp" },
@@ -60,6 +54,12 @@ describe("gateway bonjour advertiser", () => {
const prevEnv = { ...process.env };
beforeEach(() => {
vi.spyOn(logging, "getLogger").mockReturnValue({
info: (...args: unknown[]) => getLoggerInfo(...args),
});
});
afterEach(() => {
for (const key of Object.keys(process.env)) {
if (!(key in prevEnv)) delete process.env[key];
+1 -1
View File
@@ -90,7 +90,7 @@ describe("exec approvals command resolution", () => {
const script = path.join(cwd, "bin", "tool");
fs.mkdirSync(path.dirname(script), { recursive: true });
fs.writeFileSync(script, "");
const res = resolveCommandResolution("\"./bin/tool\" --version", cwd, undefined);
const res = resolveCommandResolution('"./bin/tool" --version', cwd, undefined);
expect(res?.resolvedPath).toBe(script);
});
});
+6 -1
View File
@@ -86,7 +86,12 @@ export async function requestExecHostViaSocket(params: {
idx = buffer.indexOf("\n");
if (!line) continue;
try {
const msg = JSON.parse(line) as { type?: string; ok?: boolean; payload?: unknown; error?: unknown };
const msg = JSON.parse(line) as {
type?: string;
ok?: boolean;
payload?: unknown;
error?: unknown;
};
if (msg?.type === "exec-res") {
clearTimeout(timer);
if (msg.ok === true && msg.payload) {
+67 -4
View File
@@ -1,4 +1,67 @@
export * from "./logging/console.js";
export type { LogLevel } from "./logging/levels.js";
export * from "./logging/logger.js";
export * from "./logging/subsystem.js";
import {
enableConsoleCapture,
getConsoleSettings,
getResolvedConsoleSettings,
routeLogsToStderr,
setConsoleSubsystemFilter,
setConsoleTimestampPrefix,
shouldLogSubsystemToConsole,
} from "./logging/console.js";
import type { ConsoleLoggerSettings, ConsoleStyle } from "./logging/console.js";
import { ALLOWED_LOG_LEVELS, levelToMinLevel, normalizeLogLevel } from "./logging/levels.js";
import type { LogLevel } from "./logging/levels.js";
import {
DEFAULT_LOG_DIR,
DEFAULT_LOG_FILE,
getChildLogger,
getLogger,
getResolvedLoggerSettings,
isFileLogLevelEnabled,
resetLogger,
setLoggerOverride,
toPinoLikeLogger,
} from "./logging/logger.js";
import type { LoggerResolvedSettings, LoggerSettings, PinoLikeLogger } from "./logging/logger.js";
import {
createSubsystemLogger,
createSubsystemRuntime,
runtimeForLogger,
stripRedundantSubsystemPrefixForConsole,
} from "./logging/subsystem.js";
import type { SubsystemLogger } from "./logging/subsystem.js";
export {
enableConsoleCapture,
getConsoleSettings,
getResolvedConsoleSettings,
routeLogsToStderr,
setConsoleSubsystemFilter,
setConsoleTimestampPrefix,
shouldLogSubsystemToConsole,
ALLOWED_LOG_LEVELS,
levelToMinLevel,
normalizeLogLevel,
DEFAULT_LOG_DIR,
DEFAULT_LOG_FILE,
getChildLogger,
getLogger,
getResolvedLoggerSettings,
isFileLogLevelEnabled,
resetLogger,
setLoggerOverride,
toPinoLikeLogger,
createSubsystemLogger,
createSubsystemRuntime,
runtimeForLogger,
stripRedundantSubsystemPrefixForConsole,
};
export type {
ConsoleLoggerSettings,
ConsoleStyle,
LogLevel,
LoggerResolvedSettings,
LoggerSettings,
PinoLikeLogger,
SubsystemLogger,
};
+17 -3
View File
@@ -1,6 +1,7 @@
import { createRequire } from "node:module";
import util from "node:util";
import { type ClawdbotConfig, loadConfig } from "../config/config.js";
import type { ClawdbotConfig } from "../config/config.js";
import { isVerbose } from "../globals.js";
import { stripAnsi } from "../terminal/ansi.js";
import { type LogLevel, normalizeLogLevel } from "./levels.js";
@@ -14,6 +15,8 @@ type ConsoleSettings = {
};
export type ConsoleLoggerSettings = ConsoleSettings;
const requireConfig = createRequire(import.meta.url);
function normalizeConsoleLevel(level?: string): LogLevel {
if (isVerbose()) return "debug";
return normalizeLogLevel(level, "info");
@@ -28,8 +31,19 @@ function normalizeConsoleStyle(style?: string): ConsoleStyle {
}
function resolveConsoleSettings(): ConsoleSettings {
const cfg: ClawdbotConfig["logging"] | undefined =
(loggingState.overrideSettings as LoggerSettings | null) ?? loadConfig().logging;
let cfg: ClawdbotConfig["logging"] | undefined;
if (loggingState.overrideSettings) {
cfg = loggingState.overrideSettings as LoggerSettings;
} else {
try {
const loaded = requireConfig("../config/config.js") as {
loadConfig?: () => ClawdbotConfig;
};
cfg = loaded.loadConfig?.().logging;
} catch {
cfg = undefined;
}
}
const level = normalizeConsoleLevel(cfg?.consoleLevel);
const style = normalizeConsoleStyle(cfg?.consoleStyle);
return { level, style };
+17 -3
View File
@@ -1,9 +1,10 @@
import { createRequire } from "node:module";
import fs from "node:fs";
import path from "node:path";
import { Logger as TsLogger } from "tslog";
import { type ClawdbotConfig, loadConfig } from "../config/config.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { ConsoleStyle } from "./console.js";
import { type LogLevel, levelToMinLevel, normalizeLogLevel } from "./levels.js";
import { loggingState } from "./state.js";
@@ -17,6 +18,8 @@ const LOG_PREFIX = "clawdbot";
const LOG_SUFFIX = ".log";
const MAX_LOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h
const requireConfig = createRequire(import.meta.url);
export type LoggerSettings = {
level?: LogLevel;
file?: string;
@@ -33,8 +36,19 @@ type ResolvedSettings = {
export type LoggerResolvedSettings = ResolvedSettings;
function resolveSettings(): ResolvedSettings {
const cfg: ClawdbotConfig["logging"] | undefined =
(loggingState.overrideSettings as LoggerSettings | null) ?? loadConfig().logging;
let cfg: ClawdbotConfig["logging"] | undefined;
if (loggingState.overrideSettings) {
cfg = loggingState.overrideSettings as LoggerSettings;
} else {
try {
const loaded = requireConfig("../config/config.js") as {
loadConfig?: () => ClawdbotConfig;
};
cfg = loaded.loadConfig?.().logging;
} catch {
cfg = undefined;
}
}
const level = normalizeLogLevel(cfg?.level, "info");
const file = cfg?.file ?? defaultRollingPathForToday();
return { level, file };
+14 -2
View File
@@ -1,4 +1,8 @@
import { loadConfig } from "../config/config.js";
import { createRequire } from "node:module";
import type { ClawdbotConfig } from "../config/config.js";
const requireConfig = createRequire(import.meta.url);
export type RedactSensitiveMode = "off" | "tools";
@@ -93,7 +97,15 @@ function redactText(text: string, patterns: RegExp[]): string {
}
function resolveConfigRedaction(): RedactOptions {
const cfg = loadConfig().logging;
let cfg: ClawdbotConfig["logging"] | undefined;
try {
const loaded = requireConfig("../config/config.js") as {
loadConfig?: () => ClawdbotConfig;
};
cfg = loaded.loadConfig?.().logging;
} catch {
cfg = undefined;
}
return {
mode: normalizeMode(cfg?.redactSensitive),
patterns: cfg?.redactPatterns,
+46 -7
View File
@@ -1,21 +1,60 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import JSZip from "jszip";
import sharp from "sharp";
import { describe, expect, it, vi } from "vitest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { isPathWithinBase } from "../../test/helpers/paths.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
describe("media store", () => {
let store: typeof import("./store.js");
let home = "";
const envSnapshot: Record<string, string | undefined> = {};
const snapshotEnv = () => {
for (const key of ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "CLAWDBOT_STATE_DIR"]) {
envSnapshot[key] = process.env[key];
}
};
const restoreEnv = () => {
for (const [key, value] of Object.entries(envSnapshot)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
};
beforeAll(async () => {
snapshotEnv();
home = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-test-home-"));
process.env.HOME = home;
process.env.USERPROFILE = home;
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
if (process.platform === "win32") {
const match = home.match(/^([A-Za-z]:)(.*)$/);
if (match) {
process.env.HOMEDRIVE = match[1];
process.env.HOMEPATH = match[2] || "\\";
}
}
await fs.mkdir(path.join(home, ".clawdbot"), { recursive: true });
store = await import("./store.js");
});
afterAll(async () => {
restoreEnv();
try {
await fs.rm(home, { recursive: true, force: true });
} catch {
// ignore cleanup failures in tests
}
});
async function withTempStore<T>(
fn: (store: typeof import("./store.js"), home: string) => Promise<T>,
): Promise<T> {
return await withTempHome(async (home) => {
vi.resetModules();
const store = await import("./store.js");
return await fn(store, home);
});
return await fn(store, home);
}
it("creates and returns media directory", async () => {
+12 -10
View File
@@ -4,29 +4,30 @@ import fs from "node:fs/promises";
import { request } from "node:https";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import { CONFIG_DIR } from "../utils.js";
import { resolveConfigDir } from "../utils.js";
import { detectMime, extensionForMime } from "./mime.js";
const MEDIA_DIR = path.join(CONFIG_DIR, "media");
const resolveMediaDir = () => path.join(resolveConfigDir(), "media");
const MAX_BYTES = 5 * 1024 * 1024; // 5MB default
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
export function getMediaDir() {
return MEDIA_DIR;
return resolveMediaDir();
}
export async function ensureMediaDir() {
await fs.mkdir(MEDIA_DIR, { recursive: true });
return MEDIA_DIR;
const mediaDir = resolveMediaDir();
await fs.mkdir(mediaDir, { recursive: true });
return mediaDir;
}
export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS) {
await ensureMediaDir();
const entries = await fs.readdir(MEDIA_DIR).catch(() => []);
const mediaDir = await ensureMediaDir();
const entries = await fs.readdir(mediaDir).catch(() => []);
const now = Date.now();
await Promise.all(
entries.map(async (file) => {
const full = path.join(MEDIA_DIR, file);
const full = path.join(mediaDir, file);
const stat = await fs.stat(full).catch(() => null);
if (!stat) return;
if (now - stat.mtimeMs > ttlMs) {
@@ -110,7 +111,8 @@ export async function saveMediaSource(
headers?: Record<string, string>,
subdir = "",
): Promise<SavedMedia> {
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
const baseDir = resolveMediaDir();
const dir = subdir ? path.join(baseDir, subdir) : baseDir;
await fs.mkdir(dir, { recursive: true });
await cleanOldMedia();
const baseId = crypto.randomUUID();
@@ -154,7 +156,7 @@ export async function saveMediaBuffer(
if (buffer.byteLength > maxBytes) {
throw new Error(`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`);
}
const dir = path.join(MEDIA_DIR, subdir);
const dir = path.join(resolveMediaDir(), subdir);
await fs.mkdir(dir, { recursive: true });
const baseId = crypto.randomUUID();
const headerExt = extensionForMime(contentType?.split(";")[0]?.trim() ?? undefined);
+9 -4
View File
@@ -183,7 +183,9 @@ async function fetchGeminiBatchStatus(params: {
batchName: string;
}): Promise<GeminiBatchStatus> {
const baseUrl = getGeminiBaseUrl(params.gemini);
const name = params.batchName.startsWith("batches/") ? params.batchName : `batches/${params.batchName}`;
const name = params.batchName.startsWith("batches/")
? params.batchName
: `batches/${params.batchName}`;
const statusUrl = `${baseUrl}/${name}`;
debugLog("memory embeddings: gemini batch status", { statusUrl });
const res = await fetch(statusUrl, {
@@ -328,7 +330,11 @@ export async function runGeminiEmbeddingBatches(params: {
requests: group.length,
});
if (!params.wait && batchInfo.state && !["SUCCEEDED", "COMPLETED", "DONE"].includes(batchInfo.state)) {
if (
!params.wait &&
batchInfo.state &&
!["SUCCEEDED", "COMPLETED", "DONE"].includes(batchInfo.state)
) {
throw new Error(
`gemini batch ${batchName} submitted; enable remote.batch.wait to await completion`,
);
@@ -376,8 +382,7 @@ export async function runGeminiEmbeddingBatches(params: {
errors.push(`${customId}: ${line.response.error.message}`);
continue;
}
const embedding =
line.embedding?.values ?? line.response?.embedding?.values ?? [];
const embedding = line.embedding?.values ?? line.response?.embedding?.values ?? [];
if (embedding.length === 0) {
errors.push(`${customId}: empty embedding`);
continue;
+3 -12
View File
@@ -3,14 +3,8 @@ import fsSync from "node:fs";
import type { Llama, LlamaEmbeddingContext, LlamaModel } from "node-llama-cpp";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveUserPath } from "../utils.js";
import {
createGeminiEmbeddingProvider,
type GeminiEmbeddingClient,
} from "./embeddings-gemini.js";
import {
createOpenAiEmbeddingProvider,
type OpenAiEmbeddingClient,
} from "./embeddings-openai.js";
import { createGeminiEmbeddingProvider, type GeminiEmbeddingClient } from "./embeddings-gemini.js";
import { createOpenAiEmbeddingProvider, type OpenAiEmbeddingClient } from "./embeddings-openai.js";
import { importNodeLlamaCpp } from "./node-llama.js";
export type { GeminiEmbeddingClient } from "./embeddings-gemini.js";
@@ -68,7 +62,6 @@ function isMissingApiKeyError(err: unknown): boolean {
return message.includes("No API key found for provider");
}
async function createLocalEmbeddingProvider(
options: EmbeddingProviderOptions,
): Promise<EmbeddingProvider> {
@@ -188,9 +181,7 @@ export async function createEmbeddingProvider(
fallbackReason: reason,
};
} catch (fallbackErr) {
throw new Error(
`${reason}\n\nFallback to ${fallback} failed: ${formatError(fallbackErr)}`,
);
throw new Error(`${reason}\n\nFallback to ${fallback} failed: ${formatError(fallbackErr)}`);
}
}
throw new Error(reason);
+3 -5
View File
@@ -697,9 +697,7 @@ export class MemoryIndexManager {
private async removeIndexFiles(basePath: string): Promise<void> {
const suffixes = ["", "-wal", "-shm"];
await Promise.all(
suffixes.map((suffix) => fs.rm(`${basePath}${suffix}`, { force: true })),
);
await Promise.all(suffixes.map((suffix) => fs.rm(`${basePath}${suffix}`, { force: true })));
}
private ensureSchema() {
@@ -1064,8 +1062,8 @@ export class MemoryIndexManager {
const batch = this.settings.remote?.batch;
const enabled = Boolean(
batch?.enabled &&
((this.openAi && this.provider.id === "openai") ||
(this.gemini && this.provider.id === "gemini")),
((this.openAi && this.provider.id === "openai") ||
(this.gemini && this.provider.id === "gemini")),
);
return {
enabled,
+16 -8
View File
@@ -63,7 +63,11 @@ describe("memory vector dedupe", () => {
if (!result.manager) throw new Error("manager missing");
manager = result.manager;
const db = (manager as unknown as { db: { exec: (sql: string) => void; prepare: (sql: string) => unknown } }).db;
const db = (
manager as unknown as {
db: { exec: (sql: string) => void; prepare: (sql: string) => unknown };
}
).db;
db.exec("CREATE TABLE IF NOT EXISTS chunks_vec (id TEXT PRIMARY KEY, embedding BLOB)");
const sqlSeen: string[] = [];
@@ -75,16 +79,20 @@ describe("memory vector dedupe", () => {
return originalPrepare(sql);
};
(manager as unknown as { ensureVectorReady: (dims?: number) => Promise<boolean> }).ensureVectorReady =
async () => true;
(
manager as unknown as { ensureVectorReady: (dims?: number) => Promise<boolean> }
).ensureVectorReady = async () => true;
const entry = await buildFileEntry(path.join(workspaceDir, "MEMORY.md"), workspaceDir);
await (manager as unknown as { indexFile: (entry: unknown, options: { source: "memory" }) => Promise<void> }).indexFile(
entry,
{ source: "memory" },
);
await (
manager as unknown as {
indexFile: (entry: unknown, options: { source: "memory" }) => Promise<void>;
}
).indexFile(entry, { source: "memory" });
const deleteIndex = sqlSeen.findIndex((sql) => sql.includes("DELETE FROM chunks_vec WHERE id = ?"));
const deleteIndex = sqlSeen.findIndex((sql) =>
sql.includes("DELETE FROM chunks_vec WHERE id = ?"),
);
const insertIndex = sqlSeen.findIndex((sql) => sql.includes("INSERT INTO chunks_vec"));
expect(deleteIndex).toBeGreaterThan(-1);
expect(insertIndex).toBeGreaterThan(-1);
+2 -1
View File
@@ -576,7 +576,8 @@ async function handleInvoke(
const skillAllow =
autoAllowSkills && resolution?.executableName ? bins.has(resolution.executableName) : false;
const useMacAppExec = process.platform === "darwin" && (execHostEnforced || !execHostFallbackAllowed);
const useMacAppExec =
process.platform === "darwin" && (execHostEnforced || !execHostFallbackAllowed);
if (useMacAppExec) {
const execRequest: ExecHostRequest = {
command: argv,
+28 -44
View File
@@ -4,7 +4,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import JSZip from "jszip";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
const tempDirs: string[] = [];
@@ -77,22 +77,6 @@ function packToArchive({
return dest;
}
async function withStateDir<T>(stateDir: string, fn: () => Promise<T>) {
const prev = process.env.CLAWDBOT_STATE_DIR;
process.env.CLAWDBOT_STATE_DIR = stateDir;
vi.resetModules();
try {
return await fn();
} finally {
if (prev === undefined) {
delete process.env.CLAWDBOT_STATE_DIR;
} else {
process.env.CLAWDBOT_STATE_DIR = prev;
}
vi.resetModules();
}
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
try {
@@ -126,10 +110,9 @@ describe("installPluginFromArchive", () => {
outName: "plugin.tgz",
});
const result = await withStateDir(stateDir, async () => {
const { installPluginFromArchive } = await import("./install.js");
return await installPluginFromArchive({ archivePath });
});
const extensionsDir = path.join(stateDir, "extensions");
const { installPluginFromArchive } = await import("./install.js");
const result = await installPluginFromArchive({ archivePath, extensionsDir });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.pluginId).toBe("voice-call");
@@ -160,12 +143,10 @@ describe("installPluginFromArchive", () => {
outName: "plugin.tgz",
});
const { first, second } = await withStateDir(stateDir, async () => {
const { installPluginFromArchive } = await import("./install.js");
const first = await installPluginFromArchive({ archivePath });
const second = await installPluginFromArchive({ archivePath });
return { first, second };
});
const extensionsDir = path.join(stateDir, "extensions");
const { installPluginFromArchive } = await import("./install.js");
const first = await installPluginFromArchive({ archivePath, extensionsDir });
const second = await installPluginFromArchive({ archivePath, extensionsDir });
expect(first.ok).toBe(true);
expect(second.ok).toBe(false);
@@ -191,10 +172,9 @@ describe("installPluginFromArchive", () => {
const buffer = await zip.generateAsync({ type: "nodebuffer" });
fs.writeFileSync(archivePath, buffer);
const result = await withStateDir(stateDir, async () => {
const { installPluginFromArchive } = await import("./install.js");
return await installPluginFromArchive({ archivePath });
});
const extensionsDir = path.join(stateDir, "extensions");
const { installPluginFromArchive } = await import("./install.js");
const result = await installPluginFromArchive({ archivePath, extensionsDir });
expect(result.ok).toBe(true);
if (!result.ok) return;
@@ -243,18 +223,23 @@ describe("installPluginFromArchive", () => {
});
})();
const result = await withStateDir(stateDir, async () => {
const { installPluginFromArchive } = await import("./install.js");
const first = await installPluginFromArchive({ archivePath: archiveV1 });
const second = await installPluginFromArchive({ archivePath: archiveV2, mode: "update" });
return { first, second };
const extensionsDir = path.join(stateDir, "extensions");
const { installPluginFromArchive } = await import("./install.js");
const first = await installPluginFromArchive({
archivePath: archiveV1,
extensionsDir,
});
const second = await installPluginFromArchive({
archivePath: archiveV2,
extensionsDir,
mode: "update",
});
expect(result.first.ok).toBe(true);
expect(result.second.ok).toBe(true);
if (!result.second.ok) return;
expect(first.ok).toBe(true);
expect(second.ok).toBe(true);
if (!second.ok) return;
const manifest = JSON.parse(
fs.readFileSync(path.join(result.second.targetDir, "package.json"), "utf-8"),
fs.readFileSync(path.join(second.targetDir, "package.json"), "utf-8"),
) as { version?: string };
expect(manifest.version).toBe("0.0.2");
});
@@ -276,10 +261,9 @@ describe("installPluginFromArchive", () => {
outName: "bad.tgz",
});
const result = await withStateDir(stateDir, async () => {
const { installPluginFromArchive } = await import("./install.js");
return await installPluginFromArchive({ archivePath });
});
const extensionsDir = path.join(stateDir, "extensions");
const { installPluginFromArchive } = await import("./install.js");
const result = await installPluginFromArchive({ archivePath, extensionsDir });
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toContain("clawdbot.extensions");
+20
View File
@@ -75,6 +75,26 @@ describe("loadClawdbotPlugins", () => {
expect(enabled?.status).toBe("loaded");
});
it("loads bundled telegram plugin when enabled", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = path.join(process.cwd(), "extensions");
const registry = loadClawdbotPlugins({
cache: false,
config: {
plugins: {
allow: ["telegram"],
entries: {
telegram: { enabled: true },
},
},
},
});
const telegram = registry.plugins.find((entry) => entry.id === "telegram");
expect(telegram?.status).toBe("loaded");
expect(registry.channels.some((entry) => entry.plugin.id === "telegram")).toBe(true);
});
it("enables bundled memory plugin when selected by slot", () => {
const bundledDir = makeTempDir();
const bundledPath = path.join(bundledDir, "memory-core.ts");
+29 -15
View File
@@ -11,7 +11,11 @@ import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../../auto-reply/inbound-debounce.js";
import { formatAgentEnvelope } from "../../auto-reply/envelope.js";
import {
formatAgentEnvelope,
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
} from "../../auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
@@ -33,12 +37,16 @@ import {
import { resolveStateDir } from "../../config/paths.js";
import { loadConfig, writeConfigFile } from "../../config/config.js";
import {
readSessionUpdatedAt,
recordSessionMetaFromInbound,
resolveStorePath,
updateLastRoute,
} from "../../config/sessions.js";
import { auditDiscordChannelPermissions } from "../../discord/audit.js";
import { listDiscordDirectoryGroupsLive, listDiscordDirectoryPeersLive } from "../../discord/directory-live.js";
import {
listDiscordDirectoryGroupsLive,
listDiscordDirectoryPeersLive,
} from "../../discord/directory-live.js";
import { monitorDiscordProvider } from "../../discord/monitor.js";
import { probeDiscord } from "../../discord/probe.js";
import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js";
@@ -68,7 +76,10 @@ import { monitorSignalProvider } from "../../signal/index.js";
import { probeSignal } from "../../signal/probe.js";
import { sendMessageSignal } from "../../signal/send.js";
import { monitorSlackProvider } from "../../slack/index.js";
import { listSlackDirectoryGroupsLive, listSlackDirectoryPeersLive } from "../../slack/directory-live.js";
import {
listSlackDirectoryGroupsLive,
listSlackDirectoryPeersLive,
} from "../../slack/directory-live.js";
import { probeSlack } from "../../slack/probe.js";
import { resolveSlackChannelAllowlist } from "../../slack/resolve-channels.js";
import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js";
@@ -137,12 +148,12 @@ export function createPluginRuntime(): PluginRuntime {
registerMemoryCli,
},
channel: {
text: {
chunkMarkdownText,
chunkText,
resolveTextChunkLimit,
hasControlCommand,
},
text: {
chunkMarkdownText,
chunkText,
resolveTextChunkLimit,
hasControlCommand,
},
reply: {
dispatchReplyWithBufferedBlockDispatcher,
createReplyDispatcherWithTyping,
@@ -151,6 +162,8 @@ export function createPluginRuntime(): PluginRuntime {
dispatchReplyFromConfig,
finalizeInboundContext,
formatAgentEnvelope,
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
},
routing: {
resolveAgentRoute,
@@ -166,6 +179,7 @@ export function createPluginRuntime(): PluginRuntime {
},
session: {
resolveStorePath,
readSessionUpdatedAt,
recordSessionMetaFromInbound,
updateLastRoute,
},
@@ -181,12 +195,12 @@ export function createPluginRuntime(): PluginRuntime {
createInboundDebouncer,
resolveInboundDebounceMs,
},
commands: {
resolveCommandAuthorizedFromAuthorizers,
isControlCommandMessage,
shouldComputeCommandAuthorized,
shouldHandleTextCommands,
},
commands: {
resolveCommandAuthorizedFromAuthorizers,
isControlCommandMessage,
shouldComputeCommandAuthorized,
shouldHandleTextCommands,
},
discord: {
messageActions: discordMessageActions,
auditChannelPermissions: auditDiscordChannelPermissions,
+8 -2
View File
@@ -44,10 +44,14 @@ type DispatchReplyFromConfig =
type FinalizeInboundContext =
typeof import("../../auto-reply/reply/inbound-context.js").finalizeInboundContext;
type FormatAgentEnvelope = typeof import("../../auto-reply/envelope.js").formatAgentEnvelope;
type FormatInboundEnvelope = typeof import("../../auto-reply/envelope.js").formatInboundEnvelope;
type ResolveEnvelopeFormatOptions =
typeof import("../../auto-reply/envelope.js").resolveEnvelopeFormatOptions;
type ResolveStateDir = typeof import("../../config/paths.js").resolveStateDir;
type RecordSessionMetaFromInbound =
typeof import("../../config/sessions.js").recordSessionMetaFromInbound;
type ResolveStorePath = typeof import("../../config/sessions.js").resolveStorePath;
type ReadSessionUpdatedAt = typeof import("../../config/sessions.js").readSessionUpdatedAt;
type UpdateLastRoute = typeof import("../../config/sessions.js").updateLastRoute;
type LoadConfig = typeof import("../../config/config.js").loadConfig;
type WriteConfigFile = typeof import("../../config/config.js").writeConfigFile;
@@ -59,8 +63,7 @@ type MediaKindFromMime = typeof import("../../media/constants.js").mediaKindFrom
type IsVoiceCompatibleAudio = typeof import("../../media/audio.js").isVoiceCompatibleAudio;
type GetImageMetadata = typeof import("../../media/image-ops.js").getImageMetadata;
type ResizeToJpeg = typeof import("../../media/image-ops.js").resizeToJpeg;
type CreateMemoryGetTool =
typeof import("../../agents/tools/memory-tool.js").createMemoryGetTool;
type CreateMemoryGetTool = typeof import("../../agents/tools/memory-tool.js").createMemoryGetTool;
type CreateMemorySearchTool =
typeof import("../../agents/tools/memory-tool.js").createMemorySearchTool;
type RegisterMemoryCli = typeof import("../../cli/memory-cli.js").registerMemoryCli;
@@ -170,6 +173,8 @@ export type PluginRuntime = {
dispatchReplyFromConfig: DispatchReplyFromConfig;
finalizeInboundContext: FinalizeInboundContext;
formatAgentEnvelope: FormatAgentEnvelope;
formatInboundEnvelope: FormatInboundEnvelope;
resolveEnvelopeFormatOptions: ResolveEnvelopeFormatOptions;
};
routing: {
resolveAgentRoute: ResolveAgentRoute;
@@ -185,6 +190,7 @@ export type PluginRuntime = {
};
session: {
resolveStorePath: ResolveStorePath;
readSessionUpdatedAt: ReadSessionUpdatedAt;
recordSessionMetaFromInbound: RecordSessionMetaFromInbound;
updateLastRoute: UpdateLastRoute;
};
+1 -3
View File
@@ -33,9 +33,7 @@ describe("applyExclusiveSlotSelection", () => {
expect(result.warnings).toContain(
'Exclusive slot "memory" switched from "memory-core" to "memory".',
);
expect(result.warnings).toContain(
'Disabled other "memory" slot plugins: memory-core.',
);
expect(result.warnings).toContain('Disabled other "memory" slot plugins: memory-core.');
});
it("does nothing when the slot already matches", () => {
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more