Compare commits

..
Author SHA1 Message Date
Peter Steinberger 4429b75813 fix: refine Anthropic token hints (#1185) (thanks @KrauseFx) 2026-01-18 23:27:33 +00:00
Felix Krause ff7d4b00b3 Improve Anthropic token option hints in onboarding wizard 2026-01-18 23:18:39 +00:00
Peter Steinberger a5d0cb90b1 chore: update pnpm lockfile 2026-01-18 23:18:27 +00:00
Peter Steinberger c639b386da fix: hide menubar usage errors 2026-01-18 23:18:10 +00:00
Peter Steinberger 97531f174f fix: import wizard prompter in onboarding adapters 2026-01-18 23:05:05 +00:00
Peter Steinberger ef125d5ba7 docs: update changelog for docs:list guard 2026-01-18 22:53:59 +00:00
Peter Steinberger 86950d3474 fix: guard docs:list when docs dir missing 2026-01-18 22:53:39 +00:00
Peter Steinberger 3a9582bc41 docs: update channel allowlist guidance 2026-01-18 22:52:00 +00:00
Peter Steinberger d198474415 feat: resolve allowlists in channel plugins 2026-01-18 22:52:00 +00:00
Peter Steinberger ace8a1b44e feat: expand dm allowlist onboarding 2026-01-18 22:52:00 +00:00
Peter Steinberger a7be3a9649 fix: honor telegram pairing allowlists for native commands 2026-01-18 22:52:00 +00:00
Peter Steinberger 0d543dd1ff test: update expectations for session reset behavior 2026-01-18 22:51:37 +00:00
Peter Steinberger 404c373153 feat: add agent targeting + reply overrides 2026-01-18 22:50:51 +00:00
Peter Steinberger 024691e4e7 feat(mac): manage node service in remote mode 2026-01-18 22:50:02 +00:00
Peter Steinberger a86d7a2f35 Merge pull request #1196 from vignesh07/feat/tui-waiting-shimmer-clean
feat(tui): animated waiting status with shimmer effect ✨
2026-01-18 22:38:08 +00:00
Peter Steinberger e7e34c442e fix: smooth TUI waiting shimmer (#1196) (thanks @vignesh07) 2026-01-18 22:37:36 +00:00
Peter Steinberger 9b9e8d4ae8 chore: block node_modules commits 2026-01-18 22:28:59 +00:00
Peter Steinberger bf925e5758 chore: rename memory-lancedb extension folder 2026-01-18 22:27:22 +00:00
Peter Steinberger c0c9df4ab7 build: update A2UI bundle hash 2026-01-18 22:26:12 +00:00
Peter Steinberger 6aa90f8b18 build: refresh A2UI bundle 2026-01-18 22:26:12 +00:00
Peter Steinberger 9af1c8a886 fix: patch session store updates 2026-01-18 22:26:12 +00:00
Peter Steinberger ed5ece4120 fix: remove unreachable approval fallback 2026-01-18 22:26:12 +00:00
Peter Steinberger 85d1835476 feat: add live memory index progress 2026-01-18 22:25:08 +00:00
Vignesh Natarajan e85d2dff97 TUI: pick waiting phrase once per waiting session 2026-01-18 22:19:47 +00:00
Vignesh Natarajan fac66d4dda TUI: waiting shimmer helper + tests 2026-01-18 22:19:47 +00:00
Vignesh Natarajan 2e99369113 TUI: add animated waiting status with shimmer 2026-01-18 22:19:47 +00:00
Peter Steinberger 835f9ee575 docs: clarify envelope time work 2026-01-18 22:17:24 +00:00
Peter Steinberger a136c6aa89 Merge pull request #1187 from fayrose/fix/compaction-failure-silent-reset
fix: return user-facing error when session reset after compaction failure
2026-01-18 22:02:36 +00:00
Peter Steinberger b621d4550b chore: tighten skills prompt rules 2026-01-18 21:30:27 +00:00
Lauren Rosenberg c290217305 fix: add reserveTokensFloor suggestion to compaction error messages
When context limit is exceeded, the error message now suggests
setting agents.defaults.compaction.reserveTokensFloor to 4000
or higher to prevent future occurrences.
2026-01-18 19:37:15 +00:00
Peter Steinberger 769b286cf2 fix: make docs list node-safe 2026-01-18 19:37:13 +00:00
Peter Steinberger 690bb192e6 style: format code 2026-01-18 19:36:46 +00:00
Peter Steinberger 601a052216 fix: unblock bundled plugin load 2026-01-18 19:34:21 +00:00
Peter Steinberger bf3021d266 fix: stabilize logging imports and tests 2026-01-18 19:34:08 +00:00
Peter Steinberger 145b2e5f52 fix: menu preview label colors 2026-01-18 19:04:01 +00:00
Peter Steinberger 4b73dc95c4 fix: normalize envelope options 2026-01-18 18:59:34 +00:00
Peter Steinberger e17cb408a5 fix(cli): load pairing channels after plugins 2026-01-18 18:56:40 +00:00
Peter Steinberger 3cf92152c3 fix: appease tsc in test helpers 2026-01-18 18:54:59 +00:00
Peter Steinberger c50cde2170 fix: clean up lint in gateway tests 2026-01-18 18:51:43 +00:00
Peter Steinberger 7e0bebd669 docs: update clawtributors 2026-01-18 18:51:08 +00:00
Peter Steinberger 7c49326191 fix: satisfy oxlint spread rule 2026-01-18 18:50:52 +00:00
Peter SteinbergerandShiva Prasad 744d1329cb feat: make inbound envelopes configurable
Co-authored-by: Shiva Prasad <shiv19@users.noreply.github.com>
2026-01-18 18:50:37 +00:00
Peter Steinberger 42e6ff4611 feat(cli): show Telegram bot username in status 2026-01-18 18:48:25 +00:00
Peter Steinberger 5f21bf735a chore: switch repo scripts to node 2026-01-18 18:46:18 +00:00
Peter Steinberger ee380e9ab9 fix: run cli scripts via node build runner 2026-01-18 18:43:39 +00:00
Peter Steinberger ab340c82fb fix: stabilize tests and logging 2026-01-18 18:43:31 +00:00
Peter Steinberger 57dd0505a3 Merge pull request #1181 from sebslight/plugins/exclusive-slots
Plugins: auto-select exclusive slots
2026-01-18 18:40:38 +00:00
Peter Steinberger d6f9f1c79a Merge pull request #1182 from zerone0x/fix/issue-1115-filter-openrouter-auto
fix(configure): filter openrouter/auto from model selection list
2026-01-18 18:32:47 +00:00
Peter Steinberger a08a772ffc fix: add model picker regression for openrouter auto (#1182) (thanks @zerone0x) 2026-01-18 18:32:25 +00:00
zerone0xandClaude 2622b1936b fix(configure): filter openrouter/auto from model selection list
The openrouter/auto model is OpenRouter's internal routing feature,
not a callable model. While it's valid as a default (set automatically
during OpenRouter auth flow), showing it in the configure wizard's
model selection causes "Unknown model: openrouter/auto" errors when
users select it manually.

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

Fixes #1115

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-18 18:31:55 +00:00
Peter Steinberger c0457e0cc4 fix(mac): load menu session previews 2026-01-18 18:28:48 +00:00
Peter Steinberger ee2f0a175a docs: add Windows installer git mode 2026-01-18 18:26:20 +00:00
Lauren Rosenberg 0e94f0c018 style: apply prettier formatting 2026-01-18 18:21:11 +00:00
Lauren Rosenberg 576485b0c9 fix: return user-facing error when session reset after compaction failure
Previously, when auto-compaction failed due to context overflow, the system
would reset the session and silently continue the execution loop without
sending any response to the user. This made it appear as if messages were
being ignored.

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

Fixes the silent failure case where message + compaction exceeds context limits.
2026-01-18 18:16:20 +00:00
Sebastian Slight cef6b16d14 Plugins: auto-select exclusive slots 2026-01-18 11:26:50 -05:00
181 changed files with 19305 additions and 18501 deletions

No files matched your search

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