Compare commits

..
Author SHA1 Message Date
Shadow 9aed6ac49a fix: centralize telegram api error logging (#2492) (thanks @altryne) 2026-01-26 20:30:29 -06:00
Shadow 9e200068dc telegram: centralize api error logging 2026-01-26 20:27:36 -06:00
Gustavo Madeira Santana 66a5b324a1 fix: harden session lock cleanup (#2483) (thanks @janeexai) 2026-01-26 21:16:05 -05:00
Shadow 5796a92231 fix: log telegram API fetch errors (#2492) (thanks @altryne) 2026-01-26 20:04:05 -06:00
wolfredandClaude Opus 4.5 241436a525 fix: handle fetch/API errors in telegram delivery to prevent gateway crashes
Wrap all bot.api.sendXxx() media calls in delivery.ts with error handler
that logs failures before re-throwing. This ensures network failures are
properly logged with context instead of causing unhandled promise rejections
that crash the gateway.

Also wrap the fetch() call in telegram onboarding with try/catch to
gracefully handle network errors during username lookup.

Fixes #2487

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 20:04:05 -06:00
Shakker Nerd d5f2924b5a fix(msteams): use sendActivity for typing indicator
Merges #1810
2026-01-27 02:02:37 +00:00
Shakker Nerd e33114551d Merge branch 'main' into pr-1810 2026-01-27 02:01:19 +00:00
Shadow 260f6e2c00 Docs: fix /scripts redirect loop 2026-01-26 19:57:49 -06:00
Shakker Nerd f300875dfe Fix: Corrected the sendActivity parameter type from an array to a single activity object 2026-01-27 01:57:13 +00:00
481bd333eb fix(gateway): gracefully handle AbortError and transient network errors (#2451)
* fix(tts): generate audio when block streaming drops final reply

When block streaming succeeds, final replies are dropped but TTS was only
applied to final replies. Fix by accumulating block text during streaming
and generating TTS-only audio after streaming completes.

Also:
- Change truncate vs skip behavior when summary OFF (now truncates)
- Align TTS limits with Telegram max (4096 chars)
- Improve /tts command help messages with examples
- Add newline separator between accumulated blocks

* fix(tts): add error handling for accumulated block TTS

* feat(tts): add descriptive inline menu with action descriptions

- Add value/label support for command arg choices
- TTS menu now shows descriptive title listing each action
- Capitalize button labels (On, Off, Status, etc.)
- Update Telegram, Discord, and Slack handlers to use labels

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(gateway): gracefully handle AbortError and transient network errors

Addresses issues #1851, #1997, and #2034.

During config reload (SIGUSR1), in-flight requests are aborted, causing
AbortError exceptions. Similarly, transient network errors (fetch failed,
ECONNRESET, ETIMEDOUT, etc.) can crash the gateway unnecessarily.

This change:
- Adds isAbortError() to detect intentional cancellations
- Adds isTransientNetworkError() to detect temporary connectivity issues
- Logs these errors appropriately instead of crashing
- Handles nested cause chains and AggregateError

AbortError is logged as a warning (expected during shutdown).
Network errors are logged as non-fatal errors (will resolve on their own).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(test): update commands-registry test expectations

Update test expectations to match new ResolvedCommandArgChoice format
(choices now return {label, value} objects instead of plain strings).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: harden unhandled rejection handling and tts menus (#2451) (thanks @Glucksberg)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Shadow <hi@shadowing.dev>
2026-01-26 19:51:53 -06:00
Shadow d8e5dd91ba fix: clean up session locks on exit (#2483) (thanks @janeexai) 2026-01-26 19:48:46 -06:00
Jane 14f8acdecb fix(agents): release session locks on process termination
Adds process exit handlers to release all held session locks on:
- Normal process.exit() calls
- SIGTERM / SIGINT signals

This ensures locks are cleaned up even when the process terminates
unexpectedly, preventing the 'session file locked' error.
2026-01-26 19:46:04 -06:00
Shakker 761cb01e20 Merge branch 'main' into main 2026-01-27 01:39:22 +00:00
Yuan Chenandvignesh07 27174f5d82 bugfix:The Mintlify navbar (logo + search bar with ⌘K) scrolls away w… (#2445)
* bugfix:The Mintlify navbar (logo + search bar with ⌘K) scrolls away when scrolling down the documentation, so it disappears from view.

* fix(docs): keep navbar visible on scroll (#2445) (thanks @chenyuan99)

---------

Co-authored-by: vignesh07 <vigneshnatarajan92@gmail.com>
2026-01-26 17:39:10 -08:00
Gustavo Madeira Santana 2f7fff8dcd CLI: add changelog for versioned node argv (#2490) (thanks @David-Marsh-Photo) 2026-01-26 20:29:47 -05:00
Gustavo Madeira Santana 566c9982b3 CLI: expand versioned node argv handling 2026-01-26 20:29:47 -05:00
David Marsh c95072fc26 fix: support versioned node binaries (e.g., node-22)
Fedora and some other distros install Node.js with a version suffix
(e.g., /usr/bin/node-22) and create a symlink from /usr/bin/node.
When Node resolves process.execPath, it returns the real binary path,
not the symlink, causing buildParseArgv to fail the looksLikeNode check.

This adds executable.startsWith('node-') to handle versioned binaries.

Fixes #2442
2026-01-26 20:23:19 -05:00
Shadow 58b96ca0c0 CI: sync labels on PR updates 2026-01-26 19:21:31 -06:00
Shadow e0dc49f287 line: centralize webhook signature validation 2026-01-26 19:21:26 -06:00
Luka Zhang 3b8792ee29 Security: fix timing attack vulnerability in LINE webhook signature validation 2026-01-26 19:21:26 -06:00
Shadow 1e7cb23f00 Fix: avoid plugin registration on global help/version (#2212) (thanks @dial481) 2026-01-26 19:14:09 -06:00
Peter Steinberger 1506d493ea fix: switch Matrix plugin SDK 2026-01-27 01:00:23 +00:00
Gustavo Madeira Santana 0c855bd36a Infra: fix recoverable error formatting 2026-01-26 19:59:25 -05:00
Gustavo Madeira Santanaandtechboss b861a0bd73 Telegram: harden network retries and config
Co-authored-by: techboss <techboss@users.noreply.github.com>
2026-01-26 19:36:43 -05:00
techboss e43f4c0628 fix(telegram): handle network errors gracefully
- Add bot.catch() to prevent unhandled rejections from middleware
- Add isRecoverableNetworkError() to retry on transient failures
- Add maxRetryTime and exponential backoff to grammY runner
- Global unhandled rejection handler now logs recoverable errors
  instead of crashing (fetch failures, timeouts, connection resets)

Fixes crash loop when Telegram API is temporarily unreachable.
2026-01-26 19:36:43 -05:00
DominicandTyler Yust a8ad242f88 fix(security): properly test Windows ACL audit for config includes (#2403)
* fix(security): properly test Windows ACL audit for config includes

The test expected fs.config_include.perms_writable on Windows but
chmod 0o644 has no effect on Windows ACLs. Use icacls to grant
Everyone write access, which properly triggers the security check.

Also stubs execIcacls to return proper ACL output so the audit
can parse permissions without running actual icacls on the system.

Adds cleanup via try/finally to remove temp directory containing
world-writable test file.

Fixes checks-windows CI failure.

* test: isolate heartbeat runner tests from user workspace

* docs: update changelog for #2403

---------

Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM>
2026-01-26 16:27:53 -08:00
vignesh07 343882d45c feat(telegram): add edit message action (#2394) (thanks @marcelomar21) 2026-01-26 15:34:47 -08:00
Shadow 5c35b62a5c fix: refresh history key order for LRU eviction 2026-01-26 17:22:18 -06:00
Robby (AI-assisted) af9606de36 fix(history): add LRU eviction for groupHistories to prevent memory leak
Add evictOldHistoryKeys() function that removes oldest keys when the
history map exceeds MAX_HISTORY_KEYS (1000). Called automatically in
appendHistoryEntry() to bound memory growth.

The map previously grew unbounded as users interacted with more groups
over time. Growth is O(unique groups) not O(messages), but still causes
slow memory accumulation on long-running instances.

Fixes #2384
2026-01-26 17:22:18 -06:00
Robby (AI-assisted) 5aa02cf3f7 fix(gateway): sanitize error responses to prevent information disclosure
Replace raw error messages with generic 'Internal Server Error' to prevent
leaking internal error details to unauthenticated HTTP clients.

Fixes #2383
2026-01-26 17:22:13 -06:00
Shadow 91d5ea6e33 Fix: allow cron heartbeat payloads through filters (#2219) (thanks @dwfinkelstein)
# Conflicts:
#	CHANGELOG.md
2026-01-26 17:22:08 -06:00
Dave LauerandClaude Opus 4.5 82746973d4 fix(heartbeat): remove unhandled rejection crash in wake handler
The async setTimeout callback re-threw errors without a .catch() handler,
causing unhandled promise rejections that crashed the gateway. The error
is already logged by the heartbeat runner and a retry is scheduled, so
the re-throw served no purpose.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 17:19:48 -06:00
vignesh07 cd7be58b8e docs: add Northflank deploy guide to changelog (#2167) (thanks @AdeboyeDN) 2026-01-26 15:11:02 -08:00
Clawdbot Maintainers 107f07ad69 docs: add Northflank page to nav + polish copy 2026-01-26 15:11:02 -08:00
adeboyedn 99ce47e86a minor update 2026-01-26 15:11:02 -08:00
adeboyedn 2a709385f8 cleanup 2026-01-26 15:11:02 -08:00
adeboyedn 0aa48a26d1 docs: add Northflank deployment guide for Clawdbot 2026-01-26 15:11:02 -08:00
Peter Steinberger 6cbdd767af fix: pin tar override for npm installs 2026-01-26 22:58:14 +00:00
Dave Lauer 2807f5afbc feat: add heartbeat visibility filtering for webchat
- Add isHeartbeat to AgentRunContext to track heartbeat runs
- Pass isHeartbeat flag through agent runner execution
- Suppress webchat broadcast (deltas + final) for heartbeat runs when showOk is false
- Webchat uses channels.defaults.heartbeat settings (no per-channel config)
- Default behavior: hide HEARTBEAT_OK from webchat (matches other channels)

This allows users to control whether heartbeat responses appear in
the webchat UI via channels.defaults.heartbeat.showOk (defaults to false).
2026-01-26 14:52:23 -08:00
Peter Steinberger b3a60af71c fix: gate ngrok free-tier bypass to loopback 2026-01-26 22:26:26 +00:00
Tyler Yust fe1f2d971a fix: add multi-image input support to nano-banana-pro skill (#1958) (thanks @tyler6204) 2026-01-26 14:23:06 -08:00
Tyler Yust 3888f1edc6 docs: update SKILL.md and generate_image.py to support multi-image editing and improve input handling 2026-01-26 14:23:06 -08:00
Peter Steinberger 0f8f0fb9d7 docs: clarify command authorization for exec directives 2026-01-26 22:18:41 +00:00
Tyler Yust 9c0c5866db fix: coalesce BlueBubbles link previews (#1981) (thanks @tyler6204) 2026-01-26 14:12:22 -08:00
Tyler Yust 147842fadc refactor(bluebubbles): remove URL balloon message handling and improve error logging
This commit removes the URL balloon message handling logic from the monitor, simplifying the message processing flow. Additionally, it enhances error logging by including the account ID in the error messages for better traceability.
2026-01-26 14:12:22 -08:00
Tyler Yust 420e5299d2 fix(bluebubbles): increase inbound message debounce time for URL previews 2026-01-26 14:12:22 -08:00
Tyler Yust 6d26971051 fix(bluebubbles): add inbound message debouncing to coalesce URL link previews
When users send iMessages containing URLs, BlueBubbles sends separate
webhook events for the text message and the URL balloon/link preview.
This caused Clawdbot to receive them as separate queued messages.

This fix adds inbound debouncing (following the pattern from WhatsApp/MS Teams):

- Uses the existing createInboundDebouncer utility from plugin-sdk
- Adds debounceMs config option to BlueBubblesAccountConfig (default: 500ms)
- Routes inbound messages through debouncer before processing
- Combines messages from same sender/chat within the debounce window
- Handles URLBalloonProvider messages by coalescing with preceding text
- Skips debouncing for messages with attachments or control commands

Config example:
  channels.bluebubbles.debounceMs: 500  # milliseconds (0 to disable)

Fixes inbound URL message splitting issue.
2026-01-26 14:12:22 -08:00
Marchel Fahrezi 7307cfb5cb Merge branch 'clawdbot:main' into main 2026-01-25 20:37:11 +07:00
Alg0rix dd6bc5382d fix(msteams): correct typing indicator sendActivity call 2026-01-25 13:35:32 +00:00
151 changed files with 2736 additions and 508 deletions

No files matched your search

+1
View File
@@ -21,3 +21,4 @@ jobs:
with:
configuration-path: .github/labeler.yml
repo-token: ${{ steps.app-token.outputs.token }}
sync-labels: true
+16
View File
@@ -6,9 +6,12 @@ Docs: https://docs.clawd.bot
Status: unreleased.
### Changes
- Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204.
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
- Matrix: switch plugin SDK to @vector-im/matrix-bot-sdk.
- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido.
- Docs: add migration guide for moving to a new machine. (#2381)
- Docs: add Northflank one-click deployment guide. (#2167) Thanks @AdeboyeDN.
- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.
- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
@@ -36,12 +39,14 @@ Status: unreleased.
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
- Docs: keep docs header sticky so navbar stays visible while scrolling. (#2445) Thanks @chenyuan99.
- Security: use Windows ACLs for permission audits and fixes on Windows. (#1957)
- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.
- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
- Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma.
- Telegram: support editing sent messages via message(action="edit"). (#2394) Thanks @marcelomar21.
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
@@ -50,8 +55,19 @@ Status: unreleased.
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
### Fixes
- Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg.
- TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg.
- Security: pin npm overrides to keep tar@7.5.4 for install toolchains.
- Security: properly test Windows ACL audit for config includes. (#2403) Thanks @dominicnunez.
- CLI: recognize versioned Node executables when parsing argv. (#2490) Thanks @David-Marsh-Photo.
- BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204.
- Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein.
- CLI: avoid loading config for global help/version while registering plugin commands. (#2212) Thanks @dial481.
- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj.
- Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai.
- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss.
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
- Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne.
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
- Build: align memory-core peer dependency with lockfile.
+32 -31
View File
@@ -477,35 +477,36 @@ Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and
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/plum-dawg"><img src="https://avatars.githubusercontent.com/u/5909950?v=4&s=48" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></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/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></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/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></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/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/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/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></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/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/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/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/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/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/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/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/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></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/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></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/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/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/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></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/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></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/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></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/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></a>
<a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></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/joeynyc"><img src="https://avatars.githubusercontent.com/u/17919866?v=4&s=48" width="48" height="48" alt="joeynyc" title="joeynyc"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></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/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/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/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></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/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></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/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></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/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></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/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></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/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/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/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/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/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/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></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/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></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/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/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/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/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></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/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/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/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/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/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></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/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></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/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></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/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></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/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></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/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></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/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/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/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></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/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a>
<a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></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/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></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/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/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/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></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/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/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/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></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/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></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/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/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/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/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/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/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/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></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/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></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/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></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/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></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/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></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/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></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/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></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/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></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/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></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/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></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/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/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/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/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></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/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></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/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></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/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></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/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></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/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></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/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></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/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a>
<a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></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/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/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></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=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/mertcicekci0"><img src="https://avatars.githubusercontent.com/u/179321902?v=4&s=48" width="48" height="48" alt="mertcicekci0" title="mertcicekci0"/></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/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></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/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></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/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a> <a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></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/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></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/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></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/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a>
<a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></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/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></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/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></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/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></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/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></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/plum-dawg"><img src="https://avatars.githubusercontent.com/u/5909950?v=4&s=48" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></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/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></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/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></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/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/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/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></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/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/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/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/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/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/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/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/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></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/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></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/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/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/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></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/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></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/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></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/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></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/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="hirefrank" title="hirefrank"/></a> <a href="https://github.com/joeynyc"><img src="https://avatars.githubusercontent.com/u/17919866?v=4&s=48" width="48" height="48" alt="joeynyc" title="joeynyc"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></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/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/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/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></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/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></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/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></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/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></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/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></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/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/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/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/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/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/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/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/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></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/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="dominicnunez" title="dominicnunez"/></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/AdeboyeDN"><img src="https://avatars.githubusercontent.com/u/65312338?v=4&s=48" width="48" height="48" alt="AdeboyeDN" title="AdeboyeDN"/></a> <a href="https://github.com/Alg0rix"><img src="https://avatars.githubusercontent.com/u/53804949?v=4&s=48" width="48" height="48" alt="Alg0rix" title="Alg0rix"/></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/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/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></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/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/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/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/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/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></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/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></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/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></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/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></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/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></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/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/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></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/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/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/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></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/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a>
<a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></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/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></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/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></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/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/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/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></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/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/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/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></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/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></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/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/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/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/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/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/search?q=Joshua%20Mitchell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Joshua Mitchell" title="Joshua Mitchell"/></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/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></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/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></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/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></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/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></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/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></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/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></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/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></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/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></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/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></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/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></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/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></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/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/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/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/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></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/search?q=Pocket%20Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pocket Clawd" title="Pocket Clawd"/></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/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></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/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></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/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></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/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a> <a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></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/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></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/chenyuan99"><img src="https://avatars.githubusercontent.com/u/25518100?v=4&s=48" width="48" height="48" alt="chenyuan99" title="chenyuan99"/></a> <a href="https://github.com/search?q=Clawdbot%20Maintainers"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawdbot Maintainers" title="Clawdbot Maintainers"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/David-Marsh-Photo"><img src="https://avatars.githubusercontent.com/u/228404527?v=4&s=48" width="48" height="48" alt="David-Marsh-Photo" title="David-Marsh-Photo"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></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/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></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=Jane"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jane" title="Jane"/></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/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></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/Kiwitwitter"><img src="https://avatars.githubusercontent.com/u/25277769?v=4&s=48" width="48" height="48" alt="Kiwitwitter" title="Kiwitwitter"/></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/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/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></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=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/mertcicekci0"><img src="https://avatars.githubusercontent.com/u/179321902?v=4&s=48" width="48" height="48" alt="mertcicekci0" title="mertcicekci0"/></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/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/ppamment"><img src="https://avatars.githubusercontent.com/u/2122919?v=4&s=48" width="48" height="48" alt="ppamment" title="ppamment"/></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/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></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/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a>
<a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></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/Suksham-sharma"><img src="https://avatars.githubusercontent.com/u/94667656?v=4&s=48" width="48" height="48" alt="Suksham-sharma" title="Suksham-sharma"/></a> <a href="https://github.com/search?q=techboss"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="techboss" title="techboss"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></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/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/search?q=wolfred"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="wolfred" title="wolfred"/></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/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></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/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></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/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></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/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></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/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></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>
+3
View File
@@ -115,6 +115,9 @@ body::after {
}
.shell {
position: sticky;
top: 0;
z-index: 100;
padding: 22px 16px 10px;
}
+1 -1
View File
@@ -10,7 +10,7 @@ on any homeserver, so you need a Matrix account for the bot. Once it is logged i
the bot directly or invite it to rooms (Matrix "groups"). Beeper is a valid client option too,
but it requires E2EE to be enabled.
Status: supported via plugin (matrix-bot-sdk). Direct messages, rooms, threads, media, reactions,
Status: supported via plugin (@vector-im/matrix-bot-sdk). Direct messages, rooms, threads, media, reactions,
polls (send + poll-start as text), location, and E2EE (with crypto support).
## Plugin required
+1
View File
@@ -529,6 +529,7 @@ Provider options:
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to disabled on Node 22 to avoid Happy Eyeballs timeouts.
- `channels.telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP).
- `channels.telegram.webhookUrl`: enable webhook mode.
- `channels.telegram.webhookSecret`: webhook secret (optional).
-4
View File
@@ -345,10 +345,6 @@
"source": "/auth-monitoring",
"destination": "/automation/auth-monitoring"
},
{
"source": "/scripts",
"destination": "/scripts"
},
{
"source": "/camera",
"destination": "/nodes/camera"
+5
View File
@@ -954,6 +954,8 @@ Notes:
- `commands.debug: true` enables `/debug` (runtime-only overrides).
- `commands.restart: true` enables `/restart` and the gateway tool restart action.
- `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies.
- Slash commands and directives are only honored for **authorized senders**. Authorization is derived from
channel allowlists/pairing plus `commands.useAccessGroups`.
### `web` (WhatsApp web channel runtime)
@@ -1027,6 +1029,9 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w
maxDelayMs: 30000,
jitter: 0.1
},
network: { // transport overrides
autoSelectFamily: false
},
proxy: "socks5://localhost:9050",
webhookUrl: "https://example.com/telegram-webhook",
webhookSecret: "secret",
@@ -59,6 +59,8 @@ Two layers matter:
Rules of thumb:
- `deny` always wins.
- If `allow` is non-empty, everything else is treated as blocked.
- Tool policy is the hard stop: `/exec` cannot override a denied `exec` tool.
- `/exec` only changes session defaults for authorized senders; it does not grant tool access.
Provider tool keys accept either `provider` (e.g. `google-antigravity`) or `provider/model` (e.g. `openai/gpt-5.2`).
### Tool groups (shorthands)
@@ -95,6 +97,7 @@ Elevated does **not** grant extra tools; it only affects `exec`.
- Use `/elevated full` to skip exec approvals for the session.
- If you’re already running direct, elevated is effectively a no-op (still gated).
- Elevated is **not** skill-scoped and does **not** override tool allow/deny.
- `/exec` is separate from elevated. It only adjusts per-session exec defaults for authorized senders.
Gates:
- Enablement: `tools.elevated.enabled` (and optionally `agents.list[].tools.elevated.enabled`)
+2
View File
@@ -142,6 +142,8 @@ Tool allow/deny policies still apply before sandbox rules. If a tool is denied
globally or per-agent, sandboxing doesn’t bring it back.
`tools.elevated` is an explicit escape hatch that runs `exec` on the host.
`/exec` directives only apply for authorized senders and persist per session; to hard-disable
`exec`, use tool policy deny (see [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated)).
Debugging:
- Use `clawdbot sandbox explain` to inspect effective sandbox mode, tool policy, and fix-it config keys.
+10
View File
@@ -142,6 +142,16 @@ Clawdbot’s stance:
- **Scope next:** decide where the bot is allowed to act (group allowlists + mention gating, tools, sandboxing, device permissions).
- **Model last:** assume the model can be manipulated; design so manipulation has limited blast radius.
## Command authorization model
Slash commands and directives are only honored for **authorized senders**. Authorization is derived from
channel allowlists/pairing plus `commands.useAccessGroups` (see [Configuration](/gateway/configuration)
and [Slash commands](/tools/slash-commands)). If a channel allowlist is empty or includes `"*"`,
commands are effectively open for that channel.
`/exec` is a session-only convenience for authorized operators. It does **not** write config or
change other sessions.
## Plugins/extensions
Plugins run **in-process** with the Gateway. Treat them as trusted code:
+4 -3
View File
@@ -1,9 +1,10 @@
---
title: "Node.js + npm (PATH sanity)"
summary: "Node.js + npm install sanity: versions, PATH, and global installs"
read_when:
- You installed Clawdbot but `clawdbot` is “command not found”
- You’re setting up Node.js/npm on a new machine
- `npm install -g ...` fails with permissions or PATH issues
- "You installed Clawdbot but `clawdbot` is “command not found”"
- "You’re setting up Node.js/npm on a new machine"
- "npm install -g ... fails with permissions or PATH issues"
---
# Node.js + npm (PATH sanity)
+1
View File
@@ -104,6 +104,7 @@ Notes:
- `mock` is a local dev provider (no network calls).
- `skipSignatureVerification` is for local testing only.
- If you use ngrok free tier, set `publicUrl` to the exact ngrok URL; signature verification is always enforced.
- `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only.
- Ngrok free tier URLs can change or add interstitial behavior; if `publicUrl` drifts, Twilio signatures will fail. For production, prefer a stable domain or Tailscale funnel.
## TTS for calls
+1
View File
@@ -23,6 +23,7 @@ read_when:
- **Approvals**: `full` skips exec approvals; `on`/`ask` honor them when allowlist/ask rules require.
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
- **Separate from `/exec`**: `/exec` adjusts per-session defaults for authorized senders and does not require elevated.
## Resolution order
1. Inline directive on the message (applies only to that message).
+3
View File
@@ -216,6 +216,9 @@ Approval-gated execs reuse the approval id as the `runId` in these messages for
- **full** is powerful; prefer allowlists when possible.
- **ask** keeps you in the loop while still allowing fast approvals.
- Per-agent allowlists prevent one agent’s approvals from leaking into others.
- Approvals only apply to host exec requests from **authorized senders**. Unauthorized senders cannot issue `/exec`.
- `/exec security=full` is a session-level convenience for authorized operators and skips approvals by design.
To hard-block host exec, set approvals security to `deny` or deny the `exec` tool via tool policy.
Related:
- [Exec tool](/tools/exec)
+7
View File
@@ -91,6 +91,13 @@ Example:
/exec host=gateway security=allowlist ask=on-miss node=mac-1
```
## Authorization model
`/exec` is only honored for **authorized senders** (channel allowlists/pairing plus `commands.useAccessGroups`).
It updates **session state only** and does not write config. To hard-disable exec, deny it via tool
policy (`tools.deny: ["exec"]` or per-agent). Host approvals still apply unless you explicitly set
`security=full` and `ask=off`.
## Exec approvals (companion app / node host)
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
+2
View File
@@ -16,6 +16,8 @@ There are two related systems:
- Directives are stripped from the message before the model sees it.
- In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings.
- In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement.
- Directives are only applied for **authorized senders** (channel allowlists/pairing plus `commands.useAccessGroups`).
Unauthorized senders see directives treated as plain text.
There are also a few **inline shortcuts** (allowlisted/authorized senders only): `/help`, `/commands`, `/status`, `/whoami` (`/id`).
They run immediately, are stripped before the model sees the message, and the remaining text continues through the normal flow.
+2
View File
@@ -11,6 +11,8 @@ deployments work at a high level.
## Pick a provider
- **Railway** (one‑click + browser setup): [Railway](/railway)
- **Northflank** (one‑click + browser setup): [Northflank](/northflank)
- **Oracle Cloud (Always Free)**: [Oracle](/platforms/oracle) — $0/month (Always Free, ARM; capacity/signup can be finicky)
- **Fly.io**: [Fly.io](/platforms/fly)
- **Hetzner (Docker)**: [Hetzner](/platforms/hetzner)
+8 -2
View File
@@ -146,8 +146,14 @@ function createMockRuntime(): PluginRuntime {
resolveRequireMention: mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
},
debounce: {
createInboundDebouncer: vi.fn() as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"],
resolveInboundDebounceMs: vi.fn() as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"],
// Create a pass-through debouncer that immediately calls onFlush
createInboundDebouncer: vi.fn((params: { onFlush: (items: unknown[]) => Promise<void> }) => ({
enqueue: async (item: unknown) => {
await params.onFlush([item]);
},
flushKey: vi.fn(),
})) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"],
resolveInboundDebounceMs: vi.fn(() => 0) as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"],
},
commands: {
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"],
+176 -1
View File
@@ -250,8 +250,178 @@ type WebhookTarget = {
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
/**
* Entry type for debouncing inbound messages.
* Captures the normalized message and its target for later combined processing.
*/
type BlueBubblesDebounceEntry = {
message: NormalizedWebhookMessage;
target: WebhookTarget;
};
/**
* Default debounce window for inbound message coalescing (ms).
* This helps combine URL text + link preview balloon messages that BlueBubbles
* sends as separate webhook events when no explicit inbound debounce config exists.
*/
const DEFAULT_INBOUND_DEBOUNCE_MS = 350;
/**
* Combines multiple debounced messages into a single message for processing.
* Used when multiple webhook events arrive within the debounce window.
*/
function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): NormalizedWebhookMessage {
if (entries.length === 0) {
throw new Error("Cannot combine empty entries");
}
if (entries.length === 1) {
return entries[0].message;
}
// Use the first message as the base (typically the text message)
const first = entries[0].message;
// Combine text from all entries, filtering out duplicates and empty strings
const seenTexts = new Set<string>();
const textParts: string[] = [];
for (const entry of entries) {
const text = entry.message.text.trim();
if (!text) continue;
// Skip duplicate text (URL might be in both text message and balloon)
const normalizedText = text.toLowerCase();
if (seenTexts.has(normalizedText)) continue;
seenTexts.add(normalizedText);
textParts.push(text);
}
// Merge attachments from all entries
const allAttachments = entries.flatMap((e) => e.message.attachments ?? []);
// Use the latest timestamp
const timestamps = entries
.map((e) => e.message.timestamp)
.filter((t): t is number => typeof t === "number");
const latestTimestamp = timestamps.length > 0 ? Math.max(...timestamps) : first.timestamp;
// Collect all message IDs for reference
const messageIds = entries
.map((e) => e.message.messageId)
.filter((id): id is string => Boolean(id));
// Prefer reply context from any entry that has it
const entryWithReply = entries.find((e) => e.message.replyToId);
return {
...first,
text: textParts.join(" "),
attachments: allAttachments.length > 0 ? allAttachments : first.attachments,
timestamp: latestTimestamp,
// Use first message's ID as primary (for reply reference), but we've coalesced others
messageId: messageIds[0] ?? first.messageId,
// Preserve reply context if present
replyToId: entryWithReply?.message.replyToId ?? first.replyToId,
replyToBody: entryWithReply?.message.replyToBody ?? first.replyToBody,
replyToSender: entryWithReply?.message.replyToSender ?? first.replyToSender,
// Clear balloonBundleId since we've combined (the combined message is no longer just a balloon)
balloonBundleId: undefined,
};
}
const webhookTargets = new Map<string, WebhookTarget[]>();
/**
* Maps webhook targets to their inbound debouncers.
* Each target gets its own debouncer keyed by a unique identifier.
*/
const targetDebouncers = new Map<
WebhookTarget,
ReturnType<BlueBubblesCoreRuntime["channel"]["debounce"]["createInboundDebouncer"]>
>();
function resolveBlueBubblesDebounceMs(
config: ClawdbotConfig,
core: BlueBubblesCoreRuntime,
): number {
const inbound = config.messages?.inbound;
const hasExplicitDebounce =
typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number";
if (!hasExplicitDebounce) return DEFAULT_INBOUND_DEBOUNCE_MS;
return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" });
}
/**
* Creates or retrieves a debouncer for a webhook target.
*/
function getOrCreateDebouncer(target: WebhookTarget) {
const existing = targetDebouncers.get(target);
if (existing) return existing;
const { account, config, runtime, core } = target;
const debouncer = core.channel.debounce.createInboundDebouncer<BlueBubblesDebounceEntry>({
debounceMs: resolveBlueBubblesDebounceMs(config, core),
buildKey: (entry) => {
const msg = entry.message;
// Build key from account + chat + sender to coalesce messages from same source
const chatKey =
msg.chatGuid?.trim() ??
msg.chatIdentifier?.trim() ??
(msg.chatId ? String(msg.chatId) : "dm");
return `bluebubbles:${account.accountId}:${chatKey}:${msg.senderId}`;
},
shouldDebounce: (entry) => {
const msg = entry.message;
// Skip debouncing for messages with attachments - process immediately
if (msg.attachments && msg.attachments.length > 0) return false;
// Skip debouncing for from-me messages (they're just cached, not processed)
if (msg.fromMe) return false;
// Skip debouncing for control commands - process immediately
if (core.channel.text.hasControlCommand(msg.text, config)) return false;
// Debounce normal text messages and URL balloon messages
return true;
},
onFlush: async (entries) => {
if (entries.length === 0) return;
// Use target from first entry (all entries have same target due to key structure)
const flushTarget = entries[0].target;
if (entries.length === 1) {
// Single message - process normally
await processMessage(entries[0].message, flushTarget);
return;
}
// Multiple messages - combine and process
const combined = combineDebounceEntries(entries);
if (core.logging.shouldLogVerbose()) {
const count = entries.length;
const preview = combined.text.slice(0, 50);
runtime.log?.(
`[bluebubbles] coalesced ${count} messages: "${preview}${combined.text.length > 50 ? "..." : ""}"`,
);
}
await processMessage(combined, flushTarget);
},
onError: (err) => {
runtime.error?.(`[${account.accountId}] [bluebubbles] debounce flush failed: ${String(err)}`);
},
});
targetDebouncers.set(target, debouncer);
return debouncer;
}
/**
* Removes a debouncer for a target (called during unregistration).
*/
function removeDebouncer(target: WebhookTarget): void {
targetDebouncers.delete(target);
}
function normalizeWebhookPath(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return "/";
@@ -275,6 +445,8 @@ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => v
} else {
webhookTargets.delete(key);
}
// Clean up debouncer when target is unregistered
removeDebouncer(normalizedTarget);
};
}
@@ -1205,7 +1377,10 @@ export async function handleBlueBubblesWebhookRequest(
);
});
} else if (message) {
processMessage(message, target).catch((err) => {
// Route messages through debouncer to coalesce rapid-fire events
// (e.g., text message + URL balloon arriving as separate webhooks)
const debouncer = getOrCreateDebouncer(target);
debouncer.enqueue({ message, target }).catch((err) => {
target.runtime.error?.(
`[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
);
+1 -1
View File
@@ -26,7 +26,7 @@
"dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
"markdown-it": "14.1.0",
"matrix-bot-sdk": "0.8.0",
"@vector-im/matrix-bot-sdk": "0.8.0-element.3",
"music-metadata": "^11.10.6",
"zod": "^4.3.6"
},
@@ -95,7 +95,7 @@ export async function readMatrixMessages(
: 20;
const token = opts.before?.trim() || opts.after?.trim() || undefined;
const dir = opts.after ? "f" : "b";
// matrix-bot-sdk uses doRequest for room messages
// @vector-im/matrix-bot-sdk uses doRequest for room messages
const res = await client.doRequest(
"GET",
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,
@@ -21,7 +21,7 @@ export async function listMatrixReactions(
typeof opts.limit === "number" && Number.isFinite(opts.limit)
? Math.max(1, Math.floor(opts.limit))
: 100;
// matrix-bot-sdk uses doRequest for relations
// @vector-im/matrix-bot-sdk uses doRequest for relations
const res = await client.doRequest(
"GET",
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
+3 -3
View File
@@ -9,9 +9,9 @@ export async function getMatrixMemberInfo(
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
// matrix-bot-sdk uses getUserProfile
// @vector-im/matrix-bot-sdk uses getUserProfile
const profile = await client.getUserProfile(userId);
// Note: matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk
// Note: @vector-im/matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk
// We'd need to fetch room state separately if needed
return {
userId,
@@ -36,7 +36,7 @@ export async function getMatrixRoomInfo(
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
// matrix-bot-sdk uses getRoomState for state events
// @vector-im/matrix-bot-sdk uses getRoomState for state events
let name: string | null = null;
let topic: string | null = null;
let canonicalAlias: string | null = null;
@@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk";
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import {
EventType,
@@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk";
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
export const MsgType = {
Text: "m.text",
@@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk";
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
let activeClient: MatrixClient | null = null;
@@ -1,4 +1,4 @@
import { MatrixClient } from "matrix-bot-sdk";
import { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { CoreConfig } from "../types.js";
import { getMatrixRuntime } from "../../runtime.js";
@@ -5,8 +5,8 @@ import {
MatrixClient,
SimpleFsStorageProvider,
RustSdkCryptoStorageProvider,
} from "matrix-bot-sdk";
import type { IStorageProvider, ICryptoStorageProvider } from "matrix-bot-sdk";
} from "@vector-im/matrix-bot-sdk";
import type { IStorageProvider, ICryptoStorageProvider } from "@vector-im/matrix-bot-sdk";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import {
@@ -1,4 +1,4 @@
import { ConsoleLogger, LogService } from "matrix-bot-sdk";
import { ConsoleLogger, LogService } from "@vector-im/matrix-bot-sdk";
let matrixSdkLoggingConfigured = false;
const matrixSdkBaseLogger = new ConsoleLogger();
@@ -1,5 +1,5 @@
import { LogService } from "matrix-bot-sdk";
import type { MatrixClient } from "matrix-bot-sdk";
import { LogService } from "@vector-im/matrix-bot-sdk";
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { CoreConfig } from "../types.js";
import { createMatrixClient } from "./create-client.js";
@@ -157,7 +157,7 @@ export async function waitForMatrixSync(_params: {
timeoutMs?: number;
abortSignal?: AbortSignal;
}): Promise<void> {
// matrix-bot-sdk handles sync internally in start()
// @vector-im/matrix-bot-sdk handles sync internally in start()
// This is kept for API compatibility but is essentially a no-op now
}
+4 -4
View File
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js";
const MATRIX_SDK_PACKAGE = "matrix-bot-sdk";
const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk";
export function isMatrixSdkAvailable(): boolean {
try {
@@ -30,9 +30,9 @@ export async function ensureMatrixSdkInstalled(params: {
if (isMatrixSdkAvailable()) return;
const confirm = params.confirm;
if (confirm) {
const ok = await confirm("Matrix requires matrix-bot-sdk. Install now?");
const ok = await confirm("Matrix requires @vector-im/matrix-bot-sdk. Install now?");
if (!ok) {
throw new Error("Matrix requires matrix-bot-sdk (install dependencies first).");
throw new Error("Matrix requires @vector-im/matrix-bot-sdk (install dependencies first).");
}
}
@@ -52,6 +52,6 @@ export async function ensureMatrixSdkInstalled(params: {
);
}
if (!isMatrixSdkAvailable()) {
throw new Error("Matrix dependency install completed but matrix-bot-sdk is still missing.");
throw new Error("Matrix dependency install completed but @vector-im/matrix-bot-sdk is still missing.");
}
}
@@ -1,5 +1,5 @@
import type { MatrixClient } from "matrix-bot-sdk";
import { AutojoinRoomsMixin } from "matrix-bot-sdk";
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { AutojoinRoomsMixin } from "@vector-im/matrix-bot-sdk";
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import type { CoreConfig } from "../../types.js";
@@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk";
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
type DirectMessageCheck = {
roomId: string;
@@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk";
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
import type { MatrixAuth } from "../client.js";
@@ -1,4 +1,4 @@
import type { LocationMessageEventContent, MatrixClient } from "matrix-bot-sdk";
import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk";
import {
createReplyPrefixContext,
@@ -110,7 +110,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
try {
const eventType = event.type;
if (eventType === EventType.RoomMessageEncrypted) {
// Encrypted messages are decrypted automatically by matrix-bot-sdk with crypto enabled
// Encrypted messages are decrypted automatically by @vector-im/matrix-bot-sdk with crypto enabled
return;
}
@@ -436,7 +436,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
threadReplies,
messageId,
threadRootId,
isThreadRoot: false, // matrix-bot-sdk doesn't have this info readily available
isThreadRoot: false, // @vector-im/matrix-bot-sdk doesn't have this info readily available
});
const route = core.channel.routing.resolveAgentRoute({
@@ -244,7 +244,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
});
logVerboseMessage("matrix: client started");
// matrix-bot-sdk client is already started via resolveSharedMatrixClient
// @vector-im/matrix-bot-sdk client is already started via resolveSharedMatrixClient
logger.info(`matrix: logged in as ${auth.userId}`);
// If E2EE is enabled, trigger device verification
@@ -1,4 +1,4 @@
import type { LocationMessageEventContent } from "matrix-bot-sdk";
import type { LocationMessageEventContent } from "@vector-im/matrix-bot-sdk";
import {
formatLocationText,
@@ -29,7 +29,7 @@ describe("downloadMatrixMedia", () => {
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("matrix-bot-sdk").MatrixClient;
} as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
const file = {
url: "mxc://example/file",
@@ -70,7 +70,7 @@ describe("downloadMatrixMedia", () => {
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("matrix-bot-sdk").MatrixClient;
} as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
const file = {
url: "mxc://example/file",
@@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk";
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { getMatrixRuntime } from "../../runtime.js";
@@ -22,7 +22,7 @@ async function fetchMatrixMediaBuffer(params: {
mxcUrl: string;
maxBytes: number;
}): Promise<{ buffer: Buffer; headerType?: string } | null> {
// matrix-bot-sdk provides mxcToHttp helper
// @vector-im/matrix-bot-sdk provides mxcToHttp helper
const url = params.client.mxcToHttp(params.mxcUrl);
if (!url) return null;
@@ -40,7 +40,7 @@ async function fetchMatrixMediaBuffer(params: {
/**
* Download and decrypt encrypted media from a Matrix room.
* Uses matrix-bot-sdk's decryptMedia which handles both download and decryption.
* Uses @vector-im/matrix-bot-sdk's decryptMedia which handles both download and decryption.
*/
async function fetchEncryptedMediaBuffer(params: {
client: MatrixClient;
@@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk";
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
import { sendMessageMatrix } from "../send.js";
@@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk";
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
export type MatrixRoomInfo = {
name?: string;
@@ -1,4 +1,4 @@
// Type for raw Matrix event from matrix-bot-sdk
// Type for raw Matrix event from @vector-im/matrix-bot-sdk
type MatrixRawEvent = {
event_id: string;
sender: string;
@@ -1,4 +1,4 @@
import type { EncryptedFile, MessageEventContent } from "matrix-bot-sdk";
import type { EncryptedFile, MessageEventContent } from "@vector-im/matrix-bot-sdk";
export const EventType = {
RoomMessage: "m.room.message",
+1 -1
View File
@@ -49,7 +49,7 @@ export async function probeMatrix(params: {
accessToken: params.accessToken,
localTimeoutMs: params.timeoutMs,
});
// matrix-bot-sdk uses getUserId() which calls whoami internally
// @vector-im/matrix-bot-sdk uses getUserId() which calls whoami internally
const userId = await client.getUserId();
result.ok = true;
result.userId = userId ?? null;
+2 -2
View File
@@ -3,7 +3,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
import { setMatrixRuntime } from "../runtime.js";
vi.mock("matrix-bot-sdk", () => ({
vi.mock("@vector-im/matrix-bot-sdk", () => ({
ConsoleLogger: class {
trace = vi.fn();
debug = vi.fn();
@@ -60,7 +60,7 @@ const makeClient = () => {
sendMessage,
uploadContent,
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
} as unknown as import("matrix-bot-sdk").MatrixClient;
} as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
return { client, sendMessage, uploadContent };
};
+3 -3
View File
@@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk";
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { PollInput } from "clawdbot/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js";
@@ -72,7 +72,7 @@ export async function sendMessageMatrix(
? buildThreadRelation(threadId, opts.replyToId)
: buildReplyRelation(opts.replyToId);
const sendContent = async (content: MatrixOutboundContent) => {
// matrix-bot-sdk uses sendMessage differently
// @vector-im/matrix-bot-sdk uses sendMessage differently
const eventId = await client.sendMessage(roomId, content);
return eventId;
};
@@ -172,7 +172,7 @@ export async function sendPollMatrix(
const pollPayload = threadId
? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) }
: pollContent;
// matrix-bot-sdk sendEvent returns eventId string directly
// @vector-im/matrix-bot-sdk sendEvent returns eventId string directly
const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload);
return {
+2 -2
View File
@@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk";
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { getMatrixRuntime } from "../../runtime.js";
import { getActiveMatrixClient } from "../active-client.js";
@@ -57,7 +57,7 @@ export async function resolveMatrixClient(opts: {
// Ignore crypto prep failures for one-off sends; normal sync will retry.
}
}
// matrix-bot-sdk uses start() instead of startClient()
// @vector-im/matrix-bot-sdk uses start() instead of startClient()
await client.start();
return { client, stopOnDone: true };
}
+1 -1
View File
@@ -5,7 +5,7 @@ import type {
MatrixClient,
TimedFileInfo,
VideoFileInfo,
} from "matrix-bot-sdk";
} from "@vector-im/matrix-bot-sdk";
import { parseBuffer, type IFileInfo } from "music-metadata";
import { getMatrixRuntime } from "../../runtime.js";
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "matrix-bot-sdk";
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { EventType } from "./types.js";
let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId;
+1 -1
View File
@@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk";
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { EventType, type MatrixDirectAccountData } from "./types.js";
+2 -2
View File
@@ -6,7 +6,7 @@ import type {
TextualMessageEventContent,
TimedFileInfo,
VideoFileInfo,
} from "matrix-bot-sdk";
} from "@vector-im/matrix-bot-sdk";
// Message types
export const MsgType = {
@@ -85,7 +85,7 @@ export type MatrixSendResult = {
};
export type MatrixSendOpts = {
client?: import("matrix-bot-sdk").MatrixClient;
client?: import("@vector-im/matrix-bot-sdk").MatrixClient;
mediaUrl?: string;
accountId?: string;
replyToId?: string;
+1 -1
View File
@@ -185,7 +185,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
`Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`,
],
selectionHint: !sdkReady
? "install matrix-bot-sdk"
? "install @vector-im/matrix-bot-sdk"
: configured
? "configured"
: "needs auth",
+1 -1
View File
@@ -53,7 +53,7 @@ export type MatrixConfig = {
password?: string;
/** Optional device name when logging in via password. */
deviceName?: string;
/** Initial sync limit for startup (default: matrix-bot-sdk default). */
/** Initial sync limit for startup (default: @vector-im/matrix-bot-sdk default). */
initialSyncLimit?: number;
/** Enable end-to-end encryption (E2EE). Default: false. */
encryption?: boolean;
+1 -1
View File
@@ -9,6 +9,6 @@
]
},
"peerDependencies": {
"clawdbot": ">=2026.1.25"
"clawdbot": ">=2026.1.24-3"
}
}
+33 -33
View File
@@ -42,7 +42,7 @@ export function createMSTeamsReplyDispatcher(params: {
}) {
const core = getMSTeamsRuntime();
const sendTypingIndicator = async () => {
await params.context.sendActivities([{ type: "typing" }]);
await params.context.sendActivity({ type: "typing" });
};
const typingCallbacks = createTypingCallbacks({
start: sendTypingIndicator,
@@ -70,38 +70,38 @@ export function createMSTeamsReplyDispatcher(params: {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: params.cfg,
channel: "msteams",
});
const messages = renderReplyPayloadsToMessages([payload], {
textChunkLimit: params.textLimit,
chunkText: true,
mediaMode: "split",
tableMode,
chunkMode,
});
const mediaMaxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg,
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
});
const ids = await sendMSTeamsMessages({
replyStyle: params.replyStyle,
adapter: params.adapter,
appId: params.appId,
conversationRef: params.conversationRef,
context: params.context,
messages,
// Enable default retry/backoff for throttling/transient failures.
retry: {},
onRetry: (event) => {
params.log.debug("retrying send", {
replyStyle: params.replyStyle,
...event,
});
},
tokenProvider: params.tokenProvider,
sharePointSiteId: params.sharePointSiteId,
mediaMaxBytes,
});
if (ids.length > 0) params.onSentMessageIds?.(ids);
});
const messages = renderReplyPayloadsToMessages([payload], {
textChunkLimit: params.textLimit,
chunkText: true,
mediaMode: "split",
tableMode,
chunkMode,
});
const mediaMaxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg,
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
});
const ids = await sendMSTeamsMessages({
replyStyle: params.replyStyle,
adapter: params.adapter,
appId: params.appId,
conversationRef: params.conversationRef,
context: params.context,
messages,
// Enable default retry/backoff for throttling/transient failures.
retry: {},
onRetry: (event) => {
params.log.debug("retrying send", {
replyStyle: params.replyStyle,
...event,
});
},
tokenProvider: params.tokenProvider,
sharePointSiteId: params.sharePointSiteId,
mediaMaxBytes,
});
if (ids.length > 0) params.onSentMessageIds?.(ids);
},
onError: (err, info) => {
const errMsg = formatUnknownError(err);
+1
View File
@@ -6,6 +6,7 @@
- Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core).
- Telephony TTS supports OpenAI + ElevenLabs; Edge TTS is ignored for calls.
- Removed legacy `tts.model`/`tts.voice`/`tts.instructions` plugin fields.
- Ngrok free-tier bypass renamed to `tunnel.allowNgrokFreeTierLoopbackBypass` and gated to loopback + `tunnel.provider="ngrok"`.
## 2026.1.23
+1
View File
@@ -74,6 +74,7 @@ Put under `plugins.entries.voice-call.config`:
Notes:
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
- `mock` is a local dev provider (no network calls).
- `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only.
## TTS for calls
+3 -3
View File
@@ -78,8 +78,8 @@
"label": "ngrok Domain",
"advanced": true
},
"tunnel.allowNgrokFreeTier": {
"label": "Allow ngrok Free Tier",
"tunnel.allowNgrokFreeTierLoopbackBypass": {
"label": "Allow ngrok Free Tier (Loopback Bypass)",
"advanced": true
},
"streaming.enabled": {
@@ -330,7 +330,7 @@
"ngrokDomain": {
"type": "string"
},
"allowNgrokFreeTier": {
"allowNgrokFreeTierLoopbackBypass": {
"type": "boolean"
}
}
+2 -2
View File
@@ -62,8 +62,8 @@ const voiceCallConfigSchema = {
advanced: true,
},
"tunnel.ngrokDomain": { label: "ngrok Domain", advanced: true },
"tunnel.allowNgrokFreeTier": {
label: "Allow ngrok Free Tier",
"tunnel.allowNgrokFreeTierLoopbackBypass": {
label: "Allow ngrok Free Tier (Loopback Bypass)",
advanced: true,
},
"streaming.enabled": { label: "Enable Streaming", advanced: true },
+1 -1
View File
@@ -19,7 +19,7 @@ function createBaseConfig(
maxConcurrentCalls: 1,
serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
tailscale: { mode: "off", path: "/voice/webhook" },
tunnel: { provider: "none", allowNgrokFreeTier: false },
tunnel: { provider: "none", allowNgrokFreeTierLoopbackBypass: false },
streaming: {
enabled: false,
sttProvider: "openai-realtime",
+13 -4
View File
@@ -217,12 +217,17 @@ export const VoiceCallTunnelConfigSchema = z
/**
* Allow ngrok free tier compatibility mode.
* When true, signature verification failures on ngrok-free.app URLs
* will include extra diagnostics. Signature verification is still required.
* will be allowed only for loopback requests (ngrok local agent).
*/
allowNgrokFreeTier: z.boolean().default(false),
allowNgrokFreeTierLoopbackBypass: z.boolean().default(false),
/**
* Legacy ngrok free tier compatibility mode (deprecated).
* Use allowNgrokFreeTierLoopbackBypass instead.
*/
allowNgrokFreeTier: z.boolean().optional(),
})
.strict()
.default({ provider: "none", allowNgrokFreeTier: false });
.default({ provider: "none", allowNgrokFreeTierLoopbackBypass: false });
export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>;
// -----------------------------------------------------------------------------
@@ -419,8 +424,12 @@ export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig
// Tunnel Config
resolved.tunnel = resolved.tunnel ?? {
provider: "none",
allowNgrokFreeTier: false,
allowNgrokFreeTierLoopbackBypass: false,
};
resolved.tunnel.allowNgrokFreeTierLoopbackBypass =
resolved.tunnel.allowNgrokFreeTierLoopbackBypass ||
resolved.tunnel.allowNgrokFreeTier ||
false;
resolved.tunnel.ngrokAuthToken =
resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN;
resolved.tunnel.ngrokDomain =
@@ -31,8 +31,8 @@ import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
* @see https://www.twilio.com/docs/voice/media-streams
*/
export interface TwilioProviderOptions {
/** Allow ngrok free tier compatibility mode (less secure) */
allowNgrokFreeTier?: boolean;
/** Allow ngrok free tier compatibility mode (loopback only, less secure) */
allowNgrokFreeTierLoopbackBypass?: boolean;
/** Override public URL for signature verification */
publicUrl?: string;
/** Path for media stream WebSocket (e.g., /voice/stream) */
@@ -11,7 +11,8 @@ export function verifyTwilioProviderWebhook(params: {
}): WebhookVerificationResult {
const result = verifyTwilioWebhook(params.ctx, params.authToken, {
publicUrl: params.currentPublicUrl || undefined,
allowNgrokFreeTier: params.options.allowNgrokFreeTier ?? false,
allowNgrokFreeTierLoopbackBypass:
params.options.allowNgrokFreeTierLoopbackBypass ?? false,
skipVerification: params.options.skipVerification,
});
+13 -1
View File
@@ -33,7 +33,19 @@ type Logger = {
debug: (message: string) => void;
};
function isLoopbackBind(bind: string | undefined): boolean {
if (!bind) return false;
return bind === "127.0.0.1" || bind === "::1" || bind === "localhost";
}
function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
const allowNgrokFreeTierLoopbackBypass =
config.tunnel?.provider === "ngrok" &&
isLoopbackBind(config.serve?.bind) &&
(config.tunnel?.allowNgrokFreeTierLoopbackBypass ||
config.tunnel?.allowNgrokFreeTier ||
false);
switch (config.provider) {
case "telnyx":
return new TelnyxProvider({
@@ -48,7 +60,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
authToken: config.twilio?.authToken,
},
{
allowNgrokFreeTier: config.tunnel?.allowNgrokFreeTier ?? false,
allowNgrokFreeTierLoopbackBypass,
publicUrl: config.publicUrl,
skipVerification: config.skipSignatureVerification,
streamPath: config.streaming?.enabled
+1
View File
@@ -180,6 +180,7 @@ export type WebhookContext = {
url: string;
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
query?: Record<string, string | string[] | undefined>;
remoteAddress?: string;
};
export type ProviderWebhookParseResult = {
@@ -221,13 +221,40 @@ describe("verifyTwilioWebhook", () => {
rawBody: postBody,
url: "http://127.0.0.1:3334/voice/webhook",
method: "POST",
remoteAddress: "203.0.113.10",
},
authToken,
{ allowNgrokFreeTier: true },
{ allowNgrokFreeTierLoopbackBypass: true },
);
expect(result.ok).toBe(false);
expect(result.isNgrokFreeTier).toBe(true);
expect(result.reason).toMatch(/Invalid signature/);
});
it("allows invalid signatures for ngrok free tier only on loopback", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
const result = verifyTwilioWebhook(
{
headers: {
host: "127.0.0.1:3334",
"x-forwarded-proto": "https",
"x-forwarded-host": "local.ngrok-free.app",
"x-twilio-signature": "invalid",
},
rawBody: postBody,
url: "http://127.0.0.1:3334/voice/webhook",
method: "POST",
remoteAddress: "127.0.0.1",
},
authToken,
{ allowNgrokFreeTierLoopbackBypass: true },
);
expect(result.ok).toBe(true);
expect(result.isNgrokFreeTier).toBe(true);
expect(result.reason).toMatch(/compatibility mode/);
});
});
+25 -2
View File
@@ -131,6 +131,13 @@ function getHeader(
return value;
}
function isLoopbackAddress(address?: string): boolean {
if (!address) return false;
if (address === "127.0.0.1" || address === "::1") return true;
if (address.startsWith("::ffff:127.")) return true;
return false;
}
/**
* Result of Twilio webhook verification with detailed info.
*/
@@ -155,8 +162,8 @@ export function verifyTwilioWebhook(
options?: {
/** Override the public URL (e.g., from config) */
publicUrl?: string;
/** Allow ngrok free tier compatibility mode (less secure) */
allowNgrokFreeTier?: boolean;
/** Allow ngrok free tier compatibility mode (loopback only, less secure) */
allowNgrokFreeTierLoopbackBypass?: boolean;
/** Skip verification entirely (only for development) */
skipVerification?: boolean;
},
@@ -195,6 +202,22 @@ export function verifyTwilioWebhook(
verificationUrl.includes(".ngrok-free.app") ||
verificationUrl.includes(".ngrok.io");
if (
isNgrokFreeTier &&
options?.allowNgrokFreeTierLoopbackBypass &&
isLoopbackAddress(ctx.remoteAddress)
) {
console.warn(
"[voice-call] Twilio signature validation failed (ngrok free tier compatibility, loopback only)",
);
return {
ok: true,
reason: "ngrok free tier compatibility mode (loopback only)",
verificationUrl,
isNgrokFreeTier: true,
};
}
return {
ok: false,
reason: `Invalid signature for URL: ${verificationUrl}`,
+1
View File
@@ -252,6 +252,7 @@ export class VoiceCallWebhookServer {
url: `http://${req.headers.host}${req.url}`,
method: "POST",
query: Object.fromEntries(url.searchParams),
remoteAddress: req.socket.remoteAddress ?? undefined,
};
// Verify signature
+3
View File
@@ -237,6 +237,9 @@
"vitest": "^4.0.18",
"wireit": "^0.14.12"
},
"overrides": {
"tar": "7.5.4"
},
"pnpm": {
"minimumReleaseAge": 2880,
"overrides": {
+157 -39
View File
@@ -172,13 +172,6 @@ importers:
zod:
specifier: ^4.3.6
version: 4.3.6
optionalDependencies:
'@napi-rs/canvas':
specifier: ^0.1.88
version: 0.1.88
node-llama-cpp:
specifier: 3.15.0
version: 3.15.0(typescript@5.9.3)
devDependencies:
'@grammyjs/types':
specifier: ^3.23.0
@@ -261,6 +254,13 @@ importers:
wireit:
specifier: ^0.14.12
version: 0.14.12
optionalDependencies:
'@napi-rs/canvas':
specifier: ^0.1.88
version: 0.1.88
node-llama-cpp:
specifier: 3.15.0
version: 3.15.0(typescript@5.9.3)
extensions/bluebubbles: {}
@@ -335,12 +335,12 @@ importers:
'@matrix-org/matrix-sdk-crypto-nodejs':
specifier: ^0.4.0
version: 0.4.0
'@vector-im/matrix-bot-sdk':
specifier: 0.8.0-element.3
version: 0.8.0-element.3
markdown-it:
specifier: 14.1.0
version: 14.1.0
matrix-bot-sdk:
specifier: 0.8.0
version: 0.8.0
music-metadata:
specifier: ^11.10.6
version: 11.10.6
@@ -357,8 +357,8 @@ importers:
extensions/memory-core:
dependencies:
clawdbot:
specifier: '>=2026.1.25'
version: link:../..
specifier: '>=2026.1.24-3'
version: 2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3)
extensions/memory-lancedb:
dependencies:
@@ -1316,6 +1316,7 @@ packages:
'@lancedb/lancedb@0.23.0':
resolution: {integrity: sha512-aYrIoEG24AC+wILCL57Ius/Y4yU+xFHDPKLvmjzzN4byAjzeIGF0TC86S5RBt4Ji+dxS7yIWV5Q/gE5/fybIFQ==}
engines: {node: '>= 18'}
cpu: [x64, arm64]
os: [darwin, linux, win32]
peerDependencies:
apache-arrow: '>=15.0.0 <=18.1.0'
@@ -2667,6 +2668,9 @@ packages:
'@types/bun@1.3.6':
resolution: {integrity: sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA==}
'@types/caseless@0.12.5':
resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -2748,6 +2752,9 @@ packages:
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
'@types/request@2.48.13':
resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==}
'@types/retry@0.12.0':
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
@@ -2766,6 +2773,9 @@ packages:
'@types/serve-static@2.2.0':
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -2822,6 +2832,10 @@ packages:
'@urbit/http-api@3.0.0':
resolution: {integrity: sha512-EmyPbWHWXhfYQ/9wWFcLT53VvCn8ct9ljd6QEe+UBjNPEhUPOFBLpDsDp3iPLQgg8ykSU8JMMHxp95LHCorExA==}
'@vector-im/matrix-bot-sdk@0.8.0-element.3':
resolution: {integrity: sha512-2FFo/Kz2vTnOZDv59Q0s803LHf7KzuQ2EwOYYAtO0zUKJ8pV5CPsVC/IHyFb+Fsxl3R9XWFiX529yhslb4v9cQ==}
engines: {node: '>=22.0.0'}
'@vitest/browser-playwright@4.0.18':
resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==}
peerDependencies:
@@ -3194,6 +3208,11 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
clawdbot@2026.1.24-3:
resolution: {integrity: sha512-zt9BzhWXduq8ZZR4rfzQDurQWAgmijTTyPZCQGrn5ew6wCEwhxxEr2/NHG7IlCwcfRsKymsY4se9KMhoNz0JtQ==}
engines: {node: '>=22.12.0'}
hasBin: true
cli-cursor@5.0.0:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'}
@@ -3611,6 +3630,10 @@ packages:
resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==}
engines: {node: '>= 0.12'}
form-data@2.5.5:
resolution: {integrity: sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==}
engines: {node: '>= 0.12'}
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
@@ -4235,10 +4258,6 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
matrix-bot-sdk@0.8.0:
resolution: {integrity: sha512-sCY5UvZfsZhJdCjSc8wZhGhIHOe5cSFSILxx9Zp5a/NEXtmQ6W/bIhefIk4zFAZXetFwXsgvKh1960k1hG5WDw==}
engines: {node: '>=22.0.0'}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
@@ -8419,6 +8438,8 @@ snapshots:
bun-types: 1.3.6
optional: true
'@types/caseless@0.12.5': {}
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@@ -8511,6 +8532,13 @@ snapshots:
'@types/range-parser@1.2.7': {}
'@types/request@2.48.13':
dependencies:
'@types/caseless': 0.12.5
'@types/node': 25.0.10
'@types/tough-cookie': 4.0.5
form-data: 2.5.5
'@types/retry@0.12.0': {}
'@types/retry@0.12.5': {}
@@ -8535,6 +8563,8 @@ snapshots:
'@types/http-errors': 2.0.5
'@types/node': 25.0.10
'@types/tough-cookie@4.0.5': {}
'@types/trusted-types@2.0.7': {}
'@types/ws@8.18.1':
@@ -8588,6 +8618,30 @@ snapshots:
browser-or-node: 1.3.0
core-js: 3.48.0
'@vector-im/matrix-bot-sdk@0.8.0-element.3':
dependencies:
'@matrix-org/matrix-sdk-crypto-nodejs': 0.4.0
'@types/express': 4.17.25
'@types/request': 2.48.13
another-json: 0.2.0
async-lock: 1.4.1
chalk: 4.1.2
express: 4.22.1
glob-to-regexp: 0.4.1
hash.js: 1.1.7
html-to-text: 9.0.5
htmlencode: 0.0.4
lowdb: 1.0.0
lru-cache: 10.4.3
mkdirp: 3.0.1
morgan: 1.10.1
postgres: 3.4.8
request: 2.88.2
request-promise: 4.2.6(request@2.88.2)
sanitize-html: 2.17.0
transitivePeerDependencies:
- supports-color
'@vitest/browser-playwright@4.0.18(playwright@1.58.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)':
dependencies:
'@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)
@@ -9038,6 +9092,84 @@ snapshots:
dependencies:
clsx: 2.1.1
clawdbot@2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3):
dependencies:
'@agentclientprotocol/sdk': 0.13.1(zod@4.3.6)
'@aws-sdk/client-bedrock': 3.975.0
'@buape/carbon': 0.14.0(hono@4.11.4)
'@clack/prompts': 0.11.0
'@grammyjs/runner': 2.0.3(grammy@1.39.3)
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3)
'@homebridge/ciao': 1.3.4
'@line/bot-sdk': 10.6.0
'@lydell/node-pty': 1.2.0-beta.3
'@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui': 0.49.3
'@mozilla/readability': 0.6.0
'@sinclair/typebox': 0.34.47
'@slack/bolt': 4.6.0(@types/express@5.0.6)
'@slack/web-api': 7.13.0
'@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
ajv: 8.17.1
body-parser: 2.2.2
chalk: 5.6.2
chokidar: 5.0.0
chromium-bidi: 13.0.1(devtools-protocol@0.0.1561482)
cli-highlight: 2.1.11
commander: 14.0.2
croner: 9.1.0
detect-libc: 2.1.2
discord-api-types: 0.38.37
dotenv: 17.2.3
express: 5.2.1
file-type: 21.3.0
grammy: 1.39.3
hono: 4.11.4
jiti: 2.6.1
json5: 2.2.3
jszip: 3.10.1
linkedom: 0.18.12
long: 5.3.2
markdown-it: 14.1.0
node-edge-tts: 1.2.9
osc-progress: 0.3.0
pdfjs-dist: 5.4.530
playwright-core: 1.58.0
proper-lockfile: 4.1.2
qrcode-terminal: 0.12.0
sharp: 0.34.5
sqlite-vec: 0.1.7-alpha.2
tar: 7.5.4
tslog: 4.10.2
undici: 7.19.0
ws: 8.19.0
yaml: 2.8.2
zod: 4.3.6
optionalDependencies:
'@napi-rs/canvas': 0.1.88
node-llama-cpp: 3.15.0(typescript@5.9.3)
transitivePeerDependencies:
- '@discordjs/opus'
- '@modelcontextprotocol/sdk'
- '@types/express'
- audio-decode
- aws-crt
- bufferutil
- canvas
- debug
- devtools-protocol
- encoding
- ffmpeg-static
- jimp
- link-preview-js
- node-opus
- opusscript
- supports-color
- typescript
- utf-8-validate
cli-cursor@5.0.0:
dependencies:
restore-cursor: 5.1.0
@@ -9518,6 +9650,15 @@ snapshots:
combined-stream: 1.0.8
mime-types: 2.1.35
form-data@2.5.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
safe-buffer: 5.2.1
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
@@ -10197,29 +10338,6 @@ snapshots:
math-intrinsics@1.1.0: {}
matrix-bot-sdk@0.8.0:
dependencies:
'@matrix-org/matrix-sdk-crypto-nodejs': 0.4.0
'@types/express': 4.17.25
another-json: 0.2.0
async-lock: 1.4.1
chalk: 4.1.2
express: 4.22.1
glob-to-regexp: 0.4.1
hash.js: 1.1.7
html-to-text: 9.0.5
htmlencode: 0.0.4
lowdb: 1.0.0
lru-cache: 10.4.3
mkdirp: 3.0.1
morgan: 1.10.1
postgres: 3.4.8
request: 2.88.2
request-promise: 4.2.6(request@2.88.2)
sanitize-html: 2.17.0
transitivePeerDependencies:
- supports-color
mdurl@2.0.0: {}
media-typer@0.3.0: {}
+7 -2
View File
@@ -14,9 +14,14 @@ Generate
uv run {baseDir}/scripts/generate_image.py --prompt "your image description" --filename "output.png" --resolution 1K
```
Edit
Edit (single image)
```bash
uv run {baseDir}/scripts/generate_image.py --prompt "edit instructions" --filename "output.png" --input-image "/path/in.png" --resolution 2K
uv run {baseDir}/scripts/generate_image.py --prompt "edit instructions" --filename "output.png" -i "/path/in.png" --resolution 2K
```
Multi-image composition (up to 14 images)
```bash
uv run {baseDir}/scripts/generate_image.py --prompt "combine these into one scene" --filename "output.png" -i img1.png -i img2.png -i img3.png
```
API key
@@ -11,6 +11,9 @@ Generate images using Google's Nano Banana Pro (Gemini 3 Pro Image) API.
Usage:
uv run generate_image.py --prompt "your image description" --filename "output.png" [--resolution 1K|2K|4K] [--api-key KEY]
Multi-image editing (up to 14 images):
uv run generate_image.py --prompt "combine these images" --filename "output.png" -i img1.png -i img2.png -i img3.png
"""
import argparse
@@ -42,7 +45,10 @@ def main():
)
parser.add_argument(
"--input-image", "-i",
help="Optional input image path for editing/modification"
action="append",
dest="input_images",
metavar="IMAGE",
help="Input image path(s) for editing/composition. Can be specified multiple times (up to 14 images)."
)
parser.add_argument(
"--resolution", "-r",
@@ -78,34 +84,43 @@ def main():
output_path = Path(args.filename)
output_path.parent.mkdir(parents=True, exist_ok=True)
# Load input image if provided
input_image = None
# Load input images if provided (up to 14 supported by Nano Banana Pro)
input_images = []
output_resolution = args.resolution
if args.input_image:
try:
input_image = PILImage.open(args.input_image)
print(f"Loaded input image: {args.input_image}")
# Auto-detect resolution if not explicitly set by user
if args.resolution == "1K": # Default value
# Map input image size to resolution
width, height = input_image.size
max_dim = max(width, height)
if max_dim >= 3000:
output_resolution = "4K"
elif max_dim >= 1500:
output_resolution = "2K"
else:
output_resolution = "1K"
print(f"Auto-detected resolution: {output_resolution} (from input {width}x{height})")
except Exception as e:
print(f"Error loading input image: {e}", file=sys.stderr)
if args.input_images:
if len(args.input_images) > 14:
print(f"Error: Too many input images ({len(args.input_images)}). Maximum is 14.", file=sys.stderr)
sys.exit(1)
# Build contents (image first if editing, prompt only if generating)
if input_image:
contents = [input_image, args.prompt]
print(f"Editing image with resolution {output_resolution}...")
max_input_dim = 0
for img_path in args.input_images:
try:
img = PILImage.open(img_path)
input_images.append(img)
print(f"Loaded input image: {img_path}")
# Track largest dimension for auto-resolution
width, height = img.size
max_input_dim = max(max_input_dim, width, height)
except Exception as e:
print(f"Error loading input image '{img_path}': {e}", file=sys.stderr)
sys.exit(1)
# Auto-detect resolution from largest input if not explicitly set
if args.resolution == "1K" and max_input_dim > 0: # Default value
if max_input_dim >= 3000:
output_resolution = "4K"
elif max_input_dim >= 1500:
output_resolution = "2K"
else:
output_resolution = "1K"
print(f"Auto-detected resolution: {output_resolution} (from max input dimension {max_input_dim})")
# Build contents (images first if editing, prompt only if generating)
if input_images:
contents = [*input_images, args.prompt]
img_count = len(input_images)
print(f"Processing {img_count} image{'s' if img_count > 1 else ''} with resolution {output_resolution}...")
else:
contents = args.prompt
print(f"Generating image with resolution {output_resolution}...")
+129 -1
View File
@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { acquireSessionWriteLock } from "./session-write-lock.js";
import { __testing, acquireSessionWriteLock } from "./session-write-lock.js";
describe("acquireSessionWriteLock", () => {
it("reuses locks across symlinked session paths", async () => {
@@ -31,4 +31,132 @@ describe("acquireSessionWriteLock", () => {
await fs.rm(root, { recursive: true, force: true });
}
});
it("keeps the lock file until the last release", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
try {
const sessionFile = path.join(root, "sessions.json");
const lockPath = `${sessionFile}.lock`;
const lockA = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
const lockB = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
await expect(fs.access(lockPath)).resolves.toBeUndefined();
await lockA.release();
await expect(fs.access(lockPath)).resolves.toBeUndefined();
await lockB.release();
await expect(fs.access(lockPath)).rejects.toThrow();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("reclaims stale lock files", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
try {
const sessionFile = path.join(root, "sessions.json");
const lockPath = `${sessionFile}.lock`;
await fs.writeFile(
lockPath,
JSON.stringify({ pid: 123456, createdAt: new Date(Date.now() - 60_000).toISOString() }),
"utf8",
);
const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500, staleMs: 10 });
const raw = await fs.readFile(lockPath, "utf8");
const payload = JSON.parse(raw) as { pid: number };
expect(payload.pid).toBe(process.pid);
await lock.release();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("removes held locks on termination signals", async () => {
const signals = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT"] as const;
for (const signal of signals) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-cleanup-"));
try {
const sessionFile = path.join(root, "sessions.json");
const lockPath = `${sessionFile}.lock`;
await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
const keepAlive = () => {};
if (signal === "SIGINT") {
process.on(signal, keepAlive);
}
__testing.handleTerminationSignal(signal);
await expect(fs.stat(lockPath)).rejects.toThrow();
if (signal === "SIGINT") {
process.off(signal, keepAlive);
}
} finally {
await fs.rm(root, { recursive: true, force: true });
}
}
});
it("registers cleanup for SIGQUIT and SIGABRT", () => {
expect(__testing.cleanupSignals).toContain("SIGQUIT");
expect(__testing.cleanupSignals).toContain("SIGABRT");
});
it("cleans up locks on SIGINT without removing other handlers", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
const originalKill = process.kill.bind(process);
const killCalls: Array<NodeJS.Signals | undefined> = [];
let otherHandlerCalled = false;
process.kill = ((pid: number, signal?: NodeJS.Signals) => {
killCalls.push(signal);
return true;
}) as typeof process.kill;
const otherHandler = () => {
otherHandlerCalled = true;
};
process.on("SIGINT", otherHandler);
try {
const sessionFile = path.join(root, "sessions.json");
const lockPath = `${sessionFile}.lock`;
await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
process.emit("SIGINT");
await expect(fs.access(lockPath)).rejects.toThrow();
expect(otherHandlerCalled).toBe(true);
expect(killCalls).toEqual([]);
} finally {
process.off("SIGINT", otherHandler);
process.kill = originalKill;
await fs.rm(root, { recursive: true, force: true });
}
});
it("cleans up locks on exit", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
try {
const sessionFile = path.join(root, "sessions.json");
const lockPath = `${sessionFile}.lock`;
await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
process.emit("exit", 0);
await expect(fs.access(lockPath)).rejects.toThrow();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("keeps other signal listeners registered", () => {
const keepAlive = () => {};
process.on("SIGINT", keepAlive);
__testing.handleTerminationSignal("SIGINT");
expect(process.listeners("SIGINT")).toContain(keepAlive);
process.off("SIGINT", keepAlive);
});
});
+70
View File
@@ -1,3 +1,4 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
@@ -13,6 +14,9 @@ type HeldLock = {
};
const HELD_LOCKS = new Map<string, HeldLock>();
const CLEANUP_SIGNALS = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT"] as const;
type CleanupSignal = (typeof CLEANUP_SIGNALS)[number];
const cleanupHandlers = new Map<CleanupSignal, () => void>();
function isAlive(pid: number): boolean {
if (!Number.isFinite(pid) || pid <= 0) return false;
@@ -24,6 +28,65 @@ function isAlive(pid: number): boolean {
}
}
/**
* Synchronously release all held locks.
* Used during process exit when async operations aren't reliable.
*/
function releaseAllLocksSync(): void {
for (const [sessionFile, held] of HELD_LOCKS) {
try {
if (typeof held.handle.fd === "number") {
fsSync.closeSync(held.handle.fd);
}
} catch {
// Ignore errors during cleanup - best effort
}
try {
fsSync.rmSync(held.lockPath, { force: true });
} catch {
// Ignore errors during cleanup - best effort
}
HELD_LOCKS.delete(sessionFile);
}
}
let cleanupRegistered = false;
function handleTerminationSignal(signal: CleanupSignal): void {
releaseAllLocksSync();
const shouldReraise = process.listenerCount(signal) === 1;
if (shouldReraise) {
const handler = cleanupHandlers.get(signal);
if (handler) process.off(signal, handler);
try {
process.kill(process.pid, signal);
} catch {
// Ignore errors during shutdown
}
}
}
function registerCleanupHandlers(): void {
if (cleanupRegistered) return;
cleanupRegistered = true;
// Cleanup on normal exit and process.exit() calls
process.on("exit", () => {
releaseAllLocksSync();
});
// Handle termination signals
for (const signal of CLEANUP_SIGNALS) {
try {
const handler = () => handleTerminationSignal(signal);
cleanupHandlers.set(signal, handler);
process.on(signal, handler);
} catch {
// Ignore unsupported signals on this platform.
}
}
}
async function readLockPayload(lockPath: string): Promise<LockFilePayload | null> {
try {
const raw = await fs.readFile(lockPath, "utf8");
@@ -43,6 +106,7 @@ export async function acquireSessionWriteLock(params: {
}): Promise<{
release: () => Promise<void>;
}> {
registerCleanupHandlers();
const timeoutMs = params.timeoutMs ?? 10_000;
const staleMs = params.staleMs ?? 30 * 60 * 1000;
const sessionFile = path.resolve(params.sessionFile);
@@ -116,3 +180,9 @@ export async function acquireSessionWriteLock(params: {
const owner = payload?.pid ? `pid=${payload.pid}` : "unknown";
throw new Error(`session file locked (timeout ${timeoutMs}ms): ${owner} ${lockPath}`);
}
export const __testing = {
cleanupSignals: [...CLEANUP_SIGNALS],
handleTerminationSignal,
releaseAllLocksSync,
};
+46
View File
@@ -3,6 +3,7 @@ import type { ClawdbotConfig } from "../../config/config.js";
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
import {
deleteMessageTelegram,
editMessageTelegram,
reactMessageTelegram,
sendMessageTelegram,
} from "../../telegram/send.js";
@@ -209,5 +210,50 @@ export async function handleTelegramAction(
return jsonResult({ ok: true, deleted: true });
}
if (action === "editMessage") {
if (!isActionEnabled("editMessage")) {
throw new Error("Telegram editMessage is disabled.");
}
const chatId = readStringOrNumberParam(params, "chatId", {
required: true,
});
const messageId = readNumberParam(params, "messageId", {
required: true,
integer: true,
});
const content = readStringParam(params, "content", {
required: true,
allowEmpty: false,
});
const buttons = readTelegramButtons(params);
if (buttons) {
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
cfg,
accountId: accountId ?? undefined,
});
if (inlineButtonsScope === "off") {
throw new Error(
'Telegram inline buttons are disabled. Set channels.telegram.capabilities.inlineButtons to "dm", "group", "all", or "allowlist".',
);
}
}
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
throw new Error(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
const result = await editMessageTelegram(chatId ?? "", messageId ?? 0, content, {
token,
accountId: accountId ?? undefined,
buttons,
});
return jsonResult({
ok: true,
messageId: result.messageId,
chatId: result.chatId,
});
}
throw new Error(`Unsupported Telegram action: ${action}`);
}
+37 -2
View File
@@ -181,9 +181,44 @@ function buildChatCommands(): ChatCommandDefinition[] {
defineChatCommand({
key: "tts",
nativeName: "tts",
description: "Configure text-to-speech.",
description: "Control text-to-speech (TTS).",
textAlias: "/tts",
acceptsArgs: true,
args: [
{
name: "action",
description: "TTS action",
type: "string",
choices: [
{ value: "on", label: "On" },
{ value: "off", label: "Off" },
{ value: "status", label: "Status" },
{ value: "provider", label: "Provider" },
{ value: "limit", label: "Limit" },
{ value: "summary", label: "Summary" },
{ value: "audio", label: "Audio" },
{ value: "help", label: "Help" },
],
},
{
name: "value",
description: "Provider, limit, or text",
type: "string",
captureRemaining: true,
},
],
argsMenu: {
arg: "action",
title:
"TTS Actions:\n" +
"• On – Enable TTS for responses\n" +
"• Off – Disable TTS\n" +
"• Status – Show current settings\n" +
"• Provider – Set voice provider (edge, elevenlabs, openai)\n" +
"• Limit – Set max characters for TTS\n" +
"• Summary – Toggle AI summary for long texts\n" +
"• Audio – Generate TTS from custom text\n" +
"• Help – Show usage guide",
},
}),
defineChatCommand({
key: "whoami",
+10 -2
View File
@@ -229,7 +229,12 @@ describe("commands registry args", () => {
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("mode");
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
expect(menu?.choices).toEqual([
{ label: "off", value: "off" },
{ label: "tokens", value: "tokens" },
{ label: "full", value: "full" },
{ label: "cost", value: "cost" },
]);
});
it("does not show menus when arg already provided", () => {
@@ -284,7 +289,10 @@ describe("commands registry args", () => {
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("level");
expect(menu?.choices).toEqual(["low", "high"]);
expect(menu?.choices).toEqual([
{ label: "low", value: "low" },
{ label: "high", value: "high" },
]);
expect(seen?.commandKey).toBe("think");
expect(seen?.argName).toBe("level");
expect(seen?.provider).toBeTruthy();
+20 -12
View File
@@ -255,33 +255,41 @@ function resolveDefaultCommandContext(cfg?: ClawdbotConfig): {
};
}
export type ResolvedCommandArgChoice = { value: string; label: string };
export function resolveCommandArgChoices(params: {
command: ChatCommandDefinition;
arg: CommandArgDefinition;
cfg?: ClawdbotConfig;
provider?: string;
model?: string;
}): string[] {
}): ResolvedCommandArgChoice[] {
const { command, arg, cfg } = params;
if (!arg.choices) return [];
const provided = arg.choices;
if (Array.isArray(provided)) return provided;
const defaults = resolveDefaultCommandContext(cfg);
const context: CommandArgChoiceContext = {
cfg,
provider: params.provider ?? defaults.provider,
model: params.model ?? defaults.model,
command,
arg,
};
return provided(context);
const raw = Array.isArray(provided)
? provided
: (() => {
const defaults = resolveDefaultCommandContext(cfg);
const context: CommandArgChoiceContext = {
cfg,
provider: params.provider ?? defaults.provider,
model: params.model ?? defaults.model,
command,
arg,
};
return provided(context);
})();
return raw.map((choice) =>
typeof choice === "string" ? { value: choice, label: choice } : choice,
);
}
export function resolveCommandArgMenu(params: {
command: ChatCommandDefinition;
args?: CommandArgs;
cfg?: ClawdbotConfig;
}): { arg: CommandArgDefinition; choices: string[]; title?: string } | null {
}): { arg: CommandArgDefinition; choices: ResolvedCommandArgChoice[]; title?: string } | null {
const { command, args, cfg } = params;
if (!command.args || !command.argsMenu) return null;
if (command.argsParsing === "none") return null;
+4 -2
View File
@@ -12,14 +12,16 @@ export type CommandArgChoiceContext = {
arg: CommandArgDefinition;
};
export type CommandArgChoicesProvider = (context: CommandArgChoiceContext) => string[];
export type CommandArgChoice = string | { value: string; label: string };
export type CommandArgChoicesProvider = (context: CommandArgChoiceContext) => CommandArgChoice[];
export type CommandArgDefinition = {
name: string;
description: string;
type: CommandArgType;
required?: boolean;
choices?: string[] | CommandArgChoicesProvider;
choices?: CommandArgChoice[] | CommandArgChoicesProvider;
captureRemaining?: boolean;
};
@@ -89,6 +89,7 @@ export async function runAgentTurnWithFallback(params: {
registerAgentRunContext(runId, {
sessionKey: params.sessionKey,
verboseLevel: params.resolvedVerboseLevel,
isHeartbeat: params.isHeartbeat,
});
}
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
+66 -69
View File
@@ -6,20 +6,18 @@ import {
getTtsMaxLength,
getTtsProvider,
isSummarizationEnabled,
isTtsEnabled,
isTtsProviderConfigured,
normalizeTtsAutoMode,
resolveTtsAutoMode,
resolveTtsApiKey,
resolveTtsConfig,
resolveTtsPrefsPath,
resolveTtsProviderOrder,
setLastTtsAttempt,
setSummarizationEnabled,
setTtsEnabled,
setTtsMaxLength,
setTtsProvider,
textToSpeech,
} from "../../tts/tts.js";
import { updateSessionStore } from "../../config/sessions.js";
type ParsedTtsCommand = {
action: string;
@@ -40,14 +38,27 @@ function ttsUsage(): ReplyPayload {
// Keep usage in one place so help/validation stays consistent.
return {
text:
"⚙️ Usage: /tts <off|always|inbound|tagged|status|provider|limit|summary|audio> [value]" +
"\nExamples:\n" +
"/tts always\n" +
"/tts provider openai\n" +
"/tts provider edge\n" +
"/tts limit 2000\n" +
"/tts summary off\n" +
"/tts audio Hello from Clawdbot",
`🔊 **TTS (Text-to-Speech) Help**\n\n` +
`**Commands:**\n` +
`• /tts on — Enable automatic TTS for replies\n` +
`• /tts off — Disable TTS\n` +
`• /tts status — Show current settings\n` +
`• /tts provider [name] — View/change provider\n` +
`• /tts limit [number] — View/change text limit\n` +
`• /tts summary [on|off] — View/change auto-summary\n` +
`• /tts audio <text> — Generate audio from text\n\n` +
`**Providers:**\n` +
`• edge — Free, fast (default)\n` +
`• openai — High quality (requires API key)\n` +
`• elevenlabs — Premium voices (requires API key)\n\n` +
`**Text Limit (default: 1500, max: 4096):**\n` +
`When text exceeds the limit:\n` +
`• Summary ON: AI summarizes, then generates audio\n` +
`• Summary OFF: Truncates text, then generates audio\n\n` +
`**Examples:**\n` +
`/tts provider edge\n` +
`/tts limit 2000\n` +
`/tts audio Hello, this is a test!`,
};
}
@@ -72,35 +83,27 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
return { shouldContinue: false, reply: ttsUsage() };
}
const requestedAuto = normalizeTtsAutoMode(
action === "on" ? "always" : action === "off" ? "off" : action,
);
if (requestedAuto) {
const entry = params.sessionEntry;
const sessionKey = params.sessionKey;
const store = params.sessionStore;
if (entry && store && sessionKey) {
entry.ttsAuto = requestedAuto;
entry.updatedAt = Date.now();
store[sessionKey] = entry;
if (params.storePath) {
await updateSessionStore(params.storePath, (store) => {
store[sessionKey] = entry;
});
}
}
const label = requestedAuto === "always" ? "enabled (always)" : requestedAuto;
return {
shouldContinue: false,
reply: {
text: requestedAuto === "off" ? "🔇 TTS disabled." : `🔊 TTS ${label}.`,
},
};
if (action === "on") {
setTtsEnabled(prefsPath, true);
return { shouldContinue: false, reply: { text: "🔊 TTS enabled." } };
}
if (action === "off") {
setTtsEnabled(prefsPath, false);
return { shouldContinue: false, reply: { text: "🔇 TTS disabled." } };
}
if (action === "audio") {
if (!args.trim()) {
return { shouldContinue: false, reply: ttsUsage() };
return {
shouldContinue: false,
reply: {
text:
`🎤 Generate audio from text.\n\n` +
`Usage: /tts audio <text>\n` +
`Example: /tts audio Hello, this is a test!`,
},
};
}
const start = Date.now();
@@ -146,9 +149,6 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
if (action === "provider") {
const currentProvider = getTtsProvider(config, prefsPath);
if (!args.trim()) {
const fallback = resolveTtsProviderOrder(currentProvider)
.slice(1)
.filter((provider) => isTtsProviderConfigured(config, provider));
const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai"));
const hasElevenLabs = Boolean(resolveTtsApiKey(config, "elevenlabs"));
const hasEdge = isTtsProviderConfigured(config, "edge");
@@ -158,7 +158,6 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
text:
`🎙️ TTS provider\n` +
`Primary: ${currentProvider}\n` +
`Fallbacks: ${fallback.join(", ") || "none"}\n` +
`OpenAI key: ${hasOpenAI ? "✅" : "❌"}\n` +
`ElevenLabs key: ${hasElevenLabs ? "✅" : "❌"}\n` +
`Edge enabled: ${hasEdge ? "✅" : "❌"}\n` +
@@ -173,18 +172,9 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
}
setTtsProvider(prefsPath, requested);
const fallback = resolveTtsProviderOrder(requested)
.slice(1)
.filter((provider) => isTtsProviderConfigured(config, provider));
return {
shouldContinue: false,
reply: {
text:
`✅ TTS provider set to ${requested} (fallbacks: ${fallback.join(", ") || "none"}).` +
(requested === "edge"
? "\nEnable Edge TTS in config: messages.tts.edge.enabled = true."
: ""),
},
reply: { text: `✅ TTS provider set to ${requested}.` },
};
}
@@ -193,12 +183,22 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
const currentLimit = getTtsMaxLength(prefsPath);
return {
shouldContinue: false,
reply: { text: `📏 TTS limit: ${currentLimit} characters.` },
reply: {
text:
`📏 TTS limit: ${currentLimit} characters.\n\n` +
`Text longer than this triggers summary (if enabled).\n` +
`Range: 100-4096 chars (Telegram max).\n\n` +
`To change: /tts limit <number>\n` +
`Example: /tts limit 2000`,
},
};
}
const next = Number.parseInt(args.trim(), 10);
if (!Number.isFinite(next) || next < 100 || next > 10_000) {
return { shouldContinue: false, reply: ttsUsage() };
if (!Number.isFinite(next) || next < 100 || next > 4096) {
return {
shouldContinue: false,
reply: { text: "❌ Limit must be between 100 and 4096 characters." },
};
}
setTtsMaxLength(prefsPath, next);
return {
@@ -210,9 +210,17 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
if (action === "summary") {
if (!args.trim()) {
const enabled = isSummarizationEnabled(prefsPath);
const maxLen = getTtsMaxLength(prefsPath);
return {
shouldContinue: false,
reply: { text: `📝 TTS auto-summary: ${enabled ? "on" : "off"}.` },
reply: {
text:
`📝 TTS auto-summary: ${enabled ? "on" : "off"}.\n\n` +
`When text exceeds ${maxLen} chars:\n` +
`• ON: summarizes text, then generates audio\n` +
`• OFF: truncates text, then generates audio\n\n` +
`To change: /tts summary on | off`,
},
};
}
const requested = args.trim().toLowerCase();
@@ -229,27 +237,16 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
}
if (action === "status") {
const sessionAuto = params.sessionEntry?.ttsAuto;
const autoMode = resolveTtsAutoMode({ config, prefsPath, sessionAuto });
const enabled = autoMode !== "off";
const enabled = isTtsEnabled(config, prefsPath);
const provider = getTtsProvider(config, prefsPath);
const hasKey = isTtsProviderConfigured(config, provider);
const providerStatus =
provider === "edge"
? hasKey
? "✅ enabled"
: "❌ disabled"
: hasKey
? "✅ key"
: "❌ no key";
const maxLength = getTtsMaxLength(prefsPath);
const summarize = isSummarizationEnabled(prefsPath);
const last = getLastTtsAttempt();
const autoLabel = sessionAuto ? `${autoMode} (session)` : autoMode;
const lines = [
"📊 TTS status",
`Auto: ${enabled ? autoLabel : "off"}`,
`Provider: ${provider} (${providerStatus})`,
`State: ${enabled ? "✅ enabled" : "❌ disabled"}`,
`Provider: ${provider} (${hasKey ? "✅ configured" : "❌ not configured"})`,
`Text limit: ${maxLength} chars`,
`Auto-summary: ${summarize ? "on" : "off"}`,
];
+14
View File
@@ -420,3 +420,17 @@ describe("handleCommands subagents", () => {
expect(result.reply?.text).toContain("Status: done");
});
});
describe("handleCommands /tts", () => {
it("returns status for bare /tts on text command surfaces", async () => {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
messages: { tts: { prefsPath: path.join(testWorkspaceDir, "tts.json") } },
} as ClawdbotConfig;
const params = buildParams("/tts", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("TTS status");
});
});
+71 -1
View File
@@ -16,7 +16,7 @@ import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
import { isRoutableChannel, routeReply } from "./route-reply.js";
import { maybeApplyTtsToPayload, normalizeTtsAutoMode } from "../../tts/tts.js";
import { maybeApplyTtsToPayload, normalizeTtsAutoMode, resolveTtsConfig } from "../../tts/tts.js";
const AUDIO_PLACEHOLDER_RE = /^<media:audio>(\s*\([^)]*\))?$/i;
const AUDIO_HEADER_RE = /^\[Audio\b/i;
@@ -266,12 +266,26 @@ export async function dispatchReplyFromConfig(params: {
return { queuedFinal, counts };
}
// Track accumulated block text for TTS generation after streaming completes.
// When block streaming succeeds, there's no final reply, so we need to generate
// TTS audio separately from the accumulated block content.
let accumulatedBlockText = "";
let blockCount = 0;
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
ctx,
{
...params.replyOptions,
onBlockReply: (payload: ReplyPayload, context) => {
const run = async () => {
// Accumulate block text for TTS generation after streaming
if (payload.text) {
if (accumulatedBlockText.length > 0) {
accumulatedBlockText += "\n";
}
accumulatedBlockText += payload.text;
blockCount++;
}
const ttsPayload = await maybeApplyTtsToPayload({
payload,
cfg,
@@ -327,6 +341,62 @@ export async function dispatchReplyFromConfig(params: {
queuedFinal = dispatcher.sendFinalReply(ttsReply) || queuedFinal;
}
}
const ttsMode = resolveTtsConfig(cfg).mode ?? "final";
// Generate TTS-only reply after block streaming completes (when there's no final reply).
// This handles the case where block streaming succeeds and drops final payloads,
// but we still want TTS audio to be generated from the accumulated block content.
if (
ttsMode === "final" &&
replies.length === 0 &&
blockCount > 0 &&
accumulatedBlockText.trim()
) {
try {
const ttsSyntheticReply = await maybeApplyTtsToPayload({
payload: { text: accumulatedBlockText },
cfg,
channel: ttsChannel,
kind: "final",
inboundAudio,
ttsAuto: sessionTtsAuto,
});
// Only send if TTS was actually applied (mediaUrl exists)
if (ttsSyntheticReply.mediaUrl) {
// Send TTS-only payload (no text, just audio) so it doesn't duplicate the block content
const ttsOnlyPayload: ReplyPayload = {
mediaUrl: ttsSyntheticReply.mediaUrl,
audioAsVoice: ttsSyntheticReply.audioAsVoice,
};
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
const result = await routeReply({
payload: ttsOnlyPayload,
channel: originatingChannel,
to: originatingTo,
sessionKey: ctx.SessionKey,
accountId: ctx.AccountId,
threadId: ctx.MessageThreadId,
cfg,
});
queuedFinal = result.ok || queuedFinal;
if (result.ok) routedFinalCount += 1;
if (!result.ok) {
logVerbose(
`dispatch-from-config: route-reply (tts-only) failed: ${result.error ?? "unknown error"}`,
);
}
} else {
const didQueue = dispatcher.sendFinalReply(ttsOnlyPayload);
queuedFinal = didQueue || queuedFinal;
}
}
} catch (err) {
logVerbose(
`dispatch-from-config: accumulated block TTS failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
await dispatcher.waitForIdle();
const counts = dispatcher.getQueuedCounts();
+26
View File
@@ -3,6 +3,26 @@ import { CURRENT_MESSAGE_MARKER } from "./mentions.js";
export const HISTORY_CONTEXT_MARKER = "[Chat messages since your last reply - for context]";
export const DEFAULT_GROUP_HISTORY_LIMIT = 50;
/** Maximum number of group history keys to retain (LRU eviction when exceeded). */
export const MAX_HISTORY_KEYS = 1000;
/**
* Evict oldest keys from a history map when it exceeds MAX_HISTORY_KEYS.
* Uses Map's insertion order for LRU-like behavior.
*/
export function evictOldHistoryKeys<T>(
historyMap: Map<string, T[]>,
maxKeys: number = MAX_HISTORY_KEYS,
): void {
if (historyMap.size <= maxKeys) return;
const keysToDelete = historyMap.size - maxKeys;
const iterator = historyMap.keys();
for (let i = 0; i < keysToDelete; i++) {
const key = iterator.next().value;
if (key !== undefined) historyMap.delete(key);
}
}
export type HistoryEntry = {
sender: string;
body: string;
@@ -34,7 +54,13 @@ export function appendHistoryEntry<T extends HistoryEntry>(params: {
const history = historyMap.get(historyKey) ?? [];
history.push(entry);
while (history.length > params.limit) history.shift();
if (historyMap.has(historyKey)) {
// Refresh insertion order so eviction keeps recently used histories.
historyMap.delete(historyKey);
}
historyMap.set(historyKey, history);
// Evict oldest keys if map exceeds max size to prevent unbounded memory growth
evictOldHistoryKeys(historyMap);
return history;
}
+5 -1
View File
@@ -21,7 +21,11 @@ export async function prependSystemEvents(params: {
if (!trimmed) return null;
const lower = trimmed.toLowerCase();
if (lower.includes("reason periodic")) return null;
if (lower.includes("heartbeat")) return null;
// Filter out the actual heartbeat prompt, but not cron jobs that mention "heartbeat"
// The heartbeat prompt starts with "Read HEARTBEAT.md" - cron payloads won't match this
if (lower.startsWith("read heartbeat.md")) return null;
// Also filter heartbeat poll/wake noise
if (lower.includes("heartbeat poll") || lower.includes("heartbeat wake")) return null;
if (trimmed.startsWith("Node:")) {
return trimmed.replace(/ · last input [^·]+/i, "").trim();
}
@@ -62,4 +62,53 @@ describe("telegramMessageActions", () => {
cfg,
);
});
it("maps edit action params into editMessage", async () => {
handleTelegramAction.mockClear();
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
await telegramMessageActions.handleAction({
action: "edit",
params: {
chatId: "123",
messageId: 42,
message: "Updated",
buttons: [],
},
cfg,
accountId: undefined,
});
expect(handleTelegramAction).toHaveBeenCalledWith(
{
action: "editMessage",
chatId: "123",
messageId: 42,
content: "Updated",
buttons: [],
accountId: undefined,
},
cfg,
);
});
it("rejects non-integer messageId for edit before reaching telegram-actions", async () => {
handleTelegramAction.mockClear();
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
await expect(
telegramMessageActions.handleAction({
action: "edit",
params: {
chatId: "123",
messageId: "nope",
message: "Updated",
},
cfg,
accountId: undefined,
}),
).rejects.toThrow();
expect(handleTelegramAction).not.toHaveBeenCalled();
});
});
+29 -2
View File
@@ -1,5 +1,6 @@
import {
createActionGate,
readNumberParam,
readStringOrNumberParam,
readStringParam,
} from "../../../agents/tools/common.js";
@@ -43,6 +44,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
const actions = new Set<ChannelMessageActionName>(["send"]);
if (gate("reactions")) actions.add("react");
if (gate("deleteMessage")) actions.add("delete");
if (gate("editMessage")) actions.add("edit");
return Array.from(actions);
},
supportsButtons: ({ cfg }) => {
@@ -100,14 +102,39 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
readStringOrNumberParam(params, "chatId") ??
readStringOrNumberParam(params, "channelId") ??
readStringParam(params, "to", { required: true });
const messageId = readStringParam(params, "messageId", {
const messageId = readNumberParam(params, "messageId", {
required: true,
integer: true,
});
return await handleTelegramAction(
{
action: "deleteMessage",
chatId,
messageId: Number(messageId),
messageId,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "edit") {
const chatId =
readStringOrNumberParam(params, "chatId") ??
readStringOrNumberParam(params, "channelId") ??
readStringParam(params, "to", { required: true });
const messageId = readNumberParam(params, "messageId", {
required: true,
integer: true,
});
const message = readStringParam(params, "message", { required: true, allowEmpty: false });
const buttons = params.buttons;
return await handleTelegramAction(
{
action: "editMessage",
chatId,
messageId,
content: message,
buttons,
accountId: accountId ?? undefined,
},
cfg,
+14 -8
View File
@@ -80,14 +80,20 @@ async function promptTelegramAllowFrom(params: {
if (!token) return null;
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
const url = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(username)}`;
const res = await fetch(url);
const data = (await res.json().catch(() => null)) as {
ok?: boolean;
result?: { id?: number | string };
} | null;
const id = data?.ok ? data?.result?.id : undefined;
if (typeof id === "number" || typeof id === "string") return String(id);
return null;
try {
const res = await fetch(url);
if (!res.ok) return null;
const data = (await res.json().catch(() => null)) as {
ok?: boolean;
result?: { id?: number | string };
} | null;
const id = data?.ok ? data?.result?.id : undefined;
if (typeof id === "number" || typeof id === "string") return String(id);
return null;
} catch {
// Network error during username lookup - return null to prompt user for numeric ID
return null;
}
};
const parseInput = (value: string) =>
+42
View File
@@ -78,6 +78,48 @@ describe("argv helpers", () => {
});
expect(nodeArgv).toEqual(["node", "clawdbot", "status"]);
const versionedNodeArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["node-22", "clawdbot", "status"],
});
expect(versionedNodeArgv).toEqual(["node-22", "clawdbot", "status"]);
const versionedNodeWindowsArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["node-22.2.0.exe", "clawdbot", "status"],
});
expect(versionedNodeWindowsArgv).toEqual(["node-22.2.0.exe", "clawdbot", "status"]);
const versionedNodePatchlessArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["node-22.2", "clawdbot", "status"],
});
expect(versionedNodePatchlessArgv).toEqual(["node-22.2", "clawdbot", "status"]);
const versionedNodeWindowsPatchlessArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["node-22.2.exe", "clawdbot", "status"],
});
expect(versionedNodeWindowsPatchlessArgv).toEqual(["node-22.2.exe", "clawdbot", "status"]);
const versionedNodeWithPathArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["/usr/bin/node-22.2.0", "clawdbot", "status"],
});
expect(versionedNodeWithPathArgv).toEqual(["/usr/bin/node-22.2.0", "clawdbot", "status"]);
const nodejsArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["nodejs", "clawdbot", "status"],
});
expect(nodejsArgv).toEqual(["nodejs", "clawdbot", "status"]);
const nonVersionedNodeArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["node-dev", "clawdbot", "status"],
});
expect(nonVersionedNodeArgv).toEqual(["node", "clawdbot", "node-dev", "clawdbot", "status"]);
const directArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["clawdbot", "status"],
+17 -5
View File
@@ -96,15 +96,27 @@ export function buildParseArgv(params: {
: baseArgv;
const executable = (normalizedArgv[0]?.split(/[/\\]/).pop() ?? "").toLowerCase();
const looksLikeNode =
normalizedArgv.length >= 2 &&
(executable === "node" ||
executable === "node.exe" ||
executable === "bun" ||
executable === "bun.exe");
normalizedArgv.length >= 2 && (isNodeExecutable(executable) || isBunExecutable(executable));
if (looksLikeNode) return normalizedArgv;
return ["node", programName || "clawdbot", ...normalizedArgv];
}
const nodeExecutablePattern = /^node-\d+(?:\.\d+)*(?:\.exe)?$/;
function isNodeExecutable(executable: string): boolean {
return (
executable === "node" ||
executable === "node.exe" ||
executable === "nodejs" ||
executable === "nodejs.exe" ||
nodeExecutablePattern.test(executable)
);
}
function isBunExecutable(executable: string): boolean {
return executable === "bun" || executable === "bun.exe";
}
export function shouldMigrateStateFromPath(path: string[]): boolean {
if (path.length === 0) return true;
const [primary, secondary] = path;
+10 -1
View File
@@ -11,7 +11,7 @@ import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import { formatUncaughtError } from "../infra/errors.js";
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { enableConsoleCapture } from "../logging.js";
import { getPrimaryCommand } from "./argv.js";
import { getPrimaryCommand, hasHelpOrVersion } from "./argv.js";
import { tryRouteCli } from "./route.js";
export function rewriteUpdateFlagArgv(argv: string[]): string[] {
@@ -56,6 +56,15 @@ export async function runCli(argv: string[] = process.argv) {
const { registerSubCliByName } = await import("./program/register.subclis.js");
await registerSubCliByName(program, primary);
}
const shouldSkipPluginRegistration = !primary && hasHelpOrVersion(parseArgv);
if (!shouldSkipPluginRegistration) {
// Register plugin CLI commands before parsing
const { registerPluginCliCommands } = await import("../plugins/cli.js");
const { loadConfig } = await import("../config/config.js");
registerPluginCliCommands(program, loadConfig());
}
await program.parseAsync(parseArgv);
}
+3
View File
@@ -310,6 +310,7 @@ const FIELD_LABELS: Record<string, string> = {
"channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)",
"channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",
"channels.telegram.retry.jitter": "Telegram Retry Jitter",
"channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily",
"channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)",
"channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons",
"channels.whatsapp.dmPolicy": "WhatsApp DM Policy",
@@ -643,6 +644,8 @@ const FIELD_HELP: Record<string, string> = {
"channels.telegram.retry.maxDelayMs":
"Maximum retry delay cap in ms for Telegram outbound calls.",
"channels.telegram.retry.jitter": "Jitter factor (0-1) applied to Telegram retry delays.",
"channels.telegram.network.autoSelectFamily":
"Override Node autoSelectFamily for Telegram (true=enable, false=disable).",
"channels.telegram.timeoutSeconds":
"Max seconds before Telegram API requests are aborted (default: 500 per grammY).",
"channels.whatsapp.dmPolicy":
+8
View File
@@ -15,6 +15,12 @@ export type TelegramActionConfig = {
reactions?: boolean;
sendMessage?: boolean;
deleteMessage?: boolean;
editMessage?: boolean;
};
export type TelegramNetworkConfig = {
/** Override Node's autoSelectFamily behavior (true = enable, false = disable). */
autoSelectFamily?: boolean;
};
export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist";
@@ -95,6 +101,8 @@ export type TelegramAccountConfig = {
timeoutSeconds?: number;
/** Retry policy for outbound Telegram API calls. */
retry?: OutboundRetryConfig;
/** Network transport overrides for Telegram. */
network?: TelegramNetworkConfig;
proxy?: string;
webhookUrl?: string;
webhookSecret?: string;
+6
View File
@@ -110,6 +110,12 @@ export const TelegramAccountSchemaBase = z
mediaMaxMb: z.number().positive().optional(),
timeoutSeconds: z.number().int().positive().optional(),
retry: RetryConfigSchema,
network: z
.object({
autoSelectFamily: z.boolean().optional(),
})
.strict()
.optional(),
proxy: z.string().optional(),
webhookUrl: z.string().optional(),
webhookSecret: z.string().optional(),
+12 -6
View File
@@ -93,16 +93,18 @@ function buildDiscordCommandOptions(params: {
typeof focused?.value === "string" ? focused.value.trim().toLowerCase() : "";
const choices = resolveCommandArgChoices({ command, arg, cfg });
const filtered = focusValue
? choices.filter((choice) => choice.toLowerCase().includes(focusValue))
? choices.filter((choice) => choice.label.toLowerCase().includes(focusValue))
: choices;
await interaction.respond(
filtered.slice(0, 25).map((choice) => ({ name: choice, value: choice })),
filtered.slice(0, 25).map((choice) => ({ name: choice.label, value: choice.value })),
);
}
: undefined;
const choices =
resolvedChoices.length > 0 && !autocomplete
? resolvedChoices.slice(0, 25).map((choice) => ({ name: choice, value: choice }))
? resolvedChoices
.slice(0, 25)
.map((choice) => ({ name: choice.label, value: choice.value }))
: undefined;
return {
name: arg.name,
@@ -351,7 +353,11 @@ export function createDiscordCommandArgFallbackButton(params: DiscordCommandArgC
function buildDiscordCommandArgMenu(params: {
command: ChatCommandDefinition;
menu: { arg: CommandArgDefinition; choices: string[]; title?: string };
menu: {
arg: CommandArgDefinition;
choices: Array<{ value: string; label: string }>;
title?: string;
};
interaction: CommandInteraction;
cfg: ReturnType<typeof loadConfig>;
discordConfig: DiscordConfig;
@@ -365,11 +371,11 @@ function buildDiscordCommandArgMenu(params: {
const buttons = choices.map(
(choice) =>
new DiscordCommandArgButton({
label: choice,
label: choice.label,
customId: buildDiscordCommandArgCustomId({
command: commandLabel,
arg: menu.arg.name,
value: choice,
value: choice.value,
userId,
}),
cfg: params.cfg,
+28
View File
@@ -0,0 +1,28 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
function readTerminalCss() {
// This test is intentionally simple: it guards against regressions where the
// docs header stops being sticky because sticky elements live inside an
// overflow-clipped container.
const path = join(process.cwd(), "docs", "assets", "terminal.css");
return readFileSync(path, "utf8");
}
describe("docs terminal.css", () => {
test("keeps the docs header sticky (shell is sticky)", () => {
const css = readTerminalCss();
expect(css).toMatch(/\.shell\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;[^}]*\}/s);
});
test("does not rely on making body overflow visible", () => {
const css = readTerminalCss();
expect(css).not.toMatch(/body\s*\{[^}]*overflow-x:\s*visible;[^}]*\}/s);
});
test("does not make the terminal frame overflow visible (can break layout)", () => {
const css = readTerminalCss();
expect(css).not.toMatch(/\.shell__frame\s*\{[^}]*overflow:\s*visible;[^}]*\}/s);
});
});
+28 -2
View File
@@ -1,8 +1,28 @@
import { normalizeVerboseLevel } from "../auto-reply/thinking.js";
import { loadConfig } from "../config/config.js";
import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-events.js";
import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
import { loadSessionEntry } from "./session-utils.js";
import { formatForLog } from "./ws-log.js";
/**
* Check if webchat broadcasts should be suppressed for heartbeat runs.
* Returns true if the run is a heartbeat and showOk is false.
*/
function shouldSuppressHeartbeatBroadcast(runId: string): boolean {
const runContext = getAgentRunContext(runId);
if (!runContext?.isHeartbeat) return false;
try {
const cfg = loadConfig();
const visibility = resolveHeartbeatVisibility({ cfg, channel: "webchat" });
return !visibility.showOk;
} catch {
// Default to suppressing if we can't load config
return true;
}
}
export type ChatRunEntry = {
sessionKey: string;
clientRunId: string;
@@ -130,7 +150,10 @@ export function createAgentEventHandler({
timestamp: now,
},
};
broadcast("chat", payload, { dropIfSlow: true });
// Suppress webchat broadcast for heartbeat runs when showOk is false
if (!shouldSuppressHeartbeatBroadcast(clientRunId)) {
broadcast("chat", payload, { dropIfSlow: true });
}
nodeSendToSession(sessionKey, "chat", payload);
};
@@ -158,7 +181,10 @@ export function createAgentEventHandler({
}
: undefined,
};
broadcast("chat", payload);
// Suppress webchat broadcast for heartbeat runs when showOk is false
if (!shouldSuppressHeartbeatBroadcast(clientRunId)) {
broadcast("chat", payload);
}
nodeSendToSession(sessionKey, "chat", payload);
return;
}
+2 -2
View File
@@ -291,10 +291,10 @@ export function createGatewayHttpServer(opts: {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
} catch (err) {
} catch {
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(String(err));
res.end("Internal Server Error");
}
}
+4
View File
@@ -14,6 +14,7 @@ export type AgentEventPayload = {
export type AgentRunContext = {
sessionKey?: string;
verboseLevel?: VerboseLevel;
isHeartbeat?: boolean;
};
// Keep per-run counters so streams stay strictly monotonic per runId.
@@ -34,6 +35,9 @@ export function registerAgentRunContext(runId: string, context: AgentRunContext)
if (context.verboseLevel && existing.verboseLevel !== context.verboseLevel) {
existing.verboseLevel = context.verboseLevel;
}
if (context.isHeartbeat !== undefined && existing.isHeartbeat !== context.isHeartbeat) {
existing.isHeartbeat = context.isHeartbeat;
}
}
export function getAgentRunContext(runId: string) {
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff. Show more