Compare commits

..

349 Commits

Author SHA1 Message Date
Peter Steinberger
172b6e3ba2 fix: harden memory plugin config (#1149) (thanks @radek-paclt) 2026-01-18 07:09:49 +00:00
Radek Paclt
e0b1561e16 feat(memory): add lifecycle hooks and vector memory plugin
Add plugin lifecycle hooks infrastructure:
- before_agent_start: inject context before agent loop
- agent_end: analyze conversation after completion
- 13 hook types total (message, tool, session, gateway hooks)

Memory plugin implementation:
- LanceDB vector storage with OpenAI embeddings
- kind: "memory" to integrate with upstream slot system
- Auto-recall: injects <relevant-memories> when context found
- Auto-capture: stores preferences, decisions, entities
- Rule-based capture filtering with 0.95 similarity dedup
- Tools: memory_recall, memory_store, memory_forget
- CLI: clawdbot ltm list|search|stats

Plugin infrastructure:
- api.on() method for hook registration
- Global hook runner singleton for cross-module access
- Priority ordering and error catching

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 06:59:03 +00:00
Peter Steinberger
f86b24c511 refactor(session): centralize thread reset detection
Co-authored-by: Austin Mudd <austinm911@gmail.com>
2026-01-18 06:55:04 +00:00
Peter Steinberger
b5ddf08763 test: expand soul-evil coverage 2026-01-18 06:39:26 +00:00
Peter Steinberger
367826f6e4 feat(session): add daily reset policy
Co-authored-by: Austin Mudd <austinm911@gmail.com>
2026-01-18 06:37:37 +00:00
Peter Steinberger
f03c3b3f05 docs: update changelog for #1147
Co-authored-by: Andrew Lauppe <andy@t5tele.com>
2026-01-18 06:37:29 +00:00
Peter Steinberger
ac1b2d8c40 chore(gate): fix lint and protocol 2026-01-18 06:31:02 +00:00
Peter Steinberger
2087f0c6a1 ci: bump vitest timeouts 2026-01-18 06:31:02 +00:00
Peter Steinberger
bcfdcc6820 fix: keep bootstrap files in context report 2026-01-18 06:30:01 +00:00
Peter Steinberger
b65acfcbb7 chore(lint): fix context report bootstrap destructure 2026-01-18 06:30:01 +00:00
Peter Steinberger
f7fcfafb4c fix: resolve lint after rebase 2026-01-18 06:30:01 +00:00
Peter Steinberger
15606b4d88 test: cover bundled memory plugin package metadata 2026-01-18 06:30:01 +00:00
Peter Steinberger
bb8f08734a build: package memory-core as a workspace plugin 2026-01-18 06:30:01 +00:00
Peter Steinberger
0b00e591e1 fix(streaming): emit assistant deltas
Co-authored-by: Andrew Lauppe <andy@t5tele.com>
2026-01-18 06:24:52 +00:00
Peter Steinberger
e39fd7dbb3 docs: update bundled hooks list 2026-01-18 06:23:09 +00:00
Peter Steinberger
b8a82923e9 docs: add soul-evil hook docs 2026-01-18 06:21:00 +00:00
Peter Steinberger
28f8b7bafa refactor: add hook guards and test helpers 2026-01-18 06:15:24 +00:00
Peter Steinberger
32dd052260 chore: show plugin hooks in plugins info 2026-01-18 06:14:09 +00:00
Peter Steinberger
8f7f7ee7dc feat: add /exec session overrides 2026-01-18 06:12:54 +00:00
Peter Steinberger
1d8614c7c2 fix: align exec tool config and test timeouts 2026-01-18 06:12:53 +00:00
Peter Steinberger
436c5fd751 fix(openai-http): reuse history markers for chat prompts
Co-authored-by: Andrew Lauppe <andy@t5tele.com>
2026-01-18 06:07:59 +00:00
Peter Steinberger
f5f7f47c81 chore(format): oxfmt hooks-cli 2026-01-18 06:03:22 +00:00
Peter Steinberger
d4bd387e0e chore(gate): fix lint and formatting 2026-01-18 06:01:25 +00:00
Peter Steinberger
d1c85cb32d test(gateway): stabilize cron temp cleanup 2026-01-18 06:01:25 +00:00
Peter Steinberger
a3a2c641a7 test(usage): cover modes and full footer 2026-01-18 06:01:25 +00:00
Peter Steinberger
54d7551b53 refactor(usage): centralize responseUsage mode 2026-01-18 06:01:25 +00:00
Peter Steinberger
e2c10a2b7a feat: support plugin-managed hooks 2026-01-18 05:57:05 +00:00
Peter Steinberger
88b37e80fc refactor: expand bootstrap helpers and tests 2026-01-18 05:51:55 +00:00
Peter Steinberger
d5be8fa576 test: avoid timer hangs in cron tests 2026-01-18 05:44:22 +00:00
Peter Steinberger
208398973b test: stabilize gateway suites 2026-01-18 05:44:22 +00:00
Peter Steinberger
8f998741b7 fix: shorten doctor gateway health timeout in non-interactive 2026-01-18 05:44:22 +00:00
Peter Steinberger
9c0ff87c86 fix: align plugin runtime and exec wiring 2026-01-18 05:44:22 +00:00
Peter Steinberger
1a0d1cb7b2 test: stabilize gateway ports and timers 2026-01-18 05:44:22 +00:00
Peter Steinberger
cf8b3ed988 fix: harden memory indexing and embedded session locks 2026-01-18 05:41:45 +00:00
Peter Steinberger
b7575a889e refactor: align status with plugin memory slot 2026-01-18 05:40:10 +00:00
Peter Steinberger
154d4a43db build: export plugin-sdk for extensions 2026-01-18 05:40:10 +00:00
Peter Steinberger
b5c023044b docs: expand memory hybrid search explainer 2026-01-18 05:40:10 +00:00
Peter Steinberger
072a13f3b2 test: expand memory hybrid coverage 2026-01-18 05:40:10 +00:00
Peter Steinberger
c00ea63bb0 refactor: split memory manager internals 2026-01-18 05:40:10 +00:00
Peter Steinberger
8350758635 chore(lint): fix unused vars and formatting 2026-01-18 05:38:23 +00:00
Peter Steinberger
2dabce59ce feat(slash-commands): usage footer modes 2026-01-18 05:35:35 +00:00
Peter Steinberger
e7a4931932 refactor: centralize bootstrap file resolution 2026-01-18 05:31:04 +00:00
Peter Steinberger
ad3c12a43a feat: add bootstrap hook and soul-evil hook 2026-01-18 05:24:47 +00:00
Peter Steinberger
7e2d91f3b7 test: cover subagent helpers 2026-01-18 05:19:56 +00:00
Peter Steinberger
97cef49046 refactor: share subagent helpers 2026-01-18 05:19:56 +00:00
Peter Steinberger
016693a1f5 fix: abort embedded prompts on cancel 2026-01-18 05:18:10 +00:00
Peter Steinberger
89c5185f1c feat: migrate zalouser plugin to sdk
# Conflicts:
#	CHANGELOG.md
2026-01-18 05:17:40 +00:00
Peter Steinberger
b105745299 feat: expand subagent status visibility 2026-01-18 04:46:04 +00:00
Peter Steinberger
1ae415e395 fix: align agent exec config 2026-01-18 04:37:15 +00:00
Peter Steinberger
55aff22274 feat: surface batch request progress 2026-01-18 04:30:15 +00:00
Peter Steinberger
e4e1396a98 perf: improve batch status logging 2026-01-18 04:28:14 +00:00
Peter Steinberger
331b8157b0 docs: clarify plugin agent tool config 2026-01-18 04:28:00 +00:00
Peter Steinberger
efdb33c975 feat: add exec host approvals flow 2026-01-18 04:27:41 +00:00
Peter Steinberger
fa1079214b fix: include query in Twilio webhook verification 2026-01-18 04:25:28 +00:00
Peter Steinberger
82e49af5a7 fix: resolve plugin tool meta typing 2026-01-18 04:24:16 +00:00
Peter Steinberger
fabc2882aa fix: avoid keychain prompts in embedded runner 2026-01-18 04:19:28 +00:00
Peter Steinberger
6b3d3f5e21 refactor: centralize plugin tool policy helpers 2026-01-18 04:18:32 +00:00
Peter Steinberger
6da6582ced feat: add optional plugin tools 2026-01-18 04:08:00 +00:00
Peter Steinberger
45bf07ba31 Update canvas skill with Tailscale integration details and architecture 2026-01-18 03:57:19 +00:00
Peter Steinberger
50ae43f886 Add canvas skill documentation 2026-01-18 03:55:52 +00:00
Peter Steinberger
afb877a96b perf: speed up memory batch polling 2026-01-18 03:55:14 +00:00
Peter Steinberger
0d9172d761 fix: persist session origin metadata 2026-01-18 03:41:51 +00:00
Peter Steinberger
dad69afc84 fix: align plugin runtime types 2026-01-18 03:41:25 +00:00
Peter Steinberger
787bed4996 test: stabilize doctor + pi-embedded suites 2026-01-18 03:40:47 +00:00
Peter Steinberger
b6d470a679 feat: migrate zalo plugin to sdk 2026-01-18 03:37:26 +00:00
Peter Steinberger
5fa1a63978 Merge pull request #1136 from cheeeee/fix/prompt-failover
fix(agent): Enable model fallback for prompt-phase quota/rate limit errors
2026-01-18 03:32:03 +00:00
Peter Steinberger
6cc57ae772 feat: add bluebubbles plugin 2026-01-18 03:17:43 +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 Steinberger
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 Steinberger
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
Mykyta Bozhenko
448394a0de 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 01:29:48 +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 Steinberger
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 Steinberger
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 Steinberger
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 Steinberger
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 Steinberger
a08438ae97 refactor(discord): centralize target parsing
Co-authored-by: Jonathan Rhyne <jonathan@pspdfkit.com>
2026-01-18 00:04:38 +00:00
Peter Steinberger
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 Steinberger
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 Steinberger
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 Steinberger
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 Steinberger
5aed38eebc fix(discord): honor thread allowlists in reactions
Co-authored-by: Codex <codex@openai.com>
2026-01-17 23:03:51 +00:00
Peter Steinberger
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 Steinberger
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 Steinberger
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 Steinberger
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 Steinberger
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 Steinberger
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
zerone0x
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 Steinberger
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 Steinberger
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 Steinberger
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 Steinberger
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 Steinberger
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 Steinberger
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 Steinberger
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 Steinberger
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 Steinberger
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 Steinberger
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
Peter Steinberger
86a46874da fix: preserve discord chunk whitespace 2026-01-17 07:11:21 +00:00
Peter Steinberger
3a6ee5ee00 feat: unify hooks installs and webhooks 2026-01-17 07:08:04 +00:00
Peter Steinberger
5dc87a2ed4 fix: respond to PTY cursor queries 2026-01-17 07:05:24 +00:00
Peter Steinberger
a85ddf258c fix: expose deliveryContext in sessions_list
Co-authored-by: Adam Holt <mail@adamholt.co.nz>
2026-01-17 06:54:31 +00:00
Peter Steinberger
1f3a09b43b fix: persist deliveryContext on last-route updates
Co-authored-by: Adam Holt <mail@adamholt.co.nz>
2026-01-17 06:54:31 +00:00
Peter Steinberger
7b31b280f8 refactor: reuse agent outbound target resolution
Co-authored-by: Adam Holt <mail@adamholt.co.nz>
2026-01-17 06:54:31 +00:00
Peter Steinberger
6a3ed5c850 fix(security): gate slash/control commands 2026-01-17 06:49:34 +00:00
Peter Steinberger
7ed55682b7 fix(build): allow @lydell/node-pty builds 2026-01-17 06:49:33 +00:00
Peter Steinberger
37a2eee837 refactor: drop legacy session store keys 2026-01-17 06:48:44 +00:00
Peter Steinberger
353d778988 refactor: centralize target normalization 2026-01-17 06:45:11 +00:00
Peter Steinberger
5a1ff5b9e7 refactor: tune media understanding 2026-01-17 06:44:19 +00:00
Peter Steinberger
3dc4a96330 feat: add process submit helper 2026-01-17 06:38:56 +00:00
Peter Steinberger
65a8a93854 fix: normalize delivery routing context
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 06:38:33 +00:00
Peter Steinberger
eb8a0510e0 refactor: unify queue drop handling 2026-01-17 06:38:33 +00:00
Peter Steinberger
a4178e4062 fix: stabilize pty send-keys tests 2026-01-17 06:32:24 +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
Peter Steinberger
5ebfc0738f feat: add session slug generator 2026-01-17 06:23:26 +00:00
Peter Steinberger
bd32cc40e6 feat: add keypad key mappings 2026-01-17 06:22:05 +00:00
Peter Steinberger
b31d8d3b10 feat: add tmux-style process key helpers 2026-01-17 06:12:56 +00:00
Peter Steinberger
331141ad77 refactor: centralize message target resolution
Co-authored-by: Thinh Dinh <tobalsan@users.noreply.github.com>
2026-01-17 06:04:49 +00:00
Peter Steinberger
c7ae5100fa refactor: share queue helpers
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 06:02:27 +00:00
Peter Steinberger
285ed8bac3 fix: sync delivery routing context
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 06:02:27 +00:00
Peter Steinberger
e59d8c5436 style: oxfmt format 2026-01-17 05:48:56 +00:00
Peter Steinberger
8b42902cee refactor: drop legacy room chatType 2026-01-17 05:46:40 +00:00
Peter Steinberger
07a3db153d feat: notify on exec exit 2026-01-17 05:43:34 +00:00
Peter Steinberger
68d35be383 feat: emit tool outputs for full verbose 2026-01-17 05:40:21 +00:00
Peter Steinberger
99dd428862 feat: extend verbose tool feedback 2026-01-17 05:33:39 +00:00
Peter Steinberger
4d314db750 refactor: extract subagent announce queue
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 05:29:07 +00:00
Peter Steinberger
ccea3a0615 refactor: unify delivery target resolution
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 05:29:06 +00:00
Peter Steinberger
f4f20c6762 refactor: normalize session route fields
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 05:29:06 +00:00
Peter Steinberger
a624878973 fix(security): gate slash commands by sender 2026-01-17 05:25:42 +00:00
Peter Steinberger
c8b826ea8c fix: add media understanding decision types 2026-01-17 05:24:54 +00:00
Peter Steinberger
f7089cde54 fix: unify inbound sender labels 2026-01-17 05:21:09 +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
Peter Steinberger
572e04d5fb refactor(cli): split outbound send deps 2026-01-17 05:06:39 +00:00
Peter Steinberger
bc49c20434 fix: finalize inbound contexts 2026-01-17 05:06:39 +00:00
Peter Steinberger
4b085f23e0 docs: note live target cache refresh
Co-authored-by: Thinh Dinh <tobalsan@users.noreply.github.com>
2026-01-17 05:00:15 +00:00
Peter Steinberger
c4ea25a509 feat: add exec pty support 2026-01-17 04:57:11 +00:00
Peter Steinberger
312cb75c50 fix: trim /status oauth output 2026-01-17 04:54:28 +00:00
Peter Steinberger
ee738e6578 test: fix mocks for target resolver 2026-01-17 04:41:02 +00:00
Peter Steinberger
fcb7c9ff65 refactor: unify media understanding pipeline 2026-01-17 04:39:00 +00:00
Peter Steinberger
49ecbd8fea test: expand accountId routing coverage
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 04:33:24 +00:00
Peter Steinberger
19ee6699d2 refactor: clarify subagent announce origin
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 04:33:24 +00:00
Peter Steinberger
5fcc9b3244 refactor: centralize target errors and cache lookups 2026-01-17 04:28:22 +00:00
Peter Steinberger
3efc5e54fa fix: preserve account routing for explicit targets
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 04:24:59 +00:00
Peter Steinberger
780c811146 refactor: migrate subagent registry store v2
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 04:24:59 +00:00
Peter Steinberger
4f37f66264 refactor: normalize delivery context
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 04:24:59 +00:00
Peter Steinberger
8ebfa2950d refactor: align message target wording 2026-01-17 04:18:59 +00:00
Peter Steinberger
9a60d431c5 docs: credit #1034 in changelog 2026-01-17 04:15:46 +00:00
Peter Steinberger
6e4d86f426 refactor: require target for message actions 2026-01-17 04:15:46 +00:00
Peter Steinberger
87cecd0268 refactor: align channel chatType 2026-01-17 04:13:06 +00:00
Peter Steinberger
388b2bce01 refactor: add inbound context helpers 2026-01-17 04:05:34 +00:00
Peter Steinberger
a2b5b1f0cb refactor: normalize inbound context 2026-01-17 04:05:33 +00:00
Peter Steinberger
9f4b7a1683 fix: normalize subagent announce delivery origin
Co-authored-by: Adam Holt <mail@adamholt.co.nz>
2026-01-17 04:03:28 +00:00
Peter Steinberger
dd68faef23 refactor: split message tool schema + action handling 2026-01-17 03:58:55 +00:00
Peter Steinberger
97cfa0846c chore: remove legacy transcription helpers 2026-01-17 03:54:46 +00:00
Peter Steinberger
1b973f7506 feat: add inbound media understanding
Co-authored-by: Tristan Manchester <tmanchester96@gmail.com>
2026-01-17 03:54:46 +00:00
Peter Steinberger
4b749f1b8f refactor: share telegram caption splitting 2026-01-17 03:50:09 +00:00
Peter Steinberger
7f1f9473a0 refactor: dedupe message action helpers 2026-01-17 03:46:03 +00:00
Peter Steinberger
a32e5d040c Merge pull request #1063 from mukhtharcm/fix/telegram-caption-split
fix(telegram): split long captions into follow-up messages
2026-01-17 03:42:13 +00:00
Peter Steinberger
a82217a5f3 chore: format + regenerate protocol 2026-01-17 03:40:49 +00:00
Muhammed Mukhthar CM
02184dd055 fix(telegram): split long captions into follow-up messages 2026-01-17 02:16:01 +00:00
948 changed files with 50850 additions and 9613 deletions

2
.npmrc
View File

@@ -1 +1 @@
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty

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}`. Dont add extra quotes.
- launchd PATH is minimal; ensure the apps 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 tools escaping.
- Release guardrails: do not change version numbers without operators 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.

View File

@@ -1,55 +1,223 @@
# Changelog
## 2026.1.16 (unreleased)
Docs: https://docs.clawd.bot
### 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.
### Breaking
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
## 2026.1.18-3
### Changes
- Exec: add host/security/ask routing for gateway + node exec.
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
- macOS: add approvals socket UI server + node exec lifecycle events.
- Plugins: add typed lifecycle hooks + vector memory plugin. (#1149) — thanks @radek-paclt.
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
### Fixes
- Tools: return a companion-app-required message when node exec is requested with no paired node.
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
## 2026.1.18-2
### Fixes
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
## 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: add the bundled BlueBubbles channel plugin (disabled by default).
- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime.
- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime.
## 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 FTS + embedding cache state in `clawdbot memory status`.
- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default.
- Plugins: allow optional agent tools with explicit allowlists and add plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
- Tools: centralize plugin tool policy helpers.
- Commands: add `/subagents info` and show sub-agent counts in `/status`.
- Docs: clarify plugin agent tool configuration. https://docs.clawd.bot/plugins/agent-tools
### Fixes
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
## 2026.1.18-1
### 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.
### 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
- 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:** `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:** `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.
- Health: add per-agent session summaries and account-level health details, and allow selective probes. (#1047) — thanks @gumadeiras.
- Hooks: add hook pack installs (npm/path/zip/tar) with `clawdbot.hooks` manifests and `clawdbot hooks install/update`.
- Plugins: add zip installs and `--link` to avoid copying local paths.
### Fixes
- Sub-agents: route announce delivery through the correct channel account IDs. (#1061, #1058) — thanks @adam91holt.
- Telegram: split long media captions into follow-up text messages in bot delivery. (#1063) — thanks @mukhtharcm.
- 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.
- 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.
- 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.
- Security: bump `tar` to 7.5.3.
- 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

View File

@@ -249,7 +249,7 @@ Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands ar
- `/compact` — compact session context (summary)
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
- `/verbose on|off`
- `/cost on|off` — append per-response token/cost usage lines
- `/usage off|tokens|full` — per-response usage footer
- `/restart` — restart the gateway (owner-only in groups)
- `/activation mention|always` — group activation toggle (groups only)
@@ -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>

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>

View File

@@ -170,8 +170,15 @@ final class AppState {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
}
var systemRunPolicy: SystemRunPolicy {
didSet { self.ifNotPreview { MacNodeConfigFile.setSystemRunPolicy(self.systemRunPolicy) } }
var execApprovalMode: ExecApprovalQuickMode {
didSet {
self.ifNotPreview {
ExecApprovalsStore.updateDefaults { defaults in
defaults.security = self.execApprovalMode.security
defaults.ask = self.execApprovalMode.ask
}
}
}
}
/// Tracks whether the Canvas panel is currently visible (not persisted).
@@ -274,7 +281,8 @@ final class AppState {
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
self.systemRunPolicy = SystemRunPolicy.load()
let execDefaults = ExecApprovalsStore.resolveDefaults()
self.execApprovalMode = ExecApprovalQuickMode.from(security: execDefaults.security, ask: execDefaults.ask)
self.peekabooBridgeEnabled = UserDefaults.standard
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
if !self.isPreview {

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"
}
}

View File

@@ -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))

View File

@@ -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) ?? ""

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -0,0 +1,566 @@
import Foundation
import OSLog
import Security
enum ExecSecurity: String, CaseIterable, Codable, Identifiable {
case deny
case allowlist
case full
var id: String { self.rawValue }
var title: String {
switch self {
case .deny: "Deny"
case .allowlist: "Allowlist"
case .full: "Always Allow"
}
}
}
enum ExecApprovalQuickMode: String, CaseIterable, Identifiable {
case deny
case ask
case allow
var id: String { self.rawValue }
var title: String {
switch self {
case .deny: "Deny"
case .ask: "Always Ask"
case .allow: "Always Allow"
}
}
var security: ExecSecurity {
switch self {
case .deny: .deny
case .ask: .allowlist
case .allow: .full
}
}
var ask: ExecAsk {
switch self {
case .deny: .off
case .ask: .onMiss
case .allow: .off
}
}
static func from(security: ExecSecurity, ask: ExecAsk) -> ExecApprovalQuickMode {
switch security {
case .deny:
return .deny
case .full:
return .allow
case .allowlist:
return .ask
}
}
}
enum ExecAsk: String, CaseIterable, Codable, Identifiable {
case off
case onMiss = "on-miss"
case always
var id: String { self.rawValue }
var title: String {
switch self {
case .off: "Never Ask"
case .onMiss: "Ask on Allowlist Miss"
case .always: "Always Ask"
}
}
}
enum ExecApprovalDecision: String, Codable, Sendable {
case allowOnce = "allow-once"
case allowAlways = "allow-always"
case deny
}
struct ExecAllowlistEntry: Codable, Hashable {
var pattern: String
var lastUsedAt: Double? = nil
var lastUsedCommand: String? = nil
var lastResolvedPath: String? = nil
}
struct ExecApprovalsDefaults: Codable {
var security: ExecSecurity?
var ask: ExecAsk?
var askFallback: ExecSecurity?
var autoAllowSkills: Bool?
}
struct ExecApprovalsAgent: Codable {
var security: ExecSecurity?
var ask: ExecAsk?
var askFallback: ExecSecurity?
var autoAllowSkills: Bool?
var allowlist: [ExecAllowlistEntry]?
var isEmpty: Bool {
security == nil && ask == nil && askFallback == nil && autoAllowSkills == nil && (allowlist?.isEmpty ?? true)
}
}
struct ExecApprovalsSocketConfig: Codable {
var path: String?
var token: String?
}
struct ExecApprovalsFile: Codable {
var version: Int
var socket: ExecApprovalsSocketConfig?
var defaults: ExecApprovalsDefaults?
var agents: [String: ExecApprovalsAgent]?
}
struct ExecApprovalsResolved {
let url: URL
let socketPath: String
let token: String
let defaults: ExecApprovalsResolvedDefaults
let agent: ExecApprovalsResolvedDefaults
let allowlist: [ExecAllowlistEntry]
var file: ExecApprovalsFile
}
struct ExecApprovalsResolvedDefaults {
var security: ExecSecurity
var ask: ExecAsk
var askFallback: ExecSecurity
var autoAllowSkills: Bool
}
enum ExecApprovalsStore {
private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals")
private static let defaultSecurity: ExecSecurity = .deny
private static let defaultAsk: ExecAsk = .onMiss
private static let defaultAskFallback: ExecSecurity = .deny
private static let defaultAutoAllowSkills = false
static func fileURL() -> URL {
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.json")
}
static func socketPath() -> String {
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
}
static func loadFile() -> ExecApprovalsFile {
let url = self.fileURL()
guard FileManager.default.fileExists(atPath: url.path) else {
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
}
do {
let data = try Data(contentsOf: url)
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
if decoded.version != 1 {
return ExecApprovalsFile(version: 1, socket: decoded.socket, defaults: decoded.defaults, agents: decoded.agents)
}
return decoded
} catch {
self.logger.warning("exec approvals load failed: \(error.localizedDescription, privacy: .public)")
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
}
}
static func saveFile(_ file: ExecApprovalsFile) {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(file)
let url = self.fileURL()
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
} catch {
self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)")
}
}
static func ensureFile() -> ExecApprovalsFile {
var file = self.loadFile()
if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) }
let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if path.isEmpty {
file.socket?.path = self.socketPath()
}
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if token.isEmpty {
file.socket?.token = self.generateToken()
}
if file.agents == nil { file.agents = [:] }
self.saveFile(file)
return file
}
static func resolve(agentId: String?) -> ExecApprovalsResolved {
var file = self.ensureFile()
let defaults = file.defaults ?? ExecApprovalsDefaults()
let resolvedDefaults = ExecApprovalsResolvedDefaults(
security: defaults.security ?? self.defaultSecurity,
ask: defaults.ask ?? self.defaultAsk,
askFallback: defaults.askFallback ?? self.defaultAskFallback,
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
: "default"
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
let resolvedAgent = ExecApprovalsResolvedDefaults(
security: agentEntry.security ?? resolvedDefaults.security,
ask: agentEntry.ask ?? resolvedDefaults.ask,
askFallback: agentEntry.askFallback ?? resolvedDefaults.askFallback,
autoAllowSkills: agentEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills)
let allowlist = (agentEntry.allowlist ?? [])
.map { entry in
ExecAllowlistEntry(
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: entry.lastUsedAt,
lastUsedCommand: entry.lastUsedCommand,
lastResolvedPath: entry.lastResolvedPath)
}
.filter { !$0.pattern.isEmpty }
let socketPath = self.expandPath(file.socket?.path ?? self.socketPath())
let token = file.socket?.token ?? ""
return ExecApprovalsResolved(
url: self.fileURL(),
socketPath: socketPath,
token: token,
defaults: resolvedDefaults,
agent: resolvedAgent,
allowlist: allowlist,
file: file)
}
static func resolveDefaults() -> ExecApprovalsResolvedDefaults {
let file = self.ensureFile()
let defaults = file.defaults ?? ExecApprovalsDefaults()
return ExecApprovalsResolvedDefaults(
security: defaults.security ?? self.defaultSecurity,
ask: defaults.ask ?? self.defaultAsk,
askFallback: defaults.askFallback ?? self.defaultAskFallback,
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
}
static func saveDefaults(_ defaults: ExecApprovalsDefaults) {
self.updateFile { file in
file.defaults = defaults
}
}
static func updateDefaults(_ mutate: (inout ExecApprovalsDefaults) -> Void) {
self.updateFile { file in
var defaults = file.defaults ?? ExecApprovalsDefaults()
mutate(&defaults)
file.defaults = defaults
}
}
static func saveAgent(_ agent: ExecApprovalsAgent, agentId: String?) {
self.updateFile { file in
var agents = file.agents ?? [:]
let key = self.agentKey(agentId)
if agent.isEmpty {
agents.removeValue(forKey: key)
} else {
agents[key] = agent
}
file.agents = agents.isEmpty ? nil : agents
}
}
static func addAllowlistEntry(agentId: String?, pattern: String) {
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.updateFile { file in
let key = self.agentKey(agentId)
var agents = file.agents ?? [:]
var entry = agents[key] ?? ExecApprovalsAgent()
var allowlist = entry.allowlist ?? []
if allowlist.contains(where: { $0.pattern == trimmed }) { return }
allowlist.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: Date().timeIntervalSince1970 * 1000))
entry.allowlist = allowlist
agents[key] = entry
file.agents = agents
}
}
static func recordAllowlistUse(
agentId: String?,
pattern: String,
command: String,
resolvedPath: String?)
{
self.updateFile { file in
let key = self.agentKey(agentId)
var agents = file.agents ?? [:]
var entry = agents[key] ?? ExecApprovalsAgent()
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
guard item.pattern == pattern else { return item }
return ExecAllowlistEntry(
pattern: item.pattern,
lastUsedAt: Date().timeIntervalSince1970 * 1000,
lastUsedCommand: command,
lastResolvedPath: resolvedPath)
}
entry.allowlist = allowlist
agents[key] = entry
file.agents = agents
}
}
static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) {
self.updateFile { file in
let key = self.agentKey(agentId)
var agents = file.agents ?? [:]
var entry = agents[key] ?? ExecApprovalsAgent()
let cleaned = allowlist
.map { item in
ExecAllowlistEntry(
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: item.lastUsedAt,
lastUsedCommand: item.lastUsedCommand,
lastResolvedPath: item.lastResolvedPath)
}
.filter { !$0.pattern.isEmpty }
entry.allowlist = cleaned
agents[key] = entry
file.agents = agents
}
}
static func updateAgentSettings(agentId: String?, mutate: (inout ExecApprovalsAgent) -> Void) {
self.updateFile { file in
let key = self.agentKey(agentId)
var agents = file.agents ?? [:]
var entry = agents[key] ?? ExecApprovalsAgent()
mutate(&entry)
if entry.isEmpty {
agents.removeValue(forKey: key)
} else {
agents[key] = entry
}
file.agents = agents.isEmpty ? nil : agents
}
}
private static func updateFile(_ mutate: (inout ExecApprovalsFile) -> Void) {
var file = self.ensureFile()
mutate(&file)
self.saveFile(file)
}
private static func generateToken() -> String {
var bytes = [UInt8](repeating: 0, count: 24)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
if status == errSecSuccess {
return Data(bytes)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
return UUID().uuidString
}
private static func expandPath(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "~" {
return FileManager.default.homeDirectoryForCurrentUser.path
}
if trimmed.hasPrefix("~/") {
let suffix = trimmed.dropFirst(2)
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(String(suffix)).path
}
return trimmed
}
private static func agentKey(_ agentId: String?) -> String {
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "default" : trimmed
}
}
struct ExecCommandResolution: Sendable {
let rawExecutable: String
let resolvedPath: String?
let executableName: String
let cwd: String?
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
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("/") || 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
}
let searchPaths = self.searchPaths(from: env)
return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths)
}()
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
return ExecCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
}
private static func searchPaths(from env: [String: String]?) -> [String] {
let raw = env?["PATH"]
if let raw, !raw.isEmpty {
return raw.split(separator: ":").map(String.init)
}
return CommandResolver.preferredPaths()
}
}
enum ExecCommandFormatter {
static func displayString(for argv: [String]) -> String {
argv.map { arg in
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "\"\"" }
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
if !needsQuotes { return trimmed }
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}.joined(separator: " ")
}
}
enum ExecAllowlistMatcher {
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
guard let resolution, !entries.isEmpty else { return nil }
let rawExecutable = resolution.rawExecutable
let resolvedPath = resolution.resolvedPath
let executableName = resolution.executableName
for entry in entries {
let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
if pattern.isEmpty { continue }
let hasPath = pattern.contains("/") || pattern.contains("~") || pattern.contains("\\")
if hasPath {
let target = resolvedPath ?? rawExecutable
if self.matches(pattern: pattern, target: target) { return entry }
} else if self.matches(pattern: pattern, target: executableName) {
return entry
}
}
return nil
}
private static func matches(pattern: String, target: String) -> Bool {
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed
let normalizedPattern = self.normalizeMatchTarget(expanded)
let normalizedTarget = self.normalizeMatchTarget(target)
guard let regex = self.regex(for: normalizedPattern) else { return false }
let range = NSRange(location: 0, length: normalizedTarget.utf16.count)
return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil
}
private static func normalizeMatchTarget(_ value: String) -> String {
value.replacingOccurrences(of: "\\\\", with: "/").lowercased()
}
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, options: [.caseInsensitive])
}
}
struct ExecEventPayload: Codable, Sendable {
var sessionKey: String
var runId: String
var host: String
var command: String?
var exitCode: Int?
var timedOut: Bool?
var success: Bool?
var output: String?
var reason: String?
static func truncateOutput(_ raw: String, maxChars: Int = 20_000) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.count <= maxChars { return trimmed }
let suffix = trimmed.suffix(maxChars)
return "… (truncated) \(suffix)"
}
}
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
}
}

View File

@@ -0,0 +1,359 @@
import AppKit
import ClawdbotKit
import Darwin
import Foundation
import OSLog
struct ExecApprovalPromptRequest: Codable, Sendable {
var command: String
var cwd: String?
var host: String?
var security: String?
var ask: String?
var agentId: String?
var resolvedPath: String?
}
private struct ExecApprovalSocketRequest: Codable {
var type: String
var token: String
var id: String
var request: ExecApprovalPromptRequest
}
private struct ExecApprovalSocketDecision: Codable {
var type: String
var id: String
var decision: ExecApprovalDecision
}
enum ExecApprovalsSocketClient {
private struct TimeoutError: LocalizedError {
var message: String
var errorDescription: String? { message }
}
static func requestDecision(
socketPath: String,
token: String,
request: ExecApprovalPromptRequest,
timeoutMs: Int = 15_000) async -> ExecApprovalDecision?
{
let trimmedPath = socketPath.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedPath.isEmpty, !trimmedToken.isEmpty else { return nil }
do {
return try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: {
TimeoutError(message: "exec approvals socket timeout")
}, operation: {
try await Task.detached {
try self.requestDecisionSync(
socketPath: trimmedPath,
token: trimmedToken,
request: request)
}.value
})
} catch {
return nil
}
}
private static func requestDecisionSync(
socketPath: String,
token: String,
request: ExecApprovalPromptRequest) throws -> ExecApprovalDecision?
{
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else {
throw NSError(domain: "ExecApprovals", code: 1, userInfo: [
NSLocalizedDescriptionKey: "socket create failed",
])
}
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
if socketPath.utf8.count >= maxLen {
throw NSError(domain: "ExecApprovals", code: 2, userInfo: [
NSLocalizedDescriptionKey: "socket path too long",
])
}
socketPath.withCString { cstr in
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self)
strncpy(raw, cstr, maxLen - 1)
}
}
let size = socklen_t(MemoryLayout.size(ofValue: addr))
let result = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in
connect(fd, rebound, size)
}
}
if result != 0 {
throw NSError(domain: "ExecApprovals", code: 3, userInfo: [
NSLocalizedDescriptionKey: "socket connect failed",
])
}
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
let message = ExecApprovalSocketRequest(
type: "request",
token: token,
id: UUID().uuidString,
request: request)
let data = try JSONEncoder().encode(message)
var payload = data
payload.append(0x0A)
try handle.write(contentsOf: payload)
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
let lineData = line.data(using: .utf8)
else { return nil }
let response = try JSONDecoder().decode(ExecApprovalSocketDecision.self, from: lineData)
return response.decision
}
private static func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? {
var buffer = Data()
while buffer.count < maxBytes {
let chunk = try handle.read(upToCount: 4096) ?? Data()
if chunk.isEmpty { break }
buffer.append(chunk)
if buffer.contains(0x0A) { break }
}
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
guard !buffer.isEmpty else { return nil }
return String(data: buffer, encoding: .utf8)
}
let lineData = buffer.subdata(in: 0..<newlineIndex)
return String(data: lineData, encoding: .utf8)
}
}
@MainActor
final class ExecApprovalsPromptServer {
static let shared = ExecApprovalsPromptServer()
private var server: ExecApprovalsSocketServer?
func start() {
guard self.server == nil else { return }
let approvals = ExecApprovalsStore.resolve(agentId: nil)
let server = ExecApprovalsSocketServer(
socketPath: approvals.socketPath,
token: approvals.token,
onPrompt: { request in
await ExecApprovalsPromptPresenter.prompt(request)
})
server.start()
self.server = server
}
func stop() {
self.server?.stop()
self.server = nil
}
}
private enum ExecApprovalsPromptPresenter {
@MainActor
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Allow this command?"
var details = "Clawdbot wants to run:\n\n\(request.command)"
let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedCwd.isEmpty {
details += "\n\nWorking directory:\n\(trimmedCwd)"
}
let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedAgent.isEmpty {
details += "\n\nAgent:\n\(trimmedAgent)"
}
let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedPath.isEmpty {
details += "\n\nExecutable:\n\(trimmedPath)"
}
let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedHost.isEmpty {
details += "\n\nHost:\n\(trimmedHost)"
}
if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty {
details += "\n\nSecurity:\n\(security)"
}
if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty {
details += "\nAsk mode:\n\(ask)"
}
details += "\n\nThis runs on this machine."
alert.informativeText = details
alert.addButton(withTitle: "Allow Once")
alert.addButton(withTitle: "Always Allow")
alert.addButton(withTitle: "Don't Allow")
switch alert.runModal() {
case .alertFirstButtonReturn:
return .allowOnce
case .alertSecondButtonReturn:
return .allowAlways
default:
return .deny
}
}
}
private final class ExecApprovalsSocketServer {
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
private let socketPath: String
private let token: String
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
private var socketFD: Int32 = -1
private var acceptTask: Task<Void, Never>?
private var isRunning = false
init(
socketPath: String,
token: String,
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision)
{
self.socketPath = socketPath
self.token = token
self.onPrompt = onPrompt
}
func start() {
guard !self.isRunning else { return }
self.isRunning = true
self.acceptTask = Task.detached { [weak self] in
await self?.runAcceptLoop()
}
}
func stop() {
self.isRunning = false
self.acceptTask?.cancel()
self.acceptTask = nil
if self.socketFD >= 0 {
close(self.socketFD)
self.socketFD = -1
}
if !self.socketPath.isEmpty {
unlink(self.socketPath)
}
}
private func runAcceptLoop() async {
let fd = self.openSocket()
guard fd >= 0 else {
self.isRunning = false
return
}
self.socketFD = fd
while self.isRunning {
var addr = sockaddr_un()
var len = socklen_t(MemoryLayout.size(ofValue: addr))
let client = withUnsafeMutablePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in
accept(fd, rebound, &len)
}
}
if client < 0 {
if errno == EINTR { continue }
break
}
Task.detached { [weak self] in
await self?.handleClient(fd: client)
}
}
}
private func openSocket() -> Int32 {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else {
self.logger.error("exec approvals socket create failed")
return -1
}
unlink(self.socketPath)
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
if self.socketPath.utf8.count >= maxLen {
self.logger.error("exec approvals socket path too long")
close(fd)
return -1
}
self.socketPath.withCString { cstr in
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self)
memset(raw, 0, maxLen)
strncpy(raw, cstr, maxLen - 1)
}
}
let size = socklen_t(MemoryLayout.size(ofValue: addr))
let result = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in
bind(fd, rebound, size)
}
}
if result != 0 {
self.logger.error("exec approvals socket bind failed")
close(fd)
return -1
}
if listen(fd, 16) != 0 {
self.logger.error("exec approvals socket listen failed")
close(fd)
return -1
}
chmod(self.socketPath, 0o600)
self.logger.info("exec approvals socket listening at \(self.socketPath, privacy: .public)")
return fd
}
private func handleClient(fd: Int32) async {
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
do {
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
let data = line.data(using: .utf8)
else {
return
}
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
guard request.type == "request", request.token == self.token else {
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: .deny)
let data = try JSONEncoder().encode(response)
var payload = data
payload.append(0x0A)
try handle.write(contentsOf: payload)
return
}
let decision = await self.onPrompt(request.request)
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: decision)
let responseData = try JSONEncoder().encode(response)
var payload = responseData
payload.append(0x0A)
try handle.write(contentsOf: payload)
} catch {
self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)")
}
}
private func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? {
var buffer = Data()
while buffer.count < maxBytes {
let chunk = try handle.read(upToCount: 4096) ?? Data()
if chunk.isEmpty { break }
buffer.append(chunk)
if buffer.contains(0x0A) { break }
}
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
guard !buffer.isEmpty else { return nil }
return String(data: buffer, encoding: .utf8)
}
let lineData = buffer.subdata(in: 0..<newlineIndex)
return String(data: lineData, encoding: .utf8)
}
}

View File

@@ -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)

View File

@@ -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")

View File

@@ -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

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 {

View File

@@ -1,81 +0,0 @@
import Foundation
import OSLog
enum MacNodeConfigFile {
private static let logger = Logger(subsystem: "com.clawdbot", category: "mac-node-config")
static func url() -> URL {
ClawdbotPaths.stateDirURL.appendingPathComponent("macos-node.json")
}
static func loadDict() -> [String: Any] {
let url = self.url()
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
do {
let data = try Data(contentsOf: url)
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
self.logger.warning("mac node config JSON root invalid")
return [:]
}
return root
} catch {
self.logger.warning("mac node config read failed: \(error.localizedDescription, privacy: .public)")
return [:]
}
}
static func saveDict(_ dict: [String: Any]) {
do {
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
let url = self.url()
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
} catch {
self.logger.error("mac node config save failed: \(error.localizedDescription, privacy: .public)")
}
}
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
}
static func setSystemRunPolicy(_ policy: SystemRunPolicy) {
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
}
if systemRun.isEmpty {
root.removeValue(forKey: "systemRun")
} else {
root["systemRun"] = systemRun
}
self.saveDict(root)
}
}

View File

@@ -256,6 +256,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
TerminationSignalWatcher.shared.start()
NodePairingApprovalPrompter.shared.start()
ExecApprovalsPromptServer.shared.start()
MacNodeModeCoordinator.shared.start()
VoiceWakeGlobalSettingsSync.shared.start()
Task { PresenceReporter.shared.start() }
@@ -280,6 +281,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillTerminate(_ notification: Notification) {
PresenceReporter.shared.stop()
NodePairingApprovalPrompter.shared.stop()
ExecApprovalsPromptServer.shared.stop()
MacNodeModeCoordinator.shared.stop()
TerminationSignalWatcher.shared.stop()
VoiceWakeGlobalSettingsSync.shared.stop()

View File

@@ -31,10 +31,10 @@ struct MenuContent: View {
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
}
private var systemRunPolicyBinding: Binding<SystemRunPolicy> {
private var execApprovalModeBinding: Binding<ExecApprovalQuickMode> {
Binding(
get: { self.state.systemRunPolicy },
set: { self.state.systemRunPolicy = $0 })
get: { self.state.execApprovalMode },
set: { self.state.execApprovalMode = $0 })
}
var body: some View {
@@ -74,12 +74,12 @@ struct MenuContent: View {
Toggle(isOn: self.$cameraEnabled) {
Label("Allow Camera", systemImage: "camera")
}
Picker(selection: self.systemRunPolicyBinding) {
ForEach(SystemRunPolicy.allCases) { policy in
Text(policy.title).tag(policy)
Picker(selection: self.execApprovalModeBinding) {
ForEach(ExecApprovalQuickMode.allCases) { mode in
Text(mode.title).tag(mode)
}
} label: {
Label("Node Run Commands", systemImage: "terminal")
Label("Exec Approvals", systemImage: "terminal")
}
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")

View File

@@ -43,7 +43,6 @@ final class MacNodeModeCoordinator {
private func run() async {
var retryDelay: UInt64 = 1_000_000_000
var lastCameraEnabled: Bool?
var lastSystemRunPolicy: SystemRunPolicy?
let defaults = UserDefaults.standard
while !Task.isCancelled {
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
@@ -60,15 +59,6 @@ final class MacNodeModeCoordinator {
try? await Task.sleep(nanoseconds: 200_000_000)
}
let systemRunPolicy = SystemRunPolicy.load()
if lastSystemRunPolicy == nil {
lastSystemRunPolicy = systemRunPolicy
} else if lastSystemRunPolicy != systemRunPolicy {
lastSystemRunPolicy = systemRunPolicy
await self.session.disconnect()
try? await Task.sleep(nanoseconds: 200_000_000)
}
guard let target = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
retryDelay = min(retryDelay * 2, 10_000_000_000)
@@ -89,8 +79,13 @@ final class MacNodeModeCoordinator {
if let mainSessionKey {
await self?.runtime.updateMainSessionKey(mainSessionKey)
}
await self?.runtime.setEventSender { [weak self] event, payload in
guard let self else { return }
try? await self.session.sendEvent(event: event, payloadJSON: payload)
}
},
onDisconnected: { reason in
onDisconnected: { [weak self] reason in
await self?.runtime.setEventSender(nil)
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
},
onInvoke: { [weak self] req in
@@ -161,13 +156,10 @@ final class MacNodeModeCoordinator {
ClawdbotCanvasA2UICommand.reset.rawValue,
MacNodeScreenCommand.record.rawValue,
ClawdbotSystemCommand.notify.rawValue,
ClawdbotSystemCommand.which.rawValue,
ClawdbotSystemCommand.run.rawValue,
]
if SystemRunPolicy.load() != .never {
commands.append(ClawdbotSystemCommand.which.rawValue)
commands.append(ClawdbotSystemCommand.run.rawValue)
}
let capsSet = Set(caps)
if capsSet.contains(ClawdbotCapability.camera.rawValue) {
commands.append(ClawdbotCameraCommand.list.rawValue)

View File

@@ -8,6 +8,7 @@ actor MacNodeRuntime {
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
private var mainSessionKey: String = "main"
private var eventSender: (@Sendable (String, String?) async -> Void)?
init(
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
@@ -23,6 +24,10 @@ actor MacNodeRuntime {
self.mainSessionKey = trimmed
}
func setEventSender(_ sender: (@Sendable (String, String?) async -> Void)?) {
self.eventSender = sender
}
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
let command = req.command
if self.isCanvasCommand(command), !Self.canvasEnabled() {
@@ -428,41 +433,129 @@ actor MacNodeRuntime {
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
}
let wasAllowlisted = SystemRunAllowlist.contains(command)
switch Self.systemRunPolicy() {
case .never:
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
let approvals = ExecApprovalsStore.resolve(agentId: agentId)
let security = approvals.agent.security
let ask = approvals.agent.ask
let askFallback = approvals.agent.askFallback
let autoAllowSkills = approvals.agent.autoAllowSkills
let sessionKey = (params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
: self.mainSessionKey
let runId = UUID().uuidString
let resolution = ExecCommandResolution.resolve(command: command, cwd: params.cwd, env: params.env)
let allowlistMatch = security == .allowlist
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
: nil
let skillAllow: Bool
if autoAllowSkills, let name = resolution?.executableName {
let bins = await SkillBinsCache.shared.currentBins()
skillAllow = bins.contains(name)
} else {
skillAllow = false
}
if security == .deny {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: ExecCommandFormatter.displayString(for: command),
reason: "security=deny"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DISABLED: policy=never")
case .always:
break
case .ask:
if !wasAllowlisted {
let services = await self.mainActorServices()
let decision = await services.confirmSystemRun(
command: SystemRunAllowlist.displayString(for: command),
cwd: params.cwd)
switch decision {
case .allowOnce:
break
case .allowAlways:
SystemRunAllowlist.add(command)
case .deny:
message: "SYSTEM_RUN_DISABLED: security=deny")
}
let requiresAsk: Bool = {
if ask == .always { return true }
if ask == .onMiss && security == .allowlist && allowlistMatch == nil && !skillAllow { return true }
return false
}()
if requiresAsk {
let decision = await ExecApprovalsSocketClient.requestDecision(
socketPath: approvals.socketPath,
token: approvals.token,
request: ExecApprovalPromptRequest(
command: ExecCommandFormatter.displayString(for: command),
cwd: params.cwd,
host: "node",
security: security.rawValue,
ask: ask.rawValue,
agentId: agentId,
resolvedPath: resolution?.resolvedPath))
switch decision {
case .deny?:
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: ExecCommandFormatter.displayString(for: command),
reason: "user-denied"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: user denied")
case nil:
if askFallback == .deny || (askFallback == .allowlist && allowlistMatch == nil && !skillAllow) {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: ExecCommandFormatter.displayString(for: command),
reason: "approval-required"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: user denied")
message: "SYSTEM_RUN_DENIED: approval required")
}
case .allowAlways?:
if security == .allowlist {
let pattern = resolution?.resolvedPath ??
resolution?.rawExecutable ??
command.first?.trimmingCharacters(in: .whitespacesAndNewlines) ??
""
if !pattern.isEmpty {
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
}
}
case .allowOnce?:
break
}
}
if let match = allowlistMatch {
ExecApprovalsStore.recordAllowlistUse(
agentId: agentId,
pattern: match.pattern,
command: ExecCommandFormatter.displayString(for: command),
resolvedPath: resolution?.resolvedPath)
}
let env = Self.sanitizedEnv(params.env)
if params.needsScreenRecording == true {
let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false
if !authorized {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: ExecCommandFormatter.displayString(for: command),
reason: "permission:screenRecording"))
return Self.errorResponse(
req,
code: .unavailable,
@@ -471,11 +564,30 @@ actor MacNodeRuntime {
}
let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 }
await self.emitExecEvent(
"exec.started",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: ExecCommandFormatter.displayString(for: command)))
let result = await ShellExecutor.runDetailed(
command: command,
cwd: params.cwd,
env: env,
timeout: timeoutSec)
let combined = [result.stdout, result.stderr, result.errorMessage].filter { !$0.isEmpty }.joined(separator: "\n")
await self.emitExecEvent(
"exec.finished",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: ExecCommandFormatter.displayString(for: command),
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
output: ExecEventPayload.truncateOutput(combined)))
struct RunPayload: Encodable {
var exitCode: Int?
@@ -523,6 +635,16 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func emitExecEvent(_ event: String, payload: ExecEventPayload) async {
guard let sender = self.eventSender else { return }
guard let data = try? JSONEncoder().encode(payload),
let json = String(data: data, encoding: .utf8)
else {
return
}
await sender(event, json)
}
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -589,10 +711,6 @@ actor MacNodeRuntime {
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
}
private nonisolated static func systemRunPolicy() -> SystemRunPolicy {
SystemRunPolicy.load()
}
private static let blockedEnvKeys: Set<String> = [
"PATH",
"NODE_OPTIONS",

View File

@@ -1,14 +1,7 @@
import AppKit
import ClawdbotKit
import CoreLocation
import Foundation
enum SystemRunDecision: Sendable {
case allowOnce
case allowAlways
case deny
}
@MainActor
protocol MacNodeRuntimeMainActorServices: Sendable {
func recordScreen(
@@ -24,8 +17,6 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
desiredAccuracy: ClawdbotLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision
}
@MainActor
@@ -67,30 +58,4 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices
timeoutMs: timeoutMs)
}
func confirmSystemRun(command: String, cwd: String?) 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) ?? ""
if !trimmedCwd.isEmpty {
details += "\n\nWorking directory:\n\(trimmedCwd)"
}
details += "\n\nThis runs on this Mac via node mode."
alert.informativeText = details
alert.addButton(withTitle: "Allow Once")
alert.addButton(withTitle: "Always Allow")
alert.addButton(withTitle: "Don't Allow")
switch alert.runModal() {
case .alertFirstButtonReturn:
return .allowOnce
case .alertSecondButtonReturn:
return .allowAlways
default:
return .deny
}
}
}

View File

@@ -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
}

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)"
}

View File

@@ -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] {

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -1,89 +0,0 @@
import Foundation
enum SystemRunPolicy: String, CaseIterable, Identifiable {
case never
case ask
case always
var id: String { self.rawValue }
var title: String {
switch self {
case .never:
"Never"
case .ask:
"Always Ask"
case .always:
"Always Allow"
}
}
static func load(from defaults: UserDefaults = .standard) -> SystemRunPolicy {
if let policy = MacNodeConfigFile.systemRunPolicy() {
return policy
}
if let raw = defaults.string(forKey: systemRunPolicyKey),
let policy = SystemRunPolicy(rawValue: raw)
{
MacNodeConfigFile.setSystemRunPolicy(policy)
return policy
}
if let legacy = defaults.object(forKey: systemRunEnabledKey) as? Bool {
let policy: SystemRunPolicy = legacy ? .ask : .never
MacNodeConfigFile.setSystemRunPolicy(policy)
return policy
}
let fallback: SystemRunPolicy = .ask
MacNodeConfigFile.setSystemRunPolicy(fallback)
return fallback
}
}
enum SystemRunAllowlist {
static func key(for argv: [String]) -> String {
let trimmed = argv.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard !trimmed.isEmpty else { return "" }
if let data = try? JSONEncoder().encode(trimmed),
let json = String(data: data, encoding: .utf8)
{
return json
}
return trimmed.joined(separator: " ")
}
static func displayString(for argv: [String]) -> String {
argv.map { arg in
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "\"\"" }
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
if !needsQuotes { return trimmed }
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}.joined(separator: " ")
}
static func load(from defaults: UserDefaults = .standard) -> Set<String> {
if let allowlist = MacNodeConfigFile.systemRunAllowlist() {
return Set(allowlist)
}
if let legacy = defaults.stringArray(forKey: systemRunAllowlistKey), !legacy.isEmpty {
MacNodeConfigFile.setSystemRunAllowlist(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())
}
}
}

View File

@@ -0,0 +1,330 @@
import Foundation
import Observation
import SwiftUI
struct SystemRunSettingsView: View {
@State private var model = ExecApprovalsSettingsModel()
@State private var tab: ExecApprovalsSettingsTab = .policy
@State private var newPattern: String = ""
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .center, spacing: 12) {
Text("Exec approvals")
.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(ExecApprovalsSettingsTab.allCases) { tab in
Text(tab.title).tag(tab)
}
}
.pickerStyle(.segmented)
.frame(width: 320)
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: 8) {
Picker("", selection: Binding(
get: { self.model.security },
set: { self.model.setSecurity($0) }))
{
ForEach(ExecSecurity.allCases) { security in
Text(security.title).tag(security)
}
}
.labelsHidden()
.pickerStyle(.menu)
Picker("", selection: Binding(
get: { self.model.ask },
set: { self.model.setAsk($0) }))
{
ForEach(ExecAsk.allCases) { ask in
Text(ask.title).tag(ask)
}
}
.labelsHidden()
.pickerStyle(.menu)
Picker("", selection: Binding(
get: { self.model.askFallback },
set: { self.model.setAskFallback($0) }))
{
ForEach(ExecSecurity.allCases) { mode in
Text("Fallback: \(mode.title)").tag(mode)
}
}
.labelsHidden()
.pickerStyle(.menu)
Text("Security controls whether system.run can execute on this Mac when paired as a node. Ask controls prompt behavior; fallback is used when no companion UI is reachable.")
.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 (case-insensitive 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: \.offset) { index, _ in
ExecAllowlistRow(
entry: Binding(
get: { self.model.entries[index] },
set: { self.model.updateEntry($0, at: index) }),
onRemove: { self.model.removeEntry(at: index) })
}
}
}
}
}
}
private enum ExecApprovalsSettingsTab: String, CaseIterable, Identifiable {
case policy
case allowlist
var id: String { self.rawValue }
var title: String {
switch self {
case .policy: "Access"
case .allowlist: "Allowlist"
}
}
}
struct ExecAllowlistRow: View {
@Binding var entry: ExecAllowlistEntry
let onRemove: () -> 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) {
TextField("Pattern", text: self.patternBinding)
.textFieldStyle(.roundedBorder)
Button(role: .destructive) {
self.onRemove()
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
}
if let lastUsedAt = self.entry.lastUsedAt {
let date = Date(timeIntervalSince1970: lastUsedAt / 1000.0)
Text("Last used \(Self.relativeFormatter.localizedString(for: date, 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
})
}
}
@MainActor
@Observable
final class ExecApprovalsSettingsModel {
var agentIds: [String] = []
var selectedAgentId: String = "main"
var defaultAgentId: String = "main"
var security: ExecSecurity = .deny
var ask: ExecAsk = .onMiss
var askFallback: ExecSecurity = .deny
var autoAllowSkills = false
var entries: [ExecAllowlistEntry] = []
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) {
let resolved = ExecApprovalsStore.resolve(agentId: agentId)
self.security = resolved.agent.security
self.ask = resolved.agent.ask
self.askFallback = resolved.agent.askFallback
self.autoAllowSkills = resolved.agent.autoAllowSkills
self.entries = resolved.allowlist
.sorted { $0.pattern.localizedCaseInsensitiveCompare($1.pattern) == .orderedAscending }
}
func setSecurity(_ security: ExecSecurity) {
self.security = security
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.security = security
}
self.syncQuickMode()
}
func setAsk(_ ask: ExecAsk) {
self.ask = ask
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.ask = ask
}
self.syncQuickMode()
}
func setAskFallback(_ mode: ExecSecurity) {
self.askFallback = mode
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.askFallback = mode
}
}
func setAutoAllowSkills(_ enabled: Bool) {
self.autoAllowSkills = enabled
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.autoAllowSkills = enabled
}
Task { await self.refreshSkillBins(force: enabled) }
}
func addEntry(_ pattern: String) {
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil))
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
guard self.entries.indices.contains(index) else { return }
self.entries[index] = entry
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func removeEntry(at index: Int) {
guard self.entries.indices.contains(index) else { return }
self.entries.remove(at: index)
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
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()
}
private func syncQuickMode() {
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
}
}
}

View File

@@ -357,7 +357,7 @@ public struct SendParams: Codable, Sendable {
gifplayback: Bool?,
channel: String?,
accountid: String?,
sessionkey: String? = nil,
sessionkey: String?,
idempotencykey: String
) {
self.to = to
@@ -431,6 +431,7 @@ public struct AgentParams: Codable, Sendable {
public let deliver: Bool?
public let attachments: [AnyCodable]?
public let channel: String?
public let accountid: String?
public let timeout: Int?
public let lane: String?
public let extrasystemprompt: String?
@@ -447,6 +448,7 @@ public struct AgentParams: Codable, Sendable {
deliver: Bool?,
attachments: [AnyCodable]?,
channel: String?,
accountid: String?,
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
@@ -462,6 +464,7 @@ public struct AgentParams: Codable, Sendable {
self.deliver = deliver
self.attachments = attachments
self.channel = channel
self.accountid = accountid
self.timeout = timeout
self.lane = lane
self.extrasystemprompt = extrasystemprompt
@@ -478,6 +481,7 @@ public struct AgentParams: Codable, Sendable {
case deliver
case attachments
case channel
case accountid = "accountId"
case timeout
case lane
case extrasystemprompt = "extraSystemPrompt"
@@ -756,6 +760,10 @@ public struct SessionsPatchParams: Codable, Sendable {
public let reasoninglevel: AnyCodable?
public let responseusage: AnyCodable?
public let elevatedlevel: AnyCodable?
public let exechost: AnyCodable?
public let execsecurity: AnyCodable?
public let execask: AnyCodable?
public let execnode: AnyCodable?
public let model: AnyCodable?
public let spawnedby: AnyCodable?
public let sendpolicy: AnyCodable?
@@ -769,6 +777,10 @@ public struct SessionsPatchParams: Codable, Sendable {
reasoninglevel: AnyCodable?,
responseusage: AnyCodable?,
elevatedlevel: AnyCodable?,
exechost: AnyCodable?,
execsecurity: AnyCodable?,
execask: AnyCodable?,
execnode: AnyCodable?,
model: AnyCodable?,
spawnedby: AnyCodable?,
sendpolicy: AnyCodable?,
@@ -781,6 +793,10 @@ public struct SessionsPatchParams: Codable, Sendable {
self.reasoninglevel = reasoninglevel
self.responseusage = responseusage
self.elevatedlevel = elevatedlevel
self.exechost = exechost
self.execsecurity = execsecurity
self.execask = execask
self.execnode = execnode
self.model = model
self.spawnedby = spawnedby
self.sendpolicy = sendpolicy
@@ -794,6 +810,10 @@ public struct SessionsPatchParams: Codable, Sendable {
case reasoninglevel = "reasoningLevel"
case responseusage = "responseUsage"
case elevatedlevel = "elevatedLevel"
case exechost = "execHost"
case execsecurity = "execSecurity"
case execask = "execAsk"
case execnode = "execNode"
case model
case spawnedby = "spawnedBy"
case sendpolicy = "sendPolicy"

View File

@@ -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"))

View File

@@ -0,0 +1,49 @@
import Foundation
import Testing
@testable import Clawdbot
struct ExecAllowlistTests {
@Test func matchUsesResolvedPath() {
let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg")
let resolution = ExecCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match?.pattern == entry.pattern)
}
@Test func matchUsesBasenameForSimplePattern() {
let entry = ExecAllowlistEntry(pattern: "rg")
let resolution = ExecCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match?.pattern == entry.pattern)
}
@Test func matchIsCaseInsensitive() {
let entry = ExecAllowlistEntry(pattern: "RG")
let resolution = ExecCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match?.pattern == entry.pattern)
}
@Test func matchSupportsGlobStar() {
let entry = ExecAllowlistEntry(pattern: "/opt/**/rg")
let resolution = ExecCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match?.pattern == entry.pattern)
}
}

View File

@@ -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)
}
}

View File

@@ -74,10 +74,6 @@ struct MacNodeRuntimeTests {
{
CLLocation(latitude: 0, longitude: 0)
}
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
.allowOnce
}
}
let services = await MainActor.run { FakeMainActorServices() }

View File

@@ -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")
}

View File

@@ -24,19 +24,25 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
public var env: [String: String]?
public var timeoutMs: Int?
public var needsScreenRecording: Bool?
public var agentId: String?
public var sessionKey: String?
public init(
command: [String],
cwd: String? = nil,
env: [String: String]? = nil,
timeoutMs: Int? = nil,
needsScreenRecording: Bool? = nil)
needsScreenRecording: Bool? = nil,
agentId: String? = nil,
sessionKey: String? = nil)
{
self.command = command
self.cwd = cwd
self.env = env
self.timeoutMs = timeoutMs
self.needsScreenRecording = needsScreenRecording
self.agentId = agentId
self.sessionKey = sessionKey
}
}

View File

@@ -92,13 +92,13 @@ under `hooks.transformsDir` (see [Webhooks](/automation/webhook)).
Use the Clawdbot helper to wire everything together (installs deps on macOS via brew):
```bash
clawdbot hooks gmail setup \
clawdbot webhooks gmail setup \
--account clawdbot@gmail.com
```
Defaults:
- Uses Tailscale Funnel for the public push endpoint.
- Writes `hooks.gmail` config for `clawdbot hooks gmail run`.
- Writes `hooks.gmail` config for `clawdbot webhooks gmail run`.
- Enables the Gmail hook preset (`hooks.presets: ["gmail"]`).
Path note: when `tailscale.mode` is enabled, Clawdbot automatically sets
@@ -124,7 +124,7 @@ Gateway auto-start (recommended):
Manual daemon (starts `gog gmail watch serve` + auto-renew):
```bash
clawdbot hooks gmail run
clawdbot webhooks gmail run
```
## One-time setup
@@ -191,7 +191,7 @@ Notes:
- `--hook-url` points to Clawdbot `/hooks/gmail` (mapped; isolated run + summary to main).
- `--include-body` and `--max-bytes` control the body snippet sent to Clawdbot.
Recommended: `clawdbot hooks gmail run` wraps the same flow and auto-renews the watch.
Recommended: `clawdbot webhooks gmail run` wraps the same flow and auto-renews the watch.
## Expose the handler (advanced, unsupported)

View File

@@ -16,19 +16,19 @@ read_when:
```bash
# WhatsApp
clawdbot message poll --to +15555550123 \
clawdbot message poll --target +15555550123 \
--poll-question "Lunch today?" --poll-option "Yes" --poll-option "No" --poll-option "Maybe"
clawdbot message poll --to 123456789@g.us \
clawdbot message poll --target 123456789@g.us \
--poll-question "Meeting time?" --poll-option "10am" --poll-option "2pm" --poll-option "4pm" --poll-multi
# Discord
clawdbot message poll --channel discord --to channel:123456789 \
clawdbot message poll --channel discord --target channel:123456789 \
--poll-question "Snack?" --poll-option "Pizza" --poll-option "Sushi"
clawdbot message poll --channel discord --to channel:123456789 \
clawdbot message poll --channel discord --target channel:123456789 \
--poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48
# MS Teams
clawdbot message poll --channel msteams --to conversation:19:abc@thread.tacv2 \
clawdbot message poll --channel msteams --target conversation:19:abc@thread.tacv2 \
--poll-question "Lunch?" --poll-option "Pizza" --poll-option "Sushi"
```

View File

@@ -96,7 +96,7 @@ Mapping options (summary):
- TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime.
- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface
(`channel` defaults to `last` and falls back to WhatsApp).
- `clawdbot hooks gmail setup` writes `hooks.gmail` config for `clawdbot hooks gmail run`.
- `clawdbot webhooks gmail setup` writes `hooks.gmail` config for `clawdbot webhooks gmail run`.
See [Gmail Pub/Sub](/automation/gmail-pubsub) for the full Gmail watch flow.
## Responses

40
docs/brave-search.md Normal file
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.

View File

@@ -0,0 +1,64 @@
---
summary: "iMessage via BlueBubbles macOS server (REST send/receive, typing, reactions, pairing)."
read_when:
- Setting up BlueBubbles channel
- Troubleshooting webhook pairing
---
# BlueBubbles (macOS REST)
Status: bundled plugin (disabled by default) that talks to the BlueBubbles macOS server over HTTP.
## Overview
- Runs on macOS via the BlueBubbles helper app (`https://bluebubbles.app`).
- Clawdbot talks to it through its REST API (`GET /api/v1/ping`, `POST /message/text`, `POST /chat/:id/*`).
- Incoming messages arrive via webhooks; outgoing replies, typing indicators, read receipts, and tapbacks are REST calls.
- Attachments and stickers are ingested as inbound media (and surfaced to the agent when possible).
- Pairing/allowlist works the same way as other channels (`/start/pairing` etc) with `channels.bluebubbles.allowFrom` + pairing codes.
- Reactions are surfaced as system events just like Slack/Telegram so agents can “mention” them before replying.
## Quick start
1. Install the BlueBubbles server on your Mac (follows the app store instructions at `https://bluebubbles.app/install`).
2. In the BlueBubbles config, enable the web API and set a password for `guid`/`password`.
3. Configure Clawdbot:
```json5
{
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://bluebubbles-host:1234",
password: "example-password",
webhookPath: "/bluebubbles-webhook",
actions: { reactions: true }
}
}
}
```
4. Point BlueBubbles webhooks to your gateway (example: `http://your-gateway-host/bluebubbles-webhook?password=<password>`).
5. Start the gateway; it will register the webhook handler and start pairing.
## Configuration notes
- `channels.bluebubbles.serverUrl`: base URL of the BlueBubbles REST API.
- `channels.bluebubbles.password`: password that BlueBubbles expects on every request (`?password=...` or header).
- `channels.bluebubbles.webhookPath`: HTTP path the gateway exposes for BlueBubbles webhooks.
- `channels.bluebubbles.dmPolicy` / `groupPolicy` + `allowFrom`/`groupAllowFrom` behave like other channels; pairing/allowlist info is stored in `/pairing`.
- `channels.bluebubbles.actions.reactions` toggles whether the gateway enqueues system events for reactions/tapbacks.
- `channels.bluebubbles.textChunkLimit` overrides the default 4k limit.
- `channels.bluebubbles.mediaMaxMb` controls the max size of inbound attachments saved for analysis (default 8MB).
## How it works
- Outbound replies: `sendMessageBlueBubbles` resolves a chat GUID via `/api/v1/chat/query` and posts to `/api/v1/message/text`. Typing (`/api/v1/chat/<guid>/typing`) and read receipts (`/api/v1/chat/<guid>/read`) are sent before/after responses.
- Webhooks: BlueBubbles POSTs JSON payloads with `type` and `data`. The plugin ignores non-message events (typing indicator, read status) and extracts `chatGuid` from `data.chats[0].guid`.
- Reactions/tapbacks generate `BlueBubbles reaction added/removed` system events so agents can mention them. Agents can also trigger tapbacks via the `react` action with `messageId`, `emoji`, and a `to`/`chatGuid`.
- Attachments are downloaded via the REST API and stored in the inbound media cache; text-less messages are converted into `<media:...>` placeholders so the agent knows something was sent.
## Security
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
- Keep the API password and webhook endpoint secret (treat them like credentials).
- Enable HTTPS + firewall rules on the BlueBubbles server if exposing it outside your LAN.
## Troubleshooting
- If Voice/typing events stop working, check the BlueBubbles webhook logs and verify the gateway path matches `channels.bluebubbles.webhookPath`.
- Pairing codes expire after one hour; use `clawdbot pairing list bluebubbles` and `clawdbot pairing approve bluebubbles <code>`.
- Reactions require the BlueBubbles private API (`POST /api/v1/message/react`); ensure the server version exposes it.
For general channel workflow reference, see [/channels/index] and the [[plugins|/plugin]] guide.

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 cant 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 didnt 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 cant verify permissions.
- **DMs dont work**: `channels.discord.dm.enabled=false`, `channels.discord.dm.policy="disabled"`, or you havent 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 Clawdbots 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 arent allowlisted; Clawdbot enforces allowlists on execution and replies “not authorized”.
## Tool actions
The agent can call `discord` with actions like:

