Compare commits

...

620 Commits

Author SHA1 Message Date
Peter Steinberger
5411a845c1 fix: note CLI quick reference change (#953) (thanks @roshanasingh4) 2026-01-15 16:55:33 +00:00
Roshan Singh
4766dcb02c fix(system-prompt): add CLI quick reference to prevent invented commands 2026-01-15 16:51:03 +00:00
Peter Steinberger
2d1ae0916f chore: start 2026.1.15 (unreleased) 2026-01-15 16:47:19 +00:00
Peter Steinberger
f25fe032c4 fix(macos): codesign dmg before notarize 2026-01-15 11:21:25 +00:00
Peter Steinberger
0a5b1fbcd1 fix(browser): handle extension relay page selection 2026-01-15 11:16:53 +00:00
Peter Steinberger
8daab932a2 chore(macos): prep Sparkle release 2026.1.14-1 2026-01-15 11:15:41 +00:00
Peter Steinberger
3b7b45c29e chore: align plugin versions 2026-01-15 10:51:16 +00:00
Peter Steinberger
3a446dd400 style: oxfmt 2026-01-15 10:48:04 +00:00
Peter Steinberger
876efb11e7 docs(changelog): note chrome relay fixes 2026-01-15 10:46:48 +00:00
Peter Steinberger
c7615aa559 fix(browser): improve chrome relay tab selection 2026-01-15 10:44:11 +00:00
Peter Steinberger
6042485367 fix(browser): default chrome profile to host 2026-01-15 10:43:39 +00:00
Peter Steinberger
725c340257 docs(gog): prefer body-file for multi-line email 2026-01-15 10:43:07 +00:00
Peter Steinberger
9abd7ceda2 test: cover dynamic command arg menus 2026-01-15 10:25:35 +00:00
Peter Steinberger
cf81cb942b fix: guard Slack cmdarg decode 2026-01-15 10:25:35 +00:00
Peter Steinberger
9097ef90b7 docs(browser): clarify chrome extension profile usage 2026-01-15 10:22:29 +00:00
Peter Steinberger
4f1a4ab072 feat(browser): add snapshot refs=aria mode 2026-01-15 10:22:29 +00:00
Peter Steinberger
0facc63019 fix(skills): improve summarize selection 2026-01-15 10:14:59 +00:00
Peter Steinberger
95735c3978 chore: bump version to 2026.1.14-1 2026-01-15 10:00:43 +00:00
Peter Steinberger
d5d33d4848 fix(browser): persist role refs per targetId 2026-01-15 09:56:24 +00:00
Peter Steinberger
84e9401d53 fix(ci): repair format + android tests 2026-01-15 09:50:18 +00:00
Peter Steinberger
84bee3d7d0 Merge pull request #936 from clawdbot/shadow/dynamic-command-args
Commands: add dynamic arg menus
2026-01-15 09:44:08 +00:00
Peter Steinberger
6ff3c39989 test: add command arg parsing coverage (#936) (thanks @thewilloftheshadow) 2026-01-15 09:38:21 +00:00
Peter Steinberger
5c8239a2a9 docs(agents): note plugin-only deps 2026-01-15 09:38:01 +00:00
Peter Steinberger
f9170c5d02 fix(browser): keep tab stable across snapshot and act 2026-01-15 09:36:48 +00:00
Peter Steinberger
6ccb19e274 chore: regen protocol swift models (#936) (thanks @thewilloftheshadow) 2026-01-15 09:33:31 +00:00
Peter Steinberger
52f876bfbc fix: native command arg menus follow-ups (#936) (thanks @thewilloftheshadow) 2026-01-15 09:33:31 +00:00
Peter Steinberger
415ff7f483 chore(workspace): include extensions in workspace 2026-01-15 09:31:18 +00:00
Shadow
74bc5bfd7c Commands: add dynamic arg menus 2026-01-15 09:31:16 +00:00
Peter Steinberger
7e1e7ba2d8 fix(agents): skip thinking tags in code spans 2026-01-15 09:23:56 +00:00
Peter Steinberger
aac5b4673f docs: clarify Brave Search API key plan 2026-01-15 09:17:24 +00:00
Peter Steinberger
c86b257d38 docs(tools): hint chrome extension in browser tool prompt 2026-01-15 09:13:36 +00:00
Peter Steinberger
4291d56e0b chore: format + fix telegram thread ids 2026-01-15 09:13:19 +00:00
Peter Steinberger
5599603bdb feat: offer local plugin install in git checkouts 2026-01-15 09:07:48 +00:00
Peter Steinberger
e0f69a2294 docs: align matrix changelog to first release 2026-01-15 09:07:48 +00:00
Peter Steinberger
510915a801 fix(onboarding): wait for gateway before health 2026-01-15 09:07:30 +00:00
Peter Steinberger
609d029e20 docs(changelog): regroup 2026.1.14 2026-01-15 09:07:30 +00:00
Peter Steinberger
4275ed68a2 fix(browser): default to chrome extension takeover 2026-01-15 09:02:42 +00:00
Peter Steinberger
75d2785d20 fix(browser): make extension relay zero-config 2026-01-15 09:02:42 +00:00
Peter Steinberger
b77b47bb98 fix: use canonical main session keys in apps 2026-01-15 08:59:05 +00:00
Peter Steinberger
5f87f7bbf5 fix: macos wizard auth bootstrap 2026-01-15 08:47:45 +00:00
Peter Steinberger
1afdb850f3 chore: add matrix changelog 2026-01-15 08:40:37 +00:00
Peter Steinberger
725a6b71dc feat: add matrix channel plugin 2026-01-15 08:40:37 +00:00
Peter Steinberger
f4bb5b381d chore: note issue/pr url requirement 2026-01-15 08:39:53 +00:00
Peter Steinberger
11d4fc101e fix: sync cron run history selection in control ui 2026-01-15 08:38:21 +00:00
Peter Steinberger
4e6fb47a3f fix(changelog): merge 2026.1.14 sections 2026-01-15 08:35:25 +00:00
Peter Steinberger
a39bb4310c fix(onboarding): daemon progress + web search setup 2026-01-15 08:31:02 +00:00
Peter Steinberger
f1ac18933c fix(cli): daemon output + health colors 2026-01-15 08:31:02 +00:00
Peter Steinberger
2bae4d2dba fix(ui): override token from URL 2026-01-15 08:31:02 +00:00
Peter Steinberger
1797233989 fix(tui): surface model errors 2026-01-15 08:31:02 +00:00
Peter Steinberger
3171781d58 fix: show raw any-map entries in config UI 2026-01-15 08:24:23 +00:00
Peter Steinberger
35ddd8db5e docs: add compaction alias page 2026-01-15 08:15:33 +00:00
Peter Steinberger
c269d5f258 fix: isolate Slack thread sessions (#758) 2026-01-15 08:11:03 +00:00
Peter Steinberger
627cc1f04b fix(changelog): keep 2026.1.14 unreleased 2026-01-15 08:09:32 +00:00
Peter Steinberger
53d0bf653a fix: canonicalize main session keys 2026-01-15 08:05:41 +00:00
Peter Steinberger
a5a9788b20 fix: imessage dm replies and error details (#935) 2026-01-15 08:05:10 +00:00
Peter Steinberger
9c04a79c0a fix(ui): move docs link into nav 2026-01-15 08:01:34 +00:00
Peter Steinberger
f3519d895c fix: normalize slack channel types for sessions 2026-01-15 08:00:20 +00:00
Peter Steinberger
0ac5480034 fix(onboarding): move web search hint to end 2026-01-15 07:57:18 +00:00
Peter Steinberger
5cd48bbe7d docs: redirect /sandboxing to /gateway/sandboxing 2026-01-15 07:55:49 +00:00
Peter Steinberger
8dacafce7f fix: harden whatsapp command auth 2026-01-15 07:54:39 +00:00
Peter Steinberger
8c4b8f2c38 chore: ship dist/security in npm pack 2026-01-15 07:54:12 +00:00
Peter Steinberger
fee8bcc50b docs: hide experiments nav group 2026-01-15 07:53:43 +00:00
Peter Steinberger
cb9f4a0485 docs: link multi-gateway guide in sidebar 2026-01-15 07:49:52 +00:00
Peter Steinberger
79f340a410 chore: prep 2026.1.14 npm release 2026-01-15 07:47:18 +00:00
Peter Steinberger
081e5ef572 fix(tools): enable web_fetch by default 2026-01-15 07:42:07 +00:00
Peter Steinberger
f5577ad78e docs: add multi-gateway guide 2026-01-15 07:29:48 +00:00
Peter Steinberger
b2b1c1de9c Merge pull request #846 from vrknetha/feature/voice-call-plivo
feat(voice-call): add Plivo provider (no SDK dependency)
2026-01-15 07:28:37 +00:00
Peter Steinberger
3e6917c8ae fix: restore notify init + Plivo numbers (#846) (thanks @vrknetha) 2026-01-15 07:28:14 +00:00
Peter Steinberger
564b6aa2aa docs: clarify Telegram IPv6 DNS troubleshooting 2026-01-15 07:23:28 +00:00
Peter Steinberger
eb3eb3c39e test: clear config overrides in unit tests 2026-01-15 07:23:00 +00:00
vrknetha
2579609922 Voice Call: fix Plivo webhook method typing 2026-01-15 07:21:40 +00:00
vrknetha
946b0229e8 Voice Call: add Plivo provider 2026-01-15 07:21:40 +00:00
Peter Steinberger
0a1eeedc10 fix: unblock control commands during active runs 2026-01-15 07:08:48 +00:00
Peter Steinberger
5a58feefdc chore: enforce plugin version sync 2026-01-15 07:08:33 +00:00
Peter Steinberger
48b15bd099 chore: sync plugin versions 2026-01-15 07:07:48 +00:00
Peter Steinberger
1c96477686 fix: harden session cache + heartbeat restore
Co-authored-by: Ronak Guliani <ronak-guliani@users.noreply.github.com>
2026-01-15 07:07:12 +00:00
Peter Steinberger
04ebf8d67a docs: note child process bridge helper 2026-01-15 06:59:02 +00:00
Peter Steinberger
82d83a9d54 Merge pull request #852 from mneves75/fix/cron-settings-channel-param
fix(macos): use channel param in CronSettings+Testing
2026-01-15 06:38:33 +00:00
Peter Steinberger
66de8aedeb fix: satisfy swiftformat lint (#852) (thanks @mneves75) 2026-01-15 06:38:15 +00:00
Peter Steinberger
154b8e3e0e fix: bridge respawned child signals (#933) (thanks @roshanasingh4)
Co-authored-by: Roshan Singh <roshanasingh4@users.noreply.github.com>
2026-01-15 06:37:27 +00:00
Roshan Singh
d9f2ee40f7 Fix entry respawn signal forwarding
Fixes #931
2026-01-15 06:33:28 +00:00
Peter Steinberger
6bcb89cf38 Merge pull request #882 from chrisrodz/feat/whatsapp-send-read-receipts-option
feat(whatsapp): add sendReadReceipts config option
2026-01-15 06:27:35 +00:00
Peter Steinberger
3475ecde6f docs: expand Zalo overview + add longmaba
Co-authored-by: Long <longmaba@gmail.com>
2026-01-15 06:26:55 +00:00
Peter Steinberger
728cd5e974 fix: document WhatsApp read receipts toggle (#882) (thanks @chrisrodz) 2026-01-15 06:22:33 +00:00
Christian A. Rodriguez
0fab14ad9d style: fix CronModels.swift indentation
- Move CronPayload enum to top level (was incorrectly nested)
- Fix all tabs to spaces
- Align case statements consistently
2026-01-15 06:22:09 +00:00
Christian A. Rodriguez
d23febcd6a style: fix tabs in CronModels.swift 2026-01-15 06:22:09 +00:00
Christian A. Rodriguez
d763926364 style: fix biome formatting 2026-01-15 06:22:09 +00:00
Christian A. Rodriguez
7a683a4b62 feat(whatsapp): add sendReadReceipts config option
Add option to disable automatic read receipts for WhatsApp messages.
When set to false, Clawdbot will not mark messages as read (blue ticks).

Closes #344

Changes:
- Add sendReadReceipts to WhatsAppConfig and WhatsAppAccountConfig types
- Add sendReadReceipts to zod schemas for validation
- Add sendReadReceipts to ResolvedWhatsAppAccount with fallback chain
- Pass sendReadReceipts through to monitorWebInbox
- Gate sock.readMessages() call based on config option

Default behavior (true) is preserved - only explicitly setting false
will disable read receipts.
2026-01-15 06:22:09 +00:00
Peter Steinberger
44a237b637 feat(browser): copy extension path to clipboard 2026-01-15 06:19:47 +00:00
Peter Steinberger
375304bf13 test: remove legacy config migration test 2026-01-15 06:18:44 +00:00
Peter Steinberger
d59aab7fd3 chore: drop Clawdis legacy references 2026-01-15 06:18:44 +00:00
Peter Steinberger
60748b1370 docs: refresh clawtributors list 2026-01-15 06:17:02 +00:00
Peter Steinberger
624cb33534 feat: expand Telegram allowFrom guidance
Co-authored-by: Christoph Nakazawa <cpojer@users.noreply.github.com>
2026-01-15 06:15:06 +00:00
Peter Steinberger
9603eff79a Merge pull request #909 from roshanasingh4/fix/macos-wizard-gateway-logdir
macOS: fix wizard hang when /tmp/clawdbot is missing
2026-01-15 06:13:51 +00:00
Peter Steinberger
e9d6dec2f4 fix: make log dir overrideable in tests (#909) (thanks @roshanasingh4) 2026-01-15 06:13:20 +00:00
Peter Steinberger
0cbfea79fa docs(cli): add per-command CLI pages 2026-01-15 06:13:10 +00:00
Roshan Singh
aaae327563 macOS: ensure /tmp/clawdbot exists for launchd logs 2026-01-15 06:10:05 +00:00
Peter Steinberger
5b23f847d6 docs: clarify Telegram allowFrom lookup 2026-01-15 06:09:19 +00:00
Peter Steinberger
5a55789bc1 Merge pull request #859 from CashWilliams/main
fix: Make timezone and 24 hour clock explicit in system prompt
2026-01-15 06:08:51 +00:00
Peter Steinberger
9c396f6331 fix: note timezone + 24h prompt update (#859) (thanks @CashWilliams) 2026-01-15 06:08:43 +00:00
Cash Williams
51e871f9e5 Make timezone and 24 hour clock explicit in system prompt 2026-01-15 06:08:43 +00:00
Peter Steinberger
47634c294d style: format pi embedded utils 2026-01-15 06:08:17 +00:00
Peter Steinberger
9c1122def0 test: fix Windows security audit perms 2026-01-15 06:04:39 +00:00
Peter Steinberger
2bd9e84851 fix(agents): strip tool leak text (#905)
Thanks @erikpr1994.

Co-authored-by: Erik Pastor Rios <erikpastorrios1994@gmail.com>
2026-01-15 05:58:02 +00:00
Erik
5c2eedc340 test: add thought tag stripping test case 2026-01-15 05:58:02 +00:00
Erik
3b7d103758 fix(agent): strip thinking tags from text content 2026-01-15 05:58:02 +00:00
Erik
8146c43aa3 fix(agents): strip leaked tool call text from assistant messages
When replaying conversation history to Gemini, tool calls without
thought_signature are downgraded to text blocks like [Tool Call: ...].
This leaked internal technical info into user-facing chat messages.

Added stripDowngradedToolCallText filter alongside existing Minimax
filter to remove these text representations before extraction.
2026-01-15 05:58:02 +00:00
Peter Steinberger
df386927ce docs: prune internal notes and doc aliases 2026-01-15 05:55:28 +00:00
Peter Steinberger
0c18b2c442 feat(status): add security audit section 2026-01-15 05:52:01 +00:00
Peter Steinberger
1a7b7eba59 docs(lore): mention Warelay + Clawdis 2026-01-15 05:52:01 +00:00
Peter Steinberger
54fb59b8f3 feat: extend Telegram dock commands and config hashing (#929)
Thanks @grp06.

Co-authored-by: George Pickett <gpickett00@gmail.com>
2026-01-15 05:49:28 +00:00
Peter Steinberger
a2f0d335f4 docs: add Zalo channel nav + plugin note 2026-01-15 05:36:31 +00:00
Peter Steinberger
2e70c3ceab fix: return setup hint when web_search lacks key 2026-01-15 05:35:22 +00:00
Peter Steinberger
ca1902fb4e feat(security): expand audit and safe --fix 2026-01-15 05:31:43 +00:00
Peter Steinberger
f11a89031b chore: format reply dispatcher 2026-01-15 05:31:03 +00:00
Peter Steinberger
3c22fab679 docs(cli): add dedicated browser command page 2026-01-15 05:30:03 +00:00
Peter Steinberger
ad46e95df9 test: relax browser act contract timeout on Windows 2026-01-15 05:29:37 +00:00
Peter Steinberger
7d4f2d9aed Merge pull request #928 from sebslight/feature/response-prefix-template-variables
feat: add dynamic template variables to messages.responsePrefix
2026-01-15 05:27:20 +00:00
Peter Steinberger
738b3592cd fix: remove conflict marker in google helper (#875) 2026-01-15 05:25:45 +00:00
Peter Steinberger
cd2af64860 fix: cap tool call IDs for OpenAI/OpenRouter (#875) (thanks @j1philli) 2026-01-15 05:25:45 +00:00
Josh Phillips
04f1e767b2 Fix OpenAI tool_call id length for OpenRouter 2026-01-15 05:25:45 +00:00
Peter Steinberger
1c7ac2a6ab test: fix windows-only expectations 2026-01-15 05:25:08 +00:00
Peter Steinberger
77cf40da87 feat: profile-aware gateway service names (#671)
Thanks @bjesuiter.

Co-authored-by: Benjamin Jesuiter <bjesuiter@gmail.com>
2026-01-15 05:23:41 +00:00
Peter Steinberger
1fe8df85cb docs: clarify extension tab attachment 2026-01-15 05:20:56 +00:00
Peter Steinberger
139f80a291 chore: format sources and update protocol outputs 2026-01-15 05:17:19 +00:00
Peter Steinberger
2d066b8715 docs: explain sandboxed browser control 2026-01-15 05:17:12 +00:00
Peter Steinberger
757243993c fix: stabilize gateway config tests + tool schema 2026-01-15 05:16:28 +00:00
Peter Steinberger
4fb114dcb3 feat: improve extension options diagnostics 2026-01-15 05:15:33 +00:00
Peter Steinberger
612cdac4c3 docs: clarify when browser serve is needed 2026-01-15 05:15:33 +00:00
Peter Steinberger
57c66fe813 fix: clean up onboarding + channel selection types 2026-01-15 05:12:33 +00:00
Peter Steinberger
9c02ea9098 feat: polish chrome extension UX 2026-01-15 05:11:03 +00:00
Peter Steinberger
042b65dfcc feat: add web tools config to configure 2026-01-15 05:08:56 +00:00
Peter Steinberger
f6a72ef3c2 feat: improve browser extension install output 2026-01-15 05:05:27 +00:00
Peter Steinberger
568cc368ae feat: add onboarding plugin install flow 2026-01-15 05:04:09 +00:00
Peter Steinberger
5abe3c2145 feat: add plugin HTTP hooks + Zalo plugin 2026-01-15 05:04:09 +00:00
Peter Steinberger
0e76d21f11 docs(security): mention audit --fix 2026-01-15 05:03:13 +00:00
Peter Steinberger
5e8693bc42 chore: bump pi packages to 0.45.7 2026-01-15 04:55:40 +00:00
Peter Steinberger
ef78b198cb feat: add Chrome extension browser relay 2026-01-15 04:52:28 +00:00
Peter Steinberger
5fdaef3646 fix: downgrade unsigned gemini thinking 2026-01-15 04:51:21 +00:00
Peter Steinberger
fa4670c5fe feat: improve agent auth guidance 2026-01-15 04:51:21 +00:00
Peter Steinberger
c4402a1ce5 docs: clarify agent auth + sandboxed skills 2026-01-15 04:51:03 +00:00
Peter Steinberger
edd8c613d6 feat(security): add audit --fix 2026-01-15 04:50:06 +00:00
Peter Steinberger
0a7f5bf6a5 test(gateway): fix config.patch baseHash fixtures 2026-01-15 04:50:06 +00:00
Peter Steinberger
eb3e865f15 fix(build): restore tool policy typing 2026-01-15 04:50:06 +00:00
Roshan Singh
1baa55c145 Structured subagent announce output + include run outcome (#835)
* docs: clarify subagent announce status

* Make subagent announce structured and include run outcome

* fix: stabilize sub-agent announce status (#835) (thanks @roshanasingh4)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-15 04:48:07 +00:00
Sebastian
6ef3837e73 Remove debug logging for responsePrefix template resolution 2026-01-14 23:36:47 -05:00
Sebastian
e7167e35ed debug: use console.log instead of logVerbose for always-visible logging 2026-01-14 23:29:17 -05:00
Peter Steinberger
2e0325e3bf feat: add web search hint to onboarding 2026-01-15 04:25:19 +00:00
Sebastian
56b3b44342 debug: add responsePrefix template logging 2026-01-14 23:23:21 -05:00
Sebastian
113eea5047 fix: mutate prefixContext object instead of reassigning for closure correctness 2026-01-14 23:20:19 -05:00
Peter Steinberger
dcadaad228 docs: add web search note to quick start 2026-01-15 04:16:09 +00:00
Sebastian
7b04e6ac42 debug: add prefix template resolution logging 2026-01-14 23:15:46 -05:00
Peter Steinberger
1533868680 docs: add web search FAQ 2026-01-15 04:15:24 +00:00
Peter Steinberger
f275cc180b feat: add web tools 2026-01-15 04:07:40 +00:00
Peter Steinberger
31d3aef8d6 fix: prevent config clobbering 2026-01-15 04:06:11 +00:00
Peter Steinberger
bd467ff765 chore: mark 2026.1.14 as unreleased 2026-01-15 04:05:18 +00:00
Sebastian
d0a4cce41e feat: add dynamic template variables to messages.responsePrefix (#923)
Adds support for template variables in `messages.responsePrefix` that
resolve dynamically at runtime with the actual model used (including
after fallback).

Supported variables (case-insensitive):
- {model} - short model name (e.g., "claude-opus-4-5", "gpt-4o")
- {modelFull} - full model identifier (e.g., "anthropic/claude-opus-4-5")
- {provider} - provider name (e.g., "anthropic", "openai")
- {thinkingLevel} or {think} - thinking level ("high", "low", "off")
- {identity.name} or {identityName} - agent identity name

Example: "[{model} | think:{thinkingLevel}]" → "[claude-opus-4-5 | think:high]"

Variables show the actual model used after fallback, not the intended
model. Unresolved variables remain as literal text.

Implementation:
- New module: src/auto-reply/reply/response-prefix-template.ts
- Template interpolation in normalize-reply.ts via context provider
- onModelSelected callback in agent-runner-execution.ts
- Updated all 6 provider message handlers (web, signal, discord,
  telegram, slack, imessage)
- 27 unit tests covering all variables and edge cases
- Documentation in docs/gateway/configuration.md and JSDoc

Fixes #923
2026-01-14 23:05:08 -05:00
Peter Steinberger
429f973280 test: cover browser snapshot labels and efficient mode 2026-01-15 04:04:30 +00:00
Peter Steinberger
6320f739d4 refactor: centralize whatsapp auth detection 2026-01-15 04:01:06 +00:00
Peter Steinberger
1732932c57 fix: unblock launchctl stub on windows 2026-01-15 03:58:32 +00:00
Peter Steinberger
574b6ab5b1 docs: document provider tool policies 2026-01-15 03:55:20 +00:00
Peter Steinberger
1c737f88fe test: cover provider tool policies 2026-01-15 03:55:20 +00:00
Peter Steinberger
fa8d9b9189 feat: add provider-specific tool policies 2026-01-15 03:55:20 +00:00
Peter Steinberger
512dbedee3 chore: bump Peekaboo submodule 2026-01-15 03:54:06 +00:00
Peter Steinberger
db21c2d397 docs: clarify group sandbox folder allowlist 2026-01-15 03:52:57 +00:00
Peter Steinberger
1078d178d7 fix: doctor ack reaction migration (#927)
Thanks @grp06.

Co-authored-by: George Pickett <gpickett00@gmail.com>
2026-01-15 03:51:55 +00:00
Peter Steinberger
a6e780b2f6 feat: add browser snapshot modes 2026-01-15 03:50:57 +00:00
Peter Steinberger
4e48d0a431 docs: document personal DMs vs public groups 2026-01-15 03:48:14 +00:00
Peter Steinberger
c289a88f50 fix(security): force hono 4.11.4 2026-01-15 03:40:02 +00:00
Peter Steinberger
4ce495cb2c Merge pull request #849 from ndraiman/fix/cli-launchd-enable-before-bootstrap
fix(daemon): enable launchd service before bootstrap
2026-01-15 03:36:14 +00:00
Peter Steinberger
dfea2991c9 fix(daemon): clear launchd disabled state before bootstrap (#849) (thanks @ndraiman) 2026-01-15 03:35:24 +00:00
Peter Steinberger
6f5fc2276a chore(packaging): align workspace patch version 2026-01-15 03:33:00 +00:00
Netanel Draiman
d51a9ebb0e fix(daemon): enable launchd service before bootstrap
When a launchd service is uninstalled via bootout, macOS marks it as
disabled in /var/db/com.apple.xpc.launchd/disabled.*.plist. This persists
across reboots and plist recreation. Future bootstrap calls fail with
error 5 (I/O error) because the service is still marked disabled.

Fix: Call 'launchctl enable' before 'launchctl bootstrap' to clear the
disabled state, matching the fix already applied to the macOS Swift app
in GatewayLaunchAgentManager.swift.

Related: #306
2026-01-15 03:31:52 +00:00
Peter Steinberger
536d3d76a3 Merge pull request #925 from grp06/fix/console-eio
Logging: tolerate EIO console writes (AI)
2026-01-15 03:28:56 +00:00
Peter Steinberger
5c52dbf661 style: oxfmt fixes (#925) (thanks @grp06) 2026-01-15 03:22:54 +00:00
George Pickett
8f797f213e Logging: tolerate EIO console writes 2026-01-15 03:20:48 +00:00
Peter Steinberger
ed68f378d7 Merge pull request #227 from Hyaxia/detect-secrets
add detect-secrets CI job + baseline guidance
2026-01-15 03:17:14 +00:00
Peter Steinberger
7e8de907f8 fix: note detect-secrets CI scan (#227) (thanks @Hyaxia) 2026-01-15 03:16:41 +00:00
hyaxia
f3c9252840 Security: add detect-secrets scan 2026-01-15 03:14:43 +00:00
Peter Steinberger
da9e27f466 docs: update changelog for telegram aggregation 2026-01-15 03:05:46 +00:00
Peter Steinberger
aa74e28112 fix(telegram): aggregate split inbound messages 2026-01-15 03:04:59 +00:00
Peter Steinberger
e569f15631 fix: scrub tuple items schemas for Gemini tools (#926) — thanks @grp06
Co-authored-by: George Pickett <gpickett00@gmail.com>
2026-01-15 02:59:35 +00:00
Peter Steinberger
eaace34233 fix: restore docker binds and PATH in sandbox exec (#873)
Thanks @akonyer.

Co-authored-by: Aaron Konyer <aaronk@gomodular.ca>
2026-01-15 02:58:20 +00:00
Peter Steinberger
7a839e7eb6 docs: add messaging channel guide 2026-01-15 02:50:03 +00:00
Peter Steinberger
1b24b6a02b fix: add plugin runtime registry 2026-01-15 02:44:45 +00:00
Peter Steinberger
2b4a68e276 feat: load channel plugins 2026-01-15 02:42:44 +00:00
Peter Steinberger
b1e3d79eaa docs: clarify Claude CLI auth mode 2026-01-15 02:35:20 +00:00
Peter Steinberger
2fb2035dbf fix: normalize Claude CLI auth mode to oauth (#855)
Thanks @sebslight.

Co-authored-by: Sebastian <sebslight@gmail.com>
2026-01-15 02:29:43 +00:00
Peter Steinberger
3c51290e0d Merge pull request #850 from evalexpr/fix/slack-top-level-require-mention
fix(slack): respect top-level requireMention config
2026-01-15 02:28:25 +00:00
Peter Steinberger
765196d5c3 style(slack): satisfy oxfmt (#850) 2026-01-15 02:23:22 +00:00
Peter Steinberger
12c2d37e62 docs: document DM history limits (#883)
Thanks @pkrmf.

Co-authored-by: Marc Terns <tenxurz@gmail.com>
2026-01-15 02:22:29 +00:00
Peter Steinberger
151a551be9 fix(slack): honor channels.slack.requireMention default (#850) (thanks @evalexpr) 2026-01-15 02:19:24 +00:00
Jonathan Wilkins
09ce6ff99e fix(slack): respect top-level requireMention config
The `channels.slack.requireMention` setting was defined in the schema
but never passed to `resolveSlackChannelConfig()`, which always
defaulted to `true`. This meant setting `requireMention: false` at the
top level had no effect—channels still required mentions.

Pass `slackCfg.requireMention` as `defaultRequireMention` to the
resolver and use it as the fallback instead of hardcoded `true`.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 02:17:27 +00:00
Peter Steinberger
6ffd7111a6 fix: add TUI status spinner 2026-01-15 02:11:12 +00:00
Peter Steinberger
7904a14af1 fix: render TUI pickers as overlays 2026-01-15 01:59:05 +00:00
Peter Steinberger
1b79730db8 style: apply oxfmt fixes 2026-01-15 01:53:14 +00:00
Peter Steinberger
ad8799522c feat(config): gate channel config writes 2026-01-15 01:41:15 +00:00
Peter Steinberger
f65668cb5f fix: suppress raw API error payloads (#924) (thanks @grp06)
Co-authored-by: George Pickett <gpickett00@gmail.com>
2026-01-15 01:34:19 +00:00
George Pickett
393d21d86c Format: fix report + telegram formatting 2026-01-15 01:27:16 +00:00
George Pickett
232c512502 Format: apply oxfmt fixes 2026-01-15 01:27:16 +00:00
George Pickett
8c1e6a82b2 Tests: add Gemini thoughtSignature for tool-call ids 2026-01-15 01:27:16 +00:00
George Pickett
2d54efe851 Embedded runner: suppress raw API error payloads (#919) 2026-01-15 01:27:16 +00:00
Peter Steinberger
c2a4f256c8 feat: add security audit + onboarding checkpoint 2026-01-15 01:25:11 +00:00
Peter Steinberger
c91c85532a docs: add context concept page 2026-01-15 01:12:59 +00:00
Peter Steinberger
326d4049da fix(telegram): migrate group config on supergroup IDs (#906)
Thanks @sleontenko.

Co-authored-by: Stan <sleontenko@users.noreply.github.com>
2026-01-15 01:10:30 +00:00
sleontenko
9b7c4b3884 feat(telegram): auto-migrate group config on supergroup migration
When a Telegram group is upgraded to a supergroup, the chat ID changes
(e.g., -123456 → -100123456). This causes the bot to lose its group
configuration since it's keyed by chat ID.

This change:
- Adds handler for `message:migrate_to_chat_id` event
- Logs the migration (old_id → new_id) for visibility
- If the old chat ID has config in channels.telegram.groups, automatically:
  - Copies the config to the new chat ID
  - Removes the old chat ID entry
  - Saves the updated config file

This eliminates the need to manually update clawdbot.json when groups
migrate to supergroups.
2026-01-15 01:10:30 +00:00
Peter Steinberger
bcde09ae91 feat: add /context prompt breakdown 2026-01-15 01:06:35 +00:00
Peter Steinberger
632651aee2 docs: add markdown IR example 2026-01-15 00:37:04 +00:00
Peter Steinberger
d2b76acb72 docs: expand markdown formatting pipeline 2026-01-15 00:34:34 +00:00
Peter Steinberger
daf69e8154 chore: update clawtributors 2026-01-15 00:31:07 +00:00
Peter Steinberger
e5c8abab9e fix: preserve markdown code fences 2026-01-15 00:31:07 +00:00
Peter Steinberger
2c312e20f1 fix: finalize markdown IR formatting 2026-01-15 00:31:07 +00:00
Peter Steinberger
9f1a8be2bf refactor: unify markdown formatting pipeline 2026-01-15 00:31:07 +00:00
Peter Steinberger
bd7d362d3b refactor: unify markdown formatting pipeline 2026-01-15 00:31:07 +00:00
Peter Steinberger
0d0b77ded6 fix(telegram): wire delete action for message tool (#903) - thanks @sleontenko
Co-authored-by: Stan <sleontenko@users.noreply.github.com>
2026-01-15 00:29:53 +00:00
sleontenko
83a25d26fc feat(telegram): add deleteMessage action
Add ability to delete messages in Telegram chats via the message tool.

Changes:
- Add deleteMessageTelegram function in send.ts
- Add deleteMessage action handler in telegram-actions.ts
- Add delete action support in telegram message plugin adapter
- Add deleteMessage to TelegramActionConfig type
- Update message tool description to mention delete action

Usage:
- Via message tool: action="delete", chatId, messageId
- Can be disabled via channels.telegram.actions.deleteMessage=false

Limitations (Telegram API):
- Bot can delete its own messages in any chat
- Bot can delete others' messages only if admin with "Delete Messages"
- Messages older than 48h in groups may fail to delete
2026-01-15 00:29:53 +00:00
Peter Steinberger
9b7df414e6 test: add gemini 3 antigravity switch live repro 2026-01-15 00:29:53 +00:00
Peter Steinberger
f87016a5fe fix: handle unsigned tool calls for gemini 3 2026-01-15 00:29:53 +00:00
Peter Steinberger
5894ffe82e refactor(auth): streamline allowFrom normalization 2026-01-14 23:42:50 +00:00
Peter Steinberger
50fa106d87 refactor: centralize dashboard url + ws close code 2026-01-14 23:42:12 +00:00
Peter Steinberger
983e1b2303 fix: dashboard auth query items (#918) - thanks @rahthakor
Co-authored-by: Rahul Thakor <rahthakor@users.noreply.github.com>
2026-01-14 23:36:23 +00:00
rahthakor
de7f567b9a docs(changelog): add gateway fix entries 2026-01-14 23:34:48 +00:00
rahthakor
0eabc89840 fix(ui): use application-defined WebSocket close code 2026-01-14 23:34:48 +00:00
rahthakor
400e901c9c fix(mac): pass auth token to dashboard URL 2026-01-14 23:34:48 +00:00
Peter Steinberger
57b4865ab3 fix(whatsapp): normalize user JIDs for group allowlists (#838)
Thanks @peschee.

Co-authored-by: Peter Siska <63866+peschee@users.noreply.github.com>
2026-01-14 23:25:42 +00:00
Peter Steinberger
fd41000bc3 test(whatsapp): add context isolation coverage
Includes outbound gating, threading fallback, and web auto-reply context assertions.
2026-01-14 23:23:36 +00:00
Peter Steinberger
a70937c926 refactor(discord): centralize autoThread reply plan (#856) 2026-01-14 23:07:05 +00:00
Shadow
4ec2222fa9 Revert "Clarify coding-agent skill trigger"
This reverts commit 0adcb68092.
2026-01-14 15:58:01 -06:00
Peter Steinberger
fe974f420d chore: standardize Claude Code CLI naming (#915)
Follow-up to #915.
2026-01-14 20:07:35 +00:00
Peter Steinberger
e65e5f40c9 fix(whatsapp): use conversation id for context isolation (#911)
Thanks @tristanmanchester.

Co-authored-by: Tristan Manchester <tmanchester96@gmail.com>
2026-01-14 20:06:20 +00:00
Peter Steinberger
0235eb6c72 refactor(discord): clean autoThread context wiring (#856)
Build reply/session context once (no post-hoc ctx mutation) and type into the actual delivery target.

Thanks @davidguttman.

Co-authored-by: David Guttman <david@davidguttman.com>
2026-01-14 20:04:25 +00:00
Peter Steinberger
e943e63174 chore(auth): rename Claude CLI to Claude Code CLI (#915)
Thanks @SeanZoR.

Co-authored-by: Sean Katz <connect@sean8.com>
2026-01-14 19:57:42 +00:00
Shadow
b4ba6e4eaf Discord: isolate autoThread thread context (#856) 2026-01-14 12:25:35 -06:00
Mariano Belinky
0adcb68092 Clarify coding-agent skill trigger 2026-01-14 19:10:09 +01:00
Peter Steinberger
dadef27d7a fix(slack): drop mismatched Socket Mode events (#889)
Filter Slack Socket Mode events by api_app_id/team_id.
Refs: #828
Contributor: @roshanasingh4

Co-authored-by: Roshan Singh <roshanasingh4@users.noreply.github.com>
2026-01-14 15:54:37 +00:00
Peter Steinberger
53465a4d2d fix: split long Telegram captions (#907) - thanks @jalehman
Co-authored-by: Josh Lehman <josh@martian.engineering>
2026-01-14 15:52:54 +00:00
Peter Steinberger
4e837cfa2d docs(changelog): credit macOS cron channel fix (#867)
Co-authored-by: Wes <wesleydavis@me.com>
2026-01-14 15:37:00 +00:00
Peter Steinberger
964e6169cb docs(changelog): add minimax usage entry 2026-01-14 15:02:20 +00:00
Peter Steinberger
c379191f80 chore: migrate to oxlint and oxfmt
Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
2026-01-14 15:02:19 +00:00
Nimrod Gutman
912ebffc63 fix(macos): update cron testing channel arg (#896) 2026-01-14 08:53:23 -06:00
Peter Steinberger
b7a11b7bd4 test(telegram): cover per-account timeoutSeconds (#863)
Co-authored-by: Snaver <194855+Snaver@users.noreply.github.com>
2026-01-14 10:35:42 +00:00
Peter Steinberger
95bdb28a05 test(doctor): bump timeouts for slow cases 2026-01-14 10:35:39 +00:00
Peter Steinberger
9930ba91c5 fix(telegram): honor timeoutSeconds (thanks @Snaver) (#863) 2026-01-14 10:10:05 +00:00
Peter Steinberger
802c02eb74 style(lint): fix minimax usage lint 2026-01-14 10:10:04 +00:00
Peter Steinberger
2d4e3253ca fix(lint): tidy minimax usage fetch 2026-01-14 10:04:26 +00:00
Peter Steinberger
da8d45d6c6 docs(usage): note minimax usage key 2026-01-14 09:57:54 +00:00
Peter Steinberger
18b4575e4d feat(usage): add minimax usage snapshot 2026-01-14 09:57:32 +00:00
Peter Steinberger
40fb59e5f7 refactor(live-tests): stabilize docker live suites 2026-01-14 09:52:39 +00:00
Peter Steinberger
e3ff8c4d28 refactor(ui): split app modules 2026-01-14 09:11:36 +00:00
Peter Steinberger
ce59e2dd76 refactor(telegram): split bot handlers 2026-01-14 09:11:32 +00:00
Peter Steinberger
32cfc49002 refactor(tui): split handlers 2026-01-14 09:11:28 +00:00
Peter Steinberger
d19bc1562b refactor(gateway): split server runtime 2026-01-14 09:11:21 +00:00
Peter Steinberger
ea018a68cc refactor(auto-reply): split reply pipeline 2026-01-14 09:11:16 +00:00
Peter Steinberger
1089444807 fix(gateway): reduce flaky ws rpc timeouts 2026-01-14 05:41:02 +00:00
Peter Steinberger
4af8228c34 test(web): rename split suites 2026-01-14 05:40:58 +00:00
Peter Steinberger
ebea98b8ec test(slack): rename split suites 2026-01-14 05:40:55 +00:00
Peter Steinberger
51683071e8 test(signal): rename split suites 2026-01-14 05:40:52 +00:00
Peter Steinberger
bfa46b2471 test(providers): rename split suites 2026-01-14 05:40:49 +00:00
Peter Steinberger
de62797128 test(imessage): rename split suites 2026-01-14 05:40:45 +00:00
Peter Steinberger
05673fb6cf test(cron): rename split suites 2026-01-14 05:40:42 +00:00
Peter Steinberger
350f4709b7 test(auto-reply): rename split suites 2026-01-14 05:40:39 +00:00
Peter Steinberger
b5f7ba502d refactor(voice-call): split manager 2026-01-14 05:40:19 +00:00
Peter Steinberger
8ba80d2dac refactor(ui): split render + connections 2026-01-14 05:40:14 +00:00
Peter Steinberger
b11eea07b0 refactor(wizard): split onboarding 2026-01-14 05:40:10 +00:00
Peter Steinberger
35cea9be25 refactor(telegram): split bot helpers 2026-01-14 05:40:07 +00:00
Peter Steinberger
3e0e608110 refactor(infra): split provider usage 2026-01-14 05:40:03 +00:00
Peter Steinberger
e2f8909982 refactor(agents): split tools + PI subscribe 2026-01-14 05:39:59 +00:00
Peter Steinberger
ac613b6632 refactor(discord): split send pipeline 2026-01-14 05:39:55 +00:00
Peter Steinberger
5323652cfd refactor(config): split legacy handling 2026-01-14 05:39:51 +00:00
Peter Steinberger
a58ff1ac63 refactor(commands): split CLI commands 2026-01-14 05:39:47 +00:00
Peter Steinberger
2b60ee96f2 refactor(browser): split pw tools + agent routes 2026-01-14 05:39:44 +00:00
Peter Steinberger
da6f07b7c1 refactor(auto-reply): split directive handling 2026-01-14 05:39:41 +00:00
Peter Steinberger
f0b624d6c9 chore: prep 2026.1.14 release 2026-01-14 01:39:29 +00:00
Peter Steinberger
e4c3c02a36 refactor(voice-call): split twilio provider 2026-01-14 01:17:56 +00:00
Peter Steinberger
ae796b1194 docs(tools): update slash commands 2026-01-14 01:17:56 +00:00
Peter Steinberger
c892f38d3c refactor(scripts): extract clawtributors types 2026-01-14 01:17:56 +00:00
Peter Steinberger
d98b6beb4d chore(e2e): reduce doctor-switch install noise 2026-01-14 01:17:56 +00:00
Peter Steinberger
b80abf8dd1 refactor(vendor): align a2ui renderer typings 2026-01-14 01:17:56 +00:00
Peter Steinberger
acfa762617 refactor(ui): split config and connections views 2026-01-14 01:17:56 +00:00
Peter Steinberger
a44f1912b3 chore(repo): drop .DS_Store 2026-01-14 01:17:56 +00:00
Peter Steinberger
bcbfb357be refactor(src): split oversized modules 2026-01-14 01:17:56 +00:00
Peter Steinberger
b2179de839 fix(docker): harden docker e2e scripts 2026-01-14 01:17:08 +00:00
Peter Steinberger
b1102cedd7 fix: support non-interactive token auth 2026-01-14 00:56:34 +00:00
Peter Steinberger
571f8c78bd chore: allow install e2e previous override 2026-01-14 00:38:26 +00:00
Peter Steinberger
93fbd103ba ci: set install smoke previous 2026-01-14 00:11:45 +00:00
Peter Steinberger
a740d563d7 chore: allow install smoke previous override 2026-01-14 00:07:50 +00:00
Peter Steinberger
8f6e67553f chore: drop stale pi-ai patch 2026-01-13 23:59:17 +00:00
Peter Steinberger
0a8be132b9 chore: prep 2026.1.13 release 2026-01-13 23:59:04 +00:00
Peter Steinberger
4c932edabc fix: make postinstall patcher idempotent 2026-01-13 23:12:27 +00:00
Cash Williams
5283872e00 Make timezone and 24 hour clock explicit in system prompt 2026-01-13 12:42:37 -06:00
Peter Steinberger
45c314fbe6 docs(voice-call): expand plugin guide 2026-01-13 11:42:09 +00:00
Peter Steinberger
119e53967b chore(voice-call): release 0.1.0 2026-01-13 11:10:29 +00:00
Peter Steinberger
d36a004468 Merge pull request #831 from roshanasingh4/fix/817-persist-subagent-registry
Persist subagent run registry to survive gateway restarts
2026-01-13 10:10:59 +00:00
Peter Steinberger
8778c39ed0 fix: restore GatewayAgentChannel enum location 2026-01-13 10:10:25 +00:00
Peter Steinberger
b071f73fef fix: resume subagent registry safely (#831) (thanks @roshanasingh4) 2026-01-13 10:10:15 +00:00
Peter Steinberger
afde0a17b7 fix: macOS app release 2026.1.12-2 2026-01-13 10:06:07 +00:00
Roshan Singh
714de9d996 Persist subagent registry across restarts 2026-01-13 10:00:30 +00:00
Peter Steinberger
6b587fa411 chore: fix npm package contents 2026-01-13 09:36:15 +00:00
Peter Steinberger
7e2f5126bc chore: fix npm package contents 2026-01-13 09:33:02 +00:00
Peter Steinberger
9eab82b717 chore: release 2026.1.12 2026-01-13 09:25:50 +00:00
Rob Axelsen
e49ccf49fd Update outdated contributor information for Shadow 2026-01-13 09:21:40 +00:00
Peter Steinberger
aac3615d7a test: fix cron delivery channel expectations 2026-01-13 09:07:13 +00:00
Peter Steinberger
7de6e925aa chore(lint): format entry shim 2026-01-13 08:43:54 +00:00
Peter Steinberger
6fdfe8ea73 fix: finalize channels rename cleanup 2026-01-13 08:40:40 +00:00
Peter Steinberger
84bfaad6e6 fix: finish channels rename sweep 2026-01-13 08:40:40 +00:00
Peter Steinberger
fcac2464e6 refactor: remove redundant spread fallbacks 2026-01-13 08:40:39 +00:00
Peter Steinberger
3eb48cbea7 docs: complete channels rename sweep 2026-01-13 08:40:39 +00:00
Peter Steinberger
72a48c4992 docs: align channels naming 2026-01-13 08:40:39 +00:00
Peter Steinberger
993c1de361 fix: stabilize channel migration 2026-01-13 08:40:39 +00:00
Peter Steinberger
90342a4f3a refactor!: rename chat providers to channels 2026-01-13 08:40:39 +00:00
Peter Steinberger
0cd632ba84 Merge pull request #832 from danielz1z/fix/overloaded-error-handling
fix: handle Anthropic overloaded_error gracefully
2026-01-13 08:33:16 +00:00
Peter Steinberger
e8779ac329 fix: handle Anthropic overloaded_error gracefully (#832) (thanks @danielz1z) 2026-01-13 08:32:06 +00:00
Peter Steinberger
32d844d3b6 fix: preserve execArgv on reexec 2026-01-13 08:31:50 +00:00
danielz1z
36725ce153 fix: handle Anthropic overloaded_error gracefully
When Anthropic's API returns an overloaded_error (temporary capacity issue),
the raw JSON error was being sent to users instead of a friendly message.

Changes:
- Add overloaded error pattern detection
- Return user-friendly message for overloaded errors
- Classify overloaded as failover-worthy (triggers retry logic)
2026-01-13 08:18:59 +00:00
Peter Steinberger
0d537ece10 docs: add minimax coding plan referral 2026-01-13 08:15:44 +00:00
Peter Steinberger
f825dd2897 chore(lint): format live profiles test 2026-01-13 08:03:58 +00:00
Peter Steinberger
9faa95d558 fix: recover from compaction overflow 2026-01-13 08:03:11 +00:00
Peter Steinberger
365cbe8d50 docs: clarify Discord requireMention placement 2026-01-13 08:03:11 +00:00
Peter Steinberger
9a322d52e2 docs: update faq and install guidance 2026-01-13 08:03:11 +00:00
Peter Steinberger
89013efbca docs: fix Discord invite link in showcase 2026-01-13 08:03:11 +00:00
Peter Steinberger
3a90335b5a chore(lint): satisfy biome checks 2026-01-13 07:53:38 +00:00
Peter Steinberger
676b64e8a3 chore(changelog): note auth profile order fix 2026-01-13 07:53:38 +00:00
Peter Steinberger
9007920695 fix(auth): drop invalid auth profiles from order 2026-01-13 07:53:38 +00:00
Peter Steinberger
2887376646 test: stabilize gpt-5.2 tool-only live check 2026-01-13 07:51:24 +00:00
Peter Steinberger
e48d452c63 docs: credit MiniMax agent-dir fix 2026-01-13 07:30:54 +00:00
Peter Steinberger
165841ae79 fix: suppress experimental sqlite warning at startup 2026-01-13 07:27:32 +00:00
Peter Steinberger
76acdb7ae7 test: silence experimental sqlite warnings in docker 2026-01-13 07:27:32 +00:00
Peter Steinberger
dfbe4041f5 fix: skip Control UI asset check when UI is skipped 2026-01-13 07:27:32 +00:00
Peter Steinberger
4fd1a6dec3 fix: harden doctor install checks 2026-01-13 07:25:25 +00:00
Peter Steinberger
aa394d0e14 chore: update clawtributors (thanks @vrknetha) 2026-01-13 07:19:53 +00:00
Peter Steinberger
d5b17d344b Merge pull request #722 from vrknetha/feat/slash-bash-command
feat(bash): add host-only /bash slash command
2026-01-13 07:18:35 +00:00
Peter Steinberger
8111e18dbd docs: add Opus/MiniMax fallback examples 2026-01-13 07:15:20 +00:00
Peter Steinberger
ba7d12f205 refactor: centralize onboarding auth paths 2026-01-13 07:12:20 +00:00
Peter Steinberger
ef66ad3b52 docs: group FAQ sections 2026-01-13 07:07:48 +00:00
Peter Steinberger
69e4339af9 Tests: run e2e gateway with node 2026-01-13 07:06:41 +00:00
Peter Steinberger
779904657f Docs: reorder 2025.1.12 changelog 2026-01-13 07:03:44 +00:00
Peter Steinberger
cb0f6cefa4 Deps: update Pi + Vitest and add Bedrock docs 2026-01-13 06:57:11 +00:00
Peter Steinberger
25ef01b74a docs: add FAQ table of contents 2026-01-13 06:55:00 +00:00
Peter Steinberger
6db0201fcd Merge pull request #583 from mitschabaude-bot/feat/agent-model-fallbacks
Config: per-agent model fallbacks
2026-01-13 06:54:00 +00:00
Peter Steinberger
40e508823f Merge pull request #444 from grp06/feature/xhigh-thinking-models
Thinking: gate xhigh by model
2026-01-13 06:51:39 +00:00
Peter Steinberger
3368284b2a fix: per-agent model fallbacks (#583) (thanks @mitschabaude-bot) 2026-01-13 06:50:41 +00:00
Gregor's Bot
ece01d89fe Docs: document agents.list model fallbacks 2026-01-13 06:50:20 +00:00
Gregor's Bot
9c0c4f50ec Agents: test per-agent model fallbacks override 2026-01-13 06:50:20 +00:00
Gregor's Bot
6729637f61 Config: support per-agent model fallbacks 2026-01-13 06:50:20 +00:00
Peter Steinberger
18d22aa426 fix: gate xhigh by model (#444) (thanks @grp06) 2026-01-13 06:48:41 +00:00
George Pickett
a3641526ab Thinking: gate xhigh by model 2026-01-13 06:48:26 +00:00
Peter Steinberger
f50e06a1b6 test(tools): cover tool policy helpers 2026-01-13 06:32:59 +00:00
Peter Steinberger
2ae3b45ac1 docs: update changelog for tool schema and profiles 2026-01-13 06:31:12 +00:00
Peter Steinberger
780a43711f feat(tools): add tool profiles and group shorthands 2026-01-13 06:30:20 +00:00
Peter Steinberger
d682b604de fix(tools): harden tool schemas for strict providers 2026-01-13 06:30:20 +00:00
vrknetha
721a6bf0f9 Docs: document bash chat command 2026-01-13 11:56:51 +05:30
vrknetha
25a5f1cb96 Auto-reply: add host-only /bash + ! bash command 2026-01-13 11:54:34 +05:30
Peter Steinberger
fa75d84b75 docs: clarify clawdhub workdir defaults 2026-01-13 06:05:06 +00:00
Peter Steinberger
bb2df13be0 Merge pull request #700 from clawdbot/shadow/compaction
Agents: safeguard compaction summarization
2026-01-13 05:59:15 +00:00
Peter Steinberger
5918def440 fix: honor gateway service override labels 2026-01-13 05:58:49 +00:00
Peter Steinberger
1fdd3592d3 fix: tune compaction safeguard schema (#700) (thanks @thewilloftheshadow) 2026-01-13 05:58:35 +00:00
Shadow
a96d299971 Agents: safeguard compaction summarization 2026-01-13 05:55:30 +00:00
Peter Steinberger
42ff634a9d docs: add model routing faq 2026-01-13 05:47:57 +00:00
Peter Steinberger
b45d7c3256 docs: add custom skills folder faq 2026-01-13 05:35:32 +00:00
Peter Steinberger
77ca508274 docs: clarify dashboard token auth 2026-01-13 05:31:26 +00:00
Peter Steinberger
61b7398cb7 refactor: centralize auth-choice model defaults 2026-01-13 05:25:16 +00:00
Peter Steinberger
0321d5ed74 Merge pull request #740 from jeffersonwarrior/main
feat: add Tailscale binary detection, custom gateway IP binding, and health probe auth fix
2026-01-13 05:22:48 +00:00
Peter Steinberger
78627ce7c2 fix: tighten custom bind probing (#740) (thanks @jeffersonwarrior) 2026-01-13 05:21:59 +00:00
Jefferson Warrior
c851bdd47a feat: add Tailscale binary detection, IP binding modes, and health probe password fix
This PR includes three main improvements:

1. Tailscale Binary Detection with Fallback Strategies
   - Added findTailscaleBinary() with multi-strategy detection:
     * PATH lookup via 'which' command
     * Known macOS app path (/Applications/Tailscale.app/Contents/MacOS/Tailscale)
     * find /Applications for Tailscale.app
     * locate database lookup
   - Added getTailscaleBinary() with caching
   - Updated all Tailscale operations to use detected binary
   - Added TUI warning when Tailscale binary not found for serve/funnel modes

2. Custom Gateway IP Binding with Fallback
   - New bind mode "custom" allowing user-specified IP with fallback to 0.0.0.0
   - Removed "tailnet" mode (folded into "auto")
   - All modes now support graceful fallback: custom (if fail → 0.0.0.0), loopback (127.0.0.1 → 0.0.0.0), auto (tailnet → 0.0.0.0), lan (0.0.0.0)
   - Added customBindHost config option for custom bind mode
   - Added canBindTo() helper to test IP availability before binding
   - Updated configure and onboarding wizards with new bind mode options

3. Health Probe Password Auth Fix
   - Gateway probe now tries both new and old passwords
   - Fixes issue where password change fails health check if gateway hasn't restarted yet
   - Uses nextConfig password first, falls back to baseConfig password if needed

Files changed:
- src/infra/tailscale.ts: Binary detection + caching
- src/gateway/net.ts: IP binding with fallback logic
- src/config/types.ts: BridgeBindMode type + customBindHost field
- src/commands/configure.ts: Health probe dual-password try + Tailscale detection warning + bind mode UI
- src/wizard/onboarding.ts: Tailscale detection warning + bind mode UI
- src/gateway/server.ts: Use new resolveGatewayBindHost
- src/gateway/call.ts: Updated preferTailnet logic (removed "tailnet" mode)
- src/commands/onboard-types.ts: Updated GatewayBind type
- src/commands/onboard-helpers.ts: resolveControlUiLinks updated
- src/cli/*.ts: Updated bind mode casts
- src/gateway/call.test.ts: Removed "tailnet" mode test
2026-01-13 05:20:02 +00:00
Peter Steinberger
9ec0016258 chore: regenerate protocol models 2026-01-13 05:18:07 +00:00
Peter Steinberger
01776e0569 refactor: dedupe OAuth flow handlers 2026-01-13 05:14:05 +00:00
Peter Steinberger
d8f14078f0 refactor: simplify cron job editor payloads 2026-01-13 05:13:49 +00:00
Peter Steinberger
38244b8e94 test: cover cron delete-after-run in macos 2026-01-13 05:12:48 +00:00
Peter Steinberger
9308762d0b style: swiftformat macos swift files 2026-01-13 05:12:48 +00:00
Peter Steinberger
8eb1c76337 fix: clean up api key validation 2026-01-13 05:12:42 +00:00
Peter Steinberger
f94ad21f1e style: format cron cli flags 2026-01-13 05:05:39 +00:00
Peter Steinberger
8d640ccc68 Merge pull request #726 from FrieSei/feature/chutes-oauth
Auth: add Chutes OAuth
2026-01-13 05:02:25 +00:00
Peter Steinberger
f566e6451f fix: harden Chutes OAuth flow (#726) (thanks @FrieSei) 2026-01-13 05:01:08 +00:00
Peter Steinberger
75a7855223 feat: cron ISO at + delete-after-run 2026-01-13 04:55:48 +00:00
Peter Steinberger
8f105288d2 Merge pull request #783 from ananth-vardhan-cn/fix/756-gemini-cli-id-field
fix(providers): strip unsupported id field from google-gemini-cli function calls
2026-01-13 04:51:50 +00:00
Peter Steinberger
696a6ec4d4 fix: strip gemini-cli tool ids (#783) (thanks @ananth-vardhan-cn) 2026-01-13 04:51:05 +00:00
Friederike Seiler
3271ff1d6e Tests: clean chutes fetch spies 2026-01-13 04:50:27 +00:00
Friederike Seiler
0efcfc0864 Chores: fix chutes oauth build 2026-01-13 04:50:27 +00:00
Friederike Seiler
0aba911912 Chores: format chutes oauth 2026-01-13 04:50:26 +00:00
Friederike Seiler
4efb5cc18e Auth: add Chutes OAuth 2026-01-13 04:50:26 +00:00
Peter Steinberger
57db3f22a1 fix: clean lint in auth-choice + tests 2026-01-13 04:49:04 +00:00
Peter Steinberger
9b44c80b30 docs: add X showcases 2026-01-13 04:41:01 +00:00
Peter Steinberger
7c7f4d0eb7 chore(discord): restore gateway log context 2026-01-13 04:40:22 +00:00
Peter Steinberger
ccc24e2c26 chore(logging): strip redundant provider tags 2026-01-13 04:40:22 +00:00
Peter Steinberger
62bdbe1821 chore(logging): strip redundant console prefixes 2026-01-13 04:40:22 +00:00
Peter Steinberger
58d1d11762 chore(discord): trim gateway log prefixes 2026-01-13 04:40:22 +00:00
Peter Steinberger
8a9096cd52 Merge pull request #823 from roshanasingh4/fix/820-tailscale-allow-token
Fix allowTailscale bypass for token auth in Serve mode
2026-01-13 04:39:55 +00:00
Peter Steinberger
b70298fbca fix: document Tailscale Serve auth headers (#823) (thanks @roshanasingh4) 2026-01-13 04:37:04 +00:00
Roshan Singh
7616b02bb1 Fix tailscale allowTailscale bypass in token mode 2026-01-13 04:34:28 +00:00
Peter Steinberger
d4c205f8e1 fix: start typing on message start 2026-01-13 04:33:24 +00:00
Peter Steinberger
0ba60ff69c Merge pull request #794 from roshanasingh4/fix/777-windows-openurl-quotes
Fix Antigravity OAuth login on Windows (quote URL for cmd start)
2026-01-13 04:28:11 +00:00
Peter Steinberger
755a7e1b20 feat: add configurable bootstrap truncation 2026-01-13 04:27:03 +00:00
Peter Steinberger
3061d8e057 fix: preserve Windows cmd start URL quoting (#794) (thanks @roshanasingh4) 2026-01-13 04:26:43 +00:00
Peter Steinberger
ea5597b483 fix: restore implicit providers + copilot auth choice 2026-01-13 04:26:08 +00:00
Peter Steinberger
b41e75a15d feat: cron agent binding + doctor UI refresh 2026-01-13 04:25:41 +00:00
meaningfool
bfdbaa5ab6 feat(doctor): add UI protocol freshness check 2026-01-13 04:25:41 +00:00
meaningfool
93ae3b8405 fix: sync protocol artifacts and resolve linting errors 2026-01-13 04:25:41 +00:00
meaningfool
f249a82383 fix: resolve CI failures (test timeout & formatting) 2026-01-13 04:25:41 +00:00
Roshan Singh
ea9486ae2d Fix: quote URLs when opening browser on Windows 2026-01-13 04:23:20 +00:00
Shadow
da95b58a2a Typing: keep indicators active during tool runs
Closes #450
Closes #447
2026-01-12 22:20:29 -06:00
Shadow
e15d5d0533 Cron: persist enabled=false patches
Closes #205
2026-01-12 22:16:17 -06:00
Peter Steinberger
1cf45f8439 Merge pull request #805 from marcmarg/fix/strip-thought-signatures
fix: strip thought_signature fields for cross-provider compatibility
2026-01-13 04:14:45 +00:00
Peter Steinberger
2a9ef806a0 fix: strip only msg_* thought_signature (#805) (thanks @marcmarg) 2026-01-13 04:13:24 +00:00
Peter Steinberger
32115a8b98 test: expand auth fallback coverage 2026-01-13 04:12:16 +00:00
Shadow
7ce902b096 Control UI: preserve chat scroll when scrolled up
Closes #217
2026-01-12 22:11:43 -06:00
Marc
c4e8b60d2c fix: strip thought_signature fields for cross-provider compatibility
Claude's extended thinking feature generates thought_signature fields
(message IDs like "msg_abc123...") in content blocks. When these are
sent to Google's Gemini API, it expects Base64-encoded bytes and
rejects Claude's format with a 400 error.

This commit adds stripThoughtSignatures() to remove these fields from
assistant message content blocks during sanitization, enabling session
histories to be shared across different providers (e.g., Claude → Gemini).

Fixes cross-provider session bug where switching from Claude-thinking
to Gemini (or vice versa) would fail with:
"Invalid value at 'thought_signature' (TYPE_BYTES), Base64 decoding failed"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 04:11:02 +00:00
Shadow
c9fdd68232 Telegram: keep forum topic thread ids in replies
Closes #727
2026-01-12 22:07:28 -06:00
Peter Steinberger
50260fd385 fix: sync protocol outputs 2026-01-13 04:04:22 +00:00
Peter Steinberger
0822f5610a Merge pull request #822 from clawdbot/fix/credential-fallback-761
fix: treat credential validation failures as auth errors for fallback
2026-01-13 04:03:02 +00:00
Peter Steinberger
2c2ca7f03b fix: treat credential validation errors as auth errors (#822) (thanks @sebslight) 2026-01-13 04:02:47 +00:00
Sebastian
c4014c0092 fix: treat credential validation failures as auth errors for fallback (#761) 2026-01-12 22:53:21 -05:00
Shadow
c08441c42c Telegram: persist polling update offsets
Closes #739
2026-01-12 21:52:20 -06:00
Peter Steinberger
980f274fc9 fix: stabilize docs and tests after system event timestamps 2026-01-13 03:51:34 +00:00
Shadow
9f1f65f0e3 Discord: dedupe listener registration on reload
Closes #744
2026-01-12 21:41:59 -06:00
Peter Steinberger
cddd836909 Merge pull request #821 from gumadeiras/fix-bindings-telegram-webhook
Telegram: fix webhook multi-account routing (respect bindings.accountId)
2026-01-13 03:41:17 +00:00
Peter Steinberger
bab7eeaf91 fix: respect telegram webhook bindings (#821) (thanks @gumadeiras) 2026-01-13 03:40:30 +00:00
Peter Steinberger
d45915d39f fix: refine group intro prompt guidance 2026-01-13 03:40:27 +00:00
Shadow
fcc814accd System events: add local timestamps in prompt injection
Closes #245
2026-01-12 21:38:56 -06:00
Peter Steinberger
78a3d965e0 chore: update clawtributors 2026-01-13 03:38:11 +00:00
Peter Steinberger
66ad8a9289 fix: apply lint fixes 2026-01-13 03:36:53 +00:00
Peter Steinberger
df6634727e fix: refine synthetic provider + minimax probes 2026-01-13 03:36:53 +00:00
Travis Hinton
8b5cd97ceb Add Synthetic provider support 2026-01-13 03:36:53 +00:00
Shadow
25297ce3f5 Cron: accept jobId in gateway cron params
Closes #252
2026-01-12 21:35:43 -06:00
Gustavo Madeira Santana
3800fea962 Improve webhook test config verification
Refactors tests to use a shared config object and adds stricter assertions to verify that the config is passed correctly to createTelegramBot. Ensures the bindings property is checked in the test expectations.
2026-01-13 03:34:32 +00:00
Gustavo Madeira Santana
ecb91bbb1a Add accountId and config support to Telegram webhook
The Telegram webhook and monitor now accept and pass through accountId and config parameters, enabling routing and configuration per Telegram account. Tests have been updated to verify correct bot instantiation and DM routing based on accountId bindings.
2026-01-13 03:34:32 +00:00
Shadow
ab993904d7 Models: normalize Gemini 3 ids in runtime selection
Closes #795
2026-01-12 21:32:53 -06:00
Shadow
2467a103b2 TUI: keep streamed text when final output is empty
Closes #747
2026-01-12 21:29:15 -06:00
Shadow
68569afb4b Slack: accept slash command names with or without leading slash
Closes #798
2026-01-12 21:27:04 -06:00
Peter Steinberger
67d0ab3030 docs: simplify local models guidance 2026-01-13 03:26:00 +00:00
Peter Steinberger
4024bca55d docs: add custom memory endpoint example 2026-01-13 03:21:59 +00:00
Peter Steinberger
b928b96af5 fix: sync Moonshot Kimi K2 models (#818) (thanks @mickahouan)
Co-authored-by: mickahouan <mickahouan@users.noreply.github.com>
2026-01-13 03:19:49 +00:00
Peter Steinberger
cb871a3fc1 Merge pull request #819 from mukhtharcm/feature/memory-search-custom-endpoint
feat(memory): support custom OpenAI-compatible embedding endpoints
2026-01-13 03:11:21 +00:00
Peter Steinberger
da0a062fa7 fix: memory search remote overrides (#819) (thanks @mukhtharcm) 2026-01-13 03:11:03 +00:00
Muhammed Mukhthar CM
ba316a10cc feat: add remote config overrides to memorySearch 2026-01-13 03:02:43 +00:00
Peter Steinberger
4086408b10 docs: add Microsoft Teams to README 2026-01-13 02:42:16 +00:00
Peter Steinberger
c1f82d9ec1 refactor: dedupe enforceFinalTag resolution 2026-01-13 02:38:07 +00:00
Peter Steinberger
eb6cace60f docs: update README providers and stars 2026-01-13 02:34:54 +00:00
Peter Steinberger
00f4331343 Merge pull request #796 from gabriel-trigo/bugfix/ai-snapshot-limit
Fix AI snapshot size overflow
2026-01-13 02:33:53 +00:00
Peter Steinberger
cb469ecf45 Merge pull request #818 from mickahouan/chore-pre-update-2026-01-12
feat: add Kimi K2 variants to Moonshot preset
2026-01-13 02:33:48 +00:00
Peter Steinberger
46a694bbc7 fix: preserve explicit maxChars=0 (#796) (thanks @gabriel-trigo) 2026-01-13 02:33:38 +00:00
Peter Steinberger
56c406b19e chore: credit @ThomsenDrake
Co-authored-by: ThomsenDrake <ThomsenDrake@users.noreply.github.com>
2026-01-13 02:33:38 +00:00
Gabriel Trigo
79a6506593 fix(browser): limit ai snapshot size
test(browser): cover ai snapshot limit
2026-01-13 02:33:38 +00:00
Peter Steinberger
99a6fcf3f7 fix: add Kimi K2 variants to Moonshot preset (#818) (thanks @mickahouan) 2026-01-13 02:32:29 +00:00
Mickaël Ahouansou
abe9440096 feat: add Kimi K2 variants to Moonshot preset 2026-01-13 02:26:43 +00:00
Peter Steinberger
542c8020ec docs: clarify minimax lightning routing 2026-01-13 02:21:24 +00:00
Peter Steinberger
8edf2146ae fix: cleanup stale resume cli processes 2026-01-13 02:21:20 +00:00
Peter Steinberger
3c8d0083cb docs: note gh issue newline handling 2026-01-13 02:13:51 +00:00
Peter Steinberger
28248f9602 docs: add repo link to AGENTS 2026-01-13 02:09:03 +00:00
Peter Steinberger
d225c4a7d1 docs: add new showcase cards 2026-01-13 02:08:51 +00:00
Peter Steinberger
e05a8477b9 Merge pull request #816 from clawdbot/style/account-cards-polish
style: polish multi-account cards
2026-01-13 02:07:30 +00:00
Peter Steinberger
357a6e340a docs: update changelog for account card polish (#816) (thanks @steipete) 2026-01-13 02:07:09 +00:00
Peter Steinberger
a87d37f26d style: polish multi-account cards 2026-01-13 02:05:00 +00:00
Peter Steinberger
958a4fd414 Merge pull request #782 from AbhisekBasu1/fix/antigravity-opus-tools-not-working
Fix - Opus on Antigravity Errors
2026-01-13 01:59:52 +00:00
Peter Steinberger
a27efd57bd fix: drop null-only union variants (#782) (thanks @AbhisekBasu1)
Co-authored-by: Abhi <AbhisekBasu1@users.noreply.github.com>
2026-01-13 01:58:30 +00:00
Peter Steinberger
d57db17300 docs: clarify semantic memory key requirements 2026-01-13 01:53:40 +00:00
Peter Steinberger
4f1c6e76fd fix: gate inline /status stripping 2026-01-13 01:53:40 +00:00
Peter Steinberger
2111d0c653 test: force real config module for lan onboarding test (#766) 2026-01-13 01:53:40 +00:00
Peter Steinberger
642e6acf49 test: unmock config for lan onboarding auto-token (#766) 2026-01-13 01:53:40 +00:00
Peter Steinberger
88716d8d2a fix: harden inline /status stripping (#766) 2026-01-13 01:53:40 +00:00
Peter Steinberger
c2e37c78ff fix: trim sender ids before auth fallback 2026-01-13 01:53:40 +00:00
Peter Steinberger
5a2688c7b5 Merge pull request #813 from dbhurley/feat/multi-account-ui-clean
feat(ui): display per-account status for multi-account Telegram
2026-01-13 01:52:56 +00:00
Abhi
ba1d80bd00 formatting fix 2026-01-13 01:48:56 +00:00
Abhi
4dfcd56893 Fix pi-tools test ordering and clean-for-gemini handling - which fixes the 400 error people are experiencing trying to use antigravity on opus 2026-01-13 01:48:34 +00:00
Peter Steinberger
e0ddc488d0 Merge pull request #810 from mcinteerj/fix/runtime-reasoning-enforcement
fix(auto-reply): enforce reasoning tags on fallback providers
2026-01-13 01:46:41 +00:00
Peter Steinberger
c012019a8a fix: enforce reasoning tags on fallback providers (#810) (thanks @mcinteerj) 2026-01-13 01:46:21 +00:00
Keith the Silly Goose
7896b30489 fix(auto-reply): enforce reasoning tags on fallback providers 2026-01-13 01:40:55 +00:00
Peter Steinberger
ffc465394e fix: enforce message context isolation 2026-01-13 01:19:14 +00:00
Peter Steinberger
0edbdb1948 fix: downgrade Gemini tool history 2026-01-13 01:19:13 +00:00
hsrvc
5dc187f00c fix: accept Claude/Gemini tool param aliases 2026-01-13 01:19:13 +00:00
Peter Steinberger
231d2d5fdf fix(config): require doctor for invalid configs (#764 — thanks @mukhtharcm) 2026-01-13 01:18:18 +00:00
Muhammed Mukhthar CM
20ba8d4891 fix(config): preserve config data when validation fails
When readConfigFileSnapshot encounters validation errors, it now:
1. Returns the resolved config data instead of empty object
2. Uses passthrough() on main schema to preserve unknown fields

This prevents config loss when:
- User has custom/unknown fields
- Legacy config issues are detected but config is otherwise valid
- Zod schema does not recognize newer fields

Fixes config being overwritten with empty object on validation failure.
2026-01-13 01:16:13 +00:00
Peter Steinberger
b32f6a0e00 docs: update clawtributors 2026-01-13 01:12:34 +00:00
Peter Steinberger
e5c77315ce chore: credit @ThomsenDrake
Co-authored-by: ThomsenDrake <ThomsenDrake@users.noreply.github.com>
2026-01-13 01:11:29 +00:00
Peter Steinberger
dd8f7552ad refactor: reuse dispatcher helper for native commands 2026-01-13 01:07:59 +00:00
DB Hurley
95ed49ce9a feat(ui): display per-account status for multi-account Telegram
When multiple Telegram accounts are configured, the Connections UI now
displays individual status cards for each account showing:
- Bot username and account ID
- Running/configured status
- Last inbound message time
- Per-account errors

Falls back to the existing summary view for single-account configs.
No changes needed to types (already added upstream).
2026-01-12 20:03:06 -05:00
Peter Steinberger
92760de472 Merge pull request #781 from ronyrus/fix/update-doctor-noninteractive
fix: pass --non-interactive to doctor during update
2026-01-13 00:59:52 +00:00
Peter Steinberger
24190d09da fix: document non-interactive update doctor (#781) (thanks @ronyrus) 2026-01-13 00:58:56 +00:00
Rony Kelner
07bdb8af7e fix: pass --non-interactive to doctor during update 2026-01-13 00:57:18 +00:00
Peter Steinberger
6a48688c09 fix: stream native slash tool replies 2026-01-13 00:53:30 +00:00
Peter Steinberger
c03a745f61 test: expand Minimax XML strip coverage 2026-01-13 00:43:59 +00:00
Peter Steinberger
48fdf3775d test: cover user turn merging 2026-01-13 00:42:15 +00:00
Peter Steinberger
e5708d443a Merge pull request #809 from latitudeki5223/fix/minimax-tool-xml-leak
fix(minimax): strip tool invocation XML from assistant text
2026-01-13 00:37:29 +00:00
Peter Steinberger
e2ea20f862 fix: gate minimax XML stripping (#809) (thanks @latitudeki5223) 2026-01-13 00:36:39 +00:00
L36 Server
1eb924739b style: fix import order in pi-embedded-utils.test.ts 2026-01-13 00:34:01 +00:00
L36 Server
350f956f2c fix(minimax): strip tool invocation XML from assistant text 2026-01-13 00:34:01 +00:00
Peter Steinberger
1f3ae2346e docs: clarify memory search auth 2026-01-13 00:26:01 +00:00
Peter Steinberger
4f3bedfdb7 fix: align discord autoThread config types 2026-01-13 00:22:42 +00:00
Peter Steinberger
6f75feaeb8 refactor: reuse model selection assertions 2026-01-13 00:20:08 +00:00
Peter Steinberger
e949cc383f docs: update changelog 2026-01-13 00:16:15 +00:00
Peter Steinberger
a4bd960880 refactor: streamline thread reply planning 2026-01-13 00:15:29 +00:00
Peter Steinberger
3636a2bf51 refactor: unify message tool + CLI 2026-01-13 00:12:57 +00:00
Peter Steinberger
103003d9ff docs: add provider shorthand redirects 2026-01-13 00:08:11 +00:00
Peter Steinberger
ce23c70855 fix: validate Anthropic turn order (#804) (thanks @ThomsenDrake) 2026-01-12 23:43:25 +00:00
Drake Thomsen
c5fa757ef6 fix(agents): prevent Anthropic 400 'Incorrect role information' error
Add validateAnthropicTurns() to merge consecutive user messages that can
occur when steering messages are injected during streaming. This prevents
the API from rejecting requests due to improper role alternation.

Changes:
- Add validateAnthropicTurns() function in pi-embedded-helpers.ts
- Integrate validation into sanitization pipeline in pi-embedded-runner.ts
- Add user-friendly error message for role ordering errors
- Add comprehensive tests for the new validation function
2026-01-12 23:42:13 +00:00
Peter Steinberger
bb9a9633a8 fix: align reply threading refs 2026-01-12 23:41:40 +00:00
Peter Steinberger
ca98f87b2f chore: reinforce memory recall prompts 2026-01-12 23:29:56 +00:00
Peter Steinberger
df64771ecf test: cover fuzzy model selection 2026-01-12 23:16:54 +00:00
Peter Steinberger
cbe11e3de0 fix: address lint warnings 2026-01-12 23:13:40 +00:00
Peter Steinberger
daa753112c fix: unblock auto-reply lint/typecheck 2026-01-12 23:13:39 +00:00
David Guttman
2e654e8d63 Fix Discord autoThread thread-only replies (#807)
Co-authored-by: Shadow <shadow@clawd.bot>
2026-01-12 17:11:48 -06:00
Peter Steinberger
cf92099d40 test(auto-reply): fix heartbeat typing block reply assertions 2026-01-12 23:01:53 +00:00
Peter Steinberger
a55c880191 docs: add changelog for sandbox memory shorthand 2026-01-12 22:59:36 +00:00
Peter Steinberger
a8680f9a09 fix(auto-reply): fix streaming block reply media handling 2026-01-12 22:59:36 +00:00
Peter Steinberger
2e08a868a7 style: format native commands bits 2026-01-12 22:59:36 +00:00
Peter Steinberger
2785009c6f fix(config): resolve native commands schemas 2026-01-12 22:59:36 +00:00
Peter Steinberger
73d9469bf8 fix(telegram): tolerate missing native command APIs 2026-01-12 22:59:36 +00:00
Peter Steinberger
72100ba3ab refactor(sandbox): drop legacy memory shorthand 2026-01-12 22:59:36 +00:00
Peter Steinberger
ec5099db89 fix: pick best fuzzy model match 2026-01-12 22:59:35 +00:00
Peter Steinberger
209380edf8 Merge pull request #801 from mcinteerj/fix/restore-reasoning-tag-check
fix(agent): restore reasoning tag enforcement for non-ollama providers
2026-01-12 22:59:13 +00:00
Peter Steinberger
cf8251bb81 test: cover typing signals from block and tool streams 2026-01-12 22:55:17 +00:00
Peter Steinberger
6ed2d69ff9 docs: clarify telegram allowFrom 2026-01-12 22:45:39 +00:00
Peter Steinberger
e7e544174f docs: add region guidance for hosted minimax 2026-01-12 22:45:00 +00:00
Peter Steinberger
9b0d9db3a3 docs: detail memory tools and local models 2026-01-12 22:35:19 +00:00
Peter Steinberger
fd768334a9 refactor: fast-lane directives helpers 2026-01-12 22:34:13 +00:00
Peter Steinberger
9f90d0721a docs: note relative plugin install paths 2026-01-12 22:34:13 +00:00
Peter Steinberger
1920138122 docs: add voice-call plugin changelog 2026-01-12 22:34:13 +00:00
Peter Steinberger
27d940f5b6 refactor: reuse streaming text normalizer across callbacks 2026-01-12 22:27:56 +00:00
Peter Steinberger
7ba72aeb6c fix: make pw download tests platform-safe 2026-01-12 22:27:19 +00:00
Peter Steinberger
f19d37c7bb build: harden installer smoke apt 2026-01-12 22:21:50 +00:00
Peter Steinberger
9d5bf38416 style(telegram): format bot.ts 2026-01-12 22:19:02 +00:00
Peter Steinberger
e0c1f2fdc0 test(agents): avoid Copilot token fetch in image-tool tests 2026-01-12 22:19:02 +00:00
Peter Steinberger
08fdac0561 fix(telegram): guard setMyCommands in native commands 2026-01-12 22:19:02 +00:00
Peter Steinberger
d3eeddfc2f chore: fix lint after rebase 2026-01-12 22:19:02 +00:00
Peter Steinberger
0a24bc0427 docs(changelog): note scrollintoview 2026-01-12 22:19:02 +00:00
Peter Steinberger
9da97d1a41 test(browser): expand scrollintoview coverage 2026-01-12 22:19:02 +00:00
Peter Steinberger
29b7b2068a refactor: centralize streaming text normalization 2026-01-12 22:17:14 +00:00
Peter Steinberger
f4ab057807 fix: start typing on partial deltas 2026-01-12 22:16:29 +00:00
Peter Steinberger
cb35db0c7e docs: note to echo docs links after edits 2026-01-12 22:13:12 +00:00
Peter Steinberger
f13db1c836 test: guard telegram native commands when mock lacks .command 2026-01-12 22:12:16 +00:00
Peter Steinberger
59063a7c15 test: skip setMyCommands when API mock lacks it 2026-01-12 22:08:53 +00:00
Peter Steinberger
5bc4971432 chore: fix lint warnings 2026-01-12 22:07:39 +00:00
Peter Steinberger
59c8d2d17f docs: clarify sandbox bind mounts (#790) 2026-01-12 22:06:35 +00:00
Peter Steinberger
21405b0dfc fix(discord): rebalance reasoning italics 2026-01-12 22:01:48 +00:00
Peter Steinberger
256304037e fix: keep Claude file_path aliases validated 2026-01-12 22:00:08 +00:00
Peter Steinberger
d8ae905d54 Merge pull request #790 from akonyer/feature/custom-sandbox-binds
Custom sandbox binds for docker sandbox
2026-01-12 21:58:31 +00:00
Peter Steinberger
bbc34215a2 fix: land sandbox binds (#790) (thanks @akonyer) 2026-01-12 21:58:16 +00:00
Aaron Konyer
583fc4fb11 test(sandbox): add coverage for binds -v flag emission 2026-01-12 21:57:51 +00:00
Aaron Konyer
0b2b8c7c52 Add docker bind mounds for sandboxing 2026-01-12 21:57:51 +00:00
Peter Steinberger
5d83be76c9 test: cover mixed directive fast-lane 2026-01-12 21:57:10 +00:00
Peter Steinberger
877bc61b53 docs(browser): document scrollintoview 2026-01-12 21:56:27 +00:00
Peter Steinberger
fcaeee7073 test(browser): cover scrollintoview 2026-01-12 21:56:27 +00:00
Peter Steinberger
6857f16609 feat(browser): add scrollintoview action 2026-01-12 21:56:27 +00:00
Peter Steinberger
2faf7cea93 feat(sandbox): add tool-policy groups 2026-01-12 21:51:49 +00:00
Peter Steinberger
26d5cca97c feat: auto native commands defaults 2026-01-12 21:49:44 +00:00
Peter Steinberger
99fea64823 fix: fast-lane directives bypass queue dedupe 2026-01-12 21:44:19 +00:00
Peter Steinberger
42c17adb5e feat: restore voice-call plugin parity 2026-01-12 21:44:19 +00:00
Zach Knickerbocker
3467b0ba07 Discord: add allowBots config option (#802)
Co-authored-by: Shadow <shadow@clawd.bot>
2026-01-12 15:30:05 -06:00
Peter Steinberger
490cb834e5 style: italicize reasoning output 2026-01-12 21:24:36 +00:00
Peter Steinberger
cd12ad8aab fix(image): accept @-prefixed file paths 2026-01-12 20:53:16 +00:00
Jake
eceb41f6f7 fix(agent): restore reasoning tag enforcement for non-ollama providers
This restores the fix from PR #754 which was accidentally reverted in bf11a42c3.
2026-01-13 09:47:04 +13:00
Peter Steinberger
6f496b7739 fix(macos): treat tests as preview 2026-01-12 20:38:34 +00:00
Peter Steinberger
e961e02f71 fix(gateway): quiet loopback ws closes 2026-01-12 20:38:34 +00:00
Peter Steinberger
36a02b3e67 fix(image): route MiniMax vision to VLM 2026-01-12 20:38:34 +00:00
David Guttman
b73042500e Discord: per-channel autoThread (#800)
Co-authored-by: Shadow <shadow@clawd.bot>
2026-01-12 14:33:07 -06:00
Peter Steinberger
6406ed869a docs(browser): document downloads + responsebody 2026-01-12 19:41:12 +00:00
Peter Steinberger
f839d949b2 test(browser): cover downloads + responsebody 2026-01-12 19:41:12 +00:00
Peter Steinberger
d4f7dc067e feat(browser): add downloads + response bodies 2026-01-12 19:41:12 +00:00
Peter Steinberger
3dbfe65eea Merge pull request #791 from roshanasingh4/fix/788-implicit-delivery-leak
Prevent onboarding TUI from auto-delivering to lastProvider/lastTo
2026-01-12 19:39:05 +00:00
Peter Steinberger
ddd4b55cf6 fix: prevent onboarding TUI auto-delivery (#791) (thanks @roshanasingh4) 2026-01-12 19:38:52 +00:00
Roshan Singh
298c6eea1f Fix: prevent onboarding TUI auto-delivery 2026-01-12 19:31:04 +00:00
Peter Steinberger
74806aa5e3 docs: add dashboard faq 2026-01-12 19:14:08 +00:00
Peter Steinberger
55aeb8a0d3 fix(image): drop temperature for OpenAI 2026-01-12 19:09:15 +00:00
Peter Steinberger
86ea00dc21 fix(tools): accept legacy bash tool calls 2026-01-12 19:09:15 +00:00
Peter Steinberger
a0a7e74a62 fix(models): preserve implicit vision models 2026-01-12 19:09:15 +00:00
Peter Steinberger
bb7397c636 feat: add dashboard command 2026-01-12 19:08:29 +00:00
Peter Steinberger
7dc44b04c1 fix: close memory index and refresh protocol outputs 2026-01-12 18:49:24 +00:00
Peter Steinberger
45232137a2 fix(logging): honor silent console level 2026-01-12 18:46:40 +00:00
Peter Steinberger
b1c3e38df0 refactor(models): share implicit providers 2026-01-12 18:46:40 +00:00
Peter Steinberger
0be62c3542 fix(image): fail over on empty output 2026-01-12 18:46:16 +00:00
Peter Steinberger
523f91758d test(browser): extend automation coverage 2026-01-12 18:42:46 +00:00
Peter Steinberger
5baba5f84e docs(browser): expand automation docs 2026-01-12 18:42:46 +00:00
Peter Steinberger
ffbcd83d1e chore: log elevated and reasoning toggles 2026-01-12 18:37:44 +00:00
Peter Steinberger
1baf9f6a83 fix(image): normalize mime type handling 2026-01-12 18:24:11 +00:00
Peter Steinberger
77b20377cc fix: stabilize session tools and tests 2026-01-12 18:21:20 +00:00
Peter Steinberger
3ffb9a3b5e fix: keep session sanitizer stable 2026-01-12 18:19:30 +00:00
Peter Steinberger
29807119d5 chore: format tool guard files 2026-01-12 18:19:30 +00:00
Peter Steinberger
b88ea39b83 fix: add subagent default model typing 2026-01-12 18:18:15 +00:00
Peter Steinberger
0a2dcd844b fix(image): support data URLs 2026-01-12 18:17:43 +00:00
Peter Steinberger
2ed95634fe fix: relax image tool agentDir guard 2026-01-12 18:12:51 +00:00
Peter Steinberger
9526f9861a chore: mark 2026.1.12 unreleased 2026-01-12 18:09:35 +00:00
Peter Steinberger
7b93356fb7 feat: subagent model defaults 2026-01-12 18:08:30 +00:00
Peter Steinberger
17ff25bd20 fix(sandbox): always allow image tool 2026-01-12 18:07:34 +00:00
Peter Steinberger
d24de1ec3b feat(sandbox): allow image tool 2026-01-12 17:56:51 +00:00
Peter Steinberger
44e1f271c8 fix: keep image sanitizer scoped 2026-01-12 17:55:45 +00:00
Peter Steinberger
8ff09f8337 feat(image): auto-pair image model 2026-01-12 17:50:47 +00:00
Peter Steinberger
e91aa0657e fix: add copilot tests and lint fixes 2026-01-12 17:48:08 +00:00
Ayaan Zaidi
14801b46fc Merge pull request #792 from clawdbot/fix/gateway-auth-off
fix: clear gateway auth when set to off
2026-01-12 23:10:15 +05:30
Peter Steinberger
99a7548c07 docs(browser): update browser tool docs 2026-01-12 17:32:44 +00:00
Peter Steinberger
35bbc2ba87 feat(cli): expand browser commands 2026-01-12 17:32:44 +00:00
Peter Steinberger
cf78d28d74 test(browser): add regression coverage 2026-01-12 17:32:44 +00:00
Peter Steinberger
eeca541dde feat(browser): expand browser control surface 2026-01-12 17:32:44 +00:00
Ayaan Zaidi
4bba49770d fix: clear gateway auth on off selection 2026-01-12 22:59:37 +05:30
Peter Steinberger
f5d5661adf fix: guard session tool results 2026-01-12 17:28:46 +00:00
Peter Steinberger
f83fb70360 fix: keep main agent in list output 2026-01-12 17:24:03 +00:00
Peter Steinberger
355c13564c fix: restore heartbeat defaults and model listing 2026-01-12 17:17:54 +00:00
Peter Steinberger
f1dd59bf82 test: update heartbeat and agent list thresholds 2026-01-12 17:14:04 +00:00
Peter Steinberger
fd1e959c2d fix: clean up models-config provider normalization 2026-01-12 17:14:04 +00:00
Peter Steinberger
1b2c1545a0 docs: harden local model guidance 2026-01-12 17:11:04 +00:00
Peter Steinberger
05ac67c520 refactor: split models-config provider helpers 2026-01-12 17:05:53 +00:00
Peter Steinberger
f5ee2b3a4f Merge pull request #705 from TAGOOZ/feat/github-copilot-onboard
feat: add GitHub Copilot provider
2026-01-12 16:56:51 +00:00
Peter Steinberger
8afdf75e2b fix: honor copilot config and profiles (#705) (thanks @TAGOOZ) 2026-01-12 16:55:47 +00:00
Peter Steinberger
6aed3c0fd3 docs: add changelog entry for minimax fix 2026-01-12 16:53:53 +00:00
Peter Steinberger
f0fbd8b012 docs: note minimax unknown-model fix 2026-01-12 16:52:49 +00:00
Peter Steinberger
5a3eb5ad62 test: cover models.json apiKey fill 2026-01-12 16:52:43 +00:00
Peter Steinberger
79beb20ba2 fix: make models.json generation fill apiKey 2026-01-12 16:52:43 +00:00
Mustafa Tag Eldeen
3da1afed68 feat: add GitHub Copilot provider
Copilot device login + onboarding option; model list auth detection.
2026-01-12 16:52:15 +00:00
Peter Steinberger
717a259056 docs: add local models guide 2026-01-12 16:50:37 +00:00
Ayaan Zaidi
adaa30c73a test(telegram): cover General topic typing fallback 2026-01-12 22:03:42 +05:30
Azade
ff292e67ce fix(telegram): show typing indicator in General forum topic
In forum supergroups, messages from the General topic arrive without
message_thread_id in updates, but sendChatAction requires one to display
the typing indicator in the correct topic.

Use message_thread_id=1 (Telegram's internal ID for General topic) as
fallback when messageThreadId is undefined in a forum chat.
2026-01-12 22:01:21 +05:30
Peter Steinberger
31752aa944 chore: remove qmd skill 2026-01-12 11:31:34 +00:00
Peter Steinberger
bf11a42c37 feat: add memory vector search 2026-01-12 11:23:44 +00:00
Peter Steinberger
8049f33435 chore: sanitize onboarding api keys 2026-01-12 11:18:31 +00:00
Peter Steinberger
115591c5b6 feat: add cron agent binding 2026-01-12 11:07:38 +00:00
Peter Steinberger
a3938d62f6 chore: raise heartbeat ack window 2026-01-12 11:06:46 +00:00
Peter Steinberger
3c7a8579ad test: cover minimax env provider injection 2026-01-12 11:05:08 +00:00
Peter Steinberger
f5a9421b10 fix: auto-add minimax provider from auth 2026-01-12 11:05:08 +00:00
Peter Steinberger
562d0e3b5f fix: avoid duplicate status replies 2026-01-12 11:04:03 +00:00
Peter Steinberger
bf7e813573 chore: release 2026.1.11-4 2026-01-12 10:52:34 +00:00
Peter Steinberger
c69abe08eb chore: update appcast for 2026.1.11-3 2026-01-12 10:40:31 +00:00
Peter Steinberger
5a29ec78ca chore: release 2026.1.11-3 2026-01-12 10:35:50 +00:00
2332 changed files with 204528 additions and 136843 deletions

26
.detect-secrets.cfg Normal file
View File

@@ -0,0 +1,26 @@
# detect-secrets exclusion patterns (regex)
#
# Note: detect-secrets does not read this file by default. If you want these
# applied, wire them into your scan command (e.g. translate to --exclude-files
# / --exclude-lines) or into a baseline's filters_used.
[exclude-files]
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
pattern = (^|/)pnpm-lock\.yaml$
[exclude-lines]
# Fastlane checks for private key marker; not a real key.
pattern = key_content\.include\?\("BEGIN PRIVATE KEY"\)
# UI label string for Anthropic auth mode.
pattern = case \.apiKeyEnv: "API key \(env var\)"
# CodingKeys mapping uses apiKey literal.
pattern = case apikey = "apiKey"
# Schema labels referencing password fields (not actual secrets).
pattern = "gateway\.remote\.password"
pattern = "gateway\.auth\.password"
# Schema label for talk API key (label text only).
pattern = "talk\.apiKey"
# checking for typeof is not something we care about.
pattern = === "string"
# specific optional-chaining password check that didn't match the line above.
pattern = typeof remote\?\.password === "string"

View File

@@ -74,9 +74,9 @@ jobs:
- runtime: node
task: protocol
command: pnpm protocol:check
- runtime: bun
task: lint
command: bunx biome check src
- runtime: node
task: format
command: pnpm format
- runtime: bun
task: test
command: bunx vitest run
@@ -141,6 +141,31 @@ jobs:
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
secrets:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install detect-secrets
run: |
python -m pip install --upgrade pip
python -m pip install detect-secrets==1.5.0
- name: Detect secrets
run: |
if ! detect-secrets scan --baseline .secrets.baseline; then
echo "::error::Secret scanning failed. See docs/gateway/security.md#secret-scanning-detect-secrets"
exit 1
fi
checks-windows:
runs-on: blacksmith-4vcpu-windows-2025
defaults:

View File

@@ -29,4 +29,5 @@ jobs:
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
CLAWDBOT_NO_ONBOARD: "1"
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
run: pnpm test:install:smoke

5
.oxfmtrc.jsonc Normal file
View File

@@ -0,0 +1,5 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"indentWidth": 2,
"printWidth": 100
}

4
.oxlintrc.jsonc Normal file
View File

@@ -0,0 +1,4 @@
{
"$schema": "https://json.schemastore.org/oxlintrc",
"extends": ["recommended"]
}

518
.secrets.baseline Normal file
View File

@@ -0,0 +1,518 @@
{
"version": "1.5.0",
"plugins_used": [
{
"name": "ArtifactoryDetector"
},
{
"name": "AWSKeyDetector"
},
{
"name": "AzureStorageKeyDetector"
},
{
"name": "Base64HighEntropyString",
"limit": 4.5
},
{
"name": "BasicAuthDetector"
},
{
"name": "CloudantDetector"
},
{
"name": "DiscordBotTokenDetector"
},
{
"name": "GitHubTokenDetector"
},
{
"name": "GitLabTokenDetector"
},
{
"name": "HexHighEntropyString",
"limit": 3.0
},
{
"name": "IbmCloudIamDetector"
},
{
"name": "IbmCosHmacDetector"
},
{
"name": "IPPublicDetector"
},
{
"name": "JwtTokenDetector"
},
{
"name": "KeywordDetector",
"keyword_exclude": ""
},
{
"name": "MailchimpDetector"
},
{
"name": "NpmDetector"
},
{
"name": "OpenAIDetector"
},
{
"name": "PrivateKeyDetector"
},
{
"name": "PypiTokenDetector"
},
{
"name": "SendGridDetector"
},
{
"name": "SlackDetector"
},
{
"name": "SoftlayerDetector"
},
{
"name": "SquareOAuthDetector"
},
{
"name": "StripeDetector"
},
{
"name": "TelegramBotTokenDetector"
},
{
"name": "TwilioKeyDetector"
}
],
"filters_used": [
{
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
},
{
"path": "detect_secrets.filters.common.is_baseline_file",
"filename": ".secrets.baseline"
},
{
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
"min_level": 2
},
{
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
},
{
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
},
{
"path": "detect_secrets.filters.heuristic.is_lock_file"
},
{
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
},
{
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
},
{
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
},
{
"path": "detect_secrets.filters.heuristic.is_sequential_string"
},
{
"path": "detect_secrets.filters.heuristic.is_swagger_file"
},
{
"path": "detect_secrets.filters.heuristic.is_templated_secret"
},
{
"path": "detect_secrets.filters.regex.should_exclude_file",
"pattern": [
"(^|/)pnpm-lock\\.yaml$"
]
},
{
"path": "detect_secrets.filters.regex.should_exclude_line",
"pattern": [
"key_content\\.include\\?\\(\"BEGIN PRIVATE KEY\"\\)",
"case \\.apiKeyEnv: \"API key \\(env var\\)\"",
"case apikey = \"apiKey\"",
"\"gateway\\.remote\\.password\"",
"\"gateway\\.auth\\.password\"",
"\"talk\\.apiKey\"",
"=== \"string\"",
"typeof remote\\?\\.password === \"string\""
]
}
],
"results": {
".env.example": [
{
"type": "Twilio API Key",
"filename": ".env.example",
"hashed_secret": "3c7206eff845bc69cf12d904d0f95f9aec15535e",
"is_verified": false,
"line_number": 2
}
],
"appcast.xml": [
{
"type": "Base64 High Entropy String",
"filename": "appcast.xml",
"hashed_secret": "1b1c2b73eca84e441a823c37a06c71c9fadcfe24",
"is_verified": false,
"line_number": 19
},
{
"type": "Base64 High Entropy String",
"filename": "appcast.xml",
"hashed_secret": "5c47736fee5151b26b3bb61bb38955da0e8937c6",
"is_verified": false,
"line_number": 35
},
{
"type": "Base64 High Entropy String",
"filename": "appcast.xml",
"hashed_secret": "bbbca47179268f154c63affa0ca441c6e49e650f",
"is_verified": false,
"line_number": 52
}
],
"apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift": [
{
"type": "Secret Keyword",
"filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift",
"hashed_secret": "e761624445731fcb8b15da94343c6b92e507d190",
"is_verified": false,
"line_number": 26
},
{
"type": "Secret Keyword",
"filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift",
"hashed_secret": "a23c8630c8a5fbaa21f095e0269c135c20d21689",
"is_verified": false,
"line_number": 42
}
],
"apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift": [
{
"type": "Secret Keyword",
"filename": "apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 83
}
],
"apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift": [
{
"type": "Secret Keyword",
"filename": "apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 27
}
],
"docs/configuration.md": [
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 268
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
"is_verified": false,
"line_number": 465
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27",
"is_verified": false,
"line_number": 718
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
"is_verified": false,
"line_number": 760
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
"is_verified": false,
"line_number": 859
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6",
"is_verified": false,
"line_number": 982
}
],
"docs/faq.md": [
{
"type": "Secret Keyword",
"filename": "docs/faq.md",
"hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281",
"is_verified": false,
"line_number": 593
},
{
"type": "Secret Keyword",
"filename": "docs/faq.md",
"hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0",
"is_verified": false,
"line_number": 650
}
],
"docs/skills-config.md": [
{
"type": "Secret Keyword",
"filename": "docs/skills-config.md",
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
"is_verified": false,
"line_number": 28
}
],
"docs/skills.md": [
{
"type": "Secret Keyword",
"filename": "docs/skills.md",
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
"is_verified": false,
"line_number": 97
}
],
"docs/tailscale.md": [
{
"type": "Secret Keyword",
"filename": "docs/tailscale.md",
"hashed_secret": "9cb0dc5383312aa15b9dc6745645bde18ff5ade9",
"is_verified": false,
"line_number": 52
}
],
"docs/talk.md": [
{
"type": "Secret Keyword",
"filename": "docs/talk.md",
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
"is_verified": false,
"line_number": 50
}
],
"docs/telegram.md": [
{
"type": "Secret Keyword",
"filename": "docs/telegram.md",
"hashed_secret": "e9fe51f94eadabf54dbf2fbbd57188b9abee436e",
"is_verified": false,
"line_number": 57
}
],
"skills/local-places/SERVER_README.md": [
{
"type": "Secret Keyword",
"filename": "skills/local-places/SERVER_README.md",
"hashed_secret": "6d9c68c603e465077bdd49c62347fe54717f83a3",
"is_verified": false,
"line_number": 28
}
],
"skills/openai-whisper-api/SKILL.md": [
{
"type": "Secret Keyword",
"filename": "skills/openai-whisper-api/SKILL.md",
"hashed_secret": "1077361f94d70e1ddcc7c6dc581a489532a81d03",
"is_verified": false,
"line_number": 39
}
],
"skills/trello/SKILL.md": [
{
"type": "Secret Keyword",
"filename": "skills/trello/SKILL.md",
"hashed_secret": "11fa7c37d697f30e6aee828b4426a10f83ab2380",
"is_verified": false,
"line_number": 18
}
],
"src/agents/models-config.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/agents/models-config.test.ts",
"hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d",
"is_verified": false,
"line_number": 25
},
{
"type": "Secret Keyword",
"filename": "src/agents/models-config.test.ts",
"hashed_secret": "3a81eb091f80c845232225be5663d270e90dacb7",
"is_verified": false,
"line_number": 90
}
],
"src/agents/skills.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/agents/skills.test.ts",
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
"is_verified": false,
"line_number": 158
},
{
"type": "Secret Keyword",
"filename": "src/agents/skills.test.ts",
"hashed_secret": "7a85f4764bbd6daf1c3545efbbf0f279a6dc0beb",
"is_verified": false,
"line_number": 265
},
{
"type": "Secret Keyword",
"filename": "src/agents/skills.test.ts",
"hashed_secret": "5df3a673d724e8a1eb673a8baf623e183940804d",
"is_verified": false,
"line_number": 462
},
{
"type": "Secret Keyword",
"filename": "src/agents/skills.test.ts",
"hashed_secret": "8921daaa546693e52bc1f9c40bdcf15e816e0448",
"is_verified": false,
"line_number": 490
}
],
"src/browser/target-id.test.ts": [
{
"type": "Hex High Entropy String",
"filename": "src/browser/target-id.test.ts",
"hashed_secret": "4e126c049580d66ca1549fa534d95a7263f27f46",
"is_verified": false,
"line_number": 16
}
],
"src/commands/antigravity-oauth.ts": [
{
"type": "Base64 High Entropy String",
"filename": "src/commands/antigravity-oauth.ts",
"hashed_secret": "709d0f232b6ac4f8d24dec3e4fabfdb14257174f",
"is_verified": false,
"line_number": 17
},
{
"type": "Base64 High Entropy String",
"filename": "src/commands/antigravity-oauth.ts",
"hashed_secret": "3848603b8e866f62d07c206ff622279b9dcb0238",
"is_verified": false,
"line_number": 20
}
],
"src/commands/onboard-auth.ts": [
{
"type": "Secret Keyword",
"filename": "src/commands/onboard-auth.ts",
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
"is_verified": false,
"line_number": 50
}
],
"src/config/config.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/config/config.test.ts",
"hashed_secret": "bea2f7b64fab8d1d414d0449530b1e088d36d5b1",
"is_verified": false,
"line_number": 520
}
],
"src/gateway/server.auth.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/gateway/server.auth.test.ts",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 89
},
{
"type": "Secret Keyword",
"filename": "src/gateway/server.auth.test.ts",
"hashed_secret": "a4b48a81cdab1e1a5dd37907d6c85ca1c61ddc7c",
"is_verified": false,
"line_number": 109
}
],
"src/infra/env.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/infra/env.test.ts",
"hashed_secret": "df98a117ddabf85991b9fe0e268214dc0e1254dc",
"is_verified": false,
"line_number": 10
},
{
"type": "Secret Keyword",
"filename": "src/infra/env.test.ts",
"hashed_secret": "6d811dc1f59a55ca1a3d38b5042a062b9f79e8ec",
"is_verified": false,
"line_number": 25
}
],
"src/infra/shell-env.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/infra/shell-env.test.ts",
"hashed_secret": "65c10dc3549fe07424148a8a4790a3341ecbc253",
"is_verified": false,
"line_number": 35
},
{
"type": "Base64 High Entropy String",
"filename": "src/infra/shell-env.test.ts",
"hashed_secret": "64db6bf7f0e5a0491df4419f0eb1bbcc402989e8",
"is_verified": false,
"line_number": 56
},
{
"type": "Secret Keyword",
"filename": "src/infra/shell-env.test.ts",
"hashed_secret": "e013ffda590d2178607c16d11b1ea42f75ceb0e7",
"is_verified": false,
"line_number": 73
},
{
"type": "Base64 High Entropy String",
"filename": "src/infra/shell-env.test.ts",
"hashed_secret": "be6ee9a6bf9f2dad84a5a67d6c0576a5bacc391e",
"is_verified": false,
"line_number": 75
}
],
"src/web/qr-image.test.ts": [
{
"type": "Hex High Entropy String",
"filename": "src/web/qr-image.test.ts",
"hashed_secret": "564666dc1ca6e7318b2d5feeb1ce7b5bf717411e",
"is_verified": false,
"line_number": 12
}
],
"vendor/a2ui/README.md": [
{
"type": "Secret Keyword",
"filename": "vendor/a2ui/README.md",
"hashed_secret": "2619a5397a5d054dab3fe24e6a8da1fbd76ec3a6",
"is_verified": false,
"line_number": 123
}
]
},
"generated_at": "2026-01-05T13:01:00Z"
}

View File

@@ -1,9 +1,12 @@
# Repository Guidelines
- Repo: https://github.com/clawdbot/clawdbot
- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
## Project Structure & Module Organization
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
- Tests: colocated `*.test.ts`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
## Docs Linking (Mintlify)
@@ -11,6 +14,7 @@
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
@@ -22,12 +26,12 @@
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
- Node remains supported for running built output (`dist/*`) and production installs.
- Type-check/build: `pnpm build` (tsc)
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
## Coding Style & Naming Conventions
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
- Formatting/linting via Biome; run `pnpm lint` before commits.
- Formatting/linting via Oxlint and Oxfmt; run `pnpm lint` before commits.
- Add brief code comments for tricky or non-obvious logic.
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
@@ -67,12 +71,14 @@
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
## Troubleshooting
- Rebrand/migration issues (Clawdis → Clawdbot) or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
- Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
## Agent-Specific Notes
- Vocabulary: "makeup" = "mac app".
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency.
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); dont hand-roll spinners/bars.
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
@@ -97,6 +103,7 @@
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.

View File

@@ -1,15 +1,228 @@
# Changelog
## 2026.1.11-2
## 2026.1.15 (unreleased)
- Agents: add CLI quick reference to the system prompt to avoid invented commands. (#953) — thanks @roshanasingh4.
## 2026.1.14-1
### Highlights
- Web search: `web_search`/`web_fetch` tools (Brave API) + first-time setup in onboarding/configure.
- Browser control: Chrome extension relay takeover mode + remote browser control via `clawdbot browser serve`.
- Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy.
### Changes
#### Web Tools
- Tools: add `web_search`/`web_fetch` (Brave API), including helpful setup hints when the key is missing.
- Tools: enable `web_fetch` by default (unless explicitly disabled in config).
- CLI/Docs: add `clawdbot configure --section web` for storing Brave API keys and update onboarding tips.
#### Browser / Control UI
- Browser: add Chrome extension relay takeover mode (toolbar button) + `clawdbot browser serve` remote control + `browser.controlToken`.
- Browser: ship a built-in `chrome` profile for extension relay and start the relay automatically when running locally.
- Browser: default `browser.defaultProfile` to `chrome` (existing Chrome takeover mode).
- Browser: add `clawdbot browser extension install/path` and copy extension path to clipboard.
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot``act`.
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
- Control UI: show raw any-map entries in config views; move Docs link into the left nav.
#### Plugins
- Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.
- Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.
- Channels: add Matrix plugin (external) with docs + onboarding hooks.
- Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.
#### Security
- Security: expand `clawdbot security audit` checks and publish a `SECURITY.md` reporting policy.
- Security: extend `clawdbot security audit --fix` to tighten more sensitive state paths.
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
#### Onboarding / Daemon
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
- Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.
#### Auth / Usage / Config
- Usage: add MiniMax coding plan usage tracking.
- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
- Auth: add dynamic template variables to `messages.responsePrefix`. (#928) — thanks @sebslight.
- Config: add `channels.<provider>.configWrites` gating for channel-initiated config writes; migrate Slack channel IDs.
#### Channels
- Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.
- WhatsApp: add `channels.whatsapp.sendReadReceipts` to disable auto read receipts. (#882) — thanks @chrisrodz.
#### Docs
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
- Docs: expand gateway security hardening guidance and incident response checklist.
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)
- Docs: add per-command CLI doc pages and link them from `clawdbot <command> --help`.
- Docs: add multi-gateway guide (sidebar + nav).
### Fixes
- Installer: ensure the CLI entrypoint is executable after npm installs.
- Packaging: include `dist/plugins/` in the npm package to avoid missing module errors.
## 2026.1.11-1
#### Gateway / Daemon / Sessions
- Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.
- Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare `main` alias).
- Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
- Browser: persist role snapshot refs per CDP target so `snapshot``act` clicks work even if Playwright returns a different Page instance.
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
- Packaging: run `pnpm build` on `prepack` so npm publishes include fresh `dist/` output.
- Telegram: register dock native commands with underscores to avoid `BOT_COMMAND_INVALID` (#929, fixes #901) — thanks @grp06.
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman.
- Sessions: return deep clones (`structuredClone`) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.
- Heartbeat: keep `updatedAt` monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.
- Agent: clear run context after CLI runs (`clearAgentRunContext`) to avoid runaway contexts. (#934) — thanks @ronak-guliani.
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
#### CLI / Onboarding
- Onboarding: show web search setup at the end (not the beginning).
- Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.
- Health: colorize “not configured” provider lines for easier scanning.
#### Control UI / TUI
- Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)
- UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.
- UI: always apply `?token=` from URL (fixes unauthorized after re-onboard).
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.
- TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`.
#### Agents / Auth / Tools / Sandbox
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer.
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
#### macOS / Apps
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
- macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
- macOS: update cron testing channel arg. (#896) — thanks @ngutman.
#### Channels / Messaging
- Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4.
- Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
- Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.
- Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.
- Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.
- Telegram: register dock native commands with underscores to avoid `BOT_COMMAND_INVALID` (#929, fixes #901) — thanks @grp06.
- Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.
- iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)
- WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.
- WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.
- WhatsApp: harden owner command auth.
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
#### Config / Doctor / Packaging
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
- Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
- Packaging: run `pnpm build` on `prepack` so npm publishes include fresh `dist/` output.
## 2026.1.13
### Fixes
- Installer: include `patches/` in the npm package so postinstall patching works for npm/bun installs.
- Postinstall: treat already-applied pnpm patches as no-ops to avoid npm/bun install failures.
- Packaging: pin `@mariozechner/pi-ai` to 0.45.7 and refresh patched dependency to match npm resolution.
## 2026.1.12-2
### Fixes
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`).
- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4.
## 2026.1.12-1
### Fixes
- Packaging: include `dist/channels/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/channels/registry.js`).
## 2026.1.12
### Highlights
- **BREAKING:** rename chat “providers” (Slack/Telegram/WhatsApp/…) to **channels** across CLI/RPC/config; legacy config keys auto-migrate on load (and are written back as `channels.*`).
- Memory: add vector search for agent memories (Markdown-only) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback.
- Plugins: restore full voice-call plugin parity (Telnyx/Twilio, streaming, inbound policies, tools/CLI).
- Models: add Synthetic provider plus Moonshot Kimi K2 0905 + turbo/thinking variants (with docs). (#811) — thanks @siraht; (#818) — thanks @mickahouan.
- Cron: one-shot schedules accept ISO timestamps (UTC) with optional delete-after-run; cron jobs can target a specific agent (CLI + macOS/Control UI).
- Agents: add compaction mode config with optional safeguard summarization and per-agent model fallbacks. (#700) — thanks @thewilloftheshadow; (#583) — thanks @mitschabaude-bot.
### New & Improved
- Memory: add custom OpenAI-compatible embedding endpoints; support OpenAI/local `node-llama-cpp` embeddings with per-agent overrides and provider metadata in tools/CLI. (#819) — thanks @mukhtharcm.
- Memory: new `clawdbot memory` CLI plus `memory_search`/`memory_get` tools with snippets + line ranges; index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default.
- Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config.
- Tools/Sandbox: add tool profiles + group shorthands; support tool-policy groups in `tools.sandbox.tools`; drop legacy `memory` shorthand; allow Docker bind mounts via `docker.binds`. (#790) — thanks @akonyer.
- Tools: add provider/model-specific tool policy overrides (`tools.byProvider`) to trim tool exposure per provider.
- Tools: add browser `scrollintoview` action; allow Claude/Gemini tool param aliases; allow thinking `xhigh` for GPT-5.2/Codex with safe downgrades. (#793) — thanks @hsrvc; (#444) — thanks @grp06.
- Gateway/CLI: add Tailscale binary discovery, custom bind mode, and probe auth retry; add `clawdbot dashboard` auto-open flow; default native slash commands to `"auto"` with per-provider overrides. (#740) — thanks @jeffersonwarrior.
- Auth/Onboarding: add Chutes OAuth (PKCE + refresh + onboarding choice); normalize API key inputs; default TUI onboarding to `deliver: false`. (#726) — thanks @FrieSei; (#791) — thanks @roshanasingh4.
- Providers: add `discord.allowBots`; trim legacy MiniMax M2 from default catalogs; route MiniMax vision to the Coding Plan VLM endpoint (also accepts `@/path/to/file.png` inputs). (#802) — thanks @zknicker.
- Gateway: allow Tailscale Serve identity headers to satisfy token auth; rebuild Control UI assets when protocol schema is newer. (#823) — thanks @roshanasingh4; (#786) — thanks @meaningfool.
- Heartbeat: default `ackMaxChars` to 300 so short `HEARTBEAT_OK` replies stay internal.
### Installer
- Install: run `clawdbot doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected.
### Fixes
- Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds.
- Tools: apply global tool allow/deny even when agent-specific tool policy is set.
- Models/Providers: treat credential validation failures as auth errors to trigger fallback; normalize `${ENV_VAR}` apiKey values and auto-fill missing provider keys; preserve explicit GitHub Copilot provider config + agent-dir auth profiles. (#822) — thanks @sebslight; (#705) — thanks @TAGOOZ.
- Auth: drop invalid auth profiles from ordering so environment keys can still be used for providers like MiniMax.
- Gemini: normalize Gemini 3 ids to preview variants; strip Gemini CLI tool call/response ids; downgrade missing `thought_signature`; strip Claude `msg_*` thought_signature fields to avoid base64 decode errors. (#795) — thanks @thewilloftheshadow; (#783) — thanks @ananth-vardhan-cn; (#793) — thanks @hsrvc; (#805) — thanks @marcmarg.
- Agents: auto-recover from compaction context overflow by resetting the session and retrying; propagate overflow details from embedded runs so callers can recover.
- MiniMax: strip malformed tool invocation XML; include `MiniMax-VL-01` in implicit provider for image pairing. (#809) — thanks @latitudeki5223.
- Onboarding/Auth: honor `CLAWDBOT_AGENT_DIR` / `PI_CODING_AGENT_DIR` when writing auth profiles (MiniMax). (#829) — thanks @roshanasingh4.
- Anthropic: handle `overloaded_error` with a friendly message and failover classification. (#832) — thanks @danielz1z.
- Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid incorrect role errors. (#804) — thanks @ThomsenDrake.
- Messaging: enforce context isolation for message tool sends; keep typing indicators alive during tool execution. (#793) — thanks @hsrvc; (#450, #447) — thanks @thewilloftheshadow.
- Auto-reply: `/status` allowlist behavior, reasoning-tag enforcement on fallback, and system-event enqueueing for elevated/reasoning toggles. (#810) — thanks @mcinteerj.
- System events: include local timestamps when events are injected into prompts. (#245) — thanks @thewilloftheshadow.
- Auto-reply: resolve ambiguous `/model` matches; fix streaming block reply media handling; keep >300 char heartbeat replies instead of dropping.
- Discord/Slack: centralize reply-thread planning; fix autoThread routing + add per-channel autoThread; avoid duplicate listeners; keep reasoning italics intact; allow clearing channel parents via message tool. (#800, #807) — thanks @davidguttman; (#744) — thanks @thewilloftheshadow.
- Telegram: preserve forum topic thread ids, persist polling offsets, respect account bindings in webhook mode, and show typing indicator in General topics. (#727, #739) — thanks @thewilloftheshadow; (#821) — thanks @gumadeiras; (#779) — thanks @azade-c.
- Slack: accept slash commands with or without leading `/` for custom command configs. (#798) — thanks @thewilloftheshadow.
- Cron: persist disabled jobs correctly; accept `jobId` aliases for update/run/remove params. (#205, #252) — thanks @thewilloftheshadow.
- Gateway/CLI: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides; `agents.list` respects explicit config; reduce noisy loopback WS logs during tests; run `clawdbot doctor --non-interactive` during updates. (#781) — thanks @ronyrus.
- Onboarding/Control UI: refuse invalid configs (run doctor first); quote Windows browser URLs for OAuth; keep chat scroll position unless the user is near the bottom. (#764) — thanks @mukhtharcm; (#794) — thanks @roshanasingh4; (#217) — thanks @thewilloftheshadow.
- Tools/UI: harden tool input schemas for strict providers; drop null-only union variants for Gemini schema cleanup; treat `maxChars: 0` as unlimited; keep TUI last streamed response instead of "(no output)". (#782) — thanks @AbhisekBasu1; (#796) — thanks @gabriel-trigo; (#747) — thanks @thewilloftheshadow.
- Connections UI: polish multi-account account cards. (#816) — thanks @steipete.
### Maintenance
- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai.
- Testing: update Vitest + browser-playwright to 4.0.17.
- Docs: add Amazon Bedrock provider notes and link from models/FAQ.
## 2026.1.11
@@ -20,9 +233,6 @@
- Agents: automatic pre-compaction memory flush turn to store durable memories before compaction.
### Changes
- Deps: update pi-agent-core/pi-ai/pi-coding-agent/pi-tui and refresh the pi-ai patch.
- Dev: bump @types/node.
- macOS: add wizard debug CLI and share wizard parsing helpers.
- CLI/Onboarding: simplify MiniMax auth choice to a single M2.1 option.
- CLI: configure section selection now loops until Continue.
- Docs: explain MiniMax vs MiniMax Lightning (speed vs cost) and restore LM Studio example.
@@ -30,20 +240,16 @@
- Onboarding/CLI: group model/auth choice by provider and label Z.AI as GLM 4.7.
- Onboarding/Docs: add Moonshot AI (Kimi K2) auth choice + config example.
- CLI/Onboarding: prompt to reuse detected API keys for Moonshot/MiniMax/Z.AI/Gemini/Anthropic/OpenCode.
- CLI/Onboarding: move MiniMax to the top of the provider list.
- CLI/Onboarding: add MiniMax M2.1 Lightning auth choice.
- CLI/Onboarding: show key previews when reusing detected API keys.
- Auto-reply: add compact `/model` picker (models + available providers) and show provider endpoints in `/model status`.
- Control UI: add Config tab model presets (MiniMax M2.1, GLM 4.7, Kimi) for one-click setup.
- Plugins: add extension loader (tools/RPC/CLI/services), discovery paths, and config schema + Control UI labels (uiHints).
- Plugins: add `clawdbot plugins install` (path/tgz/npm), plus `list|info|enable|disable|doctor` UX.
- Plugins: voice-call plugin now real (Twilio/log), adds start/status RPC/CLI/tool + tests.
- Docs: add plugins doc + cross-links from tools/skills/gateway config.
- Docs: clarify memory flush behavior + writable workspace requirement in Memory/Session/FAQ.
- Docs: add beginner-friendly plugin quick start + expand Voice Call plugin docs.
- Tests: add Docker plugin loader + tgz-install smoke test.
- Tests: extend Docker plugin E2E to cover installing from local folders (`plugins.load.paths`) and `file:` npm specs.
- Tests: add coverage for pre-compaction memory flush settings (including read-only/CLI skips).
- Tests: add coverage for pre-compaction memory flush settings.
- Tests: modernize live model smoke selection for current releases and enforce tools/images/thinking-high coverage. (#769) — thanks @steipete.
- Agents/Tools: add `apply_patch` tool for multi-file edits (experimental; gated by tools.exec.applyPatch; OpenAI-only).
- Agents/Tools: rename the bash tool to exec (config alias maintained). (#748) — thanks @myfunc.
@@ -70,17 +276,9 @@
- Installer UX: add `--install-method git|npm` and auto-detect source checkouts (prompt to update git checkout vs migrate to npm).
### Fixes
- Control UI: flatten nav into a single horizontal scroll row on tablet/mobile (and always show collapsed group items). (#771) — thanks @carlulsoe.
- macOS: start + await local gateway before onboarding wizard begins.
- macOS: cancel onboarding wizard on close, recover if the gateway drops the session, and time out stalled gateway connects.
- macOS: wizard debug CLI now surfaces error status instead of exiting as complete.
- Models/Onboarding: configure MiniMax (minimax.io) via Anthropic-compatible `/anthropic` endpoint by default (keep `minimax-api` as a legacy alias).
- Agents/Browser: cap Playwright AI snapshots for tool calls (maxChars); CLI snapshots remain full. (#763) — thanks @thesash.
- Models: normalize Gemini 3 Pro/Flash IDs to preview names for live model lookups. (#769) — thanks @steipete.
- CLI: fix guardCancel typing for configure prompts. (#769) — thanks @steipete.
- Providers: default groupPolicy to allowlist across providers and warn in doctor when groups are open.
- MS Teams: add groupPolicy/groupAllowFrom gating for group chats and warn when groups are open.
- Providers: strip tool call/result ids from Gemini CLI payloads to avoid API 400s. (#756)
- Gateway/WebChat: include handshake validation details in the WebSocket close reason for easier debugging; preserve close codes.
- Gateway/Auth: send invalid connect responses before closing the handshake; stabilize invalid-connect auth test.
- Gateway: tighten gateway listener detection.
@@ -92,23 +290,17 @@
- Config: expand `~` in `CLAWDBOT_CONFIG_PATH` and common path-like config fields (including `plugins.load.paths`); guard invalid `$include` paths. (#731) — thanks @pasogott.
- Agents: stop pre-creating session transcripts so first user messages persist in JSONL history.
- Agents: skip pre-compaction memory flush when the session workspace is read-only.
- Auto-reply: allow inline `/status` for allowlisted senders (stripped before the model); unauthorized senders see it as plain text.
- Auto-reply: include config-only allowlisted models in `/model` even when the catalog is partial.
- Auto-reply: allow fuzzy `/model` matches (e.g. `/model kimi` or `/model moonshot/kimi`) when unambiguous.
- Auto-reply: ignore inline `/status` directives unless the message is directive-only.
- CLI/Configure: enter the selected section immediately, then return to the section picker.
- CLI/Configure: apply the chosen auth model as default (skip the extra picker) and refresh the model catalog for new providers.
- Auto-reply: align `/think` default display with model reasoning defaults. (#751) — thanks @gabriel-trigo.
- Auto-reply: flush block reply buffers on tool boundaries. (#750) — thanks @sebslight.
- Auto-reply: allow sender fallback for command authorization when `SenderId` is empty (WhatsApp self-chat). (#755) — thanks @juanpablodlc.
- Auto-reply: treat whitespace-only sender ids as missing for command authorization (WhatsApp self-chat). (#766) — thanks @steipete.
- Heartbeat: refresh prompt text for updated defaults.
- Agents/Tools: use PowerShell on Windows to capture system utility output. (#748) — thanks @myfunc.
- Agents/Tools: normalize Claude Code-style read/write/edit params (file_path/old_string/new_string) and keep sandbox guards in place. (#768) — thanks @hsrvc.
- Docker: tolerate unset optional env vars in docker-setup.sh under strict mode. (#725) — thanks @petradonka.
- CLI/Update: preserve base environment when passing overrides to update subprocesses. (#713) — thanks @danielz1z.
- Agents: treat message tool errors as failures so fallback replies still send; require `to` + `message` for `action=send`. (#717) — thanks @theglove44.
- Agents: preserve reasoning items on tool-only turns.
- Agents: enforce `<final>` gating for reasoning-tag providers to prevent tag/reasoning leaks. (#754) — thanks @mcinteerj.
- Agents/Subagents: wait for completion before announcing, align wait timeout with run timeout, and make announce prompts more emphatic.
- Agents: route subagent transcripts to the target agent sessions directory and add regression coverage. (#708) — thanks @xMikeMickelson.
- Agents/Tools: preserve action enums when flattening tool schemas. (#708) — thanks @xMikeMickelson.
@@ -271,7 +463,7 @@
- Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj.
- Build: Docker build cache layer (#605) — thanks @zknicker.
- Auth: enable OAuth token refresh for Claude CLI credentials (`anthropic:claude-cli`) with bidirectional sync back to Claude Code storage (file on Linux/Windows, Keychain on macOS). This allows long-running agents to operate autonomously without manual re-authentication (#654 — thanks @radek-paclt).
- Auth: enable OAuth token refresh for Claude Code CLI credentials (`anthropic:claude-cli`) with bidirectional sync back to Claude Code storage (file on Linux/Windows, Keychain on macOS). This allows long-running agents to operate autonomously without manual re-authentication (#654 — thanks @radek-paclt).
## 2026.1.8

View File

@@ -13,7 +13,7 @@ Welcome to the lobster tank! 🦞
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
- **Shadow** - Discord + Slack subsystem
- GitHub: [@4shadowed](https://github.com/4shadowed) · X: [@4shad0wed](https://x.com/4shad0wed)
- GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed)
- **Jos** - Telegram, API, Nix mode
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)

132
README.md
View File

@@ -16,13 +16,13 @@
</p>
**Clawdbot** is a *personal AI assistant* you run on your own devices.
It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
[Website](https://clawdbot.com) · [Docs](https://docs.clawd.bot) · [Getting Started](https://docs.clawd.bot/start/getting-started) · [Updating](https://docs.clawd.bot/install/updating) · [Showcase](https://docs.clawd.bot/start/showcase) · [FAQ](https://docs.clawd.bot/start/faq) · [Wizard](https://docs.clawd.bot/start/wizard) · [Nix](https://github.com/clawdbot/nix-clawdbot) · [Docker](https://docs.clawd.bot/install/docker) · [Discord](https://discord.gg/clawd)
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
Works with npm, pnpm, or bun.
New install? Start here: [Getting started](https://docs.clawd.bot/start/getting-started)
@@ -54,7 +54,7 @@ The wizard installs the Gateway daemon (launchd/systemd user service) so it stay
Runtime: **Node ≥22**.
Full beginner guide (auth, pairing, providers): [Getting started](https://docs.clawd.bot/start/getting-started)
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.clawd.bot/start/getting-started)
```bash
clawdbot onboard --install-daemon
@@ -64,7 +64,7 @@ clawdbot gateway --port 18789 --verbose
# Send a message
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord/Microsoft Teams)
clawdbot agent --message "Ship checklist" --thinking high
```
@@ -96,24 +96,28 @@ Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted i
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
Default behavior on Telegram/WhatsApp/Signal/iMessage/Discord/Slack:
- **DM pairing** (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
- Approve with: `clawdbot pairing approve <provider> <code>` (then the sender is added to a local allowlist store).
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the provider allowlist (`allowFrom` / `discord.dm.allowFrom` / `slack.dm.allowFrom`).
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Slack:
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
- Approve with: `clawdbot pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`).
Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## Highlights
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, providers, tools, and events.
- **[Multi-provider inbox](https://docs.clawd.bot/providers)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound providers/accounts/peers to isolated agents (workspaces + per-agent sessions).
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat, macOS, iOS/Android.
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
- **[First-class tools](https://docs.clawd.bot/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
- **[Companion apps](https://docs.clawd.bot/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes).
- **[Onboarding](https://docs.clawd.bot/start/wizard) + [skills](https://docs.clawd.bot/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=clawdbot/clawdbot&type=date&legend=top-left)](https://www.star-history.com/#clawdbot/clawdbot&type=date&legend=top-left)
## Everything we built so far
### Core platform
@@ -123,9 +127,9 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
- [Session model](https://docs.clawd.bot/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawd.bot/concepts/groups).
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
### Providers
- [Providers](https://docs.clawd.bot/providers): [WhatsApp](https://docs.clawd.bot/providers/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/providers/telegram) (grammY), [Slack](https://docs.clawd.bot/providers/slack) (Bolt), [Discord](https://docs.clawd.bot/providers/discord) (discord.js), [Signal](https://docs.clawd.bot/providers/signal) (signal-cli), [iMessage](https://docs.clawd.bot/providers/imessage) (imsg), [WebChat](https://docs.clawd.bot/web/webchat).
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-provider chunking and routing. Provider rules: [Providers](https://docs.clawd.bot/providers).
### Channels
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/webchat).
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
### Apps + nodes
- [macOS app](https://docs.clawd.bot/platforms/macos): menu bar control plane, [Voice Wake](https://docs.clawd.bot/nodes/voicewake)/PTT, [Talk Mode](https://docs.clawd.bot/nodes/talk) overlay, [WebChat](https://docs.clawd.bot/web/webchat), debug tools, [remote gateway](https://docs.clawd.bot/gateway/remote) control.
@@ -141,10 +145,10 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
- [Skills platform](https://docs.clawd.bot/tools/skills): bundled, managed, and workspace skills with install gating + UI.
### Runtime + safety
- [Provider routing](https://docs.clawd.bot/concepts/provider-routing), [retry policy](https://docs.clawd.bot/concepts/retry), and [streaming/chunking](https://docs.clawd.bot/concepts/streaming).
- [Channel routing](https://docs.clawd.bot/concepts/channel-routing), [retry policy](https://docs.clawd.bot/concepts/retry), and [streaming/chunking](https://docs.clawd.bot/concepts/streaming).
- [Presence](https://docs.clawd.bot/concepts/presence), [typing indicators](https://docs.clawd.bot/concepts/typing-indicators), and [usage tracking](https://docs.clawd.bot/concepts/usage-tracking).
- [Models](https://docs.clawd.bot/concepts/models), [model failover](https://docs.clawd.bot/concepts/model-failover), and [session pruning](https://docs.clawd.bot/concepts/session-pruning).
- [Security](https://docs.clawd.bot/gateway/security) and [troubleshooting](https://docs.clawd.bot/providers/troubleshooting).
- [Security](https://docs.clawd.bot/gateway/security) and [troubleshooting](https://docs.clawd.bot/channels/troubleshooting).
### Ops + packaging
- [Control UI](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/web/webchat) served directly from the Gateway.
@@ -155,7 +159,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## How it works (short)
```
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Microsoft Teams / WebChat
┌───────────────────────────────┐
@@ -200,7 +204,7 @@ Details: [Tailscale guide](https://docs.clawd.bot/gateway/tailscale) · [Web sur
Its perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute devicelocal actions when needed.
- **Gateway host** runs the exec tool and provider connections by default.
- **Gateway host** runs the exec tool and channel connections by default.
- **Device nodes** run devicelocal actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
In short: exec runs where the Gateway lives; device actions run where the device lives.
@@ -238,12 +242,12 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc
## Chat commands
Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):
Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands are owner-only):
- `/status` — compact session status (model + tokens, cost when available)
- `/new` or `/reset` — reset the session
- `/compact` — compact session context (summary)
- `/think <level>` — off|minimal|low|medium|high
- `/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
- `/restart` — restart the gateway (owner-only in groups)
@@ -311,50 +315,59 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
Details: [Security guide](https://docs.clawd.bot/gateway/security) · [Docker + sandboxing](https://docs.clawd.bot/install/docker) · [Sandbox config](https://docs.clawd.bot/gateway/configuration)
### [WhatsApp](https://docs.clawd.bot/providers/whatsapp)
### [WhatsApp](https://docs.clawd.bot/channels/whatsapp)
- Link the device: `pnpm clawdbot providers login` (stores creds in `~/.clawdbot/credentials`).
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
- If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
- Link the device: `pnpm clawdbot channels login` (stores creds in `~/.clawdbot/credentials`).
- Allowlist who can talk to the assistant via `channels.whatsapp.allowFrom`.
- If `channels.whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
### [Telegram](https://docs.clawd.bot/providers/telegram)
### [Telegram](https://docs.clawd.bot/channels/telegram)
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `telegram.allowFrom` or `telegram.webhookUrl` as needed.
- Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` (env wins).
- Optional: set `channels.telegram.groups` (with `channels.telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `channels.telegram.allowFrom` or `channels.telegram.webhookUrl` as needed.
```json5
{
telegram: {
botToken: "123456:ABCDEF"
channels: {
telegram: {
botToken: "123456:ABCDEF"
}
}
}
```
### [Slack](https://docs.clawd.bot/providers/slack)
### [Slack](https://docs.clawd.bot/channels/slack)
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `slack.botToken` + `slack.appToken`).
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `channels.slack.botToken` + `channels.slack.appToken`).
### [Discord](https://docs.clawd.bot/providers/discord)
### [Discord](https://docs.clawd.bot/channels/discord)
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins).
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.dm.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
```json5
{
discord: {
token: "1234abcd"
channels: {
discord: {
token: "1234abcd"
}
}
}
```
### [Signal](https://docs.clawd.bot/providers/signal)
### [Signal](https://docs.clawd.bot/channels/signal)
- Requires `signal-cli` and a `signal` config section.
- Requires `signal-cli` and a `channels.signal` config section.
### [iMessage](https://docs.clawd.bot/providers/imessage)
### [iMessage](https://docs.clawd.bot/channels/imessage)
- macOS only; Messages must be signed in.
- If `imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
### [Microsoft Teams](https://docs.clawd.bot/channels/msteams)
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
### [WebChat](https://docs.clawd.bot/web/webchat)
@@ -386,7 +399,7 @@ Use these when youre past the onboarding flow and want the deeper reference.
- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/automation/gmail-pubsub)
- [Learn the macOS menu bar companion details.](https://docs.clawd.bot/platforms/mac/menu-bar)
- [Platform guides: Windows (WSL2)](https://docs.clawd.bot/platforms/windows), [Linux](https://docs.clawd.bot/platforms/linux), [macOS](https://docs.clawd.bot/platforms/macos), [iOS](https://docs.clawd.bot/platforms/ios), [Android](https://docs.clawd.bot/platforms/android)
- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/providers/troubleshooting)
- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/channels/troubleshooting)
- [Review security guidance before exposing anything.](https://docs.clawd.bot/gateway/security)
## Advanced docs (discovery + control)
@@ -455,23 +468,28 @@ AI/vibe-coded PRs welcome! 🤖
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
Core contributors:
- @cpojer — Telegram onboarding UX + docs
Thanks to all clawtributors:
<p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/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/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/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/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/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="Sebastian Barrios" title="Sebastian Barrios"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a>
<a href="https://github.com/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/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/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/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/search?q=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/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/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/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/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=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></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/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/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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=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/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=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/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/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a>
<a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=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/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/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></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/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/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/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/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/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/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/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/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/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/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/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=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p>

15
SECURITY.md Normal file
View File

@@ -0,0 +1,15 @@
# Security Policy
If you believe youve found a security issue in Clawdbot, please report it privately.
## Reporting
- Email: `steipete@gmail.com`
- What to include: reproduction steps, impact assessment, and (if possible) a minimal PoC.
## Operational Guidance
For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see:
- `https://docs.clawd.bot/gateway/security`

View File

@@ -34,8 +34,7 @@ extension AttributedString {
var ranges: [Range<AttributedString.Index>] = []
for wordRange in wordRanges {
if let lastRange = ranges.last,
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength
{
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength {
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
} else {
ranges.append(wordRange)

View File

@@ -13,8 +13,7 @@ public actor TranscriptsStore {
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
fileURL = dir.appendingPathComponent("transcripts.log")
if let data = try? Data(contentsOf: fileURL),
let text = String(data: data, encoding: .utf8)
{
let text = String(data: data, encoding: .utf8) {
entries = text.split(separator: "\n").map(String.init).suffix(limit)
}
}

View File

@@ -24,8 +24,7 @@ public struct WakeWordGateConfig: Sendable, Equatable {
public init(
triggers: [String],
minPostTriggerGap: TimeInterval = 0.45,
minCommandLength: Int = 1)
{
minCommandLength: Int = 1) {
self.triggers = triggers
self.minPostTriggerGap = minPostTriggerGap
self.minCommandLength = minCommandLength
@@ -57,6 +56,12 @@ public enum WakeWordGate {
let tokens: [String]
}
private struct MatchCandidate {
let index: Int
let triggerEnd: TimeInterval
let gap: TimeInterval
}
public static func match(
transcript: String,
segments: [WakeWordSegment],
@@ -68,7 +73,7 @@ public enum WakeWordGate {
let tokens = normalizeSegments(segments)
guard !tokens.isEmpty else { return nil }
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
var best: MatchCandidate?
for trigger in triggerTokens {
let count = trigger.tokens.count
@@ -84,7 +89,7 @@ public enum WakeWordGate {
if let best, i <= best.index { continue }
best = (i, triggerEnd, gap)
best = MatchCandidate(index: i, triggerEnd: triggerEnd, gap: gap)
}
}

View File

@@ -24,7 +24,7 @@ enum CLIRegistry {
subcommands: [
descriptor(for: ServiceInstall.self),
descriptor(for: ServiceUninstall.self),
descriptor(for: ServiceStatus.self),
descriptor(for: ServiceStatus.self)
])
let doctorDesc = descriptor(for: DoctorCommand.self)
let setupDesc = descriptor(for: SetupCommand.self)
@@ -54,7 +54,7 @@ enum CLIRegistry {
startDesc,
stopDesc,
restartDesc,
statusDesc,
statusDesc
])
return [root]
}

View File

@@ -25,7 +25,7 @@ private enum LaunchdHelper {
"Label": label,
"ProgramArguments": [executable, "serve"],
"RunAtLoad": true,
"KeepAlive": true,
"KeepAlive": true
]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: plistURL)

View File

@@ -25,78 +25,123 @@ private func dispatch(invocation: CommandInvocation) async throws {
switch first {
case "swabble":
guard path.count >= 2 else { throw CommanderProgramError.missingSubcommand(command: "swabble") }
let sub = path[1]
switch sub {
case "serve":
var cmd = ServeCommand(parsed: parsed)
try await cmd.run()
case "transcribe":
var cmd = TranscribeCommand(parsed: parsed)
try await cmd.run()
case "test-hook":
var cmd = TestHookCommand(parsed: parsed)
try await cmd.run()
case "mic":
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "mic") }
let micSub = path[2]
if micSub == "list" {
var cmd = MicList(parsed: parsed)
try await cmd.run()
} else if micSub == "set" {
var cmd = MicSet(parsed: parsed)
try await cmd.run()
} else {
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
}
case "service":
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "service") }
let svcSub = path[2]
switch svcSub {
case "install":
var cmd = ServiceInstall()
try await cmd.run()
case "uninstall":
var cmd = ServiceUninstall()
try await cmd.run()
case "status":
var cmd = ServiceStatus()
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
}
case "doctor":
var cmd = DoctorCommand(parsed: parsed)
try await cmd.run()
case "setup":
var cmd = SetupCommand(parsed: parsed)
try await cmd.run()
case "health":
var cmd = HealthCommand(parsed: parsed)
try await cmd.run()
case "tail-log":
var cmd = TailLogCommand(parsed: parsed)
try await cmd.run()
case "start":
var cmd = StartCommand()
try await cmd.run()
case "stop":
var cmd = StopCommand()
try await cmd.run()
case "restart":
var cmd = RestartCommand()
try await cmd.run()
case "status":
var cmd = StatusCommand()
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
}
try await dispatchSwabble(parsed: parsed, path: path)
default:
throw CommanderProgramError.unknownCommand(first)
}
}
@available(macOS 26.0, *)
@MainActor
private func dispatchSwabble(parsed: ParsedValues, path: [String]) async throws {
let sub = try subcommand(path, index: 1, command: "swabble")
switch sub {
case "mic":
try await dispatchMic(parsed: parsed, path: path)
case "service":
try await dispatchService(path: path)
default:
let handlers = swabbleHandlers(parsed: parsed)
guard let handler = handlers[sub] else {
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
}
try await handler()
}
}
@available(macOS 26.0, *)
@MainActor
private func swabbleHandlers(parsed: ParsedValues) -> [String: () async throws -> Void] {
[
"serve": {
var cmd = ServeCommand(parsed: parsed)
try await cmd.run()
},
"transcribe": {
var cmd = TranscribeCommand(parsed: parsed)
try await cmd.run()
},
"test-hook": {
var cmd = TestHookCommand(parsed: parsed)
try await cmd.run()
},
"doctor": {
var cmd = DoctorCommand(parsed: parsed)
try await cmd.run()
},
"setup": {
var cmd = SetupCommand(parsed: parsed)
try await cmd.run()
},
"health": {
var cmd = HealthCommand(parsed: parsed)
try await cmd.run()
},
"tail-log": {
var cmd = TailLogCommand(parsed: parsed)
try await cmd.run()
},
"start": {
var cmd = StartCommand()
try await cmd.run()
},
"stop": {
var cmd = StopCommand()
try await cmd.run()
},
"restart": {
var cmd = RestartCommand()
try await cmd.run()
},
"status": {
var cmd = StatusCommand()
try await cmd.run()
}
]
}
@available(macOS 26.0, *)
@MainActor
private func dispatchMic(parsed: ParsedValues, path: [String]) async throws {
let micSub = try subcommand(path, index: 2, command: "mic")
switch micSub {
case "list":
var cmd = MicList(parsed: parsed)
try await cmd.run()
case "set":
var cmd = MicSet(parsed: parsed)
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
}
}
@available(macOS 26.0, *)
@MainActor
private func dispatchService(path: [String]) async throws {
let svcSub = try subcommand(path, index: 2, command: "service")
switch svcSub {
case "install":
var cmd = ServiceInstall()
try await cmd.run()
case "uninstall":
var cmd = ServiceUninstall()
try await cmd.run()
case "status":
var cmd = ServiceStatus()
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
}
}
private func subcommand(_ path: [String], index: Int, command: String) throws -> String {
guard path.count > index else {
throw CommanderProgramError.missingSubcommand(command: command)
}
return path[index]
}
if #available(macOS 26.0, *) {
let exitCode = await runCLI()
exit(exitCode)

View File

@@ -3,160 +3,209 @@
<channel>
<title>Clawdbot</title>
<item>
<title>2026.1.11-2</title>
<pubDate>Mon, 12 Jan 2026 10:25:53 +0000</pubDate>
<title>2026.1.14-1</title>
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5210</sparkle:version>
<sparkle:shortVersionString>2026.1.11-2</sparkle:shortVersionString>
<sparkle:version>5825</sparkle:version>
<sparkle:shortVersionString>2026.1.14-1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.11-2</h2>
<h3>Fixes</h3>
<ul>
<li>Installer: ensure the CLI entrypoint is executable after npm installs.</li>
<li>Packaging: include <code>dist/plugins/</code> in the npm package to avoid missing module errors.</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.11-2/Clawdbot-2026.1.11-2.zip" length="19860732" type="application/octet-stream" sparkle:edSignature="0UG+d9v3Qf5F9vs/KozUB404WpHjFBQRVoRuhwtzF8kpU7jJmmGlQzh1c61E+LMN4fHcljpxIwHHrvvIfRyrCw=="/>
</item>
<item>
<title>2026.1.11-1</title>
<pubDate>Mon, 12 Jan 2026 09:53:46 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5207</sparkle:version>
<sparkle:shortVersionString>2026.1.11-1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.11-1</h2>
<h3>Fixes</h3>
<ul>
<li>Installer: include <code>patches/</code> in the npm package so postinstall patching works for npm/bun 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.11-1/Clawdbot-2026.1.11-1.zip" length="19860761" type="application/octet-stream" sparkle:edSignature="CXKzzha/s6cGBeF0TMz+cV8/pfqoAL9ZyNVacYRLnnHEwA1cMbOWRftpGRhYe4HknVQYYBgNQqZK2lBxpOZgBg=="/>
</item>
<item>
<title>2026.1.11</title>
<pubDate>Mon, 12 Jan 2026 09:37:49 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5205</sparkle:version>
<sparkle:shortVersionString>2026.1.11</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.11</h2>
<description><![CDATA[<h2>Clawdbot 2026.1.14-1</h2>
<h3>Highlights</h3>
<ul>
<li>Plugins are now first-class: loader + CLI management, plus the new Voice Call plugin.</li>
<li>Config: modular <code>$include</code> support for split config files. (#731) — thanks @pasogott.</li>
<li>Agents/Pi: reserve compaction headroom so pre-compaction memory writes can run before auto-compaction.</li>
<li>Agents: automatic pre-compaction memory flush turn to store durable memories before compaction.</li>
<li>Web search: <code>web_search</code>/<code>web_fetch</code> tools (Brave API) + first-time setup in onboarding/configure.</li>
<li>Browser control: Chrome extension relay takeover mode + remote browser control via <code>clawdbot browser serve</code>.</li>
<li>Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.</li>
<li>Security: expanded <code>clawdbot security audit</code> (+ <code>--fix</code>), detect-secrets CI scan, and a <code>SECURITY.md</code> reporting policy.</li>
</ul>
<h3>Changes</h3>
<h4>Web Tools</h4>
<ul>
<li>Deps: update pi-agent-core/pi-ai/pi-coding-agent/pi-tui and refresh the pi-ai patch.</li>
<li>Dev: bump @types/node.</li>
<li>macOS: add wizard debug CLI and share wizard parsing helpers.</li>
<li>CLI/Onboarding: simplify MiniMax auth choice to a single M2.1 option.</li>
<li>CLI: configure section selection now loops until Continue.</li>
<li>Docs: explain MiniMax vs MiniMax Lightning (speed vs cost) and restore LM Studio example.</li>
<li>Docs: add Cerebras GLM 4.6/4.7 config example (OpenAI-compatible endpoint).</li>
<li>Onboarding/CLI: group model/auth choice by provider and label Z.AI as GLM 4.7.</li>
<li>Onboarding/Docs: add Moonshot AI (Kimi K2) auth choice + config example.</li>
<li>CLI/Onboarding: prompt to reuse detected API keys for Moonshot/MiniMax/Z.AI/Gemini/Anthropic/OpenCode.</li>
<li>CLI/Onboarding: move MiniMax to the top of the provider list.</li>
<li>CLI/Onboarding: add MiniMax M2.1 Lightning auth choice.</li>
<li>CLI/Onboarding: show key previews when reusing detected API keys.</li>
<li>Auto-reply: add compact <code>/model</code> picker (models + available providers) and show provider endpoints in <code>/model status</code>.</li>
<li>Control UI: add Config tab model presets (MiniMax M2.1, GLM 4.7, Kimi) for one-click setup.</li>
<li>Plugins: add extension loader (tools/RPC/CLI/services), discovery paths, and config schema + Control UI labels (uiHints).</li>
<li>Plugins: add <code>clawdbot plugins install</code> (path/tgz/npm), plus <code>list|info|enable|disable|doctor</code> UX.</li>
<li>Plugins: voice-call plugin now real (Twilio/log), adds start/status RPC/CLI/tool + tests.</li>
<li>Docs: add plugins doc + cross-links from tools/skills/gateway config.</li>
<li>Docs: clarify memory flush behavior + writable workspace requirement in Memory/Session/FAQ.</li>
<li>Docs: add beginner-friendly plugin quick start + expand Voice Call plugin docs.</li>
<li>Tests: add Docker plugin loader + tgz-install smoke test.</li>
<li>Tests: extend Docker plugin E2E to cover installing from local folders (<code>plugins.load.paths</code>) and <code>file:</code> npm specs.</li>
<li>Tests: add coverage for pre-compaction memory flush settings (including read-only/CLI skips).</li>
<li>Tests: modernize live model smoke selection for current releases and enforce tools/images/thinking-high coverage. (#769) — thanks @steipete.</li>
<li>Agents/Tools: add <code>apply_patch</code> tool for multi-file edits (experimental; gated by tools.exec.applyPatch; OpenAI-only).</li>
<li>Agents/Tools: rename the bash tool to exec (config alias maintained). (#748) — thanks @myfunc.</li>
<li>Agents: add pre-compaction memory flush config (<code>agents.defaults.compaction.*</code>) with a soft threshold + system prompt.</li>
<li>Config: add <code>$include</code> directive for modular config files. (#731) — thanks @pasogott.</li>
<li>Build: set pnpm minimum release age to 2880 minutes (2 days). (#718) — thanks @dan-dr.</li>
<li>macOS: prompt to install the global <code>clawdbot</code> CLI when missing in local mode; install via <code>clawd.bot/install-cli.sh</code> (no onboarding) and use external launchd/CLI instead of the embedded gateway runtime.</li>
<li>Docs: add gog calendar event color IDs from <code>gog calendar colors</code>. (#715) — thanks @mjrussell.</li>
<li>Cron/CLI: add <code>--model</code> flag to cron add/edit commands. (#711) — thanks @mjrussell.</li>
<li>Cron/CLI: trim model overrides on cron edits and document main-session guidance. (#711) — thanks @mjrussell.</li>
<li>Skills: bundle <code>skill-creator</code> to guide creating and packaging skills.</li>
<li>Providers: add per-DM history limit overrides (<code>dmHistoryLimit</code>) with provider-level config. (#728) — thanks @pkrmf.</li>
<li>Discord: expose channel/category management actions in the message tool. (#730) — thanks @NicholasSpisak.</li>
<li>Docs: rename README “macOS app” section to “Apps”. (#733) — thanks @AbhisekBasu1.</li>
<li>Gateway: require <code>client.id</code> in WebSocket connect params; use <code>client.instanceId</code> for presence de-dupe; update docs/tests.</li>
<li>macOS: remove the attach-only gateway setting; local mode now always manages launchd while still attaching to an existing gateway if present.</li>
<li>Tools: add <code>web_search</code>/<code>web_fetch</code> (Brave API), including helpful setup hints when the key is missing.</li>
<li>Tools: enable <code>web_fetch</code> by default (unless explicitly disabled in config).</li>
<li>CLI/Docs: add <code>clawdbot configure --section web</code> for storing Brave API keys and update onboarding tips.</li>
</ul>
<h3>Installer</h3>
<h4>Browser / Control UI</h4>
<ul>
<li>Postinstall: replace <code>git apply</code> with builtin JS patcher (works npm/pnpm/bun; no git dependency) plus regression tests.</li>
<li>Postinstall: skip pnpm patch fallback when the new patcher is active.</li>
<li>Installer tests: add root+non-root docker smokes, CI workflow to fetch clawd.bot scripts and run install sh/cli with onboarding skipped.</li>
<li>Installer UX: support <code>CLAWDBOT_NO_ONBOARD=1</code> for non-interactive installs; fix npm prefix on Linux and auto-install git.</li>
<li>Installer UX: add <code>install.sh --help</code> with flags/env and git install hint.</li>
<li>Installer UX: add <code>--install-method git|npm</code> and auto-detect source checkouts (prompt to update git checkout vs migrate to npm).</li>
<li>Browser: add Chrome extension relay takeover mode (toolbar button) + <code>clawdbot browser serve</code> remote control + <code>browser.controlToken</code>.</li>
<li>Browser: ship a built-in <code>chrome</code> profile for extension relay and start the relay automatically when running locally.</li>
<li>Browser: default <code>browser.defaultProfile</code> to <code>chrome</code> (existing Chrome takeover mode).</li>
<li>Browser: add <code>clawdbot browser extension install/path</code> and copy extension path to clipboard.</li>
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
<li>Control UI: show raw any-map entries in config views; move Docs link into the left nav.</li>
</ul>
<h4>Plugins</h4>
<ul>
<li>Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.</li>
<li>Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.</li>
<li>Channels: add Matrix plugin (external) with docs + onboarding hooks.</li>
<li>Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.</li>
</ul>
<h4>Security</h4>
<ul>
<li>Security: expand <code>clawdbot security audit</code> checks and publish a <code>SECURITY.md</code> reporting policy.</li>
<li>Security: extend <code>clawdbot security audit --fix</code> to tighten more sensitive state paths.</li>
<li>Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.</li>
</ul>
<h4>Onboarding / Daemon</h4>
<ul>
<li>Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require <code>--accept-risk</code> for <code>--non-interactive</code>.</li>
<li>Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.</li>
</ul>
<h4>Auth / Usage / Config</h4>
<ul>
<li>Usage: add MiniMax coding plan usage tracking.</li>
<li>Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.</li>
<li>Agents: add optional auth-profile copy prompt on <code>agents add</code> and improve auth error messaging.</li>
<li>Auth: add dynamic template variables to <code>messages.responsePrefix</code>. (#928) — thanks @sebslight.</li>
<li>Config: add <code>channels.<provider>.configWrites</code> gating for channel-initiated config writes; migrate Slack channel IDs.</li>
</ul>
<h4>Channels</h4>
<ul>
<li>Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.</li>
<li>WhatsApp: add <code>channels.whatsapp.sendReadReceipts</code> to disable auto read receipts. (#882) — thanks @chrisrodz.</li>
</ul>
<h4>Docs</h4>
<ul>
<li>Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.</li>
<li>Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.</li>
<li>Docs: expand gateway security hardening guidance and incident response checklist.</li>
<li>Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.</li>
<li>Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)</li>
<li>Docs: add per-command CLI doc pages and link them from <code>clawdbot <command> --help</code>.</li>
<li>Docs: add multi-gateway guide (sidebar + nav).</li>
</ul>
<h3>Fixes</h3>
<h4>Gateway / Daemon / Sessions</h4>
<ul>
<li>Control UI: flatten nav into a single horizontal scroll row on tablet/mobile (and always show collapsed group items). (#771) — thanks @carlulsoe.</li>
<li>macOS: start + await local gateway before onboarding wizard begins.</li>
<li>macOS: cancel onboarding wizard on close, recover if the gateway drops the session, and time out stalled gateway connects.</li>
<li>macOS: wizard debug CLI now surfaces error status instead of exiting as complete.</li>
<li>Models/Onboarding: configure MiniMax (minimax.io) via Anthropic-compatible <code>/anthropic</code> endpoint by default (keep <code>minimax-api</code> as a legacy alias).</li>
<li>Agents/Browser: cap Playwright AI snapshots for tool calls (maxChars); CLI snapshots remain full. (#763) — thanks @thesash.</li>
<li>Models: normalize Gemini 3 Pro/Flash IDs to preview names for live model lookups. (#769) — thanks @steipete.</li>
<li>CLI: fix guardCancel typing for configure prompts. (#769) — thanks @steipete.</li>
<li>Providers: default groupPolicy to allowlist across providers and warn in doctor when groups are open.</li>
<li>MS Teams: add groupPolicy/groupAllowFrom gating for group chats and warn when groups are open.</li>
<li>Providers: strip tool call/result ids from Gemini CLI payloads to avoid API 400s. (#756)</li>
<li>Gateway/WebChat: include handshake validation details in the WebSocket close reason for easier debugging; preserve close codes.</li>
<li>Gateway/Auth: send invalid connect responses before closing the handshake; stabilize invalid-connect auth test.</li>
<li>Gateway: tighten gateway listener detection.</li>
<li>Control UI: hide onboarding chat when configured and guard the mobile chat sidebar overlay.</li>
<li>Auth: read Codex keychain credentials and make the lookup platform-aware.</li>
<li>macOS/Release: avoid bundling dist artifacts in relay builds and generate appcasts from zip-only sources.</li>
<li>Doctor: surface plugin diagnostics in the report.</li>
<li>Plugins: treat <code>plugins.load.paths</code> directory entries as package roots when they contain <code>package.json</code> + <code>clawdbot.extensions</code>; load plugin packages from config dirs; extract archives without system tar.</li>
<li>Config: expand <code>~</code> in <code>CLAWDBOT_CONFIG_PATH</code> and common path-like config fields (including <code>plugins.load.paths</code>); guard invalid <code>$include</code> paths. (#731) — thanks @pasogott.</li>
<li>Agents: stop pre-creating session transcripts so first user messages persist in JSONL history.</li>
<li>Agents: skip pre-compaction memory flush when the session workspace is read-only.</li>
<li>Auto-reply: allow inline <code>/status</code> for allowlisted senders (stripped before the model); unauthorized senders see it as plain text.</li>
<li>Auto-reply: include config-only allowlisted models in <code>/model</code> even when the catalog is partial.</li>
<li>Auto-reply: allow fuzzy <code>/model</code> matches (e.g. <code>/model kimi</code> or <code>/model moonshot/kimi</code>) when unambiguous.</li>
<li>Auto-reply: ignore inline <code>/status</code> directives unless the message is directive-only.</li>
<li>CLI/Configure: enter the selected section immediately, then return to the section picker.</li>
<li>CLI/Configure: apply the chosen auth model as default (skip the extra picker) and refresh the model catalog for new providers.</li>
<li>Auto-reply: align <code>/think</code> default display with model reasoning defaults. (#751) — thanks @gabriel-trigo.</li>
<li>Auto-reply: flush block reply buffers on tool boundaries. (#750) — thanks @sebslight.</li>
<li>Auto-reply: allow sender fallback for command authorization when <code>SenderId</code> is empty (WhatsApp self-chat). (#755) — thanks @juanpablodlc.</li>
<li>Heartbeat: refresh prompt text for updated defaults.</li>
<li>Agents/Tools: use PowerShell on Windows to capture system utility output. (#748) — thanks @myfunc.</li>
<li>Agents/Tools: normalize Claude Code-style read/write/edit params (file_path/old_string/new_string) and keep sandbox guards in place. (#768) — thanks @hsrvc.</li>
<li>Docker: tolerate unset optional env vars in docker-setup.sh under strict mode. (#725) — thanks @petradonka.</li>
<li>CLI/Update: preserve base environment when passing overrides to update subprocesses. (#713) — thanks @danielz1z.</li>
<li>Agents: treat message tool errors as failures so fallback replies still send; require <code>to</code> + <code>message</code> for <code>action=send</code>. (#717) — thanks @theglove44.</li>
<li>Agents: preserve reasoning items on tool-only turns.</li>
<li>Agents: enforce <code><final></code> gating for reasoning-tag providers to prevent tag/reasoning leaks. (#754) — thanks @mcinteerj.</li>
<li>Agents/Subagents: wait for completion before announcing, align wait timeout with run timeout, and make announce prompts more emphatic.</li>
<li>Agents: route subagent transcripts to the target agent sessions directory and add regression coverage. (#708) — thanks @xMikeMickelson.</li>
<li>Agents/Tools: preserve action enums when flattening tool schemas. (#708) — thanks @xMikeMickelson.</li>
<li>Gateway/Agents: canonicalize main session aliases for store writes and add regression coverage. (#709) — thanks @xMikeMickelson.</li>
<li>Agents: reset sessions and retry when auto-compaction overflows instead of crashing the gateway.</li>
<li>Providers/Telegram: normalize command mentions for consistent parsing. (#729) — thanks @obviyus.</li>
<li>Providers: skip DM history limit handling for non-DM sessions. (#728) — thanks @pkrmf.</li>
<li>Sandbox: fix non-main mode incorrectly sandboxing the main DM session and align <code>/status</code> runtime reporting with effective sandbox state.</li>
<li>Sandbox/Gateway: treat <code>agent:<id>:main</code> as a main-session alias when <code>session.mainKey</code> is customized (backwards compatible).</li>
<li>Auto-reply: fast-path allowlisted slash commands (inline <code>/help</code>/<code>/commands</code>/<code>/status</code>/<code>/whoami</code> stripped before model).</li>
<li>Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.</li>
<li>Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare <code>main</code> alias).</li>
<li>Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.</li>
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
<li>Browser: persist role snapshot refs per CDP target so <code>snapshot</code> → <code>act</code> clicks work even if Playwright returns a different Page instance.</li>
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
<li>Daemon: clear persisted launchd disabled state before bootstrap (fixes <code>daemon install</code> after uninstall). (#849) — thanks @ndraiman.</li>
<li>Sessions: return deep clones (<code>structuredClone</code>) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.</li>
<li>Heartbeat: keep <code>updatedAt</code> monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.</li>
<li>Agent: clear run context after CLI runs (<code>clearAgentRunContext</code>) to avoid runaway contexts. (#934) — thanks @ronak-guliani.</li>
<li>Gateway/Dev: ensure <code>pnpm gateway:dev</code> always uses the dev profile config + state (<code>~/.clawdbot-dev</code>).</li>
</ul>
<h4>CLI / Onboarding</h4>
<ul>
<li>Onboarding: show web search setup at the end (not the beginning).</li>
<li>Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.</li>
<li>Health: colorize “not configured” provider lines for easier scanning.</li>
</ul>
<h4>Control UI / TUI</h4>
<ul>
<li>Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)</li>
<li>UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.</li>
<li>UI: always apply <code>?token=</code> from URL (fixes unauthorized after re-onboard).</li>
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
<li>TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.</li>
<li>TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.</li>
<li>TUI: show LLM error messages (rate limits, auth, etc.) instead of <code>(no output)</code>.</li>
</ul>
<h4>Agents / Auth / Tools / Sandbox</h4>
<ul>
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
<li>Logging: tolerate <code>EIO</code> from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.</li>
<li>Sandbox: restore <code>docker.binds</code> config validation and preserve configured PATH for <code>docker exec</code>. (#873) — thanks @akonyer.</li>
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
</ul>
<h4>macOS / Apps</h4>
<ul>
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
<li>macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.</li>
<li>macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)</li>
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
<li>macOS: fix cron preview/testing payload to use <code>channel</code> key. (#867) — thanks @wes-davis.</li>
<li>macOS: update cron testing channel arg. (#896) — thanks @ngutman.</li>
</ul>
<h4>Channels / Messaging</h4>
<ul>
<li>Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)</li>
<li>Slack: respect <code>channels.slack.requireMention</code> default when resolving channel mention gating. (#850) — thanks @evalexpr.</li>
<li>Slack: drop Socket Mode events with mismatched <code>api_app_id</code>/<code>team_id</code>. (#889) — thanks @roshanasingh4.</li>
<li>Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.</li>
<li>Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.</li>
<li>Telegram: honor <code>channels.telegram.timeoutSeconds</code> for grammY API requests. (#863) — thanks @Snaver.</li>
<li>Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).</li>
<li>Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.</li>
<li>Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.</li>
<li>Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.</li>
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
<li>Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.</li>
<li>iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)</li>
<li>WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.</li>
<li>WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.</li>
<li>WhatsApp: harden owner command auth.</li>
<li>Auto-reply: treat trailing <code>NO_REPLY</code> tokens as silent replies.</li>
</ul>
<h4>Config / Doctor / Packaging</h4>
<ul>
<li>Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).</li>
<li>Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).</li>
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</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.11/Clawdbot-2026.1.11.zip" length="19860746" type="application/octet-stream" sparkle:edSignature="zmN6RovfpvPNm7PPYKDQ2c8nUtlq4k1+S6c3fvZwEHFRwKOxjhQtaW0phriDJwcqfguU3ibAQd/cpRtP4LYdCQ=="/>
<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>
<item>
<title>2026.1.11-3</title>
<pubDate>Mon, 12 Jan 2026 10:40:23 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5212</sparkle:version>
<sparkle:shortVersionString>2026.1.11-3</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.11-3</h2>
<h3>Fixes</h3>
<ul>
<li>CLI: avoid top-level await warnings in the entrypoint on fresh installs.</li>
<li>CLI: show a commit hash in the banner for npm installs (package.json gitHead fallback).</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.11-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/>
</item>
</channel>
</rss>

View File

@@ -21,8 +21,8 @@ android {
applicationId = "com.clawdbot.android"
minSdk = 31
targetSdk = 36
versionCode = 202601112
versionName = "2026.1.11-2"
versionCode = 202601114
versionName = "2026.1.11-4"
}
buildTypes {

View File

@@ -27,6 +27,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
val isForeground: StateFlow<Boolean> = runtime.isForeground
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
val mainSessionKey: StateFlow<String> = runtime.mainSessionKey
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
@@ -138,7 +139,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
}
fun loadChat(sessionKey: String = "main") {
fun loadChat(sessionKey: String) {
runtime.loadChat(sessionKey)
}

View File

@@ -78,7 +78,7 @@ class NodeRuntime(context: Context) {
payloadJson =
buildJsonObject {
put("message", JsonPrimitive(command))
put("sessionKey", JsonPrimitive(mainSessionKey.value))
put("sessionKey", JsonPrimitive(resolveMainSessionKey()))
put("thinking", JsonPrimitive(chatThinkingLevel.value))
put("deliver", JsonPrimitive(false))
}.toString(),
@@ -142,12 +142,13 @@ class NodeRuntime(context: Context) {
private val session =
BridgeSession(
scope = scope,
onConnected = { name, remote ->
onConnected = { name, remote, mainSessionKey ->
_statusText.value = "Connected"
_serverName.value = name
_remoteAddress.value = remote
_isConnected.value = true
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
applyMainSessionKey(mainSessionKey)
scope.launch { refreshBrandingFromGateway() }
scope.launch { refreshWakeWordsFromGateway() }
maybeNavigateToA2uiOnConnect()
@@ -172,11 +173,31 @@ class NodeRuntime(context: Context) {
_remoteAddress.value = null
_isConnected.value = false
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
_mainSessionKey.value = "main"
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
_mainSessionKey.value = "main"
}
val mainKey = resolveMainSessionKey()
talkMode.setMainSessionKey(mainKey)
chat.applyMainSessionKey(mainKey)
chat.onDisconnected(message)
showLocalCanvasOnDisconnect()
}
private fun applyMainSessionKey(candidate: String?) {
val trimmed = candidate?.trim().orEmpty()
if (trimmed.isEmpty()) return
if (isCanonicalMainSessionKey(_mainSessionKey.value)) return
if (_mainSessionKey.value == trimmed) return
_mainSessionKey.value = trimmed
talkMode.setMainSessionKey(trimmed)
chat.applyMainSessionKey(trimmed)
}
private fun resolveMainSessionKey(): String {
val trimmed = _mainSessionKey.value.trim()
return if (trimmed.isEmpty()) "main" else trimmed
}
private fun maybeNavigateToA2uiOnConnect() {
val a2uiUrl = resolveA2uiHostUrl() ?: return
val current = canvas.currentUrl()?.trim().orEmpty()
@@ -559,7 +580,7 @@ class NodeRuntime(context: Context) {
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
val sessionKey = "main"
val sessionKey = resolveMainSessionKey()
val message =
ClawdbotCanvasA2UIAction.formatAgentMessage(
actionName = name,
@@ -607,8 +628,9 @@ class NodeRuntime(context: Context) {
}
}
fun loadChat(sessionKey: String = "main") {
chat.load(sessionKey)
fun loadChat(sessionKey: String) {
val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() }
chat.load(key)
}
fun refreshChat() {
@@ -701,7 +723,7 @@ class NodeRuntime(context: Context) {
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
val sessionCfg = config?.get("session").asObjectOrNull()
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
_mainSessionKey.value = mainKey
applyMainSessionKey(mainKey)
val parsed = parseHexColorArgb(raw)
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB

View File

@@ -4,3 +4,10 @@ internal fun normalizeMainKey(raw: String?): String {
val trimmed = raw?.trim()
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
}
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
val trimmed = raw?.trim().orEmpty()
if (trimmed.isEmpty()) return false
if (trimmed == "global") return true
return trimmed.startsWith("agent:")
}

View File

@@ -31,7 +31,7 @@ import java.util.concurrent.ConcurrentHashMap
class BridgeSession(
private val scope: CoroutineScope,
private val onConnected: (serverName: String, remoteAddress: String?) -> Unit,
private val onConnected: (serverName: String, remoteAddress: String?, mainSessionKey: String?) -> Unit,
private val onDisconnected: (message: String) -> Unit,
private val onEvent: (event: String, payloadJson: String?) -> Unit,
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
@@ -64,6 +64,7 @@ class BridgeSession(
private val writeLock = Mutex()
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
@Volatile private var canvasHostUrl: String? = null
@Volatile private var mainSessionKey: String? = null
private var desired: Pair<BridgeEndpoint, Hello>? = null
private var job: Job? = null
@@ -90,11 +91,13 @@ class BridgeSession(
job?.cancelAndJoin()
job = null
canvasHostUrl = null
mainSessionKey = null
onDisconnected("Offline")
}
}
fun currentCanvasHostUrl(): String? = canvasHostUrl
fun currentMainSessionKey(): String? = mainSessionKey
suspend fun sendEvent(event: String, payloadJson: String?) {
val conn = currentConnection ?: return
@@ -212,7 +215,9 @@ class BridgeSession(
"hello-ok" -> {
val name = first["serverName"].asStringOrNull() ?: "Bridge"
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null }
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
mainSessionKey = rawMainSessionKey
if (BuildConfig.DEBUG) {
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
runCatching {
@@ -222,7 +227,7 @@ class BridgeSession(
)
}
}
onConnected(name, conn.remoteAddress)
onConnected(name, conn.remoteAddress, rawMainSessionKey)
}
"error" -> {
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"

View File

@@ -71,12 +71,21 @@ class ChatController(
_sessionId.value = null
}
fun load(sessionKey: String = "main") {
fun load(sessionKey: String) {
val key = sessionKey.trim().ifEmpty { "main" }
_sessionKey.value = key
scope.launch { bootstrap(forceHealth = true) }
}
fun applyMainSessionKey(mainSessionKey: String) {
val trimmed = mainSessionKey.trim()
if (trimmed.isEmpty()) return
if (_sessionKey.value == trimmed) return
if (_sessionKey.value != "main") return
_sessionKey.value = trimmed
scope.launch { bootstrap(forceHealth = true) }
}
fun refresh() {
scope.launch { bootstrap(forceHealth = true) }
}

View File

@@ -44,6 +44,7 @@ import com.clawdbot.android.chat.ChatSessionEntry
fun ChatComposer(
sessionKey: String,
sessions: List<ChatSessionEntry>,
mainSessionKey: String,
healthOk: Boolean,
thinkingLevel: String,
pendingRunCount: Int,
@@ -61,7 +62,7 @@ fun ChatComposer(
var showThinkingMenu by remember { mutableStateOf(false) }
var showSessionMenu by remember { mutableStateOf(false) }
val sessionOptions = resolveSessionChoices(sessionKey, sessions)
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
val currentSessionLabel =
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey

View File

@@ -33,13 +33,14 @@ fun ChatSheetContent(viewModel: MainViewModel) {
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
val healthOk by viewModel.chatHealthOk.collectAsState()
val sessionKey by viewModel.chatSessionKey.collectAsState()
val mainSessionKey by viewModel.mainSessionKey.collectAsState()
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val sessions by viewModel.chatSessions.collectAsState()
LaunchedEffect(Unit) {
viewModel.loadChat("main")
LaunchedEffect(mainSessionKey) {
viewModel.loadChat(mainSessionKey)
viewModel.refreshChatSessions(limit = 200)
}
@@ -85,6 +86,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
ChatComposer(
sessionKey = sessionKey,
sessions = sessions,
mainSessionKey = mainSessionKey,
healthOk = healthOk,
thinkingLevel = thinkingLevel,
pendingRunCount = pendingRunCount,

View File

@@ -2,20 +2,23 @@ package com.clawdbot.android.ui.chat
import com.clawdbot.android.chat.ChatSessionEntry
private const val MAIN_SESSION_KEY = "main"
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
fun resolveSessionChoices(
currentSessionKey: String,
sessions: List<ChatSessionEntry>,
mainSessionKey: String,
nowMs: Long = System.currentTimeMillis(),
): List<ChatSessionEntry> {
val current = currentSessionKey.trim()
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it }
val aliasKey = if (mainKey == "main") null else "main"
val cutoff = nowMs - RECENT_WINDOW_MS
val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L }
val recent = mutableListOf<ChatSessionEntry>()
val seen = mutableSetOf<String>()
for (entry in sorted) {
if (aliasKey != null && entry.key == aliasKey) continue
if (!seen.add(entry.key)) continue
if ((entry.updatedAtMs ?: 0L) < cutoff) continue
recent.add(entry)
@@ -23,13 +26,13 @@ fun resolveSessionChoices(
val result = mutableListOf<ChatSessionEntry>()
val included = mutableSetOf<String>()
val mainEntry = sorted.firstOrNull { it.key == MAIN_SESSION_KEY }
val mainEntry = sorted.firstOrNull { it.key == mainKey }
if (mainEntry != null) {
result.add(mainEntry)
included.add(MAIN_SESSION_KEY)
} else if (current == MAIN_SESSION_KEY) {
result.add(ChatSessionEntry(key = MAIN_SESSION_KEY, updatedAtMs = null))
included.add(MAIN_SESSION_KEY)
included.add(mainKey)
} else if (current == mainKey) {
result.add(ChatSessionEntry(key = mainKey, updatedAtMs = null))
included.add(mainKey)
}
for (entry in recent) {

View File

@@ -21,6 +21,7 @@ import android.speech.tts.UtteranceProgressListener
import android.util.Log
import androidx.core.content.ContextCompat
import com.clawdbot.android.bridge.BridgeSession
import com.clawdbot.android.isCanonicalMainSessionKey
import com.clawdbot.android.normalizeMainKey
import java.net.HttpURLConnection
import java.net.URL
@@ -116,6 +117,13 @@ class TalkModeManager(
chatSubscribedSessionKey = null
}
fun setMainSessionKey(sessionKey: String?) {
val trimmed = sessionKey?.trim().orEmpty()
if (trimmed.isEmpty()) return
if (isCanonicalMainSessionKey(mainSessionKey)) return
mainSessionKey = trimmed
}
fun setEnabled(enabled: Boolean) {
if (_isEnabled.value == enabled) return
_isEnabled.value = enabled
@@ -827,7 +835,9 @@ class TalkModeManager(
val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
mainSessionKey = mainKey
if (!isCanonicalMainSessionKey(mainSessionKey)) {
mainSessionKey = mainKey
}
defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
voiceAliases = aliases
if (!voiceOverrideActive) currentVoiceId = defaultVoiceId

View File

@@ -30,7 +30,7 @@ class BridgeSessionTest {
val session =
BridgeSession(
scope = scope,
onConnected = { _, _ -> connected.complete(Unit) },
onConnected = { _, _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) },
@@ -97,7 +97,7 @@ class BridgeSessionTest {
val session =
BridgeSession(
scope = scope,
onConnected = { _, _ -> connected.complete(Unit) },
onConnected = { _, _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) },
@@ -167,7 +167,7 @@ class BridgeSessionTest {
val session =
BridgeSession(
scope = scope,
onConnected = { _, _ -> connected.complete(Unit) },
onConnected = { _, _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { throw IllegalStateException("FOO_BAR: boom") },
@@ -239,7 +239,7 @@ class BridgeSessionTest {
val session =
BridgeSession(
scope = scope,
onConnected = { _, _ -> connected.countDown() },
onConnected = { _, _, _ -> connected.countDown() },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) },

View File

@@ -19,7 +19,7 @@ class SessionFiltersTest {
ChatSessionEntry(key = "recent-2", updatedAtMs = recent2),
)
val result = resolveSessionChoices("main", sessions, nowMs = now).map { it.key }
val result = resolveSessionChoices("main", sessions, mainSessionKey = "main", nowMs = now).map { it.key }
assertEquals(listOf("main", "recent-1", "recent-2"), result)
}
@@ -29,7 +29,7 @@ class SessionFiltersTest {
val recent = now - 10 * 60 * 1000L
val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent))
val result = resolveSessionChoices("custom", sessions, nowMs = now).map { it.key }
val result = resolveSessionChoices("custom", sessions, mainSessionKey = "main", nowMs = now).map { it.key }
assertEquals(listOf("main", "custom"), result)
}
}

View File

@@ -26,6 +26,7 @@ actor BridgeSession {
private(set) var state: State = .idle
private var canvasHostUrl: String?
private var mainSessionKey: String?
func currentCanvasHostUrl() -> String? {
self.canvasHostUrl
@@ -68,7 +69,7 @@ actor BridgeSession {
func connect(
endpoint: NWEndpoint,
hello: BridgeHello,
onConnected: (@Sendable (String) async -> Void)? = nil,
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
async throws
{
@@ -107,7 +108,9 @@ actor BridgeSession {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName)
self.canvasHostUrl = ok.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines)
await onConnected?(ok.serverName)
let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
self.mainSessionKey = (mainKey?.isEmpty == false) ? mainKey : nil
await onConnected?(ok.serverName, self.mainSessionKey)
} else if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
self.state = .failed(message: "\(err.code): \(err.message)")
@@ -217,6 +220,7 @@ actor BridgeSession {
self.queue = nil
self.buffer = Data()
self.canvasHostUrl = nil
self.mainSessionKey = nil
let pending = self.pendingRPC.values
self.pendingRPC.removeAll()
@@ -234,6 +238,10 @@ actor BridgeSession {
self.state = .idle
}
func currentMainSessionKey() -> String? {
self.mainSessionKey
}
private func beginRPC(
id: String,
request: BridgeRPCRequest,

View File

@@ -6,7 +6,7 @@ struct ChatSheet: View {
@State private var viewModel: ClawdbotChatViewModel
private let userAccent: Color?
init(bridge: BridgeSession, sessionKey: String = "main", userAccent: Color? = nil) {
init(bridge: BridgeSession, sessionKey: String, userAccent: Color? = nil) {
let transport = IOSBridgeChatTransport(bridge: bridge)
self._viewModel = State(
initialValue: ClawdbotChatViewModel(

View File

@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.11-2</string>
<string>2026.1.11-4</string>
<key>CFBundleVersion</key>
<string>202601112</string>
<string>202601113</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>

View File

@@ -109,7 +109,7 @@ final class NodeAppModel {
let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name
let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
let contextJSON = ClawdbotCanvasA2UIAction.compactJSON(userAction["context"])
let sessionKey = "main"
let sessionKey = self.mainSessionKey
let messageContext = ClawdbotCanvasA2UIAction.AgentMessageContext(
actionName: name,
@@ -232,12 +232,15 @@ final class NodeAppModel {
try await self.bridge.connect(
endpoint: endpoint,
hello: hello,
onConnected: { [weak self] serverName in
onConnected: { [weak self] serverName, mainSessionKey in
guard let self else { return }
await MainActor.run {
self.bridgeStatusText = "Connected"
self.bridgeServerName = serverName
}
await MainActor.run {
self.applyMainSessionKey(mainSessionKey)
}
if let addr = await self.bridge.currentRemoteAddress() {
await MainActor.run {
self.bridgeRemoteAddress = addr
@@ -286,7 +289,10 @@ final class NodeAppModel {
self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil
self.seamColorHex = nil
self.mainSessionKey = "main"
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = "main"
self.talkMode.updateMainSessionKey(self.mainSessionKey)
}
self.showLocalCanvasOnDisconnect()
}
}
@@ -303,10 +309,23 @@ final class NodeAppModel {
self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil
self.seamColorHex = nil
self.mainSessionKey = "main"
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = "main"
self.talkMode.updateMainSessionKey(self.mainSessionKey)
}
self.showLocalCanvasOnDisconnect()
}
private func applyMainSessionKey(_ key: String?) {
let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let current = self.mainSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
if SessionKey.isCanonicalMainSessionKey(current) { return }
if trimmed == current { return }
self.mainSessionKey = trimmed
self.talkMode.updateMainSessionKey(trimmed)
}
var seamColor: Color {
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
}
@@ -335,7 +354,10 @@ final class NodeAppModel {
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
await MainActor.run {
self.seamColorHex = raw.isEmpty ? nil : raw
self.mainSessionKey = mainKey
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = mainKey
self.talkMode.updateMainSessionKey(mainKey)
}
}
} catch {
// ignore

View File

@@ -5,4 +5,11 @@ enum SessionKey {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "main" : trimmed
}
static func isCanonicalMainSessionKey(_ value: String?) -> Bool {
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return false }
if trimmed == "global" { return true }
return trimmed.hasPrefix("agent:")
}
}

View File

@@ -53,6 +53,13 @@ final class TalkModeManager: NSObject {
self.bridge = bridge
}
func updateMainSessionKey(_ sessionKey: String?) {
let trimmed = (sessionKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
if SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { return }
self.mainSessionKey = trimmed
}
func setEnabled(_ enabled: Bool) {
self.isEnabled = enabled
if enabled {
@@ -649,7 +656,10 @@ final class TalkModeManager: NSObject {
guard let config = json["config"] as? [String: Any] else { return }
let talk = config["talk"] as? [String: Any]
let session = config["session"] as? [String: Any]
self.mainSessionKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = mainKey
}
self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let aliases = talk?["voiceAliases"] as? [String: Any] {
var resolved: [String: String] = [:]

View File

@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.11-2</string>
<string>2026.1.11-4</string>
<key>CFBundleVersion</key>
<string>202601112</string>
<string>202601113</string>
</dict>
</plist>

View File

@@ -307,8 +307,8 @@ enum AgentWorkspace {
}
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
urls.append(cwd.appendingPathComponent("docs")
.appendingPathComponent(self.templateDirname)
.appendingPathComponent(named))
.appendingPathComponent(self.templateDirname)
.appendingPathComponent(named))
return urls
}

View File

@@ -109,8 +109,8 @@ struct AnthropicAuthControls: View {
}
.buttonStyle(.bordered)
.disabled(self.busy || self.connectionMode != .local || self.code
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty)
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty)
}
}

View File

@@ -228,7 +228,7 @@ enum ClawdbotOAuthStore {
static func oauthDir() -> URL {
if let override = ProcessInfo.processInfo.environment[self.clawdbotOAuthDirEnv]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!override.isEmpty
!override.isEmpty
{
let expanded = NSString(string: override).expandingTildeInPath
return URL(fileURLWithPath: expanded, isDirectory: true)

View File

@@ -204,7 +204,7 @@ final class AppState {
private var earBoostTask: Task<Void, Never>?
init(preview: Bool = false) {
self.isPreview = preview
self.isPreview = preview || ProcessInfo.processInfo.isRunningTests
let onboardingSeen = UserDefaults.standard.bool(forKey: "clawdbot.onboardingSeen")
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
self.launchAtLogin = false
@@ -264,8 +264,8 @@ final class AppState {
}
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
let configHasRemoteUrl = !(configRemoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
let resolvedConnectionMode: ConnectionMode = if let configMode {
@@ -356,8 +356,8 @@ final class AppState {
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String
let hasRemoteUrl = !(remoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
let desiredMode: ConnectionMode? = switch modeRaw {
case "local":

View File

@@ -282,7 +282,12 @@ actor BridgeConnectionHandler {
do {
try await self.send(BridgePairOk(type: "pair-ok", token: token))
self.isAuthenticated = true
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
try await self.send(
BridgeHelloOk(
type: "hello-ok",
serverName: serverName,
mainSessionKey: mainSessionKey))
} catch {
self.logger.error("bridge send pair-ok failed: \(error.localizedDescription, privacy: .public)")
}
@@ -298,7 +303,12 @@ actor BridgeConnectionHandler {
case .ok:
self.isAuthenticated = true
do {
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
try await self.send(
BridgeHelloOk(
type: "hello-ok",
serverName: serverName,
mainSessionKey: mainSessionKey))
} catch {
self.logger.error("bridge send hello-ok failed: \(error.localizedDescription, privacy: .public)")
}

View File

@@ -182,12 +182,12 @@ actor BridgeServer {
?? "main"
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: text,
sessionKey: sessionKey,
thinking: "low",
deliver: false,
to: nil,
provider: .last))
message: text,
sessionKey: sessionKey,
thinking: "low",
deliver: false,
to: nil,
channel: .last))
case "agent.request":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
@@ -205,15 +205,15 @@ actor BridgeServer {
?? "node-\(nodeId)"
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let provider = GatewayAgentProvider(raw: link.channel)
let channel = GatewayAgentChannel(raw: link.channel)
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: message,
sessionKey: sessionKey,
thinking: thinking,
deliver: link.deliver,
to: to,
provider: provider))
message: message,
sessionKey: sessionKey,
thinking: thinking,
deliver: link.deliver,
to: to,
channel: channel))
default:
break

View File

@@ -55,7 +55,7 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
guard let name = ClawdbotCanvasA2UIAction.extractActionName(userAction) else { return }
let actionId =
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? UUID().uuidString
?? UUID().uuidString
canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)")
@@ -86,7 +86,7 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
thinking: "low",
deliver: false,
to: nil,
provider: .last,
channel: .last,
idempotencyKey: actionId))
await MainActor.run {

View File

@@ -39,13 +39,13 @@ final class CanvasFileWatcher: @unchecked Sendable {
kFSEventStreamCreateFlagNoDefer)
guard let stream = FSEventStreamCreate(
kCFAllocatorDefault,
Self.callback,
&context,
paths,
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
0.05,
flags)
kCFAllocatorDefault,
Self.callback,
&context,
paths,
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
0.05,
flags)
else {
retainedSelf.release()
return

View File

@@ -242,8 +242,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
}
guard let url = CanvasScheme.makeURL(
session: CanvasWindowController.sanitizeSessionKey(self.sessionKey),
path: trimmed)
session: CanvasWindowController.sanitizeSessionKey(self.sessionKey),
path: trimmed)
else {
canvasWindowLogger
.error(

View File

@@ -125,13 +125,13 @@ enum CommandResolver {
// fnm
bins.append(contentsOf: self.versionedNodeBinPaths(
base: home.appendingPathComponent(".local/share/fnm/node-versions"),
suffix: "installation/bin"))
base: home.appendingPathComponent(".local/share/fnm/node-versions"),
suffix: "installation/bin"))
// nvm
bins.append(contentsOf: self.versionedNodeBinPaths(
base: home.appendingPathComponent(".nvm/versions/node"),
suffix: "bin"))
base: home.appendingPathComponent(".nvm/versions/node"),
suffix: "bin"))
return bins
}

View File

@@ -45,13 +45,13 @@ final class ConfigFileWatcher: @unchecked Sendable {
kFSEventStreamCreateFlagNoDefer)
guard let stream = FSEventStreamCreate(
kCFAllocatorDefault,
Self.callback,
&context,
paths,
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
0.05,
flags)
kCFAllocatorDefault,
Self.callback,
&context,
paths,
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
0.05,
flags)
else {
retainedSelf.release()
return

View File

@@ -10,7 +10,7 @@ struct ConfigSettings: View {
"When enabled, the browser server will only connect if the clawd browser is already running."
private static let browserProfileNote =
"Clawd uses a separate Chrome profile and ports (default 18791/18792) "
+ "so it wont interfere with your daily browser."
+ "so it wont interfere with your daily browser."
@State private var configModel: String = ""
@State private var configSaving = false
@State private var hasLoaded = false
@@ -97,8 +97,8 @@ extension ConfigSettings {
Text("Clawdbot CLI config")
.font(.title3.weight(.semibold))
Text(self.isNixMode
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
: "Edit ~/.clawdbot/clawdbot.json (agent / session / routing / messages).")
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
: "Edit ~/.clawdbot/clawdbot.json (agent / session / routing / messages).")
.font(.callout)
.foregroundStyle(.secondary)
}
@@ -753,9 +753,9 @@ extension ConfigSettings {
do {
let res: ModelsListResult =
try await GatewayConnection.shared
.requestDecoded(
method: .modelsList,
timeoutMs: 15000)
.requestDecoded(
method: .modelsList,
timeoutMs: 15000)
self.models = res.models
self.modelsSourceLabel = "gateway"
} catch {
@@ -792,8 +792,8 @@ extension ConfigSettings {
choice.provider,
self.modelRef(for: choice),
]
.joined(separator: " ")
.lowercased()
.joined(separator: " ")
.lowercased()
return tokens.allSatisfy { haystack.contains($0) }
}
}

View File

@@ -19,6 +19,7 @@ enum ConfigStore {
}
private static let overrideStore = OverrideStore()
@MainActor private static var lastHash: String?
private static func isRemoteMode() async -> Bool {
let overrides = await self.overrideStore.overrides
@@ -75,6 +76,7 @@ enum ConfigStore {
method: .configGet,
params: nil,
timeoutMs: 8000)
self.lastHash = snap.hash
return snap.config?.mapValues { $0.foundationValue } ?? [:]
} catch {
return nil
@@ -83,17 +85,24 @@ enum ConfigStore {
@MainActor
private static func saveToGateway(_ root: [String: Any]) async throws {
if self.lastHash == nil {
_ = await self.loadFromGateway()
}
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else {
throw NSError(domain: "ConfigStore", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode config.",
])
}
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
var params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
if let baseHash = self.lastHash {
params["baseHash"] = AnyCodable(baseHash)
}
_ = try await GatewayConnection.shared.requestRaw(
method: .configSet,
params: params,
timeoutMs: 10000)
_ = await self.loadFromGateway()
}
#if DEBUG

View File

@@ -53,8 +53,8 @@ final class ConnectionModeCoordinator {
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote(
target: settings.target,
identity: settings.identity))
target: settings.target,
identity: settings.identity))
} catch {
self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)")
}

View File

@@ -11,9 +11,9 @@ extension ConnectionsSettings {
}
@ViewBuilder
func providerHeaderActions(_ provider: ConnectionProvider) -> some View {
func channelHeaderActions(_ channel: ConnectionChannel) -> some View {
HStack(spacing: 8) {
if provider == .whatsapp {
if channel == .whatsapp {
Button("Logout") {
Task { await self.store.logoutWhatsApp() }
}
@@ -21,7 +21,7 @@ extension ConnectionsSettings {
.disabled(self.store.whatsappBusy)
}
if provider == .telegram {
if channel == .telegram {
Button("Logout") {
Task { await self.store.logoutTelegram() }
}

View File

@@ -1,15 +1,15 @@
import SwiftUI
extension ConnectionsSettings {
private func providerStatus<T: Decodable>(
private func channelStatus<T: Decodable>(
_ id: String,
as type: T.Type) -> T?
{
self.store.snapshot?.decodeProvider(id, as: type)
self.store.snapshot?.decodeChannel(id, as: type)
}
var whatsAppTint: Color {
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return .secondary }
if !status.configured { return .secondary }
if !status.linked { return .red }
@@ -20,7 +20,7 @@ extension ConnectionsSettings {
}
var telegramTint: Color {
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return .secondary }
if !status.configured { return .secondary }
if status.lastError != nil { return .orange }
@@ -30,7 +30,7 @@ extension ConnectionsSettings {
}
var discordTint: Color {
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return .secondary }
if !status.configured { return .secondary }
if status.lastError != nil { return .orange }
@@ -40,7 +40,7 @@ extension ConnectionsSettings {
}
var signalTint: Color {
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return .secondary }
if !status.configured { return .secondary }
if status.lastError != nil { return .orange }
@@ -50,7 +50,7 @@ extension ConnectionsSettings {
}
var imessageTint: Color {
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return .secondary }
if !status.configured { return .secondary }
if status.lastError != nil { return .orange }
@@ -60,7 +60,7 @@ extension ConnectionsSettings {
}
var whatsAppSummary: String {
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return "Checking…" }
if !status.linked { return "Not linked" }
if status.connected { return "Connected" }
@@ -69,7 +69,7 @@ extension ConnectionsSettings {
}
var telegramSummary: String {
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return "Checking…" }
if !status.configured { return "Not configured" }
if status.running { return "Running" }
@@ -77,7 +77,7 @@ extension ConnectionsSettings {
}
var discordSummary: String {
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return "Checking…" }
if !status.configured { return "Not configured" }
if status.running { return "Running" }
@@ -85,7 +85,7 @@ extension ConnectionsSettings {
}
var signalSummary: String {
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return "Checking…" }
if !status.configured { return "Not configured" }
if status.running { return "Running" }
@@ -93,7 +93,7 @@ extension ConnectionsSettings {
}
var imessageSummary: String {
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return "Checking…" }
if !status.configured { return "Not configured" }
if status.running { return "Running" }
@@ -101,7 +101,7 @@ extension ConnectionsSettings {
}
var whatsAppDetails: String? {
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return nil }
var lines: [String] = []
if let e164 = status.`self`?.e164 ?? status.`self`?.jid {
@@ -132,7 +132,7 @@ extension ConnectionsSettings {
}
var telegramDetails: String? {
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return nil }
var lines: [String] = []
if let source = status.tokenSource {
@@ -164,7 +164,7 @@ extension ConnectionsSettings {
}
var discordDetails: String? {
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return nil }
var lines: [String] = []
if let source = status.tokenSource {
@@ -193,7 +193,7 @@ extension ConnectionsSettings {
}
var signalDetails: String? {
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return nil }
var lines: [String] = []
lines.append("Base URL: \(status.baseUrl)")
@@ -220,7 +220,7 @@ extension ConnectionsSettings {
}
var imessageDetails: String? {
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return nil }
var lines: [String] = []
if let cliPath = status.cliPath, !cliPath.isEmpty {
@@ -243,68 +243,68 @@ extension ConnectionsSettings {
}
var isTelegramTokenLocked: Bool {
self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)?.tokenSource == "env"
self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?.tokenSource == "env"
}
var isDiscordTokenLocked: Bool {
self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)?.tokenSource == "env"
self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?.tokenSource == "env"
}
var orderedProviders: [ConnectionProvider] {
ConnectionProvider.allCases.sorted { lhs, rhs in
let lhsEnabled = self.providerEnabled(lhs)
let rhsEnabled = self.providerEnabled(rhs)
var orderedChannels: [ConnectionChannel] {
ConnectionChannel.allCases.sorted { lhs, rhs in
let lhsEnabled = self.channelEnabled(lhs)
let rhsEnabled = self.channelEnabled(rhs)
if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled }
return lhs.sortOrder < rhs.sortOrder
}
}
var enabledProviders: [ConnectionProvider] {
self.orderedProviders.filter { self.providerEnabled($0) }
var enabledChannels: [ConnectionChannel] {
self.orderedChannels.filter { self.channelEnabled($0) }
}
var availableProviders: [ConnectionProvider] {
self.orderedProviders.filter { !self.providerEnabled($0) }
var availableChannels: [ConnectionChannel] {
self.orderedChannels.filter { !self.channelEnabled($0) }
}
func ensureSelection() {
guard let selected = self.selectedProvider else {
self.selectedProvider = self.orderedProviders.first
guard let selected = self.selectedChannel else {
self.selectedChannel = self.orderedChannels.first
return
}
if !self.orderedProviders.contains(selected) {
self.selectedProvider = self.orderedProviders.first
if !self.orderedChannels.contains(selected) {
self.selectedChannel = self.orderedChannels.first
}
}
func providerEnabled(_ provider: ConnectionProvider) -> Bool {
switch provider {
func channelEnabled(_ channel: ConnectionChannel) -> Bool {
switch channel {
case .whatsapp:
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return false }
return status.configured || status.linked || status.running
case .telegram:
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return false }
return status.configured || status.running
case .discord:
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return false }
return status.configured || status.running
case .signal:
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return false }
return status.configured || status.running
case .imessage:
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return false }
return status.configured || status.running
}
}
@ViewBuilder
func providerSection(_ provider: ConnectionProvider) -> some View {
switch provider {
func channelSection(_ channel: ConnectionChannel) -> some View {
switch channel {
case .whatsapp:
self.whatsAppSection
case .telegram:
@@ -318,8 +318,8 @@ extension ConnectionsSettings {
}
}
func providerTint(_ provider: ConnectionProvider) -> Color {
switch provider {
func channelTint(_ channel: ConnectionChannel) -> Color {
switch channel {
case .whatsapp:
self.whatsAppTint
case .telegram:
@@ -333,8 +333,8 @@ extension ConnectionsSettings {
}
}
func providerSummary(_ provider: ConnectionProvider) -> String {
switch provider {
func channelSummary(_ channel: ConnectionChannel) -> String {
switch channel {
case .whatsapp:
self.whatsAppSummary
case .telegram:
@@ -348,8 +348,8 @@ extension ConnectionsSettings {
}
}
func providerDetails(_ provider: ConnectionProvider) -> String? {
switch provider {
func channelDetails(_ channel: ConnectionChannel) -> String? {
switch channel {
case .whatsapp:
self.whatsAppDetails
case .telegram:
@@ -363,55 +363,55 @@ extension ConnectionsSettings {
}
}
func providerLastCheckText(_ provider: ConnectionProvider) -> String {
guard let date = self.providerLastCheck(provider) else { return "never" }
func channelLastCheckText(_ channel: ConnectionChannel) -> String {
guard let date = self.channelLastCheck(channel) else { return "never" }
return relativeAge(from: date)
}
func providerLastCheck(_ provider: ConnectionProvider) -> Date? {
switch provider {
func channelLastCheck(_ channel: ConnectionChannel) -> Date? {
switch channel {
case .whatsapp:
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return nil }
return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt)
case .telegram:
return self
.date(fromMs: self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)?
.lastProbeAt)
.date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
.lastProbeAt)
case .discord:
return self
.date(fromMs: self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)?
.lastProbeAt)
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.lastProbeAt)
case .signal:
return self
.date(fromMs: self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)?.lastProbeAt)
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
case .imessage:
return self
.date(fromMs: self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)?
.lastProbeAt)
.date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)?
.lastProbeAt)
}
}
func providerHasError(_ provider: ConnectionProvider) -> Bool {
switch provider {
func channelHasError(_ channel: ConnectionChannel) -> Bool {
switch channel {
case .whatsapp:
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true
case .telegram:
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case .discord:
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case .signal:
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case .imessage:
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
}

View File

@@ -11,7 +11,7 @@ extension ConnectionsSettings {
self.store.start()
self.ensureSelection()
}
.onChange(of: self.orderedProviders) { _, _ in
.onChange(of: self.orderedChannels) { _, _ in
self.ensureSelection()
}
.onDisappear { self.store.stop() }
@@ -20,17 +20,17 @@ extension ConnectionsSettings {
private var sidebar: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
if !self.enabledProviders.isEmpty {
if !self.enabledChannels.isEmpty {
self.sidebarSectionHeader("Configured")
ForEach(self.enabledProviders) { provider in
self.sidebarRow(provider)
ForEach(self.enabledChannels) { channel in
self.sidebarRow(channel)
}
}
if !self.availableProviders.isEmpty {
if !self.availableChannels.isEmpty {
self.sidebarSectionHeader("Available")
ForEach(self.availableProviders) { provider in
self.sidebarRow(provider)
ForEach(self.availableChannels) { channel in
self.sidebarRow(channel)
}
}
}
@@ -46,8 +46,8 @@ extension ConnectionsSettings {
private var detail: some View {
Group {
if let provider = self.selectedProvider {
self.providerDetail(provider)
if let channel = self.selectedChannel {
self.channelDetail(channel)
} else {
self.emptyDetail
}
@@ -59,7 +59,7 @@ extension ConnectionsSettings {
VStack(alignment: .leading, spacing: 8) {
Text("Connections")
.font(.title3.weight(.semibold))
Text("Select a provider to view status and settings.")
Text("Select a channel to view status and settings.")
.font(.callout)
.foregroundStyle(.secondary)
}
@@ -67,12 +67,12 @@ extension ConnectionsSettings {
.padding(.vertical, 18)
}
private func providerDetail(_ provider: ConnectionProvider) -> some View {
private func channelDetail(_ channel: ConnectionChannel) -> some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 16) {
self.detailHeader(for: provider)
self.detailHeader(for: channel)
Divider()
self.providerSection(provider)
self.channelSection(channel)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -81,18 +81,18 @@ extension ConnectionsSettings {
}
}
private func sidebarRow(_ provider: ConnectionProvider) -> some View {
let isSelected = self.selectedProvider == provider
private func sidebarRow(_ channel: ConnectionChannel) -> some View {
let isSelected = self.selectedChannel == channel
return Button {
self.selectedProvider = provider
self.selectedChannel = channel
} label: {
HStack(spacing: 8) {
Circle()
.fill(self.providerTint(provider))
.fill(self.channelTint(channel))
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
Text(provider.title)
Text(self.providerSummary(provider))
Text(channel.title)
Text(self.channelSummary(channel))
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -119,23 +119,23 @@ extension ConnectionsSettings {
.padding(.top, 2)
}
private func detailHeader(for provider: ConnectionProvider) -> some View {
private func detailHeader(for channel: ConnectionChannel) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Label(provider.detailTitle, systemImage: provider.systemImage)
Label(channel.detailTitle, systemImage: channel.systemImage)
.font(.title3.weight(.semibold))
self.statusBadge(
self.providerSummary(provider),
color: self.providerTint(provider))
self.channelSummary(channel),
color: self.channelTint(channel))
Spacer()
self.providerHeaderActions(provider)
self.channelHeaderActions(channel)
}
HStack(spacing: 10) {
Text("Last check \(self.providerLastCheckText(provider))")
Text("Last check \(self.channelLastCheckText(channel))")
.font(.caption)
.foregroundStyle(.secondary)
if self.providerHasError(provider) {
if self.channelHasError(channel) {
Text("Error")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
@@ -146,7 +146,7 @@ extension ConnectionsSettings {
}
}
if let details = self.providerDetails(provider) {
if let details = self.channelDetails(channel) {
Text(details)
.font(.caption)
.foregroundStyle(.secondary)

View File

@@ -2,7 +2,7 @@ import AppKit
import SwiftUI
struct ConnectionsSettings: View {
enum ConnectionProvider: String, CaseIterable, Identifiable, Hashable {
enum ConnectionChannel: String, CaseIterable, Identifiable, Hashable {
case whatsapp
case telegram
case discord
@@ -53,7 +53,7 @@ struct ConnectionsSettings: View {
}
@Bindable var store: ConnectionsStore
@State var selectedProvider: ConnectionProvider?
@State var selectedChannel: ConnectionChannel?
@State var showTelegramToken = false
@State var showDiscordToken = false

View File

@@ -2,6 +2,16 @@ import ClawdbotProtocol
import Foundation
extension ConnectionsStore {
var isTelegramTokenLocked: Bool {
self.snapshot?.decodeChannel("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
.tokenSource == "env"
}
var isDiscordTokenLocked: Bool {
self.snapshot?.decodeChannel("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.tokenSource == "env"
}
func loadConfig() async {
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
@@ -12,6 +22,7 @@ extension ConnectionsStore {
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
: nil
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
self.configHash = snap.hash
self.configLoaded = true
self.applyUIConfig(snap)
@@ -27,17 +38,30 @@ extension ConnectionsStore {
private func applyUIConfig(_ snap: ConfigSnapshot) {
let ui = snap.config?[
"ui",
]?.dictionaryValue
]?.dictionaryValue
let rawSeam = ui?[
"seamColor",
]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
}
private func resolveChannelConfig(_ snap: ConfigSnapshot, key: String) -> [String: AnyCodable]? {
if let channels = snap.config?["channels"]?.dictionaryValue,
let entry = channels[key]?.dictionaryValue
{
return entry
}
return snap.config?[key]?.dictionaryValue
}
private func applyTelegramConfig(_ snap: ConfigSnapshot) {
let telegram = snap.config?["telegram"]?.dictionaryValue
let telegram = self.resolveChannelConfig(snap, key: "telegram")
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true
let groups = telegram?["groups"]?.dictionaryValue
let defaultGroup = groups?["*"]?.dictionaryValue
self.telegramRequireMention = defaultGroup?["requireMention"]?.boolValue
?? telegram?["requireMention"]?.boolValue
?? true
self.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue)
self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
@@ -46,7 +70,7 @@ extension ConnectionsStore {
}
private func applyDiscordConfig(_ snap: ConfigSnapshot) {
let discord = snap.config?["discord"]?.dictionaryValue
let discord = self.resolveChannelConfig(snap, key: "discord")
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
self.discordToken = discord?["token"]?.stringValue ?? ""
@@ -122,7 +146,7 @@ extension ConnectionsStore {
}
private func applySignalConfig(_ snap: ConfigSnapshot) {
let signal = snap.config?["signal"]?.dictionaryValue
let signal = self.resolveChannelConfig(snap, key: "signal")
self.signalEnabled = signal?["enabled"]?.boolValue ?? true
self.signalAccount = signal?["account"]?.stringValue ?? ""
self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? ""
@@ -139,7 +163,7 @@ extension ConnectionsStore {
}
private func applyIMessageConfig(_ snap: ConfigSnapshot) {
let imessage = snap.config?["imessage"]?.dictionaryValue
let imessage = self.resolveChannelConfig(snap, key: "imessage")
self.imessageEnabled = imessage?["enabled"]?.boolValue ?? true
self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? ""
self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? ""
@@ -150,6 +174,15 @@ extension ConnectionsStore {
self.imessageMediaMaxMb = self.numberString(from: imessage?["mediaMaxMb"])
}
private func channelConfigRoot(for key: String) -> [String: Any] {
if let channels = self.configRoot["channels"] as? [String: Any],
let entry = channels[key] as? [String: Any]
{
return entry
}
return self.configRoot[key] as? [String: Any] ?? [:]
}
func saveTelegramConfig() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
@@ -158,30 +191,24 @@ extension ConnectionsStore {
await self.loadConfig()
}
var telegram: [String: Any] = (self.configRoot["telegram"] as? [String: Any]) ?? [:]
let token = self.trimmed(self.telegramToken)
if token.isEmpty {
telegram.removeValue(forKey: "botToken")
} else {
telegram["botToken"] = token
var telegram: [String: Any] = [:]
if !self.isTelegramTokenLocked {
self.setPatchString(&telegram, key: "botToken", value: self.telegramToken)
}
telegram["requireMention"] = self.telegramRequireMention
telegram["requireMention"] = NSNull()
telegram["groups"] = [
"*": [
"requireMention": self.telegramRequireMention,
],
]
let allow = self.splitCsv(self.telegramAllowFrom)
if allow.isEmpty {
telegram.removeValue(forKey: "allowFrom")
} else {
telegram["allowFrom"] = allow
}
self.setPatchList(&telegram, key: "allowFrom", values: allow)
self.setPatchString(&telegram, key: "proxy", value: self.telegramProxy)
self.setPatchString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
self.setPatchString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
self.setPatchString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
self.setOptionalString(&telegram, key: "proxy", value: self.telegramProxy)
self.setOptionalString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
self.setOptionalString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
self.setOptionalString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
self.setSection("telegram", payload: telegram)
await self.persistConfig()
await self.persistChannelPatch("telegram", payload: telegram)
}
func saveDiscordConfig() async {
@@ -192,9 +219,9 @@ extension ConnectionsStore {
await self.loadConfig()
}
let discord = self.buildDiscordConfig()
self.setSection("discord", payload: discord)
await self.persistConfig()
let base = self.channelConfigRoot(for: "discord")
let discord = self.buildDiscordPatch(base: base)
await self.persistChannelPatch("discord", payload: discord)
}
func saveSignalConfig() async {
@@ -205,42 +232,23 @@ extension ConnectionsStore {
await self.loadConfig()
}
var signal: [String: Any] = (self.configRoot["signal"] as? [String: Any]) ?? [:]
if self.signalEnabled {
signal.removeValue(forKey: "enabled")
} else {
signal["enabled"] = false
}
self.setOptionalString(&signal, key: "account", value: self.signalAccount)
self.setOptionalString(&signal, key: "httpUrl", value: self.signalHttpUrl)
self.setOptionalString(&signal, key: "httpHost", value: self.signalHttpHost)
self.setOptionalNumber(&signal, key: "httpPort", value: self.signalHttpPort)
self.setOptionalString(&signal, key: "cliPath", value: self.signalCliPath)
if self.signalAutoStart {
signal.removeValue(forKey: "autoStart")
} else {
signal["autoStart"] = false
}
self.setOptionalString(&signal, key: "receiveMode", value: self.signalReceiveMode)
self.setOptionalBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments)
self.setOptionalBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories)
self.setOptionalBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts)
var signal: [String: Any] = [:]
self.setPatchBool(&signal, key: "enabled", value: self.signalEnabled, defaultValue: true)
self.setPatchString(&signal, key: "account", value: self.signalAccount)
self.setPatchString(&signal, key: "httpUrl", value: self.signalHttpUrl)
self.setPatchString(&signal, key: "httpHost", value: self.signalHttpHost)
self.setPatchNumber(&signal, key: "httpPort", value: self.signalHttpPort)
self.setPatchString(&signal, key: "cliPath", value: self.signalCliPath)
self.setPatchBool(&signal, key: "autoStart", value: self.signalAutoStart, defaultValue: true)
self.setPatchString(&signal, key: "receiveMode", value: self.signalReceiveMode)
self.setPatchBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments, defaultValue: false)
self.setPatchBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories, defaultValue: false)
self.setPatchBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts, defaultValue: false)
let allow = self.splitCsv(self.signalAllowFrom)
if allow.isEmpty {
signal.removeValue(forKey: "allowFrom")
} else {
signal["allowFrom"] = allow
}
self.setPatchList(&signal, key: "allowFrom", values: allow)
self.setPatchNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
self.setOptionalNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
self.setSection("signal", payload: signal)
await self.persistConfig()
await self.persistChannelPatch("signal", payload: signal)
}
func saveIMessageConfig() async {
@@ -251,147 +259,168 @@ extension ConnectionsStore {
await self.loadConfig()
}
var imessage: [String: Any] = (self.configRoot["imessage"] as? [String: Any]) ?? [:]
if self.imessageEnabled {
imessage.removeValue(forKey: "enabled")
} else {
imessage["enabled"] = false
}
self.setOptionalString(&imessage, key: "cliPath", value: self.imessageCliPath)
self.setOptionalString(&imessage, key: "dbPath", value: self.imessageDbPath)
var imessage: [String: Any] = [:]
self.setPatchBool(&imessage, key: "enabled", value: self.imessageEnabled, defaultValue: true)
self.setPatchString(&imessage, key: "cliPath", value: self.imessageCliPath)
self.setPatchString(&imessage, key: "dbPath", value: self.imessageDbPath)
let service = self.trimmed(self.imessageService)
if service.isEmpty || service == "auto" {
imessage.removeValue(forKey: "service")
imessage["service"] = NSNull()
} else {
imessage["service"] = service
}
self.setOptionalString(&imessage, key: "region", value: self.imessageRegion)
self.setPatchString(&imessage, key: "region", value: self.imessageRegion)
let allow = self.splitCsv(self.imessageAllowFrom)
if allow.isEmpty {
imessage.removeValue(forKey: "allowFrom")
} else {
imessage["allowFrom"] = allow
}
self.setPatchList(&imessage, key: "allowFrom", values: allow)
self.setOptionalBool(&imessage, key: "includeAttachments", value: self.imessageIncludeAttachments)
self.setOptionalNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
self.setPatchBool(
&imessage,
key: "includeAttachments",
value: self.imessageIncludeAttachments,
defaultValue: false)
self.setPatchNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
self.setSection("imessage", payload: imessage)
await self.persistConfig()
await self.persistChannelPatch("imessage", payload: imessage)
}
private func buildDiscordConfig() -> [String: Any] {
var discord: [String: Any] = (self.configRoot["discord"] as? [String: Any]) ?? [:]
if self.discordEnabled {
discord.removeValue(forKey: "enabled")
} else {
discord["enabled"] = false
private func buildDiscordPatch(base: [String: Any]) -> [String: Any] {
var discord: [String: Any] = [:]
self.setPatchBool(&discord, key: "enabled", value: self.discordEnabled, defaultValue: true)
if !self.isDiscordTokenLocked {
self.setPatchString(&discord, key: "token", value: self.discordToken)
}
self.setOptionalString(&discord, key: "token", value: self.discordToken)
if let dm = self.buildDiscordDmConfig(base: discord["dm"] as? [String: Any] ?? [:]) {
if let dm = self.buildDiscordDmPatch() {
discord["dm"] = dm
} else {
discord.removeValue(forKey: "dm")
discord["dm"] = NSNull()
}
self.setOptionalNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
self.setOptionalInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
self.setOptionalInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
self.setPatchNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
self.setPatchInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
self.setPatchInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
let replyToMode = self.trimmed(self.discordReplyToMode)
if replyToMode.isEmpty || replyToMode == "off" {
discord.removeValue(forKey: "replyToMode")
} else if ["first", "all"].contains(replyToMode) {
discord["replyToMode"] = replyToMode
if replyToMode.isEmpty || replyToMode == "off" || !["first", "all"].contains(replyToMode) {
discord["replyToMode"] = NSNull()
} else {
discord.removeValue(forKey: "replyToMode")
discord["replyToMode"] = replyToMode
}
if let guilds = self.buildDiscordGuildsConfig() {
let baseGuilds = base["guilds"] as? [String: Any] ?? [:]
if let guilds = self.buildDiscordGuildsPatch(base: baseGuilds) {
discord["guilds"] = guilds
} else {
discord.removeValue(forKey: "guilds")
discord["guilds"] = NSNull()
}
if let actions = self.buildDiscordActionsConfig(base: discord["actions"] as? [String: Any] ?? [:]) {
if let actions = self.buildDiscordActionsPatch() {
discord["actions"] = actions
} else {
discord.removeValue(forKey: "actions")
discord["actions"] = NSNull()
}
if let slash = self.buildDiscordSlashConfig(base: discord["slashCommand"] as? [String: Any] ?? [:]) {
if let slash = self.buildDiscordSlashPatch() {
discord["slashCommand"] = slash
} else {
discord.removeValue(forKey: "slashCommand")
discord["slashCommand"] = NSNull()
}
return discord
}
private func buildDiscordDmConfig(base: [String: Any]) -> [String: Any]? {
var dm = base
if self.discordDmEnabled {
dm.removeValue(forKey: "enabled")
} else {
dm["enabled"] = false
}
private func buildDiscordDmPatch() -> [String: Any]? {
var dm: [String: Any] = [:]
self.setPatchBool(&dm, key: "enabled", value: self.discordDmEnabled, defaultValue: true)
let allow = self.splitCsv(self.discordAllowFrom)
if allow.isEmpty {
dm.removeValue(forKey: "allowFrom")
} else {
dm["allowFrom"] = allow
}
if self.discordGroupEnabled {
dm["groupEnabled"] = true
} else {
dm.removeValue(forKey: "groupEnabled")
}
self.setPatchList(&dm, key: "allowFrom", values: allow)
self.setPatchBool(&dm, key: "groupEnabled", value: self.discordGroupEnabled, defaultValue: false)
let groupChannels = self.splitCsv(self.discordGroupChannels)
if groupChannels.isEmpty {
dm.removeValue(forKey: "groupChannels")
} else {
dm["groupChannels"] = groupChannels
}
self.setPatchList(&dm, key: "groupChannels", values: groupChannels)
return dm.isEmpty ? nil : dm
}
private func buildDiscordGuildsConfig() -> [String: Any]? {
let guilds: [String: Any] = self.discordGuilds.reduce(into: [:]) { result, entry in
let key = self.trimmed(entry.key)
guard !key.isEmpty else { return }
var payload: [String: Any] = [:]
let slug = self.trimmed(entry.slug)
if !slug.isEmpty { payload["slug"] = slug }
if entry.requireMention { payload["requireMention"] = true }
if ["off", "own", "all", "allowlist"].contains(entry.reactionNotifications) {
payload["reactionNotifications"] = entry.reactionNotifications
}
let users = self.splitCsv(entry.users)
if !users.isEmpty { payload["users"] = users }
let channels: [String: Any] = entry.channels.reduce(into: [:]) { channelsResult, channel in
let channelKey = self.trimmed(channel.key)
guard !channelKey.isEmpty else { return }
var channelPayload: [String: Any] = [:]
if !channel.allow { channelPayload["allow"] = false }
if channel.requireMention { channelPayload["requireMention"] = true }
channelsResult[channelKey] = channelPayload
}
if !channels.isEmpty { payload["channels"] = channels }
result[key] = payload
private func buildDiscordGuildsPatch(base: [String: Any]) -> Any? {
if self.discordGuilds.isEmpty {
return NSNull()
}
return guilds.isEmpty ? nil : guilds
var patch: [String: Any] = [:]
let baseKeys = Set(base.keys)
var formKeys = Set<String>()
for entry in self.discordGuilds {
let key = self.trimmed(entry.key)
guard !key.isEmpty else { continue }
formKeys.insert(key)
let baseGuild = base[key] as? [String: Any] ?? [:]
patch[key] = self.buildDiscordGuildPatch(entry, base: baseGuild)
}
for key in baseKeys.subtracting(formKeys) {
patch[key] = NSNull()
}
return patch.isEmpty ? NSNull() : patch
}
private func buildDiscordActionsConfig(base: [String: Any]) -> [String: Any]? {
var actions = base
private func buildDiscordGuildPatch(_ entry: DiscordGuildForm, base: [String: Any]) -> [String: Any] {
var payload: [String: Any] = [:]
let slug = self.trimmed(entry.slug)
if slug.isEmpty {
payload["slug"] = NSNull()
} else {
payload["slug"] = slug
}
if entry.requireMention {
payload["requireMention"] = true
} else {
payload["requireMention"] = NSNull()
}
if ["off", "all", "allowlist"].contains(entry.reactionNotifications) {
payload["reactionNotifications"] = entry.reactionNotifications
} else {
payload["reactionNotifications"] = NSNull()
}
let users = self.splitCsv(entry.users)
self.setPatchList(&payload, key: "users", values: users)
let baseChannels = base["channels"] as? [String: Any] ?? [:]
if let channels = self.buildDiscordChannelsPatch(base: baseChannels, forms: entry.channels) {
payload["channels"] = channels
} else {
payload["channels"] = NSNull()
}
return payload
}
private func buildDiscordChannelsPatch(base: [String: Any], forms: [DiscordGuildChannelForm]) -> Any? {
if forms.isEmpty {
return NSNull()
}
var patch: [String: Any] = [:]
let baseKeys = Set(base.keys)
var formKeys = Set<String>()
for channel in forms {
let channelKey = self.trimmed(channel.key)
guard !channelKey.isEmpty else { continue }
formKeys.insert(channelKey)
var channelPayload: [String: Any] = [:]
self.setPatchBool(&channelPayload, key: "allow", value: channel.allow, defaultValue: true)
self.setPatchBool(
&channelPayload,
key: "requireMention",
value: channel.requireMention,
defaultValue: false)
patch[channelKey] = channelPayload
}
for key in baseKeys.subtracting(formKeys) {
patch[key] = NSNull()
}
return patch.isEmpty ? NSNull() : patch
}
private func buildDiscordActionsPatch() -> [String: Any]? {
var actions: [String: Any] = [:]
self.setAction(&actions, key: "reactions", value: self.discordActionReactions, defaultValue: true)
self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true)
self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true)
@@ -410,52 +439,44 @@ extension ConnectionsStore {
return actions.isEmpty ? nil : actions
}
private func buildDiscordSlashConfig(base: [String: Any]) -> [String: Any]? {
var slash = base
if self.discordSlashEnabled {
slash["enabled"] = true
} else {
slash.removeValue(forKey: "enabled")
}
self.setOptionalString(&slash, key: "name", value: self.discordSlashName)
self.setOptionalString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
if self.discordSlashEphemeral {
slash.removeValue(forKey: "ephemeral")
} else {
slash["ephemeral"] = false
}
private func buildDiscordSlashPatch() -> [String: Any]? {
var slash: [String: Any] = [:]
self.setPatchBool(&slash, key: "enabled", value: self.discordSlashEnabled, defaultValue: false)
self.setPatchString(&slash, key: "name", value: self.discordSlashName)
self.setPatchString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
self.setPatchBool(&slash, key: "ephemeral", value: self.discordSlashEphemeral, defaultValue: true)
return slash.isEmpty ? nil : slash
}
private func persistConfig() async {
private func persistChannelPatch(_ channelId: String, payload: [String: Any]) async {
do {
guard let baseHash = self.configHash else {
self.configStatus = "Config hash missing; reload and retry."
return
}
let data = try JSONSerialization.data(
withJSONObject: self.configRoot,
withJSONObject: ["channels": [channelId: payload]],
options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else {
self.configStatus = "Failed to encode config."
return
}
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
let params: [String: AnyCodable] = [
"raw": AnyCodable(raw),
"baseHash": AnyCodable(baseHash),
]
_ = try await GatewayConnection.shared.requestRaw(
method: .configSet,
method: .configPatch,
params: params,
timeoutMs: 10000)
self.configStatus = "Saved to ~/.clawdbot/clawdbot.json."
await self.loadConfig()
await self.refresh(probe: true)
} catch {
self.configStatus = error.localizedDescription
}
}
private func setSection(_ key: String, payload: [String: Any]) {
if payload.isEmpty {
self.configRoot.removeValue(forKey: key)
} else {
self.configRoot[key] = payload
}
}
private func stringList(from values: [AnyCodable]?) -> String {
guard let values else { return "" }
let strings = values.compactMap { entry -> String? in
@@ -492,25 +513,29 @@ extension ConnectionsStore {
value.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func setOptionalString(_ target: inout [String: Any], key: String, value: String) {
private func setPatchString(_ target: inout [String: Any], key: String, value: String) {
let trimmed = self.trimmed(value)
if trimmed.isEmpty {
target.removeValue(forKey: key)
target[key] = NSNull()
} else {
target[key] = trimmed
}
}
private func setOptionalNumber(_ target: inout [String: Any], key: String, value: String) {
private func setPatchNumber(_ target: inout [String: Any], key: String, value: String) {
let trimmed = self.trimmed(value)
if trimmed.isEmpty {
target.removeValue(forKey: key)
} else if let number = Double(trimmed) {
target[key] = NSNull()
return
}
if let number = Double(trimmed) {
target[key] = number
} else {
target[key] = NSNull()
}
}
private func setOptionalInt(
private func setPatchInt(
_ target: inout [String: Any],
key: String,
value: String,
@@ -518,26 +543,39 @@ extension ConnectionsStore {
{
let trimmed = self.trimmed(value)
if trimmed.isEmpty {
target.removeValue(forKey: key)
target[key] = NSNull()
return
}
guard let number = Int(trimmed) else {
target.removeValue(forKey: key)
target[key] = NSNull()
return
}
let isValid = allowZero ? number >= 0 : number > 0
guard isValid else {
target.removeValue(forKey: key)
target[key] = NSNull()
return
}
target[key] = number
}
private func setOptionalBool(_ target: inout [String: Any], key: String, value: Bool) {
if value {
target[key] = true
private func setPatchBool(
_ target: inout [String: Any],
key: String,
value: Bool,
defaultValue: Bool)
{
if value == defaultValue {
target[key] = NSNull()
} else {
target.removeValue(forKey: key)
target[key] = value
}
}
private func setPatchList(_ target: inout [String: Any], key: String, values: [String]) {
if values.isEmpty {
target[key] = NSNull()
} else {
target[key] = values
}
}
@@ -548,7 +586,7 @@ extension ConnectionsStore {
defaultValue: Bool)
{
if value == defaultValue {
actions.removeValue(forKey: key)
actions[key] = NSNull()
} else {
actions[key] = value
}

View File

@@ -31,8 +31,8 @@ extension ConnectionsStore {
"probe": AnyCodable(probe),
"timeoutMs": AnyCodable(8000),
]
let snap: ProvidersStatusSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .providersStatus,
let snap: ChannelsStatusSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .channelsStatus,
params: params,
timeoutMs: 12000)
self.snapshot = snap
@@ -101,10 +101,10 @@ extension ConnectionsStore {
defer { self.whatsappBusy = false }
do {
let params: [String: AnyCodable] = [
"provider": AnyCodable("whatsapp"),
"channel": AnyCodable("whatsapp"),
]
let result: ProviderLogoutResult = try await GatewayConnection.shared.requestDecoded(
method: .providersLogout,
let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded(
method: .channelsLogout,
params: params,
timeoutMs: 15000)
self.whatsappLoginMessage = result.cleared
@@ -123,10 +123,10 @@ extension ConnectionsStore {
defer { self.telegramBusy = false }
do {
let params: [String: AnyCodable] = [
"provider": AnyCodable("telegram"),
"channel": AnyCodable("telegram"),
]
let result: ProviderLogoutResult = try await GatewayConnection.shared.requestDecoded(
method: .providersLogout,
let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded(
method: .channelsLogout,
params: params,
timeoutMs: 15000)
if result.envToken == true {
@@ -154,8 +154,8 @@ private struct WhatsAppLoginWaitResult: Codable {
let message: String
}
private struct ProviderLogoutResult: Codable {
let provider: String?
private struct ChannelLogoutResult: Codable {
let channel: String?
let accountId: String?
let cleared: Bool
let envToken: Bool?

View File

@@ -2,7 +2,7 @@ import ClawdbotProtocol
import Foundation
import Observation
struct ProvidersStatusSnapshot: Codable {
struct ChannelsStatusSnapshot: Codable {
struct WhatsAppSelf: Codable {
let e164: String?
let jid: String?
@@ -121,7 +121,7 @@ struct ProvidersStatusSnapshot: Codable {
let lastProbeAt: Double?
}
struct ProviderAccountSnapshot: Codable {
struct ChannelAccountSnapshot: Codable {
let accountId: String
let name: String?
let enabled: Bool?
@@ -154,14 +154,14 @@ struct ProvidersStatusSnapshot: Codable {
}
let ts: Double
let providerOrder: [String]
let providerLabels: [String: String]
let providers: [String: AnyCodable]
let providerAccounts: [String: [ProviderAccountSnapshot]]
let providerDefaultAccountId: [String: String]
let channelOrder: [String]
let channelLabels: [String: String]
let channels: [String: AnyCodable]
let channelAccounts: [String: [ChannelAccountSnapshot]]
let channelDefaultAccountId: [String: String]
func decodeProvider<T: Decodable>(_ id: String, as type: T.Type) -> T? {
guard let value = self.providers[id] else { return nil }
func decodeChannel<T: Decodable>(_ id: String, as type: T.Type) -> T? {
guard let value = self.channels[id] else { return nil }
do {
let data = try JSONEncoder().encode(value)
return try JSONDecoder().decode(type, from: data)
@@ -180,6 +180,7 @@ struct ConfigSnapshot: Codable {
let path: String?
let exists: Bool?
let raw: String?
let hash: String?
let parsed: AnyCodable?
let valid: Bool?
let config: [String: AnyCodable]?
@@ -230,7 +231,7 @@ struct DiscordGuildForm: Identifiable {
final class ConnectionsStore {
static let shared = ConnectionsStore()
var snapshot: ProvidersStatusSnapshot?
var snapshot: ChannelsStatusSnapshot?
var lastError: String?
var lastSuccess: Date?
var isRefreshing = false
@@ -307,6 +308,7 @@ final class ConnectionsStore {
var pollTask: Task<Void, Never>?
var configRoot: [String: Any] = [:]
var configLoaded = false
var configHash: String?
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview

View File

@@ -189,20 +189,20 @@ enum CritterIconRenderer {
canvas.context.setFillColor(NSColor.labelColor.cgColor)
canvas.context.addPath(CGPath(
roundedRect: geometry.bodyRect,
cornerWidth: geometry.bodyCorner,
cornerHeight: geometry.bodyCorner,
transform: nil))
roundedRect: geometry.bodyRect,
cornerWidth: geometry.bodyCorner,
cornerHeight: geometry.bodyCorner,
transform: nil))
canvas.context.addPath(CGPath(
roundedRect: geometry.leftEarRect,
cornerWidth: geometry.earCorner,
cornerHeight: geometry.earCorner,
transform: nil))
roundedRect: geometry.leftEarRect,
cornerWidth: geometry.earCorner,
cornerHeight: geometry.earCorner,
transform: nil))
canvas.context.addPath(CGPath(
roundedRect: geometry.rightEarRect,
cornerWidth: geometry.earCorner,
cornerHeight: geometry.earCorner,
transform: nil))
roundedRect: geometry.rightEarRect,
cornerWidth: geometry.earCorner,
cornerHeight: geometry.earCorner,
transform: nil))
for i in 0..<4 {
let x = geometry.legStartX + CGFloat(i) * (geometry.legW + geometry.legSpacing)
@@ -213,10 +213,10 @@ enum CritterIconRenderer {
width: geometry.legW,
height: geometry.legH * geometry.legHeightScale)
canvas.context.addPath(CGPath(
roundedRect: rect,
cornerWidth: geometry.legW * 0.34,
cornerHeight: geometry.legW * 0.34,
transform: nil))
roundedRect: rect,
cornerWidth: geometry.legW * 0.34,
cornerHeight: geometry.legW * 0.34,
transform: nil))
}
canvas.context.fillPath()
}
@@ -252,15 +252,15 @@ enum CritterIconRenderer {
height: holeH)
canvas.context.addPath(CGPath(
roundedRect: leftHoleRect,
cornerWidth: holeCorner,
cornerHeight: holeCorner,
transform: nil))
roundedRect: leftHoleRect,
cornerWidth: holeCorner,
cornerHeight: holeCorner,
transform: nil))
canvas.context.addPath(CGPath(
roundedRect: rightHoleRect,
cornerWidth: holeCorner,
cornerHeight: holeCorner,
transform: nil))
roundedRect: rightHoleRect,
cornerWidth: holeCorner,
cornerHeight: holeCorner,
transform: nil))
}
if options.eyesClosedLines {
@@ -278,41 +278,41 @@ enum CritterIconRenderer {
width: lineW,
height: lineH)
canvas.context.addPath(CGPath(
roundedRect: leftRect,
cornerWidth: corner,
cornerHeight: corner,
transform: nil))
roundedRect: leftRect,
cornerWidth: corner,
cornerHeight: corner,
transform: nil))
canvas.context.addPath(CGPath(
roundedRect: rightRect,
cornerWidth: corner,
cornerHeight: corner,
transform: nil))
roundedRect: rightRect,
cornerWidth: corner,
cornerHeight: corner,
transform: nil))
} else {
let eyeOpen = max(0.05, 1 - options.blink)
let eyeH = canvas.snapY(geometry.bodyRect.height * 0.26 * eyeOpen)
let left = CGMutablePath()
left.move(to: CGPoint(
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
y: canvas.snapY(leftCenter.y - eyeH)))
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
y: canvas.snapY(leftCenter.y - eyeH)))
left.addLine(to: CGPoint(
x: canvas.snapX(leftCenter.x + geometry.eyeW / 2),
y: canvas.snapY(leftCenter.y)))
x: canvas.snapX(leftCenter.x + geometry.eyeW / 2),
y: canvas.snapY(leftCenter.y)))
left.addLine(to: CGPoint(
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
y: canvas.snapY(leftCenter.y + eyeH)))
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
y: canvas.snapY(leftCenter.y + eyeH)))
left.closeSubpath()
let right = CGMutablePath()
right.move(to: CGPoint(
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
y: canvas.snapY(rightCenter.y - eyeH)))
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
y: canvas.snapY(rightCenter.y - eyeH)))
right.addLine(to: CGPoint(
x: canvas.snapX(rightCenter.x - geometry.eyeW / 2),
y: canvas.snapY(rightCenter.y)))
x: canvas.snapX(rightCenter.x - geometry.eyeW / 2),
y: canvas.snapY(rightCenter.y)))
right.addLine(to: CGPoint(
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
y: canvas.snapY(rightCenter.y + eyeH)))
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
y: canvas.snapY(rightCenter.y + eyeH)))
right.closeSubpath()
canvas.context.addPath(left)

View File

@@ -121,12 +121,12 @@ extension CritterStatusLabel {
}
return Image(nsImage: CritterIconRenderer.makeIcon(
blink: self.blinkAmount,
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
earWiggle: self.earWiggle,
earScale: self.earBoostActive ? 1.9 : 1.0,
earHoles: self.earBoostActive,
badge: badge))
blink: self.blinkAmount,
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
earWiggle: self.earWiggle,
earScale: self.earBoostActive ? 1.9 : 1.0,
earHoles: self.earBoostActive,
badge: badge))
}
private func resetMotion() {

View File

@@ -13,7 +13,9 @@ extension CronJobEditor {
guard let job else { return }
self.name = job.name
self.description = job.description ?? ""
self.agentId = job.agentId ?? ""
self.enabled = job.enabled
self.deleteAfterRun = job.deleteAfterRun ?? false
self.sessionTarget = job.sessionTarget
self.wakeMode = job.wakeMode
@@ -34,13 +36,13 @@ extension CronJobEditor {
case let .systemEvent(text):
self.payloadKind = .systemEvent
self.systemEventText = text
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver):
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
self.payloadKind = .agentTurn
self.agentMessage = message
self.thinking = thinking ?? ""
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
self.deliver = deliver ?? false
self.provider = GatewayAgentProvider(raw: provider)
self.channel = GatewayAgentChannel(raw: channel)
self.to = to ?? ""
self.bestEffortDeliver = bestEffortDeliver ?? false
}
@@ -59,18 +61,60 @@ extension CronJobEditor {
}
func buildPayload() throws -> [String: AnyCodable] {
let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
let name = try self.requireName()
let description = self.trimmed(self.description)
let agentId = self.trimmed(self.agentId)
let schedule = try self.buildSchedule()
let payload = try self.buildSelectedPayload()
try self.validateSessionTarget(payload)
try self.validatePayloadRequiredFields(payload)
var root: [String: Any] = [
"name": name,
"enabled": self.enabled,
"schedule": schedule,
"sessionTarget": self.sessionTarget.rawValue,
"wakeMode": self.wakeMode.rawValue,
"payload": payload,
]
self.applyDeleteAfterRun(to: &root)
if !description.isEmpty { root["description"] = description }
if !agentId.isEmpty {
root["agentId"] = agentId
} else if self.job?.agentId != nil {
root["agentId"] = NSNull()
}
if self.sessionTarget == .isolated {
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
root["isolation"] = [
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
]
}
return root.mapValues { AnyCodable($0) }
}
func trimmed(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines)
}
func requireName() throws -> String {
let name = self.trimmed(self.name)
if name.isEmpty {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Name is required."])
}
let description = self.description.trimmingCharacters(in: .whitespacesAndNewlines)
let schedule: [String: Any]
return name
}
func buildSchedule() throws -> [String: Any] {
switch self.scheduleKind {
case .at:
schedule = ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
return ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
case .every:
guard let ms = Self.parseDurationMs(self.everyText) else {
throw NSError(
@@ -78,34 +122,35 @@ extension CronJobEditor {
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."])
}
schedule = ["kind": "every", "everyMs": ms]
return ["kind": "every", "everyMs": ms]
case .cron:
let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines)
let expr = self.trimmed(self.cronExpr)
if expr.isEmpty {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
}
let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines)
let tz = self.trimmed(self.cronTz)
if tz.isEmpty {
schedule = ["kind": "cron", "expr": expr]
} else {
schedule = ["kind": "cron", "expr": expr, "tz": tz]
return ["kind": "cron", "expr": expr]
}
return ["kind": "cron", "expr": expr, "tz": tz]
}
}
let payload: [String: Any] = {
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
switch self.payloadKind {
case .systemEvent:
let text = self.systemEventText.trimmingCharacters(in: .whitespacesAndNewlines)
return ["kind": "systemEvent", "text": text]
case .agentTurn:
return self.buildAgentTurnPayload()
}
}()
func buildSelectedPayload() throws -> [String: Any] {
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
switch self.payloadKind {
case .systemEvent:
let text = self.trimmed(self.systemEventText)
return ["kind": "systemEvent", "text": text]
case .agentTurn:
return self.buildAgentTurnPayload()
}
}
func validateSessionTarget(_ payload: [String: Any]) throws {
if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" {
throw NSError(
domain: "Cron",
@@ -122,7 +167,9 @@ extension CronJobEditor {
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."])
}
}
func validatePayloadRequiredFields(_ payload: [String: Any]) throws {
if payload["kind"] as? String == "systemEvent" {
if (payload["text"] as? String ?? "").isEmpty {
throw NSError(
@@ -130,7 +177,8 @@ extension CronJobEditor {
code: 0,
userInfo: [NSLocalizedDescriptionKey: "System event text is required."])
}
} else if payload["kind"] as? String == "agentTurn" {
}
if payload["kind"] as? String == "agentTurn" {
if (payload["message"] as? String ?? "").isEmpty {
throw NSError(
domain: "Cron",
@@ -138,25 +186,14 @@ extension CronJobEditor {
userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
}
}
}
var root: [String: Any] = [
"name": name,
"enabled": self.enabled,
"schedule": schedule,
"sessionTarget": self.sessionTarget.rawValue,
"wakeMode": self.wakeMode.rawValue,
"payload": payload,
]
if !description.isEmpty { root["description"] = description }
if self.sessionTarget == .isolated {
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
root["isolation"] = [
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
]
func applyDeleteAfterRun(to root: inout [String: Any]) {
if self.scheduleKind == .at {
root["deleteAfterRun"] = self.deleteAfterRun
} else if self.job?.deleteAfterRun != nil {
root["deleteAfterRun"] = false
}
return root.mapValues { AnyCodable($0) }
}
func buildAgentTurnPayload() -> [String: Any] {
@@ -167,7 +204,7 @@ extension CronJobEditor {
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
payload["deliver"] = self.deliver
if self.deliver {
payload["provider"] = self.provider.rawValue
payload["channel"] = self.channel.rawValue
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { payload["to"] = to }
payload["bestEffortDeliver"] = self.bestEffortDeliver

View File

@@ -3,6 +3,7 @@ extension CronJobEditor {
mutating func exerciseForTesting() {
self.name = "Test job"
self.description = "Test description"
self.agentId = "ops"
self.enabled = true
self.sessionTarget = .isolated
self.wakeMode = .now
@@ -13,7 +14,7 @@ extension CronJobEditor {
self.payloadKind = .agentTurn
self.agentMessage = "Run diagnostic"
self.deliver = true
self.provider = .last
self.channel = .last
self.to = "+15551230000"
self.thinking = "low"
self.timeoutSeconds = "90"

View File

@@ -11,15 +11,15 @@ struct CronJobEditor: View {
let labelColumnWidth: CGFloat = 160
static let introText =
"Create a schedule that wakes clawd via the Gateway. "
+ "Use an isolated session for agent turns so your main chat stays clean."
+ "Use an isolated session for agent turns so your main chat stays clean."
static let sessionTargetNote =
"Main jobs post a system event into the current main session. "
+ "Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/Discord/etc)."
+ "Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/Discord/etc)."
static let scheduleKindNote =
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
static let isolatedPayloadNote =
"Isolated jobs always run an agent turn. The result can be delivered to a provider, "
+ "and a short summary is posted back to your main chat."
"Isolated jobs always run an agent turn. The result can be delivered to a channel, "
+ "and a short summary is posted back to your main chat."
static let mainPayloadNote =
"System events are injected into the current main session. Agent turns require an isolated session target."
static let mainSummaryNote =
@@ -27,9 +27,11 @@ struct CronJobEditor: View {
@State var name: String = ""
@State var description: String = ""
@State var agentId: String = ""
@State var enabled: Bool = true
@State var sessionTarget: CronSessionTarget = .main
@State var wakeMode: CronWakeMode = .nextHeartbeat
@State var deleteAfterRun: Bool = false
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
@State var scheduleKind: ScheduleKind = .every
@@ -43,7 +45,7 @@ struct CronJobEditor: View {
@State var systemEventText: String = ""
@State var agentMessage: String = ""
@State var deliver: Bool = false
@State var provider: GatewayAgentProvider = .last
@State var channel: GatewayAgentChannel = .last
@State var to: String = ""
@State var thinking: String = ""
@State var timeoutSeconds: String = ""
@@ -77,6 +79,12 @@ struct CronJobEditor: View {
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Agent ID")
TextField("Optional (default agent)", text: self.$agentId)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$enabled)
@@ -149,6 +157,11 @@ struct CronJobEditor: View {
.labelsHidden()
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Auto-delete")
Toggle("Delete after successful run", isOn: self.$deleteAfterRun)
.toggleStyle(.switch)
}
case .every:
GridRow {
self.gridLabel("Every")
@@ -310,7 +323,7 @@ struct CronJobEditor: View {
}
GridRow {
self.gridLabel("Deliver")
Toggle("Deliver result to a provider", isOn: self.$deliver)
Toggle("Deliver result to a channel", isOn: self.$deliver)
.toggleStyle(.switch)
}
}
@@ -318,15 +331,15 @@ struct CronJobEditor: View {
if self.deliver {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Provider")
Picker("", selection: self.$provider) {
Text("last").tag(GatewayAgentProvider.last)
Text("whatsapp").tag(GatewayAgentProvider.whatsapp)
Text("telegram").tag(GatewayAgentProvider.telegram)
Text("discord").tag(GatewayAgentProvider.discord)
Text("slack").tag(GatewayAgentProvider.slack)
Text("signal").tag(GatewayAgentProvider.signal)
Text("imessage").tag(GatewayAgentProvider.imessage)
self.gridLabel("Channel")
Picker("", selection: self.$channel) {
Text("last").tag(GatewayAgentChannel.last)
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
Text("telegram").tag(GatewayAgentChannel.telegram)
Text("discord").tag(GatewayAgentChannel.discord)
Text("slack").tag(GatewayAgentChannel.slack)
Text("signal").tag(GatewayAgentChannel.signal)
Text("imessage").tag(GatewayAgentChannel.imessage)
}
.labelsHidden()
.pickerStyle(.segmented)

View File

@@ -70,16 +70,16 @@ enum CronSchedule: Codable, Equatable {
enum CronPayload: Codable, Equatable {
case systemEvent(text: String)
case agentTurn(
message: String,
thinking: String?,
timeoutSeconds: Int?,
deliver: Bool?,
provider: String?,
to: String?,
bestEffortDeliver: Bool?)
message: String,
thinking: String?,
timeoutSeconds: Int?,
deliver: Bool?,
channel: String?,
to: String?,
bestEffortDeliver: Bool?)
enum CodingKeys: String, CodingKey {
case kind, text, message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver
case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver
}
var kind: String {
@@ -101,7 +101,8 @@ enum CronPayload: Codable, Equatable {
thinking: container.decodeIfPresent(String.self, forKey: .thinking),
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver),
provider: container.decodeIfPresent(String.self, forKey: .provider),
channel: container.decodeIfPresent(String.self, forKey: .channel)
?? container.decodeIfPresent(String.self, forKey: .provider),
to: container.decodeIfPresent(String.self, forKey: .to),
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
default:
@@ -118,12 +119,12 @@ enum CronPayload: Codable, Equatable {
switch self {
case let .systemEvent(text):
try container.encode(text, forKey: .text)
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver):
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
try container.encode(message, forKey: .message)
try container.encodeIfPresent(thinking, forKey: .thinking)
try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
try container.encodeIfPresent(deliver, forKey: .deliver)
try container.encodeIfPresent(provider, forKey: .provider)
try container.encodeIfPresent(channel, forKey: .channel)
try container.encodeIfPresent(to, forKey: .to)
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
}
@@ -145,9 +146,11 @@ struct CronJobState: Codable, Equatable {
struct CronJob: Identifiable, Codable, Equatable {
let id: String
let agentId: String?
var name: String
var description: String?
var enabled: Bool
var deleteAfterRun: Bool?
let createdAtMs: Int
let updatedAtMs: Int
let schedule: CronSchedule

View File

@@ -26,8 +26,8 @@ extension CronSettings {
})
}
.alert("Delete cron job?", isPresented: Binding(
get: { self.confirmDelete != nil },
set: { if !$0 { self.confirmDelete = nil } }))
get: { self.confirmDelete != nil },
set: { if !$0 { self.confirmDelete = nil } }))
{
Button("Cancel", role: .cancel) { self.confirmDelete = nil }
Button("Delete", role: .destructive) {
@@ -42,9 +42,9 @@ extension CronSettings {
}
}
.onChange(of: self.store.selectedJobId) { _, newValue in
guard let newValue else { return }
Task { await self.store.refreshRuns(jobId: newValue) }
}
guard let newValue else { return }
Task { await self.store.refreshRuns(jobId: newValue) }
}
}
var schedulerBanner: some View {

View File

@@ -20,6 +20,9 @@ extension CronSettings {
HStack(spacing: 6) {
StatusPill(text: job.sessionTarget.rawValue, tint: .secondary)
StatusPill(text: job.wakeMode.rawValue, tint: .secondary)
if let agentId = job.agentId, !agentId.isEmpty {
StatusPill(text: "agent \(agentId)", tint: .secondary)
}
if let status = job.state.lastStatus {
StatusPill(text: status, tint: status == "ok" ? .green : .orange)
}
@@ -66,8 +69,8 @@ extension CronSettings {
Spacer()
HStack(spacing: 8) {
Toggle("Enabled", isOn: Binding(
get: { job.enabled },
set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } }))
get: { job.enabled },
set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } }))
.toggleStyle(.switch)
.labelsHidden()
Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } }
@@ -91,9 +94,15 @@ extension CronSettings {
func detailCard(_ job: CronJob) -> some View {
VStack(alignment: .leading, spacing: 10) {
LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) }
if case .at = job.schedule, job.deleteAfterRun == true {
LabeledContent("Auto-delete") { Text("after success") }
}
if let desc = job.description, !desc.isEmpty {
LabeledContent("Description") { Text(desc).font(.callout) }
}
if let agentId = job.agentId, !agentId.isEmpty {
LabeledContent("Agent") { Text(agentId) }
}
LabeledContent("Session") { Text(job.sessionTarget.rawValue) }
LabeledContent("Wake") { Text(job.wakeMode.rawValue) }
LabeledContent("Next run") {

View File

@@ -7,9 +7,11 @@ struct CronSettings_Previews: PreviewProvider {
store.jobs = [
CronJob(
id: "job-1",
agentId: "ops",
name: "Daily summary",
description: nil,
enabled: true,
deleteAfterRun: nil,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .every(everyMs: 86_400_000, anchorMs: nil),
@@ -20,7 +22,7 @@ struct CronSettings_Previews: PreviewProvider {
thinking: "low",
timeoutSeconds: 600,
deliver: true,
provider: "last",
channel: "last",
to: nil,
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"),
@@ -59,9 +61,11 @@ extension CronSettings {
let job = CronJob(
id: "job-1",
agentId: "ops",
name: "Daily summary",
description: "Summary job",
enabled: true,
deleteAfterRun: nil,
createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_100_000,
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
@@ -72,7 +76,7 @@ extension CronSettings {
thinking: "low",
timeoutSeconds: 120,
deliver: true,
provider: "whatsapp",
channel: "whatsapp",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "),

View File

@@ -102,8 +102,8 @@ enum DebugActions {
_ = try await RemoteTunnelManager.shared.ensureControlTunnel()
let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote(
target: settings.target,
identity: settings.identity))
target: settings.target,
identity: settings.identity))
} catch {
// ControlChannel will surface a degraded state; also refresh health to update the menu text.
Task { await HealthStore.shared.refresh(onDemand: true) }
@@ -127,8 +127,8 @@ enum DebugActions {
_ = try await RemoteTunnelManager.shared.ensureControlTunnel()
let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote(
target: settings.target,
identity: settings.identity))
target: settings.target,
identity: settings.identity))
await HealthStore.shared.refresh(onDemand: true)
return .success("SSH tunnel reset.")
} catch {

View File

@@ -59,7 +59,7 @@ final class DeepLinkHandler {
}
do {
let provider = GatewayAgentProvider(raw: link.channel)
let channel = GatewayAgentChannel(raw: link.channel)
let explicitSessionKey = link.sessionKey?
.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty
@@ -72,9 +72,9 @@ final class DeepLinkHandler {
message: messagePreview,
sessionKey: resolvedSessionKey,
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
deliver: provider.shouldDeliver(link.deliver),
deliver: channel.shouldDeliver(link.deliver),
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
provider: provider,
channel: channel,
timeoutSeconds: link.timeoutSeconds,
idempotencyKey: UUID().uuidString)

View File

@@ -107,9 +107,9 @@ enum DeviceModelCatalog {
private static func loadMapping(resourceName: String) -> [String: String] {
guard let url = self.resourceBundle?.url(
forResource: resourceName,
withExtension: "json",
subdirectory: self.resourceSubdirectory)
forResource: resourceName,
withExtension: "json",
subdirectory: self.resourceSubdirectory)
else { return [:] }
do {

View File

@@ -1,26 +0,0 @@
import Foundation
enum GatewayAgentChannel: String, CaseIterable, Sendable {
case last
case webchat
case whatsapp
case telegram
init(raw: String?) {
let trimmed = raw?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased() ?? ""
self = GatewayAgentChannel(rawValue: trimmed) ?? .last
}
func shouldDeliver(_ isLast: Bool) -> Bool {
switch self {
case .webchat:
false
case .last:
isLast
case .whatsapp, .telegram:
true
}
}
}

View File

@@ -113,17 +113,17 @@ actor GatewayChannelActor {
self.task = nil
await self.failPending(NSError(
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
let waiters = self.connectWaiters
self.connectWaiters.removeAll()
for waiter in waiters {
waiter.resume(throwing: NSError(
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
}
}

View File

@@ -5,7 +5,7 @@ import OSLog
private let gatewayConnectionLogger = Logger(subsystem: "com.clawdbot", category: "gateway.connection")
enum GatewayAgentProvider: String, Codable, CaseIterable, Sendable {
enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
case last
case whatsapp
case telegram
@@ -18,7 +18,7 @@ enum GatewayAgentProvider: String, Codable, CaseIterable, Sendable {
init(raw: String?) {
let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
self = GatewayAgentProvider(rawValue: normalized) ?? .last
self = GatewayAgentChannel(rawValue: normalized) ?? .last
}
var isDeliverable: Bool { self != .webchat }
@@ -32,7 +32,7 @@ struct GatewayAgentInvocation: Sendable {
var thinking: String?
var deliver: Bool = false
var to: String?
var provider: GatewayAgentProvider = .last
var channel: GatewayAgentChannel = .last
var timeoutSeconds: Int?
var idempotencyKey: String = UUID().uuidString
}
@@ -52,9 +52,10 @@ actor GatewayConnection {
case setHeartbeats = "set-heartbeats"
case systemEvent = "system-event"
case health
case providersStatus = "providers.status"
case channelsStatus = "channels.status"
case configGet = "config.get"
case configSet = "config.set"
case configPatch = "config.patch"
case wizardStart = "wizard.start"
case wizardNext = "wizard.next"
case wizardCancel = "wizard.cancel"
@@ -62,7 +63,7 @@ actor GatewayConnection {
case talkMode = "talk.mode"
case webLoginStart = "web.login.start"
case webLoginWait = "web.login.wait"
case providersLogout = "providers.logout"
case channelsLogout = "channels.logout"
case modelsList = "models.list"
case chatHistory = "chat.history"
case chatSend = "chat.send"
@@ -232,7 +233,18 @@ actor GatewayConnection {
func canvasHostUrl() async -> String? {
guard let snapshot = self.lastSnapshot else { return nil }
let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private func sessionDefaultString(_ defaults: [String: AnyCodable]?, key: String) -> String {
(defaults?[key]?.stringValue ?? "")
.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
}
func cachedMainSessionKey() -> String? {
guard let snapshot = self.lastSnapshot else { return nil }
let trimmed = self.sessionDefaultString(snapshot.snapshot.sessiondefaults, key: "mainSessionKey")
return trimmed.isEmpty ? nil : trimmed
}
@@ -267,12 +279,35 @@ actor GatewayConnection {
private func broadcast(_ push: GatewayPush) {
if case let .snapshot(snapshot) = push {
self.lastSnapshot = snapshot
if let mainSessionKey = self.cachedMainSessionKey() {
Task { @MainActor in
WorkActivityStore.shared.setMainSessionKey(mainSessionKey)
}
}
}
for (_, continuation) in self.subscribers {
continuation.yield(push)
}
}
private func canonicalizeSessionKey(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
guard !trimmed.isEmpty else { return trimmed }
guard let defaults = self.lastSnapshot?.snapshot.sessiondefaults else { return trimmed }
let mainSessionKey = self.sessionDefaultString(defaults, key: "mainSessionKey")
guard !mainSessionKey.isEmpty else { return trimmed }
let mainKey = self.sessionDefaultString(defaults, key: "mainKey")
let defaultAgentId = self.sessionDefaultString(defaults, key: "defaultAgentId")
let isMainAlias =
trimmed == "main" ||
(!mainKey.isEmpty && trimmed == mainKey) ||
trimmed == mainSessionKey ||
(!defaultAgentId.isEmpty &&
(trimmed == "agent:\(defaultAgentId):main" ||
(mainKey.isEmpty == false && trimmed == "agent:\(defaultAgentId):\(mainKey)")))
return isMainAlias ? mainSessionKey : trimmed
}
private func configure(url: URL, token: String?, password: String?) async {
if self.client != nil, self.configuredURL == url, self.configuredToken == token,
self.configuredPassword == password
@@ -331,6 +366,9 @@ extension GatewayConnection {
}
func mainSessionKey(timeoutMs: Double = 15000) async -> String {
if let cached = self.cachedMainSessionKey() {
return cached
}
do {
let data = try await self.requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs)
return try Self.mainSessionKey(fromConfigGetData: data)
@@ -361,14 +399,15 @@ extension GatewayConnection {
func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) {
let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return (false, "message empty") }
let sessionKey = self.canonicalizeSessionKey(invocation.sessionKey)
var params: [String: AnyCodable] = [
"message": AnyCodable(trimmed),
"sessionKey": AnyCodable(invocation.sessionKey),
"sessionKey": AnyCodable(sessionKey),
"thinking": AnyCodable(invocation.thinking ?? "default"),
"deliver": AnyCodable(invocation.deliver),
"to": AnyCodable(invocation.to ?? ""),
"provider": AnyCodable(invocation.provider.rawValue),
"channel": AnyCodable(invocation.channel.rawValue),
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
]
if let timeout = invocation.timeoutSeconds {
@@ -389,19 +428,19 @@ extension GatewayConnection {
sessionKey: String,
deliver: Bool,
to: String?,
provider: GatewayAgentProvider = .last,
channel: GatewayAgentChannel = .last,
timeoutSeconds: Int? = nil,
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
{
await self.sendAgent(GatewayAgentInvocation(
message: message,
sessionKey: sessionKey,
thinking: thinking,
deliver: deliver,
to: to,
provider: provider,
timeoutSeconds: timeoutSeconds,
idempotencyKey: idempotencyKey))
message: message,
sessionKey: sessionKey,
thinking: thinking,
deliver: deliver,
to: to,
channel: channel,
timeoutSeconds: timeoutSeconds,
idempotencyKey: idempotencyKey))
}
func sendSystemEvent(_ params: [String: AnyCodable]) async {
@@ -468,7 +507,8 @@ extension GatewayConnection {
limit: Int? = nil,
timeoutMs: Int? = nil) async throws -> ClawdbotChatHistoryPayload
{
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(sessionKey)]
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)]
if let limit { params["limit"] = AnyCodable(limit) }
let timeout = timeoutMs.map { Double($0) }
return try await self.requestDecoded(
@@ -485,8 +525,9 @@ extension GatewayConnection {
attachments: [ClawdbotChatAttachmentPayload],
timeoutMs: Int = 30000) async throws -> ClawdbotChatSendResponse
{
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
var params: [String: AnyCodable] = [
"sessionKey": AnyCodable(sessionKey),
"sessionKey": AnyCodable(resolvedKey),
"message": AnyCodable(message),
"thinking": AnyCodable(thinking),
"idempotencyKey": AnyCodable(idempotencyKey),
@@ -512,10 +553,11 @@ extension GatewayConnection {
}
func chatAbort(sessionKey: String, runId: String) async throws -> Bool {
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? }
let res: AbortResponse = try await self.requestDecoded(
method: .chatAbort,
params: ["sessionKey": AnyCodable(sessionKey), "runId": AnyCodable(runId)])
params: ["sessionKey": AnyCodable(resolvedKey), "runId": AnyCodable(runId)])
return res.aborted ?? false
}

View File

@@ -27,7 +27,7 @@ struct GatewayDiscoveryInlineList: View {
ForEach(self.discovery.gateways.prefix(6)) { gateway in
let target = self.suggestedSSHTarget(gateway)
let selected = (target != nil && self.currentTarget?
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
@@ -61,8 +61,8 @@ struct GatewayDiscoveryInlineList: View {
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(self.rowBackground(
selected: selected,
hovered: self.hoveredGatewayID == gateway.id)))
selected: selected,
hovered: self.hoveredGatewayID == gateway.id)))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(

View File

@@ -33,14 +33,16 @@ actor GatewayEndpointStore {
return GatewayEndpointStore.resolveGatewayToken(
isRemote: CommandResolver.connectionModeIsRemote(),
root: root,
env: ProcessInfo.processInfo.environment)
env: ProcessInfo.processInfo.environment,
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
},
password: {
let root = ClawdbotConfigFile.loadDict()
return GatewayEndpointStore.resolveGatewayPassword(
isRemote: CommandResolver.connectionModeIsRemote(),
root: root,
env: ProcessInfo.processInfo.environment)
env: ProcessInfo.processInfo.environment,
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
},
localPort: { GatewayEnvironment.gatewayPort() },
localHost: {
@@ -60,7 +62,8 @@ actor GatewayEndpointStore {
private static func resolveGatewayPassword(
isRemote: Bool,
root: [String: Any],
env: [String: String]) -> String?
env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot?) -> String?
{
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -88,13 +91,19 @@ actor GatewayEndpointStore {
return pw
}
}
if let password = launchdSnapshot?.password?.trimmingCharacters(in: .whitespacesAndNewlines),
!password.isEmpty
{
return password
}
return nil
}
private static func resolveGatewayToken(
isRemote: Bool,
root: [String: Any],
env: [String: String]) -> String?
env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot?) -> String?
{
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -122,6 +131,11 @@ actor GatewayEndpointStore {
return value
}
}
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
return token
}
return nil
}
@@ -192,10 +206,10 @@ actor GatewayEndpointStore {
let port = self.deps.localPort()
let host = await self.deps.localHost()
self.setState(.ready(
mode: .local,
url: URL(string: "ws://\(host):\(port)")!,
token: token,
password: password))
mode: .local,
url: URL(string: "ws://\(host):\(port)")!,
token: token,
password: password))
case .remote:
let port = await self.deps.remotePortIfRunning()
guard let port else {
@@ -205,10 +219,10 @@ actor GatewayEndpointStore {
}
self.cancelRemoteEnsure()
self.setState(.ready(
mode: .remote,
url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
token: token,
password: password))
mode: .remote,
url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
token: token,
password: password))
case .unconfigured:
self.cancelRemoteEnsure()
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
@@ -388,14 +402,61 @@ actor GatewayEndpointStore {
}
}
extension GatewayEndpointStore {
static func dashboardURL(for config: GatewayConnection.Config) throws -> URL {
guard var components = URLComponents(url: config.url, resolvingAgainstBaseURL: false) else {
throw NSError(domain: "Dashboard", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Invalid gateway URL",
])
}
switch components.scheme?.lowercased() {
case "ws":
components.scheme = "http"
case "wss":
components.scheme = "https"
default:
components.scheme = "http"
}
components.path = "/"
var queryItems: [URLQueryItem] = []
if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
queryItems.append(URLQueryItem(name: "token", value: token))
}
if let password = config.password?.trimmingCharacters(in: .whitespacesAndNewlines),
!password.isEmpty
{
queryItems.append(URLQueryItem(name: "password", value: password))
}
components.queryItems = queryItems.isEmpty ? nil : queryItems
guard let url = components.url else {
throw NSError(domain: "Dashboard", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Failed to build dashboard URL",
])
}
return url
}
}
#if DEBUG
extension GatewayEndpointStore {
static func _testResolveGatewayPassword(
isRemote: Bool,
root: [String: Any],
env: [String: String]) -> String?
env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String?
{
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot)
}
static func _testResolveGatewayToken(
isRemote: Bool,
root: [String: Any],
env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String?
{
self.resolveGatewayToken(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot)
}
static func _testResolveGatewayBindMode(

View File

@@ -306,6 +306,10 @@ enum GatewayLaunchAgentManager {
password: snapshot.password)
}
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
LaunchAgentPlist.snapshot(url: self.plistURL)
}
private static func ensureEnabled() async {
let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
guard result.status != 0 else { return }

View File

@@ -8,7 +8,7 @@ enum GatewayPayloadDecoding {
}
static func decodeIfPresent<T: Decodable>(_ payload: ClawdbotProtocol.AnyCodable?, as _: T.Type = T.self) throws
-> T?
-> T?
{
guard let payload else { return nil }
return try self.decode(payload, as: T.self)

View File

@@ -211,19 +211,19 @@ final class GatewayProcessManager {
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
let instanceText = instance ?? "pid unknown"
if let snap {
let linkId = snap.providerOrder?.first(where: {
if let summary = snap.providers[$0] { return summary.linked != nil }
let linkId = snap.channelOrder?.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
return false
}) ?? snap.providers.keys.first(where: {
if let summary = snap.providers[$0] { return summary.linked != nil }
}) ?? snap.channels.keys.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
return false
})
let linked = linkId.flatMap { snap.providers[$0]?.linked } ?? false
let authAge = linkId.flatMap { snap.providers[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
let label =
linkId.flatMap { snap.providerLabels?[$0] } ??
linkId.flatMap { snap.channelLabels?[$0] } ??
linkId?.capitalized ??
"provider"
"channel"
let linkText = linked ? "linked" : "not linked"
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
}

View File

@@ -238,7 +238,7 @@ struct GeneralSettings: View {
}
.buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
GatewayDiscoveryInlineList(
@@ -496,18 +496,18 @@ struct GeneralSettings: View {
}
if let snap = snapshot {
let linkId = snap.providerOrder?.first(where: {
if let summary = snap.providers[$0] { return summary.linked != nil }
let linkId = snap.channelOrder?.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
return false
}) ?? snap.providers.keys.first(where: {
if let summary = snap.providers[$0] { return summary.linked != nil }
}) ?? snap.channels.keys.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
return false
})
let linkLabel =
linkId.flatMap { snap.providerLabels?[$0] } ??
linkId.flatMap { snap.channelLabels?[$0] } ??
linkId?.capitalized ??
"Link provider"
let linkAge = linkId.flatMap { snap.providers[$0]?.authAgeMs }
"Link channel"
let linkAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }
Text("\(linkLabel) auth age: \(healthAgeString(linkAge))")
.font(.caption)
.foregroundStyle(.secondary)
@@ -627,8 +627,8 @@ extension GeneralSettings {
let originalMode = AppStateStore.shared.connectionMode
do {
try await ControlChannel.shared.configure(mode: .remote(
target: settings.target,
identity: settings.identity))
target: settings.target,
identity: settings.identity))
let data = try await ControlChannel.shared.health(timeout: 10)
if decodeHealthSnapshot(from: data) != nil {
self.remoteStatus = .ok

View File

@@ -4,7 +4,7 @@ import Observation
import SwiftUI
struct HealthSnapshot: Codable, Sendable {
struct ProviderSummary: Codable, Sendable {
struct ChannelSummary: Codable, Sendable {
struct Probe: Codable, Sendable {
struct Bot: Codable, Sendable {
let username: String?
@@ -44,9 +44,9 @@ struct HealthSnapshot: Codable, Sendable {
let ok: Bool?
let ts: Double
let durationMs: Double
let providers: [String: ProviderSummary]
let providerOrder: [String]?
let providerLabels: [String: String]?
let channels: [String: ChannelSummary]
let channelOrder: [String]?
let channelLabels: [String: String]?
let heartbeatSeconds: Int?
let sessions: Sessions
}
@@ -144,13 +144,13 @@ final class HealthStore {
}
}
private static func isProviderHealthy(_ summary: HealthSnapshot.ProviderSummary) -> Bool {
private static func isChannelHealthy(_ summary: HealthSnapshot.ChannelSummary) -> Bool {
guard summary.configured == true else { return false }
// If probe is missing, treat it as "configured but unknown health" (not a hard fail).
return summary.probe?.ok ?? true
}
private static func describeProbeFailure(_ probe: HealthSnapshot.ProviderSummary.Probe) -> String {
private static func describeProbeFailure(_ probe: HealthSnapshot.ChannelSummary.Probe) -> String {
let elapsed = probe.elapsedMs.map { "\(Int($0))ms" }
if let error = probe.error, error.lowercased().contains("timeout") || probe.status == nil {
if let elapsed { return "Health check timed out (\(elapsed))" }
@@ -162,28 +162,28 @@ final class HealthStore {
return "\(reason) (\(code))"
}
private func resolveLinkProvider(
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ProviderSummary)?
private func resolveLinkChannel(
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
{
let order = snap.providerOrder ?? Array(snap.providers.keys)
let order = snap.channelOrder ?? Array(snap.channels.keys)
for id in order {
if let summary = snap.providers[id], summary.linked != nil {
if let summary = snap.channels[id], summary.linked != nil {
return (id: id, summary: summary)
}
}
return nil
}
private func resolveFallbackProvider(
private func resolveFallbackChannel(
_ snap: HealthSnapshot,
excluding id: String?) -> (id: String, summary: HealthSnapshot.ProviderSummary)?
excluding id: String?) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
{
let order = snap.providerOrder ?? Array(snap.providers.keys)
for providerId in order {
if providerId == id { continue }
guard let summary = snap.providers[providerId] else { continue }
if Self.isProviderHealthy(summary) {
return (id: providerId, summary: summary)
let order = snap.channelOrder ?? Array(snap.channels.keys)
for channelId in order {
if channelId == id { continue }
guard let summary = snap.channels[channelId] else { continue }
if Self.isChannelHealthy(summary) {
return (id: channelId, summary: summary)
}
}
return nil
@@ -194,13 +194,13 @@ final class HealthStore {
return .degraded(error)
}
guard let snap = self.snapshot else { return .unknown }
guard let link = self.resolveLinkProvider(snap) else { return .unknown }
guard let link = self.resolveLinkChannel(snap) else { return .unknown }
if link.summary.linked != true {
// Linking is optional if any other provider is healthy; don't paint the whole app red.
let fallback = self.resolveFallbackProvider(snap, excluding: link.id)
// Linking is optional if any other channel is healthy; don't paint the whole app red.
let fallback = self.resolveFallbackChannel(snap, excluding: link.id)
return fallback != nil ? .degraded("Not linked") : .linkingNeeded
}
// A provider can be "linked" but still unhealthy (failed probe / cannot connect).
// A channel can be "linked" but still unhealthy (failed probe / cannot connect).
if let probe = link.summary.probe, probe.ok == false {
return .degraded(Self.describeProbeFailure(probe))
}
@@ -211,10 +211,10 @@ final class HealthStore {
if self.isRefreshing { return "Health check running…" }
if let error = self.lastError { return "Health check failed: \(error)" }
guard let snap = self.snapshot else { return "Health check pending" }
guard let link = self.resolveLinkProvider(snap) else { return "Health check pending" }
guard let link = self.resolveLinkChannel(snap) else { return "Health check pending" }
if link.summary.linked != true {
if let fallback = self.resolveFallbackProvider(snap, excluding: link.id) {
let fallbackLabel = snap.providerLabels?[fallback.id] ?? fallback.id.capitalized
if let fallback = self.resolveFallbackChannel(snap, excluding: link.id) {
let fallbackLabel = snap.channelLabels?[fallback.id] ?? fallback.id.capitalized
let fallbackState = (fallback.summary.probe?.ok ?? true) ? "ok" : "degraded"
return "\(fallbackLabel) \(fallbackState) · Not linked — run clawdbot login"
}
@@ -247,10 +247,10 @@ final class HealthStore {
}
func describeFailure(from snap: HealthSnapshot, fallback: String?) -> String {
if let link = self.resolveLinkProvider(snap), link.summary.linked != true {
if let link = self.resolveLinkChannel(snap), link.summary.linked != true {
return "Not linked — run clawdbot login"
}
if let link = self.resolveLinkProvider(snap), let probe = link.summary.probe, probe.ok == false {
if let link = self.resolveLinkChannel(snap), let probe = link.summary.probe, probe.ok == false {
return Self.describeProbeFailure(probe)
}
if let fallback, !fallback.isEmpty {

View File

@@ -13,7 +13,7 @@ enum InstanceIdentity {
let defaults = Self.defaults
if let existing = defaults.string(forKey: instanceIdKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
!existing.isEmpty
{
return existing
}

View File

@@ -242,18 +242,18 @@ final class InstancesStore {
do {
let data = try await ControlChannel.shared.health(timeout: 8)
guard let snap = decodeHealthSnapshot(from: data) else { return }
let linkId = snap.providerOrder?.first(where: {
if let summary = snap.providers[$0] { return summary.linked != nil }
let linkId = snap.channelOrder?.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
return false
}) ?? snap.providers.keys.first(where: {
if let summary = snap.providers[$0] { return summary.linked != nil }
}) ?? snap.channels.keys.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
return false
})
let linked = linkId.flatMap { snap.providers[$0]?.linked } ?? false
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
let linkLabel =
linkId.flatMap { snap.providerLabels?[$0] } ??
linkId.flatMap { snap.channelLabels?[$0] } ??
linkId?.capitalized ??
"provider"
"channel"
let entry = InstanceInfo(
id: "health-\(snap.ts)",
host: "gateway (health)",

View File

@@ -1,9 +1,25 @@
import Foundation
enum LogLocator {
private static let logDir = URL(fileURLWithPath: "/tmp/clawdbot")
private static let stdoutLog = logDir.appendingPathComponent("clawdbot-stdout.log")
private static let gatewayLog = logDir.appendingPathComponent("clawdbot-gateway.log")
private static var logDir: URL {
if let override = ProcessInfo.processInfo.environment["CLAWDBOT_LOG_DIR"], !override.isEmpty {
return URL(fileURLWithPath: override)
}
return URL(fileURLWithPath: "/tmp/clawdbot")
}
private static var stdoutLog: URL {
logDir.appendingPathComponent("clawdbot-stdout.log")
}
private static var gatewayLog: URL {
logDir.appendingPathComponent("clawdbot-gateway.log")
}
private static func ensureLogDirExists() {
try? FileManager.default.createDirectory(at: self.logDir, withIntermediateDirectories: true)
}
private static func modificationDate(for url: URL) -> Date {
(try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
@@ -11,11 +27,12 @@ enum LogLocator {
/// Returns the newest log file under /tmp/clawdbot/ (rolling or stdout), or nil if none exist.
static func bestLogFile() -> URL? {
self.ensureLogDirExists()
let fm = FileManager.default
let files = (try? fm.contentsOfDirectory(
at: self.logDir,
includingPropertiesForKeys: [.contentModificationDateKey],
options: [.skipsHiddenFiles])) ?? []
at: self.logDir,
includingPropertiesForKeys: [.contentModificationDateKey],
options: [.skipsHiddenFiles])) ?? []
return files
.filter { $0.lastPathComponent.hasPrefix("clawdbot") && $0.pathExtension == "log" }
@@ -26,11 +43,13 @@ enum LogLocator {
/// Path to use for launchd stdout/err.
static var launchdLogPath: String {
stdoutLog.path
self.ensureLogDirExists()
return stdoutLog.path
}
/// Path to use for the Gateway launchd job stdout/err.
static var launchdGatewayLogPath: String {
gatewayLog.path
self.ensureLogDirExists()
return gatewayLog.path
}
}

View File

@@ -102,11 +102,14 @@ struct MenuContent: View {
}
if self.state.canvasEnabled {
Button {
if self.state.canvasPanelVisible {
CanvasManager.shared.hideAll()
} else {
// Don't force a navigation on re-open: preserve the current web view state.
_ = try? CanvasManager.shared.show(sessionKey: "main", path: nil)
Task { @MainActor in
if self.state.canvasPanelVisible {
CanvasManager.shared.hideAll()
} else {
let sessionKey = await GatewayConnection.shared.mainSessionKey()
// Don't force a navigation on re-open: preserve the current web view state.
_ = try? CanvasManager.shared.show(sessionKey: sessionKey, path: nil)
}
}
} label: {
Label(
@@ -313,27 +316,7 @@ struct MenuContent: View {
private func openDashboard() async {
do {
let config = try await GatewayEndpointStore.shared.requireConfig()
let wsURL = config.url
guard var components = URLComponents(url: wsURL, resolvingAgainstBaseURL: false) else {
throw NSError(domain: "Dashboard", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Invalid gateway URL",
])
}
switch components.scheme?.lowercased() {
case "ws":
components.scheme = "http"
case "wss":
components.scheme = "https"
default:
components.scheme = "http"
}
components.path = "/"
components.query = nil
guard let url = components.url else {
throw NSError(domain: "Dashboard", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Failed to build dashboard URL",
])
}
let url = try GatewayEndpointStore.dashboardURL(for: config)
NSWorkspace.shared.open(url)
} catch {
let alert = NSAlert()

View File

@@ -51,9 +51,9 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
let initialWidth = self.initialCardWidth(for: menu)
let initial = AnyView(ContextMenuCardView(
rows: initialRows,
statusText: initialStatusText,
isLoading: initialIsLoading))
rows: initialRows,
statusText: initialStatusText,
isLoading: initialIsLoading))
let hosting = NSHostingView(rootView: initial)
hosting.frame.size.width = max(1, initialWidth)

View File

@@ -103,6 +103,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
extension MenuSessionsInjector {
// MARK: - Injection
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
private func inject(into menu: NSMenu) {
// Remove any previous injected items.
@@ -120,13 +121,15 @@ extension MenuSessionsInjector {
if let snapshot = self.cachedSnapshot {
let now = Date()
let mainKey = self.mainSessionKey
let rows = snapshot.rows.filter { row in
if row.key == "main" { return true }
if row.key == "main", mainKey != "main" { return false }
if row.key == mainKey { return true }
guard let updatedAt = row.updatedAt else { return false }
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
}.sorted { lhs, rhs in
if lhs.key == "main" { return true }
if rhs.key == "main" { return false }
if lhs.key == mainKey { return true }
if rhs.key == mainKey { return false }
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
}
@@ -135,8 +138,8 @@ extension MenuSessionsInjector {
headerItem.isEnabled = false
let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(
count: rows.count,
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
count: rows.count,
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
width: width,
highlighted: false)
headerItem.view = hosted
@@ -172,8 +175,8 @@ extension MenuSessionsInjector {
: self.controlChannelStatusText(for: channelState)
let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(
count: 0,
statusText: statusText)),
count: 0,
statusText: statusText)),
width: width,
highlighted: false)
headerItem.view = hosted
@@ -296,7 +299,7 @@ extension MenuSessionsInjector {
headerItem.isEnabled = false
headerItem.view = self.makeHostedView(
rootView: AnyView(MenuUsageHeaderView(
count: rows.count)),
count: rows.count)),
width: width,
highlighted: false)
menu.insertItem(headerItem, at: cursor)
@@ -469,11 +472,11 @@ extension MenuSessionsInjector {
item.tag = self.tag
item.isEnabled = false
let view = AnyView(SessionMenuPreviewView(
sessionKey: sessionKey,
width: width,
maxItems: 10,
maxLines: maxLines,
title: title))
sessionKey: sessionKey,
width: width,
maxItems: 10,
maxLines: maxLines,
title: title))
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
return item
}
@@ -593,10 +596,10 @@ extension MenuSessionsInjector {
let width = self.submenuWidth()
menu.addItem(self.makeSessionPreviewItem(
sessionKey: row.key,
title: "Recent messages (last 10)",
width: width,
maxLines: 3))
sessionKey: row.key,
title: "Recent messages (last 10)",
width: width,
maxLines: 3))
let morePreview = NSMenuItem(title: "More preview…", action: nil, keyEquivalent: "")
morePreview.submenu = self.buildPreviewSubmenu(sessionKey: row.key, width: width)
@@ -645,7 +648,7 @@ extension MenuSessionsInjector {
compact.representedObject = row.key
menu.addItem(compact)
if row.key != "main", row.key != "global" {
if row.key != self.mainSessionKey, row.key != "global" {
let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "")
del.target = self
del.representedObject = row.key
@@ -700,10 +703,10 @@ extension MenuSessionsInjector {
private func buildPreviewSubmenu(sessionKey: String, width: CGFloat) -> NSMenu {
let menu = NSMenu()
menu.addItem(self.makeSessionPreviewItem(
sessionKey: sessionKey,
title: "Recent messages (expanded)",
width: width,
maxLines: 8))
sessionKey: sessionKey,
title: "Recent messages (expanded)",
width: width,
maxLines: 8))
return menu
}
@@ -760,9 +763,9 @@ extension MenuSessionsInjector {
!commands.isEmpty
{
menu.addItem(self.makeNodeMultilineItem(
label: "Commands",
value: commands.joined(separator: ", "),
width: width))
label: "Commands",
value: commands.joined(separator: ", "),
width: width))
}
return menu
@@ -852,9 +855,9 @@ extension MenuSessionsInjector {
guard let key = sender.representedObject as? String else { return }
Task { @MainActor in
guard SessionActions.confirmDestructiveAction(
title: "Reset session?",
message: "Starts a new session id for “\(key)”.",
action: "Reset")
title: "Reset session?",
message: "Starts a new session id for “\(key)”.",
action: "Reset")
else { return }
do {
@@ -871,9 +874,9 @@ extension MenuSessionsInjector {
guard let key = sender.representedObject as? String else { return }
Task { @MainActor in
guard SessionActions.confirmDestructiveAction(
title: "Compact session log?",
message: "Keeps the last 400 lines; archives the old file.",
action: "Compact")
title: "Compact session log?",
message: "Keeps the last 400 lines; archives the old file.",
action: "Compact")
else { return }
do {
@@ -890,9 +893,9 @@ extension MenuSessionsInjector {
guard let key = sender.representedObject as? String else { return }
Task { @MainActor in
guard SessionActions.confirmDestructiveAction(
title: "Delete session?",
message: "Deletes the “\(key)” entry and archives its transcript.",
action: "Delete")
title: "Delete session?",
message: "Deletes the “\(key)” entry and archives its transcript.",
action: "Delete")
else { return }
do {

View File

@@ -36,10 +36,10 @@ actor MacNodeBridgeSession {
func connect(
endpoint: NWEndpoint,
hello: BridgeHello,
onConnected: (@Sendable (String) async -> Void)? = nil,
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
onDisconnected: (@Sendable (String) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
async throws
async throws
{
await self.disconnect()
self.disconnectHandler = onDisconnected
@@ -77,15 +77,15 @@ actor MacNodeBridgeSession {
})
guard let line = try await AsyncTimeout.withTimeout(
seconds: 6,
onTimeout: {
TimeoutError(message: "operation timed out")
},
operation: {
try await self.receiveLine()
}),
let data = line.data(using: .utf8),
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
seconds: 6,
onTimeout: {
TimeoutError(message: "operation timed out")
},
operation: {
try await self.receiveLine()
}),
let data = line.data(using: .utf8),
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
else {
self.logger.error("node bridge hello failed (unexpected response)")
await self.disconnect()
@@ -98,7 +98,8 @@ actor MacNodeBridgeSession {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName)
self.startPingLoop()
await onConnected?(ok.serverName)
let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
await onConnected?(ok.serverName, mainKey?.isEmpty == false ? mainKey : nil)
} else if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
self.state = .failed(message: "\(err.code): \(err.message)")

View File

@@ -67,8 +67,11 @@ final class MacNodeModeCoordinator {
try await self.session.connect(
endpoint: endpoint,
hello: hello,
onConnected: { [weak self] serverName in
onConnected: { [weak self] serverName, mainSessionKey in
self?.logger.info("mac node connected to \(serverName, privacy: .public)")
if let mainSessionKey {
await self?.runtime.updateMainSessionKey(mainSessionKey)
}
},
onDisconnected: { reason in
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
@@ -386,10 +389,10 @@ final class MacNodeModeCoordinator {
let preferred = BridgeDiscoveryPreferences.preferredStableID()
if let preferred,
let match = results.first(where: {
if case .service = $0.endpoint {
return BridgeEndpointID.stableID($0.endpoint) == preferred
}
return false
if case .service = $0.endpoint {
return BridgeEndpointID.stableID($0.endpoint) == preferred
}
return false
})
{
state.finish(match.endpoint)

View File

@@ -7,6 +7,7 @@ actor MacNodeRuntime {
private let cameraCapture = CameraCaptureService()
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
private var mainSessionKey: String = "main"
init(
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
@@ -16,6 +17,12 @@ actor MacNodeRuntime {
self.makeMainActorServices = makeMainActorServices
}
func updateMainSessionKey(_ sessionKey: String) {
let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.mainSessionKey = trimmed
}
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
let command = req.command
if self.isCanvasCommand(command), !Self.canvasEnabled() {
@@ -72,28 +79,32 @@ actor MacNodeRuntime {
let placement = params.placement.map {
CanvasPlacement(x: $0.x, y: $0.y, width: $0.width, height: $0.height)
}
let sessionKey = self.mainSessionKey
try await MainActor.run {
_ = try CanvasManager.shared.showDetailed(
sessionKey: "main",
sessionKey: sessionKey,
target: url,
placement: placement)
}
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdbotCanvasCommand.hide.rawValue:
let sessionKey = self.mainSessionKey
await MainActor.run {
CanvasManager.shared.hide(sessionKey: "main")
CanvasManager.shared.hide(sessionKey: sessionKey)
}
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdbotCanvasCommand.navigate.rawValue:
let params = try Self.decodeParams(ClawdbotCanvasNavigateParams.self, from: req.paramsJSON)
let sessionKey = self.mainSessionKey
try await MainActor.run {
_ = try CanvasManager.shared.show(sessionKey: "main", path: params.url)
_ = try CanvasManager.shared.show(sessionKey: sessionKey, path: params.url)
}
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdbotCanvasCommand.evalJS.rawValue:
let params = try Self.decodeParams(ClawdbotCanvasEvalParams.self, from: req.paramsJSON)
let sessionKey = self.mainSessionKey
let result = try await CanvasManager.shared.eval(
sessionKey: "main",
sessionKey: sessionKey,
javaScript: params.javaScript)
let payload = try Self.encodePayload(["result": result] as [String: String])
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
@@ -109,7 +120,8 @@ actor MacNodeRuntime {
}()
let quality = params?.quality ?? 0.9
let path = try await CanvasManager.shared.snapshot(sessionKey: "main", outPath: nil)
let sessionKey = self.mainSessionKey
let path = try await CanvasManager.shared.snapshot(sessionKey: sessionKey, outPath: nil)
defer { try? FileManager.default.removeItem(atPath: path) }
let data = try Data(contentsOf: URL(fileURLWithPath: path))
guard let image = NSImage(data: data) else {
@@ -169,10 +181,10 @@ actor MacNodeRuntime {
var height: Int
}
let payload = try Self.encodePayload(SnapPayload(
format: (params.format ?? .jpg).rawValue,
base64: res.data.base64EncodedString(),
width: Int(res.size.width),
height: Int(res.size.height)))
format: (params.format ?? .jpg).rawValue,
base64: res.data.base64EncodedString(),
width: Int(res.size.width),
height: Int(res.size.height)))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdbotCameraCommand.clip.rawValue:
let params = (try? Self.decodeParams(ClawdbotCameraClipParams.self, from: req.paramsJSON)) ??
@@ -192,10 +204,10 @@ actor MacNodeRuntime {
var hasAudio: Bool
}
let payload = try Self.encodePayload(ClipPayload(
format: (params.format ?? .mp4).rawValue,
base64: data.base64EncodedString(),
durationMs: res.durationMs,
hasAudio: res.hasAudio))
format: (params.format ?? .mp4).rawValue,
base64: data.base64EncodedString(),
durationMs: res.durationMs,
hasAudio: res.hasAudio))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdbotCameraCommand.list.rawValue:
let devices = await self.cameraCapture.listDevices()
@@ -300,12 +312,12 @@ actor MacNodeRuntime {
var hasAudio: Bool
}
let payload = try Self.encodePayload(ScreenPayload(
format: "mp4",
base64: data.base64EncodedString(),
durationMs: params.durationMs,
fps: params.fps,
screenIndex: params.screenIndex,
hasAudio: res.hasAudio))
format: "mp4",
base64: data.base64EncodedString(),
durationMs: params.durationMs,
fps: params.fps,
screenIndex: params.screenIndex,
hasAudio: res.hasAudio))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
@@ -319,7 +331,8 @@ actor MacNodeRuntime {
private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
try await self.ensureA2UIHost()
let json = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: """
let sessionKey = self.mainSessionKey
let json = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: """
(() => {
if (!globalThis.clawdbotA2UI) return JSON.stringify({ ok: false, error: "missing clawdbotA2UI" });
return JSON.stringify(globalThis.clawdbotA2UI.reset());
@@ -358,7 +371,8 @@ actor MacNodeRuntime {
}
})()
"""
let resultJSON = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: js)
let sessionKey = self.mainSessionKey
let resultJSON = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: js)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
}
@@ -369,8 +383,9 @@ actor MacNodeRuntime {
NSLocalizedDescriptionKey: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
])
}
let sessionKey = self.mainSessionKey
_ = try await MainActor.run {
try CanvasManager.shared.show(sessionKey: "main", path: a2uiUrl)
try CanvasManager.shared.show(sessionKey: sessionKey, path: a2uiUrl)
}
if await self.isA2UIReady(poll: true) { return }
throw NSError(domain: "Canvas", code: 31, userInfo: [
@@ -389,7 +404,8 @@ actor MacNodeRuntime {
let deadline = poll ? Date().addingTimeInterval(6.0) : Date()
while true {
do {
let ready = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: """
let sessionKey = self.mainSessionKey
let ready = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: """
(() => String(Boolean(globalThis.clawdbotA2UI)))()
""")
let trimmed = ready.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -438,12 +454,12 @@ actor MacNodeRuntime {
}
let payload = try Self.encodePayload(RunPayload(
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
stdout: result.stdout,
stderr: result.stderr,
error: result.errorMessage))
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
stdout: result.stdout,
stderr: result.stderr,
error: result.errorMessage))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
@@ -560,8 +576,8 @@ actor MacNodeRuntime {
case .jpeg:
let clamped = min(1.0, max(0.05, quality))
guard let data = rep.representation(
using: .jpeg,
properties: [.compressionFactor: clamped])
using: .jpeg,
properties: [.compressionFactor: clamped])
else {
throw NSError(domain: "Canvas", code: 24, userInfo: [
NSLocalizedDescriptionKey: "jpeg encode failed",

View File

@@ -454,7 +454,7 @@ final class NodePairingApprovalPrompter {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
guard settings.authorizationStatus == .authorized ||
settings.authorizationStatus == .provisional
settings.authorizationStatus == .provisional
else {
return
}
@@ -547,7 +547,7 @@ final class NodePairingApprovalPrompter {
let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first
guard let gateway else { return nil }
let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ??
gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
guard let host, !host.isEmpty else { return nil }
let port = gateway.sshPort > 0 ? gateway.sshPort : 22
return SSHTarget(host: host, port: port)

View File

@@ -694,7 +694,7 @@ extension OnboardingView {
systemImage: "bubble.left.and.bubble.right")
self.featureActionRow(
title: "Connect WhatsApp or Telegram",
subtitle: "Open Settings → Connections to link providers and monitor status.",
subtitle: "Open Settings → Connections to link channels and monitor status.",
systemImage: "link")
{
self.openSettings(tab: .connections)

View File

@@ -58,6 +58,13 @@ final class OnboardingWizardModel {
func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async {
guard self.sessionId == nil, !self.isStarting else { return }
guard mode == .local else { return }
if self.shouldSkipWizard() {
self.sessionId = nil
self.currentStep = nil
self.status = "done"
self.errorMessage = nil
return
}
self.isStarting = true
self.errorMessage = nil
self.lastStartMode = mode
@@ -149,8 +156,7 @@ final class OnboardingWizardModel {
onboardingWizardLogger.error("wizard step decode failed")
}
if res.done { self.currentStep = nil }
if res.done || status == "done" || status == "cancelled" || status == "error"
{
if res.done || status == "done" || status == "cancelled" || status == "error" {
self.sessionId = nil
}
}
@@ -178,6 +184,33 @@ final class OnboardingWizardModel {
Task { await self.startIfNeeded(mode: mode, workspace: self.lastStartWorkspace) }
return true
}
private func shouldSkipWizard() -> Bool {
let root = ClawdbotConfigFile.loadDict()
if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty {
return true
}
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any]
{
if let mode = auth["mode"] as? String,
!mode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
if let token = auth["token"] as? String,
!token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
if let password = auth["password"] as? String,
!password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
}
return false
}
}
struct OnboardingWizardStepView: View {

View File

@@ -77,10 +77,10 @@ final class PeekabooBridgeHostCoordinator {
var infoCF: CFDictionary?
guard SecCodeCopySigningInformation(
staticCode,
SecCSFlags(rawValue: kSecCSSigningInformation),
&infoCF) == errSecSuccess,
let info = infoCF as? [String: Any]
staticCode,
SecCSFlags(rawValue: kSecCSSigningInformation),
&infoCF) == errSecSuccess,
let info = infoCF as? [String: Any]
else {
return nil
}
@@ -106,9 +106,9 @@ private final class ClawdbotPeekabooBridgeServices: PeekabooBridgeServiceProvidi
let feedbackClient: any AutomationFeedbackClient = NoopAutomationFeedbackClient()
let snapshots = InMemorySnapshotManager(options: .init(
snapshotValidityWindow: 600,
maxSnapshots: 50,
deleteArtifactsOnCleanup: false))
snapshotValidityWindow: 600,
maxSnapshots: 50,
deleteArtifactsOnCleanup: false))
let applications = ApplicationService(feedbackClient: feedbackClient)
let screenCapture = ScreenCaptureService(loggingService: logging)

View File

@@ -158,10 +158,10 @@ actor PortGuardian {
mode: mode,
listeners: listeners)
reports.append(Self.buildReport(
port: port,
listeners: listeners,
mode: mode,
tunnelHealthy: tunnelHealthy))
port: port,
listeners: listeners,
mode: mode,
tunnelHealthy: tunnelHealthy))
}
return reports

View File

@@ -105,8 +105,8 @@ final class RemotePortTunnel {
return
}
guard let line = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!line.isEmpty
.trimmingCharacters(in: .whitespacesAndNewlines),
!line.isEmpty
else { return }
Self.logger.error("ssh tunnel stderr: \(line, privacy: .public)")
}

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.11-2</string>
<string>2026.1.11-4</string>
<key>CFBundleVersion</key>
<string>202601112</string>
<string>202601113</string>
<key>CFBundleIconFile</key>
<string>Clawdbot</string>
<key>CFBundleURLTypes</key>

View File

@@ -42,11 +42,11 @@ struct RuntimeResolution {
enum RuntimeResolutionError: Error {
case notFound(searchPaths: [String])
case unsupported(
kind: RuntimeKind,
found: RuntimeVersion,
required: RuntimeVersion,
path: String,
searchPaths: [String])
kind: RuntimeKind,
found: RuntimeVersion,
required: RuntimeVersion,
path: String,
searchPaths: [String])
case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String])
}
@@ -65,21 +65,21 @@ enum RuntimeLocator {
}
guard let rawVersion = readVersion(of: binary, pathEnv: pathEnv) else {
return .failure(.versionParse(
kind: runtime,
raw: "(unreadable)",
path: binary,
searchPaths: searchPaths))
kind: runtime,
raw: "(unreadable)",
path: binary,
searchPaths: searchPaths))
}
guard let parsed = RuntimeVersion.from(string: rawVersion) else {
return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths))
}
guard parsed >= self.minNode else {
return .failure(.unsupported(
kind: runtime,
found: parsed,
required: self.minNode,
path: binary,
searchPaths: searchPaths))
kind: runtime,
found: parsed,
required: self.minNode,
path: binary,
searchPaths: searchPaths))
}
return .success(RuntimeResolution(kind: runtime, path: binary, version: parsed))

View File

@@ -251,11 +251,11 @@ private final class StreamRecorder: NSObject, SCStreamOutput, SCStreamDelegate,
if let err = self.writer.error {
cont
.resume(throwing: ScreenRecordService.ScreenRecordError
.writeFailed(err.localizedDescription))
.writeFailed(err.localizedDescription))
} else if self.writer.status != .completed {
cont
.resume(throwing: ScreenRecordService.ScreenRecordError
.writeFailed("Failed to finalize video"))
.writeFailed("Failed to finalize video"))
} else {
cont.resume()
}

View File

@@ -54,9 +54,9 @@ enum SoundEffectCatalog {
var map: [String: URL] = [:]
for root in Self.searchRoots {
guard let contents = try? FileManager.default.contentsOfDirectory(
at: root,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles])
at: root,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles])
else { continue }
for url in contents where Self.allowedExtensions.contains(url.pathExtension.lowercased()) {
@@ -88,9 +88,9 @@ enum SoundEffectPlayer {
static func sound(from bookmark: Data) -> NSSound? {
var stale = false
guard let url = try? URL(
resolvingBookmarkData: bookmark,
options: [.withoutUI, .withSecurityScope],
bookmarkDataIsStale: &stale)
resolvingBookmarkData: bookmark,
options: [.withoutUI, .withSecurityScope],
bookmarkDataIsStale: &stale)
else { return nil }
let scoped = url.startAccessingSecurityScopedResource()

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