Compare commits

..
Author SHA1 Message Date
Peter Steinberger d1ccc1e542 fix: align prompt failover fallback gating (#1136) (thanks @cheeeee) 2026-01-18 03:31:38 +00:00
Mykyta Bozhenko 62324eed0b fix(agent): Enable model fallback for prompt-phase quota/rate limit errors
When a prompt submission fails with quota or rate limit errors, throw
FailoverError instead of the raw promptError. This enables the model
fallback system to try alternative models.

Previously, rate limit errors during the prompt phase (before streaming)
were thrown directly, bypassing fallback. Only response-phase errors
triggered model fallback.

Now checks if fallback models are configured and the error is failover-
eligible. If so, wraps in FailoverError to trigger the fallback chain.
2026-01-18 03:15:28 +00:00
Peter Steinberger 0f6f7059d9 test: stabilize embedded runner tests 2026-01-18 02:55:41 +00:00
Peter Steinberger 67f63ecd7e chore: remove tracked artifacts 2026-01-18 02:55:07 +00:00
Peter Steinberger 1420d113d8 refactor: migrate extensions to plugin sdk 2026-01-18 02:55:07 +00:00
Peter Steinberger 5b4651d9ed refactor: add plugin sdk runtime scaffolding 2026-01-18 02:52:30 +00:00
Peter Steinberger 5f22b68268 feat: add session origin metadata helpers 2026-01-18 02:42:11 +00:00
Peter Steinberger 34590d2144 feat: persist session origin metadata across connectors 2026-01-18 02:42:10 +00:00
Peter Steinberger 0c93b9b7bb style: apply oxfmt 2026-01-18 02:19:35 +00:00
Peter Steinberger b659db0a5b chore(changelog): align 2026.1.17 versions 2026-01-18 02:13:56 +00:00
Peter Steinberger 9fd9f4c896 feat(plugins): add memory slot plugin 2026-01-18 02:12:10 +00:00
Peter Steinberger 005b831023 test: stabilize env-dependent tool defaults 2026-01-18 01:57:54 +00:00
Peter Steinberger 8013c4717c feat: show memory summary in status 2026-01-18 01:57:54 +00:00
Peter Steinberger 14e6b21b50 test: cover perplexity baseUrl precedence 2026-01-18 01:56:34 +00:00
Peter Steinbergerandthewilloftheshadow 62354dff9c refactor: share allowlist match metadata
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-18 01:49:25 +00:00
Peter Steinberger ccb30665f7 feat: add hybrid memory search 2026-01-18 01:47:58 +00:00
Peter Steinberger 0fb2777c6d feat: add memory embedding cache 2026-01-18 01:47:58 +00:00
Peter Steinberger 568b8ee96c refactor: split web tools and docs 2026-01-18 01:42:54 +00:00
Peter Steinberger fc60699f03 fix: delay discord slow listener warnings 2026-01-18 01:41:10 +00:00
Peter Steinbergerandthewilloftheshadow c1da78a271 refactor: share teams allowlist matching helpers
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-18 01:37:22 +00:00
Peter Steinberger 0674f1fa3c feat: add exec approvals allowlists 2026-01-18 01:34:31 +00:00
Peter Steinberger 3a0fd6be3c test: stub slack allowlist resolvers 2026-01-18 01:25:19 +00:00
Peter Steinberger 8b1bec11d0 feat: speed up memory batch indexing 2026-01-18 01:24:51 +00:00
Peter Steinbergerandthewilloftheshadow f73dbdbaea refactor: unify channel config matching and gating
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-18 01:24:00 +00:00
Peter Steinberger 05f49d2846 fix(slack): resolve allowlists async 2026-01-18 01:23:25 +00:00
Peter Steinberger 1d83389776 Merge pull request #1131 from CMLKevin/feat/perplexity-search-provider
feat(web): add Perplexity Sonar as alternative search provider
2026-01-18 01:16:00 +00:00
Peter Steinberger e0e8f11f70 fix: bundle Textual resources in macOS app 2026-01-18 01:15:19 +00:00
Peter Steinberger 36d88f6079 fix: normalize gateway dev mode detection 2026-01-18 01:08:47 +00:00
Peter Steinberger 2c070952e1 Merge pull request #1120 from mukhtharcm/qwen-portal-oauth
Models: add Qwen Portal OAuth support
2026-01-18 01:04:46 +00:00
Peter Steinberger fc45148155 fix: harden qwen oauth flow (#1120) (thanks @mukhtharcm) 2026-01-18 01:03:08 +00:00
Muhammed Mukhthar CM 215c395fc2 UI: simplify Qwen labels 2026-01-18 01:03:08 +00:00
Muhammed Mukhthar CM b56b67cdbd UI: label Qwen provider 2026-01-18 01:03:08 +00:00
Muhammed Mukhthar CM a760db9921 Docs: add Qwen Portal provider 2026-01-18 01:03:08 +00:00
Muhammed Mukhthar CM 8eb80ee40a Models: add Qwen Portal OAuth support 2026-01-18 01:03:08 +00:00
Peter Steinberger f9e3b129ed test: reindex on embedding model change 2026-01-18 01:00:57 +00:00
Peter Steinberger e5050abe2a docs: note model change reindex 2026-01-18 01:00:57 +00:00
Peter Steinberger 4f0771f67b fix(channels): clean up discord resolve typing 2026-01-18 01:00:25 +00:00
Peter Steinberger 075ff675ac refactor(channels): share allowlist + resolver helpers 2026-01-18 01:00:25 +00:00
Peter Steinberger c7ea47e886 feat(channels): add resolve command + defaults 2026-01-18 01:00:24 +00:00
Rodrigo Uroz b543339373 Update tagline.ts with a nice reference from an old movie 2026-01-18 00:59:43 +00:00
Peter Steinbergerandthewilloftheshadow 22c7f659f6 fix: surface match metadata in audits and slack logs
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-18 00:50:36 +00:00
Peter Steinberger 79a44d0da4 refactor(channels): unify target parsing 2026-01-18 00:31:42 +00:00
Peter Steinberger d593a809f0 fix: apply openai batch defaults 2026-01-18 00:29:18 +00:00
Peter Steinberger 22add31e91 docs: update changelog for sessions_spawn thinking 2026-01-18 00:17:28 +00:00
Peter SteinbergerandNicholas Spisak b44d740720 refactor: centralize cli manager cleanup
Co-authored-by: Nicholas Spisak <jsnsdirect@gmail.com>
2026-01-18 00:16:01 +00:00
Peter Steinberger 4d590f9254 refactor(slack): centralize target parsing 2026-01-18 00:15:05 +00:00
Peter Steinbergerandthewilloftheshadow a5aa48beea feat: add dm allowlist match metadata logs
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-18 00:14:44 +00:00
Peter Steinberger 1bf3861ca4 feat: add thinking override to sessions_spawn 2026-01-18 00:14:18 +00:00
Kevin Lin ff9d069a33 feat(web): add Perplexity Sonar as alternative search provider 2026-01-18 08:08:36 +08:00
joshrad-dev f8052be369 docs: add docs for Copilot device flow 2026-01-18 00:06:04 +00:00
Peter SteinbergerandJonathan Rhyne a08438ae97 refactor(discord): centralize target parsing
Co-authored-by: Jonathan Rhyne <jonathan@pspdfkit.com>
2026-01-18 00:04:38 +00:00
Peter Steinbergerandthewilloftheshadow fe00d6aacf feat: add matrix room match metadata logs
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-18 00:00:00 +00:00
Peter Steinbergerandthewilloftheshadow 984692cda2 refactor: reuse channel config resolver in matrix extension
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-17 23:53:05 +00:00
Peter Steinbergerandthewilloftheshadow 4c12c4fc04 feat: add channel match metadata logs
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-17 23:48:45 +00:00
Peter SteinbergerandNicholas Spisak 794bab45ff fix: harden memory cli manager cleanup
Co-authored-by: Nicholas Spisak <jsnsdirect@gmail.com>
2026-01-17 23:45:42 +00:00
Peter Steinberger 16e5fa1db9 test: cover daemon install helpers 2026-01-17 23:41:45 +00:00
Peter Steinberger 125be3e111 fix: restore wizard/doctor imports 2026-01-17 23:41:45 +00:00
Peter Steinberger b60a53e10d feat: enable batch indexing by default 2026-01-17 23:29:40 +00:00
Peter Steinberger 9de762faa2 refactor: unify gateway daemon install plan 2026-01-17 23:29:34 +00:00
Peter SteinbergerandCodex 5aed38eebc fix(discord): honor thread allowlists in reactions
Co-authored-by: Codex <codex@openai.com>
2026-01-17 23:03:51 +00:00
Peter SteinbergerandCodex e63e483c38 refactor(channels): share channel config matching
Co-authored-by: Codex <codex@openai.com>
2026-01-17 23:03:51 +00:00
Shadow 277e43e32c Discord: inherit thread allowlists 2026-01-17 23:03:51 +00:00
Peter Steinberger 852aa16ca0 fix: stabilize memory sync progress 2026-01-17 23:02:03 +00:00
Peter Steinberger 82b7153ac1 fix: handle daemon install failure in wizard 2026-01-17 23:00:34 +00:00
Peter Steinberger 7d2e510087 fix: retry embedding 5xx errors 2026-01-17 22:48:50 +00:00
Peter Steinberger 9ca4c10e59 test: cover channels capabilities probes 2026-01-17 22:33:18 +00:00
Peter Steinberger a31a79396b feat: add OpenAI batch memory indexing 2026-01-17 22:32:04 +00:00
Peter Steinberger acc3eb11d0 Update bird skill with Twitter posting wisdom from Ruby
- CLI for reading only (Twitter flags CLI posts as automated)
- Browser tool with paste hack for writing
- React input workaround with ClipboardEvent
- Selectors and rate limiting tips
- Credit: Shadow's Ruby documented the forbidden arts
2026-01-17 22:28:23 +00:00
Peter Steinbergerandabdaraxus 9d9fff2991 fix: sessions list label fallback
Co-authored-by: abdaraxus <abdaraxus@users.noreply.github.com>
2026-01-17 22:22:01 +00:00
Peter Steinberger 030ed5d592 fix: skip empty memory chunks 2026-01-17 21:58:59 +00:00
Peter Steinberger f6d359932a fix: parallelize memory embedding indexing 2026-01-17 21:57:12 +00:00
Peter Steinberger 3200b51160 fix: format exec elevated flag first in tool summaries 2026-01-17 21:54:24 +00:00
Peter Steinberger 4b11ebb30e fix: split long memory lines 2026-01-17 21:11:56 +00:00
Peter Steinberger 40345642fa fix: show memory index counts in progress 2026-01-17 21:09:22 +00:00
Peter Steinberger e932772230 fix: report memory index progress 2026-01-17 20:42:04 +00:00
Peter SteinbergerandClaude Opus 4.5 63d466fe5e fix(telegram): expand text_link entities in inbound text
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 20:41:34 +00:00
Peter Steinberger c2fada7062 fix: suppress duplicate discord slow-listener logs 2026-01-17 20:37:36 +00:00
Peter Steinberger d9c29f5ce5 fix: add agent context to ws logs 2026-01-17 20:37:36 +00:00
Peter Steinberger f5d5ef6857 feat: confirm memory index completion 2026-01-17 20:35:15 +00:00
Peter Steinberger 361a17415f chore: release 2026.1.17-1 2026-01-17 20:26:24 +00:00
Peter Steinberger fb393c3c51 feat: add progress to memory status deep 2026-01-17 20:25:19 +00:00
Peter Steinberger e0158c5d5d feat: add deep memory status checks 2026-01-17 20:18:36 +00:00
Peter Steinberger be12b0771c fix: soften windows daemon install 2026-01-17 20:12:26 +00:00
Peter Steinberger 1309fc1f48 test: expand frontmatter coverage 2026-01-17 20:12:04 +00:00
Peter Steinberger 4fdecfb845 fix: split memory embedding batches 2026-01-17 20:10:11 +00:00
Peter Steinberger 31c6f178f3 fix: preserve inline frontmatter values 2026-01-17 19:56:10 +00:00
Peter Steinberger 1e2ab8bf1e fix: improve frontmatter parsing 2026-01-17 19:56:10 +00:00
Sebastian Slight 35a1d81518 fix: handle multi-line metadata blocks in HOOK.md frontmatter
The frontmatter parser was using a simple line-by-line regex that only
captured single-line key-value pairs. This meant multi-line metadata
blocks (as used by bundled hooks) were not parsed correctly.

Changes:
- Add extractMultiLineValue() to handle indented continuation lines
- Use JSON5 instead of JSON.parse() to support trailing commas
- Add comprehensive test coverage for frontmatter parsing

Fixes #1113
2026-01-17 19:56:10 +00:00
Peter Steinberger 1c4297d8b5 test: update memory cli mocks for vector probe 2026-01-17 19:49:41 +00:00
Peter Steinberger e3638a9a9e fix: probe memory vector availability 2026-01-17 19:46:34 +00:00
Peter Steinberger 1f8558771a Docs: note MiniMax usage endpoint 2026-01-17 19:45:54 +00:00
Peter Steinberger 2e231d09ec Infra: update MiniMax usage endpoint 2026-01-17 19:45:48 +00:00
Peter Steinberger 727c07bd88 feat: add slack user scopes and teams graph hints 2026-01-17 19:33:03 +00:00
Peter Steinberger c32ad19377 docs: restore changelog entries 2026-01-17 19:32:30 +00:00
Peter Steinberger ef40ab2933 test: expand memory cli coverage 2026-01-17 19:30:46 +00:00
Peter Steinberger e71fa4a145 docs: note session log disk access 2026-01-17 19:30:46 +00:00
Peter Steinberger a7c0887f94 feat: add per-provider scope probes to channels capabilities 2026-01-17 19:28:52 +00:00
Peter Steinberger 53218b91c6 fix: close memory cli managers 2026-01-17 19:20:55 +00:00
Peter Steinberger 2d4de656d2 test: avoid global SIGTERM emit in child-process-bridge 2026-01-17 19:20:48 +00:00
Peter Steinberger b0f44acf9e chore: bump versions to 2026.1.17 2026-01-17 19:16:35 +00:00
Peter Steinberger a828e60067 feat: add channels capabilities command 2026-01-17 19:06:07 +00:00
Peter Steinberger 96df70fccf fix: add nested agent log context 2026-01-17 18:59:59 +00:00
Peter Steinberger 0e49dca53c feat: add experimental session memory source 2026-01-17 18:53:52 +00:00
Peter SteinbergerandRhys Johnston 8ec4af4641 fix(status): show 2 usage windows in /status (#1101)
Thanks @rhjoh.

Co-authored-by: Rhys Johnston <rhys.johnston00@gmail.com>
2026-01-17 18:46:41 +00:00
Peter Steinberger 2f6d9417bd test(memory): await watch sync completion 2026-01-17 18:45:42 +00:00
Peter Steinberger 534a012a4e style: apply oxfmt 2026-01-17 18:32:23 +00:00
Peter Steinberger 7a3fa9ce03 feat: show update availability in status 2026-01-17 18:23:27 +00:00
Peter Steinberger 8a67d29748 fix: improve WSL2 systemd daemon hints 2026-01-17 18:19:55 +00:00
Peter Steinberger 408f4f2dac fix: reuse shared ansi stripper 2026-01-17 18:18:14 +00:00
Peter Steinberger 3df2dc0b15 fix: normalize exec tool alias naming 2026-01-17 18:15:45 +00:00
Peter Steinberger 5304a8c2d1 fix: add timestamped tool context to logs 2026-01-17 18:14:21 +00:00
Peter Steinberger 1569d29b2d fix: normalize telegram forwarded context (#1090) (thanks @sleontenko) 2026-01-17 18:08:23 +00:00
Peter SteinbergerandYuri Chukhlib 50c8e74230 fix(doctor): avoid ack reaction migration without config (#1087)
Thanks @YuriNachos.

Co-authored-by: Yuri Chukhlib <YuriNachos@users.noreply.github.com>
2026-01-17 18:07:06 +00:00
Peter Steinberger 1045b032a2 refactor(logging): use subsystem loggers for discord/ws 2026-01-17 18:03:40 +00:00
Peter SteinbergerandYuri Chukhlib a813343aa7 docs: clarify model refs and runtime notes
Co-authored-by: Yuri Chukhlib <YuriNachos@users.noreply.github.com>
2026-01-17 18:03:40 +00:00
Peter Steinberger 5a08471dcd feat: add sqlite-vec memory search acceleration 2026-01-17 18:02:34 +00:00
Peter Steinberger 252dfbcd40 fix: include context in elevated exec denial 2026-01-17 17:55:11 +00:00
Peter Steinberger 75588fe732 test: expand semver parsing coverage 2026-01-17 17:54:41 +00:00
Peter Steinberger 9bbdeb3d52 Merge pull request #1111 from artuskg/fix/cli-install-version-suffix
macos: keep CLI install build suffix
2026-01-17 17:46:13 +00:00
Peter Steinberger ec9ba5b784 fix: show full gateway version string in status (#1111) (thanks @artuskg) 2026-01-17 17:45:14 +00:00
Artus KG cee4149884 macos: handle empty install version safely 2026-01-17 17:45:14 +00:00
Artus KG 5599e4cf35 test: make session update timestamp UTC 2026-01-17 17:45:14 +00:00
Artus KG 84cdd2df73 changelog: note CLI install build suffix fix 2026-01-17 17:45:14 +00:00
Artus KG 7929f57460 macos: keep CLI install build suffix 2026-01-17 17:45:04 +00:00
Peter Steinberger 7876679c5d style: apply oxfmt 2026-01-17 17:44:54 +00:00
Peter Steinberger bc6928525d docs: note discord interaction logging fix 2026-01-17 17:42:56 +00:00
Peter Steinberger 755c847d9a fix: soften discord interaction logging 2026-01-17 17:42:46 +00:00
Peter Steinberger 80a8639940 refactor: centralize telegram send param parsing 2026-01-17 17:36:37 +00:00
Peter Steinberger 6cb5704291 Merge pull request #1085 from dan-dr/chore/kimi-code-provider
Add Kimi Code provider onboarding
2026-01-17 17:36:30 +00:00
Peter Steinberger 4a987c836d fix: add Kimi Code docs + defaults (#1085) (thanks @dan-dr) 2026-01-17 17:35:40 +00:00
Peter Steinberger a2fb55326c Merge pull request #1099 from mukhtharcm/feat/message-tool-voice-support
feat(telegram): support sending audio as native voice notes via asVoice param in message tool
2026-01-17 17:33:20 +00:00
Peter Steinberger af29c6a980 fix: allow media-only telegram voice sends (#1099) (thanks @mukhtharcm) 2026-01-17 17:33:08 +00:00
Muhammed Mukhthar CM f2a0e8e5bb feat(telegram): support sending audio as native voice notes via asVoice param in message tool 2026-01-17 17:32:50 +00:00
Peter Steinberger f6456c2883 Merge pull request #1106 from gumadeiras/patch-2
Remove extra 'logging' page in the docs
2026-01-17 17:32:15 +00:00
Peter Steinberger 39f0d000d1 Merge pull request #1088 from sibbl/fix-matrix
feat(matrix): fix sending bug, add specific support for voice messages and images
2026-01-17 17:27:44 +00:00
Peter Steinberger a8d9d630bc fix: handle legacy matrix polls (#1088) (thanks @sibbl) 2026-01-17 17:27:12 +00:00
ddyo e93a1d8138 feat: add kimi code provider onboarding 2026-01-17 17:25:07 +00:00
Peter Steinberger f6681be6f4 style: tidy macOS config UI formatting 2026-01-17 17:22:42 +00:00
Peter Steinberger c79ac3fe81 test: cover semver suffix variants 2026-01-17 17:15:08 +00:00
Sebastian Schubotz b78b06353a feat(matrix): add specific voice message + image sending extending generic attachment sending 2026-01-17 17:12:38 +00:00
Sebastian Schubotz c49b6cc241 fix(matrix): fix sending being broken by normalizing thread ID normalization in message sending functions; improve matrix types 2026-01-17 17:12:38 +00:00
Peter Steinberger 30c945fe92 fix: cover semver patch suffix parsing (#1110) (thanks @zerone0x) 2026-01-17 16:50:05 +00:00
Peter Steinberger dfd511c310 Merge pull request #1110 from zerone0x/fix/issue-1107-semver-prerelease-suffix
fix(macos): parse semver patch correctly when version has prerelease suffix
2026-01-17 16:45:49 +00:00
Peter Steinberger 1657525201 chore: prep 2026.1.17 and onboard flow 2026-01-17 16:41:25 +00:00
zerone0xandClaude 3e4b0d0505 fix(macos): parse semver patch correctly when version has prerelease suffix
Strip prerelease (`-beta.1`) and build (`-4`) suffixes from the patch
component before parsing as integer. Previously `2026.1.11-4` parsed to
`patch: 0` because `Int("11-4")` returns nil; now correctly yields
`patch: 11`.

Fixes #1107

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-18 00:31:20 +08:00
Michael Behr 003c6c9ae1 add patches/.gitkeep (#1104) 2026-01-17 08:50:50 -06:00
Gustavo Madeira Santana 41fbb4d8b0 Remove extra 'logging' page in the docs
Removed 'logging' from the list of gateways.
2026-01-17 09:49:39 -05:00
Peter Steinberger eecb340f64 style: format update-cli 2026-01-17 12:51:08 +00:00
Peter Steinberger d029eaa0bb docs: tighten release preflight 2026-01-17 12:47:54 +00:00
Peter Steinberger 9c7dcc1ed7 chore: update appcast for 2026.1.16-2 2026-01-17 12:46:42 +00:00
Peter Steinberger be37b39782 docs: clarify build-info release check 2026-01-17 12:34:37 +00:00
Peter Steinberger 49c35c752c fix: stamp build commit metadata 2026-01-17 12:30:11 +00:00
Peter Steinberger 25d8043b9d feat: add gateway update check on start 2026-01-17 12:07:17 +00:00
Peter Steinberger f9f4a953fc docs: restore tmux skill 2026-01-17 11:52:50 +00:00
Peter Steinberger 34c3fbc66c chore: set extension versions to 2026.1.16 2026-01-17 11:40:25 +00:00
Peter Steinberger a9f21b3d3a feat: add update channel support 2026-01-17 11:40:05 +00:00
Peter Steinberger ed5c5629f6 fix: cut 2026.1.16-1 beta 2026-01-17 11:12:43 +00:00
Peter Steinberger 868952f958 docs: add release guardrails 2026-01-17 11:12:27 +00:00
Peter Steinberger 9b9836be71 fix: repair 2026.1.16 beta pack 2026-01-17 11:08:37 +00:00
Peter Steinberger 22cd839cb2 fix: include media-understanding in npm pack 2026-01-17 11:03:46 +00:00
Peter Steinberger dc3ac9fa28 docs: update coding-agent skill guidance 2026-01-17 10:59:23 +00:00
Peter Steinberger c874fa9712 chore: bump 2026.1.16 for beta 2026-01-17 10:49:49 +00:00
Peter Steinberger 6b784a9771 style: oxfmt 2026-01-17 10:26:08 +00:00
Peter SteinbergerandMuhammed Mukhthar CM f8e673cdbc fix: block invalid config startup
Co-authored-by: Muhammed Mukhthar CM <mukhtharcm@gmail.com>
2026-01-17 10:25:24 +00:00
Peter Steinberger ad360b4d18 docs(discord): clarify slash command visibility 2026-01-17 10:19:34 +00:00
Peter Steinberger 69ba2765de refactor(security): harden CommandAuthorized plumbing 2026-01-17 10:19:34 +00:00
Peter Steinberger 31e8ecca10 fix: format verbose tool output by channel 2026-01-17 10:17:57 +00:00
Peter SteinbergerandItzR3NO 4ca38286d8 chore: fix lint/format and update changelog
Co-authored-by: ItzR3NO <ItzR3NO@users.noreply.github.com>
2026-01-17 10:16:35 +00:00
Peter Steinberger fbf1c3ca3c test: cover plugin enable/disable semantics 2026-01-17 10:16:35 +00:00
Peter Steinberger 1a4313c2aa fix: avoid crash on memory embeddings errors (#1004) 2026-01-17 09:45:53 +00:00
Peter SteinbergerandItzR3NO a6deb0d9d5 feat: bundle provider auth plugins
Co-authored-by: ItzR3NO <ItzR3NO@users.noreply.github.com>
2026-01-17 09:38:53 +00:00
Peter Steinberger b6ea5895b6 fix: gate image tool and deepgram audio payload 2026-01-17 09:34:40 +00:00
Peter Steinberger d66bc65ca6 refactor: unify media provider options 2026-01-17 09:28:05 +00:00
Peter Steinberger 89f85ddeab fix: normalize deepgram audio upload bytes 2026-01-17 09:19:27 +00:00
Peter Steinberger bbb71c9198 refactor: prune legacy group targets 2026-01-17 09:01:47 +00:00
Peter Steinberger ae6792522d feat: add deepgram audio options 2026-01-17 08:53:42 +00:00
Peter SteinbergerandSafzan Pirani e637bbdfb5 feat: add Deepgram audio transcription
Co-authored-by: Safzan Pirani <safzanpirani@users.noreply.github.com>
2026-01-17 08:53:42 +00:00
Peter Steinberger 869ef0c5ba refactor(macos): centralize process pipe draining 2026-01-17 08:53:10 +00:00
Peter Steinberger 1002c74d9c refactor: share inbound envelope label helper 2026-01-17 08:51:31 +00:00
Peter Steinberger 61e60f3b84 docs: update changelog for 2026.1.16 2026-01-17 08:50:33 +00:00
Peter Steinberger 13b931c006 refactor: prune legacy group prefixes 2026-01-17 08:47:25 +00:00
Peter SteinbergerandTyler Yust ab49fe0e92 fix: tidy iMessage/Signal sender envelopes (#1080) - thanks @tyler6204
Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM>
2026-01-17 08:29:54 +00:00
Tyler Yust d0bc08a934 fix: reduce redundant envelope formatting for iMessage and Signal 2026-01-17 08:29:54 +00:00
Tyler Yust 64d21f5ea8 fix: improve message handling by logging sender issues 2026-01-17 08:29:54 +00:00
Peter Steinberger 0a95d8a840 fix(skills): fix watcher ignored typing 2026-01-17 08:28:09 +00:00
Peter Steinberger 56f3a2de25 fix(security): default-deny command execution 2026-01-17 08:28:09 +00:00
Peter Steinberger d8b463d0b3 fix: cap pending process output 2026-01-17 08:26:12 +00:00
Peter SteinbergerandSash Catanzarite eef3df9fa5 fix(macos): drain subprocess pipes before wait (#1081)
Thanks @thesash.

Co-authored-by: Sash Catanzarite <sashcatanzarite@Sash-MacBook-Pro-14in-3.local>
2026-01-17 08:24:59 +00:00
Peter Steinberger 837eea4ebd fix: refresh TUI session info after runs 2026-01-17 08:22:32 +00:00
Peter Steinberger 55622bac06 Merge pull request #1079 from d-ploutarchos/fix/tui-token-refresh
TUI: refresh token counts after agent runs complete
2026-01-17 08:17:03 +00:00
Peter Steinberger f172ccfcf6 fix: remove stray skills watcher bracket 2026-01-17 08:15:21 +00:00
Peter Steinberger a2a6893566 fix: allow skills watcher ignore list 2026-01-17 08:12:57 +00:00
Peter Steinberger 616ee3075c fix: repair skills watcher ignored typing 2026-01-17 08:12:00 +00:00
Peter Steinberger c5239f6a8e fix: stabilize pty tests and media kind 2026-01-17 08:10:44 +00:00
Peter Steinberger cccd7c7b8e test: stabilize windows pty expectations 2026-01-17 08:07:31 +00:00
Peter Steinberger 8f1132e8ec fix: share skills watcher ignores 2026-01-17 08:07:06 +00:00
Peter Steinberger e6477363e9 refactor: normalize channel capabilities typing 2026-01-17 08:06:35 +00:00
Peter Steinberger 1a4fc8dea6 fix: guard memory sync errors 2026-01-17 08:04:48 +00:00
Peter Steinberger a3daf3d115 style: oxfmt 2026-01-17 08:01:46 +00:00
Peter Steinbergeranddanielz1z f3f80509e3 test: cover tg/group/topic inline button targets (#1072) — thanks @danielz1z
Co-authored-by: danielz1z <danielz1z@users.noreply.github.com>
2026-01-17 08:00:16 +00:00
Peter Steinberger 7cebe7a506 style: run oxfmt 2026-01-17 08:00:05 +00:00
Peter Steinberger 5986175268 fix: restore CI lint/build 2026-01-17 08:00:05 +00:00
Peter Steinberger 7630c6dccb Merge pull request #1074 from roshanasingh4/fix/1056-ignore-heavy-watch-paths
Fix #1056: prevent macOS FD exhaustion by ignoring node_modules in skills watcher
2026-01-17 07:56:54 +00:00
Peter Steinbergeranddanielz1z d63cc1e8a7 fix: allow telegram prefixes/topics for inline buttons (#1072) — thanks @danielz1z
Co-authored-by: danielz1z <danielz1z@users.noreply.github.com>
2026-01-17 07:49:57 +00:00
danielz1z 80bb6b712c fix: handle telegram: prefix in resolveTelegramTargetChatType
When using inlineButtons="dm" or "group" scope, the validation check
resolveTelegramTargetChatType() failed for numeric chat IDs because
normalizeTelegramMessagingTarget() adds a "telegram:" prefix during
target resolution.

For example, target "5232990709" becomes "telegram:5232990709" after
normalization, but the regex /^-?\d+$/ expects a pure numeric string.

The fix strips the telegram: prefix before checking the numeric pattern.

Adds tests for resolveTelegramTargetChatType with various input formats.
2026-01-17 07:47:14 +00:00
Peter SteinbergerandRoshan Singh 410b8f223e fix: keep extension relay list current (#1073)
Thanks @roshanasingh4.

Co-authored-by: Roshan Singh <88576930+roshanasingh4@users.noreply.github.com>
2026-01-17 07:43:31 +00:00
Roshan Singh 693f152895 Fix #1035: refresh extension tab metadata
Handle Target.targetInfoChanged in extension relay so /json/list reflects updated title/url after navigation. Adds regression coverage.
2026-01-17 07:43:09 +00:00
Peter Steinberger 78a4441ac2 test: stabilize bash send-keys submit 2026-01-17 07:41:24 +00:00
Peter Steinberger c92265a51b refactor: canonicalize gateway session store keys 2026-01-17 07:41:24 +00:00
Peter Steinberger d5fdda8e28 refactor: prune room legacy 2026-01-17 07:41:24 +00:00
Dimitrios Ploutarchos cddf198321 TUI: refresh token counts after agent runs complete. Closes #1078 2026-01-17 07:40:59 +00:00
Peter Steinberger 6d969fe58e refactor: normalize media attachment selection 2026-01-17 07:38:11 +00:00
Peter Steinberger 68c7d577a4 chore: drop target format helper 2026-01-17 07:36:13 +00:00
Peter Steinberger 1ea8917e2b refactor: trim resolver exports 2026-01-17 07:36:09 +00:00
Peter Steinberger 07c93dfd30 refactor: streamline target resolver helpers 2026-01-17 07:34:26 +00:00
Peter Steinberger cf0ea6c756 refactor: unify target resolver metadata 2026-01-17 07:34:26 +00:00
Peter SteinbergerandAdam Holt 8c9e32c4a3 refactor: share sessions list row type
Co-authored-by: Adam Holt <mail@adamholt.co.nz>
2026-01-17 07:34:21 +00:00
Peter Steinberger 34d59d7913 refactor: rename hooks docs and add tests 2026-01-17 07:32:54 +00:00
Peter Steinberger 0c0d9e1d22 Merge pull request #1071 from danielz1z/fix/capabilities-object-format
fix: handle object-format capabilities in normalizeCapabilities
2026-01-17 07:31:52 +00:00
Peter Steinberger 2ee45d50a4 refactor: tighten media diagnostics 2026-01-17 07:27:38 +00:00
Peter Steinberger 0c0e1e4226 refactor: extend media understanding 2026-01-17 07:17:13 +00:00
Roshan Singh e7953d8164 Fix #1056: ignore heavy paths in skills watcher
On macOS, watching deep dependency trees can exhaust file descriptors and lead to spawn EBADF failures. The skills watcher only needs to observe skill changes, so ignore dotfiles, node_modules, and dist by default. Adds regression coverage.
2026-01-17 06:26:27 +00:00
danielz1z f42b12646d fix: handle object-format capabilities in normalizeCapabilities
When capabilities is configured as an object (e.g., { inlineButtons: "dm" })
instead of a string array, normalizeCapabilities() would crash with
"capabilities.map is not a function".

This can occur when using the new Telegram inline buttons scoping feature:
  channels.telegram.capabilities.inlineButtons = "dm"

The fix adds an Array.isArray() guard to return undefined for non-array
capabilities, allowing channel-specific handlers (like
resolveTelegramInlineButtonsScope) to process the object format separately.

Fixes crash when using object-format TelegramCapabilitiesConfig.
2026-01-17 05:11:57 +00:00
633 changed files with 25855 additions and 5649 deletions

No files matched your search

+2
View File
@@ -73,6 +73,7 @@
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
- Environment variables: see `~/.profile`.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
## Troubleshooting
- Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
@@ -117,6 +118,7 @@
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step.
## NPM + 1Password (publish/verify)
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
+130 -13
View File
@@ -1,40 +1,136 @@
# Changelog
## 2026.1.16 (unreleased)
Docs: https://docs.clawd.bot
## 2026.1.17-6
### Changes
- Plugins: add exclusive plugin slots with a dedicated memory slot selector.
- Memory: ship core memory tools + CLI as the bundled `memory-core` plugin.
- Docs: document plugin slots and memory plugin behavior.
- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
### Fixes
- Agents: trigger model fallback for prompt-phase failover errors, respecting per-agent overrides. (#1136) — thanks @cheeeee.
## 2026.1.17-5
### Changes
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback.
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates.
- CLI: surface memory search state in `clawdbot status` and detailed FTS + embedding cache state in `clawdbot memory status`.
## 2026.1.17-4
### Changes
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
### Fixes
- Memory: apply OpenAI batch defaults even without explicit remote config.
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
- Discord: only emit slow listener warnings after 30s.
## 2026.1.17-3
### Changes
- Memory: add OpenAI Batch API indexing for embeddings when configured.
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings.
- Sessions: persist origin metadata across connectors for generic session explainers.
### Fixes
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
## 2026.1.17-2
### Changes
### Fixes
- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.
- Memory: parallelize embedding indexing with rate-limit retries.
- Memory: split overly long lines to keep embeddings under token limits.
- Memory: skip empty chunks to avoid invalid embedding inputs.
- Sessions: fall back to session labels when listing display names. (#1124) — thanks @abdaraxus.
- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123) — thanks @thewilloftheshadow.
## 2026.1.17-1
### Changes
- Telegram: enrich forwarded message context with normalized origin details + legacy fallback. (#1090) — thanks @sleontenko.
- macOS: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x.
- macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg.
- CLI: surface update availability in `clawdbot status`.
- CLI: add `clawdbot memory status --deep/--index` probes.
- CLI: add playful update completion quips.
### Fixes
- Doctor: avoid re-adding WhatsApp ack reaction config when only legacy auth files exist. (#1087) — thanks @YuriNachos.
- Hooks: parse multi-line/YAML frontmatter metadata blocks (JSON5-friendly). (#1114) — thanks @sebslight.
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
- Windows: install gateway scheduled task as the current user; show friendly guidance instead of failing on access denied.
- Status: show both usage windows with reset hints when usage data is available. (#1101) — thanks @rhjoh.
- Memory: probe sqlite-vec availability in `clawdbot memory status`.
- Memory: split embedding batches to avoid OpenAI token limits during indexing.
- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118) — thanks @sleontenko.
## 2026.1.16-2
### Changes
- CLI: stamp build commit into dist metadata so banners show the commit in npm installs.
- CLI: close memory manager after memory commands to avoid hanging processes. (#1127) — thanks @NicholasSpisak.
## 2026.1.16-1
### Highlights
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos.
- Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh.
- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins.
- Sessions: add `session.identityLinks` for cross-platform DM session linking. (#1033) — thanks @thewilloftheshadow.
- Hooks: add internal hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake.
- Hooks: add hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake. https://docs.clawd.bot/hooks
- Media: add inbound media understanding (image/audio/video) with provider + CLI fallbacks. https://docs.clawd.bot/nodes/media-understanding
- Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh. https://docs.clawd.bot/plugins/zalouser
- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins. https://docs.clawd.bot/providers/vercel-ai-gateway
- Sessions: add `session.identityLinks` for cross-platform DM session li nking. (#1033) — thanks @thewilloftheshadow. https://docs.clawd.bot/concepts/session
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos. https://docs.clawd.bot/tools/web
### Breaking
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
- **BREAKING:** `clawdbot message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan.
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
- **BREAKING:** drop legacy target normalization helpers; use outbound target normalization and resolver flows.
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; internal hooks live under `clawdbot hooks`.
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; hooks live under `clawdbot hooks`. https://docs.clawd.bot/cli/webhooks
- **BREAKING:** `clawdbot plugins install <path>` now copies into `~/.clawdbot/extensions` (use `--link` to keep path-based loading).
### Changes
- Plugins: ship bundled plugins disabled by default and allow overrides by installed versions. (#1066) — thanks @ItzR3NO.
- Plugins: add bundled Antigravity + Gemini CLI OAuth + Copilot Proxy provider plugins. (#1066) — thanks @ItzR3NO.
- Tools: improve `web_fetch` extraction using Readability (with fallback).
- Tools: add Firecrawl fallback for `web_fetch` when configured.
- Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites.
- Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails.
- Tools: default `exec` exit notifications and auto-migrate legacy `tools.bash` to `tools.exec`.
- Tools: add `exec` PTY support for interactive sessions. https://docs.clawd.bot/tools/exec
- Tools: add tmux-style `process send-keys` and bracketed paste helpers for PTY sessions.
- Tools: add `process submit` helper to send CR for PTY sessions.
- Tools: respond to PTY cursor position queries to unblock interactive TUIs.
- Tools: include tool outputs in verbose mode and expand verbose tool feedback.
- Skills: update coding-agent guidance to prefer PTY-enabled exec runs and simplify tmux usage.
- TUI: refresh session token counts after runs complete or fail. (#1079) — thanks @d-ploutarchos.
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
- Directory: unify `clawdbot directory` across channels and plugin channels.
- UI: allow deleting sessions from the Control UI.
- Memory: add sqlite-vec vector acceleration with CLI status details.
- Memory: add experimental session transcript indexing for memory_search (opt-in via memorySearch.experimental.sessionMemory + sources).
- Skills: add user-invocable skill commands and expanded skill command registration.
- Telegram: default reaction level to minimal and enable reaction notifications by default.
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
- iMessage: add remote attachment support for VM/SSH deployments.
- Messages: refresh live directory cache results when resolving targets.
- Messages: mirror delivered outbound text/media into session transcripts. (#1031) — thanks @TSavo.
- Messages: avoid redundant sender envelopes for iMessage + Signal group chats. (#1080) — thanks @tyler6204.
- Media: normalize Deepgram audio upload bytes for fetch compatibility.
- Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup.
- Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs.
- Config: support env var substitution in config values. (#1044) — thanks @sebslight.
@@ -43,29 +139,50 @@
- Plugins: add zip installs and `--link` to avoid copying local paths.
### Fixes
- macOS: drain subprocess pipes before waiting to avoid deadlocks. (#1081) — thanks @thesash.
- Verbose: wrap tool summaries/output in markdown only for markdown-capable channels.
- Tools: include provider/session context in elevated exec denial errors.
- Tools: normalize exec tool alias naming in tool error logs.
- Logging: reuse shared ANSI stripping to keep console capture lint-clean.
- Logging: prefix nested agent output with session/run/channel context.
- Telegram: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z.
- Telegram: split long captions into follow-up messages.
- Config: block startup on invalid config, preserve best-effort doctor config, and keep rolling config backups. (#1083) — thanks @mukhtharcm.
- Sub-agents: normalize announce delivery origin + queue bucketing by accountId to keep multi-account routing stable. (#1061, #1058) — thanks @adam91holt.
- Config: handle object-format Telegram capabilities in channel capability resolution. (#1071) — thanks @danielz1z.
- Sessions: include deliveryContext in sessions.list and reuse normalized delivery routing for announce/restart fallbacks. (#1058)
- Sessions: propagate deliveryContext into last-route updates to keep account/channel routing stable. (#1058)
- Sessions: preserve overrides on `/new` reset.
- Memory: prevent unhandled rejections when watch/interval sync fails. (#1076) — thanks @roshanasingh4.
- Memory: avoid gateway crash when embeddings return 429/insufficient_quota (disable tool + surface error). (#1004)
- Gateway: honor explicit delivery targets without implicit accountId fallback; preserve lastAccountId for implicit routing.
- Gateway: avoid reusing last-to/accountId when the requested channel differs; sync deliveryContext with last route fields.
- Build: allow `@lydell/node-pty` builds on supported platforms.
- Repo: fix oxlint config filename and move ignore pattern into config. (#1064) — thanks @connorshea.
- Messages: `/stop` now hard-aborts queued followups and sub-agent runs; suppress zero-count stop notes.
- Messages: honor message tool channel when deduping sends.
- Messages: include sender labels for live group messages across channels, matching queued/history formatting. (#1059)
- Sessions: reset `compactionCount` on `/new` and `/reset`, and preserve `sessions.json` file mode (0600).
- Sessions: repair orphaned user turns before embedded prompts.
- Sessions: hard-stop `sessions.delete` cleanup.
- Channels: treat replies to the bot as implicit mentions across supported channels.
- Security: lock down slash/control commands to sender allowlists across Discord/Slack/Telegram/Signal/iMessage/WhatsApp (+ plugin channels like Matrix/Teams) and add stable `clawdbot security audit` checkIds for Slack/Discord command allowlists.
- Channels: normalize object-format capabilities in channel capability parsing.
- Security: default-deny slash/control commands unless a channel computed `CommandAuthorized` (fixes accidental “open” behavior), and ensure WhatsApp + Zalo plugin channels gate inline `/…` tokens correctly. https://docs.clawd.bot/gateway/security
- Security: redact sensitive text in gateway WS logs.
- Tools: cap pending `exec` process output to avoid unbounded buffers.
- CLI: speed up `clawdbot sandbox-explain` by avoiding heavy plugin imports when normalizing channel ids.
- Browser: remote profile tab operations prefer persistent Playwright and avoid silent HTTP fallbacks. (#1057) — thanks @mukhtharcm.
- Browser: remote profile tab ops follow-up: shared Playwright loader, Playwright-based focus, and more coverage (incl. opt-in live Browserless test). (follow-up to #1057) — thanks @mukhtharcm.
- Browser: refresh extension relay tab metadata after navigation so `/json/list` stays current. (#1073) — thanks @roshanasingh4.
- WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message.
- WhatsApp: include `linked` field in `describeAccount`.
- Agents: drop unsigned Gemini tool calls and avoid JSON Schema `format` keyword collisions.
- Agents: hide the image tool when the primary model already supports images.
- Agents: avoid duplicate sends by replying with `NO_REPLY` after `message` tool sends.
- Auth: inherit/merge sub-agent auth profiles from the main agent.
- Gateway: resolve local auth for security probe and validate gateway token/password file modes. (#1011, #1022) — thanks @ivanrvpereira, @kkarimi.
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
- OpenAI image-gen: remove deprecated `response_format` and use URL downloads.
- iMessage: avoid RPC restart loops.
- OpenAI image-gen: handle URL + `b64_json` responses and remove deprecated `response_format` (use URL downloads).
- CLI: auto-update global installs when installed via a package manager.
- Routing: migrate legacy `accountID` bindings to `accountId` and remove legacy fallback lookups. (#1047) — thanks @gumadeiras.
- Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr.
@@ -73,6 +190,7 @@
- Models: align ZAI thinking toggles.
- iMessage/Signal: include sender metadata for non-queued group messages. (#1059)
- Discord: preserve whitespace when chunking long lines so message splits keep spacing intact.
- Skills: fix skills watcher ignored list typing (tsc).
## 2026.1.15
@@ -113,7 +231,6 @@
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
- Media: add optional inbound media understanding for image/audio/video with provider + CLI fallbacks. (#1005) — thanks @tristanmanchester.
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
+17 -16
View File
@@ -478,20 +478,21 @@ Thanks to all clawtributors:
<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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=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/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/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a>
<a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a>
<a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
<a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
<a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a>
<a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a>
<a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a>
<a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a>
<a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a>
<a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
<a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a>
<a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a>
<a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a>
<a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a>
<a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p>
+16 -16
View File
@@ -2,6 +2,22 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdbot</title>
<item>
<title>2026.1.16-2</title>
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>6273</sparkle:version>
<sparkle:shortVersionString>2026.1.16-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
<h3>Changes</h3>
<ul>
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
</item>
<item>
<title>2026.1.15</title>
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
@@ -255,21 +271,5 @@
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
</item>
<item>
<title>2026.1.12-2</title>
<pubDate>Tue, 13 Jan 2026 10:05:25 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5534</sparkle:version>
<sparkle:shortVersionString>2026.1.12-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.12-2</h2>
<h3>Fixes</h3>
<ul>
<li>Packaging: include <code>dist/memory/**</code> in the npm tarball (fixes <code>ERR_MODULE_NOT_FOUND</code> for <code>dist/memory/index.js</code>).</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/>
</item>
</channel>
</rss>
@@ -35,7 +35,7 @@ enum CLIInstaller {
}
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async {
let expected = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
let prefix = Self.installPrefix()
await statusHandler("Installing clawdbot CLI…")
let cmd = self.installScriptCommand(version: expected, prefix: prefix)
@@ -6,11 +6,11 @@ struct ConfigSchemaForm: View {
let path: ConfigPath
var body: some View {
self.renderNode(schema, path: path)
self.renderNode(self.schema, path: self.path)
}
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
let storedValue = store.configValue(at: path)
let storedValue = self.store.configValue(at: path)
let value = storedValue ?? schema.explicitDefault
let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description
@@ -21,7 +21,7 @@ struct ConfigSchemaForm: View {
if nonNull.count == 1, let only = nonNull.first {
return self.renderNode(only, path: path)
}
let literals = nonNull.compactMap { $0.literalValue }
let literals = nonNull.compactMap(\.literalValue)
if !literals.isEmpty, literals.count == nonNull.count {
return AnyView(
VStack(alignment: .leading, spacing: 6) {
@@ -31,15 +31,20 @@ struct ConfigSchemaForm: View {
.font(.caption)
.foregroundStyle(.secondary)
}
Picker("", selection: self.enumBinding(path, options: literals, defaultValue: schema.explicitDefault)) {
Picker(
"",
selection: self.enumBinding(
path,
options: literals,
defaultValue: schema.explicitDefault))
{
Text("Select…").tag(-1)
ForEach(literals.indices, id: \ .self) { index in
Text(String(describing: literals[index])).tag(index)
}
}
.pickerStyle(.menu)
}
)
})
}
}
@@ -71,8 +76,7 @@ struct ConfigSchemaForm: View {
if schema.allowsAdditionalProperties {
self.renderAdditionalProperties(schema, path: path, value: value)
}
}
)
})
case "array":
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
case "boolean":
@@ -80,8 +84,7 @@ struct ConfigSchemaForm: View {
Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) {
if let label { Text(label) } else { Text("Enabled") }
}
.help(help ?? "")
)
.help(help ?? ""))
case "number", "integer":
return AnyView(self.renderNumberField(schema, path: path, label: label, help: help))
case "string":
@@ -93,8 +96,7 @@ struct ConfigSchemaForm: View {
Text("Unsupported field type.")
.font(.caption)
.foregroundStyle(.secondary)
}
)
})
}
}
@@ -155,9 +157,7 @@ struct ConfigSchemaForm: View {
text: self.numberBinding(
path,
isInteger: schema.schemaType == "integer",
defaultValue: defaultValue
)
)
defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
}
}
@@ -189,7 +189,7 @@ struct ConfigSchemaForm: View {
Button("Remove") {
var next = items
next.remove(at: index)
store.updateConfigValue(path: path, value: next)
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
@@ -202,7 +202,7 @@ struct ConfigSchemaForm: View {
} else {
next.append("")
}
store.updateConfigValue(path: path, value: next)
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
@@ -238,7 +238,7 @@ struct ConfigSchemaForm: View {
Button("Remove") {
var next = dict
next.removeValue(forKey: key)
store.updateConfigValue(path: path, value: next)
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
@@ -254,7 +254,7 @@ struct ConfigSchemaForm: View {
key = "new-\(index)"
}
next[key] = additionalSchema.defaultValue
store.updateConfigValue(path: path, value: next)
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
@@ -270,9 +270,8 @@ struct ConfigSchemaForm: View {
},
set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
}
)
self.store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
})
}
private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> {
@@ -282,16 +281,15 @@ struct ConfigSchemaForm: View {
return defaultValue ?? false
},
set: { newValue in
store.updateConfigValue(path: path, value: newValue)
}
)
self.store.updateConfigValue(path: path, value: newValue)
})
}
private func numberBinding(
_ path: ConfigPath,
isInteger: Bool,
defaultValue: Double?
) -> Binding<String> {
defaultValue: Double?) -> Binding<String>
{
Binding(
get: {
if let value = store.configValue(at: path) { return String(describing: value) }
@@ -301,22 +299,21 @@ struct ConfigSchemaForm: View {
set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
store.updateConfigValue(path: path, value: nil)
self.store.updateConfigValue(path: path, value: nil)
} else if let value = Double(trimmed) {
store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
self.store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
}
}
)
})
}
private func enumBinding(
_ path: ConfigPath,
options: [Any],
defaultValue: Any?
) -> Binding<Int> {
defaultValue: Any?) -> Binding<Int>
{
Binding(
get: {
let value = store.configValue(at: path) ?? defaultValue
let value = self.store.configValue(at: path) ?? defaultValue
guard let value else { return -1 }
return options.firstIndex { option in
String(describing: option) == String(describing: value)
@@ -324,12 +321,11 @@ struct ConfigSchemaForm: View {
},
set: { index in
guard index >= 0, index < options.count else {
store.updateConfigValue(path: path, value: nil)
self.store.updateConfigValue(path: path, value: nil)
return
}
store.updateConfigValue(path: path, value: options[index])
}
)
self.store.updateConfigValue(path: path, value: options[index])
})
}
private func mapKeyBinding(path: ConfigPath, key: String) -> Binding<String> {
@@ -339,14 +335,13 @@ struct ConfigSchemaForm: View {
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard trimmed != key else { return }
let current = store.configValue(at: path) as? [String: Any] ?? [:]
let current = self.store.configValue(at: path) as? [String: Any] ?? [:]
guard current[trimmed] == nil else { return }
var next = current
next[trimmed] = current[key]
next.removeValue(forKey: key)
store.updateConfigValue(path: path, value: next)
}
)
self.store.updateConfigValue(path: path, value: next)
})
}
}
@@ -355,10 +350,10 @@ struct ChannelConfigForm: View {
let channelId: String
var body: some View {
if store.configSchemaLoading {
if self.store.configSchemaLoading {
ProgressView().controlSize(.small)
} else if let schema = store.channelConfigSchema(for: channelId) {
ConfigSchemaForm(store: store, schema: schema, path: [.key("channels"), .key(channelId)])
ConfigSchemaForm(store: self.store, schema: schema, path: [.key("channels"), .key(self.channelId)])
} else {
Text("Schema unavailable for this channel.")
.font(.caption)
@@ -434,25 +434,25 @@ extension ChannelsSettings {
private func resolveChannelDetailTitle(_ id: String) -> String {
switch id {
case "whatsapp": return "WhatsApp Web"
case "telegram": return "Telegram Bot"
case "discord": return "Discord Bot"
case "slack": return "Slack Bot"
case "signal": return "Signal REST"
case "imessage": return "iMessage"
default: return self.resolveChannelTitle(id)
case "whatsapp": "WhatsApp Web"
case "telegram": "Telegram Bot"
case "discord": "Discord Bot"
case "slack": "Slack Bot"
case "signal": "Signal REST"
case "imessage": "iMessage"
default: self.resolveChannelTitle(id)
}
}
private func resolveChannelSystemImage(_ id: String) -> String {
switch id {
case "whatsapp": return "message"
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right"
case "slack": return "number"
case "signal": return "antenna.radiowaves.left.and.right"
case "imessage": return "message.fill"
default: return "message"
case "whatsapp": "message"
case "telegram": "paperplane"
case "discord": "bubble.left.and.bubble.right"
case "slack": "number"
case "signal": "antenna.radiowaves.left.and.right"
case "imessage": "message.fill"
default: "message"
}
}
@@ -57,7 +57,7 @@ extension ChannelsStore {
return value
}
guard path.count >= 2 else { return nil }
if case .key("channels") = path[0], case .key(_) = path[1] {
if case .key("channels") = path[0], case .key = path[1] {
let fallbackPath = Array(path.dropFirst())
return valueAtPath(self.configDraft, path: fallbackPath)
}
@@ -93,10 +93,10 @@ private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
var current: Any? = root
for segment in path {
switch segment {
case .key(let key):
case let .key(key):
guard let dict = current as? [String: Any] else { return nil }
current = dict[key]
case .index(let index):
case let .index(index):
guard let array = current as? [Any], array.indices.contains(index) else { return nil }
current = array[index]
}
@@ -107,7 +107,7 @@ private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
guard let segment = path.first else { return }
switch segment {
case .key(let key):
case let .key(key):
var dict = root as? [String: Any] ?? [:]
if path.count == 1 {
if let value {
@@ -122,7 +122,7 @@ private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
setValue(&child, path: Array(path.dropFirst()), value: value)
dict[key] = child
root = dict
case .index(let index):
case let .index(index):
var array = root as? [Any] ?? []
if index >= array.count {
array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1))
@@ -214,9 +214,10 @@ enum CommandResolver {
subcommand: String,
extraArgs: [String] = [],
defaults: UserDefaults = .standard,
configRoot: [String: Any]? = nil,
searchPaths: [String]? = nil) -> [String]
{
let settings = self.connectionSettings(defaults: defaults)
let settings = self.connectionSettings(defaults: defaults, configRoot: configRoot)
if settings.mode == .remote, let ssh = self.sshNodeCommand(
subcommand: subcommand,
extraArgs: extraArgs,
@@ -264,12 +265,14 @@ enum CommandResolver {
subcommand: String,
extraArgs: [String] = [],
defaults: UserDefaults = .standard,
configRoot: [String: Any]? = nil,
searchPaths: [String]? = nil) -> [String]
{
self.clawdbotNodeCommand(
subcommand: subcommand,
extraArgs: extraArgs,
defaults: defaults,
configRoot: configRoot,
searchPaths: searchPaths)
}
@@ -384,8 +387,11 @@ enum CommandResolver {
let cliPath: String
}
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
let root = ClawdbotConfigFile.loadDict()
static func connectionSettings(
defaults: UserDefaults = .standard,
configRoot: [String: Any]? = nil) -> RemoteSettings
{
let root = configRoot ?? ClawdbotConfigFile.loadDict()
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
let target = defaults.string(forKey: remoteTargetKey) ?? ""
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
@@ -133,7 +133,7 @@ struct ConfigSchemaNode {
for segment in path {
guard let node = current else { return nil }
switch segment {
case .key(let key):
case let .key(key):
if node.schemaType == "object" {
if let next = node.properties[key] {
current = next
@@ -174,7 +174,7 @@ func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiH
var match = true
for (index, seg) in segments.enumerated() {
let hintSegment = hintSegments[index]
if hintSegment != "*" && hintSegment != seg {
if hintSegment != "*", hintSegment != seg {
match = false
break
}
@@ -196,7 +196,7 @@ func isSensitivePath(_ path: ConfigPath) -> Bool {
func pathKey(_ path: ConfigPath) -> String {
path.compactMap { segment -> String? in
switch segment {
case .key(let key): return key
case let .key(key): return key
case .index: return nil
}
}
@@ -47,7 +47,7 @@ extension ConfigSettings {
.foregroundStyle(.secondary)
}
}
if self.store.configDirty && !self.isNixMode {
if self.store.configDirty, !self.isNixMode {
Text("Unsaved changes")
.font(.caption)
.foregroundStyle(.secondary)
@@ -25,8 +25,14 @@ struct Semver: Comparable, CustomStringConvertible, Sendable {
let major = Int(parts[0]),
let minor = Int(parts[1])
else { return nil }
let patch = Int(parts[2]) ?? 0
return Semver(major: major, minor: minor, patch: patch)
// Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5")
let patchRaw = String(parts[2])
guard let patchToken = patchRaw.split(whereSeparator: { $0 == "-" || $0 == "+" }).first,
let patchNumeric = Int(patchToken)
else {
return nil
}
return Semver(major: major, minor: minor, patch: patchNumeric)
}
func compatible(with required: Semver) -> Bool {
@@ -78,8 +84,13 @@ enum GatewayEnvironment {
}
static func expectedGatewayVersion() -> Semver? {
Semver.parse(self.expectedGatewayVersionString())
}
static func expectedGatewayVersionString() -> String? {
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
return Semver.parse(bundleVersion)
let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines)
return (trimmed?.isEmpty == false) ? trimmed : nil
}
// Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
@@ -98,6 +109,7 @@ enum GatewayEnvironment {
}
}
let expected = self.expectedGatewayVersion()
let expectedString = self.expectedGatewayVersionString()
let projectRoot = CommandResolver.projectRoot()
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
@@ -108,8 +120,8 @@ enum GatewayEnvironment {
kind: .missingNode,
nodeVersion: nil,
gatewayVersion: nil,
requiredGateway: expected?.description,
message: RuntimeLocator.describeFailure(err))
requiredGateway: expectedString,
message: RuntimeLocator.describeFailure(err))
case let .success(runtime):
let gatewayBin = CommandResolver.clawdbotExecutable()
@@ -118,7 +130,7 @@ enum GatewayEnvironment {
kind: .missingGateway,
nodeVersion: runtime.version.description,
gatewayVersion: nil,
requiredGateway: expected?.description,
requiredGateway: expectedString,
message: "clawdbot CLI not found in PATH; install the CLI.")
}
@@ -126,13 +138,14 @@ enum GatewayEnvironment {
?? self.readLocalGatewayVersion(projectRoot: projectRoot)
if let expected, let installed, !installed.compatible(with: expected) {
let expectedText = expectedString ?? expected.description
return GatewayEnvironmentStatus(
kind: .incompatible(found: installed.description, required: expected.description),
kind: .incompatible(found: installed.description, required: expectedText),
nodeVersion: runtime.version.description,
gatewayVersion: installed.description,
requiredGateway: expected.description,
requiredGateway: expectedText,
message: """
Gateway version \(installed.description) is incompatible with app \(expected.description);
Gateway version \(installed.description) is incompatible with app \(expectedText);
install or update the global package.
""")
}
@@ -150,7 +163,7 @@ enum GatewayEnvironment {
kind: .ok,
nodeVersion: runtime.version.description,
gatewayVersion: gatewayVersionText,
requiredGateway: expected?.description,
requiredGateway: expectedString,
message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)")
}
}
@@ -218,8 +231,18 @@ enum GatewayEnvironment {
}
static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async {
await self.installGlobal(versionString: version?.description, statusHandler: statusHandler)
}
static func installGlobal(versionString: String?, statusHandler: @escaping @Sendable (String) -> Void) async {
let preferred = CommandResolver.preferredPaths().joined(separator: ":")
let target = version?.description ?? "latest"
let trimmed = versionString?.trimmingCharacters(in: .whitespacesAndNewlines)
let target: String
if let trimmed, !trimmed.isEmpty {
target = trimmed
} else {
target = "latest"
}
let npm = CommandResolver.findExecutable(named: "npm")
let pnpm = CommandResolver.findExecutable(named: "pnpm")
let bun = CommandResolver.findExecutable(named: "bun")
@@ -278,8 +301,7 @@ enum GatewayEnvironment {
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = try process.runAndReadToEnd(from: pipe)
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
if elapsedMs > 500 {
self.logger.warning(
@@ -294,7 +316,6 @@ enum GatewayEnvironment {
bin=\(binary, privacy: .public)
""")
}
let data = pipe.fileHandleForReading.readToEndSafely()
let raw = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return Semver.parse(raw)
@@ -83,27 +83,7 @@ struct GeneralSettings: View {
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
binding: self.$cameraEnabled)
VStack(alignment: .leading, spacing: 6) {
Text("Node Run Commands")
.font(.body)
Picker("", selection: self.$state.systemRunPolicy) {
ForEach(SystemRunPolicy.allCases) { policy in
Text(policy.title).tag(policy)
}
}
.labelsHidden()
.pickerStyle(.menu)
Text("""
Controls remote command execution on this Mac when it is paired as a node. \
"Always Ask" prompts on each command; "Always Allow" runs without prompts; \
"Never" disables `system.run`.
""")
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
SystemRunSettingsView()
VStack(alignment: .leading, spacing: 6) {
Text("Location Access")
@@ -72,11 +72,11 @@ enum LaunchAgentManager {
let process = Process()
process.launchPath = "/bin/launchctl"
process.arguments = args
process.standardOutput = Pipe()
process.standardError = Pipe()
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
_ = try process.runAndReadToEnd(from: pipe)
return process.terminationStatus
} catch {
return -1
+1 -3
View File
@@ -16,9 +16,7 @@ enum Launchctl {
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readToEndSafely()
let data = try process.runAndReadToEnd(from: pipe)
let output = String(data: data, encoding: .utf8) ?? ""
return Result(status: process.terminationStatus, output: output)
} catch {
@@ -38,39 +38,14 @@ enum MacNodeConfigFile {
}
}
static func systemRunPolicy() -> SystemRunPolicy? {
let root = self.loadDict()
let systemRun = root["systemRun"] as? [String: Any]
let raw = systemRun?["policy"] as? String
guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil }
return policy
private static func systemRunSection(from root: [String: Any]) -> [String: Any] {
root["systemRun"] as? [String: Any] ?? [:]
}
static func setSystemRunPolicy(_ policy: SystemRunPolicy) {
private static func updateSystemRunSection(_ mutate: (inout [String: Any]) -> Void) {
var root = self.loadDict()
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
systemRun["policy"] = policy.rawValue
root["systemRun"] = systemRun
self.saveDict(root)
}
static func systemRunAllowlist() -> [String]? {
let root = self.loadDict()
let systemRun = root["systemRun"] as? [String: Any]
return systemRun?["allowlist"] as? [String]
}
static func setSystemRunAllowlist(_ allowlist: [String]) {
let cleaned = allowlist
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
var root = self.loadDict()
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
if cleaned.isEmpty {
systemRun.removeValue(forKey: "allowlist")
} else {
systemRun["allowlist"] = cleaned
}
var systemRun = self.systemRunSection(from: root)
mutate(&systemRun)
if systemRun.isEmpty {
root.removeValue(forKey: "systemRun")
} else {
@@ -78,4 +53,147 @@ enum MacNodeConfigFile {
}
self.saveDict(root)
}
private static func agentSection(_ systemRun: [String: Any], agentId: String) -> [String: Any]? {
let agents = systemRun["agents"] as? [String: Any]
return agents?[agentId] as? [String: Any]
}
private static func updateAgentSection(
_ systemRun: inout [String: Any],
agentId: String,
mutate: (inout [String: Any]) -> Void)
{
var agents = systemRun["agents"] as? [String: Any] ?? [:]
var entry = agents[agentId] as? [String: Any] ?? [:]
mutate(&entry)
if entry.isEmpty {
agents.removeValue(forKey: agentId)
} else {
agents[agentId] = entry
}
if agents.isEmpty {
systemRun.removeValue(forKey: "agents")
} else {
systemRun["agents"] = agents
}
}
static func systemRunPolicy(agentId: String? = nil) -> SystemRunPolicy? {
let root = self.loadDict()
let systemRun = self.systemRunSection(from: root)
if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) {
let raw = agent["policy"] as? String
if let raw, let policy = SystemRunPolicy(rawValue: raw) { return policy }
}
let raw = systemRun["policy"] as? String
guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil }
return policy
}
static func setSystemRunPolicy(_ policy: SystemRunPolicy, agentId: String? = nil) {
self.updateSystemRunSection { systemRun in
if let agentId {
self.updateAgentSection(&systemRun, agentId: agentId) { entry in
entry["policy"] = policy.rawValue
}
return
}
systemRun["policy"] = policy.rawValue
}
}
static func systemRunAutoAllowSkills(agentId: String?) -> Bool? {
let root = self.loadDict()
let systemRun = self.systemRunSection(from: root)
if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) {
if let value = agent["autoAllowSkills"] as? Bool { return value }
}
return systemRun["autoAllowSkills"] as? Bool
}
static func setSystemRunAutoAllowSkills(_ enabled: Bool, agentId: String?) {
self.updateSystemRunSection { systemRun in
if let agentId {
self.updateAgentSection(&systemRun, agentId: agentId) { entry in
entry["autoAllowSkills"] = enabled
}
return
}
systemRun["autoAllowSkills"] = enabled
}
}
static func systemRunAllowlist(agentId: String?) -> [SystemRunAllowlistEntry]? {
let root = self.loadDict()
let systemRun = self.systemRunSection(from: root)
let raw: [Any]? = {
if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) {
return agent["allowlist"] as? [Any]
}
return systemRun["allowlist"] as? [Any]
}()
guard let raw else { return nil }
if raw.allSatisfy({ $0 is String }) {
let legacy = raw.compactMap { $0 as? String }
return legacy.compactMap { key in
let pattern = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !pattern.isEmpty else { return nil }
return SystemRunAllowlistEntry(
pattern: pattern,
enabled: true,
matchKind: .argv,
source: .manual)
}
}
return raw.compactMap { item in
guard let dict = item as? [String: Any] else { return nil }
return SystemRunAllowlistEntry(dict: dict)
}
}
static func setSystemRunAllowlist(_ allowlist: [SystemRunAllowlistEntry], agentId: String?) {
let cleaned = allowlist
.map { $0 }
.filter { !$0.pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
let raw = cleaned.map { $0.asDict() }
self.updateSystemRunSection { systemRun in
if let agentId {
self.updateAgentSection(&systemRun, agentId: agentId) { entry in
if raw.isEmpty {
entry.removeValue(forKey: "allowlist")
} else {
entry["allowlist"] = raw
}
}
return
}
if raw.isEmpty {
systemRun.removeValue(forKey: "allowlist")
} else {
systemRun["allowlist"] = raw
}
}
}
static func systemRunAllowlistStrings() -> [String]? {
let root = self.loadDict()
let systemRun = self.systemRunSection(from: root)
return systemRun["allowlist"] as? [String]
}
static func setSystemRunAllowlistStrings(_ allowlist: [String]) {
let cleaned = allowlist
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
self.updateSystemRunSection { systemRun in
if cleaned.isEmpty {
systemRun.removeValue(forKey: "allowlist")
} else {
systemRun["allowlist"] = cleaned
}
}
}
}
@@ -428,8 +428,32 @@ actor MacNodeRuntime {
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
}
let wasAllowlisted = SystemRunAllowlist.contains(command)
switch Self.systemRunPolicy() {
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
let policy = SystemRunPolicy.load(agentId: agentId)
let allowlistEntries = SystemRunAllowlistStore.load(agentId: agentId)
let resolution = SystemRunCommandResolution.resolve(command: command, cwd: params.cwd)
let allowlistMatch = SystemRunAllowlistStore.match(
command: command,
resolution: resolution,
entries: allowlistEntries)
let autoAllowSkills = MacNodeConfigFile.systemRunAutoAllowSkills(agentId: agentId) ?? false
let skillAllow: Bool
if autoAllowSkills, let name = resolution?.executableName {
let bins = await SkillBinsCache.shared.currentBins()
skillAllow = bins.contains(name)
} else {
skillAllow = false
}
let shouldPrompt: Bool = {
if policy == .never { return false }
if allowlistMatch != nil { return false }
if skillAllow { return false }
return policy == .ask
}()
switch policy {
case .never:
return Self.errorResponse(
req,
@@ -438,16 +462,24 @@ actor MacNodeRuntime {
case .always:
break
case .ask:
if !wasAllowlisted {
if shouldPrompt {
let services = await self.mainActorServices()
let decision = await services.confirmSystemRun(
let decision = await services.confirmSystemRun(context: SystemRunPromptContext(
command: SystemRunAllowlist.displayString(for: command),
cwd: params.cwd)
cwd: params.cwd,
agentId: agentId,
executablePath: resolution?.resolvedPath))
switch decision {
case .allowOnce:
break
case .allowAlways:
SystemRunAllowlist.add(command)
if let resolvedPath = resolution?.resolvedPath, !resolvedPath.isEmpty {
_ = SystemRunAllowlistStore.add(pattern: resolvedPath, agentId: agentId)
} else if let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty
{
_ = SystemRunAllowlistStore.add(pattern: raw, agentId: agentId)
}
case .deny:
return Self.errorResponse(
req,
@@ -457,6 +489,14 @@ actor MacNodeRuntime {
}
}
if let match = allowlistMatch {
SystemRunAllowlistStore.markUsed(
entryId: match.id,
command: command,
resolvedPath: resolution?.resolvedPath,
agentId: agentId)
}
let env = Self.sanitizedEnv(params.env)
if params.needsScreenRecording == true {
@@ -9,6 +9,13 @@ enum SystemRunDecision: Sendable {
case deny
}
struct SystemRunPromptContext: Sendable {
let command: String
let cwd: String?
let agentId: String?
let executablePath: String?
}
@MainActor
protocol MacNodeRuntimeMainActorServices: Sendable {
func recordScreen(
@@ -25,7 +32,7 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision
func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision
}
@MainActor
@@ -67,16 +74,24 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices
timeoutMs: timeoutMs)
}
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Allow this command?"
var details = "Clawdbot wants to run:\n\n\(command)"
let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
var details = "Clawdbot wants to run:\n\n\(context.command)"
let trimmedCwd = context.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedCwd.isEmpty {
details += "\n\nWorking directory:\n\(trimmedCwd)"
}
let trimmedAgent = context.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedAgent.isEmpty {
details += "\n\nAgent:\n\(trimmedAgent)"
}
let trimmedPath = context.executablePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedPath.isEmpty {
details += "\n\nExecutable:\n\(trimmedPath)"
}
details += "\n\nThis runs on this Mac via node mode."
alert.informativeText = details
@@ -580,11 +580,10 @@ final class NodePairingApprovalPrompter {
process.standardError = pipe
do {
try process.run()
_ = try process.runAndReadToEnd(from: pipe)
} catch {
return false
}
process.waitUntilExit()
return process.terminationStatus == 0
}.value
}
+1 -1
View File
@@ -155,7 +155,7 @@ struct OnboardingView: View {
var canAdvance: Bool { !self.isWizardBlocking }
var devLinkCommand: String {
let version = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
let version = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
return "npm install -g clawdbot@\(version)"
}
@@ -203,15 +203,13 @@ actor PortGuardian {
proc.standardOutput = pipe
proc.standardError = Pipe()
do {
try proc.run()
proc.waitUntilExit()
let data = try proc.runAndReadToEnd(from: pipe)
guard !data.isEmpty else { return nil }
return String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
} catch {
return nil
}
let data = pipe.fileHandleForReading.readToEndSafely()
guard !data.isEmpty else { return nil }
return String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func parseListeners(from text: String) -> [Listener] {
@@ -0,0 +1,11 @@
import Foundation
extension Process {
/// Runs the process and drains the given pipe before waiting to avoid blocking on full buffers.
func runAndReadToEnd(from pipe: Pipe) throws -> Data {
try self.run()
let data = pipe.fileHandleForReading.readToEndSafely()
self.waitUntilExit()
return data
}
}
@@ -133,8 +133,7 @@ enum RuntimeLocator {
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = try process.runAndReadToEnd(from: pipe)
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
if elapsedMs > 500 {
self.logger.warning(
@@ -149,7 +148,6 @@ enum RuntimeLocator {
bin=\(binary, privacy: .public)
""")
}
let data = pipe.fileHandleForReading.readToEndSafely()
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
} catch {
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
@@ -0,0 +1,267 @@
import Foundation
enum SystemRunAllowlistMatchKind: String {
case glob
case argv
}
enum SystemRunAllowlistSource: String {
case manual
case skill
}
struct SystemRunAllowlistEntry: Identifiable, Hashable {
let id: String
var pattern: String
var enabled: Bool
var matchKind: SystemRunAllowlistMatchKind
var source: SystemRunAllowlistSource?
var skillId: String?
var lastUsedAt: Date?
var lastUsedCommand: String?
var lastUsedPath: String?
init(
id: String = UUID().uuidString,
pattern: String,
enabled: Bool = true,
matchKind: SystemRunAllowlistMatchKind = .glob,
source: SystemRunAllowlistSource? = .manual,
skillId: String? = nil,
lastUsedAt: Date? = nil,
lastUsedCommand: String? = nil,
lastUsedPath: String? = nil)
{
self.id = id
self.pattern = pattern
self.enabled = enabled
self.matchKind = matchKind
self.source = source
self.skillId = skillId
self.lastUsedAt = lastUsedAt
self.lastUsedCommand = lastUsedCommand
self.lastUsedPath = lastUsedPath
}
init?(dict: [String: Any]) {
let id = dict["id"] as? String ?? UUID().uuidString
let pattern = (dict["pattern"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if pattern.isEmpty { return nil }
let enabled = dict["enabled"] as? Bool ?? true
let matchRaw = dict["matchKind"] as? String
let matchKind = SystemRunAllowlistMatchKind(rawValue: matchRaw ?? "") ?? .glob
let sourceRaw = dict["source"] as? String
let source = SystemRunAllowlistSource(rawValue: sourceRaw ?? "")
let skillId = (dict["skillId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let lastUsedAt = (dict["lastUsedAt"] as? Double).map { Date(timeIntervalSince1970: $0) }
let lastUsedCommand = dict["lastUsedCommand"] as? String
let lastUsedPath = dict["lastUsedPath"] as? String
self.init(
id: id,
pattern: pattern,
enabled: enabled,
matchKind: matchKind,
source: source,
skillId: skillId?.isEmpty == true ? nil : skillId,
lastUsedAt: lastUsedAt,
lastUsedCommand: lastUsedCommand,
lastUsedPath: lastUsedPath)
}
func asDict() -> [String: Any] {
var dict: [String: Any] = [
"id": self.id,
"pattern": self.pattern,
"enabled": self.enabled,
"matchKind": self.matchKind.rawValue,
]
if let source = self.source { dict["source"] = source.rawValue }
if let skillId = self.skillId { dict["skillId"] = skillId }
if let lastUsedAt = self.lastUsedAt { dict["lastUsedAt"] = lastUsedAt.timeIntervalSince1970 }
if let lastUsedCommand = self.lastUsedCommand { dict["lastUsedCommand"] = lastUsedCommand }
if let lastUsedPath = self.lastUsedPath { dict["lastUsedPath"] = lastUsedPath }
return dict
}
}
struct SystemRunCommandResolution: Sendable {
let rawExecutable: String
let resolvedPath: String?
let executableName: String
let cwd: String?
static func resolve(command: [String], cwd: String?) -> SystemRunCommandResolution? {
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil
}
let expanded = raw.hasPrefix("~") ? (raw as NSString).expandingTildeInPath : raw
let hasPathSeparator = expanded.contains("/")
let resolvedPath: String? = {
if hasPathSeparator {
if expanded.hasPrefix("/") {
return expanded
}
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
let root = (base?.isEmpty == false) ? base! : FileManager.default.currentDirectoryPath
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
}
return CommandResolver.findExecutable(named: expanded)
}()
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
return SystemRunCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
}
}
enum SystemRunAllowlistStore {
static func load(agentId: String?) -> [SystemRunAllowlistEntry] {
if let entries = MacNodeConfigFile.systemRunAllowlist(agentId: agentId) {
return entries
}
return []
}
static func save(_ entries: [SystemRunAllowlistEntry], agentId: String?) {
MacNodeConfigFile.setSystemRunAllowlist(entries, agentId: agentId)
}
static func add(pattern: String, agentId: String?, source: SystemRunAllowlistSource = .manual) -> SystemRunAllowlistEntry {
var entries = self.load(agentId: agentId)
let entry = SystemRunAllowlistEntry(pattern: pattern, enabled: true, matchKind: .glob, source: source)
entries.append(entry)
self.save(entries, agentId: agentId)
return entry
}
static func update(_ entry: SystemRunAllowlistEntry, agentId: String?) {
var entries = self.load(agentId: agentId)
guard let index = entries.firstIndex(where: { $0.id == entry.id }) else { return }
entries[index] = entry
self.save(entries, agentId: agentId)
}
static func remove(entryId: String, agentId: String?) {
let entries = self.load(agentId: agentId).filter { $0.id != entryId }
self.save(entries, agentId: agentId)
}
static func markUsed(entryId: String, command: [String], resolvedPath: String?, agentId: String?) {
var entries = self.load(agentId: agentId)
guard let index = entries.firstIndex(where: { $0.id == entryId }) else { return }
entries[index].lastUsedAt = Date()
entries[index].lastUsedCommand = SystemRunAllowlist.displayString(for: command)
entries[index].lastUsedPath = resolvedPath
self.save(entries, agentId: agentId)
}
static func match(
command: [String],
resolution: SystemRunCommandResolution?,
entries: [SystemRunAllowlistEntry]) -> SystemRunAllowlistEntry?
{
guard !entries.isEmpty else { return nil }
let argvKey = SystemRunAllowlist.legacyKey(for: command)
let resolvedPath = resolution?.resolvedPath
let executableName = resolution?.executableName
let rawExecutable = resolution?.rawExecutable
for entry in entries {
guard entry.enabled else { continue }
switch entry.matchKind {
case .argv:
if argvKey == entry.pattern { return entry }
case .glob:
let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
if pattern.isEmpty { continue }
let hasPath = pattern.contains("/") || pattern.contains("~")
if hasPath {
let target = resolvedPath ?? rawExecutable
if let target, SystemRunGlob.matches(pattern: pattern, target: target) {
return entry
}
} else if let name = executableName, SystemRunGlob.matches(pattern: pattern, target: name) {
return entry
}
}
}
return nil
}
}
enum SystemRunGlob {
static func matches(pattern rawPattern: String, target: String) -> Bool {
let trimmed = rawPattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed
guard let regex = self.regex(for: expanded) else { return false }
let range = NSRange(location: 0, length: target.utf16.count)
return regex.firstMatch(in: target, options: [], range: range) != nil
}
private static func regex(for pattern: String) -> NSRegularExpression? {
var regex = "^"
var idx = pattern.startIndex
while idx < pattern.endIndex {
let ch = pattern[idx]
if ch == "*" {
let next = pattern.index(after: idx)
if next < pattern.endIndex, pattern[next] == "*" {
regex += ".*"
idx = pattern.index(after: next)
} else {
regex += "[^/]*"
idx = next
}
continue
}
if ch == "?" {
regex += "."
idx = pattern.index(after: idx)
continue
}
regex += NSRegularExpression.escapedPattern(for: String(ch))
idx = pattern.index(after: idx)
}
regex += "$"
return try? NSRegularExpression(pattern: regex)
}
}
actor SkillBinsCache {
static let shared = SkillBinsCache()
private var bins: Set<String> = []
private var lastRefresh: Date?
private let refreshInterval: TimeInterval = 90
func currentBins(force: Bool = false) async -> Set<String> {
if force || self.isStale() {
await self.refresh()
}
return self.bins
}
func refresh() async {
do {
let report = try await GatewayConnection.shared.skillsStatus()
var next = Set<String>()
for skill in report.skills {
for bin in skill.requirements.bins {
let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { next.insert(trimmed) }
}
}
self.bins = next
self.lastRefresh = Date()
} catch {
if self.lastRefresh == nil {
self.bins = []
}
}
}
private func isStale() -> Bool {
guard let lastRefresh else { return true }
return Date().timeIntervalSince(lastRefresh) > self.refreshInterval
}
}
@@ -18,7 +18,10 @@ enum SystemRunPolicy: String, CaseIterable, Identifiable {
}
}
static func load(from defaults: UserDefaults = .standard) -> SystemRunPolicy {
static func load(agentId: String? = nil, from defaults: UserDefaults = .standard) -> SystemRunPolicy {
if let policy = MacNodeConfigFile.systemRunPolicy(agentId: agentId) {
return policy
}
if let policy = MacNodeConfigFile.systemRunPolicy() {
return policy
}
@@ -40,7 +43,7 @@ enum SystemRunPolicy: String, CaseIterable, Identifiable {
}
enum SystemRunAllowlist {
static func key(for argv: [String]) -> String {
static func legacyKey(for argv: [String]) -> String {
let trimmed = argv.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard !trimmed.isEmpty else { return "" }
if let data = try? JSONEncoder().encode(trimmed),
@@ -62,28 +65,14 @@ enum SystemRunAllowlist {
}.joined(separator: " ")
}
static func load(from defaults: UserDefaults = .standard) -> Set<String> {
if let allowlist = MacNodeConfigFile.systemRunAllowlist() {
static func loadLegacy(from defaults: UserDefaults = .standard) -> Set<String> {
if let allowlist = MacNodeConfigFile.systemRunAllowlistStrings() {
return Set(allowlist)
}
if let legacy = defaults.stringArray(forKey: systemRunAllowlistKey), !legacy.isEmpty {
MacNodeConfigFile.setSystemRunAllowlist(legacy)
MacNodeConfigFile.setSystemRunAllowlistStrings(legacy)
return Set(legacy)
}
return []
}
static func contains(_ argv: [String], defaults: UserDefaults = .standard) -> Bool {
let key = key(for: argv)
return self.load(from: defaults).contains(key)
}
static func add(_ argv: [String], defaults: UserDefaults = .standard) {
let key = key(for: argv)
guard !key.isEmpty else { return }
var allowlist = self.load(from: defaults)
if allowlist.insert(key).inserted {
MacNodeConfigFile.setSystemRunAllowlist(Array(allowlist).sorted())
}
}
}
@@ -0,0 +1,291 @@
import Foundation
import Observation
import SwiftUI
struct SystemRunSettingsView: View {
@State private var model = SystemRunSettingsModel()
@State private var tab: SystemRunSettingsTab = .policy
@State private var newPattern: String = ""
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .center, spacing: 12) {
Text("Node Run Commands")
.font(.body)
Spacer(minLength: 0)
if self.model.agentIds.count > 1 {
Picker("Agent", selection: Binding(
get: { self.model.selectedAgentId },
set: { self.model.selectAgent($0) }))
{
ForEach(self.model.agentIds, id: \.self) { id in
Text(id).tag(id)
}
}
.pickerStyle(.menu)
.frame(width: 160, alignment: .trailing)
}
}
Picker("", selection: self.$tab) {
ForEach(SystemRunSettingsTab.allCases) { tab in
Text(tab.title).tag(tab)
}
}
.pickerStyle(.segmented)
.frame(width: 280)
if self.tab == .policy {
self.policyView
} else {
self.allowlistView
}
}
.task { await self.model.refresh() }
.onChange(of: self.tab) { _, _ in
Task { await self.model.refreshSkillBins() }
}
}
private var policyView: some View {
VStack(alignment: .leading, spacing: 6) {
Picker("", selection: Binding(
get: { self.model.policy },
set: { self.model.setPolicy($0) }))
{
ForEach(SystemRunPolicy.allCases) { policy in
Text(policy.title).tag(policy)
}
}
.labelsHidden()
.pickerStyle(.menu)
Text("Controls remote command execution on this Mac when it is paired as a node. \"Always Ask\" prompts on each command; \"Always Allow\" runs without prompts; \"Never\" disables system.run.")
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
private var allowlistView: some View {
VStack(alignment: .leading, spacing: 10) {
Toggle("Auto-allow skill CLIs", isOn: Binding(
get: { self.model.autoAllowSkills },
set: { self.model.setAutoAllowSkills($0) }))
if self.model.autoAllowSkills, !self.model.skillBins.isEmpty {
Text("Skill CLIs: \(self.model.skillBins.joined(separator: ", "))")
.font(.footnote)
.foregroundStyle(.secondary)
}
HStack(spacing: 8) {
TextField("Add allowlist pattern (supports globs)", text: self.$newPattern)
.textFieldStyle(.roundedBorder)
Button("Add") {
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !pattern.isEmpty else { return }
self.model.addEntry(pattern)
self.newPattern = ""
}
.buttonStyle(.bordered)
.disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
if self.model.entries.isEmpty {
Text("No allowlisted commands yet.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(self.model.entries.enumerated()), id: \.element.id) { index, _ in
SystemRunAllowlistRow(
entry: Binding(
get: { self.model.entries[index] },
set: { self.model.updateEntry($0) }),
onRemove: { self.model.removeEntry($0.id) })
}
}
}
}
}
}
private enum SystemRunSettingsTab: String, CaseIterable, Identifiable {
case policy
case allowlist
var id: String { self.rawValue }
var title: String {
switch self {
case .policy: "Policy"
case .allowlist: "Allowlist"
}
}
}
struct SystemRunAllowlistRow: View {
@Binding var entry: SystemRunAllowlistEntry
let onRemove: (SystemRunAllowlistEntry) -> Void
@State private var draftPattern: String = ""
private static let relativeFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .short
return formatter
}()
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Toggle("", isOn: self.$entry.enabled)
.labelsHidden()
TextField("Pattern", text: self.patternBinding)
.textFieldStyle(.roundedBorder)
if self.entry.matchKind == .argv {
Text("Legacy")
.font(.caption)
.foregroundStyle(.secondary)
}
Button(role: .destructive) {
self.onRemove(self.entry)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
}
if let lastUsedAt = self.entry.lastUsedAt {
Text("Last used \(Self.relativeFormatter.localizedString(for: lastUsedAt, relativeTo: Date()))")
.font(.caption)
.foregroundStyle(.secondary)
} else if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty {
Text("Last used: \(lastUsedCommand)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.onAppear {
self.draftPattern = self.entry.pattern
}
}
private var patternBinding: Binding<String> {
Binding(
get: { self.draftPattern.isEmpty ? self.entry.pattern : self.draftPattern },
set: { newValue in
self.draftPattern = newValue
self.entry.pattern = newValue
if self.entry.matchKind == .argv {
self.entry.matchKind = .glob
}
})
}
}
@MainActor
@Observable
final class SystemRunSettingsModel {
var agentIds: [String] = []
var selectedAgentId: String = "main"
var defaultAgentId: String = "main"
var policy: SystemRunPolicy = .ask
var autoAllowSkills = false
var entries: [SystemRunAllowlistEntry] = []
var skillBins: [String] = []
func refresh() async {
await self.refreshAgents()
self.loadSettings(for: self.selectedAgentId)
await self.refreshSkillBins()
}
func refreshAgents() async {
let root = await ConfigStore.load()
let agents = root["agents"] as? [String: Any]
let list = agents?["list"] as? [[String: Any]] ?? []
var ids: [String] = []
var seen = Set<String>()
var defaultId: String?
for entry in list {
guard let raw = entry["id"] as? String else { continue }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
if !seen.insert(trimmed).inserted { continue }
ids.append(trimmed)
if (entry["default"] as? Bool) == true, defaultId == nil {
defaultId = trimmed
}
}
if ids.isEmpty {
ids = ["main"]
defaultId = "main"
} else if defaultId == nil {
defaultId = ids.first
}
self.agentIds = ids
self.defaultAgentId = defaultId ?? "main"
if !self.agentIds.contains(self.selectedAgentId) {
self.selectedAgentId = self.defaultAgentId
}
}
func selectAgent(_ id: String) {
self.selectedAgentId = id
self.loadSettings(for: id)
Task { await self.refreshSkillBins() }
}
func loadSettings(for agentId: String) {
self.policy = SystemRunPolicy.load(agentId: agentId)
self.autoAllowSkills = MacNodeConfigFile.systemRunAutoAllowSkills(agentId: agentId) ?? false
self.entries = SystemRunAllowlistStore.load(agentId: agentId)
.sorted { $0.pattern.localizedCaseInsensitiveCompare($1.pattern) == .orderedAscending }
}
func setPolicy(_ policy: SystemRunPolicy) {
self.policy = policy
MacNodeConfigFile.setSystemRunPolicy(policy, agentId: self.selectedAgentId)
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
AppStateStore.shared.systemRunPolicy = policy
}
}
func setAutoAllowSkills(_ enabled: Bool) {
self.autoAllowSkills = enabled
MacNodeConfigFile.setSystemRunAutoAllowSkills(enabled, agentId: self.selectedAgentId)
Task { await self.refreshSkillBins(force: enabled) }
}
func addEntry(_ pattern: String) {
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let entry = SystemRunAllowlistEntry(pattern: trimmed, enabled: true, matchKind: .glob, source: .manual)
self.entries.append(entry)
SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId)
}
func updateEntry(_ entry: SystemRunAllowlistEntry) {
guard let index = self.entries.firstIndex(where: { $0.id == entry.id }) else { return }
self.entries[index] = entry
SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId)
}
func removeEntry(_ id: String) {
self.entries.removeAll { $0.id == id }
SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId)
}
func refreshSkillBins(force: Bool = false) async {
guard self.autoAllowSkills else {
self.skillBins = []
return
}
let bins = await SkillBinsCache.shared.currentBins(force: force)
self.skillBins = bins.sorted()
}
}
@@ -34,7 +34,7 @@ import Testing
let clawdbotPath = tmp.appendingPathComponent("node_modules/.bin/clawdbot")
try self.makeExec(at: clawdbotPath)
let cmd = CommandResolver.clawdbotCommand(subcommand: "gateway", defaults: defaults)
let cmd = CommandResolver.clawdbotCommand(subcommand: "gateway", defaults: defaults, configRoot: [:])
#expect(cmd.prefix(2).elementsEqual([clawdbotPath.path, "gateway"]))
}
@@ -55,6 +55,7 @@ import Testing
let cmd = CommandResolver.clawdbotCommand(
subcommand: "rpc",
defaults: defaults,
configRoot: [:],
searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path])
#expect(cmd.count >= 3)
@@ -75,7 +76,7 @@ import Testing
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
try self.makeExec(at: pnpmPath)
let cmd = CommandResolver.clawdbotCommand(subcommand: "rpc", defaults: defaults)
let cmd = CommandResolver.clawdbotCommand(subcommand: "rpc", defaults: defaults, configRoot: [:])
#expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "rpc"]))
}
@@ -93,7 +94,8 @@ import Testing
let cmd = CommandResolver.clawdbotCommand(
subcommand: "health",
extraArgs: ["--json", "--timeout", "5"],
defaults: defaults)
defaults: defaults,
configRoot: [:])
#expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "health", "--json"]))
#expect(cmd.suffix(2).elementsEqual(["--timeout", "5"]))
@@ -114,7 +116,11 @@ import Testing
defaults.set("/tmp/id_ed25519", forKey: remoteIdentityKey)
defaults.set("/srv/clawdbot", forKey: remoteProjectRootKey)
let cmd = CommandResolver.clawdbotCommand(subcommand: "status", extraArgs: ["--json"], defaults: defaults)
let cmd = CommandResolver.clawdbotCommand(
subcommand: "status",
extraArgs: ["--json"],
defaults: defaults,
configRoot: [:])
#expect(cmd.first == "/usr/bin/ssh")
#expect(cmd.contains("clawd@example.com"))
@@ -5,16 +5,28 @@ import Testing
@Suite struct GatewayEnvironmentTests {
@Test func semverParsesCommonForms() {
#expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse(" v1.2.3 \n") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0))
#expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 0)) // patch drops trailing text
#expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 5)) // prerelease suffix stripped
#expect(Semver.parse("2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11)) // build suffix stripped
#expect(Semver.parse("1.0.5+build.123") == Semver(major: 1, minor: 0, patch: 5)) // metadata suffix stripped
#expect(Semver.parse("v1.2.3+build.9") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("1.2.3+build.123") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("1.2.3-rc.1+build.7") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("v1.2.3-rc.1") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("1.2.0") == Semver(major: 1, minor: 2, patch: 0))
#expect(Semver.parse(nil) == nil)
#expect(Semver.parse("invalid") == nil)
#expect(Semver.parse("1.2") == nil)
#expect(Semver.parse("1.2.x") == nil)
}
@Test func semverCompatibilityRequiresSameMajorAndNotOlder() {
let required = Semver(major: 2, minor: 1, patch: 0)
#expect(Semver(major: 2, minor: 1, patch: 0).compatible(with: required))
#expect(Semver(major: 2, minor: 2, patch: 0).compatible(with: required))
#expect(Semver(major: 2, minor: 1, patch: 1).compatible(with: required))
#expect(Semver(major: 2, minor: 0, patch: 9).compatible(with: required) == false)
#expect(Semver(major: 3, minor: 0, patch: 0).compatible(with: required) == false)
#expect(Semver(major: 1, minor: 9, patch: 9).compatible(with: required) == false)
}
@@ -36,6 +48,7 @@ import Testing
@Test func expectedGatewayVersionFromStringUsesParser() {
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11))
#expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil)
}
}
@@ -75,7 +75,7 @@ struct MacNodeRuntimeTests {
CLLocation(latitude: 0, longitude: 0)
}
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision {
.allowOnce
}
}
@@ -0,0 +1,43 @@
import Foundation
import Testing
@testable import Clawdbot
struct SystemRunAllowlistTests {
@Test func matchUsesResolvedPath() {
let entry = SystemRunAllowlistEntry(pattern: "/opt/homebrew/bin/rg", enabled: true, matchKind: .glob)
let resolution = SystemRunCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
let match = SystemRunAllowlistStore.match(
command: ["rg"],
resolution: resolution,
entries: [entry])
#expect(match?.id == entry.id)
}
@Test func matchUsesBasenameForSimplePattern() {
let entry = SystemRunAllowlistEntry(pattern: "rg", enabled: true, matchKind: .glob)
let resolution = SystemRunCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
let match = SystemRunAllowlistStore.match(
command: ["rg"],
resolution: resolution,
entries: [entry])
#expect(match?.id == entry.id)
}
@Test func matchUsesLegacyArgvKey() {
let key = SystemRunAllowlist.legacyKey(for: ["echo", "hi"])
let entry = SystemRunAllowlistEntry(pattern: key, enabled: true, matchKind: .argv)
let match = SystemRunAllowlistStore.match(
command: ["echo", "hi"],
resolution: nil,
entries: [entry])
#expect(match?.id == entry.id)
}
}
@@ -37,7 +37,7 @@ import Testing
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
defaults.set("ssh alice@example.com", forKey: remoteTargetKey)
let settings = CommandResolver.connectionSettings(defaults: defaults)
let settings = CommandResolver.connectionSettings(defaults: defaults, configRoot: [:])
#expect(settings.mode == .remote)
#expect(settings.target == "alice@example.com")
}
@@ -24,19 +24,22 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
public var env: [String: String]?
public var timeoutMs: Int?
public var needsScreenRecording: Bool?
public var agentId: String?
public init(
command: [String],
cwd: String? = nil,
env: [String: String]? = nil,
timeoutMs: Int? = nil,
needsScreenRecording: Bool? = nil)
needsScreenRecording: Bool? = nil,
agentId: String? = nil)
{
self.command = command
self.cwd = cwd
self.env = env
self.timeoutMs = timeoutMs
self.needsScreenRecording = needsScreenRecording
self.agentId = agentId
}
}
+40
View File
@@ -0,0 +1,40 @@
---
summary: "Brave Search API setup for web_search"
read_when:
- You want to use Brave Search for web_search
- You need a BRAVE_API_KEY or plan details
---
# Brave Search API
Clawdbot uses Brave Search as the default provider for `web_search`.
## Get an API key
1) Create a Brave Search API account at https://brave.com/search/api/
2) In the dashboard, choose the **Data for Search** plan and generate an API key.
3) Store the key in config (recommended) or set `BRAVE_API_KEY` in the Gateway environment.
## Config example
```json5
{
tools: {
web: {
search: {
provider: "brave",
apiKey: "BRAVE_API_KEY_HERE",
maxResults: 5,
timeoutSeconds: 30
}
}
}
}
```
## Notes
- The Data for AI plan is **not** compatible with `web_search`.
- Brave provides a free tier plus paid plans; check the Brave API portal for current limits.
See [Web tools](/tools/web) for the full web_search configuration.
+12 -3
View File
@@ -58,7 +58,7 @@ Minimal config:
- The `discord` tool is only exposed when the current channel is Discord.
13. Native commands use isolated session keys (`agent:<agentId>:discord:slash:<userId>`) rather than the shared `main` session.
Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets.
Note: Name → id resolution uses guild member search and requires Server Members Intent; if the bot can’t search members, use ids or `<@id>` mentions.
Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`.
Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-ready replies easy.
@@ -175,6 +175,7 @@ Notes:
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages.
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
- If `channels` is present, any channel not listed is denied by default.
- Threads inherit parent channel config (allowlist, `requireMention`, skills, prompts, etc.) unless you add the thread channel id explicitly.
- Bot-authored messages are ignored by default; set `channels.discord.allowBots=true` to allow them (own messages remain filtered).
- Warning: If you allow replies to other bots (`channels.discord.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.discord.guilds.*.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
@@ -192,8 +193,11 @@ Notes:
- Your config requires mentions and you didn’t mention it, or
- Your guild/channel allowlist denies the channel/user.
- **`requireMention: false` but still no replies**:
- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds.<id>.channels` to restrict).
- `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored.
- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds.<id>.channels` to restrict).
- If you only set `DISCORD_BOT_TOKEN` and never create a `channels.discord` section, the runtime
defaults `groupPolicy` to `open`. Add `channels.discord.groupPolicy`,
`channels.defaults.groupPolicy`, or a guild/channel allowlist to lock it down.
- `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored.
- **Permission audits** (`channels status --probe`) only check numeric channel IDs. If you use slugs/names as `channels.discord.guilds.*.channels` keys, the audit can’t verify permissions.
- **DMs don’t work**: `channels.discord.dm.enabled=false`, `channels.discord.dm.policy="disabled"`, or you haven’t been approved yet (`channels.discord.dm.policy="pairing"`).
@@ -361,10 +365,15 @@ Allowlist matching notes:
- Use `*` to allow any sender/channel.
- When `guilds.<id>.channels` is present, channels not listed are denied by default.
- When `guilds.<id>.channels` is omitted, all channels in the allowlisted guild are allowed.
- To allow **no channels**, set `channels.discord.groupPolicy: "disabled"` (or keep an empty allowlist).
- The configure wizard accepts `Guild/Channel` names (public + private) and resolves them to IDs when possible.
- On startup, Clawdbot resolves channel/user names in allowlists to IDs (when the bot can search members)
and logs the mapping; unresolved entries are kept as typed.
Native command notes:
- The registered commands mirror Clawdbot’s chat commands.
- Native commands honor the same allowlists as DMs/guild messages (`channels.discord.dm.allowFrom`, `channels.discord.guilds`, per-channel rules).
- Slash commands may still be visible in Discord UI to users who aren’t allowlisted; Clawdbot enforces allowlists on execution and replies “not authorized”.
## Tool actions
The agent can call `discord` with actions like:
+5 -1
View File
@@ -70,9 +70,10 @@ 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).
## Rooms (groups)
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated).
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset.
- Allowlist rooms with `channels.matrix.rooms`:
```json5
{
@@ -86,6 +87,9 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis
}
```
- `requireMention: false` enables auto-reply in that room.
- The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names when possible.
- On startup, Clawdbot resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed.
- To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist).
## Threads
- Reply threading is supported.
+29 -2
View File
@@ -76,12 +76,13 @@ Disable with:
**DM access**
- Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved.
- `channels.msteams.allowFrom` accepts AAD object IDs or UPNs.
- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names (resolved at startup when Graph allows).
**Group access**
- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`).
- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). Use `channels.defaults.groupPolicy` to override the default when unset.
- `channels.msteams.groupAllowFrom` controls which senders can trigger in group chats/channels (falls back to `channels.msteams.allowFrom`).
- Set `groupPolicy: "open"` to allow any member (still mention‑gated by default).
- To allow **no channels**, set `channels.msteams.groupPolicy: "disabled"`.
Example:
```json5
@@ -95,6 +96,32 @@ Example:
}
```
**Teams + channel allowlist**
- Scope group/channel replies by listing teams and channels under `channels.msteams.teams`.
- Keys can be team IDs or names; channel keys can be conversation IDs or names.
- When `groupPolicy="allowlist"` and a teams allowlist is present, only listed teams/channels are accepted (mention‑gated).
- The configure wizard accepts `Team/Channel` entries and stores them for you.
- On startup, Clawdbot resolves team/channel and user allowlist names to IDs (when Graph permissions allow)
and logs the mapping; unresolved entries are kept as typed.
Example:
```json5
{
channels: {
msteams: {
groupPolicy: "allowlist",
teams: {
"My Team": {
channels: {
"General": { requireMention: true }
}
}
}
}
}
}
```
## How it works
1. Install the Microsoft Teams plugin.
2. Create an **Azure Bot** (App ID + secret + tenant ID).
+10
View File
@@ -335,6 +335,7 @@ For fine-grained control, use these tags in agent responses:
- DMs share the `main` session (like WhatsApp/Telegram).
- Channels map to `agent:<agentId>:slack:channel:<channelId>` sessions.
- Slash commands use `agent:<agentId>:slack:slash:<userId>` sessions (prefix configurable via `channels.slack.slashCommand.sessionPrefix`).
- If Slack doesn’t provide `channel_type`, Clawdbot infers it from the channel ID prefix (`D`, `C`, `G`) and defaults to `channel` to keep session keys stable.
- Native command registration uses `commands.native` (global default `"auto"` → Slack off) and can be overridden per-workspace with `channels.slack.commands.native`. Text commands require standalone `/...` messages and can be disabled with `commands.text: false`. Slack slash commands are managed in the Slack app and are not removed automatically. Use `commands.useAccessGroups: false` to bypass access-group checks for commands.
- Full command list + config: [Slash commands](/tools/slash-commands)
@@ -342,10 +343,19 @@ 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).
## Group policy
- `channels.slack.groupPolicy` controls channel handling (`open|disabled|allowlist`).
- `allowlist` requires channels to be listed in `channels.slack.channels`.
- If you only set `SLACK_BOT_TOKEN`/`SLACK_APP_TOKEN` and never create a `channels.slack` section,
the runtime defaults `groupPolicy` to `open`. Add `channels.slack.groupPolicy`,
`channels.defaults.groupPolicy`, or a channel allowlist to lock it down.
- The configure wizard accepts `#channel` names and resolves them to IDs when possible
(public + private); if multiple matches exist, it prefers the active channel.
- On startup, Clawdbot resolves channel/user names in allowlists to IDs (when tokens allow)
and logs the mapping; unresolved entries are kept as typed.
- To allow **no channels**, set `channels.slack.groupPolicy: "disabled"` (or keep an empty allowlist).
Channel options (`channels.slack.channels.<id>` or `channels.slack.channels.<name>`):
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`.
+15
View File
@@ -152,6 +152,7 @@ By default, the bot only responds to mentions in groups (`@botname` or patterns
```
**Important:** Setting `channels.telegram.groups` creates an **allowlist** - only listed groups (or `"*"`) will be accepted.
Forum topics inherit their parent group config (allowFrom, requireMention, skills, prompts) unless you add per-topic overrides under `channels.telegram.groups.<groupId>.topics.<topicId>`.
To allow all groups with always-respond:
```json5
@@ -216,6 +217,7 @@ Telegram forum topics include a `message_thread_id` per message. Clawdbot:
- General topic (thread id `1`) is special: message sends omit `message_thread_id` (Telegram rejects it), but typing indicators still include it.
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
- Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable).
- Topic configs inherit group settings (requireMention, allowlists, skills, prompts, enabled) unless overridden per topic.
Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps the DM session key unchanged, but still uses the thread id for replies/draft streaming when it is present.
@@ -360,6 +362,19 @@ To force a voice note bubble in agent replies, include this tag anywhere in the
The tag is stripped from the delivered text. Other channels ignore this tag.
For message tool sends, set `asVoice: true` with a voice-compatible audio `media` URL
(`message` is optional when media is present):
```json5
{
"action": "send",
"channel": "telegram",
"to": "123456789",
"media": "https://example.com/voice.ogg",
"asVoice": true
}
```
## Streaming (drafts)
Telegram can stream **draft bubbles** while the agent is generating a response.
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
+25
View File
@@ -66,11 +66,36 @@ 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).
Approve via:
- `clawdbot pairing list zalouser`
- `clawdbot pairing approve zalouser <code>`
## Group access (optional)
- Default: `channels.zalouser.groupPolicy = "open"` (groups allowed). Use `channels.defaults.groupPolicy` to override the default when unset.
- Restrict to an allowlist with:
- `channels.zalouser.groupPolicy = "allowlist"`
- `channels.zalouser.groups` (keys are group IDs or names)
- Block all groups: `channels.zalouser.groupPolicy = "disabled"`.
- The configure wizard can prompt for group allowlists.
- On startup, Clawdbot resolves group/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed.
Example:
```json5
{
channels: {
zalouser: {
groupPolicy: "allowlist",
groups: {
"123456789": { allow: true },
"Work Chat": { allow: true }
}
}
}
}
```
## Multi-account
Accounts map to zca profiles. Example:
+30
View File
@@ -18,6 +18,9 @@ Related docs:
```bash
clawdbot channels list
clawdbot channels status
clawdbot channels capabilities
clawdbot channels capabilities --channel discord --target channel:123
clawdbot channels resolve --channel slack "#general" "@jane"
clawdbot channels logs --channel all
```
@@ -42,3 +45,30 @@ clawdbot channels logout --channel whatsapp
- Run `clawdbot status --deep` for a broad probe.
- Use `clawdbot doctor` for guided fixes.
## Capabilities probe
Fetch provider capability hints (intents/scopes where available) plus static feature support:
```bash
clawdbot channels capabilities
clawdbot channels capabilities --channel discord --target channel:123
```
Notes:
- `--channel` is optional; omit it to list every channel (including extensions).
- `--target` accepts `channel:<id>` or a raw numeric channel id and only applies to Discord.
- Probes are provider-specific: Discord intents + optional channel permissions; Slack bot + user scopes; Telegram bot flags + webhook; Signal daemon version; MS Teams app token + Graph roles/scopes (annotated where known). Channels without probes report `Probe: unavailable`.
## Resolve names to IDs
Resolve channel/user names to IDs using the provider directory:
```bash
clawdbot channels resolve --channel slack "#general" "@jane"
clawdbot channels resolve --channel discord "My Server/#support" "@someone"
clawdbot channels resolve --channel matrix "Project Room"
```
Notes:
- Use `--kind user|group|auto` to force the target type.
- Resolution prefers active matches when multiple entries share the same name.
+1
View File
@@ -17,6 +17,7 @@ Related:
Notes:
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
- Channel-oriented services (Slack/Discord/Matrix/Microsoft Teams) prompt for channel/room allowlists during setup. You can enter names or IDs; the wizard resolves names to IDs when possible.
## Examples
+3
View File
@@ -21,6 +21,9 @@ clawdbot doctor --repair
clawdbot doctor --deep
```
Notes:
- Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts.
## macOS: `launchctl` env overrides
If you previously ran `launchctl setenv CLAWDBOT_GATEWAY_TOKEN ...` (or `...PASSWORD`), that value overrides your config file and can cause persistent “unauthorized” errors.
+1
View File
@@ -29,6 +29,7 @@ Notes:
- By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.clawdbot/clawdbot.json`. Use `--allow-unconfigured` for ad-hoc/dev runs.
- Binding beyond loopback without auth is blocked (safety guardrail).
- `SIGUSR1` triggers an in-process restart (useful without a supervisor).
- `SIGINT`/`SIGTERM` handlers stop the gateway process, but they don’t restore any custom terminal state. If you wrap the CLI with a TUI or raw-mode input, restore the terminal before exit.
### Options
+11 -11
View File
@@ -1,16 +1,16 @@
---
summary: "CLI reference for `clawdbot hooks` (internal hooks)"
summary: "CLI reference for `clawdbot hooks` (agent hooks)"
read_when:
- You want to manage internal agent hooks
- You want to install or update internal hooks
- You want to manage agent hooks
- You want to install or update hooks
---
# `clawdbot hooks`
Manage internal agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
Related:
- Internal Hooks: [Internal Agent Hooks](/internal-hooks)
- Hooks: [Hooks](/hooks)
## List All Hooks
@@ -18,7 +18,7 @@ Related:
clawdbot hooks list
```
List all discovered internal hooks from workspace, managed, and bundled directories.
List all discovered hooks from workspace, managed, and bundled directories.
**Options:**
- `--eligible`: Show only eligible hooks (requirements met)
@@ -28,7 +28,7 @@ List all discovered internal hooks from workspace, managed, and bundled director
**Example output:**
```
Internal Hooks (2/2 ready)
Hooks (2/2 ready)
Ready:
📝 command-logger ✓ - Log all command events to a centralized audit file
@@ -82,7 +82,7 @@ Details:
Source: clawdbot-bundled
Path: /path/to/clawdbot/hooks/bundled/session-memory/HOOK.md
Handler: /path/to/clawdbot/hooks/bundled/session-memory/handler.ts
Homepage: https://docs.clawd.bot/internal-hooks#session-memory
Homepage: https://docs.clawd.bot/hooks#session-memory
Events: command:new
Requirements:
@@ -103,7 +103,7 @@ Show summary of hook eligibility status (how many are ready vs. not ready).
**Example output:**
```
Internal Hooks Status
Hooks Status
Total hooks: 2
Ready: 2
@@ -228,7 +228,7 @@ clawdbot hooks enable session-memory
**Output:** `~/clawd/memory/YYYY-MM-DD-slug.md`
**See:** [session-memory documentation](/internal-hooks#session-memory)
**See:** [session-memory documentation](/hooks#session-memory)
### command-logger
@@ -255,4 +255,4 @@ cat ~/.clawdbot/logs/commands.log | jq .
grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
```
**See:** [command-logger documentation](/internal-hooks#command-logger)
**See:** [command-logger documentation](/hooks#command-logger)
+3 -2
View File
@@ -292,7 +292,7 @@ Options:
- `--non-interactive`
- `--mode <local|remote>`
- `--flow <quickstart|advanced>`
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|codex-cli|antigravity|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
- `--token <token>` (non-interactive; used with `--auth-choice token`)
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
@@ -302,6 +302,7 @@ Options:
- `--openrouter-api-key <key>`
- `--ai-gateway-api-key <key>`
- `--moonshot-api-key <key>`
- `--kimi-code-api-key <key>`
- `--gemini-api-key <key>`
- `--zai-api-key <key>`
- `--minimax-api-key <key>`
@@ -527,7 +528,7 @@ Surfaces:
Notes:
- Data comes directly from provider usage endpoints (no estimates).
- Providers: Anthropic, GitHub Copilot, Gemini CLI, Antigravity, OpenAI Codex OAuth, plus z.ai when an API key is configured.
- Providers: Anthropic, GitHub Copilot, OpenAI Codex OAuth, plus Gemini CLI/Antigravity when those provider plugins are enabled.
- If no matching credentials exist, usage is hidden.
- Details: see [Usage tracking](/concepts/usage-tracking).
+8
View File
@@ -8,15 +8,23 @@ read_when:
# `clawdbot memory`
Memory search tools (semantic memory status/index/search).
Provided by the active memory plugin (default: `memory-core`; use `plugins.slots.memory = "none"` to disable).
Related:
- Memory concept: [Memory](/concepts/memory)
- Plugins: [Plugins](/plugins)
## Examples
```bash
clawdbot memory status
clawdbot memory status --deep
clawdbot memory status --deep --index
clawdbot memory status --deep --index --verbose
clawdbot memory index
clawdbot memory search "release checklist"
```
## Options
- `--verbose`: emit debug logs during memory probes and indexing.
+5
View File
@@ -26,6 +26,11 @@ clawdbot models scan
When provider usage snapshots are available, the OAuth/token status section includes
provider usage headers.
Notes:
- `models set <model-or-alias>` accepts `provider/model` or an alias.
- Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
## Aliases + fallbacks
```bash
+3
View File
@@ -25,6 +25,9 @@ clawdbot plugins update <id>
clawdbot plugins update --all
```
Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to
activate them.
### Install
```bash
+1
View File
@@ -19,3 +19,4 @@ clawdbot status --usage
Notes:
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
- Output includes per-agent session stores when multiple agents are configured.
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
+6
View File
@@ -15,6 +15,8 @@ If you installed via **npm/pnpm** (global install, no git metadata), use the pac
```bash
clawdbot update
clawdbot update --channel beta
clawdbot update --tag beta
clawdbot update --restart
clawdbot update --json
clawdbot --update
@@ -23,9 +25,13 @@ clawdbot --update
## Options
- `--restart`: restart the Gateway daemon after a successful update.
- `--channel <stable|beta>`: set the update channel for npm installs (persisted in config).
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
- `--json`: print machine-readable `UpdateRunResult` JSON.
- `--timeout <seconds>`: per-step timeout (default is 1200s).
Note: downgrades require confirmation because older versions can break configuration.
## What it does (git checkout)
High-level:
+8
View File
@@ -98,6 +98,14 @@ Verbose tool summaries are emitted at tool start (no debounce); Control UI
streams tool output via agent events when available.
More details: [Streaming + chunking](/concepts/streaming).
## Model refs
Model refs in config (for example `agents.defaults.model` and `agents.defaults.models`) are parsed by splitting on the **first** `/`.
- Use `provider/model` when configuring models.
- If the model ID itself contains `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
## Configuration (minimal)
At minimum, set:
+123 -2
View File
@@ -9,6 +9,9 @@ read_when:
Clawdbot memory is **plain Markdown in the agent workspace**. The files are the
source of truth; the model only "remembers" what gets written to disk.
Memory search tools are provided by the active memory plugin (default:
`memory-core`). Disable memory plugins with `plugins.slots.memory = "none"`.
## Memory files (Markdown)
The default workspace layout uses two memory layers:
@@ -78,6 +81,7 @@ Defaults:
- Watches memory files for changes (debounced).
- Uses remote embeddings (OpenAI) unless configured for local.
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
Remote embeddings **require** an API key for the embedding provider. By default
this is OpenAI (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Codex
@@ -107,6 +111,19 @@ agents: {
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
`memorySearch.fallback = "none"`.
Batch indexing (OpenAI only):
- Enabled by default for OpenAI embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable.
- Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed.
- Set `remote.batch.concurrency` to control how many batch jobs we submit in parallel (default: 2).
- Batch mode currently applies only when `memorySearch.provider = "openai"` and uses your OpenAI API key.
Why OpenAI batch is fast + cheap:
- For large backfills, OpenAI is typically the fastest option we support because we can submit many embedding requests in a single batch job and let OpenAI process them asynchronously.
- OpenAI offers discounted pricing for Batch API workloads, so large indexing runs are usually cheaper than sending the same requests synchronously.
- See the OpenAI Batch API docs and pricing for details:
- https://platform.openai.com/docs/api-reference/batch
- https://platform.openai.com/pricing
Config example:
```json5
@@ -116,6 +133,9 @@ agents: {
provider: "openai",
model: "text-embedding-3-small",
fallback: "openai",
remote: {
batch: { enabled: true, concurrency: 2 }
},
sync: { watch: true }
}
}
@@ -140,8 +160,109 @@ Local mode:
### What gets indexed (and when)
- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`).
- Index storage: per-agent SQLite at `~/.clawdbot/state/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync runs on session start, on first search when dirty, and optionally on an interval. Reindex triggers when embedding model/provider or chunk sizes change.
- Index storage: per-agent SQLite at `~/.clawdbot/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync runs on session start, on first search when dirty, and optionally on an interval.
- Reindex triggers: the index stores the embedding **provider/model + endpoint fingerprint + chunking params**. If any of those change, Clawdbot automatically resets and reindexes the entire store.
### Hybrid search (BM25 + vector)
When enabled, Clawdbot combines:
- **Vector similarity** (semantic match, wording can differ)
- **BM25 keyword relevance** (exact tokens like IDs, env vars, code symbols)
If full-text search is unavailable on your platform, Clawdbot falls back to vector-only search.
Config:
```json5
agents: {
defaults: {
memorySearch: {
query: {
hybrid: {
enabled: true,
vectorWeight: 0.7,
textWeight: 0.3,
candidateMultiplier: 4
}
}
}
}
}
```
### Embedding cache
Clawdbot can cache **chunk embeddings** in SQLite so reindexing and frequent updates (especially session transcripts) don't re-embed unchanged text.
Config:
```json5
agents: {
defaults: {
memorySearch: {
cache: {
enabled: true,
maxEntries: 50000
}
}
}
}
```
### Session memory search (experimental)
You can optionally index **session transcripts** and surface them via `memory_search`.
This is gated behind an experimental flag.
```json5
agents: {
defaults: {
memorySearch: {
experimental: { sessionMemory: true },
sources: ["memory", "sessions"]
}
}
}
```
Notes:
- Session indexing is **opt-in** (off by default).
- Session updates are debounced and indexed lazily on the next `memory_search` (or manual `clawdbot memory index`).
- Results still include snippets only; `memory_get` remains limited to memory files.
- Session indexing is isolated per agent (only that agent’s session logs are indexed).
- Session logs live on disk (`~/.clawdbot/agents/<agentId>/sessions/*.jsonl`). Any process/user with filesystem access can read them, so treat disk access as the trust boundary. For stricter isolation, run agents under separate OS users or hosts.
### SQLite vector acceleration (sqlite-vec)
When the sqlite-vec extension is available, Clawdbot stores embeddings in a
SQLite virtual table (`vec0`) and performs vector distance queries in the
database. This keeps search fast without loading every embedding into JS.
Configuration (optional):
```json5
agents: {
defaults: {
memorySearch: {
store: {
vector: {
enabled: true,
extensionPath: "/path/to/sqlite-vec"
}
}
}
}
}
```
Notes:
- `enabled` defaults to true; when disabled, search falls back to in-process
cosine similarity over stored embeddings.
- If the sqlite-vec extension is missing or fails to load, Clawdbot logs the
error and continues with the JS fallback (no vector table).
- `extensionPath` overrides the bundled sqlite-vec path (useful for custom builds
or non-standard install locations).
### Local embedding auto-download
+50 -1
View File
@@ -83,7 +83,12 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
- Providers: `google-vertex`, `google-antigravity`, `google-gemini-cli`
- Auth: Vertex uses gcloud ADC; Antigravity/Gemini CLI use their respective auth flows
- CLI: `clawdbot onboard --auth-choice antigravity` (others via interactive wizard)
- Antigravity OAuth is shipped as a bundled plugin (`google-antigravity-auth`, disabled by default).
- Enable: `clawdbot plugins enable google-antigravity-auth`
- Login: `clawdbot models auth login --provider google-antigravity --set-default`
- Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default).
- Enable: `clawdbot plugins enable google-gemini-cli-auth`
- Login: `clawdbot models auth login --provider google-gemini-cli --set-default`
### Z.AI (GLM)
@@ -150,6 +155,50 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
}
```
### Kimi Code
Kimi Code uses a dedicated endpoint and key (separate from Moonshot):
- Provider: `kimi-code`
- Auth: `KIMICODE_API_KEY`
- Example model: `kimi-code/kimi-for-coding`
```json5
{
env: { KIMICODE_API_KEY: "sk-..." },
agents: {
defaults: { model: { primary: "kimi-code/kimi-for-coding" } }
},
models: {
mode: "merge",
providers: {
"kimi-code": {
baseUrl: "https://api.kimi.com/coding/v1",
apiKey: "${KIMICODE_API_KEY}",
api: "openai-completions",
models: [{ id: "kimi-for-coding", name: "Kimi For Coding" }]
}
}
}
}
```
### Qwen OAuth (free tier)
Qwen provides OAuth access to Qwen Coder + Vision via a device-code flow.
Enable the bundled plugin, then log in:
```bash
clawdbot plugins enable qwen-portal-auth
clawdbot models auth login --provider qwen-portal --set-default
```
Model refs:
- `qwen-portal/coder-model`
- `qwen-portal/vision-model`
See [/providers/qwen](/providers/qwen) for setup details and notes.
### Synthetic
Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
+3
View File
@@ -102,6 +102,9 @@ Notes:
- `/model` (and `/model list`) is a compact, numbered picker (model family + available providers).
- `/model <#>` selects from that picker.
- `/model status` is the detailed view (auth candidates and, when configured, provider endpoint `baseUrl` + `api` mode).
- Model refs are parsed by splitting on the **first** `/`. Use `provider/model` when typing `/model <ref>`.
- If the model ID itself contains `/` (OpenRouter-style), you must include the provider prefix (example: `/model openrouter/moonshotai/kimi-k2`).
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
Full command behavior/config: [Slash commands](/tools/slash-commands).
+9
View File
@@ -25,6 +25,7 @@ All session state is **owned by the gateway** (the “master” Clawdbot). UI cl
- Transcripts: `~/.clawdbot/agents/<agentId>/sessions/<SessionId>.jsonl` (Telegram topic sessions use `.../<SessionId>-topic-<threadId>.jsonl`).
- The store is a map `sessionKey -> { sessionId, updatedAt, ... }`. Deleting entries is safe; they are recreated on demand.
- Group entries may include `displayName`, `channel`, `subject`, `room`, and `space` to label sessions in UIs.
- Session entries include `origin` metadata (label + routing hints) so UIs can explain where a session came from.
- Clawdbot does **not** read legacy Pi/Tau session folders.
## Session pruning
@@ -113,3 +114,11 @@ Send these as standalone messages so they register.
## Tips
- Keep the primary key dedicated to 1:1 traffic; let groups keep their own keys.
- When automating cleanup, delete individual keys instead of the whole store to preserve context elsewhere.
## Session origin metadata
Each session entry records where it came from (best-effort) in `origin`:
- `label`: human label (resolved from conversation label + group subject/channel)
- `provider`: normalized channel id (including extensions)
- `from`/`to`: raw routing ids from the inbound envelope
- `accountId`: provider account id (when multi-account)
- `threadId`: thread/topic id when the channel supports it
-1
View File
@@ -894,7 +894,6 @@
"gateway/heartbeat",
"gateway/doctor",
"gateway/logging",
"logging",
"gateway/security",
"gateway/sandbox-vs-tool-policy-vs-elevated",
"gateway/sandboxing",
+1
View File
@@ -33,6 +33,7 @@ When spawning long-running child processes outside the exec/process tools (for e
Environment overrides:
- `PI_BASH_YIELD_MS`: default yield (ms)
- `PI_BASH_MAX_OUTPUT_CHARS`: in‑memory output cap (chars)
- `CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS`: pending stdout/stderr cap per stream (chars)
- `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m–3h)
Config (preferred):
+45 -1
View File
@@ -678,10 +678,11 @@ Notes:
- `"open"`: groups bypass allowlists; mention-gating still applies.
- `"disabled"`: block all group/room messages.
- `"allowlist"`: only allow groups/rooms that match the configured allowlist.
- `channels.defaults.groupPolicy` sets the default when a provider’s `groupPolicy` is unset.
- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams use `groupAllowFrom` (fallback: explicit `allowFrom`).
- Discord/Slack use channel allowlists (`channels.discord.guilds.*.channels`, `channels.slack.channels`).
- Group DMs (Discord/Slack) are still controlled by `dm.groupEnabled` + `dm.groupChannels`.
- Default is `groupPolicy: "allowlist"`; if no allowlist is configured, group messages are blocked.
- Default is `groupPolicy: "allowlist"` (unless overridden by `channels.defaults.groupPolicy`); if no allowlist is configured, group messages are blocked.
### Multi-agent routing (`agents.list` + `bindings`)
@@ -2234,6 +2235,49 @@ Notes:
- Model ref: `moonshot/kimi-k2-0905-preview`.
- Use `https://api.moonshot.cn/v1` if you need the China endpoint.
### Kimi Code
Use Kimi Code's dedicated OpenAI-compatible endpoint (separate from Moonshot):
```json5
{
env: { KIMICODE_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "kimi-code/kimi-for-coding" },
models: { "kimi-code/kimi-for-coding": { alias: "Kimi Code" } }
}
},
models: {
mode: "merge",
providers: {
"kimi-code": {
baseUrl: "https://api.kimi.com/coding/v1",
apiKey: "${KIMICODE_API_KEY}",
api: "openai-completions",
models: [
{
id: "kimi-for-coding",
name: "Kimi For Coding",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 32768,
headers: { "User-Agent": "KimiCLI/0.77" },
compat: { supportsDeveloperRole: false }
}
]
}
}
}
}
```
Notes:
- Set `KIMICODE_API_KEY` in the environment or use `clawdbot onboard --auth-choice kimi-code-api-key`.
- Model ref: `kimi-code/kimi-for-coding`.
### Synthetic (Anthropic-compatible)
Use Synthetic's Anthropic-compatible endpoint:
+8
View File
@@ -52,6 +52,14 @@ When the audit prints findings, treat this as a priority order:
5. **Plugins/extensions**: only load what you explicitly trust.
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
## Local session logs live on disk
Clawdbot stores session transcripts on disk under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl`.
This is required for session continuity and (optionally) session memory indexing, but it also means
**any process/user with filesystem access can read those logs**. Treat disk access as the trust
boundary and lock down permissions on `~/.clawdbot` (see the audit section below). If you need
stronger isolation between agents, run them under separate OS users or separate hosts.
## Node execution (system.run)
If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac:
+21 -21
View File
@@ -1,19 +1,19 @@
---
summary: "Internal agent hooks: event-driven automation for commands and lifecycle events"
summary: "Hooks: event-driven automation for commands and lifecycle events"
read_when:
- You want event-driven automation for /new, /reset, /stop, and agent lifecycle events
- You want to build, install, or debug internal hooks
- You want to build, install, or debug hooks
---
# Internal Agent Hooks
# Hooks
Internal hooks provide an extensible event-driven system for automating actions in response to agent commands and events. Hooks are automatically discovered from directories and can be managed via CLI commands, similar to how skills work in Clawdbot.
Hooks provide an extensible event-driven system for automating actions in response to agent commands and events. Hooks are automatically discovered from directories and can be managed via CLI commands, similar to how skills work in Clawdbot.
## Getting Oriented
Hooks are small scripts that run when something happens. There are two kinds:
- **Internal hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
- **Web-based hooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook) or use `clawdbot webhooks` for Gmail helper commands.
- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
- **Webhooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook) or use `clawdbot webhooks` for Gmail helper commands.
Common uses:
- Save a memory snapshot when you reset a session
@@ -21,11 +21,11 @@ Common uses:
- Trigger follow-up automation when a session starts or ends
- Write files into the agent workspace or call external APIs when events fire
If you can write a small TypeScript function, you can write an internal hook. Hooks are discovered automatically, and you enable or disable them via the CLI.
If you can write a small TypeScript function, you can write a hook. Hooks are discovered automatically, and you enable or disable them via the CLI.
## Overview
The internal hooks system allows you to:
The hooks system allows you to:
- Save session context to memory when `/new` is issued
- Log all commands for auditing
- Trigger custom automations on agent lifecycle events
@@ -120,7 +120,7 @@ The `HOOK.md` file contains metadata in YAML frontmatter plus Markdown documenta
---
name: my-hook
description: "Short description of what this hook does"
homepage: https://docs.clawd.bot/internal-hooks#my-hook
homepage: https://docs.clawd.bot/hooks#my-hook
metadata: {"clawdbot":{"emoji":"🔗","events":["command:new"],"requires":{"bins":["node"]}}}
---
@@ -162,12 +162,12 @@ The `metadata.clawdbot` object supports:
### Handler Implementation
The `handler.ts` file exports an `InternalHookHandler` function:
The `handler.ts` file exports a `HookHandler` function:
```typescript
import type { InternalHookHandler } from '../../src/hooks/internal-hooks.js';
import type { HookHandler } from '../../src/hooks/hooks.js';
const myHandler: InternalHookHandler = async (event) => {
const myHandler: HookHandler = async (event) => {
// Only trigger on 'new' command
if (event.type !== 'command' || event.action !== 'new') {
return;
@@ -260,9 +260,9 @@ This hook does something useful when you issue `/new`.
### 4. Create handler.ts
```typescript
import type { InternalHookHandler } from '../../src/hooks/internal-hooks.js';
import type { HookHandler } from '../../src/hooks/hooks.js';
const handler: InternalHookHandler = async (event) => {
const handler: HookHandler = async (event) => {
if (event.type !== 'command' || event.action !== 'new') {
return;
}
@@ -505,12 +505,12 @@ Hooks run during command processing. Keep them lightweight:
```typescript
// ✓ Good - async work, returns immediately
const handler: InternalHookHandler = async (event) => {
const handler: HookHandler = async (event) => {
void processInBackground(event); // Fire and forget
};
// ✗ Bad - blocks command processing
const handler: InternalHookHandler = async (event) => {
const handler: HookHandler = async (event) => {
await slowDatabaseQuery(event);
await evenSlowerAPICall(event);
};
@@ -521,7 +521,7 @@ const handler: InternalHookHandler = async (event) => {
Always wrap risky operations:
```typescript
const handler: InternalHookHandler = async (event) => {
const handler: HookHandler = async (event) => {
try {
await riskyOperation(event);
} catch (err) {
@@ -536,7 +536,7 @@ const handler: InternalHookHandler = async (event) => {
Return early if the event isn't relevant:
```typescript
const handler: InternalHookHandler = async (event) => {
const handler: HookHandler = async (event) => {
// Only handle 'new' commands
if (event.type !== 'command' || event.action !== 'new') {
return;
@@ -584,7 +584,7 @@ clawdbot hooks list --verbose
In your handler, log when it's called:
```typescript
const handler: InternalHookHandler = async (event) => {
const handler: HookHandler = async (event) => {
console.log('[my-handler] Triggered:', event.type, event.action);
// Your logic
};
@@ -620,11 +620,11 @@ Test your handlers in isolation:
```typescript
import { test } from 'vitest';
import { createInternalHookEvent } from './src/hooks/internal-hooks.js';
import { createHookEvent } from './src/hooks/hooks.js';
import myHandler from './hooks/my-hook/handler.js';
test('my handler works', async () => {
const event = createInternalHookEvent('command', 'new', 'test-session', {
const event = createHookEvent('command', 'new', 'test-session', {
foo: 'bar'
});
+17 -1
View File
@@ -50,6 +50,22 @@ pnpm add -g clawdbot@latest
```
We do **not** recommend Bun for the Gateway runtime (WhatsApp/Telegram bugs).
To stay on the beta channel for CLI updates:
```bash
clawdbot update --channel beta
```
Switch back to stable later:
```bash
clawdbot update --channel stable
```
Use `--tag <dist-tag|version>` for a one-off install tag/version.
Note: on npm installs, the gateway logs an update hint on startup (checks the current channel tag). Disable via `update.checkOnStart: false`.
Then:
```bash
@@ -75,7 +91,7 @@ It runs a safe-ish update flow:
- Fetches + rebases against the configured upstream.
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will skip. Use “Update (global install)” instead.
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead.
## Update (Control UI / RPC)
+17
View File
@@ -62,8 +62,25 @@ read_when:
}
```
### Provider-only (Deepgram)
```json5
{
tools: {
media: {
audio: {
enabled: true,
models: [{ provider: "deepgram", model: "nova-3" }]
}
}
}
}
```
## Notes & limits
- Provider auth follows the standard model auth order (auth profiles, env vars, `models.providers.*.apiKey`).
- Deepgram picks up `DEEPGRAM_API_KEY` when `provider: "deepgram"` is used.
- Deepgram setup details: [Deepgram (audio transcription)](/providers/deepgram).
- Audio providers can override `baseUrl`, `headers`, and `providerOptions` via `tools.media.audio`.
- Default size cap is 20MB (`tools.media.audio.maxBytes`). Oversize audio is skipped for that model and the next entry is tried.
- Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or per-entry `maxChars` to trim output.
- Use `tools.media.audio.attachments` to process multiple voice notes (`mode: "all"` + `maxAttachments`).
+15 -2
View File
@@ -32,6 +32,8 @@ If understanding fails or is disabled, **the reply flow continues** with the ori
- `tools.media.models`: shared model list (use `capabilities` to gate).
- `tools.media.image` / `tools.media.audio` / `tools.media.video`:
- defaults (`prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`)
- provider overrides (`baseUrl`, `headers`, `providerOptions`)
- Deepgram audio options via `tools.media.audio.providerOptions.deepgram`
- optional **per‑capability `models` list** (preferred before shared models)
- `attachments` policy (`mode`, `maxAttachments`, `prefer`)
- `scope` (optional gating by channel/chatType/session key)
@@ -108,6 +110,7 @@ lists, Clawdbot can infer defaults:
- `openai`, `anthropic`, `minimax`: **image**
- `google` (Gemini API): **image + audio + video**
- `groq`: **audio**
- `deepgram`: **audio**
For CLI entries, **set `capabilities` explicitly** to avoid surprising matches.
If you omit `capabilities`, the entry is eligible for the list it appears in.
@@ -116,7 +119,7 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
| Capability | Provider integration | Notes |
|------------|----------------------|-------|
| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. |
| Audio | OpenAI, Groq | Provider transcription (Whisper). |
| Audio | OpenAI, Groq, Deepgram | Provider transcription (Whisper/Deepgram). |
| Video | Google (Gemini API) | Provider video understanding. |
## Recommended providers
@@ -125,8 +128,9 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
- Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-5`, `google/gemini-3-pro-preview`.
**Audio**
- `openai/whisper-1` or `groq/whisper-large-v3-turbo`.
- `openai/whisper-1`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`.
- CLI fallback: `whisper` binary.
- Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram).
**Video**
- `google/gemini-3-flash-preview` (fast), `google/gemini-3-pro-preview` (richer).
@@ -256,6 +260,15 @@ When `mode: "all"`, outputs are labeled `[Image 1/2]`, `[Audio 2/2]`, etc.
}
```
## Status output
When media understanding runs, `/status` includes a short summary line:
```
📎 Media: image ok (openai/gpt-5.2) · audio skipped (maxBytes)
```
This shows per‑capability outcomes and the chosen provider/model when applicable.
## Notes
- Understanding is **best‑effort**. Errors do not block replies.
- Attachments are still passed to models even when understanding is disabled.
+76
View File
@@ -0,0 +1,76 @@
---
summary: "Perplexity Sonar setup for web_search"
read_when:
- You want to use Perplexity Sonar for web search
- You need PERPLEXITY_API_KEY or OpenRouter setup
---
# Perplexity Sonar
Clawdbot can use Perplexity Sonar for the `web_search` tool. You can connect
through Perplexity’s direct API or via OpenRouter.
## API options
### Perplexity (direct)
- Base URL: https://api.perplexity.ai
- Environment variable: `PERPLEXITY_API_KEY`
### OpenRouter (alternative)
- Base URL: https://openrouter.ai/api/v1
- Environment variable: `OPENROUTER_API_KEY`
- Supports prepaid/crypto credits.
## Config example
```json5
{
tools: {
web: {
search: {
provider: "perplexity",
perplexity: {
apiKey: "pplx-...",
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-pro"
}
}
}
}
}
```
## Switching from Brave
```json5
{
tools: {
web: {
search: {
provider: "perplexity",
perplexity: {
apiKey: "pplx-...",
baseUrl: "https://api.perplexity.ai"
}
}
}
}
}
```
If both `PERPLEXITY_API_KEY` and `OPENROUTER_API_KEY` are set, set
`tools.web.search.perplexity.baseUrl` (or `tools.web.search.perplexity.apiKey`)
to disambiguate.
If `PERPLEXITY_API_KEY` is used from the environment and no base URL is set,
Clawdbot defaults to the direct Perplexity endpoint. Set `baseUrl` to override.
## Models
- `perplexity/sonar` — fast Q&A with web search
- `perplexity/sonar-pro` (default) — multi-step reasoning + web search
- `perplexity/sonar-reasoning-pro` — deep research
See [Web tools](/tools/web) for the full web_search configuration.
+38 -5
View File
@@ -36,11 +36,16 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
## Available plugins (official)
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`)
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
- [Zalo](/channels/zalo) — `@clawdbot/zalo`
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
- Copilot Proxy (provider auth) — bundled as `copilot-proxy` (disabled by default)
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
register:
@@ -58,16 +63,26 @@ Plugins run **in‑process** with the Gateway, so treat them as trusted code.
Clawdbot scans, in order:
1) Global extensions
- `~/.clawdbot/extensions/*.ts`
- `~/.clawdbot/extensions/*/index.ts`
1) Config paths
- `plugins.load.paths` (file or directory)
2) Workspace extensions
- `<workspace>/.clawdbot/extensions/*.ts`
- `<workspace>/.clawdbot/extensions/*/index.ts`
3) Config paths
- `plugins.load.paths` (file or directory)
3) Global extensions
- `~/.clawdbot/extensions/*.ts`
- `~/.clawdbot/extensions/*/index.ts`
4) Bundled extensions (shipped with Clawdbot, **disabled by default**)
- `<clawdbot>/extensions/*`
Bundled plugins must be enabled explicitly via `plugins.entries.<id>.enabled`
or `clawdbot plugins enable <id>`. Installed plugins are enabled by default,
but can be disabled the same way.
If multiple plugins resolve to the same id, the first match in the order above
wins and lower-precedence copies are ignored.
### Package packs
@@ -123,6 +138,24 @@ Fields:
Config changes **require a gateway restart**.
## Plugin slots (exclusive categories)
Some plugin categories are **exclusive** (only one active at a time). Use
`plugins.slots` to select which plugin owns the slot:
```json5
{
plugins: {
slots: {
memory: "memory-core" // or "none" to disable memory plugins
}
}
}
```
If multiple plugins declare `kind: "memory"`, only the selected one loads. Others
are disabled with diagnostics.
## Control UI (schema + labels)
The Control UI uses `config.schema` (JSON Schema + `uiHints`) to render better forms.
+89
View File
@@ -0,0 +1,89 @@
---
summary: "Deepgram transcription for inbound voice notes"
read_when:
- You want Deepgram speech-to-text for audio attachments
- You need a quick Deepgram config example
---
# Deepgram (Audio Transcription)
Deepgram is a speech-to-text API. In Clawdbot it is used for **inbound audio/voice note
transcription** via `tools.media.audio`.
When enabled, Clawdbot uploads the audio file to Deepgram and injects the transcript
into the reply pipeline (`{{Transcript}}` + `[Audio]` block). This is **not streaming**;
it uses the pre-recorded transcription endpoint.
Website: https://deepgram.com
Docs: https://developers.deepgram.com
## Quick start
1) Set your API key:
```
DEEPGRAM_API_KEY=dg_...
```
2) Enable the provider:
```json5
{
tools: {
media: {
audio: {
enabled: true,
models: [{ provider: "deepgram", model: "nova-3" }]
}
}
}
}
```
## Options
- `model`: Deepgram model id (default: `nova-3`)
- `language`: language hint (optional)
- `tools.media.audio.providerOptions.deepgram.detect_language`: enable language detection (optional)
- `tools.media.audio.providerOptions.deepgram.punctuate`: enable punctuation (optional)
- `tools.media.audio.providerOptions.deepgram.smart_format`: enable smart formatting (optional)
Example with language:
```json5
{
tools: {
media: {
audio: {
enabled: true,
models: [
{ provider: "deepgram", model: "nova-3", language: "en" }
]
}
}
}
}
```
Example with Deepgram options:
```json5
{
tools: {
media: {
audio: {
enabled: true,
providerOptions: {
deepgram: {
detect_language: true,
punctuate: true,
smart_format: true
}
},
models: [{ provider: "deepgram", model: "nova-3" }]
}
}
}
}
```
## Notes
- Authentication follows the standard provider auth order; `DEEPGRAM_API_KEY` is the simplest path.
- Override endpoints or headers with `tools.media.audio.baseUrl` and `tools.media.audio.headers` when using a proxy.
- Output follows the same audio rules as other providers (size caps, timeouts, transcript injection).
+49
View File
@@ -0,0 +1,49 @@
---
summary: "Sign in to GitHub Copilot from Clawdbot using the device flow"
read_when:
- You want to use GitHub Copilot as a model provider
- You need the `clawdbot models auth login-github-copilot` flow
---
# Github Copilot
Use GitHub Copilot as a model provider (`github-copilot`). The login command runs
the GitHub device flow, saves an auth profile, and updates your config to use that
profile.
## CLI setup
```bash
clawdbot models auth login-github-copilot
```
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
open until it completes.
### Optional flags
```bash
clawdbot models auth login-github-copilot --profile-id github-copilot:work
clawdbot models auth login-github-copilot --yes
```
## Set a default model
```bash
clawdbot models set github-copilot/gpt-4o
```
### Config snippet
```json5
{
agents: { defaults: { model: { primary: "github-copilot/gpt-4o" } } }
}
```
## Notes
- Requires an interactive TTY; run it directly in a terminal.
- Copilot model availability depends on your plan; if a model is rejected, try
another ID (for example `github-copilot/gpt-4.1`).
- The login stores a GitHub token in the auth profile store and exchanges it for a
Copilot API token when Clawdbot runs.
+6 -1
View File
@@ -26,13 +26,18 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Chann
- [OpenAI (API + Codex)](/providers/openai)
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
- [Qwen (OAuth)](/providers/qwen)
- [OpenRouter](/providers/openrouter)
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Moonshot AI (Kimi)](/providers/moonshot)
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
- [OpenCode Zen](/providers/opencode)
- [Z.AI](/providers/zai)
- [GLM models](/providers/glm)
- [MiniMax](/providers/minimax)
## Transcription providers
- [Deepgram (audio transcription)](/providers/deepgram)
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
see [Model providers](/concepts/model-providers).
+1
View File
@@ -155,6 +155,7 @@ Use the interactive config wizard to set MiniMax without editing JSON:
## Notes
- Model refs are `minimax/<model>`.
- Coding Plan usage API: `https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains` (requires a coding plan key).
- Update pricing values in `models.json` if you need exact cost tracking.
- Referral link for MiniMax Coding Plan (10% off): https://platform.minimax.io/subscribe/coding-plan?code=DbXJTRClnb&source=link
- See [/concepts/model-providers](/concepts/model-providers) for provider rules.
+1 -1
View File
@@ -26,7 +26,7 @@ model as `provider/model`.
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
- [OpenRouter](/providers/openrouter)
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Moonshot AI (Kimi)](/providers/moonshot)
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
- [Synthetic](/providers/synthetic)
- [OpenCode Zen](/providers/opencode)
- [Z.AI](/providers/zai)
+56 -6
View File
@@ -1,13 +1,16 @@
---
summary: "Use Moonshot AI (Kimi K2) with Clawdbot"
summary: "Configure Moonshot K2 vs Kimi Code (separate providers + keys)"
read_when:
- You want to use Moonshot/Kimi models in Clawdbot
- You need the Moonshot auth + config example
- You want Moonshot K2 (Moonshot Open Platform) vs Kimi Code setup
- You need to understand separate endpoints, keys, and model refs
- You want copy/paste config for either provider
---
# Moonshot AI (Kimi)
Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the
provider and set the default model to `moonshot/kimi-k2-0905-preview`.
provider and set the default model to `moonshot/kimi-k2-0905-preview`, or use
Kimi Code with `kimi-code/kimi-for-coding`.
Current Kimi K2 model IDs:
{/* moonshot-kimi-k2-ids:start */}
@@ -21,7 +24,15 @@ Current Kimi K2 model IDs:
clawdbot onboard --auth-choice moonshot-api-key
```
## Config snippet
Kimi Code:
```bash
clawdbot onboard --auth-choice kimi-code-api-key
```
Note: Moonshot and Kimi Code are separate providers. Keys are not interchangeable, endpoints differ, and model refs differ (Moonshot uses `moonshot/...`, Kimi Code uses `kimi-code/...`).
## Config snippet (Moonshot API)
```json5
{
@@ -92,9 +103,48 @@ clawdbot onboard --auth-choice moonshot-api-key
}
```
## Kimi Code
```json5
{
env: { KIMICODE_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "kimi-code/kimi-for-coding" },
models: {
"kimi-code/kimi-for-coding": { alias: "Kimi Code" }
}
}
},
models: {
mode: "merge",
providers: {
"kimi-code": {
baseUrl: "https://api.kimi.com/coding/v1",
apiKey: "${KIMICODE_API_KEY}",
api: "openai-completions",
models: [
{
id: "kimi-for-coding",
name: "Kimi For Coding",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 32768,
headers: { "User-Agent": "KimiCLI/0.77" },
compat: { supportsDeveloperRole: false }
}
]
}
}
}
}
```
## Notes
- Model refs use `moonshot/<modelId>`.
- Moonshot model refs use `moonshot/<modelId>`. Kimi Code model refs use `kimi-code/<modelId>`.
- Override pricing and context metadata in `models.providers` if needed.
- If Moonshot publishes different context limits for a model, adjust
`contextWindow` accordingly.
+51
View File
@@ -0,0 +1,51 @@
---
summary: "Use Qwen OAuth (free tier) in Clawdbot"
read_when:
- You want to use Qwen with Clawdbot
- You want free-tier OAuth access to Qwen Coder
---
# Qwen
Qwen provides a free-tier OAuth flow for Qwen Coder and Qwen Vision models
(2,000 requests/day, subject to Qwen rate limits).
## Enable the plugin
```bash
clawdbot plugins enable qwen-portal-auth
```
Restart the Gateway after enabling.
## Authenticate
```bash
clawdbot models auth login --provider qwen-portal --set-default
```
This runs the Qwen device-code OAuth flow and writes a provider entry to your
`models.json` (plus a `qwen` alias for quick switching).
## Model IDs
- `qwen-portal/coder-model`
- `qwen-portal/vision-model`
Switch models with:
```bash
clawdbot models set qwen-portal/coder-model
```
## Reuse Qwen Code CLI login
If you already logged in with the Qwen Code CLI, Clawdbot will sync credentials
from `~/.qwen/oauth_creds.json` when it loads the auth store. You still need a
`models.providers.qwen-portal` entry (use the login command above to create one).
## Notes
- Tokens auto-refresh; re-run the login command if refresh fails or access is revoked.
- Default base URL: `https://portal.qwen.ai/v1` (override with
`models.providers.qwen-portal.baseUrl` if Qwen provides a different endpoint).
- See [Model providers](/concepts/model-providers) for provider-wide rules.
+187
View File
@@ -0,0 +1,187 @@
---
summary: "Plan: one clean plugin SDK + runtime for all messaging connectors"
read_when:
- Defining or refactoring the plugin architecture
- Migrating channel connectors to the plugin SDK/runtime
---
# Plugin SDK + Runtime Refactor Plan
Goal: every messaging connector is a plugin (bundled or external) using one stable API.
No plugin imports from `src/**` directly. All dependencies go through the SDK or runtime.
## Why now
- Current connectors mix patterns: direct core imports, dist-only bridges, and custom helpers.
- This makes upgrades brittle and blocks a clean external plugin surface.
## Target architecture (two layers)
### 1) Plugin SDK (compile-time, stable, publishable)
Scope: types, helpers, and config utilities. No runtime state, no side effects.
Contents (examples):
- Types: `ChannelPlugin`, adapters, `ChannelMeta`, `ChannelCapabilities`, `ChannelDirectoryEntry`.
- Config helpers: `buildChannelConfigSchema`, `setAccountEnabledInConfigSection`, `deleteAccountFromConfigSection`,
`applyAccountNameToChannelSection`.
- Pairing helpers: `PAIRING_APPROVED_MESSAGE`, `formatPairingApproveHint`.
- Onboarding helpers: `promptChannelAccessConfig`, `addWildcardAllowFrom`, onboarding types.
- Tool param helpers: `createActionGate`, `readStringParam`, `readNumberParam`, `readReactionParams`, `jsonResult`.
- Docs link helper: `formatDocsLink`.
Delivery:
- Publish as `@clawdbot/plugin-sdk` (or export from core under `clawdbot/plugin-sdk`).
- Semver with explicit stability guarantees.
### 2) Plugin Runtime (execution surface, injected)
Scope: everything that touches core runtime behavior.
Accessed via `ClawdbotPluginApi.runtime` so plugins never import `src/**`.
Proposed surface (minimal but complete):
```ts
export type PluginRuntime = {
channel: {
text: {
chunkMarkdownText(text: string, limit: number): string[];
resolveTextChunkLimit(cfg: ClawdbotConfig, channel: string, accountId?: string): number;
hasControlCommand(text: string, cfg: ClawdbotConfig): boolean;
};
reply: {
dispatchReplyWithBufferedBlockDispatcher(params: {
ctx: unknown;
cfg: unknown;
dispatcherOptions: {
deliver: (payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }) =>
void | Promise<void>;
onError?: (err: unknown, info: { kind: string }) => void;
};
}): Promise<void>;
createReplyDispatcherWithTyping?: unknown; // adapter for Teams-style flows
};
routing: {
resolveAgentRoute(params: {
cfg: unknown;
channel: string;
accountId: string;
peer: { kind: "dm" | "group" | "channel"; id: string };
}): { sessionKey: string; accountId: string };
};
pairing: {
buildPairingReply(params: { channel: string; idLine: string; code: string }): string;
readAllowFromStore(channel: string): Promise<string[]>;
upsertPairingRequest(params: {
channel: string;
id: string;
meta?: { name?: string };
}): Promise<{ code: string; created: boolean }>;
};
media: {
fetchRemoteMedia(params: { url: string }): Promise<{ buffer: Buffer; contentType?: string }>;
saveMediaBuffer(
buffer: Uint8Array,
contentType: string | undefined,
direction: "inbound" | "outbound",
maxBytes: number,
): Promise<{ path: string; contentType?: string }>;
};
mentions: {
buildMentionRegexes(cfg: ClawdbotConfig, agentId?: string): RegExp[];
matchesMentionPatterns(text: string, regexes: RegExp[]): boolean;
};
groups: {
resolveGroupPolicy(cfg: ClawdbotConfig, channel: string, accountId: string, groupId: string): {
allowlistEnabled: boolean;
allowed: boolean;
groupConfig?: unknown;
defaultConfig?: unknown;
};
resolveRequireMention(
cfg: ClawdbotConfig,
channel: string,
accountId: string,
groupId: string,
override?: boolean,
): boolean;
};
debounce: {
createInboundDebouncer<T>(opts: {
debounceMs: number;
buildKey: (v: T) => string | null;
shouldDebounce: (v: T) => boolean;
onFlush: (entries: T[]) => Promise<void>;
onError?: (err: unknown) => void;
}): { push: (v: T) => void; flush: () => Promise<void> };
resolveInboundDebounceMs(cfg: ClawdbotConfig, channel: string): number;
};
commands: {
resolveCommandAuthorizedFromAuthorizers(params: {
useAccessGroups: boolean;
authorizers: Array<{ configured: boolean; allowed: boolean }>;
}): boolean;
};
};
logging: {
shouldLogVerbose(): boolean;
getChildLogger(name: string): PluginLogger;
};
state: {
resolveStateDir(cfg: ClawdbotConfig): string;
};
};
```
Notes:
- Runtime is the only way to access core behavior.
- SDK is intentionally small and stable.
- Each runtime method maps to an existing core implementation (no duplication).
## Migration plan (phased, safe)
### Phase 0: scaffolding
- Introduce `@clawdbot/plugin-sdk`.
- Add `api.runtime` to `ClawdbotPluginApi` with the surface above.
- Maintain existing imports during a transition window (deprecation warnings).
### Phase 1: bridge cleanup (low risk)
- Replace per-extension `core-bridge.ts` with `api.runtime`.
- Migrate BlueBubbles, Zalo, Zalo Personal first (already close).
- Remove duplicated bridge code.
### Phase 2: light direct-import plugins
- Migrate Matrix to SDK + runtime.
- Validate onboarding, directory, group mention logic.
### Phase 3: heavy direct-import plugins
- Migrate MS Teams (largest set of runtime helpers).
- Ensure reply/typing semantics match current behavior.
### Phase 4: iMessage pluginization
- Move iMessage into `extensions/imessage`.
- Replace direct core calls with `api.runtime`.
- Keep config keys, CLI behavior, and docs intact.
### Phase 5: enforcement
- Add lint rule / CI check: no `extensions/**` imports from `src/**`.
- Add plugin SDK/version compatibility checks (runtime + SDK semver).
## Compatibility and versioning
- SDK: semver, published, documented changes.
- Runtime: versioned per core release. Add `api.runtime.version`.
- Plugins declare a required runtime range (e.g., `clawdbotRuntime: ">=2026.2.0"`).
## Testing strategy
- Adapter-level unit tests (runtime functions exercised with real core implementation).
- Golden tests per plugin: ensure no behavior drift (routing, pairing, allowlist, mention gating).
- A single end-to-end plugin sample used in CI (install + run + smoke).
## Open questions
- Where to host SDK types: separate package or core export?
- Runtime type distribution: in SDK (types only) or in core?
- How to expose docs links for bundled vs external plugins?
- Do we allow limited direct core imports for in-repo plugins during transition?
## Success criteria
- All channel connectors are plugins using SDK + runtime.
- No `extensions/**` imports from `src/**`.
- New connector templates depend only on SDK + runtime.
- External plugins can be developed and updated without core source access.
Related docs: [Plugins](/plugin), [Channels](/channels/index), [Configuration](/gateway/configuration).
+8 -1
View File
@@ -10,6 +10,12 @@ read_when:
Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tagging/publishing.
## Operator trigger
When the operator says “release”, immediately do this preflight (no extra questions unless blocked):
- Read this doc and `docs/platforms/mac/release.md`.
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set.
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
1) **Version & metadata**
- [ ] Bump `package.json` version (e.g., `1.1.0`).
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
@@ -20,6 +26,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
2) **Build & artifacts**
- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/clawdbot/clawdbot/blob/main/src/canvas-host/a2ui/a2ui.bundle.js).
- [ ] `pnpm run build` (regenerates `dist/`).
- [ ] Confirm `dist/build-info.json` exists and includes the expected `commit` hash (CLI banner uses this for npm installs).
- [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it).
3) **Changelog & docs**
@@ -51,7 +58,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
- [ ] Confirm git status is clean; commit and push as needed.
- [ ] `npm login` (verify 2FA) if needed.
- [ ] `npm publish --access public` (use `--tag beta` for pre-releases).
- [ ] Verify the registry: `npm view clawdbot version` and `npx -y clawdbot@X.Y.Z --version` (or `--help`).
- [ ] Verify the registry: `npm view clawdbot version`, `npm view clawdbot dist-tags`, and `npx -y clawdbot@X.Y.Z --version` (or `--help`).
### Troubleshooting (notes from 2.0.0-beta2 release)
- **npm pack/publish hangs or produces huge tarball**: the macOS app bundle in `dist/Clawdbot.app` (and release zips) get swept into the package. Fix by whitelisting publish contents via `package.json` `files` (include dist subdirs, docs, skills; exclude app bundles). Confirm with `npm pack --dry-run` that `dist/Clawdbot.app` is not listed.
+3 -1
View File
@@ -95,7 +95,8 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
- More detail: [Synthetic](/providers/synthetic)
- **Moonshot (Kimi K2)**: config is auto-written.
- More detail: [Moonshot AI](/providers/moonshot)
- **Kimi Code**: config is auto-written.
- More detail: [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
- **Skip**: no auth configured yet.
- Pick a default model from detected options (or enter provider/model manually).
- Wizard runs a model check and warns if the configured model is unknown or missing auth.
@@ -292,6 +293,7 @@ Typical fields in `~/.clawdbot/clawdbot.json`:
- `agents.defaults.model` / `models.providers` (if Minimax chosen)
- `gateway.*` (mode, bind, auth, tailscale)
- `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*`
- Channel allowlists (Slack/Discord/Matrix/Microsoft Teams) when you opt in during the prompts (names resolve to IDs when possible).
- `skills.install.nodeManager`
- `wizard.lastRunAt`
- `wizard.lastRunVersion`
+5
View File
@@ -290,6 +290,11 @@ Live tests discover credentials the same way the CLI does. Practical implication
If you want to rely on env keys (e.g. exported in your `~/.profile`), run local tests after `source ~/.profile`, or use the Docker runners below (they can mount `~/.profile` into the container).
## Deepgram live (audio transcription)
- Test: `src/media-understanding/providers/deepgram/audio.live.test.ts`
- Enable: `DEEPGRAM_API_KEY=... DEEPGRAM_LIVE_TEST=1 pnpm test:live src/media-understanding/providers/deepgram/audio.live.test.ts`
## Docker runners (optional “works in Linux” checks)
These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted):
+108
View File
@@ -0,0 +1,108 @@
---
summary: "Exec approvals, allowlists, and sandbox escape prompts in the macOS app"
read_when:
- Configuring exec approvals or allowlists
- Implementing exec approval UX in the macOS app
- Reviewing sandbox escape prompts and implications
---
# Exec approvals (macOS app)
Exec approvals are the **macOS companion app** guardrail for running host
commands from sandboxed agents. Think of it as a per-agent “run this on my Mac”
approval layer: the agent asks, the app decides, and the command runs (or not).
This is **in addition** to tool policy and elevated gating; all of those checks
must pass before a command can run.
If you are **not** running the macOS companion app, exec approvals are
unavailable and `system.run` requests will be rejected with a message that a
companion app is required.
## Settings
In the macOS app, each agent has an **Exec approvals** setting:
- **Deny**: block all host exec requests from the agent.
- **Always ask**: show a confirmation dialog for each host exec request.
- **Always allow**: run host exec requests without prompting.
Optional toggles:
- **Auto-allow skill CLIs**: when enabled, CLIs referenced by known skills are
treated as allowlisted (see below).
## Allowlist (per agent)
The allowlist is **per agent**. If multiple agents exist, you can switch which
agent’s allowlist you’re editing. Entries are path-based and support **globs**.
Examples:
- `~/Projects/**/bin/bird`
- `~/.local/bin/*`
- `/opt/homebrew/bin/rg`
Each allowlist entry tracks:
- **last used** (timestamp)
- **last used command**
- **last used path** (resolved absolute path)
- **last seen metadata** (hash/version/mtime when available)
## How matching works
1) Parse the command to determine the executable (first token).
2) Resolve the executable to an absolute path using `PATH`.
3) Match against denylist (if present) → **deny**.
4) Match against allowlist → **allow**.
5) Otherwise follow the Exec approvals policy (deny/ask/allow).
If **auto-allow skill CLIs** is enabled, each installed skill can contribute one
or more allowlist entries. A skill-based allowlist entry only auto-allows when:
- the resolved path matches, and
- the binary hash/version matches the last approved record (if tracked).
If the binary changes (new hash/version), the command falls back to **Ask** so
the user can re-approve.
## Approval flow
When the policy is **Always ask** (or when a binary has changed), the macOS app
shows a confirmation dialog. The dialog should include:
- command + args
- cwd
- environment overrides (diff)
- policy + rule that matched (if any)
Actions:
- **Allow once** → run now
- **Always allow** → add/update allowlist entry + run
- **Deny** → block
When approved, the command runs **in the background** and the agent receives
system events as it starts and completes.
## System events
The agent receives system messages for observability and recovery:
- `exec.started` — command accepted and launched
- `exec.finished` — command completed (exit code + output)
- `exec.denied` — command blocked (policy or denylist)
These are **system messages**; no extra agent tool call is required to resume.
## Implications
- **Always allow** is powerful: the agent can run any host command without a
prompt. Prefer allowlisting trusted CLIs instead.
- **Ask** keeps you in the loop while still allowing fast approvals.
- Per-agent allowlists prevent one agent’s approval set from leaking into others.
## Storage
Allowlists and approval settings are stored **locally in the macOS app** (SQLite
is a good fit). The Markdown docs describe behavior; they are not the storage
mechanism.
Related:
- [Exec tool](/tools/exec)
- [Elevated mode](/tools/elevated)
- [Skills](/tools/skills)
+5
View File
@@ -26,6 +26,11 @@ Note: `elevated` is ignored when sandboxing is off (exec already runs on the hos
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
## Exec approvals (macOS app)
Sandboxed agents can require per-request approval before `exec` runs on the host.
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
## Examples
Foreground:
+1
View File
@@ -177,6 +177,7 @@ Notes:
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and runs on the host.
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
- macOS app approvals/allowlists: [Exec approvals](/tools/exec-approvals).
### `process`
Manage background exec sessions.
+1
View File
@@ -27,6 +27,7 @@ Tool params:
- `label?` (optional)
- `agentId?` (optional; spawn under another agent id if allowed)
- `model?` (optional; overrides the sub-agent model; invalid values are skipped and the sub-agent runs on the default model with a warning in the tool result)
- `thinking?` (optional; overrides thinking level for the sub-agent run)
- `runTimeoutSeconds?` (default `0`; when set, the sub-agent run is aborted after N seconds)
- `cleanup?` (`delete|keep`, default `keep`)
+101 -6
View File
@@ -1,15 +1,16 @@
---
summary: "Web search + fetch tools (Brave Search API)"
summary: "Web search + fetch tools (Brave Search API, Perplexity direct/OpenRouter)"
read_when:
- You want to enable web_search or web_fetch
- You need Brave Search API key setup
- You want to use Perplexity Sonar for web search
---
# Web tools
Clawdbot ships two lightweight web tools:
- `web_search` — Brave Search API queries (fast, structured results).
- `web_search` — Search the web via Brave Search API (default) or Perplexity Sonar (via OpenRouter).
- `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text).
These are **not** browser automation. For JS-heavy sites or logins, use the
@@ -17,13 +18,56 @@ These are **not** browser automation. For JS-heavy sites or logins, use the
## How it works
- `web_search` calls Brave’s Search API and returns structured results
(title, URL, snippet). No browser is involved.
- `web_search` calls your configured provider and returns results.
- **Brave** (default): returns structured results (title, URL, snippet).
- **Perplexity**: returns AI-synthesized answers with citations from real-time web search.
- Results are cached by query for 15 minutes (configurable).
- `web_fetch` does a plain HTTP GET and extracts readable content
(HTML → markdown/text). It does **not** execute JavaScript.
- `web_fetch` is enabled by default (unless explicitly disabled).
## Choosing a search provider
| Provider | Pros | Cons | API Key |
|----------|------|------|---------|
| **Brave** (default) | Fast, structured results, free tier | Traditional search results | `BRAVE_API_KEY` |
| **Perplexity** | AI-synthesized answers, citations, real-time | Requires OpenRouter credits | `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` |
See [Brave Search setup](/brave-search) and [Perplexity Sonar](/perplexity) for provider-specific details.
Set the provider in config:
```json5
{
tools: {
web: {
search: {
provider: "brave" // or "perplexity"
}
}
}
}
```
Example: switch to Perplexity Sonar (direct API):
```json5
{
tools: {
web: {
search: {
provider: "perplexity",
perplexity: {
apiKey: "pplx-...",
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-pro"
}
}
}
}
}
```
## Getting a Brave API key
1) Create a Brave Search API account at https://brave.com/search/api/
@@ -42,14 +86,65 @@ current limits and pricing.
environment. For a daemon install, put it in `~/.clawdbot/.env` (or your
service environment). See [Env vars](/start/faq#how-does-clawdbot-load-environment-variables).
## Using Perplexity (direct or via OpenRouter)
Perplexity Sonar models have built-in web search capabilities and return AI-synthesized
answers with citations. You can use them via OpenRouter (no credit card required - supports
crypto/prepaid).
### Getting an OpenRouter API key
1) Create an account at https://openrouter.ai/
2) Add credits (supports crypto, prepaid, or credit card)
3) Generate an API key in your account settings
### Setting up Perplexity search
```json5
{
tools: {
web: {
search: {
enabled: true,
provider: "perplexity",
perplexity: {
// API key (optional if OPENROUTER_API_KEY or PERPLEXITY_API_KEY is set)
apiKey: "sk-or-v1-...",
// Base URL (defaults to OpenRouter)
baseUrl: "https://openrouter.ai/api/v1",
// Model (defaults to perplexity/sonar-pro)
model: "perplexity/sonar-pro"
}
}
}
}
}
```
**Environment alternative:** set `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` in the Gateway
environment. For a daemon install, put it in `~/.clawdbot/.env`.
If `PERPLEXITY_API_KEY` is used from the environment and no base URL is set,
Clawdbot defaults to the direct Perplexity endpoint (`https://api.perplexity.ai`).
### Available Perplexity models
| Model | Description | Best for |
|-------|-------------|----------|
| `perplexity/sonar` | Fast Q&A with web search | Quick lookups |
| `perplexity/sonar-pro` (default) | Multi-step reasoning with web search | Complex questions |
| `perplexity/sonar-reasoning-pro` | Chain-of-thought analysis | Deep research |
## web_search
Search the web with Brave’s API.
Search the web using your configured provider.
### Requirements
- `tools.web.search.enabled` must not be `false` (default: enabled)
- Brave API key (recommended: `clawdbot configure --section web`, or set `BRAVE_API_KEY`)
- API key for your chosen provider:
- **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
- **Perplexity**: `OPENROUTER_API_KEY`, `PERPLEXITY_API_KEY`, or `tools.web.search.perplexity.apiKey`
### Config
+24
View File
@@ -0,0 +1,24 @@
# Copilot Proxy (Clawdbot plugin)
Provider plugin for the **Copilot Proxy** VS Code extension.
## Enable
Bundled plugins are disabled by default. Enable this one:
```bash
clawdbot plugins enable copilot-proxy
```
Restart the Gateway after enabling.
## Authenticate
```bash
clawdbot models auth login --provider copilot-proxy --set-default
```
## Notes
- Copilot Proxy must be running in VS Code.
- Base URL must include `/v1`.
+139
View File
@@ -0,0 +1,139 @@
const DEFAULT_BASE_URL = "http://localhost:3000/v1";
const DEFAULT_API_KEY = "n/a";
const DEFAULT_CONTEXT_WINDOW = 128_000;
const DEFAULT_MAX_TOKENS = 8192;
const DEFAULT_MODEL_IDS = [
"gpt-5.2",
"gpt-5.2-codex",
"gpt-5.1",
"gpt-5.1-codex",
"gpt-5.1-codex-max",
"gpt-5-mini",
"claude-opus-4.5",
"claude-sonnet-4.5",
"claude-haiku-4.5",
"gemini-3-pro",
"gemini-3-flash",
"grok-code-fast-1",
] as const;
function normalizeBaseUrl(value: string): string {
const trimmed = value.trim();
if (!trimmed) return DEFAULT_BASE_URL;
let normalized = trimmed;
while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
if (!normalized.endsWith("/v1")) normalized = `${normalized}/v1`;
return normalized;
}
function validateBaseUrl(value: string): string | undefined {
const normalized = normalizeBaseUrl(value);
try {
new URL(normalized);
} catch {
return "Enter a valid URL";
}
return undefined;
}
function parseModelIds(input: string): string[] {
const parsed = input
.split(/[\n,]/)
.map((model) => model.trim())
.filter(Boolean);
return Array.from(new Set(parsed));
}
function buildModelDefinition(modelId: string) {
return {
id: modelId,
name: modelId,
api: "openai-completions",
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_WINDOW,
maxTokens: DEFAULT_MAX_TOKENS,
};
}
const copilotProxyPlugin = {
id: "copilot-proxy",
name: "Copilot Proxy",
description: "Local Copilot Proxy (VS Code LM) provider plugin",
register(api) {
api.registerProvider({
id: "copilot-proxy",
label: "Copilot Proxy",
docsPath: "/providers/models",
auth: [
{
id: "local",
label: "Local proxy",
hint: "Configure base URL + models for the Copilot Proxy server",
kind: "custom",
run: async (ctx) => {
const baseUrlInput = await ctx.prompter.text({
message: "Copilot Proxy base URL",
initialValue: DEFAULT_BASE_URL,
validate: validateBaseUrl,
});
const modelInput = await ctx.prompter.text({
message: "Model IDs (comma-separated)",
initialValue: DEFAULT_MODEL_IDS.join(", "),
validate: (value) =>
parseModelIds(value).length > 0 ? undefined : "Enter at least one model id",
});
const baseUrl = normalizeBaseUrl(baseUrlInput);
const modelIds = parseModelIds(modelInput);
const defaultModelId = modelIds[0] ?? DEFAULT_MODEL_IDS[0];
const defaultModelRef = `copilot-proxy/${defaultModelId}`;
return {
profiles: [
{
profileId: "copilot-proxy:local",
credential: {
type: "token",
provider: "copilot-proxy",
token: DEFAULT_API_KEY,
},
},
],
configPatch: {
models: {
providers: {
"copilot-proxy": {
baseUrl,
apiKey: DEFAULT_API_KEY,
api: "openai-completions",
authHeader: false,
models: modelIds.map((modelId) => buildModelDefinition(modelId)),
},
},
},
agents: {
defaults: {
models: Object.fromEntries(
modelIds.map((modelId) => [`copilot-proxy/${modelId}`, {}]),
),
},
},
},
defaultModel: defaultModelRef,
notes: [
"Start the Copilot Proxy VS Code extension before using these models.",
"Copilot Proxy serves /v1/chat/completions; base URL must include /v1.",
"Model availability depends on your Copilot plan; edit models.providers.copilot-proxy if needed.",
],
};
},
},
],
});
},
};
export default copilotProxyPlugin;
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@clawdbot/copilot-proxy",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Copilot Proxy provider plugin",
"clawdbot": {
"extensions": [
"./index.ts"
]
}
}
@@ -0,0 +1,24 @@
# Google Antigravity Auth (Clawdbot plugin)
OAuth provider plugin for **Google Antigravity** (Cloud Code Assist).
## Enable
Bundled plugins are disabled by default. Enable this one:
```bash
clawdbot plugins enable google-antigravity-auth
```
Restart the Gateway after enabling.
## Authenticate
```bash
clawdbot models auth login --provider google-antigravity --set-default
```
## Notes
- Antigravity uses Google Cloud project quotas.
- If requests fail, ensure Gemini for Google Cloud is enabled.
+428
View File
@@ -0,0 +1,428 @@
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync
const decode = (s: string) => Buffer.from(s, "base64").toString();
const CLIENT_ID = decode(
"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
);
const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
const REDIRECT_URI = "http://localhost:51121/oauth-callback";
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const TOKEN_URL = "https://oauth2.googleapis.com/token";
const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
const DEFAULT_MODEL = "google-antigravity/claude-opus-4-5-thinking";
const SCOPES = [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs",
];
const CODE_ASSIST_ENDPOINTS = [
"https://cloudcode-pa.googleapis.com",
"https://daily-cloudcode-pa.sandbox.googleapis.com",
];
const RESPONSE_PAGE = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Clawdbot Antigravity OAuth</title>
</head>
<body>
<main>
<h1>Authentication complete</h1>
<p>You can return to the terminal.</p>
</main>
</body>
</html>`;
function generatePkce(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("hex");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
function isWSL(): boolean {
if (process.platform !== "linux") return false;
try {
const release = readFileSync("/proc/version", "utf8").toLowerCase();
return release.includes("microsoft") || release.includes("wsl");
} catch {
return false;
}
}
function isWSL2(): boolean {
if (!isWSL()) return false;
try {
const version = readFileSync("/proc/version", "utf8").toLowerCase();
return version.includes("wsl2") || version.includes("microsoft-standard");
} catch {
return false;
}
}
function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
return isRemote || isWSL2();
}
function buildAuthUrl(params: { challenge: string; state: string }): string {
const url = new URL(AUTH_URL);
url.searchParams.set("client_id", CLIENT_ID);
url.searchParams.set("response_type", "code");
url.searchParams.set("redirect_uri", REDIRECT_URI);
url.searchParams.set("scope", SCOPES.join(" "));
url.searchParams.set("code_challenge", params.challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", params.state);
url.searchParams.set("access_type", "offline");
url.searchParams.set("prompt", "consent");
return url.toString();
}
function parseCallbackInput(
input: string,
): { code: string; state: string } | { error: string } {
const trimmed = input.trim();
if (!trimmed) return { error: "No input provided" };
try {
const url = new URL(trimmed);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code) return { error: "Missing 'code' parameter in URL" };
if (!state) return { error: "Missing 'state' parameter in URL" };
return { code, state };
} catch {
return { error: "Paste the full redirect URL (not just the code)." };
}
}
async function startCallbackServer(params: { timeoutMs: number }) {
const redirect = new URL(REDIRECT_URI);
const port = redirect.port ? Number(redirect.port) : 51121;
let settled = false;
let resolveCallback: (url: URL) => void;
let rejectCallback: (err: Error) => void;
const callbackPromise = new Promise<URL>((resolve, reject) => {
resolveCallback = (url) => {
if (settled) return;
settled = true;
resolve(url);
};
rejectCallback = (err) => {
if (settled) return;
settled = true;
reject(err);
};
});
const timeout = setTimeout(() => {
rejectCallback(new Error("Timed out waiting for OAuth callback"));
}, params.timeoutMs);
timeout.unref?.();
const server = createServer((request, response) => {
if (!request.url) {
response.writeHead(400, { "Content-Type": "text/plain" });
response.end("Missing URL");
return;
}
const url = new URL(request.url, `${redirect.protocol}//${redirect.host}`);
if (url.pathname !== redirect.pathname) {
response.writeHead(404, { "Content-Type": "text/plain" });
response.end("Not found");
return;
}
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end(RESPONSE_PAGE);
resolveCallback(url);
setImmediate(() => {
server.close();
});
});
await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => {
server.off("error", onError);
reject(err);
};
server.once("error", onError);
server.listen(port, "127.0.0.1", () => {
server.off("error", onError);
resolve();
});
});
return {
waitForCallback: () => callbackPromise,
close: () =>
new Promise<void>((resolve) => {
server.close(() => resolve());
}),
};
}
async function exchangeCode(params: {
code: string;
verifier: string;
}): Promise<{ access: string; refresh: string; expires: number }> {
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code: params.code,
grant_type: "authorization_code",
redirect_uri: REDIRECT_URI,
code_verifier: params.verifier,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Token exchange failed: ${text}`);
}
const data = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
const access = data.access_token?.trim();
const refresh = data.refresh_token?.trim();
const expiresIn = data.expires_in ?? 0;
if (!access) throw new Error("Token exchange returned no access_token");
if (!refresh) throw new Error("Token exchange returned no refresh_token");
const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000;
return { access, refresh, expires };
}
async function fetchUserEmail(accessToken: string): Promise<string | undefined> {
try {
const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) return undefined;
const data = (await response.json()) as { email?: string };
return data.email;
} catch {
return undefined;
}
}
async function fetchProjectId(accessToken: string): Promise<string> {
const headers = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "google-api-nodejs-client/9.15.1",
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"Client-Metadata": JSON.stringify({
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
}),
};
for (const endpoint of CODE_ASSIST_ENDPOINTS) {
try {
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
method: "POST",
headers,
body: JSON.stringify({
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
}),
});
if (!response.ok) continue;
const data = (await response.json()) as {
cloudaicompanionProject?: string | { id?: string };
};
if (typeof data.cloudaicompanionProject === "string") {
return data.cloudaicompanionProject;
}
if (
data.cloudaicompanionProject &&
typeof data.cloudaicompanionProject === "object" &&
data.cloudaicompanionProject.id
) {
return data.cloudaicompanionProject.id;
}
} catch {
// ignore
}
}
return DEFAULT_PROJECT_ID;
}
async function loginAntigravity(params: {
isRemote: boolean;
openUrl: (url: string) => Promise<void>;
prompt: (message: string) => Promise<string>;
note: (message: string, title?: string) => Promise<void>;
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
}): Promise<{
access: string;
refresh: string;
expires: number;
email?: string;
projectId: string;
}> {
const { verifier, challenge } = generatePkce();
const state = randomBytes(16).toString("hex");
const authUrl = buildAuthUrl({ challenge, state });
let callbackServer: Awaited<ReturnType<typeof startCallbackServer>> | null = null;
const needsManual = shouldUseManualOAuthFlow(params.isRemote);
if (!needsManual) {
try {
callbackServer = await startCallbackServer({ timeoutMs: 5 * 60 * 1000 });
} catch {
callbackServer = null;
}
}
if (!callbackServer) {
await params.note(
[
"Open the URL in your local browser.",
"After signing in, copy the full redirect URL and paste it back here.",
"",
`Auth URL: ${authUrl}`,
`Redirect URI: ${REDIRECT_URI}`,
].join("\n"),
"Google Antigravity OAuth",
);
}
if (!needsManual) {
params.progress.update("Opening Google sign-in…");
try {
await params.openUrl(authUrl);
} catch {
// ignore
}
}
let code = "";
let returnedState = "";
if (callbackServer) {
params.progress.update("Waiting for OAuth callback…");
const callback = await callbackServer.waitForCallback();
code = callback.searchParams.get("code") ?? "";
returnedState = callback.searchParams.get("state") ?? "";
await callbackServer.close();
} else {
params.progress.update("Waiting for redirect URL…");
const input = await params.prompt("Paste the redirect URL: ");
const parsed = parseCallbackInput(input);
if ("error" in parsed) throw new Error(parsed.error);
code = parsed.code;
returnedState = parsed.state;
}
if (!code) throw new Error("Missing OAuth code");
if (returnedState !== state) {
throw new Error("OAuth state mismatch. Please try again.");
}
params.progress.update("Exchanging code for tokens…");
const tokens = await exchangeCode({ code, verifier });
const email = await fetchUserEmail(tokens.access);
const projectId = await fetchProjectId(tokens.access);
params.progress.stop("Antigravity OAuth complete");
return { ...tokens, email, projectId };
}
const antigravityPlugin = {
id: "google-antigravity-auth",
name: "Google Antigravity Auth",
description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
register(api) {
api.registerProvider({
id: "google-antigravity",
label: "Google Antigravity",
docsPath: "/providers/models",
aliases: ["antigravity"],
auth: [
{
id: "oauth",
label: "Google OAuth",
hint: "PKCE + localhost callback",
kind: "oauth",
run: async (ctx) => {
const spin = ctx.prompter.progress("Starting Antigravity OAuth…");
try {
const result = await loginAntigravity({
isRemote: ctx.isRemote,
openUrl: ctx.openUrl,
prompt: async (message) => String(await ctx.prompter.text({ message })),
note: ctx.prompter.note,
progress: spin,
});
const profileId = `google-antigravity:${result.email ?? "default"}`;
return {
profiles: [
{
profileId,
credential: {
type: "oauth",
provider: "google-antigravity",
access: result.access,
refresh: result.refresh,
expires: result.expires,
email: result.email,
projectId: result.projectId,
},
},
],
configPatch: {
agents: {
defaults: {
models: {
[DEFAULT_MODEL]: {},
},
},
},
},
defaultModel: DEFAULT_MODEL,
notes: [
"Antigravity uses Google Cloud project quotas.",
"Enable Gemini for Google Cloud on your project if requests fail.",
],
};
} catch (err) {
spin.stop("Antigravity OAuth failed");
throw err;
}
},
},
],
});
},
};
export default antigravityPlugin;
@@ -0,0 +1,11 @@
{
"name": "@clawdbot/google-antigravity-auth",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Google Antigravity OAuth provider plugin",
"clawdbot": {
"extensions": [
"./index.ts"
]
}
}
@@ -0,0 +1,24 @@
# Google Gemini CLI Auth (Clawdbot plugin)
OAuth provider plugin for **Gemini CLI** (Google Code Assist).
## Enable
Bundled plugins are disabled by default. Enable this one:
```bash
clawdbot plugins enable google-gemini-cli-auth
```
Restart the Gateway after enabling.
## Authenticate
```bash
clawdbot models auth login --provider google-gemini-cli --set-default
```
## Env vars
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID`
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET`
@@ -0,0 +1,88 @@
import { loginGeminiCliOAuth } from "./oauth.js";
const PROVIDER_ID = "google-gemini-cli";
const PROVIDER_LABEL = "Gemini CLI OAuth";
const DEFAULT_MODEL = "google-gemini-cli/gemini-3-pro-preview";
const ENV_VARS = [
"CLAWDBOT_GEMINI_OAUTH_CLIENT_ID",
"CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET",
"GEMINI_CLI_OAUTH_CLIENT_ID",
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
];
const geminiCliPlugin = {
id: "google-gemini-cli-auth",
name: "Google Gemini CLI Auth",
description: "OAuth flow for Gemini CLI (Google Code Assist)",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: PROVIDER_LABEL,
docsPath: "/providers/models",
aliases: ["gemini-cli"],
envVars: ENV_VARS,
auth: [
{
id: "oauth",
label: "Google OAuth",
hint: "PKCE + localhost callback",
kind: "oauth",
run: async (ctx) => {
const spin = ctx.prompter.progress("Starting Gemini CLI OAuth…");
try {
const result = await loginGeminiCliOAuth({
isRemote: ctx.isRemote,
openUrl: ctx.openUrl,
log: (msg) => ctx.runtime.log(msg),
note: ctx.prompter.note,
prompt: async (message) => String(await ctx.prompter.text({ message })),
progress: spin,
});
spin.stop("Gemini CLI OAuth complete");
const profileId = `google-gemini-cli:${result.email ?? "default"}`;
return {
profiles: [
{
profileId,
credential: {
type: "oauth",
provider: PROVIDER_ID,
access: result.access,
refresh: result.refresh,
expires: result.expires,
email: result.email,
projectId: result.projectId,
},
},
],
configPatch: {
agents: {
defaults: {
models: {
[DEFAULT_MODEL]: {},
},
},
},
},
defaultModel: DEFAULT_MODEL,
notes: [
"If requests fail, set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.",
],
};
} catch (err) {
spin.stop("Gemini CLI OAuth failed");
await ctx.prompter.note(
"Trouble with OAuth? Ensure your Google account has Gemini CLI access.",
"OAuth help",
);
throw err;
}
},
},
],
});
},
};
export default geminiCliPlugin;
+496
View File
@@ -0,0 +1,496 @@
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
const CLIENT_ID_KEYS = ["CLAWDBOT_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"];
const CLIENT_SECRET_KEYS = [
"CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET",
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
];
const REDIRECT_URI = "http://localhost:8085/oauth2callback";
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const TOKEN_URL = "https://oauth2.googleapis.com/token";
const USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json";
const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
const SCOPES = [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
];
const TIER_FREE = "free-tier";
const TIER_LEGACY = "legacy-tier";
const TIER_STANDARD = "standard-tier";
export type GeminiCliOAuthCredentials = {
access: string;
refresh: string;
expires: number;
email?: string;
projectId: string;
};
export type GeminiCliOAuthContext = {
isRemote: boolean;
openUrl: (url: string) => Promise<void>;
log: (msg: string) => void;
note: (message: string, title?: string) => Promise<void>;
prompt: (message: string) => Promise<string>;
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
};
function resolveEnv(keys: string[]): string | undefined {
for (const key of keys) {
const value = process.env[key]?.trim();
if (value) return value;
}
return undefined;
}
function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } {
const clientId = resolveEnv(CLIENT_ID_KEYS);
if (!clientId) {
throw new Error(
"Missing Gemini OAuth client ID. Set CLAWDBOT_GEMINI_OAUTH_CLIENT_ID (or GEMINI_CLI_OAUTH_CLIENT_ID).",
);
}
const clientSecret = resolveEnv(CLIENT_SECRET_KEYS);
return { clientId, clientSecret };
}
function isWSL(): boolean {
if (process.platform !== "linux") return false;
try {
const release = readFileSync("/proc/version", "utf8").toLowerCase();
return release.includes("microsoft") || release.includes("wsl");
} catch {
return false;
}
}
function isWSL2(): boolean {
if (!isWSL()) return false;
try {
const version = readFileSync("/proc/version", "utf8").toLowerCase();
return version.includes("wsl2") || version.includes("microsoft-standard");
} catch {
return false;
}
}
function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
return isRemote || isWSL2();
}
function generatePkce(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("hex");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
function buildAuthUrl(challenge: string, verifier: string): string {
const { clientId } = resolveOAuthClientConfig();
const params = new URLSearchParams({
client_id: clientId,
response_type: "code",
redirect_uri: REDIRECT_URI,
scope: SCOPES.join(" "),
code_challenge: challenge,
code_challenge_method: "S256",
state: verifier,
access_type: "offline",
prompt: "consent",
});
return `${AUTH_URL}?${params.toString()}`;
}
function parseCallbackInput(
input: string,
expectedState: string,
): { code: string; state: string } | { error: string } {
const trimmed = input.trim();
if (!trimmed) return { error: "No input provided" };
try {
const url = new URL(trimmed);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state") ?? expectedState;
if (!code) return { error: "Missing 'code' parameter in URL" };
if (!state) return { error: "Missing 'state' parameter. Paste the full URL." };
return { code, state };
} catch {
if (!expectedState) return { error: "Paste the full redirect URL, not just the code." };
return { code: trimmed, state: expectedState };
}
}
async function waitForLocalCallback(params: {
expectedState: string;
timeoutMs: number;
onProgress?: (message: string) => void;
}): Promise<{ code: string; state: string }> {
const port = 8085;
const hostname = "localhost";
const expectedPath = "/oauth2callback";
return new Promise<{ code: string; state: string }>((resolve, reject) => {
let timeout: NodeJS.Timeout | null = null;
const server = createServer((req, res) => {
try {
const requestUrl = new URL(req.url ?? "/", `http://${hostname}:${port}`);
if (requestUrl.pathname !== expectedPath) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain");
res.end("Not found");
return;
}
const error = requestUrl.searchParams.get("error");
const code = requestUrl.searchParams.get("code")?.trim();
const state = requestUrl.searchParams.get("state")?.trim();
if (error) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain");
res.end(`Authentication failed: ${error}`);
finish(new Error(`OAuth error: ${error}`));
return;
}
if (!code || !state) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain");
res.end("Missing code or state");
finish(new Error("Missing OAuth code or state"));
return;
}
if (state !== params.expectedState) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain");
res.end("Invalid state");
finish(new Error("OAuth state mismatch"));
return;
}
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(
"<!doctype html><html><head><meta charset='utf-8'/></head>" +
"<body><h2>Gemini CLI OAuth complete</h2>" +
"<p>You can close this window and return to Clawdbot.</p></body></html>",
);
finish(undefined, { code, state });
} catch (err) {
finish(err instanceof Error ? err : new Error("OAuth callback failed"));
}
});
const finish = (err?: Error, result?: { code: string; state: string }) => {
if (timeout) clearTimeout(timeout);
try {
server.close();
} catch {
// ignore close errors
}
if (err) {
reject(err);
} else if (result) {
resolve(result);
}
};
server.once("error", (err) => {
finish(err instanceof Error ? err : new Error("OAuth callback server error"));
});
server.listen(port, hostname, () => {
params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}…`);
});
timeout = setTimeout(() => {
finish(new Error("OAuth callback timeout"));
}, params.timeoutMs);
});
}
async function exchangeCodeForTokens(code: string, verifier: string): Promise<GeminiCliOAuthCredentials> {
const { clientId, clientSecret } = resolveOAuthClientConfig();
const body = new URLSearchParams({
client_id: clientId,
code,
grant_type: "authorization_code",
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
});
if (clientSecret) {
body.set("client_secret", clientSecret);
}
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${errorText}`);
}
const data = (await response.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
if (!data.refresh_token) {
throw new Error("No refresh token received. Please try again.");
}
const email = await getUserEmail(data.access_token);
const projectId = await discoverProject(data.access_token);
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
return {
refresh: data.refresh_token,
access: data.access_token,
expires: expiresAt,
projectId,
email,
};
}
async function getUserEmail(accessToken: string): Promise<string | undefined> {
try {
const response = await fetch(USERINFO_URL, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (response.ok) {
const data = (await response.json()) as { email?: string };
return data.email;
}
} catch {
// ignore
}
return undefined;
}
async function discoverProject(accessToken: string): Promise<string> {
const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID;
const headers = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "google-api-nodejs-client/9.15.1",
"X-Goog-Api-Client": "gl-node/clawdbot",
};
const loadBody = {
cloudaicompanionProject: envProject,
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
duetProject: envProject,
},
};
let data: {
currentTier?: { id?: string };
cloudaicompanionProject?: string | { id?: string };
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
} = {};
try {
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, {
method: "POST",
headers,
body: JSON.stringify(loadBody),
});
if (!response.ok) {
const errorPayload = await response.json().catch(() => null);
if (isVpcScAffected(errorPayload)) {
data = { currentTier: { id: TIER_STANDARD } };
} else {
throw new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`);
}
} else {
data = (await response.json()) as typeof data;
}
} catch (err) {
if (err instanceof Error) {
throw err;
}
throw new Error("loadCodeAssist failed");
}
if (data.currentTier) {
const project = data.cloudaicompanionProject;
if (typeof project === "string" && project) return project;
if (typeof project === "object" && project?.id) return project.id;
if (envProject) return envProject;
throw new Error(
"This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.",
);
}
const tier = getDefaultTier(data.allowedTiers);
const tierId = tier?.id || TIER_FREE;
if (tierId !== TIER_FREE && !envProject) {
throw new Error(
"This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.",
);
}
const onboardBody: Record<string, unknown> = {
tierId,
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
};
if (tierId !== TIER_FREE && envProject) {
onboardBody.cloudaicompanionProject = envProject;
(onboardBody.metadata as Record<string, unknown>).duetProject = envProject;
}
const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, {
method: "POST",
headers,
body: JSON.stringify(onboardBody),
});
if (!onboardResponse.ok) {
throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}`);
}
let lro = (await onboardResponse.json()) as {
done?: boolean;
name?: string;
response?: { cloudaicompanionProject?: { id?: string } };
};
if (!lro.done && lro.name) {
lro = await pollOperation(lro.name, headers);
}
const projectId = lro.response?.cloudaicompanionProject?.id;
if (projectId) return projectId;
if (envProject) return envProject;
throw new Error(
"Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.",
);
}
function isVpcScAffected(payload: unknown): boolean {
if (!payload || typeof payload !== "object") return false;
const error = (payload as { error?: unknown }).error;
if (!error || typeof error !== "object") return false;
const details = (error as { details?: unknown[] }).details;
if (!Array.isArray(details)) return false;
return details.some(
(item) =>
typeof item === "object" && item && (item as { reason?: string }).reason === "SECURITY_POLICY_VIOLATED",
);
}
function getDefaultTier(
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>,
): { id?: string } | undefined {
if (!allowedTiers?.length) return { id: TIER_LEGACY };
return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY };
}
async function pollOperation(
operationName: string,
headers: Record<string, string>,
): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> {
for (let attempt = 0; attempt < 24; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 5000));
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, {
headers,
});
if (!response.ok) continue;
const data = (await response.json()) as {
done?: boolean;
response?: { cloudaicompanionProject?: { id?: string } };
};
if (data.done) return data;
}
throw new Error("Operation polling timeout");
}
export async function loginGeminiCliOAuth(ctx: GeminiCliOAuthContext): Promise<GeminiCliOAuthCredentials> {
const needsManual = shouldUseManualOAuthFlow(ctx.isRemote);
await ctx.note(
needsManual
? [
"You are running in a remote/VPS environment.",
"A URL will be shown for you to open in your LOCAL browser.",
"After signing in, copy the redirect URL and paste it back here.",
].join("\n")
: [
"Browser will open for Google authentication.",
"Sign in with your Google account for Gemini CLI access.",
"The callback will be captured automatically on localhost:8085.",
].join("\n"),
"Gemini CLI OAuth",
);
const { verifier, challenge } = generatePkce();
const authUrl = buildAuthUrl(challenge, verifier);
if (needsManual) {
ctx.progress.update("OAuth URL ready");
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
ctx.progress.update("Waiting for you to paste the callback URL...");
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
const parsed = parseCallbackInput(callbackInput, verifier);
if ("error" in parsed) throw new Error(parsed.error);
if (parsed.state !== verifier) {
throw new Error("OAuth state mismatch - please try again");
}
ctx.progress.update("Exchanging authorization code for tokens...");
return exchangeCodeForTokens(parsed.code, verifier);
}
ctx.progress.update("Complete sign-in in browser...");
try {
await ctx.openUrl(authUrl);
} catch {
ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`);
}
try {
const { code } = await waitForLocalCallback({
expectedState: verifier,
timeoutMs: 5 * 60 * 1000,
onProgress: (msg) => ctx.progress.update(msg),
});
ctx.progress.update("Exchanging authorization code for tokens...");
return await exchangeCodeForTokens(code, verifier);
} catch (err) {
if (
err instanceof Error &&
(err.message.includes("EADDRINUSE") ||
err.message.includes("port") ||
err.message.includes("listen"))
) {
ctx.progress.update("Local callback server failed. Switching to manual mode...");
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
const parsed = parseCallbackInput(callbackInput, verifier);
if ("error" in parsed) throw new Error(parsed.error);
if (parsed.state !== verifier) {
throw new Error("OAuth state mismatch - please try again");
}
ctx.progress.update("Exchanging authorization code for tokens...");
return exchangeCodeForTokens(parsed.code, verifier);
}
throw err;
}
}
@@ -0,0 +1,11 @@
{
"name": "@clawdbot/google-gemini-cli-auth",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Gemini CLI OAuth provider plugin",
"clawdbot": {
"extensions": [
"./index.ts"
]
}
}
+15
View File
@@ -1,5 +1,20 @@
# Changelog
## 2026.1.17-1
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.17
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.16
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.15
### Changes
+3 -1
View File
@@ -1,12 +1,14 @@
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { matrixPlugin } from "./src/channel.js";
import { setMatrixRuntime } from "./src/runtime.js";
const plugin = {
id: "matrix",
name: "Matrix",
description: "Matrix channel plugin (matrix-js-sdk)",
register(api: ClawdbotPluginApi) {
setMatrixRuntime(api.runtime);
api.registerChannel({ plugin: matrixPlugin });
},
};
+4 -2
View File
@@ -1,10 +1,12 @@
{
"name": "@clawdbot/matrix",
"version": "2026.1.15",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Matrix channel plugin",
"clawdbot": {
"extensions": ["./index.ts"]
"extensions": [
"./index.ts"
]
},
"dependencies": {
"markdown-it": "14.1.0",
+9 -7
View File
@@ -1,13 +1,15 @@
import { createActionGate, readNumberParam, readStringParam } from "../../../src/agents/tools/common.js";
import {
createActionGate,
readNumberParam,
readStringParam,
type ChannelMessageActionAdapter,
type ChannelMessageActionContext,
type ChannelMessageActionName,
type ChannelToolSend,
} from "clawdbot/plugin-sdk";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import { handleMatrixAction } from "./tool-actions.js";
import type { CoreConfig } from "./types.js";
import type {
ChannelMessageActionAdapter,
ChannelMessageActionContext,
ChannelMessageActionName,
ChannelToolSend,
} from "../../../src/channels/plugins/types.js";
export const matrixMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
+32 -15
View File
@@ -1,13 +1,14 @@
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
setAccountEnabledInConfigSection,
} from "../../../src/channels/plugins/config-helpers.js";
import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js";
import { formatPairingApproveHint } from "../../../src/channels/plugins/helpers.js";
import { PAIRING_APPROVED_MESSAGE } from "../../../src/channels/plugins/pairing-message.js";
import { applyAccountNameToChannelSection } from "../../../src/channels/plugins/setup-helpers.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
type ChannelPlugin,
} from "clawdbot/plugin-sdk";
import { matrixMessageActions } from "./actions.js";
import { MatrixConfigSchema } from "./config-schema.js";
@@ -25,6 +26,11 @@ import { probeMatrix } from "./matrix/probe.js";
import { sendMessageMatrix } from "./matrix/send.js";
import { matrixOnboardingAdapter } from "./onboarding.js";
import { matrixOutbound } from "./outbound.js";
import { resolveMatrixTargets } from "./resolve-targets.js";
import {
listMatrixDirectoryGroupsLive,
listMatrixDirectoryPeersLive,
} from "./directory-live.js";
const meta = {
id: "matrix",
@@ -147,8 +153,9 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
approveHint: formatPairingApproveHint("matrix"),
normalizeEntry: (raw) => raw.replace(/^matrix:/i, "").trim().toLowerCase(),
}),
collectWarnings: ({ account }) => {
const groupPolicy = account.config.groupPolicy ?? "allowlist";
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
"- Matrix rooms: groupPolicy=\"open\" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy=\"allowlist\" + channels.matrix.rooms to restrict rooms.",
@@ -164,13 +171,15 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
},
messaging: {
normalizeTarget: normalizeMatrixMessagingTarget,
looksLikeTargetId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^(matrix:)?[!#@]/i.test(trimmed)) return true;
return trimmed.includes(":");
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^(matrix:)?[!#@]/i.test(trimmed)) return true;
return trimmed.includes(":");
},
hint: "<room|alias|user>",
},
targetHint: "<room|alias|user>",
},
directory: {
self: async () => null,
@@ -232,6 +241,14 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
.map((id) => ({ kind: "group", id }) as const);
return ids;
},
listPeersLive: async ({ cfg, query, limit }) =>
listMatrixDirectoryPeersLive({ cfg, query, limit }),
listGroupsLive: async ({ cfg, query, limit }) =>
listMatrixDirectoryGroupsLive({ cfg, query, limit }),
},
resolver: {
resolveTargets: async ({ cfg, inputs, kind, runtime }) =>
resolveMatrixTargets({ cfg, inputs, kind, runtime }),
},
actions: matrixMessageActions,
setup: {
+175
View File
@@ -0,0 +1,175 @@
import type { ChannelDirectoryEntry } from "clawdbot/plugin-sdk";
import { resolveMatrixAuth } from "./matrix/client.js";
type MatrixUserResult = {
user_id?: string;
display_name?: string;
};
type MatrixUserDirectoryResponse = {
results?: MatrixUserResult[];
};
type MatrixJoinedRoomsResponse = {
joined_rooms?: string[];
};
type MatrixRoomNameState = {
name?: string;
};
type MatrixAliasLookup = {
room_id?: string;
};
async function fetchMatrixJson<T>(params: {
homeserver: string;
path: string;
accessToken: string;
method?: "GET" | "POST";
body?: unknown;
}): Promise<T> {
const res = await fetch(`${params.homeserver}${params.path}`, {
method: params.method ?? "GET",
headers: {
Authorization: `Bearer ${params.accessToken}`,
"Content-Type": "application/json",
},
body: params.body ? JSON.stringify(params.body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Matrix API ${params.path} failed (${res.status}): ${text || "unknown error"}`);
}
return (await res.json()) as T;
}
function normalizeQuery(value?: string | null): string {
return value?.trim().toLowerCase() ?? "";
}
export async function listMatrixDirectoryPeersLive(params: {
cfg: unknown;
query?: string | null;
limit?: number | null;
}): Promise<ChannelDirectoryEntry[]> {
const query = normalizeQuery(params.query);
if (!query) return [];
const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
const res = await fetchMatrixJson<MatrixUserDirectoryResponse>({
homeserver: auth.homeserver,
accessToken: auth.accessToken,
path: "/_matrix/client/v3/user_directory/search",
method: "POST",
body: {
search_term: query,
limit: typeof params.limit === "number" && params.limit > 0 ? params.limit : 20,
},
});
const results = res.results ?? [];
return results
.map((entry) => {
const userId = entry.user_id?.trim();
if (!userId) return null;
return {
kind: "user",
id: userId,
name: entry.display_name?.trim() || undefined,
handle: entry.display_name ? `@${entry.display_name.trim()}` : undefined,
raw: entry,
} satisfies ChannelDirectoryEntry;
})
.filter(Boolean) as ChannelDirectoryEntry[];
}
async function resolveMatrixRoomAlias(
homeserver: string,
accessToken: string,
alias: string,
): Promise<string | null> {
try {
const res = await fetchMatrixJson<MatrixAliasLookup>({
homeserver,
accessToken,
path: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`,
});
return res.room_id?.trim() || null;
} catch {
return null;
}
}
async function fetchMatrixRoomName(
homeserver: string,
accessToken: string,
roomId: string,
): Promise<string | null> {
try {
const res = await fetchMatrixJson<MatrixRoomNameState>({
homeserver,
accessToken,
path: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`,
});
return res.name?.trim() || null;
} catch {
return null;
}
}
export async function listMatrixDirectoryGroupsLive(params: {
cfg: unknown;
query?: string | null;
limit?: number | null;
}): Promise<ChannelDirectoryEntry[]> {
const query = normalizeQuery(params.query);
if (!query) return [];
const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
if (query.startsWith("#")) {
const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query);
if (!roomId) return [];
return [
{
kind: "group",
id: roomId,
name: query,
handle: query,
} satisfies ChannelDirectoryEntry,
];
}
if (query.startsWith("!")) {
return [
{
kind: "group",
id: query,
name: query,
} satisfies ChannelDirectoryEntry,
];
}
const joined = await fetchMatrixJson<MatrixJoinedRoomsResponse>({
homeserver: auth.homeserver,
accessToken: auth.accessToken,
path: "/_matrix/client/v3/joined_rooms",
});
const rooms = joined.joined_rooms ?? [];
const results: ChannelDirectoryEntry[] = [];
for (const roomId of rooms) {
const name = await fetchMatrixRoomName(auth.homeserver, auth.accessToken, roomId);
if (!name) continue;
if (!name.toLowerCase().includes(query)) continue;
results.push({
kind: "group",
id: roomId,
name,
handle: `#${name}`,
});
if (results.length >= limit) break;
}
return results;
}
+4 -4
View File
@@ -1,4 +1,4 @@
import type { ChannelGroupContext } from "../../../src/channels/plugins/types.js";
import type { ChannelGroupContext } from "clawdbot/plugin-sdk";
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
import type { CoreConfig } from "./types.js";
@@ -16,14 +16,14 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
if (roomId.toLowerCase().startsWith("room:")) {
roomId = roomId.slice("room:".length).trim();
}
const groupRoom = params.groupRoom?.trim() ?? "";
const aliases = groupRoom ? [groupRoom] : [];
const groupChannel = params.groupChannel?.trim() ?? "";
const aliases = groupChannel ? [groupChannel] : [];
const cfg = params.cfg as CoreConfig;
const resolved = resolveMatrixRoomConfig({
rooms: cfg.channels?.matrix?.rooms,
roomId,
aliases,
name: groupRoom || undefined,
name: groupChannel || undefined,
}).config;
if (resolved) {
if (resolved.autoReply === true) return false;
+1 -1
View File
@@ -1,4 +1,4 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../../src/routing/session-key.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
import type { CoreConfig, MatrixConfig } from "../types.js";
import { resolveMatrixConfig } from "./client.js";
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
+1 -1
View File
@@ -15,7 +15,7 @@ import type {
RoomTopicEventContent,
} from "matrix-js-sdk/lib/@types/state_events.js";
import { loadConfig } from "../../../../src/config/config.js";
import { loadConfig } from "clawdbot/plugin-sdk";
import type { CoreConfig } from "../types.js";
import { getActiveMatrixClient } from "./active-client.js";
import {
Loaded 100 of 633 files, more files were not shown because too many files have changed in this diff. Show more