View File

@@ -17,6 +17,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
- [Signal](/channels/signal) — signal-cli; privacy-focused.
- [iMessage](/channels/imessage) — macOS only; native integration.
- [BlueBubbles](/channels/bluebubbles) — iMessage via BlueBubbles macOS server (bundled plugin, disabled by default).
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).

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.

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 mentiongated 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 (mentiongated).
- 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).
@@ -447,7 +474,7 @@ By default, Clawdbot only downloads media from Microsoft/Teams hostnames. Overri
## Polls (Adaptive Cards)
Clawdbot sends Teams polls as Adaptive Cards (there is no native Teams poll API).
- CLI: `clawdbot message poll --channel msteams --to conversation:<id> ...`
- CLI: `clawdbot message poll --channel msteams --target conversation:<id> ...`
- Votes are recorded by the gateway in `~/.clawdbot/msteams-polls.json`.
- The gateway must stay online to record votes.
- Polls do not auto-post result summaries yet (inspect the store file if needed).

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 doesnt 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"`.

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
@@ -444,7 +459,7 @@ The agent sees reactions as **system notifications** in the conversation history
## Delivery targets (CLI/cron)
- Use a chat id (`123456789`) or a username (`@name`) as the target.
- Example: `clawdbot message send --channel telegram --to 123456789 --message "hi"`.
- Example: `clawdbot message send --channel telegram --target 123456789 --message "hi"`.
## Troubleshooting

View File

@@ -124,7 +124,7 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
## Delivery targets (CLI/cron)
- Use a chat id as the target.
- Example: `clawdbot message send --channel zalo --to 123456789 --message "hi"`.
- Example: `clawdbot message send --channel zalo --target 123456789 --message "hi"`.
## Troubleshooting

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:

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.

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

View File

@@ -15,7 +15,7 @@ Directory lookups for channels that support it (contacts/peers, groups, and “m
- `--json`: output JSON
## Notes
- `directory` is meant to help you find IDs you can paste into other commands (especially `clawdbot message send --to ...`).
- `directory` is meant to help you find IDs you can paste into other commands (especially `clawdbot message send --target ...`).
- For many channels, results are config-backed (allowlists / configured groups) rather than a live provider directory.
- Default output is `id` (and sometimes `name`) separated by a tab; use `--json` for scripting.
@@ -23,7 +23,7 @@ Directory lookups for channels that support it (contacts/peers, groups, and “m
```bash
clawdbot directory peers list --channel slack --query "U0"
clawdbot message send --channel slack --to user:U012ABCDEF --message "hello"
clawdbot message send --channel slack --target user:U012ABCDEF --message "hello"
```
## ID formats (by channel)

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.

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 dont restore any custom terminal state. If you wrap the CLI with a TUI or raw-mode input, restore the terminal before exit.
### Options

View File

@@ -1,31 +1,25 @@
---
summary: "CLI reference for `clawdbot hooks` (internal hooks + Gmail Pub/Sub + webhook helpers)"
summary: "CLI reference for `clawdbot hooks` (agent hooks)"
read_when:
- You want to manage internal agent hooks
- You want to wire Gmail Pub/Sub events into Clawdbot hooks
- You want to run the gog watch service and renew loop
- You want to manage agent hooks
- You want to install or update hooks
---
# `clawdbot hooks`
Webhook helpers and hook-based integrations.
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
Related:
- Internal Hooks: [Internal Agent Hooks](/internal-hooks)
- Webhooks: [Webhook](/automation/webhook)
- Gmail Pub/Sub: [Gmail Pub/Sub](/automation/gmail-pubsub)
- Hooks: [Hooks](/hooks)
- Plugin hooks: [Plugins](/plugin#plugin-hooks)
## Internal Hooks
Manage internal agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
### List All Hooks
## List All Hooks
```bash
clawdbot hooks internal list
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)
@@ -35,17 +29,18 @@ List all discovered internal hooks from workspace, managed, and bundled director
**Example output:**
```
Internal Hooks (2/2 ready)
Hooks (3/3 ready)
Ready:
📝 command-logger ✓ - Log all command events to a centralized audit file
💾 session-memory ✓ - Save session context to memory when /new command is issued
😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance
```
**Example (verbose):**
```bash
clawdbot hooks internal list --verbose
clawdbot hooks list --verbose
```
Shows missing requirements for ineligible hooks.
@@ -53,15 +48,15 @@ Shows missing requirements for ineligible hooks.
**Example (JSON):**
```bash
clawdbot hooks internal list --json
clawdbot hooks list --json
```
Returns structured JSON for programmatic use.
### Get Hook Information
## Get Hook Information
```bash
clawdbot hooks internal info <name>
clawdbot hooks info <name>
```
Show detailed information about a specific hook.
@@ -75,7 +70,7 @@ Show detailed information about a specific hook.
**Example:**
```bash
clawdbot hooks internal info session-memory
clawdbot hooks info session-memory
```
**Output:**
@@ -89,17 +84,17 @@ 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:
Config: ✓ workspace.dir
```
### Check Hooks Eligibility
## Check Hooks Eligibility
```bash
clawdbot hooks internal check
clawdbot hooks check
```
Show summary of hook eligibility status (how many are ready vs. not ready).
@@ -110,28 +105,31 @@ 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
Not ready: 0
```
### Enable a Hook
## Enable a Hook
```bash
clawdbot hooks internal enable <name>
clawdbot hooks enable <name>
```
Enable a specific hook by adding it to your config (`~/.clawdbot/config.json`).
**Note:** Hooks managed by plugins show `plugin:<id>` in `clawdbot hooks list` and
cant be enabled/disabled here. Enable/disable the plugin instead.
**Arguments:**
- `<name>`: Hook name (e.g., `session-memory`)
**Example:**
```bash
clawdbot hooks internal enable session-memory
clawdbot hooks enable session-memory
```
**Output:**
@@ -148,10 +146,10 @@ clawdbot hooks internal enable session-memory
**After enabling:**
- Restart the gateway so hooks reload (menu bar app restart on macOS, or restart your gateway process in dev).
### Disable a Hook
## Disable a Hook
```bash
clawdbot hooks internal disable <name>
clawdbot hooks disable <name>
```
Disable a specific hook by updating your config.
@@ -162,7 +160,7 @@ Disable a specific hook by updating your config.
**Example:**
```bash
clawdbot hooks internal disable command-logger
clawdbot hooks disable command-logger
```
**Output:**
@@ -174,6 +172,53 @@ clawdbot hooks internal disable command-logger
**After disabling:**
- Restart the gateway so hooks reload
## Install Hooks
```bash
clawdbot hooks install <path-or-spec>
```
Install a hook pack from a local folder/archive or npm.
**What it does:**
- Copies the hook pack into `~/.clawdbot/hooks/<id>`
- Enables the installed hooks in `hooks.internal.entries.*`
- Records the install under `hooks.internal.installs`
**Options:**
- `-l, --link`: Link a local directory instead of copying (adds it to `hooks.internal.load.extraDirs`)
**Supported archives:** `.zip`, `.tgz`, `.tar.gz`, `.tar`
**Examples:**
```bash
# Local directory
clawdbot hooks install ./my-hook-pack
# Local archive
clawdbot hooks install ./my-hook-pack.zip
# NPM package
clawdbot hooks install @clawdbot/my-hook-pack
# Link a local directory without copying
clawdbot hooks install -l ./my-hook-pack
```
## Update Hooks
```bash
clawdbot hooks update <id>
clawdbot hooks update --all
```
Update installed hook packs (npm installs only).
**Options:**
- `--all`: Update all tracked hook packs
- `--dry-run`: Show what would change without writing
## Bundled Hooks
### session-memory
@@ -183,12 +228,12 @@ Saves session context to memory when you issue `/new`.
**Enable:**
```bash
clawdbot hooks internal enable session-memory
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
@@ -197,7 +242,7 @@ Logs all command events to a centralized audit file.
**Enable:**
```bash
clawdbot hooks internal enable command-logger
clawdbot hooks enable command-logger
```
**Output:** `~/.clawdbot/logs/commands.log`
@@ -215,13 +260,16 @@ 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)
## Gmail
### soul-evil
Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance.
**Enable:**
```bash
clawdbot hooks gmail setup --account you@example.com
clawdbot hooks gmail run
clawdbot hooks enable soul-evil
```
See [Gmail Pub/Sub documentation](/automation/gmail-pubsub) for details.
**See:** [SOUL Evil Hook](/hooks/soul-evil)

View File

@@ -40,6 +40,7 @@ This page describes the current CLI behavior. If commands change, update this do
- [`dns`](/cli/dns)
- [`docs`](/cli/docs)
- [`hooks`](/cli/hooks)
- [`webhooks`](/cli/webhooks)
- [`pairing`](/cli/pairing)
- [`plugins`](/cli/plugins) (plugin commands)
- [`channels`](/cli/channels)
@@ -212,6 +213,14 @@ clawdbot [--dev] [--profile <name>] <command>
console
pdf
hooks
list
info
check
enable
disable
install
update
webhooks
gmail setup|run
pairing
list
@@ -283,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`)
@@ -293,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>`
@@ -414,12 +424,12 @@ Subcommands:
- `pairing list <channel> [--json]`
- `pairing approve <channel> <code> [--notify]`
### `hooks gmail`
### `webhooks gmail`
Gmail Pub/Sub hook setup + runner. See [/automation/gmail-pubsub](/automation/gmail-pubsub).
Subcommands:
- `hooks gmail setup` (requires `--account <email>`; supports `--project`, `--topic`, `--subscription`, `--label`, `--hook-url`, `--hook-token`, `--push-token`, `--bind`, `--port`, `--path`, `--include-body`, `--max-bytes`, `--renew-minutes`, `--tailscale`, `--tailscale-path`, `--tailscale-target`, `--push-endpoint`, `--json`)
- `hooks gmail run` (runtime overrides for the same flags)
- `webhooks gmail setup` (requires `--account <email>`; supports `--project`, `--topic`, `--subscription`, `--label`, `--hook-url`, `--hook-token`, `--push-token`, `--bind`, `--port`, `--path`, `--include-body`, `--max-bytes`, `--renew-minutes`, `--tailscale`, `--tailscale-path`, `--tailscale-target`, `--push-endpoint`, `--json`)
- `webhooks gmail run` (runtime overrides for the same flags)
### `dns setup`
Wide-area discovery DNS helper (CoreDNS + Tailscale). See [/gateway/discovery](/gateway/discovery).
@@ -446,8 +456,8 @@ Subcommands:
- `message event <list|create>`
Examples:
- `clawdbot message send --to +15555550123 --message "Hi"`
- `clawdbot message poll --channel discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi`
- `clawdbot message send --target +15555550123 --message "Hi"`
- `clawdbot message poll --channel discord --target channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi`
### `agent`
Run one agent turn via the Gateway (or `--local` embedded).
@@ -459,7 +469,7 @@ Options:
- `--to <dest>` (for session key and optional delivery)
- `--session-id <id>`
- `--thinking <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only)
- `--verbose <on|off>`
- `--verbose <on|full|off>`
- `--channel <whatsapp|telegram|discord|slack|signal|imessage>`
- `--local`
- `--deliver`
@@ -512,13 +522,13 @@ Options:
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
Surfaces:
- `/status` (alias: `/usage`; adds a short usage line when available)
- `/status` (adds a short provider usage line when available)
- `clawdbot status --usage` (prints full provider breakdown)
- macOS menu bar (Usage section under Context)
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).

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.

View File

@@ -21,7 +21,7 @@ Channel selection:
- If exactly one channel is configured, it becomes the default.
- Values: `whatsapp|telegram|discord|slack|signal|imessage|msteams`
Target formats (`--to`):
Target formats (`--target`):
- WhatsApp: E.164 or group JID
- Telegram: chat id or `@username`
- Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are treated as channels)
@@ -38,6 +38,7 @@ Name lookup:
- `--channel <name>`
- `--account <id>`
- `--target <dest>` (target channel or user for send/poll/read/etc)
- `--targets <name>` (repeat; broadcast only)
- `--json`
- `--dry-run`
@@ -49,7 +50,7 @@ Name lookup:
- `send`
- Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
- Required: `--to`, plus `--message` or `--media`
- Required: `--target`, plus `--message` or `--media`
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
- Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
- Telegram only: `--thread-id` (forum topic id)
@@ -58,52 +59,47 @@ Name lookup:
- `poll`
- Channels: WhatsApp/Discord/MS Teams
- Required: `--to`, `--poll-question`, `--poll-option` (repeat)
- Required: `--target`, `--poll-question`, `--poll-option` (repeat)
- Optional: `--poll-multi`
- Discord only: `--poll-duration-hours`, `--message`
- `react`
- Channels: Discord/Slack/Telegram/WhatsApp
- Required: `--message-id`, `--to` or `--channel-id`
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--channel-id`
- Required: `--message-id`, `--target`
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
- WhatsApp only: `--participant`, `--from-me`
- `reactions`
- Channels: Discord/Slack
- Required: `--message-id`, `--to` or `--channel-id`
- Optional: `--limit`, `--channel-id`
- Required: `--message-id`, `--target`
- Optional: `--limit`
- `read`
- Channels: Discord/Slack
- Required: `--to` or `--channel-id`
- Optional: `--limit`, `--before`, `--after`, `--channel-id`
- Required: `--target`
- Optional: `--limit`, `--before`, `--after`
- Discord only: `--around`
- `edit`
- Channels: Discord/Slack
- Required: `--message-id`, `--message`, `--to` or `--channel-id`
- Optional: `--channel-id`
- Required: `--message-id`, `--message`, `--target`
- `delete`
- Channels: Discord/Slack/Telegram
- Required: `--message-id`, `--to` or `--channel-id`
- Optional: `--channel-id`
- Required: `--message-id`, `--target`
- `pin` / `unpin`
- Channels: Discord/Slack
- Required: `--message-id`, `--to` or `--channel-id`
- Optional: `--channel-id`
- Required: `--message-id`, `--target`
- `pins` (list)
- Channels: Discord/Slack
- Required: `--to` or `--channel-id`
- Optional: `--channel-id`
- Required: `--target`
- `permissions`
- Channels: Discord
- Required: `--to` or `--channel-id`
- Optional: `--channel-id`
- Required: `--target`
- `search`
- Channels: Discord
@@ -114,7 +110,7 @@ Name lookup:
- `thread create`
- Channels: Discord
- Required: `--thread-name`, `--to` (channel id) or `--channel-id`
- Required: `--thread-name`, `--target` (channel id)
- Optional: `--message-id`, `--auto-archive-min`
- `thread list`
@@ -124,7 +120,7 @@ Name lookup:
- `thread reply`
- Channels: Discord
- Required: `--to` (thread id), `--message`
- Required: `--target` (thread id), `--message`
- Optional: `--media`, `--reply-to`
### Emojis
@@ -142,7 +138,7 @@ Name lookup:
- `sticker send`
- Channels: Discord
- Required: `--to`, `--sticker-id` (repeat)
- Required: `--target`, `--sticker-id` (repeat)
- Optional: `--message`
- `sticker upload`
@@ -153,7 +149,7 @@ Name lookup:
- `role info` (Discord): `--guild-id`
- `role add` / `role remove` (Discord): `--guild-id`, `--user-id`, `--role-id`
- `channel info` (Discord): `--channel-id`
- `channel info` (Discord): `--target`
- `channel list` (Discord): `--guild-id`
- `member info` (Discord/Slack): `--user-id` (+ `--guild-id` for Discord)
- `voice status` (Discord): `--guild-id`, `--user-id`
@@ -183,13 +179,13 @@ Name lookup:
Send a Discord reply:
```
clawdbot message send --channel discord \
--to channel:123 --message "hi" --reply-to 456
--target channel:123 --message "hi" --reply-to 456
```
Create a Discord poll:
```
clawdbot message poll --channel discord \
--to channel:123 \
--target channel:123 \
--poll-question "Snack?" \
--poll-option Pizza --poll-option Sushi \
--poll-multi --poll-duration-hours 48
@@ -198,13 +194,13 @@ clawdbot message poll --channel discord \
Send a Teams proactive message:
```
clawdbot message send --channel msteams \
--to conversation:19:abc@thread.tacv2 --message "hi"
--target conversation:19:abc@thread.tacv2 --message "hi"
```
Create a Teams poll:
```
clawdbot message poll --channel msteams \
--to conversation:19:abc@thread.tacv2 \
--target conversation:19:abc@thread.tacv2 \
--poll-question "Lunch?" \
--poll-option Pizza --poll-option Sushi
```
@@ -212,11 +208,11 @@ clawdbot message poll --channel msteams \
React in Slack:
```
clawdbot message react --channel slack \
--to C123 --message-id 456 --emoji "✅"
--target C123 --message-id 456 --emoji "✅"
```
Send Telegram inline buttons:
```
clawdbot message send --channel telegram --to @mychat --message "Choose:" \
clawdbot message send --channel telegram --target @mychat --message "Choose:" \
--buttons '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]'
```

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

View File

@@ -25,14 +25,25 @@ clawdbot plugins update <id>
clawdbot plugins update --all
```
Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to
activate them.
### Install
```bash
clawdbot plugins install <npm-spec>
clawdbot plugins install <path-or-spec>
```
Security note: treat plugin installs like running code. Prefer pinned versions.
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
```bash
clawdbot plugins install -l ./my-plugin
```
### Update
```bash

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)).

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:

23
docs/cli/webhooks.md Normal file
View File

@@ -0,0 +1,23 @@
---
summary: "CLI reference for `clawdbot webhooks` (webhook helpers + Gmail Pub/Sub)"
read_when:
- You want to wire Gmail Pub/Sub events into Clawdbot
- You want webhook helper commands
---
# `clawdbot webhooks`
Webhook helpers and integrations (Gmail Pub/Sub, webhook helpers).
Related:
- Webhooks: [Webhook](/automation/webhook)
- Gmail Pub/Sub: [Gmail Pub/Sub](/automation/gmail-pubsub)
## Gmail
```bash
clawdbot webhooks gmail setup --account you@example.com
clawdbot webhooks gmail run
```
See [Gmail Pub/Sub documentation](/automation/gmail-pubsub) for details.

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:

View File

@@ -21,7 +21,7 @@ Context is *not the same thing* as “memory”: memory can be stored on disk an
- `/status` → quick “how full is my window?” view + session settings.
- `/context list` → whats injected + rough sizes (per file + totals).
- `/context detail` → deeper breakdown: per-file, per-tool schema sizes, per-skill entry sizes, and system prompt size.
- `/cost on` → append per-reply usage line to normal replies.
- `/usage tokens` → append per-reply usage footer to normal replies.
- `/compact` → summarize older history into a compact entry to free window space.
See also: [Slash commands](/tools/slash-commands), [Token use & costs](/token-use), [Compaction](/concepts/compaction).
@@ -149,4 +149,3 @@ Docs: [Session](/concepts/session), [Compaction](/concepts/compaction), [Session
- `System prompt (estimate)` = computed on the fly when no run report exists (or when running via a CLI backend that doesnt generate the report).
Either way, it reports sizes and top contributors; it does **not** dump the full system prompt or tool schemas.

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,147 @@ 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.
#### Why hybrid?
Vector search is great at “this means the same thing”:
- “Mac Studio gateway host” vs “the machine running the gateway”
- “debounce file updates” vs “avoid indexing on every write”
But it can be weak at exact, high-signal tokens:
- IDs (`a828e60`, `b3b9895a…`)
- code symbols (`memorySearch.query.hybrid`)
- error strings (“sqlite-vec unavailable”)
BM25 (full-text) is the opposite: strong at exact tokens, weaker at paraphrases.
Hybrid search is the pragmatic middle ground: **use both retrieval signals** so you get
good results for both “natural language” queries and “needle in a haystack” queries.
#### How we merge results (the current design)
Implementation sketch:
1) Retrieve a candidate pool from both sides:
- **Vector**: top `maxResults * candidateMultiplier` by cosine similarity.
- **BM25**: top `maxResults * candidateMultiplier` by FTS5 BM25 rank (lower is better).
2) Convert BM25 rank into a 0..1-ish score:
- `textScore = 1 / (1 + max(0, bm25Rank))`
3) Union candidates by chunk id and compute a weighted score:
- `finalScore = vectorWeight * vectorScore + textWeight * textScore`
Notes:
- `vectorWeight` + `textWeight` is normalized to 1.0 in config resolution, so weights behave as percentages.
- If embeddings are unavailable (or the provider returns a zero-vector), we still run BM25 and return keyword matches.
- If FTS5 cant be created, we keep vector-only search (no hard failure).
This isnt “IR-theory perfect”, but its simple, fast, and tends to improve recall/precision on real notes.
If we want to get fancier later, common next steps are Reciprocal Rank Fusion (RRF) or score normalization
(min/max or z-score) before mixing.
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 agents 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

View File

@@ -85,6 +85,10 @@ When a channel supplies history, it uses a shared wrapper:
- `[Chat messages since your last reply - for context]`
- `[Current message - respond to this]`
For **non-direct chats** (groups/channels/rooms), the **current message body** is prefixed with the
sender label (same style used for history entries). This keeps real-time and queued/history
messages consistent in the agent prompt.
History buffers are **pending-only**: they include group messages that did *not*
trigger a run (for example, mention-gated messages) and **exclude** messages
already in the session transcript.

View File

@@ -83,7 +83,12 @@ Clawdbot ships with the piai 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:

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).

View File

@@ -48,6 +48,7 @@ Row shape (JSON):
- `thinkingLevel`, `verboseLevel`, `systemSent`, `abortedLastRun`
- `sendPolicy` (session override if set)
- `lastChannel`, `lastTo`
- `deliveryContext` (normalized `{ channel, to, accountId }` when available)
- `transcriptPath` (best-effort path derived from store dir + sessionId)
- `messages?` (only when `messageLimit > 0`)

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
@@ -53,8 +54,12 @@ the workspace is writable. See [Memory](/concepts/memory) and
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
- Node bridge runs: `node-<nodeId>`
## Lifecyle
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
## Lifecycle
- Reset policy: sessions are reused until they expire, and expiry is evaluated on the next inbound message.
- Daily reset: defaults to **4:00 AM local time on the gateway host**. A session is stale once its last update is earlier than the most recent daily reset time.
- Idle reset (optional): `idleMinutes` adds a sliding idle window. When both daily and idle resets are configured, **whichever expires first** forces a new session.
- Legacy idle-only: if you set `session.idleMinutes` without any `session.reset`/`resetByType` config, Clawdbot stays in idle-only mode for backward compatibility.
- Per-type overrides (optional): `resetByType` lets you override the policy for `dm`, `group`, and `thread` sessions (thread = Slack/Discord threads, Telegram topics, Matrix threads when provided by the connector).
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
@@ -92,7 +97,18 @@ Send these as standalone messages so they register.
identityLinks: {
alice: ["telegram:123456789", "discord:987654321012345678"]
},
idleMinutes: 120,
reset: {
// Defaults: mode=daily, atHour=4 (gateway host local time).
// If you also set idleMinutes, whichever expires first wins.
mode: "daily",
atHour: 4,
idleMinutes: 120
},
resetByType: {
thread: { mode: "daily", atHour: 4 },
dm: { mode: "idle", idleMinutes: 240 },
group: { mode: "idle", idleMinutes: 120 }
},
resetTriggers: ["/new", "/reset"],
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
mainKey: "main",
@@ -113,3 +129,18 @@ 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
The origin fields are populated for direct messages, channels, and groups. If a
connector only updates delivery routing (for example, to keep a DM main session
fresh), it should still provide inbound context so the session keeps its
explainer metadata. Extensions can do this by sending `ConversationLabel`,
`GroupSubject`, `GroupChannel`, `GroupSpace`, and `SenderName` in the inbound
context and calling `recordSessionMetaFromInbound` (or passing the same context
to `updateLastRoute`).

View File

@@ -58,6 +58,9 @@ Large files are truncated with a marker. The max per-file size is controlled by
`agents.defaults.bootstrapMaxChars` (default: 20000). Missing files inject a
short missing-file marker.
Internal hooks can intercept this step via `agent:bootstrap` to mutate or replace
the injected bootstrap files (for example swapping `SOUL.md` for an alternate persona).
To inspect how much each injected file contributes (raw vs injected, truncation, plus tool schema overhead), use `/context list` or `/context detail`. See [Context](/concepts/context).
## Time handling

View File

@@ -11,8 +11,8 @@ read_when:
- No estimated costs; only the provider-reported windows.
## Where it shows up
- `/status` in chats: emojirich status card with session tokens + estimated cost (API key only). When OAuth/token profiles exist, the **OAuth/token status block** includes provider usage headers (when available).
- `/cost on|off` in chats: toggles perresponse usage lines (OAuth shows tokens only).
- `/status` in chats: emojirich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available.
- `/usage off|tokens|full` in chats: per-response usage footer (OAuth shows tokens only).
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
- macOS menu bar: “Usage” section under Context (only if available).

View File

@@ -894,7 +894,6 @@
"gateway/heartbeat",
"gateway/doctor",
"gateway/logging",
"logging",
"gateway/security",
"gateway/sandbox-vs-tool-policy-vs-elevated",
"gateway/sandboxing",
@@ -957,6 +956,8 @@
{
"group": "Automation & Hooks",
"pages": [
"hooks",
"hooks/soul-evil",
"automation/auth-monitoring",
"automation/webhook",
"automation/gmail-pubsub",

View File

@@ -17,7 +17,7 @@ Key parameters:
- `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill the process after this timeout
- `elevated` (bool): run on host if elevated mode is enabled/allowed
- Need a real TTY? Use the tmux skill.
- Need a real TTY? Set `pty: true`.
- `workdir`, `env`
Behavior:
@@ -33,12 +33,14 @@ 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`: inmemory 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 1m3h)
Config (preferred):
- `tools.exec.backgroundMs` (default 10000)
- `tools.exec.timeoutSec` (default 1800)
- `tools.exec.cleanupMs` (default 1800000)
- `tools.exec.notifyOnExit` (default true): enqueue a system event + request heartbeat when a backgrounded exec exits.
## process tool

View File

@@ -124,10 +124,21 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
// Tooling
tools: {
audio: {
transcription: {
args: ["--model", "base", "{{MediaPath}}"],
media: {
audio: {
enabled: true,
maxBytes: 20971520,
models: [
{ provider: "openai", model: "whisper-1" },
// Optional CLI fallback (Whisper binary):
// { type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
],
timeoutSeconds: 120
},
video: {
enabled: true,
maxBytes: 52428800,
models: [{ provider: "google", model: "gemini-3-flash-preview" }]
}
}
},
@@ -135,7 +146,11 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
// Session behavior
session: {
scope: "per-sender",
idleMinutes: 60,
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 60
},
heartbeatIdleMinutes: 120,
resetTriggers: ["/new", "/reset"],
store: "~/.clawdbot/agents/default/sessions/sessions.json",

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 providers `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`)
@@ -1745,10 +1746,10 @@ of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
- `backgroundMs`: time before auto-background (ms, default 10000)
- `timeoutSec`: auto-kill after this runtime (seconds, default 1800)
- `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000)
- `notifyOnExit`: enqueue a system event + request heartbeat when backgrounded exec exits (default true)
- `applyPatch.enabled`: enable experimental `apply_patch` (OpenAI/OpenAI Codex only; default false)
- `applyPatch.allowModels`: optional allowlist of model ids (e.g. `gpt-5.2` or `openai/gpt-5.2`)
Note: `applyPatch` is only under `tools.exec` (no `tools.bash` alias).
Legacy: `tools.bash` is still accepted as an alias.
Note: `applyPatch` is only under `tools.exec`.
`tools.web` configures web search + fetch tools:
- `tools.web.search.enabled` (default: true when key is present)
@@ -1769,6 +1770,61 @@ Legacy: `tools.bash` is still accepted as an alias.
- `tools.web.fetch.firecrawl.maxAgeMs` (optional)
- `tools.web.fetch.firecrawl.timeoutSeconds` (optional)
`tools.media` configures inbound media understanding (image/audio/video):
- `tools.media.models`: shared model list (capability-tagged; used after per-cap lists).
- `tools.media.concurrency`: max concurrent capability runs (default 2).
- `tools.media.image` / `tools.media.audio` / `tools.media.video`:
- `enabled`: opt-out switch (default true when models are configured).
- `prompt`: optional prompt override (image/video append a `maxChars` hint automatically).
- `maxChars`: max output characters (default 500 for image/video; unset for audio).
- `maxBytes`: max media size to send (defaults: image 10MB, audio 20MB, video 50MB).
- `timeoutSeconds`: request timeout (defaults: image 60s, audio 60s, video 120s).
- `language`: optional audio hint.
- `attachments`: attachment policy (`mode`, `maxAttachments`, `prefer`).
- `scope`: optional gating (first match wins) with `match.channel`, `match.chatType`, or `match.keyPrefix`.
- `models`: ordered list of model entries; failures or oversize media fall back to the next entry.
- Each `models[]` entry:
- Provider entry (`type: "provider"` or omitted):
- `provider`: API provider id (`openai`, `anthropic`, `google`/`gemini`, `groq`, etc).
- `model`: model id override (required for image; defaults to `whisper-1`/`whisper-large-v3-turbo` for audio providers, and `gemini-3-flash-preview` for video).
- `profile` / `preferredProfile`: auth profile selection.
- CLI entry (`type: "cli"`):
- `command`: executable to run.
- `args`: templated args (supports `{{MediaPath}}`, `{{Prompt}}`, `{{MaxChars}}`, etc).
- `capabilities`: optional list (`image`, `audio`, `video`) to gate a shared entry. Defaults when omitted: `openai`/`anthropic`/`minimax` → image, `google` → image+audio+video, `groq` → audio.
- `prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language` can be overridden per entry.
If no models are configured (or `enabled: false`), understanding is skipped; the model still receives the original attachments.
Provider auth follows the standard model auth order (auth profiles, env vars like `OPENAI_API_KEY`/`GROQ_API_KEY`/`GEMINI_API_KEY`, or `models.providers.*.apiKey`).
Example:
```json5
{
tools: {
media: {
audio: {
enabled: true,
maxBytes: 20971520,
scope: {
default: "deny",
rules: [{ action: "allow", match: { chatType: "direct" } }]
},
models: [
{ provider: "openai", model: "whisper-1" },
{ type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
]
},
video: {
enabled: true,
maxBytes: 52428800,
models: [{ provider: "google", model: "gemini-3-flash-preview" }]
}
}
}
}
```
`agents.defaults.subagents` configures sub-agent defaults:
- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the callers model unless overridden per agent or per call.
- `maxConcurrent`: max concurrent sub-agent runs (default 1)
@@ -2179,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:
@@ -2317,7 +2416,7 @@ Notes:
### `session`
Controls session scoping, idle expiry, reset triggers, and where the session store is written.
Controls session scoping, reset policy, reset triggers, and where the session store is written.
```json5
{
@@ -2327,7 +2426,16 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
identityLinks: {
alice: ["telegram:123456789", "discord:987654321012345678"]
},
idleMinutes: 60,
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 60
},
resetByType: {
thread: { mode: "daily", atHour: 4 },
dm: { mode: "idle", idleMinutes: 240 },
group: { mode: "idle", idleMinutes: 120 }
},
resetTriggers: ["/new", "/reset"],
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
// You can override with {agentId} templating:
@@ -2338,12 +2446,12 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
// Max ping-pong reply turns between requester/target (05).
maxPingPongTurns: 5
},
sendPolicy: {
rules: [
sendPolicy: {
rules: [
{ action: "deny", match: { channel: "discord", chatType: "group" } }
],
default: "allow"
}
],
default: "allow"
}
}
}
```
@@ -2357,6 +2465,13 @@ Fields:
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
- Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`.
- `reset`: primary reset policy. Defaults to daily resets at 4:00 AM local time on the gateway host.
- `mode`: `daily` or `idle` (default: `daily` when `reset` is present).
- `atHour`: local hour (0-23) for the daily reset boundary.
- `idleMinutes`: sliding idle window in minutes. When daily + idle are both configured, whichever expires first wins.
- `resetByType`: per-session overrides for `dm`, `group`, and `thread`.
- If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, Clawdbot stays in idle-only mode for backward compatibility.
- `heartbeatIdleMinutes`: optional idle override for heartbeat checks (daily reset still applies when enabled).
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (05, default 5).
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
@@ -2702,7 +2817,7 @@ Mapping notes:
- If there is no prior delivery route, set `channel` + `to` explicitly (required for Telegram/Discord/Slack/Signal/iMessage/MS Teams).
- `model` overrides the LLM for this hook run (`provider/model` or alias; must be allowed if `agents.defaults.models` is set).
Gmail helper config (used by `clawdbot hooks gmail setup` / `run`):
Gmail helper config (used by `clawdbot webhooks gmail setup` / `run`):
```json5
{
@@ -2848,7 +2963,7 @@ clawdbot dns setup --apply
## Template variables
Template placeholders are expanded in `tools.audio.transcription.args` (and any future templated argument fields).
Template placeholders are expanded in `tools.media.*.models[].args` and `tools.media.models[].args` (and any future templated argument fields).
| Variable | Description |
|----------|-------------|
@@ -2864,6 +2979,8 @@ Template placeholders are expanded in `tools.audio.transcription.args` (and any
| `{{MediaPath}}` | Local media path (if downloaded) |
| `{{MediaType}}` | Media type (image/audio/document/…) |
| `{{Transcript}}` | Audio transcript (when enabled) |
| `{{Prompt}}` | Resolved media prompt for CLI entries |
| `{{MaxChars}}` | Resolved max output chars for CLI entries |
| `{{ChatType}}` | `"direct"` or `"group"` |
| `{{GroupSubject}}` | Group subject (best effort) |
| `{{GroupMembers}}` | Group members preview (best effort) |

View File

@@ -111,7 +111,7 @@ Current migrations:
- `routing.bindings` → top-level `bindings`
- `routing.agents`/`routing.defaultAgentId``agents.list` + `agents.list[].default`
- `routing.agentToAgent``tools.agentToAgent`
- `routing.transcribeAudio``tools.audio.transcription`
- `routing.transcribeAudio``tools.media.audio.models`
- `bindings[].match.accountID``bindings[].match.accountId`
- `identity``agents.list[].identity`
- `agent.*``agents.defaults` + `tools.*` (tools/elevated/exec/sandbox/subagents)

View File

@@ -281,7 +281,7 @@ Windows installs should use **WSL2** and follow the Linux systemd section above.
## CLI helpers
- `clawdbot gateway health|status` — request health/status over the Gateway WS.
- `clawdbot message send --to <num> --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp).
- `clawdbot message send --target <num> --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp).
- `clawdbot agent --message "hi" --to <num>` — run an agent turn (waits for final by default).
- `clawdbot gateway call <method> --params '{"k":"v"}'` — raw method invoker for debugging.
- `clawdbot daemon stop|restart` — stop/restart the supervised gateway service (launchd/systemd).

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:

View File

@@ -239,11 +239,15 @@ Known issue: When you send an image with ONLY a mention (no other text), WhatsAp
ls -la ~/.clawdbot/agents/<agentId>/sessions/
```
**Check 2:** Is `idleMinutes` too short?
**Check 2:** Is the reset window too short?
```json
{
"session": {
"idleMinutes": 10080 // 7 days
"reset": {
"mode": "daily",
"atHour": 4,
"idleMinutes": 10080 // 7 days
}
}
}
```

View File

@@ -1,19 +1,21 @@
---
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).
- **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.
Hooks can also be bundled inside plugins; see [Plugins](/plugin#plugin-hooks).
Common uses:
- Save a memory snapshot when you reset a session
@@ -21,11 +23,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
@@ -35,33 +37,34 @@ The internal hooks system allows you to:
### Bundled Hooks
Clawdbot ships with two bundled hooks that are automatically discovered:
Clawdbot ships with three bundled hooks that are automatically discovered:
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
List available hooks:
```bash
clawdbot hooks internal list
clawdbot hooks list
```
Enable a hook:
```bash
clawdbot hooks internal enable session-memory
clawdbot hooks enable session-memory
```
Check hook status:
```bash
clawdbot hooks internal check
clawdbot hooks check
```
Get detailed information:
```bash
clawdbot hooks internal info session-memory
clawdbot hooks info session-memory
```
### Onboarding
@@ -76,6 +79,8 @@ Hooks are automatically discovered from three directories (in order of precedenc
2. **Managed hooks**: `~/.clawdbot/hooks/` (user-installed, shared across workspaces)
3. **Bundled hooks**: `<clawdbot>/dist/hooks/bundled/` (shipped with Clawdbot)
Managed hook directories can be either a **single hook** or a **hook pack** (package directory).
Each hook is a directory containing:
```
@@ -84,6 +89,30 @@ my-hook/
└── handler.ts # Handler implementation
```
## Hook Packs (npm/archives)
Hook packs are standard npm packages that export one or more hooks via `clawdbot.hooks` in
`package.json`. Install them with:
```bash
clawdbot hooks install <path-or-spec>
```
Example `package.json`:
```json
{
"name": "@acme/my-hooks",
"version": "0.1.0",
"clawdbot": {
"hooks": ["./hooks/my-hook", "./hooks/other-hook"]
}
}
```
Each entry points to a hook directory containing `HOOK.md` and `handler.ts` (or `index.ts`).
Hook packs can ship dependencies; they will be installed under `~/.clawdbot/hooks/<id>`.
## Hook Structure
### HOOK.md Format
@@ -94,7 +123,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"]}}}
---
@@ -136,12 +165,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;
@@ -177,6 +206,8 @@ Each event includes:
sessionFile?: string,
commandSource?: string, // e.g., 'whatsapp', 'telegram'
senderId?: string,
workspaceDir?: string,
bootstrapFiles?: WorkspaceBootstrapFile[],
cfg?: ClawdbotConfig
}
}
@@ -193,6 +224,10 @@ Triggered when agent commands are issued:
- **`command:reset`**: When `/reset` command is issued
- **`command:stop`**: When `/stop` command is issued
### Agent Events
- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`)
### Future Events
Planned event types:
@@ -234,9 +269,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;
}
@@ -252,10 +287,10 @@ export default handler;
```bash
# Verify hook is discovered
clawdbot hooks internal list
clawdbot hooks list
# Enable it
clawdbot hooks internal enable my-hook
clawdbot hooks enable my-hook
# Restart your gateway process (menu bar app restart on macOS, or restart your dev process)
@@ -349,46 +384,46 @@ The old config format still works for backwards compatibility:
```bash
# List all hooks
clawdbot hooks internal list
clawdbot hooks list
# Show only eligible hooks
clawdbot hooks internal list --eligible
clawdbot hooks list --eligible
# Verbose output (show missing requirements)
clawdbot hooks internal list --verbose
clawdbot hooks list --verbose
# JSON output
clawdbot hooks internal list --json
clawdbot hooks list --json
```
### Hook Information
```bash
# Show detailed info about a hook
clawdbot hooks internal info session-memory
clawdbot hooks info session-memory
# JSON output
clawdbot hooks internal info session-memory --json
clawdbot hooks info session-memory --json
```
### Check Eligibility
```bash
# Show eligibility summary
clawdbot hooks internal check
clawdbot hooks check
# JSON output
clawdbot hooks internal check --json
clawdbot hooks check --json
```
### Enable/Disable
```bash
# Enable a hook
clawdbot hooks internal enable session-memory
clawdbot hooks enable session-memory
# Disable a hook
clawdbot hooks internal disable command-logger
clawdbot hooks disable command-logger
```
## Bundled Hooks
@@ -427,7 +462,7 @@ Saves session context to memory when you issue `/new`.
**Enable**:
```bash
clawdbot hooks internal enable session-memory
clawdbot hooks enable session-memory
```
### command-logger
@@ -468,7 +503,43 @@ grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
**Enable**:
```bash
clawdbot hooks internal enable command-logger
clawdbot hooks enable command-logger
```
### soul-evil
Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance.
**Events**: `agent:bootstrap`
**Docs**: [SOUL Evil Hook](/hooks/soul-evil)
**Output**: No files written; swaps happen in-memory only.
**Enable**:
```bash
clawdbot hooks enable soul-evil
```
**Config**:
```json
{
"hooks": {
"internal": {
"enabled": true,
"entries": {
"soul-evil": {
"enabled": true,
"file": "SOUL_EVIL.md",
"chance": 0.1,
"purge": { "at": "21:00", "duration": "15m" }
}
}
}
}
}
```
## Best Practices
@@ -479,12 +550,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);
};
@@ -495,7 +566,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) {
@@ -510,7 +581,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;
@@ -550,7 +621,7 @@ Registered hook: command-logger -> command
List all discovered hooks:
```bash
clawdbot hooks internal list --verbose
clawdbot hooks list --verbose
```
### Check Registration
@@ -558,7 +629,7 @@ clawdbot hooks internal 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
};
@@ -569,7 +640,7 @@ const handler: InternalHookHandler = async (event) => {
Check why a hook isn't eligible:
```bash
clawdbot hooks internal info my-hook
clawdbot hooks info my-hook
```
Look for missing requirements in the output.
@@ -594,11 +665,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'
});
@@ -618,7 +689,7 @@ test('my handler works', async () => {
- **`src/hooks/config.ts`**: Eligibility checking
- **`src/hooks/hooks-status.ts`**: Status reporting
- **`src/hooks/loader.ts`**: Dynamic module loader
- **`src/cli/hooks-internal-cli.ts`**: CLI commands
- **`src/cli/hooks-cli.ts`**: CLI commands
- **`src/gateway/server-startup.ts`**: Loads hooks at gateway start
- **`src/auto-reply/reply/commands-core.ts`**: Triggers command events
@@ -672,7 +743,7 @@ Session reset
3. List all discovered hooks:
```bash
clawdbot hooks internal list
clawdbot hooks list
```
### Hook Not Eligible
@@ -680,7 +751,7 @@ Session reset
Check requirements:
```bash
clawdbot hooks internal info my-hook
clawdbot hooks info my-hook
```
Look for missing:
@@ -693,7 +764,7 @@ Look for missing:
1. Verify hook is enabled:
```bash
clawdbot hooks internal list
clawdbot hooks list
# Should show ✓ next to enabled hooks
```
@@ -772,7 +843,7 @@ node -e "import('./path/to/handler.ts').then(console.log)"
4. Verify and restart your gateway process:
```bash
clawdbot hooks internal list
clawdbot hooks list
# Should show: 🎯 my-hook ✓
```
@@ -785,7 +856,7 @@ node -e "import('./path/to/handler.ts').then(console.log)"
## See Also
- [CLI Reference: hooks internal](/cli/hooks)
- [CLI Reference: hooks](/cli/hooks)
- [Bundled Hooks README](https://github.com/clawdbot/clawdbot/tree/main/src/hooks/bundled)
- [Webhook Hooks](/automation/webhook)
- [Configuration](/gateway/configuration#hooks)

68
docs/hooks/soul-evil.md Normal file
View File

@@ -0,0 +1,68 @@
---
summary: "SOUL Evil hook (swap SOUL.md with SOUL_EVIL.md)"
read_when:
- You want to enable or tune the SOUL Evil hook
- You want a purge window or random-chance persona swap
---
# SOUL Evil Hook
The SOUL Evil hook swaps the **injected** `SOUL.md` content with `SOUL_EVIL.md` during
a purge window or by random chance. It does **not** modify files on disk.
## How It Works
When `agent:bootstrap` runs, the hook can replace the `SOUL.md` content in memory
before the system prompt is assembled. If `SOUL_EVIL.md` is missing or empty,
Clawdbot logs a warning and keeps the normal `SOUL.md`.
Sub-agent runs do **not** include `SOUL.md` in their bootstrap files, so this hook
has no effect on sub-agents.
## Enable
```bash
clawdbot hooks enable soul-evil
```
Then set the config:
```json
{
"hooks": {
"internal": {
"enabled": true,
"entries": {
"soul-evil": {
"enabled": true,
"file": "SOUL_EVIL.md",
"chance": 0.1,
"purge": { "at": "21:00", "duration": "15m" }
}
}
}
}
}
```
Create `SOUL_EVIL.md` in the agent workspace root (next to `SOUL.md`).
## Options
- `file` (string): alternate SOUL filename (default: `SOUL_EVIL.md`)
- `chance` (number 01): random chance per run to use `SOUL_EVIL.md`
- `purge.at` (HH:mm): daily purge start (24-hour clock)
- `purge.duration` (duration): window length (e.g. `30s`, `10m`, `1h`)
**Precedence:** purge window wins over chance.
**Timezone:** uses `agents.defaults.userTimezone` when set; otherwise host timezone.
## Notes
- No files are written or modified on disk.
- If `SOUL.md` is not in the bootstrap list, the hook does nothing.
## See Also
- [Hooks](/hooks)

View File

@@ -137,7 +137,7 @@ clawdbot gateway --port 19001
Send a test message (requires a running Gateway):
```bash
clawdbot message send --to +15555550123 --message "Hello from Clawdbot"
clawdbot message send --target +15555550123 --message "Hello from Clawdbot"
```
## Configuration (optional)

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 cant detect the install, use “Update (global install)” instead.
## Update (Control UI / RPC)

View File

@@ -3,25 +3,73 @@ summary: "How inbound audio/voice notes are downloaded, transcribed, and injecte
read_when:
- Changing audio transcription or media handling
---
# Audio / Voice Notes — 2025-12-05
# Audio / Voice Notes — 2026-01-17
## What works
- **Optional transcription**: If `tools.audio.transcription` is set in `~/.clawdbot/clawdbot.json`, Clawdbot will:
1) Download inbound audio to a temp path when WhatsApp only provides a URL.
2) Run the configured CLI args (templated with `{{MediaPath}}`), expecting transcript on stdout.
3) Replace `Body` with the transcript, set `{{Transcript}}`, and prepend the original media path plus a `Transcript:` section in the command prompt so models see both.
4) Continue through the normal auto-reply pipeline (templating, sessions, Pi command).
- **Verbose logging**: In `--verbose`, we log when transcription runs and when the transcript replaces the body.
- **Media understanding (audio)**: If `tools.media.audio` is enabled (or a shared `tools.media.models` entry supports audio), Clawdbot:
1) Locates the first audio attachment (local path or URL) and downloads it if needed.
2) Enforces `maxBytes` before sending to each model entry.
3) Runs the first eligible model entry in order (provider or CLI).
4) If it fails or skips (size/timeout), it tries the next entry.
5) On success, it replaces `Body` with an `[Audio]` block and sets `{{Transcript}}`.
- **Command parsing**: When transcription succeeds, `CommandBody`/`RawBody` are set to the transcript so slash commands still work.
- **Verbose logging**: In `--verbose`, we log when transcription runs and when it replaces the body.
## Config example (Whisper CLI)
Requires `whisper` CLI installed:
## Config examples
### Provider + CLI fallback (OpenAI + Whisper CLI)
```json5
{
tools: {
audio: {
transcription: {
args: ["--model", "base", "{{MediaPath}}"],
timeoutSeconds: 45
media: {
audio: {
enabled: true,
maxBytes: 20971520,
models: [
{ provider: "openai", model: "whisper-1" },
{
type: "cli",
command: "whisper",
args: ["--model", "base", "{{MediaPath}}"],
timeoutSeconds: 45
}
]
}
}
}
}
```
### Provider-only with scope gating
```json5
{
tools: {
media: {
audio: {
enabled: true,
scope: {
default: "allow",
rules: [
{ action: "deny", match: { chatType: "group" } }
]
},
models: [
{ provider: "openai", model: "whisper-1" }
]
}
}
}
}
```
### Provider-only (Deepgram)
```json5
{
tools: {
media: {
audio: {
enabled: true,
models: [{ provider: "deepgram", model: "nova-3" }]
}
}
}
@@ -29,12 +77,17 @@ Requires `whisper` CLI installed:
```
## Notes & limits
- We dont ship a transcriber; you opt in with the Whisper CLI on your PATH.
- Size guard: inbound audio must be ≤5MB (matches the temp media store and transcript pipeline).
- Outbound caps: web send supports audio/voice up to 16MB (sent as a voice note with `ptt: true`).
- If transcription fails, we fall back to the original body/media note; replies still go through.
- Transcript is available to templates as `{{Transcript}}`; models get both the media path and a `Transcript:` block in the prompt when using command mode.
- 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`).
- Transcript is available to templates as `{{Transcript}}`.
- CLI stdout is capped (5MB); keep CLI output concise.
## Gotchas
- Scope rules use first-match wins. `chatType` is normalized to `direct`, `group`, or `room`.
- Ensure your CLI exits 0 and prints plain text; JSON needs to be massaged via `jq -r .text`.
- Keep timeouts reasonable (`timeoutSeconds`, default 45s) to avoid blocking the reply queue.
- Keep timeouts reasonable (`timeoutSeconds`, default 60s) to avoid blocking the reply queue.

View File

@@ -38,13 +38,23 @@ The WhatsApp channel runs via **Baileys Web**. This document captures the curren
- `{{MediaUrl}}` pseudo-URL for the inbound media.
- `{{MediaPath}}` local temp path written before running the command.
- When a per-session Docker sandbox is enabled, inbound media is copied into the sandbox workspace and `MediaPath`/`MediaUrl` are rewritten to a relative path like `media/inbound/<filename>`.
- Audio transcription (if configured via `tools.audio.transcription`) runs before templating and can replace `Body` with the transcript.
- Media understanding (if configured via `tools.media.*` or shared `tools.media.models`) runs before templating and can insert `[Image]`, `[Audio]`, and `[Video]` blocks into `Body`.
- Audio sets `{{Transcript}}` and uses the transcript for command parsing so slash commands still work.
- Video and image descriptions preserve any caption text for command parsing.
- By default only the first matching image/audio/video attachment is processed; set `tools.media.<cap>.attachments` to process multiple attachments.
## Limits & Errors
**Outbound send caps (WhatsApp web send)**
- Images: ~6MB cap after recompression.
- Audio/voice/video: 16MB cap; documents: 100MB cap.
- Oversize or unreadable media → clear error in logs and the reply is skipped.
**Media understanding caps (transcription/description)**
- Image default: 10MB (`tools.media.image.maxBytes`).
- Audio default: 20MB (`tools.media.audio.maxBytes`).
- Video default: 50MB (`tools.media.video.maxBytes`).
- Oversize media skips understanding, but replies still go through with the original body.
## Notes for Tests
- Cover send + reply flows for image/audio/document cases.
- Validate recompression for images (size bound) and voice-note flag for audio.

View File

@@ -0,0 +1,279 @@
---
summary: "Inbound image/audio/video understanding (optional) with provider + CLI fallbacks"
read_when:
- Designing or refactoring media understanding
- Tuning inbound audio/video/image preprocessing
---
# Media Understanding (Inbound) — 2026-01-17
Clawdbot can optionally **summarize inbound media** (image/audio/video) before the reply pipeline runs. This is **opt-in** and separate from the base attachment flow—if understanding is off, models still receive the original files/URLs as usual.
## Goals
- Optional: predigest inbound media into short text for faster routing + better command parsing.
- Preserve original media delivery to the model (always).
- Support **provider APIs** and **CLI fallbacks**.
- Allow multiple models with ordered fallback (error/size/timeout).
## Highlevel behavior
1) Collect inbound attachments (`MediaPaths`, `MediaUrls`, `MediaTypes`).
2) For each enabled capability (image/audio/video), select attachments per policy (default: **first**).
3) Choose the first eligible model entry (size + capability + auth).
4) If a model fails or the media is too large, **fall back to the next entry**.
5) On success:
- `Body` becomes `[Image]`, `[Audio]`, or `[Video]` block.
- Audio sets `{{Transcript}}`; command parsing uses caption text when present,
otherwise the transcript.
- Captions are preserved as `User text:` inside the block.
If understanding fails or is disabled, **the reply flow continues** with the original body + attachments.
## Config overview
`tools.media` supports **shared models** plus percapability overrides:
- `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 **percapability `models` list** (preferred before shared models)
- `attachments` policy (`mode`, `maxAttachments`, `prefer`)
- `scope` (optional gating by channel/chatType/session key)
- `tools.media.concurrency`: max concurrent capability runs (default **2**).
```json5
{
tools: {
media: {
models: [ /* shared list */ ],
image: { /* optional overrides */ },
audio: { /* optional overrides */ },
video: { /* optional overrides */ }
}
}
}
```
### Model entries
Each `models[]` entry can be **provider** or **CLI**:
```json5
{
type: "provider", // default if omitted
provider: "openai",
model: "gpt-5.2",
prompt: "Describe the image in <= 500 chars.",
maxChars: 500,
maxBytes: 10485760,
timeoutSeconds: 60,
capabilities: ["image"], // optional, used for multimodal entries
profile: "vision-profile",
preferredProfile: "vision-fallback"
}
```
```json5
{
type: "cli",
command: "gemini",
args: [
"-m",
"gemini-3-flash",
"--allowed-tools",
"read_file",
"Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters."
],
maxChars: 500,
maxBytes: 52428800,
timeoutSeconds: 120,
capabilities: ["video", "image"]
}
```
## Defaults and limits
Recommended defaults:
- `maxChars`: **500** for image/video (short, commandfriendly)
- `maxChars`: **unset** for audio (full transcript unless you set a limit)
- `maxBytes`:
- image: **10MB**
- audio: **20MB**
- video: **50MB**
Rules:
- If media exceeds `maxBytes`, that model is skipped and the **next model is tried**.
- If the model returns more than `maxChars`, output is trimmed.
- `prompt` defaults to simple “Describe the {media}.” plus the `maxChars` guidance (image/video only).
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
**active reply model** when its provider supports the capability.
## Capabilities (optional)
If you set `capabilities`, the entry only runs for those media types. For shared
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.
## Provider support matrix (Clawdbot integrations)
| Capability | Provider integration | Notes |
|------------|----------------------|-------|
| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. |
| Audio | OpenAI, Groq, Deepgram | Provider transcription (Whisper/Deepgram). |
| Video | Google (Gemini API) | Provider video understanding. |
## Recommended providers
**Image**
- Prefer your active model if it supports images.
- Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-5`, `google/gemini-3-pro-preview`.
**Audio**
- `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).
- CLI fallback: `gemini` CLI (supports `read_file` on video/audio).
## Attachment policy
Percapability `attachments` controls which attachments are processed:
- `mode`: `first` (default) or `all`
- `maxAttachments`: cap the number processed (default **1**)
- `prefer`: `first`, `last`, `path`, `url`
When `mode: "all"`, outputs are labeled `[Image 1/2]`, `[Audio 2/2]`, etc.
## Config examples
### 1) Shared models list + overrides
```json5
{
tools: {
media: {
models: [
{ provider: "openai", model: "gpt-5.2", capabilities: ["image"] },
{ provider: "google", model: "gemini-3-flash-preview", capabilities: ["image", "audio", "video"] },
{
type: "cli",
command: "gemini",
args: [
"-m",
"gemini-3-flash",
"--allowed-tools",
"read_file",
"Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters."
],
capabilities: ["image", "video"]
}
],
audio: {
attachments: { mode: "all", maxAttachments: 2 }
},
video: {
maxChars: 500
}
}
}
}
```
### 2) Audio + Video only (image off)
```json5
{
tools: {
media: {
audio: {
enabled: true,
models: [
{ provider: "openai", model: "whisper-1" },
{
type: "cli",
command: "whisper",
args: ["--model", "base", "{{MediaPath}}"]
}
]
},
video: {
enabled: true,
maxChars: 500,
models: [
{ provider: "google", model: "gemini-3-flash-preview" },
{
type: "cli",
command: "gemini",
args: [
"-m",
"gemini-3-flash",
"--allowed-tools",
"read_file",
"Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters."
]
}
]
}
}
}
}
```
### 3) Optional image understanding
```json5
{
tools: {
media: {
image: {
enabled: true,
maxBytes: 10485760,
maxChars: 500,
models: [
{ provider: "openai", model: "gpt-5.2" },
{ provider: "anthropic", model: "claude-opus-4-5" },
{
type: "cli",
command: "gemini",
args: [
"-m",
"gemini-3-flash",
"--allowed-tools",
"read_file",
"Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters."
]
}
]
}
}
}
}
```
### 4) Multimodal single entry (explicit capabilities)
```json5
{
tools: {
media: {
image: { models: [{ provider: "google", model: "gemini-3-pro-preview", capabilities: ["image", "video", "audio"] }] },
audio: { models: [{ provider: "google", model: "gemini-3-pro-preview", capabilities: ["image", "video", "audio"] }] },
video: { models: [{ provider: "google", model: "gemini-3-pro-preview", capabilities: ["image", "video", "audio"] }] }
}
}
}
```
## Status output
When media understanding runs, `/status` includes a short summary line:
```
📎 Media: image ok (openai/gpt-5.2) · audio skipped (maxBytes)
```
This shows percapability outcomes and the chosen provider/model when applicable.
## Notes
- Understanding is **besteffort**. Errors do not block replies.
- Attachments are still passed to models even when understanding is disabled.
- Use `scope` to limit where understanding runs (e.g. only DMs).
## Related docs
- [Configuration](/gateway/configuration)
- [Image & Media Support](/nodes/images)

76
docs/perplexity.md Normal file
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 Perplexitys 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.

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:
@@ -53,21 +58,32 @@ register:
- Optional config validation
Plugins run **inprocess** with the Gateway, so treat them as trusted code.
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
## Discovery & precedence
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 +139,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.
@@ -157,9 +191,11 @@ export default {
```bash
clawdbot plugins list
clawdbot plugins info <id>
clawdbot plugins install <path> # add a local file/dir to plugins.load.paths
clawdbot plugins install <path> # copy a local file/dir into ~/.clawdbot/extensions/<id>
clawdbot plugins install ./extensions/voice-call # relative path ok
clawdbot plugins install ./plugin.tgz # install from a local tarball
clawdbot plugins install ./plugin.tgz # install from a local tarball
clawdbot plugins install ./plugin.zip # install from a local zip
clawdbot plugins install -l ./extensions/voice-call # link (no copy) for dev
clawdbot plugins install @clawdbot/voice-call # install from npm
clawdbot plugins update <id>
clawdbot plugins update --all
@@ -179,6 +215,27 @@ Plugins export either:
- A function: `(api) => { ... }`
- An object: `{ id, name, configSchema, register(api) { ... } }`
## Plugin hooks
Plugins can ship hooks and register them at runtime. This lets a plugin bundle
event-driven automation without a separate hook pack install.
### Example
```
import { registerPluginHooksFromDir } from "clawdbot/plugin-sdk";
export default function register(api) {
registerPluginHooksFromDir(api, "./hooks");
}
```
Notes:
- Hook directories follow the normal hook structure (`HOOK.md` + `handler.ts`).
- Hook eligibility rules still apply (OS/bins/env/config requirements).
- Plugin-managed hooks show up in `clawdbot hooks list` with `plugin:<id>`.
- You cannot enable/disable plugin-managed hooks via `clawdbot hooks`; enable/disable the plugin instead.
## Provider plugins (model auth)
Plugins can register **model provider auth** flows so users can run OAuth or
@@ -344,24 +401,9 @@ export default function (api) {
Load the plugin (extensions dir or `plugins.load.paths`), restart the gateway,
then configure `channels.<id>` in your config.
### Register a tool
### Agent tools
```ts
import { Type } from "@sinclair/typebox";
export default function (api) {
api.registerTool({
name: "my_tool",
description: "Do a thing",
parameters: Type.Object({
input: Type.String(),
}),
async execute(_id, params) {
return { content: [{ type: "text", text: params.input }] };
},
});
}
```
See the dedicated guide: [Plugin agent tools](/plugins/agent-tools).
### Register a gateway RPC method

View File

@@ -0,0 +1,94 @@
---
summary: "Write agent tools in a plugin (schemas, optional tools, allowlists)"
read_when:
- You want to add a new agent tool in a plugin
- You need to make a tool opt-in via allowlists
---
# Plugin agent tools
Clawdbot plugins can register **agent tools** (JSONschema functions) that are exposed
to the LLM during agent runs. Tools can be **required** (always available) or
**optional** (optin).
Agent tools are configured under `tools` in the main config, or peragent under
`agents.list[].tools`. The allowlist/denylist policy controls which tools the agent
can call.
## Basic tool
```ts
import { Type } from "@sinclair/typebox";
export default function (api) {
api.registerTool({
name: "my_tool",
description: "Do a thing",
parameters: Type.Object({
input: Type.String(),
}),
async execute(_id, params) {
return { content: [{ type: "text", text: params.input }] };
},
});
}
```
## Optional tool (optin)
Optional tools are **never** autoenabled. Users must add them to an agent
allowlist.
```ts
export default function (api) {
api.registerTool(
{
name: "workflow_tool",
description: "Run a local workflow",
parameters: {
type: "object",
properties: {
pipeline: { type: "string" },
},
required: ["pipeline"],
},
async execute(_id, params) {
return { content: [{ type: "text", text: params.pipeline }] };
},
},
{ optional: true },
);
}
```
Enable optional tools in `agents.list[].tools.allow` (or global `tools.allow`):
```json5
{
agents: {
list: [
{
id: "main",
tools: {
allow: [
"workflow_tool", // specific tool name
"workflow", // plugin id (enables all tools from that plugin)
"group:plugins" // all plugin tools
]
}
}
]
}
}
```
Other config knobs that affect tool availability:
- `tools.profile` / `agents.list[].tools.profile` (base allowlist)
- `tools.byProvider` / `agents.list[].tools.byProvider` (providerspecific allow/deny)
- `tools.sandbox.tools.*` (sandbox tool policy when sandboxed)
## Rules + tips
- Tool names must **not** clash with core tool names; conflicting tools are skipped.
- Plugin ids used in allowlists must not clash with core tool names.
- Prefer `optional: true` for tools that trigger side effects or require extra
binaries/credentials.

View File

@@ -65,7 +65,7 @@ Channel config lives under `channels.zalouser` (not `plugins.entries.*`):
clawdbot channels login --channel zalouser
clawdbot channels logout --channel zalouser
clawdbot channels status --probe
clawdbot message send --channel zalouser --to <threadId> --message "Hello from Clawdbot"
clawdbot message send --channel zalouser --target <threadId> --message "Hello from Clawdbot"
clawdbot directory peers list --channel zalouser --query "name"
```

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).

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.

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).

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.

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)

Some files were not shown because too many files have changed in this diff Show More