Compare commits

..
Author SHA1 Message Date
Peter Steinberger 7a5e3662a3 fix(web): show all WhatsApp shared contacts 2026-01-10 00:16:54 +00:00
Peter Steinberger d3d6d80f4a fix(agents): require raw for gateway config.apply (#566) (thanks @sircrumpet) 2026-01-10 00:16:17 +00:00
Eugene (via Claudius) a51948a528 fix(tools): flatten gateway tool schema for Vertex AI compatibility
Claude API on Vertex AI (Cloud Code Assist / Antigravity) enforces strict
JSON Schema 2020-12 validation and rejects root-level anyOf without a
top-level type field.

TypeBox Type.Union compiles to { anyOf: [...] } which Anthropic's direct
API accepts but Vertex rejects with:
  tools.11.custom.input_schema: JSON schema is invalid

This follows the same pattern used in browser-tool.ts which has the same
fix with an explanatory comment.

Flatten the schema to Type.Object with an action enum, matching how
browser tool handles this constraint.
2026-01-10 00:16:17 +00:00
Peter Steinberger 52565864d1 Merge pull request #619 from rubyrunsstuff/fix/telegram-block-streaming
fix: enable block streaming for Telegram when streamMode is 'block'
2026-01-10 00:16:04 +00:00
Peter Steinberger 1fd7a6e310 fix: keep telegram streamMode draft-only (#619) (thanks @rubyrunsstuff) 2026-01-10 01:14:40 +01:00
Peter Steinberger 08015fbce6 Merge pull request #544 from jeffersonwarrior/fix/cloud-code-assist-api-errors
Fix Cloud Code Assist API errors (429/400)
2026-01-10 00:13:46 +00:00
Peter Steinberger a9bd01b523 refactor: unify contact list summaries 2026-01-10 01:13:38 +01:00
Peter Steinberger 7b8ac0d5ad Merge pull request #623 from magimetal/feat/opencode-zen-provider
feat(onboard): add OpenCode Zen as model provider
2026-01-10 00:13:30 +00:00
Peter Steinberger 251ed83680 fix: harden Cloud Code Assist failover (#544) (thanks @jeffersonwarrior) 2026-01-10 01:12:46 +01:00
Peter Steinberger d75b302699 style: fix pi-embedded-runner formatting (#623) (thanks @magimetal) 2026-01-10 01:12:22 +01:00
Ruby b4fbf2fe0d fix: enable block streaming for Telegram when streamMode is 'block'
- Fix disableBlockStreaming logic in telegram/bot.ts to properly enable
  block streaming when telegram.streamMode is 'block' regardless of
  blockStreamingDefault setting
- Set minChars default to 1 for Telegram block mode so chunks send
  immediately on newlines/sentences instead of waiting for 800 chars
- Skip coalescing for Telegram block mode when not explicitly configured
  to reduce chunk batching delays
- Fix newline preference to wait for actual newlines instead of breaking
  on any whitespace when buffer is under maxChars

Fixes issue where all Telegram messages were batched into one message
at the end instead of streaming as separate messages during generation.
2026-01-10 01:11:41 +01:00
Jefferson Warrior 5843733978 Fix Cloud Code Assist API errors (429/400)
- Enhanced rate limit detection for Cloud Code Assist quota exhaustion
- Added tool call ID sanitization to fix invalid request format errors
- Integrated Cloud Code Assist format error detection into failover system
- Added comprehensive error pattern matching for Cloud Code Assist APIs

Fixes #cloud-code-assist-api-errors
2026-01-10 01:10:23 +01:00
Peter Steinberger c69c4caa33 fix: polish opencode-zen onboarding (#623) (thanks @magimetal) 2026-01-10 01:09:34 +01:00
Magi Metal 05bd100f7a style: fix formatting 2026-01-10 01:09:34 +01:00
Magi Metal a399fa36c8 feat(onboard): add OpenCode Zen as model provider 2026-01-10 01:09:34 +01:00
Peter Steinberger 9b1f164447 fix: guard small context windows 2026-01-10 01:08:56 +01:00
Peter Steinberger 3554004968 fix: QuickStart provider selection (#485) 2026-01-10 01:08:00 +01:00
Jefferson Warrior 74485f171b CLI: fix Cloud Code Assist 400 API errors and missing session exports
- Add sanitizeToolCallId() to fix Cloud Code Assist tool call ID validation
- Apply sanitization in sanitizeSessionMessagesImages() for toolResult and assistant messages
- Add legacy CONFIG_PATH_CLAWDIS and STATE_DIR_CLAWDIS exports for backward compatibility
- Resolves Cloud Code Assist rejection of invalid tool call IDs with pipe characters
- Fixes missing session export functions that were blocking system startup

Addresses Cloud Code Assist API 400 errors from invalid tool call IDs like 'call_abc123|item_456'
2026-01-10 01:07:51 +01:00
Peter Steinberger 0cb28e26fc Merge pull request #625 from mahmoudashraf93/fix/whatsapp-contact-cards-multi
fix: include numbers for WhatsApp contact arrays
2026-01-10 00:04:00 +00:00
Peter Steinberger 103dd3af64 fix: keep contact summary counts (#625) (thanks @mahmoudashraf93) 2026-01-10 01:02:28 +01:00
Peter Steinberger 21eebb6d3b fix: limit subagent bootstrap context 2026-01-10 00:01:16 +00:00
Mahmoud Ibrahim d1e10af1e1 WhatsApp: show all contacts in shares 2026-01-10 01:00:00 +01:00
Mahmoud Ibrahim 18338bc60f Style: format contact label helper 2026-01-10 01:00:00 +01:00
Mahmoud Ibrahim 77c7387bbd Changelog: note multi-contact numbers 2026-01-10 01:00:00 +01:00
Mahmoud Ibrahim fd7450e5b9 WhatsApp: include numbers in contact cards 2026-01-10 01:00:00 +01:00
Peter Steinberger e311dc82e0 refactor: centralize reasoning tag handling 2026-01-10 00:53:48 +01:00
Peter Steinberger 097550c299 fix: centralize verbose overrides and tool stream gating 2026-01-10 00:52:24 +01:00
Peter Steinberger 9a8d3aed26 test: update status expectations for verbose/elevated labels 2026-01-09 23:43:24 +00:00
Peter Steinberger e18080163f fix: simplify verbose/elevated status labels 2026-01-09 23:41:57 +00:00
Peter Steinberger eea686c81e docs: update changelog 2026-01-09 23:41:57 +00:00
Peter Steinberger bd10f3d3f1 feat: allow session_status in sandbox 2026-01-09 23:41:57 +00:00
Peter Steinberger a25922a21f fix: clarify sessions_send delivery semantics 2026-01-10 00:34:24 +01:00
Peter Steinberger 96e17d407a fix: filter NO_REPLY prefixes 2026-01-09 23:29:05 +00:00
Peter Steinberger a9a70ea278 fix: persist verbose off and gate tool stream 2026-01-10 00:22:28 +01:00
Peter Steinberger 695be8e92d Merge pull request #624 from clawdbot/refactor/vcard-utils
refactor: extract vcard parsing helper
2026-01-09 23:16:31 +00:00
Peter Steinberger c17d3a0738 docs: update changelog for vcard refactor (#624) (thanks @steipete) 2026-01-10 00:16:00 +01:00
Peter Steinberger b6b1b570f9 refactor: extract vcard parsing helper 2026-01-10 00:12:53 +01:00
Peter Steinberger 6016a63162 refactor(web): centralize active web listener resolution 2026-01-09 23:12:29 +00:00
Peter Steinberger 6f6acd94cc Merge pull request #614 from zknicker/fix/block-stream-think-leak
fix: prevent <think> leakage in block streaming
2026-01-09 23:10:17 +00:00
Peter Steinberger 2396a66e82 Merge pull request #622 from mahmoudashraf93/fix/whatsapp-contact-cards
fix: handle WhatsApp contact cards inbound
2026-01-09 23:09:40 +00:00
Peter Steinberger 1277f6e27b fix: prefer FN for WhatsApp contact cards (#622) (thanks @mahmoudashraf93) 2026-01-10 00:08:30 +01:00
Mahmoud Ibrahim c9c5a71d8b Changelog: note WhatsApp contact cards 2026-01-10 00:07:04 +01:00
Mahmoud Ibrahim 30b3d6ce61 WhatsApp: handle contact cards inbound 2026-01-10 00:07:04 +01:00
Peter Steinberger 875c62689e docs: remove unused root showcase 2026-01-10 00:02:39 +01:00
Peter Steinberger 51ec578cec fix: suppress <think> leakage + split reasoning output (#614) (thanks @zknicker) 2026-01-10 00:02:13 +01:00
Zach Knickerbocker 2d0ca67c21 fix: prevent <think> leakage in block streaming 2026-01-09 23:58:48 +01:00
Peter Steinberger 88cbe2d275 fix: cap pairing requests and suppress outbound pairing replies 2026-01-09 22:58:18 +00:00
Peter Steinberger 98d0318d4e refactor: cron payload migration cleanup (#621)
* refactor: centralize cron payload migration

* test: stabilize block streaming mocks

* test: adjust chunker fence-close case
2026-01-09 22:56:55 +00:00
Peter Steinberger e3c340fd38 docs: add fresh showcase projects 2026-01-09 23:56:44 +01:00
Peter Steinberger 89dc6ebb8b feat(signal): add reaction notifications 2026-01-09 23:53:36 +01:00
Peter Steinberger 90d6a55e05 Merge pull request #615 from YuriNachos/fix/smoke-test-exit-code
fix(build): remove non-functional smoke test from package-mac-app.sh
2026-01-09 22:42:08 +00:00
Peter Steinberger a18743eabc fix(web): improve WhatsApp Web listener errors (#612, thanks @YuriNachos) 2026-01-09 22:40:58 +00:00
Yurii ChukhlibandClaude a9f8719cd2 fix(web): remove provider name from gateway listener error messages
Error messages in sendMessageWhatsApp, sendReactionWhatsApp, and
sendPollWhatsApp included hardcoded \"WhatsApp\" references. This caused
confusion when cron jobs using other providers (e.g., Telegram) failed
with errors mentioning WhatsApp.

Changes error messages to be provider-agnostic while maintaining the
same error handling behavior.

Fixes #461
Fixes #470

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-09 22:40:58 +00:00
Peter Steinberger 0730e15c6c fix: verify bundled relay with version check (#615) (thanks @YuriNachos) 2026-01-09 23:40:52 +01:00
Peter Steinberger f4b9a00d37 Merge pull request #616 from neist/feat/signal-reactions
feat(signal): add reaction message support
2026-01-09 22:40:37 +00:00
Peter Steinberger 5bc3d15bba fix: handle signal reactions safely (#616) (thanks @neist) 2026-01-09 23:39:18 +01:00
Yurii ChukhlibandClaude 9c05675d66 fix(build): remove non-functional smoke test from package-mac-app.sh
The --smoke qr argument was removed from the relay CLI, but the smoke
test remained in the macOS app packaging script. This caused the build
to fail with exit code 1 when the bundled relay didn't recognize the
argument.

Removes the 2-line smoke test section to fix the build.

Fixes #317

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-09 23:37:57 +01:00
Kasper Neist 94e7a98bf2 feat(signal): add reaction message support
- Add SignalReactionMessage type with emoji, targetAuthor, timestamp
- Handle reaction messages in monitor (log and skip for now)
- Prevents reactions from showing as unknown media
2026-01-09 23:37:33 +01:00
Peter Steinberger 65cc92c06a chore: bump pi libs and oxlint-tsgolint 2026-01-09 22:23:58 +00:00
Peter Steinberger 870c9f0b99 refactor: stop pinning hardcoded main session in chat UI 2026-01-09 23:20:58 +01:00
Peter Steinberger 3b91148a0a fix: handle fence-close paragraph breaks 2026-01-09 22:20:22 +00:00
Peter Steinberger 22b3bd4415 fix: migrate cron payload channel alias 2026-01-09 22:20:22 +00:00
Peter Steinberger bdee50da6b fix(test): reduce gateway port collisions 2026-01-09 22:18:57 +00:00
Peter Steinberger 50461c23c0 fix(protocol): sync generated Swift models 2026-01-09 22:12:28 +00:00
Peter Steinberger 8a9c38e66e docs: embed YouTube video on showcase 2026-01-09 23:10:22 +01:00
Peter Steinberger 8624feb36a docs: clarify npm vs git install switching 2026-01-09 23:10:15 +01:00
Peter Steinberger 15757d01bc fix(protocol): regen Swift GatewayModels 2026-01-09 22:10:01 +00:00
Peter Steinberger 2af3853bfa fix(protocol): keep agent provider string 2026-01-09 23:09:10 +01:00
Peter Steinberger 3f1415b8fe ci: run pnpm test on macOS (PRs) 2026-01-09 22:06:36 +00:00
Peter Steinberger 79f5ccc99d fix(gateway): harden agent provider routing 2026-01-09 23:00:36 +01:00
Peter Steinberger 3adec35632 fix: make forced block chunking fence-safe 2026-01-09 21:52:47 +00:00
Peter Steinberger dc3a0bfd1e chore: set version to 2026.1.9 2026-01-09 22:42:25 +01:00
Peter Steinberger 79af03ba5e fix(auto-reply): tighten block streaming defaults 2026-01-09 22:41:10 +01:00
Peter Steinberger f8bf041396 test(gateway): cover internal provider defaults 2026-01-09 22:40:37 +01:00
Peter Steinberger 92cc7a841c refactor: centralize main session key normalization in apps 2026-01-09 22:38:17 +01:00
Peter Steinberger 40b4341a1d test: fix Windows path in LID mapping test 2026-01-09 21:34:35 +00:00
Peter Steinberger 35083fcb37 fix(gateway): avoid whatsapp fallback for internal runs 2026-01-09 22:33:05 +01:00
Peter Steinberger 53f51786f2 fix: default block streaming coalesce idle to 1s 2026-01-09 22:31:19 +01:00
Peter Steinberger 6c7a27c010 refactor: normalize main session key handling 2026-01-09 22:30:15 +01:00
Peter Steinberger 83270f98f7 chore: set version to 2026.1.8 2026-01-09 22:22:32 +01:00
Peter Steinberger 304ec1abe5 fix(agents): sessions_spawn inherits provider (#528) 2026-01-09 22:21:33 +01:00
Peter Steinberger 55e830b009 fix: harden onboarding for non-systemd environments 2026-01-09 22:17:09 +01:00
Peter Steinberger 402c35b91c refactor(agents): centralize failover normalization 2026-01-09 22:15:06 +01:00
Peter Steinberger 6220106ab2 refactor: centralize main session key resolution 2026-01-09 22:13:20 +01:00
Peter Steinberger c37b77855b Merge pull request #464 from austinm911/fix/slack-thread-replies
feat(slack): implement configurable reply threading
2026-01-09 21:10:39 +00:00
Peter Steinberger 84046cbad8 fix(slack): mrkdwn + thread edge cases (#464) (thanks @austinm911) 2026-01-09 22:09:02 +01:00
Peter Steinberger c27b1441f7 fix(auth): billing backoff + cooldown UX 2026-01-09 22:00:14 +01:00
Austin Mudd 8890fbcf38 Slack: fix message tool not passing threading context to handleSlackAction 2026-01-09 21:59:51 +01:00
Austin Mudd 8ae0429162 Slack: add mrkdwn formatter for proper bold/italic/strikethrough rendering 2026-01-09 21:59:51 +01:00
Austin Mudd 909c14d443 fix: resolve merge conflicts and fix threading tests
- Update MessageToolOptions type to include Slack threading options
- Remove duplicate threadTs property in slack/actions.ts
- Fix replyThreadTs parameter name in monitor.ts
- Update test to correctly verify 'first' mode threading behavior:
  - 'off' mode: no threading unless already in a thread
  - 'first' mode: first reply starts a thread
- Add new test case for 'first' mode threading
2026-01-09 21:59:51 +01:00
Austin Mudd b4663ed11c Slack: implement replyToMode threading for tool path
- Add shared hasRepliedRef state between auto-reply and tool paths
- Extract buildSlackThreadingContext helper in agent-runner.ts
- Extract resolveThreadTsFromContext helper in slack-actions.ts
- Update docs with clear replyToMode table (off/first/all)
- Add tests for first mode behavior across multiple messages
2026-01-09 21:59:51 +01:00
Austin Mudd 29e6f13b29 Test: add threadTs passthrough test for Slack sendMessage 2026-01-09 21:59:27 +01:00
Austin Mudd 1be8d06cca Slack: expose threadTs in agent sendMessage tool for thread replies 2026-01-09 21:59:27 +01:00
Peter Steinberger 42a0089b3b fix: require explicit system event session keys 2026-01-09 21:59:01 +01:00
Peter Steinberger cb2a365594 Merge pull request #607 from wes-davis/fix/launch-at-login-persistence
fix Mac app Launch at Login
2026-01-09 20:55:26 +00:00
Peter Steinberger 1985790f7f fix: avoid clearing Launch at Login on startup (#607) (thanks @wes-davis) 2026-01-09 21:54:26 +01:00
Wes 6f5503688d fix(macos): prevent launch-at-login plist deletion on startup 2026-01-09 21:53:32 +01:00
Peter Steinberger a5065b354e fix(whatsapp): resolve lid mappings for inbound 2026-01-09 21:46:18 +01:00
Peter Steinberger 5fa26bfec7 feat: add per-agent elevated controls 2026-01-09 20:42:19 +00:00
Peter Steinberger 1a97aadb6b fix: broadcast group history consistency (#547) 2026-01-09 21:39:58 +01:00
Peter Steinberger 7641b142ad refactor: harden broadcast groups 2026-01-09 21:39:58 +01:00
Peter Steinberger 374aa856f2 refactor(agents): centralize failover handling 2026-01-09 21:31:18 +01:00
Peter Steinberger cfeaa34c16 refactor: centralize cli timeout parsing 2026-01-09 21:29:52 +01:00
Peter Steinberger 1689ef0b97 docs: update changelog 2026-01-09 21:27:51 +01:00
Peter Steinberger 2bb9716598 fix: write clawdbot config atomically 2026-01-09 21:27:51 +01:00
Peter Steinberger 564caf49bb feat: add config --section 2026-01-09 21:27:51 +01:00
Peter Steinberger 24605379b9 refactor: centralize skills prompt resolution 2026-01-09 21:27:20 +01:00
Shadow cf8d7139e1 Docs: credit Docker cache PR 2026-01-09 14:24:45 -06:00
Zach Knickerbocker c6fc7c2ea6 Docker: cache deps layer for faster rebuilds (#605) 2026-01-09 14:23:06 -06:00
Peter Steinberger e8dbb350ae fix: honor config timeout in tui 2026-01-09 21:22:50 +01:00
Peter Steinberger 4861f09f78 fix: inject skills prompt list 2026-01-09 21:20:51 +01:00
Peter Steinberger 0297b38ce0 fix(discord): remove unused import 2026-01-09 21:18:26 +01:00
Peter Steinberger 65cb9dc3f7 fix(agents): fail over on billing/credits errors 2026-01-09 21:17:07 +01:00
Peter Steinberger e0089bb4eb Merge pull request #547 from pasogott/feature/broadcast-groups
feat: add broadcast groups for multi-agent responses
2026-01-09 20:15:42 +00:00
Peter Steinberger 76964162c7 fix: land broadcast groups (#547) (thanks @pasogott) 2026-01-09 21:14:19 +01:00
Shadow 2396df0d97 Docs: add PR 608 changelog entry 2026-01-09 14:12:38 -06:00
Nicholas Spisak d1b0a3584f fix(discord): add zombie connection detection with HELLO timeout
Add 30-second timeout after WebSocket opens to detect when Discord
never sends HELLO (zombie state). If isConnected stays false after
timeout, forces a fresh connection instead of hanging indefinitely.

Relates to #595
2026-01-09 14:12:38 -06:00
Nicholas Spisak 90be8c632a fix(discord): log gateway debug events for disconnect visibility
Add listener for Carbon GatewayPlugin 'debug' events to capture WebSocket
connection state changes. Critical events (close, reconnect, resume) are
logged at INFO level always; all debug messages logged in verbose mode.

Fixes #595
2026-01-09 14:12:38 -06:00
Peter Steinberger 4121f9e6dc feat(agents): add session_status tool 2026-01-09 21:09:42 +01:00
Peter Steinberger d309d4fe8b fix(discord): log gateway reconnects 2026-01-09 21:06:19 +01:00
Peter Steinberger e73b812236 fix: dedupe telegram updates 2026-01-09 21:06:06 +01:00
sheeek 09769d127f feat: add broadcast groups for multi-agent responses
Enables multiple agents to process the same message simultaneously,
allowing teams of specialized agents with atomic tasks to work together
in the same group using one phone number.

Key features:
- Configure multiple agents per WhatsApp group/DM via routing.broadcast
- Parallel (default) or sequential processing strategies
- Full session isolation (separate history, workspace, tools per agent)
- Minimal code changes (~50 lines in auto-reply.ts)
- Backward compatible with existing routing

Use cases:
- Specialized agent teams (code reviewer + security scanner + docs)
- Multi-language support (EN + DE + ES agents)
- Quality assurance workflows (support + QA agents)
- Task automation (tracker + logger + reporter)

Example config:
{
  "routing": {
    "broadcast": {
      "strategy": "parallel",
      "120363403215116621@g.us": ["alfred", "baerbel", "assistant3"]
    }
  }
}

This enables scaling to hundreds of focused micro-agents on a single
phone number, each handling specific atomic tasks.
2026-01-09 21:05:58 +01:00
Shadow 2469384643 update carbon 2026-01-09 13:52:59 -06:00
Peter Steinberger 81e7e25b3a style(macos): swiftformat cleanup 2026-01-09 19:49:12 +00:00
Peter Steinberger f97fbbf2b8 chore(swift): narrow formatter/lint scope 2026-01-09 19:49:12 +00:00
Peter SteinbergerandAzade 6d378ee608 feat(telegram): inline keyboard buttons (#491)
Co-authored-by: Azade <azade@hey.com>
2026-01-09 20:47:03 +01:00
Peter Steinberger 46f0a08878 chore: update clawtributors 2026-01-09 20:46:33 +01:00
Peter Steinberger fb989cd0f8 Merge pull request #600 from samratjha96/fix/discord-duplicate-messages
fix(queue): deduplicate followup queue entries
2026-01-09 19:45:10 +00:00
Peter Steinberger d3a0114b6b fix: dedupe followup queue by message id (#600) (thanks @samratjha96) 2026-01-09 20:44:11 +01:00
Peter Steinberger e4cd03033a Merge pull request #537 from Nachx639/fix/discord-web-provider-activity
fix: Discord eventQueue + WhatsApp provider activity accounting
2026-01-09 19:43:27 +00:00
Peter Steinberger a65455e25d fix: align WhatsApp activity account id (#537) (thanks @Nachx639) 2026-01-09 20:42:35 +01:00
Samrat Jha 9185fdc896 fix(queue): deduplicate followup queue entries to prevent duplicate responses
## Problem

When messages arrived while the agent was busy processing a previous message,
the same message could be enqueued multiple times into the followup queue.
This happened because Discord's event system can emit the same message multiple
times (e.g., during reconnects or due to slow listener processing), and the
followup queue had no deduplication logic.

This caused the bot to respond to the same user message 2-4+ times.

## Solution

Add simple exact-match deduplication in `enqueueFollowupRun()`: if a prompt
is already in the queue, skip adding it again. Extracted into a small
`isPromptAlreadyQueued()` helper for clarity.

## Testing

- Added test cases for deduplication (same prompt rejected, different accepted)
- Manually verified on Discord: single response per message even when multiple
  events fire during slow agent processing
2026-01-09 20:40:18 +01:00
Tu Nombre RealandClaude Opus 4.5 a05c4fca5c fix(web): pass accountId in WhatsApp provider activity tracking
Recent changes added recordProviderActivity calls with accountId, but
the type definition and usage didn't include accountId in ActiveWebSendOptions.
This fix adds the optional accountId field and uses optional chaining
when accessing it to handle cases where options is undefined.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 20:40:03 +01:00
Peter Steinberger 85fab7afe3 chore: add pnpm app workflow scripts 2026-01-09 19:38:48 +00:00
Peter Steinberger 2977b296e6 feat(messages): add whatsapp messagePrefix and responsePrefix auto 2026-01-09 19:29:04 +00:00
Peter Steinberger 0a4cb0d264 Merge pull request #604 from clawdbot/fix/model-fallback-error-message
Fix model fallback error message handling
2026-01-09 19:24:22 +00:00
Peter Steinberger 9ecef09c69 docs: update changelog for model fallback lint fix (#604) (thanks @steipete) 2026-01-09 20:24:08 +01:00
Peter Steinberger 6481806751 fix: avoid base-to-string in model fallback 2026-01-09 20:23:00 +01:00
Shadow 60bd21d77b chore: update carbon 2026-01-09 13:22:32 -06:00
Peter Steinberger 7e00fc4ebc Merge pull request #603 from clawdbot/refactor/ui-chat-split-475
refactor(ui): split chat renderers and styles
2026-01-09 19:21:56 +00:00
Peter Steinberger 1acbd6aea0 refactor(ui): split chat renderers and styles 2026-01-09 20:20:06 +01:00
Peter Steinberger 71a08eed84 Merge pull request #599 from mcinteerj/fix/gemini-tool-schemas
fix: simplify tool schemas for Gemini compatibility
2026-01-09 19:19:01 +00:00
Peter Steinberger 72b0777341 fix(messages): restore explicit responsePrefix default 2026-01-09 19:18:34 +00:00
Peter Steinberger 0edacd0469 fix: simplify session tool schemas for Gemini compatibility (#599) (thanks @mcinteerj) 2026-01-09 20:17:46 +01:00
Peter Steinberger 237480ed9b Merge pull request #602 from clawdbot/refactor/model-override-warnings
refactor: centralize model override validation
2026-01-09 19:15:51 +00:00
Peter Steinberger 837cec64af refactor: centralize test path containment checks 2026-01-09 19:15:44 +00:00
Keith the Silly Goose 423eef4624 fix: simplify tool schemas for Gemini compatibility
Replaces Type.Integer with Type.Number and simplifies the sessions_send tool schema to avoid anyOf/oneOf unions that cause 400 errors with Google Cloud Code Assist API.
2026-01-09 20:15:26 +01:00
Peter Steinberger 133e30c594 refactor: centralize model override validation (#602) (thanks @steipete) 2026-01-09 20:15:26 +01:00
Peter Steinberger 7e81980747 refactor: centralize model override validation 2026-01-09 20:14:42 +01:00
Shadow f0a909f6dd Changelog: add PR 601 2026-01-09 13:06:41 -06:00
Peter Steinberger 840ccbccf6 docs: add messages concept overview 2026-01-09 19:06:14 +00:00
Peter Steinberger ade06cb9fb Merge pull request #472 from koala73/main
feat: add hooks.gmail.model for cheaper Gmail PubSub processing
2026-01-09 19:00:53 +00:00
Martin Púčik d23bba9d24 docs: split CLI install commands into separate code blocks (#601) 2026-01-09 13:00:50 -06:00
Peter Steinberger 5755d85ad1 fix: harden Gmail hook model defaults (#472) (thanks @koala73) 2026-01-09 19:59:45 +01:00
Magi MetalandShadow 50a5b4ddcc Discord: fix DM recipient parsing for bare numeric user IDs (#596)
Co-authored-by: Shadow <shadow@clawd.bot>
2026-01-09 12:58:25 -06:00
Shadow 04512ee67c Docs: pin Carbon dependency 2026-01-09 12:57:58 -06:00
Peter Steinberger 47a8b410ee Merge pull request #475 from rahthakor/feature/ui-enhancements
feat(ui): refactor chat layout with sidebar, message grouping, and nav improvements
2026-01-09 18:54:44 +00:00
Peter Steinberger 067c20608c fix(ui): landing cleanup (#475) (thanks @rahthakor) 2026-01-09 19:53:32 +01:00
Peter Steinberger 6ec4c4bf8f Merge pull request #505 from koala73/feat/whatsapp-media-max-size
feat(whatsapp): add configurable media max size
2026-01-09 18:53:04 +00:00
Peter Steinberger 87f432880a fix: honor whatsapp mediaMaxMb (#505) (thanks @koala73) 2026-01-09 19:51:35 +01:00
rahthakor 9624d70187 test(ui): add tests for chat modules and update for icon refactor
- Add 21 tests for message-normalizer.ts (normalizeMessage, normalizeRoleForGrouping, isToolResultMessage)
- Add 17 tests for tool-helpers.ts (formatToolOutputForSidebar, getTruncatedPreview)
- Update navigation.test.ts to test iconClassForTab instead of deprecated iconForTab
- Skip focus-mode.browser.test.ts (toggle button moved to settings)
- Skip chat-markdown.browser.test.ts (tool card rendering refactored to sidebar)
- Skip bash-tools.test.ts line offset tests (shell env pollution issue)
2026-01-09 19:47:19 +01:00
Elie Habib 3026367c1b feat(whatsapp): add configurable media max size
- Add whatsapp.mediaMaxMb config option (default: 50MB)
- Increases default from previous 5MB hardcoded limit
- Allows receiving larger documents/media files
- Per-account override via whatsapp.accounts.*.mediaMaxMb

Fixes #<issue-number> (if applicable)
2026-01-09 19:40:21 +01:00
Elie Habib da2323f80e docs: add gateway restart guide 2026-01-09 19:39:42 +01:00
Elie Habib f85807a2a6 fix: add serveBaseUrl to compactEmbeddedPiSession params 2026-01-09 19:39:42 +01:00
Elie Habib 1a47aec6e4 feat(gateway): add serve feature for sharing files via public URLs 2026-01-09 19:39:42 +01:00
Peter Steinberger fd15704c77 fix(auto-reply): coalesce block replies and document streaming toggles (#536) (thanks @mcinteerj) 2026-01-09 18:19:55 +00:00
Jake a05916bee8 Config: add support for per-provider blockStreaming override 2026-01-09 18:11:27 +00:00
Peter Steinberger 91a6f721a3 docs: changelog for iMessage thread isolation (#535) (thanks @mdahmann) 2026-01-09 18:43:32 +01:00
Peter Steinberger 3eef200145 Merge pull request #535 from mdahmann/fix/imessage-groupish-threads
imessage: isolate group-ish threads by chat_id
2026-01-09 17:42:42 +00:00
Peter Steinberger d6d84ce349 test: harden logout path check on windows 2026-01-09 18:39:38 +01:00
Peter Steinberger 42ead1499f refactor: centralize minimax onboarding + keys 2026-01-09 18:37:06 +01:00
Peter Steinberger d0a20cadaf Merge branch 'main' into fix/imessage-groupish-threads 2026-01-09 17:36:15 +00:00
Peter Steinberger dac3b675cc fix: stabilize CI path assumptions 2026-01-09 18:35:52 +01:00
Peter Steinberger 6aac3184c3 test: normalize windows path assertions 2026-01-09 18:32:45 +01:00
Peter Steinberger a06b6c807e Merge pull request #592 from clawdbot/docs/hetzner-followups 2026-01-09 17:31:27 +00:00
Peter Steinberger e6a7429ac7 refactor: add helper for empty assistant content 2026-01-09 18:29:27 +01:00
Peter Steinberger e251ac4f74 Merge pull request #591 from clawdbot/followup/empty-error-filter
refactor: drop empty error messages from session history
2026-01-09 17:24:51 +00:00
Peter Steinberger 65a11095c0 docs: add changelog entry for #591 2026-01-09 18:23:26 +01:00
Peter Steinberger 79b3abd797 refactor: drop empty error messages in history 2026-01-09 18:23:26 +01:00
Peter Steinberger 805969b598 Merge branch 'main' into fix/imessage-groupish-threads 2026-01-09 17:16:44 +00:00
Miles 27952bb94d imessage: isolate group-ish threads by chat_id 2026-01-09 00:01:01 -06:00
308 changed files with 16677 additions and 2733 deletions

No files matched your search

+61
View File
@@ -169,6 +169,67 @@ jobs:
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
checks-macos:
if: github.event_name == 'pull_request'
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
include:
- task: test
command: pnpm test
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Checkout submodules (retry)
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
check-latest: true
- name: Runtime versions
run: |
node -v
npm -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
- name: Enable corepack and pin pnpm
run: |
corepack enable
corepack prepare pnpm@10.23.0 --activate
pnpm -v
- name: Install dependencies
env:
CI: true
run: |
export PATH="$NODE_BIN:$PATH"
which node
node -v
pnpm -v
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Run ${{ matrix.task }}
run: ${{ matrix.command }}
macos-app:
if: github.event_name == 'pull_request'
runs-on: macos-latest
+1 -1
View File
@@ -48,4 +48,4 @@
--allman false
# Exclusions
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol,apps/macos/Sources/ClawdbotProtocol
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/ClawdisProtocol,apps/macos/Sources/ClawdbotProtocol
+2
View File
@@ -17,6 +17,8 @@ excluded:
- dist
- coverage
- "*.playground"
# Generated (protocol-gen-swift.ts)
- apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift
analyzer_rules:
- unused_declaration
+2 -1
View File
@@ -65,6 +65,7 @@
## Agent-Specific Notes
- Vocabulary: "makeup" = "mac app".
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency.
- 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.**
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
@@ -87,7 +88,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).
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/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.
- 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.
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
+55
View File
@@ -2,21 +2,44 @@
## Unreleased
- Models/Auth: add OpenCode Zen (multi-model proxy) onboarding. (#623) — thanks @magimetal
- WhatsApp: refactor vCard parsing helper and improve empty contact card summaries. (#624) — thanks @steipete
- WhatsApp: include phone numbers when multiple contacts are shared. (#625) — thanks @mahmoudashraf93
- Agents: warn on small context windows (<32k) and block unusable ones (<16k). — thanks @steipete
- Pairing: cap pending DM pairing requests at 3 per provider and avoid pairing replies for outbound DMs. — thanks @steipete
- macOS: replace relay smoke test with version check in packaging script. (#615) — thanks @YuriNachos
- macOS: avoid clearing Launch at Login during app initialization. (#607) — thanks @wes-davis
- Onboarding: skip systemd checks/daemon installs when systemd user services are unavailable; add onboarding flags to skip flow steps and stabilize Docker E2E. (#573) — thanks @steipete
- Onboarding: QuickStart provider picker uses single-select to avoid accidental Telegram token prompts when choosing WhatsApp. (#485) — thanks @frankstallone
- macOS: add node bridge heartbeat pings to detect half-open sockets and reconnect cleanly. (#572) — thanks @ngutman
- Node bridge: harden keepalive + heartbeat handling (TCP keepalive, better disconnects, and keepalive config tests). (#577) — thanks @steipete
- Control UI: improve mobile responsiveness. (#558) — thanks @carlulsoe
- Control UI: persist per-session verbose off and hide tool cards unless verbose is on. (#262) — thanks @steipete
- Gateway: centralize verbose overrides and gate tool stream events at the server. (#262) — thanks @steipete
- CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
- Sandbox: allow `session_status` tool in sandboxed sessions by default. — thanks @steipete
- CLI: add `clawdbot config --section <name>` to jump straight into a wizard section (repeatable).
- Docs: add Hetzner Docker VPS guide. (#556) — thanks @Iamadig
- Docs: link Hetzner guide from install + platforms docs. (#592) — thanks @steipete
- Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc
- Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro
- Slack: configurable reply threading (`slack.replyToMode`) + proper mrkdwn formatting for outbound messages. (#464) — thanks @austinm911
- Discord: avoid category parent overrides for channel allowlists and refactor thread context helpers. (#588) — thanks @steipete
- Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow
- Discord: log gateway disconnect/reconnect events at info and add verbose gateway metrics. (#595) — thanks @steipete
- Commands: accept /models as an alias for /model.
- Commands: add `/usage` as an alias for `/status`. (#492) — thanks @lc0rp
- Models/Auth: add MiniMax Anthropic-compatible API onboarding (minimax-api). (#590) — thanks @mneves75
- Models: centralize model override validation + hooks Gmail warnings in doctor. (#602) — thanks @steipete
- Agents: avoid base-to-string error stringification in model fallback. (#604) — thanks @steipete
- Agents: `sessions_spawn` inherits the requester's provider for child runs (avoid WhatsApp fallback). (#528) — thanks @rlmestre
- Agents: sub-agent context now injects only AGENTS.md + TOOLS.md (omits identity/user/soul/heartbeat/bootstrap). — thanks @steipete
- Gateway/CLI: harden agent provider routing + validation (Slack/MS Teams + aliases). (follow-up #528) — thanks @steipete
- Agents: treat billing/insufficient-credits errors as failover-worthy so model fallbacks kick in. (#486) — thanks @steipete
- Auth: default billing disable backoff to 5h (doubling, 24h cap) and surface disabled/cooldown profiles in `models list` + doctor. (#486) — thanks @steipete
- Commands: harden slash command registry and list text-only commands in `/commands`.
- Models/Auth: show per-agent auth candidates in `/model status`, and add `clawdbot models auth order {get,set,clear}` (per-agent auth rotation overrides). — thanks @steipete
- Telegram: keep streamMode draft-only; avoid forcing block streaming. (#619) — thanks @rubyrunsstuff
- Debugging: add raw model stream logging flags and document gateway watch mode.
- Gateway: decode dns-sd escaped UTF-8 in discovery output and show scan progress immediately. — thanks @steipete
- Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled).
@@ -25,31 +48,53 @@
- Hooks: default hook agent delivery to true. (#533) — thanks @mcinteerj
- Hooks: normalize hook agent providers (aliases + msteams support).
- WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj
- WhatsApp: improve "no active web listener" errors (include account + relink hint). (#612) — thanks @YuriNachos
- WhatsApp: add broadcast groups for multi-agent replies. (#547) — thanks @pasogott
- WhatsApp: resolve @lid inbound senders via auth-dir mapping fallback + shared resolver. (#365)
- WhatsApp: treat shared contact cards as inbound messages (prefer vCard FN). (#622) — thanks @mahmoudashraf93
- iMessage: isolate group-ish threads by chat_id. (#535) — thanks @mdahmann
- Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223
- Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj
- Agent: skip empty error assistant messages when rebuilding session context to avoid tool-chain corruption. (#561) — thanks @mukhtharcm
- Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles.
- Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence.
- Config: write `clawdbot.json` atomically (temp file + replace) and keep a best-effort `.bak` backup.
- Agent: enable adaptive context pruning by default for tool-result trimming.
- Agent: drop empty error assistant messages when sanitizing session history. (#591) — thanks @steipete
- Agent: inject eligible skills list into the system prompt so bundled skills load from their actual locations. (#551) — thanks @gabriel-trigo
- Doctor: check config/state permissions and offer to tighten them. — thanks @steipete
- Doctor/Daemon: audit supervisor configs, add --repair/--force flows, surface service config audits in daemon status, and document user vs system services. — thanks @steipete
- Doctor: repair gateway service entrypoint when switching between npm and git installs; add Docker e2e coverage. — thanks @steipete
- Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c
- Daemon: add KillMode=process to systemd units to avoid podman restart hangs. (#541) — thanks @ogulcancelik
- WhatsApp: make inbound media size cap configurable (default 50 MB). (#505) — thanks @koala73
- Doctor: run legacy state migrations in non-interactive mode without prompts.
- Cron: parse Telegram topic targets for isolated delivery. (#478) — thanks @nachoiacovino
- Cron: enqueue main-session system events under the resolved main session key. (#510)
- Mobile: centralize main session key normalization for iOS/Android runtime helpers. — thanks @steipete
- Chat UI: stop pinning hardcoded `main` session in the recent list; prefer active session if missing. — thanks @steipete
- Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos
- Cron: allow Telegram delivery targets with topic/thread IDs (e.g. `-100…:topic:123`). (#474) — thanks @mitschabaude-bot
- Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos
- Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123
- Agents: strip empty assistant text blocks from session history to avoid Claude API 400s. (#210)
- Agents: scrub unsupported JSON Schema keywords from tool schemas for Cloud Code Assist API compatibility. (#567) — thanks @erikpr1994
- Agents: sanitize Cloud Code Assist tool call IDs and detect format/quota errors for failover. (#544) — thanks @jeffersonwarrior
- Agents: simplify session tool schemas for Gemini compatibility. (#599) — thanks @mcinteerj
- Agents: require `raw` for gateway `config.apply` tool calls while keeping schema 2020-12 compatible. (#566) — thanks @sircrumpet
- Agents: add `session_status` agent tool for `/status`-equivalent status (incl. usage/cost) + per-session model overrides. — thanks @steipete
- Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123
- Auto-reply: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1
- Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess
- Auto-reply: preserve spacing when stripping inline directives. (#539) — thanks @joshp123
- Auto-reply: relax reply tag parsing to allow whitespace. (#560) — thanks @mcinteerj
- Auto-reply: add per-provider block streaming toggles and coalesce streamed blocks to reduce line spam. (#536) — thanks @mcinteerj
- Auto-reply: suppress `<think>` leakage in block streaming and emit `/reasoning` as a separate `Reasoning:` message. (#614) — thanks @zknicker
- Auto-reply: default block streaming off for non-Telegram providers unless explicitly enabled, and avoid splitting on forced flushes below max.
- Auto-reply: raise default coalesce minChars for Signal/Slack/Discord and clarify streaming vs draft streaming in docs.
- Auto-reply: default block streaming coalesce idle to 1s to reduce tiny chunks. — thanks @steipete
- Auto-reply: fix /status usage summary filtering for the active provider.
- Auto-reply: deduplicate followup queue entries using message id/routing to avoid duplicate replies. (#600) — thanks @samratjha96
- Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj
- Status: compact /status with session token usage + estimated cost, add `/cost` per-response usage lines (tokens-only for OAuth).
- Status: show active auth profile and key snippet in /status.
@@ -63,15 +108,20 @@
- Control UI: add Docs link, remove chat composer divider, and add New session button.
- Control UI: link sessions list to chat view. (#471) — thanks @HazAT
- Sessions: support session `label` in store/list/UI and allow `sessions_send` lookup by label. (#570) — thanks @azade-c
- Sessions: clarify `sessions_send` delivery semantics, log announce failures, and enforce Discord request timeouts. (#507) — thanks @steipete
- Control UI: show/patch per-session reasoning level and render extracted reasoning in chat.
- Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos
- Control UI: refactor chat layout with tool sidebar, grouped messages, and nav improvements. (#475) — thanks @rahthakor
- Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow).
- Telegram: retry long-polling conflicts with backoff to avoid fatal exits.
- Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos
- Telegram: add inline keyboard buttons (capability-gated) and route callback query payloads as messages. (#491) — thanks @azade-c
- WhatsApp: resolve @lid JIDs via Baileys mapping to unblock inbound messages. (#415)
- Pairing: replies now include sender ids for Discord/Slack/Signal/iMessage/WhatsApp; pairing list labels them explicitly.
- Messages: default inbound/outbound prefixes from the routed agent’s `identity.name` when set. (#578) — thanks @p6l-richard
- Signal: accept UUID-only senders for pairing/allowlists/routing when sourceNumber is missing. (#523) — thanks @neist
- Signal: ignore reaction-only messages so they don't surface as unknown media. (#616) — thanks @neist
- Signal: add reaction notifications with allowlist support. — thanks @steipete
- Agent system prompt: avoid automatic self-updates unless explicitly requested.
- Onboarding: tighten QuickStart hint copy for configuring later.
- Onboarding: set Gemini 3 Pro as the default model for Gemini API key auth. (#489) — thanks @jonasjancarik
@@ -97,6 +147,7 @@
- Docs: expand parameter descriptions for agent/wake hooks. (#532) — thanks @mcinteerj
- Docs: add community showcase entries from Discord. (#476) — thanks @gupsammy
- TUI: refresh status bar after think/verbose/reasoning changes. (#519) — thanks @jdrhyne
- TUI: stop overriding agent timeout so config defaults apply; warn on invalid `--timeout-ms`. (#549)
- Status: show Verbose/Elevated only when enabled.
- Status: filter usage summary to the active model provider.
- Status: map model providers to usage sources so unrelated usage doesn’t appear.
@@ -125,6 +176,10 @@
- Dev templates: ship C-3PO dev workspace defaults as docs templates and use them for dev bootstrap. — thanks @steipete
- Config: fix Minimax hosted onboarding to write `agents.defaults` and allow `msteams` as a heartbeat target. — thanks @steipete
- Discord: add channel/category management actions (create/edit/move/delete + category removal). (#487) - thanks @NicholasSpisak
- Docs: split CLI install commands into separate code blocks. (#601) — thanks @martinpucik
- WhatsApp: record outbound provider activity using the active account id. (#537) — thanks @Nachx639
- Discord: add gateway HELLO timeout to detect zombie connections. (#608) — thanks @NicholasSpisak
- Docker: cache dependency layer for faster rebuilds. (#605) — thanks @zknicker
## 2026.1.8
+6 -1
View File
@@ -8,9 +8,14 @@ RUN corepack enable
WORKDIR /app
COPY . .
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY ui/package.json ./ui/package.json
COPY patches ./patches
COPY scripts ./scripts
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
RUN pnpm ui:install
RUN pnpm ui:build
+13 -12
View File
@@ -457,16 +457,17 @@ Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></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/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/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/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/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/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></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/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/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/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/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/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/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/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/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/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a>
<a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/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/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/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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=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=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></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/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/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/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/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/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/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/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=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></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/search?q=Tobias%20Bischoff"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></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/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/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/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/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/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></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/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/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/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/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/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/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/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/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/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/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/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/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/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=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></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/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/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/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/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/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/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/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/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/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/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/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/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/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/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=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></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/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/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a>
</p>
@@ -700,8 +700,8 @@ class NodeRuntime(context: Context) {
val ui = config?.get("ui").asObjectOrNull()
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
val sessionCfg = config?.get("session").asObjectOrNull()
val rawMainKey = sessionCfg?.get("mainKey").asStringOrNull()?.trim()
_mainSessionKey.value = rawMainKey?.takeIf { it.isNotEmpty() } ?: "main"
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
_mainSessionKey.value = mainKey
val parsed = parseHexColorArgb(raw)
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
@@ -0,0 +1,6 @@
package com.clawdbot.android
internal fun normalizeMainKey(raw: String?): String {
val trimmed = raw?.trim()
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
}
@@ -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.normalizeMainKey
import java.net.HttpURLConnection
import java.net.URL
import java.util.UUID
@@ -814,7 +815,7 @@ class TalkModeManager(
val config = root?.get("config").asObjectOrNull()
val talk = config?.get("talk").asObjectOrNull()
val sessionCfg = config?.get("session").asObjectOrNull()
val mainKey = sessionCfg?.get("mainKey").asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: "main"
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
val voice = talk?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val aliases =
talk?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) ->
+1 -2
View File
@@ -330,8 +330,7 @@ final class NodeAppModel {
let ui = config["ui"] as? [String: Any]
let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let session = config["session"] as? [String: Any]
let rawMainKey = (session?["mainKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let mainKey = rawMainKey.isEmpty ? "main" : rawMainKey
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
await MainActor.run {
self.seamColorHex = raw.isEmpty ? nil : raw
self.mainSessionKey = mainKey
+8
View File
@@ -0,0 +1,8 @@
import Foundation
enum SessionKey {
static func normalizeMainKey(_ raw: String?) -> String {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "main" : trimmed
}
}
+1 -2
View File
@@ -651,8 +651,7 @@ 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]
let rawMainKey = (session?["mainKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
self.mainSessionKey = rawMainKey.isEmpty ? "main" : rawMainKey
self.mainSessionKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let aliases = talk?["voiceAliases"] as? [String: Any] {
var resolved: [String: String] = [:]
+4 -1
View File
@@ -29,7 +29,10 @@ final class AppState {
}
var launchAtLogin: Bool {
didSet { self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } } }
didSet {
guard !self.isInitializing else { return }
self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } }
}
}
var onboardingSeen: Bool {
@@ -331,8 +331,11 @@ actor MacNodeBridgeSession {
let now = self.clock.now
if now > last.advanced(by: timeout) {
let age = last.duration(to: now)
self.logger.warning(
"Node bridge heartbeat timed out; disconnecting (age: \(String(describing: age), privacy: .public)).")
let ageDescription = String(describing: age)
let message =
"Node bridge heartbeat timed out; disconnecting " +
"(age: \(ageDescription, privacy: .public))."
self.logger.warning(message)
await self.disconnect()
return
}
@@ -342,8 +345,11 @@ actor MacNodeBridgeSession {
do {
try await self.send(BridgePing(type: "ping", id: id))
} catch {
self.logger.warning(
"Node bridge ping send failed; disconnecting (error: \(String(describing: error), privacy: .public)).")
let errorDescription = String(describing: error)
let message =
"Node bridge ping send failed; disconnecting " +
"(error: \(errorDescription, privacy: .public))."
self.logger.warning(message)
await self.disconnect()
return
}
@@ -358,8 +364,11 @@ actor MacNodeBridgeSession {
private func handleConnectionState(_ state: NWConnection.State) async {
switch state {
case let .failed(error):
self.logger.warning(
"Node bridge connection failed; disconnecting (error: \(String(describing: error), privacy: .public)).")
let errorDescription = String(describing: error)
let message =
"Node bridge connection failed; disconnecting " +
"(error: \(errorDescription, privacy: .public))."
self.logger.warning(message)
await self.disconnect()
case .cancelled:
self.logger.warning("Node bridge connection cancelled; disconnecting.")
@@ -16,4 +16,3 @@ struct MacNodeBridgeSessionTests {
}
}
}
@@ -112,24 +112,19 @@ public final class ClawdbotChatViewModel {
recent.append(entry)
}
let mainKey = "main"
var result: [ClawdbotChatSessionEntry] = []
var included = Set<String>()
if let main = sorted.first(where: { $0.key == mainKey }) {
result.append(main)
included.insert(mainKey)
} else if self.sessionKey == mainKey {
result.append(self.placeholderSession(key: mainKey))
included.insert(mainKey)
}
for entry in recent where !included.contains(entry.key) {
result.append(entry)
included.insert(entry.key)
}
if !included.contains(self.sessionKey) {
result.append(self.placeholderSession(key: self.sessionKey))
if let current = sorted.first(where: { $0.key == self.sessionKey }) {
result.append(current)
} else {
result.append(self.placeholderSession(key: self.sessionKey))
}
}
return result
+1 -1
View File
@@ -98,7 +98,7 @@ echo " - Gateway token: $CLAWDBOT_GATEWAY_TOKEN"
echo " - Tailscale exposure: Off"
echo " - Install Gateway daemon: No"
echo ""
docker compose -f "$COMPOSE_FILE" run --rm clawdbot-cli onboard
docker compose -f "$COMPOSE_FILE" run --rm clawdbot-cli onboard --no-install-daemon
echo ""
echo "==> Provider setup (optional)"
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+19
View File
@@ -65,6 +65,25 @@ uses the last delivery route (falls back to WhatsApp).
To force a cheaper model for Gmail runs, set `model` in the mapping
(`provider/model` or alias). If you enforce `agents.defaults.models`, include it there.
To set a default model and thinking level specifically for Gmail hooks, add
`hooks.gmail.model` / `hooks.gmail.thinking` in your config:
```json5
{
hooks: {
gmail: {
model: "openrouter/meta-llama/llama-3.3-70b-instruct:free",
thinking: "off"
}
}
}
```
Notes:
- Per-hook `model`/`thinking` in the mapping still overrides these defaults.
- Fallback order: `hooks.gmail.model` → `agents.defaults.model.fallbacks` → primary (auth/rate-limit/timeouts).
- If `agents.defaults.models` is set, the Gmail model must be in the allowlist.
To customize payload handling further, add `hooks.mappings` or a JS/TS transform module
under `hooks.transformsDir` (see [`docs/webhook.md`](https://docs.clawd.bot/automation/webhook)).
+395
View File
@@ -0,0 +1,395 @@
# Broadcast Groups
**Status:** Experimental
**Version:** Added in 2026.1.9
## Overview
Broadcast Groups enable multiple agents to process and respond to the same message simultaneously. This allows you to create specialized agent teams that work together in a single WhatsApp group, DM, or channel - all using one phone number.
Broadcast groups are evaluated after provider allowlists and group activation rules. In WhatsApp groups, this means broadcasts happen when Clawdbot would normally reply (for example: on mention, depending on your group settings).
## Use Cases
### 1. Specialized Agent Teams
Deploy multiple agents with atomic, focused responsibilities:
```
Group: "Development Team"
Agents:
- CodeReviewer (reviews code snippets)
- DocumentationBot (generates docs)
- SecurityAuditor (checks for vulnerabilities)
- TestGenerator (suggests test cases)
```
Each agent processes the same message and provides its specialized perspective.
### 2. Multi-Language Support
```
Group: "International Support"
Agents:
- Agent_EN (responds in English)
- Agent_DE (responds in German)
- Agent_ES (responds in Spanish)
```
### 3. Quality Assurance Workflows
```
Group: "Customer Support"
Agents:
- SupportAgent (provides answer)
- QAAgent (reviews quality, only responds if issues found)
```
### 4. Task Automation
```
Group: "Project Management"
Agents:
- TaskTracker (updates task database)
- TimeLogger (logs time spent)
- ReportGenerator (creates summaries)
```
## Configuration
### Basic Setup
Add a top-level `broadcast` section (next to `bindings`):
```json
{
"broadcast": {
"120363403215116621@g.us": ["alfred", "baerbel", "assistant3"]
}
}
```
**Result:** When Clawdbot would reply in this chat, it will run all three agents.
### Processing Strategy
Control how agents process messages:
#### Parallel (Default)
All agents process simultaneously:
```json
{
"broadcast": {
"strategy": "parallel",
"120363403215116621@g.us": ["alfred", "baerbel"]
}
}
```
#### Sequential
Agents process in order (one waits for previous to finish):
```json
{
"broadcast": {
"strategy": "sequential",
"120363403215116621@g.us": ["alfred", "baerbel"]
}
}
```
### Complete Example
```json
{
"agents": {
"list": [
{
"id": "code-reviewer",
"name": "Code Reviewer",
"workspace": "/path/to/code-reviewer",
"sandbox": { "mode": "all" }
},
{
"id": "security-auditor",
"name": "Security Auditor",
"workspace": "/path/to/security-auditor",
"sandbox": { "mode": "all" }
},
{
"id": "docs-generator",
"name": "Documentation Generator",
"workspace": "/path/to/docs-generator",
"sandbox": { "mode": "all" }
}
]
},
"broadcast": {
"strategy": "parallel",
"120363403215116621@g.us": ["code-reviewer", "security-auditor", "docs-generator"],
"120363424282127706@g.us": ["support-en", "support-de"],
"+15555550123": ["assistant", "logger"]
}
}
```
## How It Works
### Message Flow
1. **Incoming message** arrives in a WhatsApp group
2. **Broadcast check**: System checks if peer ID is in `broadcast`
3. **If in broadcast list**:
- All listed agents process the message
- Each agent has its own session key and isolated context
- Agents process in parallel (default) or sequentially
4. **If not in broadcast list**:
- Normal routing applies (first matching binding)
Note: broadcast groups do not bypass provider allowlists or group activation rules (mentions/commands/etc). They only change *which agents run* when a message is eligible for processing.
### Session Isolation
Each agent in a broadcast group maintains completely separate:
- **Session keys** (`agent:alfred:whatsapp:group:120363...` vs `agent:baerbel:whatsapp:group:120363...`)
- **Conversation history** (agent doesn't see other agents' messages)
- **Workspace** (separate sandboxes if configured)
- **Tool access** (different allow/deny lists)
- **Memory/context** (separate IDENTITY.md, SOUL.md, etc.)
- **Group context buffer** (recent group messages used for context) is shared per peer, so all broadcast agents see the same context when triggered
This allows each agent to have:
- Different personalities
- Different tool access (e.g., read-only vs. read-write)
- Different models (e.g., opus vs. sonnet)
- Different skills installed
### Example: Isolated Sessions
In group `120363403215116621@g.us` with agents `["alfred", "baerbel"]`:
**Alfred's context:**
```
Session: agent:alfred:whatsapp:group:120363403215116621@g.us
History: [user message, alfred's previous responses]
Workspace: /Users/pascal/clawd-alfred/
Tools: read, write, bash
```
**Bärbel's context:**
```
Session: agent:baerbel:whatsapp:group:120363403215116621@g.us
History: [user message, baerbel's previous responses]
Workspace: /Users/pascal/clawd-baerbel/
Tools: read only
```
## Best Practices
### 1. Keep Agents Focused
Design each agent with a single, clear responsibility:
```json
{
"broadcast": {
"DEV_GROUP": ["formatter", "linter", "tester"]
}
}
```
✅ **Good:** Each agent has one job
❌ **Bad:** One generic "dev-helper" agent
### 2. Use Descriptive Names
Make it clear what each agent does:
```json
{
"agents": {
"security-scanner": { "name": "Security Scanner" },
"code-formatter": { "name": "Code Formatter" },
"test-generator": { "name": "Test Generator" }
}
}
```
### 3. Configure Different Tool Access
Give agents only the tools they need:
```json
{
"agents": {
"reviewer": {
"tools": { "allow": ["read", "bash"] } // Read-only
},
"fixer": {
"tools": { "allow": ["read", "write", "edit", "bash"] } // Read-write
}
}
}
```
### 4. Monitor Performance
With many agents, consider:
- Using `"strategy": "parallel"` (default) for speed
- Limiting broadcast groups to 5-10 agents
- Using faster models for simpler agents
### 5. Handle Failures Gracefully
Agents fail independently. One agent's error doesn't block others:
```
Message → [Agent A ✓, Agent B ✗ error, Agent C ✓]
Result: Agent A and C respond, Agent B logs error
```
## Compatibility
### Providers
Broadcast groups currently work with:
- ✅ WhatsApp (implemented)
- 🚧 Telegram (planned)
- 🚧 Discord (planned)
- 🚧 Slack (planned)
### Routing
Broadcast groups work alongside existing routing:
```json
{
"bindings": [
{ "match": { "provider": "whatsapp", "peer": { "kind": "group", "id": "GROUP_A" } }, "agentId": "alfred" }
],
"broadcast": {
"GROUP_B": ["agent1", "agent2"]
}
}
```
- `GROUP_A`: Only alfred responds (normal routing)
- `GROUP_B`: agent1 AND agent2 respond (broadcast)
**Precedence:** `broadcast` takes priority over `bindings`.
## Troubleshooting
### Agents Not Responding
**Check:**
1. Agent IDs exist in `agents.list`
2. Peer ID format is correct (e.g., `120363403215116621@g.us`)
3. Agents are not in deny lists
**Debug:**
```bash
tail -f ~/.clawdbot/logs/gateway.log | grep broadcast
```
### Only One Agent Responding
**Cause:** Peer ID might be in `bindings` but not `broadcast`.
**Fix:** Add to broadcast config or remove from bindings.
### Performance Issues
**If slow with many agents:**
- Reduce number of agents per group
- Use lighter models (sonnet instead of opus)
- Check sandbox startup time
## Examples
### Example 1: Code Review Team
```json
{
"broadcast": {
"strategy": "parallel",
"120363403215116621@g.us": [
"code-formatter",
"security-scanner",
"test-coverage",
"docs-checker"
]
},
"agents": {
"list": [
{ "id": "code-formatter", "workspace": "~/agents/formatter", "tools": { "allow": ["read", "write"] } },
{ "id": "security-scanner", "workspace": "~/agents/security", "tools": { "allow": ["read", "bash"] } },
{ "id": "test-coverage", "workspace": "~/agents/testing", "tools": { "allow": ["read", "bash"] } },
{ "id": "docs-checker", "workspace": "~/agents/docs", "tools": { "allow": ["read"] } }
]
}
}
```
**User sends:** Code snippet
**Responses:**
- code-formatter: "Fixed indentation and added type hints"
- security-scanner: "⚠️ SQL injection vulnerability in line 12"
- test-coverage: "Coverage is 45%, missing tests for error cases"
- docs-checker: "Missing docstring for function `process_data`"
### Example 2: Multi-Language Support
```json
{
"broadcast": {
"strategy": "sequential",
"+15555550123": ["detect-language", "translator-en", "translator-de"]
},
"agents": {
"list": [
{ "id": "detect-language", "workspace": "~/agents/lang-detect" },
{ "id": "translator-en", "workspace": "~/agents/translate-en" },
{ "id": "translator-de", "workspace": "~/agents/translate-de" }
]
}
}
```
## API Reference
### Config Schema
```typescript
interface ClawdbotConfig {
broadcast?: {
strategy?: "parallel" | "sequential";
[peerId: string]: string[];
};
}
```
### Fields
- `strategy` (optional): How to process agents
- `"parallel"` (default): All agents process simultaneously
- `"sequential"`: Agents process in array order
- `[peerId]`: WhatsApp group JID, E.164 number, or other peer ID
- Value: Array of agent IDs that should process messages
## Limitations
1. **Max agents:** No hard limit, but 10+ agents may be slow
2. **Shared context:** Agents don't see each other's responses (by design)
3. **Message ordering:** Parallel responses may arrive in any order
4. **Rate limits:** All agents count toward WhatsApp rate limits
## Future Enhancements
Planned features:
- [ ] Shared context mode (agents see each other's responses)
- [ ] Agent coordination (agents can signal each other)
- [ ] Dynamic agent selection (choose agents based on message content)
- [ ] Agent priorities (some agents respond before others)
## See Also
- [Multi-Agent Configuration](/multi-agent-sandbox-tools)
- [Routing Configuration](/concepts/provider-routing)
- [Session Management](/concepts/sessions)
+3 -2
View File
@@ -177,7 +177,7 @@ Options:
- `--workspace <dir>`
- `--non-interactive`
- `--mode <local|remote>`
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax|skip>`
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax-api|minimax|opencode-zen|skip>`
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
- `--token <token>` (non-interactive; used with `--auth-choice token`)
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
@@ -186,6 +186,7 @@ Options:
- `--openai-api-key <key>`
- `--gemini-api-key <key>`
- `--minimax-api-key <key>`
- `--opencode-zen-api-key <key>`
- `--gateway-port <port>`
- `--gateway-bind <loopback|lan|tailnet|auto>`
- `--gateway-auth <off|token|password>`
@@ -673,5 +674,5 @@ Options:
- `--deliver`
- `--thinking <level>`
- `--message <text>`
- `--timeout-ms <ms>`
- `--timeout-ms <ms>` (defaults to `agents.defaults.timeoutSeconds`)
- `--history-limit <n>`
+8 -1
View File
@@ -24,7 +24,7 @@ Provider selection:
Target formats (`--to`):
- WhatsApp: E.164 or group JID
- Telegram: chat id or `@username`
- Discord/Slack: `channel:<id>` or `user:<id>` (raw id ok)
- Discord/Slack: `channel:<id>` or `user:<id>` (raw id is ambiguous for Discord)
- Signal: E.164, `group:<id>`, or `signal:+E.164`
- iMessage: handle or `chat_id:<id>`
- MS Teams: conversation id (`19:...@thread.tacv2`) or `conversation:<id>` or `user:<aad-object-id>`
@@ -44,6 +44,7 @@ Target formats (`--to`):
- `send`
- Required: `--to`, `--message`
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
- Telegram only: `--buttons-json` (requires `"inlineButtons"` in `telegram.capabilities` or `telegram.accounts.<id>.capabilities`)
- `poll`
- Required: `--to`, `--poll-question`, `--poll-option` (repeat)
@@ -174,3 +175,9 @@ React in Slack:
clawdbot message react --provider slack \
--to C123 --message-id 456 --emoji "✅"
```
Send Telegram inline buttons:
```
clawdbot message send --provider telegram --to @mychat --message "Choose:" \
--buttons-json '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]'
```
+5 -2
View File
@@ -83,11 +83,14 @@ When queue mode is `followup` or `collect`, inbound messages are held until the
current turn ends, then a new agent turn starts with the queued payloads. See
[`docs/queue.md`](/concepts/queue) for mode + debounce/cap behavior.
Block streaming sends completed assistant blocks as soon as they finish; disable
via `agents.defaults.blockStreamingDefault: "off"` if you only want the final response.
Block streaming sends completed assistant blocks as soon as they finish; it is
**off by default** (`agents.defaults.blockStreamingDefault: "off"`).
Tune the boundary via `agents.defaults.blockStreamingBreak` (`text_end` vs `message_end`; defaults to text_end).
Control soft block chunking with `agents.defaults.blockStreamingChunk` (defaults to
800–1200 chars; prefers paragraph breaks, then newlines; sentences last).
Coalesce streamed chunks with `agents.defaults.blockStreamingCoalesce` to reduce
single-line spam (idle-based merging before send). Non-Telegram providers require
explicit `*.blockStreaming: true` to enable block replies.
Verbose tool summaries are emitted at tool start (no debounce); Control UI
streams tool output via agent events when available.
More details: [Streaming + chunking](/concepts/streaming).
+83
View File
@@ -0,0 +1,83 @@
---
summary: "Message flow, sessions, queueing, and reasoning visibility"
read_when:
- Explaining how inbound messages become replies
- Clarifying sessions, queueing modes, or streaming behavior
- Documenting reasoning visibility and usage implications
---
# Messages
This page ties together how Clawdbot handles inbound messages, sessions, queueing,
streaming, and reasoning visibility.
## Message flow (high level)
```
Inbound message
-> routing/bindings -> session key
-> queue (if a run is active)
-> agent run (streaming + tools)
-> outbound replies (provider limits + chunking)
```
Key knobs live in configuration:
- `messages.*` for prefixes, queueing, and group behavior.
- `agents.defaults.*` for block streaming and chunking defaults.
- Provider overrides (`whatsapp.*`, `telegram.*`, etc.) for caps and streaming toggles.
See [Configuration](/gateway/configuration) for full schema.
## Sessions and devices
Sessions are owned by the gateway, not by clients.
- Direct chats collapse into the agent main session key.
- Groups/channels get their own session keys.
- The session store and transcripts live on the gateway host.
Multiple devices/providers can map to the same session, but history is not fully
synced back to every client. Recommendation: use one primary device for long
conversations to avoid divergent context. The Control UI and TUI always show the
gateway-backed session transcript, so they are the source of truth.
Details: [Session management](/concepts/session).
## Queueing and followups
If a run is already active, inbound messages can be queued, steered into the
current run, or collected for a followup turn.
- Configure via `messages.queue` (and `messages.queue.byProvider`).
- Modes: `interrupt`, `steer`, `followup`, `collect`, plus backlog variants.
Details: [Queueing](/concepts/queue).
## Streaming, chunking, and batching
Block streaming sends partial replies as the model produces text blocks.
Chunking respects provider text limits and avoids splitting fenced code.
Key settings:
- `agents.defaults.blockStreamingDefault` (`on|off`, default off)
- `agents.defaults.blockStreamingBreak` (`text_end|message_end`)
- `agents.defaults.blockStreamingChunk` (`minChars|maxChars|breakPreference`)
- `agents.defaults.blockStreamingCoalesce` (idle-based batching)
- Provider overrides: `*.blockStreaming` and `*.blockStreamingCoalesce` (non-Telegram providers require explicit `*.blockStreaming: true`)
Details: [Streaming + chunking](/concepts/streaming).
## Reasoning visibility and tokens
Clawdbot can expose or hide model reasoning:
- `/reasoning on|off|stream` controls visibility.
- Reasoning content still counts toward token usage when produced by the model.
- Telegram supports reasoning stream into the draft bubble.
Details: [Thinking + reasoning directives](/tools/thinking) and [Token use](/token-use).
## Prefixes, threading, and replies
Outbound message formatting is centralized in `messages`:
- `messages.responsePrefix` (outbound prefix) and `whatsapp.messagePrefix` (WhatsApp inbound prefix)
- Reply threading via `replyToMode` and per-provider defaults
Details: [Configuration](/gateway/configuration#messages) and provider docs.
+28 -2
View File
@@ -46,7 +46,7 @@ When a provider has multiple profiles, Clawdbot chooses an order like this:
If no explicit order is configured, Clawdbot uses a round‑robin order:
- **Primary key:** profile type (**OAuth before API keys**).
- **Secondary key:** `usageStats.lastUsed` (oldest first, within each type).
- **Cooldown profiles** are moved to the end, ordered by soonest cooldown expiry.
- **Cooldown/disabled profiles** are moved to the end, ordered by soonest expiry.
### Why OAuth can “look lost”
@@ -79,16 +79,42 @@ State is stored in `auth-profiles.json` under `usageStats`:
}
```
## Billing disables
Billing/credit failures (for example “insufficient credits” / “credit balance too low”) are treated as failover‑worthy, but they’re usually not transient. Instead of a short cooldown, Clawdbot marks the profile as **disabled** (with a longer backoff) and rotates to the next profile/provider.
State is stored in `auth-profiles.json`:
```json
{
"usageStats": {
"provider:profile": {
"disabledUntil": 1736178000000,
"disabledReason": "billing"
}
}
}
```
Defaults:
- Billing backoff starts at **5 hours**, doubles per billing failure, and caps at **24 hours**.
- Backoff counters reset if the profile hasn’t failed for **24 hours** (configurable).
## Model fallback
If all profiles for a provider fail, Clawdbot moves to the next model in
`agents.defaults.model.fallbacks`. This applies to auth failures, rate limits, and
timeouts that exhausted profile rotation.
timeouts that exhausted profile rotation (other errors do not advance fallback).
When a run starts with a model override (hooks or CLI), fallbacks still end at
`agents.defaults.model.primary` after trying any configured fallbacks.
## Related config
See [`docs/configuration.md`](/gateway/configuration) for:
- `auth.profiles` / `auth.order`
- `auth.cooldowns.billingBackoffHours` / `auth.cooldowns.billingBackoffHoursByProvider`
- `auth.cooldowns.billingMaxHours` / `auth.cooldowns.failureWindowHours`
- `agents.defaults.model.primary` / `agents.defaults.model.fallbacks`
- `agents.defaults.imageModel` routing
+18
View File
@@ -51,6 +51,24 @@ Routing picks **one agent** for each inbound message:
The matched agent determines which workspace and session store are used.
## Broadcast groups (run multiple agents)
Broadcast groups let you run **multiple agents** for the same peer **when Clawdbot would normally reply** (for example: in WhatsApp groups, after mention/activation gating).
Config:
```json5
{
broadcast: {
strategy: "parallel",
"120363403215116621@g.us": ["alfred", "baerbel"],
"+15555550123": ["support", "logger"]
}
}
```
See: [Broadcast Groups](/broadcast-groups).
## Config overview
- `agents.list`: named agent definitions (workspace, model, etc.).
+1
View File
@@ -76,6 +76,7 @@ Behavior:
- `timeoutSeconds > 0`: wait up to N seconds for completion, then return `{ runId, status: "ok", reply }`.
- If wait times out: `{ runId, status: "timeout", error }`. Run continues; call `sessions_history` later.
- If the run fails: `{ runId, status: "error", error }`.
- Announce delivery runs after the primary run completes and is best-effort; `status: "ok"` does not guarantee the announce was delivered.
- Waits via gateway `agent.wait` (server-side) so reconnects don't drop the wait.
- Agent-to-agent message context is injected for the primary run.
- After the primary run completes, Clawdbot runs a **reply-back loop**:
+24 -2
View File
@@ -32,9 +32,11 @@ Legend:
- `provider send`: actual outbound messages (block replies).
**Controls:**
- `agents.defaults.blockStreamingDefault`: `"on"`/`"off"` (default on).
- `agents.defaults.blockStreamingDefault`: `"on"`/`"off"` (default off).
- Provider overrides: `*.blockStreaming` (and per-account variants) to force `"on"`/`"off"` per provider.
- `agents.defaults.blockStreamingBreak`: `"text_end"` or `"message_end"`.
- `agents.defaults.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`.
- `agents.defaults.blockStreamingCoalesce`: `{ minChars?, maxChars?, idleMs? }` (merge streamed blocks before send).
- Provider hard cap: `*.textChunkLimit` (e.g., `whatsapp.textChunkLimit`).
- Discord soft cap: `discord.maxLinesPerMessage` (default 17) splits tall replies to avoid UI clipping.
@@ -54,13 +56,32 @@ Block chunking is implemented by `EmbeddedBlockChunker`:
`maxChars` is clamped to the provider `textChunkLimit`, so you can’t exceed per-provider caps.
## Coalescing (merge streamed blocks)
When block streaming is enabled, Clawdbot can **merge consecutive block chunks**
before sending them out. This reduces “single-line spam” while still providing
progressive output.
- Coalescing waits for **idle gaps** (`idleMs`) before flushing.
- Buffers are capped by `maxChars` and will flush if they exceed it.
- `minChars` prevents tiny fragments from sending until enough text accumulates
(final flush always sends remaining text).
- Joiner is derived from `blockStreamingChunk.breakPreference`
(`paragraph` → `\n\n`, `newline` → `\n`, `sentence` → space).
- Provider overrides are available via `*.blockStreamingCoalesce` (including per-account configs).
- Default coalesce `minChars` is bumped to 1500 for Signal/Slack/Discord unless overridden.
## “Stream chunks or everything”
This maps to:
- **Stream chunks:** `blockStreamingDefault: "on"` + `blockStreamingBreak: "text_end"` (emit as you go).
- **Stream chunks:** `blockStreamingDefault: "on"` + `blockStreamingBreak: "text_end"` (emit as you go). Non-Telegram providers also need `*.blockStreaming: true`.
- **Stream everything at end:** `blockStreamingBreak: "message_end"` (flush once, possibly multiple chunks if very long).
- **No block streaming:** `blockStreamingDefault: "off"` (only final reply).
**Provider note:** For non-Telegram providers, block streaming is **off unless**
`*.blockStreaming` is explicitly set to `true`. Telegram can stream drafts
(`telegram.streamMode`) without block replies.
## Telegram draft streaming (token-ish)
Telegram is the only provider with draft streaming:
@@ -69,6 +90,7 @@ Telegram is the only provider with draft streaming:
- `partial`: draft updates with the latest stream text.
- `block`: draft updates in chunked blocks (same chunker rules).
- `off`: no draft streaming.
- Draft streaming is separate from block streaming; block replies are off by default and only enabled by `*.blockStreaming: true` on non-Telegram providers.
- Final reply is still a normal message.
- `/reasoning stream` writes reasoning into the draft bubble (Telegram only).
+13 -3
View File
@@ -15,7 +15,7 @@ The prompt is assembled by Clawdbot and injected into each agent run.
The prompt is intentionally compact and uses fixed sections:
- **Tooling**: current tool list + short descriptions.
- **Skills**: tells the model how to load skill instructions on demand.
- **Skills** (when available): tells the model how to load skill instructions on demand.
- **Clawdbot Self-Update**: how to run `config.apply` and `update.run`.
- **Workspace**: working directory (`agents.defaults.workspace`).
- **Workspace Files (injected)**: indicates bootstrap files are included below.
@@ -49,10 +49,20 @@ Use `agents.defaults.userTimezone` in `~/.clawdbot/clawdbot.json` to change the
## Skills
Skills are **not** auto-injected. Instead, the prompt instructs the model to use `read` to load skill instructions on demand:
When eligible skills exist, Clawdbot injects a compact **available skills list**
(`formatSkillsForPrompt`) that includes the **file path** for each skill. The
prompt instructs the model to use `read` to load the SKILL.md at the listed
location (workspace, managed, or bundled). If no skills are eligible, the
Skills section is omitted.
```
<workspace>/skills/<name>/SKILL.md
<available_skills>
<skill>
<name>...</name>
<description>...</description>
<location>...</location>
</skill>
</available_skills>
```
This keeps the base prompt small while still enabling targeted skill usage.
+9
View File
@@ -37,6 +37,14 @@
"source": "/model/",
"destination": "/models"
},
{
"source": "/messages",
"destination": "/concepts/messages"
},
{
"source": "/messages/",
"destination": "/concepts/messages"
},
{
"source": "/AGENTS.default",
"destination": "/reference/AGENTS.default"
@@ -575,6 +583,7 @@
"concepts/session-tool",
"concepts/presence",
"concepts/provider-routing",
"concepts/messages",
"concepts/groups",
"concepts/group-messages",
"concepts/typing-indicators",
@@ -0,0 +1,306 @@
---
summary: "Spec for hooks.gmail.model - cheaper model for Gmail PubSub processing"
read_when:
- Implementing hooks.gmail.model feature
- Modifying Gmail hook processing
- Working on hook model selection
---
# hooks.gmail.model: Cheaper Model for Gmail PubSub Processing
## Problem
Gmail PubSub hook processing (`/gmail-pubsub`) currently uses the session's primary model (`agents.defaults.model.primary`), which may be an expensive model like `claude-opus-4-5`. For automated email processing that doesn't require the most capable model, this wastes tokens/cost.
## Solution
Add `hooks.gmail.model` config option to specify an optional cheaper model for Gmail PubSub processing, with intelligent fallback to the primary model on auth/rate-limit/timeout failures.
## Config Structure
```json5
{
hooks: {
gmail: {
account: "user@gmail.com",
// ... existing gmail config ...
// NEW: Optional model override for Gmail hook processing
model: "openrouter/meta-llama/llama-3.3-70b-instruct:free",
// NEW: Optional thinking level override
thinking: "off"
}
}
}
```
### Fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `hooks.gmail.model` | `string` | (none) | Model to use for Gmail hook processing. Accepts `provider/model` refs or aliases from `agents.defaults.models`. |
| `hooks.gmail.thinking` | `string` | (inherited) | Thinking level override (`off`, `minimal`, `low`, `medium`, `high`). If unset, inherits from `agents.defaults.thinkingDefault` or model's default. |
### Alias Support
`hooks.gmail.model` accepts:
- Full refs: `"openrouter/meta-llama/llama-3.3-70b-instruct:free"`
- Aliases from `agents.defaults.models`: `"Opus"`, `"Sonnet"`, `"GLM"`
Resolution uses `buildModelAliasIndex()` from `model-selection.ts`.
## Fallback Behavior
### Fallback Triggers
Auth, rate-limit, and timeout errors trigger fallback:
- `401 Unauthorized`
- `403 Forbidden`
- `429 Too Many Requests`
- Timeouts (provider hangs / network timeouts)
Other errors (500s, content errors) fail in place.
### Fallback Chain
```
hooks.gmail.model (if set)
↓ (on auth/rate-limit/timeout)
agents.defaults.model.fallbacks[0..n]
↓ (exhausted)
agents.defaults.model.primary
```
### Uncatalogued Model
If `hooks.gmail.model` is set but not found in the model catalog or allowlist:
- **Config load**: Log warning (surfaced in `clawdbot doctor`)
- **Allowlist**: If `agents.defaults.models` is set and the model isn't listed, the hook falls back to primary.
### Cooldown Integration
Uses existing model-failover cooldown from `model-failover.ts`:
- After auth/rate-limit failure, model enters cooldown
- Next hook invocation respects cooldown before retrying
- Integrates with auth profile rotation
## Implementation
### Type Changes
```typescript
// src/config/types.ts
export type HooksGmailConfig = {
account?: string;
label?: string;
// ... existing fields ...
/** Optional model override for Gmail hook processing (provider/model or alias). */
model?: string;
/** Optional thinking level override for Gmail hook processing. */
thinking?: "off" | "minimal" | "low" | "medium" | "high";
};
```
### Model Resolution
New function in `src/cron/isolated-agent.ts` or `src/agents/model-selection.ts`:
```typescript
export function resolveHooksGmailModel(params: {
cfg: ClawdbotConfig;
defaultProvider: string;
defaultModel: string;
}): { provider: string; model: string; isHooksOverride: boolean } | null {
const hooksModel = params.cfg.hooks?.gmail?.model;
if (!hooksModel) return null;
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
defaultProvider: params.defaultProvider,
});
const resolved = resolveModelRefFromString({
raw: hooksModel,
defaultProvider: params.defaultProvider,
aliasIndex,
});
if (!resolved) return null;
return {
provider: resolved.ref.provider,
model: resolved.ref.model,
isHooksOverride: true,
};
}
```
### Processing Flow
In `runCronIsolatedAgentTurn()` (or new wrapper for hooks):
```typescript
// Resolve model - prefer hooks.gmail.model for Gmail hooks
const isGmailHook = params.sessionKey.startsWith("hook:gmail:");
const hooksModelRef = isGmailHook
? resolveHooksGmailModel({ cfg, defaultProvider, defaultModel })
: null;
const { provider, model } = hooksModelRef ?? resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
// Run with fallback - on auth/rate-limit/timeout, fall through to agents.defaults.model.fallbacks
const fallbackResult = await runWithModelFallback({
cfg: params.cfg,
provider,
model,
hooksOverride: hooksModelRef?.isHooksOverride,
run: (providerOverride, modelOverride) => runEmbeddedPiAgent({
// ... existing params ...
}),
});
```
### Fallback Detection
Extend `runWithModelFallback()` to detect auth/rate-limit:
```typescript
function isAuthRateLimitError(err: unknown): boolean {
if (err instanceof ApiError) {
return [401, 403, 429].includes(err.status);
}
// Check for common patterns in error messages
const msg = String(err).toLowerCase();
return msg.includes("unauthorized")
|| msg.includes("rate limit")
|| msg.includes("quota exceeded");
}
```
## Validation
### Config Load Time
In config validation (for `clawdbot doctor`):
```typescript
if (cfg.hooks?.gmail?.model) {
const resolved = resolveHooksGmailModel({ cfg, defaultProvider, defaultModel });
if (!resolved) {
issues.push({
path: "hooks.gmail.model",
message: `Model "${cfg.hooks.gmail.model}" could not be resolved`,
});
} else {
const catalog = await loadModelCatalog({ config: cfg });
const key = modelKey(resolved.provider, resolved.model);
const inCatalog = catalog.some(e => modelKey(e.provider, e.id) === key);
if (!inCatalog) {
issues.push({
path: "hooks.gmail.model",
message: `Model "${key}" not found in agents.defaults.models catalog (will fall back to primary)`,
});
}
}
}
```
### Runtime
At hook invocation time, validate and fall back:
- If model not in catalog → log warning, use primary
- If model auth fails → log warning, enter cooldown, fall back
## Observability
### Log Messages
```
[hooks] Gmail hook: using model openrouter/meta-llama/llama-3.3-70b-instruct:free
[hooks] Gmail hook: model llama auth failed (429), falling back to claude-opus-4-5
```
### Hook Event Summary
Include fallback info in the hook summary sent to session:
```
Hook Gmail (fallback:llama→opus): <summary>
```
## Hot Reload
`hooks.gmail.model` and `hooks.gmail.thinking` are hot-reloadable:
- Changes apply to the next hook invocation
- No gateway restart required
- Hooks config is already in the hot-reload matrix
## Test Plan
### Unit Tests
1. **Model resolution** (`model-selection.test.ts`):
- `resolveHooksGmailModel()` with valid ref
- `resolveHooksGmailModel()` with alias
- `resolveHooksGmailModel()` with invalid input → null
2. **Config validation** (`config.test.ts`):
- Warning on uncatalogued model
- No warning on valid model
- Graceful handling of missing hooks.gmail section
3. **Fallback triggers** (`model-fallback.test.ts`):
- 401/403/429 → triggers fallback
- timeouts → triggers fallback
- 500/content error → no fallback
- Content error → no fallback
### Integration Tests
1. **Hook processing** (`server.hooks.test.ts`):
- Gmail hook uses `hooks.gmail.model` when set
- Fallback to primary on auth failure
- Thinking level override applied
2. **Hot reload** (`config-reload.test.ts`):
- Change `hooks.gmail.model` → next hook uses new model
## Documentation
Update `docs/gateway/configuration.md`:
```json5
{
hooks: {
gmail: {
account: "user@gmail.com",
topic: "projects/my-project/topics/gmail-watch",
// ... existing config ...
// Optional: Use a cheaper model for Gmail processing
// Falls back to agents.defaults.model.primary on auth/rate-limit errors
model: "openrouter/meta-llama/llama-3.3-70b-instruct:free",
// Optional: Override thinking level for Gmail processing
thinking: "off"
}
}
}
```
## Scope Limitation
This PR is Gmail-specific. Future hooks (`hooks.github.model`, etc.) would follow the same pattern but are out of scope.
## Changelog Entry
```
- feat: add hooks.gmail.model for cheaper Gmail PubSub processing (#XXX)
- Falls back to agents.defaults.model.primary on auth/rate-limit/timeouts (401/403/429)
- Supports aliases from agents.defaults.models
- Add hooks.gmail.thinking override
```
+4 -1
View File
@@ -208,13 +208,16 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
thinkingDefault: "low",
verboseDefault: "off",
elevatedDefault: "on",
blockStreamingDefault: "on",
blockStreamingDefault: "off",
blockStreamingBreak: "text_end",
blockStreamingChunk: {
minChars: 800,
maxChars: 1200,
breakPreference: "paragraph"
},
blockStreamingCoalesce: {
idleMs: 1000
},
timeoutSeconds: 600,
mediaMaxMb: 5,
typingIntervalSeconds: 5,
+79 -18
View File
@@ -248,7 +248,7 @@ Controls how WhatsApp direct chats (DMs) are handled:
- `"open"`: allow all inbound DMs (**requires** `whatsapp.allowFrom` to include `"*"`)
- `"disabled"`: ignore all inbound DMs
Pairing codes expire after 1 hour; the bot only sends a pairing code when a new request is created.
Pairing codes expire after 1 hour; the bot only sends a pairing code when a new request is created. Pending DM pairing requests are capped at **3 per provider** by default.
Pairing approvals:
- `clawdbot pairing list --provider whatsapp`
@@ -265,7 +265,8 @@ For groups, use `whatsapp.groupPolicy` + `whatsapp.groupAllowFrom`.
whatsapp: {
dmPolicy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["+15555550123", "+447700900123"],
textChunkLimit: 4000 // optional outbound chunk size (chars)
textChunkLimit: 4000, // optional outbound chunk size (chars)
mediaMaxMb: 50 // optional inbound media cap (MB)
}
}
```
@@ -508,7 +509,7 @@ Read-only tools + read-only workspace:
workspaceAccess: "ro"
},
tools: {
allow: ["read", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn"],
allow: ["read", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "session_status"],
deny: ["write", "edit", "bash", "process", "browser"]
}
}
@@ -531,7 +532,7 @@ No filesystem access (messaging/session tools enabled):
workspaceAccess: "none"
},
tools: {
allow: ["sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "whatsapp", "telegram", "slack", "discord", "gateway"],
allow: ["sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "session_status", "whatsapp", "telegram", "slack", "discord", "gateway"],
deny: ["read", "write", "edit", "bash", "process", "browser", "canvas", "nodes", "cron", "gateway", "image"]
}
}
@@ -674,7 +675,7 @@ Multi-account support lives under `telegram.accounts` (see the multi-account sec
}
},
replyToMode: "first", // off | first | all
streamMode: "partial", // off | partial | block (draft streaming)
streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming)
actions: { reactions: true, sendMessage: true }, // tool action gates (false disables)
mediaMaxMb: 5,
retry: { // outbound retry policy
@@ -764,7 +765,7 @@ Multi-account support lives under `discord.accounts` (see the multi-account sect
}
```
Clawdbot starts Discord only when a `discord` config section exists. The token is resolved from `DISCORD_BOT_TOKEN` or `discord.token` (unless `discord.enabled` is `false`). Use `user:<id>` (DM) or `channel:<id>` (guild channel) when specifying delivery targets for cron/CLI commands.
Clawdbot starts Discord only when a `discord` config section exists. The token is resolved from `DISCORD_BOT_TOKEN` or `discord.token` (unless `discord.enabled` is `false`). Use `user:<id>` (DM) or `channel:<id>` (guild channel) when specifying delivery targets for cron/CLI commands; bare numeric IDs are ambiguous and rejected.
Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged channel name (no leading `#`). Prefer guild ids as keys to avoid rename ambiguity.
Reaction notification modes:
- `off`: no reaction events.
@@ -845,6 +846,26 @@ Slack action groups (gate `slack` tool actions):
| pins | enabled | Pin/unpin/list |
| memberInfo | enabled | Member info |
| emojiList | enabled | Custom emoji list |
### `signal` (signal-cli)
Signal reactions can emit system events (shared reaction tooling):
```json5
{
signal: {
reactionNotifications: "own", // off | own | all | allowlist
reactionAllowlist: ["+15551234567", "uuid:123e4567-e89b-12d3-a456-426614174000"]
}
}
```
Reaction notification modes:
- `off`: no reaction events.
- `own`: reactions on the bot's own messages (default).
- `all`: all reactions on all messages.
- `allowlist`: reactions from `signal.reactionAllowlist` on all messages (empty list disables).
### `imessage` (imsg CLI)
Clawdbot spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
@@ -920,12 +941,12 @@ message envelopes). If unset, Clawdbot uses the host timezone at runtime.
### `messages`
Controls inbound/outbound prefixes and optional ack reactions.
See [Messages](/concepts/messages) for queueing, sessions, and streaming context.
```json5
{
messages: {
messagePrefix: "[clawdbot]",
responsePrefix: "🦞",
responsePrefix: "🦞", // or "auto"
ackReaction: "👀",
ackReactionScope: "group-mentions"
}
@@ -935,13 +956,14 @@ Controls inbound/outbound prefixes and optional ack reactions.
`responsePrefix` is applied to **all outbound replies** (tool summaries, block
streaming, final replies) across providers unless already present.
If `messages.responsePrefix` is unset and the routed agent has `identity.name`
set, Clawdbot defaults the prefix to `[{identity.name}]`.
If `messages.responsePrefix` is unset, no prefix is applied by default.
Set it to `"auto"` to derive `[{identity.name}]` for the routed agent (when set).
If `messages.messagePrefix` is unset, the default stays **unchanged**:
`"[clawdbot]"` when `whatsapp.allowFrom` is empty, otherwise `""` (no prefix).
When using `"[clawdbot]"`, Clawdbot will instead use `[{identity.name}]` when
the routed agent has `identity.name` set.
WhatsApp inbound prefix is configured via `whatsapp.messagePrefix` (deprecated:
`messages.messagePrefix`). Default stays **unchanged**: `"[clawdbot]"` when
`whatsapp.allowFrom` is empty, otherwise `""` (no prefix). When using
`"[clawdbot]"`, Clawdbot will instead use `[{identity.name}]` when the routed
agent has `identity.name` set.
`ackReaction` sends a best-effort emoji reaction to acknowledge inbound messages
on providers that support reactions (Slack/Discord/Telegram). Defaults to the
@@ -1141,7 +1163,9 @@ Example (adaptive tuned):
See [/concepts/session-pruning](/concepts/session-pruning) for behavior details.
Block streaming:
- `agents.defaults.blockStreamingDefault`: `"on"`/`"off"` (default on).
- `agents.defaults.blockStreamingDefault`: `"on"`/`"off"` (default off).
- Provider overrides: `*.blockStreaming` (and per-account variants) to force block streaming on/off.
Non-Telegram providers require an explicit `*.blockStreaming: true` to enable block replies.
- `agents.defaults.blockStreamingBreak`: `"text_end"` or `"message_end"` (default: text_end).
- `agents.defaults.blockStreamingChunk`: soft chunking for streamed blocks. Defaults to
800–1200 chars, prefers paragraph breaks (`\n\n`), then newlines, then sentences.
@@ -1151,6 +1175,13 @@ Block streaming:
agents: { defaults: { blockStreamingChunk: { minChars: 800, maxChars: 1200 } } }
}
```
- `agents.defaults.blockStreamingCoalesce`: merge streamed blocks before sending.
Defaults to `{ idleMs: 1000 }` and inherits `minChars` from `blockStreamingChunk`
with `maxChars` capped to the provider text limit. Signal/Slack/Discord default
to `minChars: 1500` unless overridden.
Provider overrides: `whatsapp.blockStreamingCoalesce`, `telegram.blockStreamingCoalesce`,
`discord.blockStreamingCoalesce`, `slack.blockStreamingCoalesce`, `signal.blockStreamingCoalesce`,
`imessage.blockStreamingCoalesce`, `msteams.blockStreamingCoalesce` (and per-account variants).
See [/concepts/streaming](/concepts/streaming) for behavior + chunking details.
Typing indicators:
@@ -1225,8 +1256,24 @@ Example:
}
```
Per-agent override (further restrict):
```json5
{
agents: {
list: [
{
id: "family",
tools: {
elevated: { enabled: false }
}
}
]
}
}
```
Notes:
- `tools.elevated` is **global** (not per-agent). Availability is based on sender allowlists.
- `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can only further restrict (both must allow).
- `/elevated on|off` stores state per session key; inline directives apply to a single message.
- Elevated `bash` runs on the host and bypasses sandboxing.
- Tool policy still applies; if `bash` is denied, elevated cannot be used.
@@ -1250,7 +1297,7 @@ Defaults (if enabled):
- `"ro"`: keep the sandbox workspace at `/workspace`, and mount the agent workspace read-only at `/agent` (disables `write`/`edit`)
- `"rw"`: mount the agent workspace read/write at `/workspace`
- auto-prune: idle > 24h OR age > 7d
- tool policy: allow only `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn` (deny wins)
- tool policy: allow only `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status` (deny wins)
- configure via `tools.sandbox.tools`, override per-agent via `agents.list[].tools.sandbox.tools`
- optional sandboxed browser (Chromium + CDP, noVNC observer)
- hardening knobs: `network`, `user`, `pidsLimit`, `memory`, `cpus`, `ulimits`, `seccompProfile`, `apparmorProfile`
@@ -1315,7 +1362,7 @@ Legacy: `perSession` is still supported (`true` → `scope: "session"`,
tools: {
sandbox: {
tools: {
allow: ["bash", "process", "read", "write", "edit", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn"],
allow: ["bash", "process", "read", "write", "edit", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "session_status"],
deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"]
}
}
@@ -1877,11 +1924,25 @@ Gmail helper config (used by `clawdbot hooks gmail setup` / `run`):
renewEveryMinutes: 720,
serve: { bind: "127.0.0.1", port: 8788, path: "/" },
tailscale: { mode: "funnel", path: "/gmail-pubsub" },
// Optional: use a cheaper model for Gmail hook processing
// Falls back to agents.defaults.model.fallbacks, then primary, on auth/rate-limit/timeout
model: "openrouter/meta-llama/llama-3.3-70b-instruct:free",
// Optional: default thinking level for Gmail hooks
thinking: "off",
}
}
}
```
Model override for Gmail hooks:
- `hooks.gmail.model` specifies a model to use for Gmail hook processing (defaults to session primary).
- Accepts `provider/model` refs or aliases from `agents.defaults.models`.
- Falls back to `agents.defaults.model.fallbacks`, then `agents.defaults.model.primary`, on auth/rate-limit/timeouts.
- If `agents.defaults.models` is set, include the hooks model in the allowlist.
- At startup, warns if the configured model is not in the model catalog or allowlist.
- `hooks.gmail.thinking` sets the default thinking level for Gmail hooks and is overridden by per-hook `thinking`.
Gateway auto-start:
- If `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts
`gog gmail watch serve` on boot and auto-renews the watch.
+5 -1
View File
@@ -61,7 +61,7 @@ cat ~/.clawdbot/clawdbot.json
- Legacy on-disk state migration (sessions/agent dir/WhatsApp auth).
- State integrity and permissions checks (sessions, transcripts, state dir).
- Config file permission checks (chmod 600) when running locally.
- Model auth health: checks OAuth expiry and can refresh expiring tokens.
- Model auth health: checks OAuth expiry, can refresh expiring tokens, and reports auth-profile cooldown/disabled states.
- Legacy workspace dir detection (`~/clawdis`, `~/clawdbot`).
- Sandbox image repair when sandboxing is enabled.
- Legacy service migration and extra gateway detection.
@@ -153,6 +153,10 @@ profile is stale, it suggests `claude setup-token` on the gateway host.
Refresh prompts only appear when running interactively (TTY); `--non-interactive`
skips refresh attempts.
Doctor also reports auth profiles that are temporarily unusable due to:
- short cooldowns (rate limits/timeouts/auth failures)
- longer disables (billing/credit failures)
### 6) Sandbox image repair
When sandboxing is enabled, doctor checks Docker images and offers to build or
switch to legacy names if the current image is missing.
+3 -3
View File
@@ -38,7 +38,7 @@ Clawdbot’s stance:
All current DM-capable providers support a DM policy (`dmPolicy` or `*.dm.policy`) that gates inbound DMs **before** the message is processed:
- `pairing` (default): unknown senders receive a short pairing code and the bot ignores their message until approved. Codes expire after 1 hour; repeated DMs won’t resend a code until a new request is created.
- `pairing` (default): unknown senders receive a short pairing code and the bot ignores their message until approved. Codes expire after 1 hour; repeated DMs won’t resend a code until a new request is created. Pending requests are capped at **3 per provider** by default.
- `allowlist`: unknown senders are blocked (no pairing handshake).
- `open`: allow anyone to DM (public). **Requires** the provider allowlist to include `"*"` (explicit opt-in).
- `disabled`: ignore inbound DMs entirely.
@@ -172,7 +172,7 @@ Also consider agent workspace access inside the sandbox:
- `agents.defaults.sandbox.workspaceAccess: "ro"` mounts the agent workspace read-only at `/agent` (disables `write`/`edit`)
- `agents.defaults.sandbox.workspaceAccess: "rw"` mounts the agent workspace read/write at `/workspace`
Important: `tools.elevated` is a **global**, sender-based escape hatch that runs bash on the host. Keep `tools.elevated.allowFrom` tight and don’t enable it for strangers. See [Elevated Mode](/tools/elevated).
Important: `tools.elevated` is the global baseline escape hatch that runs bash on the host. Keep `tools.elevated.allowFrom` tight and don’t enable it for strangers. You can further restrict elevated per agent via `agents.list[].tools.elevated`. See [Elevated Mode](/tools/elevated).
## Per-agent access profiles (multi-agent)
@@ -241,7 +241,7 @@ Common use cases:
workspaceAccess: "none"
},
tools: {
allow: ["sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "whatsapp", "telegram", "slack", "discord", "gateway"],
allow: ["sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "session_status", "whatsapp", "telegram", "slack", "discord", "gateway"],
deny: ["read", "write", "edit", "bash", "process", "browser", "canvas", "nodes", "cron", "gateway", "image"]
}
}
+18
View File
@@ -169,6 +169,24 @@ clawdbot logs --follow
tail -f "$(ls -t /tmp/clawdbot/clawdbot-*.log | head -1)" | grep "blocked\\|skip\\|unauthorized"
```
### Pairing Code Not Arriving
If `dmPolicy` is `pairing`, unknown senders should receive a code and their message is ignored until approved.
**Check 1:** Is a pending request already waiting?
```bash
clawdbot pairing list --provider <provider>
```
Pending DM pairing requests are capped at **3 per provider** by default. If the list is full, new requests won’t generate a code until one is approved or expires.
**Check 2:** Did the request get created but no reply was sent?
```bash
clawdbot logs --follow | grep "pairing request"
```
**Check 3:** Confirm `dmPolicy` isn’t `open`/`allowlist` for that provider.
### Image + Mention Not Working
Known issue: When you send an image with ONLY a mention (no other text), WhatsApp sometimes doesn't include the mention metadata.
+2
View File
@@ -112,6 +112,8 @@ clawdbot providers login
clawdbot gateway --port 18789
```
Switching between npm and git installs later is easy: install the other flavor and run `clawdbot doctor` to update the gateway service entrypoint.
From source (development):
```bash
+36 -2
View File
@@ -61,6 +61,40 @@ docker compose run --rm clawdbot-cli onboard
docker compose up -d clawdbot-gateway
```
### Faster rebuilds (recommended)
To speed up rebuilds, order your Dockerfile so dependency layers are cached.
This avoids re-running `pnpm install` unless lockfiles change:
```dockerfile
FROM node:22-bookworm
# Install Bun (required for build scripts)
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}"
RUN corepack enable
WORKDIR /app
# Cache dependencies unless package metadata changes
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY ui/package.json ./ui/package.json
COPY patches ./patches
COPY scripts ./scripts
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
RUN pnpm ui:install
RUN pnpm ui:build
ENV NODE_ENV=production
CMD ["node","dist/index.js"]
```
### Provider setup (optional)
Use the CLI container to configure providers, then restart the gateway if needed.
@@ -144,7 +178,7 @@ precedence, and troubleshooting.
- `"rw"` mounts the agent workspace read/write at `/workspace`
- Auto-prune: idle > 24h OR age > 7d
- Network: `none` by default (explicitly opt-in if you need egress)
- Default allow: `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`
- Default allow: `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- Default deny: `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`
### Enable sandboxing
@@ -191,7 +225,7 @@ precedence, and troubleshooting.
tools: {
sandbox: {
tools: {
allow: ["bash", "process", "read", "write", "edit", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn"],
allow: ["bash", "process", "read", "write", "edit", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "session_status"],
deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"]
}
}
+1
View File
@@ -67,6 +67,7 @@ pnpm clawdbot health
Notes:
- `pnpm build` matters when you run the packaged `clawdbot` binary ([`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js)) or use Node to run `dist/`.
- If you run directly from TypeScript (`pnpm clawdbot ...` / `bun run clawdbot ...`), a rebuild is usually unnecessary, but **config migrations still apply** → run doctor.
- Switching between npm and git installs is easy: install the other flavor, then run `clawdbot doctor` so the gateway service entrypoint is rewritten to the current install.
## Always run: `clawdbot doctor`
+4 -3
View File
@@ -172,13 +172,14 @@ The filtering order is:
Each level can further restrict tools, but cannot grant back denied tools from earlier levels.
If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` for that agent.
### Elevated Mode (global)
`tools.elevated` is **global** and **sender-based** (per-provider allowlist). It is **not** configurable per agent.
### Elevated Mode
`tools.elevated` is the global baseline (sender-based allowlist). `agents.list[].tools.elevated` can further restrict elevated for specific agents (both must allow).
Mitigation patterns:
- Deny `bash` for untrusted agents (`agents.list[].tools.deny: ["bash"]`)
- Avoid allowlisting senders that route to restricted agents
- Disable elevated globally (`tools.elevated.enabled: false`) if you only want sandboxed execution
- Disable elevated per agent (`agents.list[].tools.elevated.enabled: false`) for sensitive profiles
---
@@ -252,7 +253,7 @@ Legacy `agent.*` configs are migrated by `clawdbot doctor`; prefer `agents.defau
```json
{
"tools": {
"allow": ["sessions_list", "sessions_send", "sessions_history"],
"allow": ["sessions_list", "sessions_send", "sessions_history", "session_status"],
"deny": ["bash", "write", "edit", "read", "browser"]
}
}
+7 -1
View File
@@ -214,9 +214,15 @@ RUN curl -L https://github.com/steipete/wacli/releases/latest/download/wacli_Lin
# Add more binaries below using the same pattern
WORKDIR /app
COPY . .
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY ui/package.json ./ui/package.json
COPY patches ./patches
COPY scripts ./scripts
RUN corepack enable
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
RUN pnpm ui:install
RUN pnpm ui:build
+1 -1
View File
@@ -20,7 +20,7 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
3. Configure Clawdbot with `DISCORD_BOT_TOKEN` (or `discord.token` in `~/.clawdbot/clawdbot.json`).
4. Run the gateway; it auto-starts the Discord provider only when a `discord` config section exists **and** the token is set (unless `discord.enabled = false`).
- If you prefer env vars, still add `discord: { enabled: true }` to `~/.clawdbot/clawdbot.json` and set `DISCORD_BOT_TOKEN`.
5. Direct chats: use `user:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session.
5. Direct chats: use `user:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session. Bare numeric IDs are ambiguous and rejected.
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default and can be set per guild or per channel.
7. Direct chats: secure by default via `discord.dm.policy` (default: `"pairing"`). Unknown senders get a pairing code (expires after 1 hour); approve via `clawdbot pairing approve --provider discord <code>`.
- To keep old “open to anyone” behavior: set `discord.dm.policy="open"` and `discord.dm.allowFrom=["*"]`.
+1 -1
View File
@@ -18,7 +18,7 @@ read_when:
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `telegram.webhookUrl` is set (otherwise it long-polls).
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same provider.
- **Config knobs:** `telegram.botToken`, `telegram.dmPolicy`, `telegram.groups` (allowlist + mention defaults), `telegram.allowFrom`, `telegram.groupAllowFrom`, `telegram.groupPolicy`, `telegram.mediaMaxMb`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`.
- **Draft streaming:** optional `telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+).
- **Draft streaming:** optional `telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from provider block streaming.
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
Open questions
+71 -23
View File
@@ -13,6 +13,7 @@ Status: external CLI integration. Gateway spawns `imsg rpc` (JSON-RPC over stdio
- iMessage provider backed by `imsg` on macOS.
- Deterministic routing: replies always go back to iMessage.
- DMs share the agent's main session; groups are isolated (`imessage:group:<chat_id>`).
- If a multi-participant thread arrives with `is_group=false`, you can still isolate it by `chat_id` using `imessage.groups` (see “Group-ish threads” below).
## Requirements
- macOS with Messages signed in.
@@ -24,35 +25,61 @@ Status: external CLI integration. Gateway spawns `imsg rpc` (JSON-RPC over stdio
1) Ensure Messages is signed in on this Mac.
2) Configure iMessage and start the gateway.
### Remote/SSH variant (optional)
If you want iMessage on another Mac, set `imessage.cliPath` to a wrapper that
execs `ssh` and runs `imsg rpc` on the remote host. Clawdbot only needs a
stdio stream; `imsg` still runs on the remote macOS host.
### Dedicated bot macOS user (for isolated identity)
If you want the bot to send from a **separate iMessage identity** (and keep your personal Messages clean), use a dedicated Apple ID + a dedicated macOS user.
Example wrapper (save somewhere in your PATH and `chmod +x`):
1) Create a dedicated Apple ID (example: `my-cool-bot@icloud.com`).
- Apple may require a phone number for verification / 2FA.
2) Create a macOS user (example: `clawdshome`) and sign into it.
3) Open Messages in that macOS user and sign into iMessage using the bot Apple ID.
4) Enable Remote Login (System Settings → General → Sharing → Remote Login).
5) Install `imsg`:
- `brew install steipete/tap/imsg`
6) Set up SSH so `ssh <bot-macos-user>@localhost true` works without a password.
7) Point `imessage.accounts.bot.cliPath` at an SSH wrapper that runs `imsg` as the bot user.
First-run note: sending/receiving may require GUI approvals (Automation + Full Disk Access) in the *bot macOS user*. If `imsg rpc` looks stuck or exits, log into that user (Screen Sharing helps), run a one-time `imsg chats --limit 1` / `imsg send ...`, approve prompts, then retry.
Example wrapper (`chmod +x`). Replace `<bot-macos-user>` with your actual macOS username:
```bash
#!/usr/bin/env bash
set -euo pipefail
# Run an interactive SSH once first to accept host keys:
# ssh <bot-macos-user>@localhost true
exec /usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=5 -T <bot-macos-user>@localhost \
"/usr/local/bin/imsg" "$@"
```
Example config:
```json5
{
imessage: {
enabled: true,
accounts: {
bot: {
name: "Bot",
enabled: true,
cliPath: "/path/to/imsg-bot",
dbPath: "/Users/<bot-macos-user>/Library/Messages/chat.db"
}
}
}
}
```
For single-account setups, use flat options (`imessage.cliPath`, `imessage.dbPath`) instead of the `accounts` map.
### Remote/SSH variant (optional)
If you want iMessage on another Mac, set `imessage.cliPath` to a wrapper that runs `imsg` on the remote macOS host over SSH. Clawdbot only needs stdio.
Example wrapper:
```bash
#!/usr/bin/env bash
exec ssh -T mac-mini imsg "$@"
```
Notes:
- Remote Mac must have Messages signed in and `imsg` installed.
- Full Disk Access + Automation prompts happen on the remote Mac.
- Use SSH keys (no password prompt) so the gateway can launch `imsg rpc` unattended.
Example:
```json5
{
imessage: {
enabled: true,
cliPath: "/usr/local/bin/imessage-remote",
dmPolicy: "pairing",
allowFrom: ["+15555550123"]
}
}
```
Multi-account support: use `imessage.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
Multi-account support: use `imessage.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. Don’t commit `~/.clawdbot/clawdbot.json` (it often contains tokens).
## Access control (DMs + groups)
DMs:
@@ -73,6 +100,27 @@ Groups:
- `imsg` streams message events; the gateway normalizes them into the shared provider envelope.
- Replies always route back to the same chat id or handle.
## Group-ish threads (`is_group=false`)
Some iMessage threads can have multiple participants but still arrive with `is_group=false` depending on how Messages stores the chat identifier.
If you explicitly configure a `chat_id` under `imessage.groups`, Clawdbot treats that thread as a “group” for:
- session isolation (separate `imessage:group:<chat_id>` session key)
- group allowlisting / mention gating behavior
Example:
```json5
{
imessage: {
groupPolicy: "allowlist",
groupAllowFrom: ["+15555550123"],
groups: {
"42": { "requireMention": false }
}
}
}
```
This is useful when you want an isolated personality/model for a specific thread (see [Multi-agent routing](/concepts/multi-agent)). For filesystem isolation, see [Sandboxing](/gateway/sandboxing).
## Media + limits
- Optional attachment ingestion via `imessage.includeAttachments`.
- Media cap via `imessage.mediaMaxMb`.
+13 -5
View File
@@ -199,12 +199,20 @@ Ack reactions are controlled globally via `messages.ackReaction` +
- Media uploads are capped by `slack.mediaMaxMb` (default 20).
## Reply threading
Slack supports optional threaded replies via tags:
- `[[reply_to_current]]` — reply to the triggering message.
- `[[reply_to:<id>]]` — reply to a specific message id.
By default, Clawdbot replies in the main channel. Use `slack.replyToMode` to control automatic threading:
Controlled by `slack.replyToMode`:
- `off` (default), `first`, `all`.
| Mode | Behavior |
| --- | --- |
| `off` | **Default.** Reply in main channel. Only thread if the triggering message was already in a thread. |
| `first` | First reply goes to thread (under the triggering message), subsequent replies go to main channel. Useful for keeping context visible while avoiding thread clutter. |
| `all` | All replies go to thread. Keeps conversations contained but may reduce visibility. |
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
### Manual threading tags
For fine-grained control, use these tags in agent responses:
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
- `[[reply_to:<id>]]` — reply to a specific message id.
## Sessions + routing
- DMs share the `main` session (like WhatsApp/Telegram).
+4
View File
@@ -206,6 +206,10 @@ Config:
- `block`: update the draft bubble in larger blocks (chunked).
- `off`: disable draft streaming.
Note: draft streaming is separate from **block streaming** (provider messages).
Block streaming is off by default and requires `telegram.blockStreaming: true`
if you want early Telegram messages instead of draft updates.
Reasoning stream (Telegram only):
- `/reasoning stream` streams reasoning into the draft bubble while the reply is
generating, then sends the final answer without reasoning.
+11 -8
View File
@@ -59,8 +59,8 @@ When the wizard asks for your personal WhatsApp number, enter the phone you will
}
```
Tip: if you set the routed agent’s `identity.name`, you can omit
`messages.responsePrefix` and it will default to `[{identity.name}]`.
Tip: set `messages.responsePrefix` explicitly if you want a consistent bot prefix
on outbound replies.
### Number sourcing tips
- **Local eSIM** from your country's mobile carrier (most reliable)
@@ -103,9 +103,9 @@ Tip: if you set the routed agent’s `identity.name`, you can omit
If you run Clawdbot on your **personal WhatsApp number**, enable `whatsapp.selfChatMode` (see sample above).
Behavior:
- Suppresses pairing replies for **outbound DMs** (prevents spamming contacts).
- Outbound DMs never trigger pairing replies (prevents spamming contacts).
- Inbound unknown senders still follow `whatsapp.dmPolicy`.
- Self-chat mode avoids auto read receipts and ignores mention JIDs.
- Self-chat mode (allowFrom includes your number) avoids auto read receipts and ignores mention JIDs.
- Read receipts sent for non-self-chat DMs.
## Message normalization (what the model sees)
@@ -151,7 +151,8 @@ Behavior:
## Limits
- Outbound text is chunked to `whatsapp.textChunkLimit` (default 4000).
- Media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB).
- Inbound media saves are capped by `whatsapp.mediaMaxMb` (default 50 MB).
- Outbound media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB).
## Outbound send (text + media)
- Uses active web listener; error if gateway not running.
@@ -166,7 +167,7 @@ Behavior:
- Gateway: `send` params include `gifPlayback: true`
## Media limits + optimization
- Default cap: 5 MB (per media item).
- Default outbound cap: 5 MB (per media item).
- Override: `agents.defaults.mediaMaxMb`.
- Images are auto-optimized to JPEG under cap (resize + quality sweep).
- Oversize media => error; media reply falls back to text warning.
@@ -185,16 +186,18 @@ Behavior:
## Config quick map
- `whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled).
- `whatsapp.selfChatMode` (same-phone setup; suppress pairing replies for outbound DMs).
- `whatsapp.selfChatMode` (same-phone setup; bot uses your personal WhatsApp number).
- `whatsapp.allowFrom` (DM allowlist).
- `whatsapp.mediaMaxMb` (inbound media save cap).
- `whatsapp.accounts.<accountId>.*` (per-account settings + optional `authDir`).
- `whatsapp.accounts.<accountId>.mediaMaxMb` (per-account inbound media cap).
- `whatsapp.groupAllowFrom` (group sender allowlist).
- `whatsapp.groupPolicy` (group policy).
- `whatsapp.groups` (group allowlist + mention gating defaults; use `"*"` to allow all)
- `whatsapp.actions.reactions` (gate WhatsApp tool reactions).
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`)
- `messages.groupChat.historyLimit`
- `messages.messagePrefix` (inbound prefix)
- `whatsapp.messagePrefix` (inbound prefix; per-account: `whatsapp.accounts.<accountId>.messagePrefix`; deprecated: `messages.messagePrefix`)
- `messages.responsePrefix` (outbound prefix)
- `agents.defaults.mediaMaxMb`
- `agents.defaults.heartbeat.every`
+1 -1
View File
@@ -92,7 +92,7 @@ git commit -m "Add Clawd workspace"
- **eightctl** — Control your sleep, from the terminal.
- **imsg** — Send, read, stream iMessage & SMS.
- **wacli** — WhatsApp CLI: sync, search, send.
- **discord** — Discord actions: react, stickers, polls.
- **discord** — Discord actions: react, stickers, polls. Use `user:<id>` or `channel:<id>` targets (bare numeric ids are ambiguous).
- **gog** — Google Suite CLI: Gmail, Calendar, Drive, Contacts.
- **spotify-player** — Terminal Spotify client to search/queue/control playback.
- **sag** — ElevenLabs speech with mac-style say UX; streams to speakers by default.
+28
View File
@@ -92,6 +92,31 @@ It also warns if your configured model is unknown or missing auth.
Bun is supported for faster TypeScript execution, but **WhatsApp requires Node** in this ecosystem. The wizard lets you pick the runtime; choose **Node** if you use WhatsApp.
### Can I switch between npm and git installs later?
Yes. Install the other flavor, then run Doctor so the gateway service points at the new entrypoint.
From npm → git:
```bash
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
pnpm install
pnpm build
pnpm clawdbot doctor
clawdbot daemon restart
```
From git → npm:
```bash
npm install -g clawdbot@latest
clawdbot doctor
clawdbot daemon restart
```
Doctor detects a gateway service entrypoint mismatch and offers to rewrite the service config to match the current install (use `--repair` in automation).
### Is there a dedicated sandboxing doc?
Yes. See [Sandboxing](/gateway/sandboxing). For Docker-specific setup (full gateway in Docker or sandbox images), see [Docker](/install/docker).
@@ -422,6 +447,8 @@ Clawdbot uses provider‑prefixed IDs like:
Yes. Config supports optional metadata for profiles and an ordering per provider (`auth.order.<provider>`). This does **not** store secrets; it maps IDs to provider/mode and sets rotation order.
Clawdbot may temporarily skip a profile if it’s in a short **cooldown** (rate limits/timeouts/auth failures) or a longer **disabled** state (billing/insufficient credits). To inspect this, run `clawdbot models status --json` and check `auth.unusableProfiles`. Tuning: `auth.cooldowns.billingBackoffHours*`.
You can also set a **per-agent** order override (stored in that agent’s `auth-profiles.json`) via the CLI:
```bash
@@ -605,6 +632,7 @@ Treat inbound DMs as untrusted input. Defaults are designed to reduce risk:
- Default behavior on DM‑capable providers is **pairing**:
- Unknown senders receive a pairing code; the bot does not process their message.
- Approve with: `clawdbot pairing approve --provider <provider> <code>`
- Pending requests are capped at **3 per provider**; check `clawdbot pairing list --provider <provider>` if a code didn’t arrive.
- Opening DMs publicly requires explicit opt‑in (`dmPolicy: "open"` and allowlist `"*"`).
Run `clawdbot doctor` to surface risky DM policies.
+6 -1
View File
@@ -48,7 +48,12 @@ Windows: use **WSL2** (Ubuntu recommended). WSL2 is strongly recommended; native
```bash
npm install -g clawdbot@latest
# or: pnpm add -g clawdbot@latest
```
Or:
```bash
pnpm add -g clawdbot@latest
```
## 2) Run the onboarding wizard (and install the daemon)
+1
View File
@@ -25,6 +25,7 @@ Default DM policies are documented in: [Security](/gateway/security)
Pairing codes:
- 8 characters, uppercase, no ambiguous chars (`0O1I`).
- **Expire after 1 hour**. The bot only sends the pairing message when a new request is created (roughly once per hour per sender).
- Pending DM pairing requests are capped at **3 per provider** by default; additional requests are ignored until one expires or is approved.
### Approve a sender
+88
View File
@@ -12,6 +12,94 @@ Real projects from the community. See what people are building with Clawdbot.
**Want to be featured?** Share your project in [#showcase on Discord](https://discord.gg/clawdbot) or [tag @clawdbot on X](https://x.com/clawdbot).
</Info>
## 🎥 Clawdbot in Action
Full setup walkthrough (28m) by VelvetShark.
<div
style={{
position: "relative",
paddingBottom: "56.25%",
height: 0,
overflow: "hidden",
borderRadius: 16,
}}
>
<iframe
src="https://www.youtube-nocookie.com/embed/SaWSPZoPX34"
title="ClawdBot: The self-hosted AI that Siri should have been (Full setup)"
style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%" }}
frameBorder="0"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>
</div>
[Watch on YouTube](https://www.youtube.com/watch?v=SaWSPZoPX34)
## 🆕 Fresh from Discord
<CardGroup cols={2}>
<Card title="Tesco Shop Autopilot" icon="cart-shopping" href="https://x.com/i/status/2009724862470689131">
**@marchattonhere** • `automation` `browser` `shopping`
Weekly meal plan → regulars → book delivery slot → confirm order. No APIs, just browser control.
<img src="/assets/showcase/tesco-shop.jpg" alt="Tesco shop automation via chat" />
</Card>
<Card title="SNAG Screenshot-to-Markdown" icon="scissors" href="https://github.com/am-will/snag">
**@am-will** • `devtools` `screenshots` `markdown`
Hotkey a screen region → Gemini vision → instant Markdown in your clipboard.
<img src="/assets/showcase/snag.png" alt="SNAG screenshot-to-markdown tool" />
</Card>
<Card title="Agents UI" icon="window-maximize" href="https://releaseflow.net/kitze/agents-ui">
**@kitze** • `ui` `skills` `sync`
Desktop app to manage skills/commands across Agents, Claude, Codex, and Clawdbot.
<img src="/assets/showcase/agents-ui.jpg" alt="Agents UI app" />
</Card>
<Card title="Telegram Voice Notes (papla.media)" icon="microphone" href="https://papla.media/docs">
**Community** • `voice` `tts` `telegram`
Wraps papla.media TTS and sends results as Telegram voice notes (no annoying autoplay).
<img src="/assets/showcase/papla-tts.jpg" alt="Telegram voice note output from TTS" />
</Card>
<Card title="CodexMonitor" icon="eye" href="https://clawdhub.com/odrobnik/codexmonitor">
**@odrobnik** • `devtools` `codex` `brew`
Homebrew-installed helper to list/inspect/watch local OpenAI Codex sessions (CLI + VS Code).
<img src="/assets/showcase/codexmonitor.png" alt="CodexMonitor on ClawdHub" />
</Card>
<Card title="Bambu 3D Printer Control" icon="print" href="https://clawdhub.com/tobiasbischoff/bambu-cli">
**@tobiasbischoff** • `hardware` `3d-printing` `skill`
Control and troubleshoot BambuLab printers: status, jobs, camera, AMS, calibration, and more.
<img src="/assets/showcase/bambu-cli.png" alt="Bambu CLI skill on ClawdHub" />
</Card>
<Card title="Vienna Transport (Wiener Linien)" icon="train" href="https://clawdhub.com/hjanuschka/wienerlinien">
**@hjanuschka** • `travel` `transport` `skill`
Real-time departures, disruptions, elevator status, and routing for Vienna's public transport.
<img src="/assets/showcase/wienerlinien.png" alt="Wiener Linien skill on ClawdHub" />
</Card>
</CardGroup>
## 🤖 Automation & Workflows
<CardGroup cols={2}>
+15 -1
View File
@@ -77,8 +77,11 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
- **OpenAI Codex OAuth**: browser flow; paste the `code#state`.
- Sets `agents.defaults.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then saves it to `~/.clawdbot/.env` so launchd can read it.
- **OpenCode Zen (multi-model proxy)**: prompts for `OPENCODE_ZEN_API_KEY` (get it at https://opencode.ai/auth).
- **API key**: stores the key for you.
- **Minimax M2.1 (LM Studio)**: config is auto‑written for the LM Studio endpoint.
- **MiniMax M2.1 (minimax.io)**: config is auto‑written for the OpenAI-compatible `/v1` endpoint.
- **MiniMax API (platform.minimax.io)**: config is auto‑written for the Anthropic-compatible `/anthropic` endpoint.
- **MiniMax M2.1 (LM Studio)**: config is auto‑written for the LM Studio endpoint.
- **Skip**: no auth configured yet.
- Wizard runs a model check and warns if the configured model is unknown or missing auth.
- OAuth credentials live in `~/.clawdbot/credentials/oauth.json`; auth profiles live in `~/.clawdbot/agents/<agentId>/agent/auth-profiles.json` (API keys + OAuth).
@@ -183,6 +186,17 @@ clawdbot onboard --non-interactive \
--gateway-bind loopback
```
OpenCode Zen example:
```bash
clawdbot onboard --non-interactive \
--mode local \
--auth-choice opencode-zen \
--opencode-zen-api-key "$OPENCODE_ZEN_API_KEY" \
--gateway-port 18789 \
--gateway-bind loopback
```
Add agent (non‑interactive) example:
```bash
+5 -3
View File
@@ -12,7 +12,7 @@ read_when:
- Only `on|off` are accepted; anything else returns a hint and does not change state.
## What it controls (and what it doesn’t)
- **Global availability gate**: `tools.elevated` is global (not per-agent). If disabled or sender not allowlisted, elevated is unavailable everywhere.
- **Availability gates**: `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can further restrict elevated per agent (both must allow).
- **Per-session state**: `/elevated on|off` sets the elevated level for the current session key.
- **Inline directive**: `/elevated on` inside a message applies to that message only.
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
@@ -42,8 +42,10 @@ Note:
## Availability + allowlists
- Feature gate: `tools.elevated.enabled` (default can be off via config even if the code supports it).
- Sender allowlist: `tools.elevated.allowFrom` with per-provider allowlists (e.g. `discord`, `whatsapp`).
- Both must pass; otherwise elevated is treated as unavailable.
- Discord fallback: if `tools.elevated.allowFrom.discord` is omitted, the `discord.dm.allowFrom` list is used as a fallback. Set `tools.elevated.allowFrom.discord` (even `[]`) to override.
- Per-agent gate: `agents.list[].tools.elevated.enabled` (optional; can only further restrict).
- Per-agent allowlist: `agents.list[].tools.elevated.allowFrom` (optional; when set, the sender must match **both** global + per-agent allowlists).
- Discord fallback: if `tools.elevated.allowFrom.discord` is omitted, the `discord.dm.allowFrom` list is used as a fallback. Set `tools.elevated.allowFrom.discord` (even `[]`) to override. Per-agent allowlists do **not** use the fallback.
- All gates must pass; otherwise elevated is treated as unavailable.
## Logging + status
- Elevated bash calls are logged at info level.
+4 -2
View File
@@ -39,7 +39,7 @@ Notes:
- Returns `status: "running"` with a `sessionId` when backgrounded.
- Use `process` to poll/log/write/kill/clear background sessions.
- If `process` is disallowed, `bash` runs synchronously and ignores `yieldMs`/`background`.
- `elevated` is gated by `tools.elevated` (global sender allowlist) and runs on the host.
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and runs on the host.
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
### `process`
@@ -193,7 +193,7 @@ Notes:
- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply.
- `restart` is disabled by default; enable with `commands.restart: true`.
### `sessions_list` / `sessions_history` / `sessions_send` / `sessions_spawn`
### `sessions_list` / `sessions_history` / `sessions_send` / `sessions_spawn` / `session_status`
List sessions, inspect transcript history, or send to another session.
Core parameters:
@@ -201,11 +201,13 @@ Core parameters:
- `sessions_history`: `sessionKey`, `limit?`, `includeTools?`
- `sessions_send`: `sessionKey`, `message`, `timeoutSeconds?` (0 = fire-and-forget)
- `sessions_spawn`: `task`, `label?`, `agentId?`, `model?`, `runTimeoutSeconds?`, `cleanup?`
- `session_status`: `sessionKey?` (default current), `model?` (`default` clears override)
Notes:
- `main` is the canonical direct-chat key; global/unknown are hidden.
- `messageLimit > 0` fetches last N messages per session (tool messages filtered).
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
- Delivery/announce happens after completion and is best-effort; `status: "ok"` confirms the agent run finished, not that the announce was delivered.
- `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat.
- `sessions_spawn` is non-blocking and returns `status: "accepted"` immediately.
- `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5).
+1
View File
@@ -16,3 +16,4 @@ Provider notes:
- **Discord/Slack**: empty `emoji` removes all of the bot's reactions on the message; `remove: true` removes just that emoji.
- **Telegram**: `remove: true` removes your own reaction (Bot API limitation).
- **WhatsApp**: `remove: true` maps to empty emoji (remove bot reaction).
- **Signal**: inbound reaction notifications emit system events when `signal.reactionNotifications` is enabled.
+1 -1
View File
@@ -48,7 +48,7 @@ Text + native (when enabled):
- `/reset` or `/new`
- `/think <level>` (aliases: `/thinking`, `/t`)
- `/verbose on|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; `stream` = Telegram draft only)
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
- `/elevated on|off` (alias: `/elev`)
- `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`)
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings)
+1
View File
@@ -98,3 +98,4 @@ Sub-agents use a dedicated in-process queue lane:
- Sub-agent announce is **best-effort**. If the gateway restarts, pending “announce back” work is lost.
- Sub-agents still share the same gateway process resources; treat `maxConcurrent` as a safety valve.
- `sessions_spawn` is always non-blocking: it returns `{ status: "accepted", runId, childSessionKey }` immediately.
- Sub-agent context only injects `AGENTS.md` + `TOOLS.md` (no `SOUL.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, or `BOOTSTRAP.md`).
+3 -2
View File
@@ -32,14 +32,15 @@ read_when:
## Verbose directives (/verbose or /v)
- Levels: `on|full` or `off` (default).
- Directive-only message toggles session verbose and replies `Verbose logging enabled.` / `Verbose logging disabled.`; invalid levels return a hint without changing state.
- `/verbose off` stores an explicit session override; clear it via the Sessions UI by choosing `inherit`.
- Inline directive affects only that message; session/global defaults apply otherwise.
- Send `/verbose` (or `/verbose:`) with no argument to see the current verbose level.
- When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool result back as its own metadata-only message, prefixed with `<emoji> <tool-name>: <arg>` when available (path/command); the tool output itself is not forwarded. These tool summaries are sent as soon as each tool finishes (separate bubbles), not as streaming deltas. If you toggle `/verbose on|off` while a run is in-flight, subsequent tool bubbles honor the new setting.
## Reasoning visibility (/reasoning)
- Levels: `on|off|stream`.
- Directive-only message toggles whether thinking blocks are shown as italic text in replies.
- When enabled, any model-provided reasoning content is appended as a separate italic block.
- Directive-only message toggles whether thinking blocks are shown in replies.
- When enabled, reasoning is sent as a **separate message** prefixed with `Reasoning:`.
- `stream` (Telegram only): streams reasoning into the Telegram draft bubble while the reply is generating, then sends the final answer without reasoning.
- Alias: `/reason`.
- Send `/reasoning` (or `/reasoning:`) with no argument to see the current reasoning level.
+1 -1
View File
@@ -109,7 +109,7 @@ Session lifecycle:
- `--session <key>`: Session key (default: `main`, or `global` when scope is global)
- `--deliver`: Deliver assistant replies to the provider (default off)
- `--thinking <level>`: Override thinking level for sends
- `--timeout-ms <ms>`: Agent timeout (default 30000)
- `--timeout-ms <ms>`: Agent timeout in ms (defaults to `agents.defaults.timeoutSeconds`)
- `--history-limit <n>`: History entries to load (default 200)
## Troubleshooting
+1 -1
View File
@@ -30,7 +30,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
- `--session <key>`: Session key (default: `main`, or `global` when scope is global).
- `--deliver`: Deliver assistant replies to the provider (default off).
- `--thinking <level>`: Override thinking level for sends.
- `--timeout-ms <ms>`: Agent timeout in ms (default 30000).
- `--timeout-ms <ms>`: Agent timeout in ms (defaults to `agents.defaults.timeoutSeconds`).
- `--history-limit <n>`: History entries to load (default 200).
## Controls
+22 -7
View File
@@ -1,6 +1,6 @@
{
"name": "clawdbot",
"version": "2026.1.8-2",
"version": "2026.1.9",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
@@ -66,11 +66,24 @@
"clawdbot": "tsx src/entry.ts",
"gateway:watch": "tsx watch src/entry.ts gateway --force",
"clawdbot:rpc": "tsx src/entry.ts agent --mode rpc --json",
"ios:gen": "cd apps/ios && xcodegen generate",
"ios:open": "cd apps/ios && xcodegen generate && open Clawdbot.xcodeproj",
"ios:build": "bash -lc 'cd apps/ios && xcodegen generate && xcodebuild -project Clawdbot.xcodeproj -scheme Clawdbot -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'",
"ios:run": "bash -lc 'cd apps/ios && xcodegen generate && xcodebuild -project Clawdbot.xcodeproj -scheme Clawdbot -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted com.clawdbot.ios'",
"android:assemble": "cd apps/android && ./gradlew :app:assembleDebug",
"android:install": "cd apps/android && ./gradlew :app:installDebug",
"android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n com.clawdbot.android/.MainActivity",
"android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest",
"mac:restart": "bash scripts/restart-mac.sh",
"mac:package": "bash scripts/package-mac-app.sh",
"mac:open": "open dist/Clawdbot.app",
"lint": "biome check src test && oxlint --type-aware src test --ignore-pattern src/canvas-host/a2ui/a2ui.bundle.js",
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
"lint:all": "pnpm lint && pnpm lint:swift",
"lint:fix": "biome check --write --unsafe src && biome format --write src",
"format": "biome format src",
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources",
"format:all": "pnpm format && pnpm format:swift",
"format:fix": "biome format src --write",
"test": "vitest run",
"test:watch": "vitest",
@@ -94,15 +107,15 @@
},
"packageManager": "pnpm@10.23.0",
"dependencies": {
"@buape/carbon": "0.0.0-beta-20260109165250",
"@buape/carbon": "0.0.0-beta-20260109194934",
"@clack/prompts": "^0.11.0",
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4",
"@mariozechner/pi-agent-core": "^0.41.0",
"@mariozechner/pi-ai": "^0.41.0",
"@mariozechner/pi-coding-agent": "^0.41.0",
"@mariozechner/pi-tui": "^0.41.0",
"@mariozechner/pi-agent-core": "^0.42.1",
"@mariozechner/pi-ai": "^0.42.1",
"@mariozechner/pi-coding-agent": "^0.42.1",
"@mariozechner/pi-tui": "^0.42.1",
"@microsoft/agents-hosting": "^1.1.1",
"@microsoft/agents-hosting-express": "^1.1.1",
"@microsoft/agents-hosting-extensions-teams": "^1.1.1",
@@ -156,7 +169,7 @@
"lucide": "^0.562.0",
"ollama": "^0.6.3",
"oxlint": "^1.38.0",
"oxlint-tsgolint": "^0.10.1",
"oxlint-tsgolint": "^0.11.0",
"quicktype-core": "^23.2.6",
"rolldown": "1.0.0-beta.59",
"signal-utils": "^0.21.1",
@@ -170,6 +183,7 @@
"@sinclair/typebox": "0.34.47"
},
"patchedDependencies": {
"@buape/carbon": "patches/@buape__carbon.patch",
"@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch",
"@mariozechner/pi-coding-agent": "patches/@mariozechner__pi-coding-agent.patch",
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch"
@@ -208,6 +222,7 @@
]
},
"patchedDependencies": {
"@buape/carbon": "patches/@buape__carbon.patch",
"@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch",
"@mariozechner/pi-coding-agent": "patches/@mariozechner__pi-coding-agent.patch",
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch",
+53
View File
@@ -0,0 +1,53 @@
--- a/dist/src/classes/RequestClient.js
+++ b/dist/src/classes/RequestClient.js
@@ -118,6 +118,9 @@
}
}
this.abortController = new AbortController();
+ const timeoutMs = typeof this.options.timeout === "number" && this.options.timeout > 0
+ ? this.options.timeout
+ : undefined;
let body;
if (data?.body &&
typeof data.body === "object" &&
@@ -178,12 +181,26 @@
body = JSON.stringify(data.body);
}
}
- const response = await fetch(url, {
- method,
- headers,
- body,
- signal: this.abortController.signal
- });
+ let timeoutId;
+ if (timeoutMs !== undefined) {
+ timeoutId = setTimeout(() => {
+ this.abortController?.abort();
+ }, timeoutMs);
+ }
+ let response;
+ try {
+ response = await fetch(url, {
+ method,
+ headers,
+ body,
+ signal: this.abortController.signal
+ });
+ }
+ finally {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ }
let rawBody = "";
let parsedBody;
try {
@@ -405,4 +422,4 @@
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(ms, 0)));
-//# sourceMappingURL=RequestClient.js.map
\ No newline at end of file
+//# sourceMappingURL=RequestClient.js.map
+62 -62
View File
@@ -23,8 +23,8 @@ importers:
.:
dependencies:
'@buape/carbon':
specifier: 0.0.0-beta-20260109165250
version: 0.0.0-beta-20260109165250(hono@4.11.3)
specifier: 0.0.0-beta-20260109194934
version: 0.0.0-beta-20260109194934(hono@4.11.3)
'@clack/prompts':
specifier: ^0.11.0
version: 0.11.0
@@ -38,17 +38,17 @@ importers:
specifier: ^1.3.4
version: 1.3.4
'@mariozechner/pi-agent-core':
specifier: ^0.41.0
version: 0.41.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
specifier: ^0.42.1
version: 0.42.1(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai':
specifier: ^0.41.0
version: 0.41.0(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)
specifier: ^0.42.1
version: 0.42.1(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-coding-agent':
specifier: ^0.41.0
version: 0.41.0(patch_hash=58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e)(ws@8.19.0)(zod@4.3.5)
specifier: ^0.42.1
version: 0.42.1(patch_hash=58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui':
specifier: ^0.41.0
version: 0.41.0
specifier: ^0.42.1
version: 0.42.1
'@microsoft/agents-hosting':
specifier: ^1.1.1
version: 1.1.1
@@ -202,10 +202,10 @@ importers:
version: 0.6.3
oxlint:
specifier: ^1.38.0
version: 1.38.0(oxlint-tsgolint@0.10.1)
version: 1.38.0(oxlint-tsgolint@0.11.0)
oxlint-tsgolint:
specifier: ^0.10.1
version: 0.10.1
specifier: ^0.11.0
version: 0.11.0
quicktype-core:
specifier: ^23.2.6
version: 23.2.6
@@ -372,8 +372,8 @@ packages:
'@borewit/text-codec@0.2.1':
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
'@buape/carbon@0.0.0-beta-20260109165250':
resolution: {integrity: sha512-Xt8W0RYZxaprp2YxMU1rZRfGkHQWEJ2KZUurMwK/G4WVTfJSWAUeaeOGhpO40fCm/g0iPMuSLgQ4eudGOQHigw==}
'@buape/carbon@0.0.0-beta-20260109194934':
resolution: {integrity: sha512-rtyoOhtihd25k8ABHf+BTCOQSF3XjUMX+TPtS7UATC3dWVE8BXvmrLeX+s7q1LBh0LLRiz6w1YtGL145laoSJA==}
'@cacheable/memory@2.0.7':
resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==}
@@ -851,22 +851,22 @@ packages:
peerDependencies:
lit: ^3.3.1
'@mariozechner/pi-agent-core@0.41.0':
resolution: {integrity: sha512-eXmnMWCeRnSjvF5nbC8LbiOhdcSuUG/p+ZzfZqhfzkc5JMKGccGPnEHzXwfrVkJpyqL0rIWi9cG0yelVAat30A==}
'@mariozechner/pi-agent-core@0.42.1':
resolution: {integrity: sha512-sIRB1jHheQSGONoVorrQ3X9vc1JFLoAe+48k7UJmpLAhJXnQmRtq1+CtzvKOVe5qVRyNBNuNdXipH+seicbwvQ==}
engines: {node: '>=20.0.0'}
'@mariozechner/pi-ai@0.41.0':
resolution: {integrity: sha512-ZcI+lFMbf35kQvppHa4hy5tu34GiH5WYwWxPD7BHm7AiYxPcytdP+0NiaJdLIRGSLZqKklXDDejbb6/QvOwI3w==}
'@mariozechner/pi-ai@0.42.1':
resolution: {integrity: sha512-uRiH+s7EPDz9Q7hjQJ4Mm8lU4e4/C8pB4rnqGds73B5/0rqZb8DeSBukKU6uOwMNkXol/tYgnkEqXy7qEoHLJQ==}
engines: {node: '>=20.0.0'}
hasBin: true
'@mariozechner/pi-coding-agent@0.41.0':
resolution: {integrity: sha512-+x5tPGxjsT5d9u48xvTwayHW/v+w7L/zK1Oyyfhpu8qqSqkM5G5jeqK3tqQREG2YE+PwPSozmDtVzHYPrcNamA==}
'@mariozechner/pi-coding-agent@0.42.1':
resolution: {integrity: sha512-BsLgFBC//98EdQCCoNYPfxKi2TEJ64a9w/CQC8/DyeAVhjrdJELUd+3EoRV6iiodbpUspUASW9w3ELoqQGAtoA==}
engines: {node: '>=20.0.0'}
hasBin: true
'@mariozechner/pi-tui@0.41.0':
resolution: {integrity: sha512-FxhNyQfsQvZJBbUIPbtvBzF8yJo2JjEXVksn5cUU8Qphw8z1Uf+bRXeleH7Q7VVvGnaH9zJR3r2cfkaWxC1Jig==}
'@mariozechner/pi-tui@0.42.1':
resolution: {integrity: sha512-dsmzcOjD+9MNgqnJ6/BJ255Sc9KQWN4Asyh1ron7bMA4Q9lqSWl6q8dk78DBp2QcVeWHtvqbwdZwNnDwWyxw3g==}
engines: {node: '>=20.0.0'}
'@microsoft/agents-activity@1.1.1':
@@ -906,33 +906,33 @@ packages:
'@oxc-project/types@0.107.0':
resolution: {integrity: sha512-QFDRbYfV2LVx8tyqtyiah3jQPUj1mK2+RYwxyFWyGoys6XJnwTdlzO6rdNNHOPorHAu5Uo34oWRKcvNpbJarmQ==}
'@oxlint-tsgolint/darwin-arm64@0.10.1':
resolution: {integrity: sha512-KGC4++BeEqrIcmDHiJt/e6/860PWJmUJjjp0mE+smpBmRXMjmOFFjrPmN+ZyCyVgf1WdmhPkQXsRSPeTR+2omw==}
'@oxlint-tsgolint/darwin-arm64@0.11.0':
resolution: {integrity: sha512-F67T8dXgYIrgv6wpd52fKQFdmieSOHaxBkscgso64YdtEHrV3s52ASiZGNzw62TKihn9Ox9ek3PYx9XsxIJDUw==}
cpu: [arm64]
os: [darwin]
'@oxlint-tsgolint/darwin-x64@0.10.1':
resolution: {integrity: sha512-tvmrDgj3Q0tdc+zMWfCVLVq8EQDEUqasm1zaWgSMYIszpID6qdgqbT+OpWWXV9fLZgtvrkoXGwxkHAUJzdVZXQ==}
'@oxlint-tsgolint/darwin-x64@0.11.0':
resolution: {integrity: sha512-z44LO7+3z2mtcBxA9T66yEy/otp/2r5ypbkx7EYlPwbEqBAIDRt/8hqQ9/BUC//1qE549P1cBU6NjhgeyuXjYQ==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/linux-arm64@0.10.1':
resolution: {integrity: sha512-7kD28z6/ykGx8WetKTPRZt30pd+ziassxg/8cM24lhjUI+hNXyRHVtHes73dh9D6glJKno+1ut+3amUdZBZcpQ==}
'@oxlint-tsgolint/linux-arm64@0.11.0':
resolution: {integrity: sha512-IeIjmpPi2j2Dn1CRizGikysyLp9B0q3jqiAalv9ewRyb8hqQW5YeMlsswo8pHd0Hz3KyFfone0NkvBt77Ex2pg==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/linux-x64@0.10.1':
resolution: {integrity: sha512-NmJmiqdzYUTHIxteSTyX6IFFgnIsOAjRWXfrS6Jbo5xlB3g39WHniSF3asB/khLJNtwSg4InUS34NprYM7zrEw==}
'@oxlint-tsgolint/linux-x64@0.11.0':
resolution: {integrity: sha512-fpYGYU2pXjaXYnKgWrihFXE8zJiTRjYKSHAaBaVI056oqKjKGEoU2BfFbddpBrKgz9TmSOX/NGftrJnyMn1wXQ==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/win32-arm64@0.10.1':
resolution: {integrity: sha512-3KrT80vl3nXUkjuJI/z8dF6xWsKx0t9Tz4ZQHgQw3fYw+CoihBRWGklrdlmCz+EGfMyVaQLqBV9PZckhSqLe2A==}
'@oxlint-tsgolint/win32-arm64@0.11.0':
resolution: {integrity: sha512-37nzks9eqBt7NYE6okquu51vaqMruF5voX475L16Y8asJVCGpO/2VSy3ulYAXhZ+5Kdc8ZgrljVViJOjfPEPaA==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/win32-x64@0.10.1':
resolution: {integrity: sha512-hW1fSJZVxG51sLdGq1sQjOzb1tsQ23z/BquJfUwL7CqBobxr7TJvGmoINL+9KryOJt0jCoaiMfWe4yoYw5XfIA==}
'@oxlint-tsgolint/win32-x64@0.11.0':
resolution: {integrity: sha512-TsK4C61+mjmbkUJ3Q3E9Ev3VFbeI6prPEAm9FAOq8VsfUGEiIUBBjrZ8ysGoQXNiU3dCKpmu012ptVUZTk5/eg==}
cpu: [x64]
os: [win32]
@@ -2542,8 +2542,8 @@ packages:
resolution: {integrity: sha512-GJR9XnS8dQ+sAdbhX90RA4WbmEyrso7X9aHMws4MaQ2GRpfEjnOUSZIdOXJQfnIfBoy9oCc7US/MNFCyuJQzjg==}
engines: {node: '>=20'}
oxlint-tsgolint@0.10.1:
resolution: {integrity: sha512-EEHNdo5cW2w1xwYdBQ7d3IXDqWAtMkfVFrh+9gQ4kYbYJwygY4QXSh1eH80/xVipZdVKujAwBgg/nNNHk56kxQ==}
oxlint-tsgolint@0.11.0:
resolution: {integrity: sha512-fGYb7z/cljC0Rjtbxh7mIe8vtF/M9TShLvniwc2rdcqNG3Z9g3nM01cr2kWRb1DZdbY4/kItvIsrV4uhaMifyQ==}
hasBin: true
oxlint@1.38.0:
@@ -3374,7 +3374,7 @@ snapshots:
'@borewit/text-codec@0.2.1': {}
'@buape/carbon@0.0.0-beta-20260109165250(hono@4.11.3)':
'@buape/carbon@0.0.0-beta-20260109194934(hono@4.11.3)':
dependencies:
'@types/node': 24.10.4
discord-api-types: 0.38.36
@@ -3781,10 +3781,10 @@ snapshots:
transitivePeerDependencies:
- tailwindcss
'@mariozechner/pi-agent-core@0.41.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)':
'@mariozechner/pi-agent-core@0.42.1(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)':
dependencies:
'@mariozechner/pi-ai': 0.41.0(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.41.0
'@mariozechner/pi-ai': 0.42.1(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.42.1
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- bufferutil
@@ -3793,7 +3793,7 @@ snapshots:
- ws
- zod
'@mariozechner/pi-ai@0.41.0(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)':
'@mariozechner/pi-ai@0.42.1(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)':
dependencies:
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
'@google/genai': 1.34.0
@@ -3813,12 +3813,12 @@ snapshots:
- ws
- zod
'@mariozechner/pi-coding-agent@0.41.0(patch_hash=58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e)(ws@8.19.0)(zod@4.3.5)':
'@mariozechner/pi-coding-agent@0.42.1(patch_hash=58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e)(ws@8.19.0)(zod@4.3.5)':
dependencies:
'@mariozechner/clipboard': 0.3.0
'@mariozechner/pi-agent-core': 0.41.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai': 0.41.0(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.41.0
'@mariozechner/pi-agent-core': 0.42.1(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai': 0.42.1(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.42.1
chalk: 5.6.2
cli-highlight: 2.1.11
diff: 8.0.2
@@ -3837,7 +3837,7 @@ snapshots:
- ws
- zod
'@mariozechner/pi-tui@0.41.0':
'@mariozechner/pi-tui@0.42.1':
dependencies:
'@types/mime-types': 2.1.4
chalk: 5.6.2
@@ -3907,22 +3907,22 @@ snapshots:
'@oxc-project/types@0.107.0': {}
'@oxlint-tsgolint/darwin-arm64@0.10.1':
'@oxlint-tsgolint/darwin-arm64@0.11.0':
optional: true
'@oxlint-tsgolint/darwin-x64@0.10.1':
'@oxlint-tsgolint/darwin-x64@0.11.0':
optional: true
'@oxlint-tsgolint/linux-arm64@0.10.1':
'@oxlint-tsgolint/linux-arm64@0.11.0':
optional: true
'@oxlint-tsgolint/linux-x64@0.10.1':
'@oxlint-tsgolint/linux-x64@0.11.0':
optional: true
'@oxlint-tsgolint/win32-arm64@0.10.1':
'@oxlint-tsgolint/win32-arm64@0.11.0':
optional: true
'@oxlint-tsgolint/win32-x64@0.10.1':
'@oxlint-tsgolint/win32-x64@0.11.0':
optional: true
'@oxlint/darwin-arm64@1.38.0':
@@ -5572,16 +5572,16 @@ snapshots:
osc-progress@0.2.0: {}
oxlint-tsgolint@0.10.1:
oxlint-tsgolint@0.11.0:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.10.1
'@oxlint-tsgolint/darwin-x64': 0.10.1
'@oxlint-tsgolint/linux-arm64': 0.10.1
'@oxlint-tsgolint/linux-x64': 0.10.1
'@oxlint-tsgolint/win32-arm64': 0.10.1
'@oxlint-tsgolint/win32-x64': 0.10.1
'@oxlint-tsgolint/darwin-arm64': 0.11.0
'@oxlint-tsgolint/darwin-x64': 0.11.0
'@oxlint-tsgolint/linux-arm64': 0.11.0
'@oxlint-tsgolint/linux-x64': 0.11.0
'@oxlint-tsgolint/win32-arm64': 0.11.0
'@oxlint-tsgolint/win32-x64': 0.11.0
oxlint@1.38.0(oxlint-tsgolint@0.10.1):
oxlint@1.38.0(oxlint-tsgolint@0.11.0):
optionalDependencies:
'@oxlint/darwin-arm64': 1.38.0
'@oxlint/darwin-x64': 1.38.0
@@ -5591,7 +5591,7 @@ snapshots:
'@oxlint/linux-x64-musl': 1.38.0
'@oxlint/win32-arm64': 1.38.0
'@oxlint/win32-x64': 1.38.0
oxlint-tsgolint: 0.10.1
oxlint-tsgolint: 0.11.0
p-finally@1.0.0: {}
+53 -78
View File
@@ -12,6 +12,7 @@ docker run --rm -t "$IMAGE_NAME" bash -lc '
set -euo pipefail
trap "" PIPE
export TERM=xterm-256color
ONBOARD_FLAGS="--flow quickstart --auth-choice skip --skip-providers --skip-skills --skip-daemon --skip-ui"
# Provide a minimal trash shim to avoid noisy "missing trash" logs in containers.
export PATH="/tmp/clawdbot-bin:$PATH"
@@ -42,7 +43,7 @@ TRASH
}
start_gateway() {
node dist/index.js gateway --port 18789 --bind loopback > /tmp/gateway-e2e.log 2>&1 &
node dist/index.js gateway --port 18789 --bind loopback --allow-unconfigured > /tmp/gateway-e2e.log 2>&1 &
GATEWAY_PID="$!"
}
@@ -71,6 +72,7 @@ TRASH
local command="$3"
local send_fn="$4"
local with_gateway="${5:-false}"
local validate_fn="${6:-}"
echo "== Wizard case: $case_name =="
export HOME="$home_dir"
@@ -78,8 +80,9 @@ TRASH
input_fifo="$(mktemp -u "/tmp/clawdbot-onboard-${case_name}.XXXXXX")"
mkfifo "$input_fifo"
local log_path="/tmp/clawdbot-onboard-${case_name}.log"
# Run under script to keep an interactive TTY for clack prompts.
script -q -c "$command" /dev/null < "$input_fifo" &
script -q -c "$command" "$log_path" < "$input_fifo" &
wizard_pid=$!
exec 3> "$input_fifo"
@@ -96,15 +99,19 @@ TRASH
wait "$wizard_pid"
rm -f "$input_fifo"
stop_gateway "$gw_pid"
if [ -n "$validate_fn" ]; then
"$validate_fn" "$log_path"
fi
}
run_wizard() {
local case_name="$1"
local home_dir="$2"
local send_fn="$3"
local validate_fn="${4:-}"
# Default onboarding command wrapper.
run_wizard_cmd "$case_name" "$home_dir" "node dist/index.js onboard" "$send_fn" true
run_wizard_cmd "$case_name" "$home_dir" "node dist/index.js onboard $ONBOARD_FLAGS" "$send_fn" true "$validate_fn"
}
make_home() {
@@ -129,23 +136,7 @@ TRASH
send_local_basic() {
# Choose local gateway, accept defaults, skip provider/skills/daemon, skip UI.
send $'"'"'\r'"'"' 1.0
send $'"'"'\r'"'"' 1.0
send "" 1.2
send $'"'"'\e[B'"'"' 0.6
send $'"'"'\e[B'"'"' 0.6
send $'"'"'\e[B'"'"' 0.6
send $'"'"'\e[B'"'"' 0.6
send $'"'"'\e[B'"'"' 0.6
send $'"'"'\r'"'"' 0.6
send $'"'"'\r'"'"' 0.5
send $'"'"'\r'"'"' 0.5
send $'"'"'\r'"'"' 0.5
send $'"'"'\r'"'"' 0.5
send $'"'"'n\r'"'"' 0.5
send $'"'"'n\r'"'"' 0.5
send $'"'"'n\r'"'"' 0.5
send $'"'"'n\r'"'"' 0.5
}
send_reset_config_only() {
@@ -160,62 +151,31 @@ TRASH
send_providers_flow() {
# Configure providers via configure wizard.
send "" 0.6
send $'"'"'\r'"'"' 1.0
send "" 1.5
# Provider mode (default Configure providers)
send $'"'"'\r'"'"' 0.8
send "" 1.2
# Select Providers section only.
send $'"'"'\e[B'"'"' 0.5
send $'"'"'\e[B'"'"' 0.5
send $'"'"'\e[B'"'"' 0.5
send $'"'"'\e[B'"'"' 0.5
send $'"'"' '"'"' 0.4
send $'"'"'\r'"'"' 0.6
# Configure providers now? (default Yes)
send $'"'"'\r'"'"' 0.8
send "" 0.8
# Select Telegram, Discord, Slack.
send $'"'"'\e[B'"'"' 0.4
send $'"'"' '"'"' 0.4
send $'"'"'\e[B'"'"' 0.4
send $'"'"' '"'"' 0.4
send $'"'"'\e[B'"'"' 0.4
send $'"'"' '"'"' 0.4
send $'"'"'\r'"'"' 0.6
send $'"'"'tg_token\r'"'"' 0.8
send $'"'"'discord_token\r'"'"' 0.8
send "" 0.6
send $'"'"'\r'"'"' 0.6
send "" 0.6
send $'"'"'slack_bot\r'"'"' 0.8
send "" 0.6
send $'"'"'slack_app\r'"'"' 0.8
send "" 1.0
# Configure chat providers now? -> No
send $'"'"'n\r'"'"' 0.6
}
send_skills_flow() {
# Select skills section and skip optional installs.
send "" 0.6
send $'"'"'\r'"'"' 0.6
send "" 1.0
send $'"'"'\e[B'"'"' 0.4
send $'"'"'\e[B'"'"' 0.4
send $'"'"'\e[B'"'"' 0.4
send $'"'"'\e[B'"'"' 0.4
send $'"'"'\e[B'"'"' 0.4
send $'"'"' '"'"' 0.3
send $'"'"'\r'"'"' 0.4
send $'"'"'n\r'"'"' 0.4
send $'"'"'n\r'"'"' 0.4
send $'"'"'\r'"'"' 1.0
send "" 1.2
send $'"'"'n\r'"'"' 0.6
}
run_case_local_basic() {
local home_dir
home_dir="$(make_home local-basic)"
run_wizard local-basic "$home_dir" send_local_basic
run_wizard local-basic "$home_dir" send_local_basic validate_local_basic_log
# Assert config + workspace scaffolding.
workspace_dir="$HOME/clawd"
config_path="$HOME/.clawdbot/clawdbot.json"
sessions_dir="$HOME/.clawdbot/sessions"
sessions_dir="$HOME/.clawdbot/agents/main/sessions"
assert_file "$config_path"
assert_dir "$sessions_dir"
@@ -383,7 +343,7 @@ NODE
local home_dir
home_dir="$(make_home providers)"
# Providers-only configure flow.
run_wizard_cmd providers "$home_dir" "node dist/index.js configure" send_providers_flow
run_wizard_cmd providers "$home_dir" "node dist/index.js configure --section providers" send_providers_flow
config_path="$HOME/.clawdbot/clawdbot.json"
assert_file "$config_path"
@@ -395,21 +355,22 @@ import JSON5 from "json5";
const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8"));
const errors = [];
if (cfg?.telegram?.botToken !== "tg_token") {
errors.push(`telegram.botToken mismatch (got ${cfg?.telegram?.botToken ?? "unset"})`);
}
if (cfg?.discord?.token !== "discord_token") {
errors.push(`discord.token mismatch (got ${cfg?.discord?.token ?? "unset"})`);
}
if (cfg?.slack?.botToken !== "slack_bot") {
errors.push(`slack.botToken mismatch (got ${cfg?.slack?.botToken ?? "unset"})`);
}
if (cfg?.slack?.appToken !== "slack_app") {
errors.push(`slack.appToken mismatch (got ${cfg?.slack?.appToken ?? "unset"})`);
}
if (cfg?.wizard?.lastRunMode !== "local") {
errors.push(`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`);
}
if (cfg?.telegram?.botToken) {
errors.push(`telegram.botToken should be unset (got ${cfg?.telegram?.botToken})`);
}
if (cfg?.discord?.token) {
errors.push(`discord.token should be unset (got ${cfg?.discord?.token})`);
}
if (cfg?.slack?.botToken || cfg?.slack?.appToken) {
errors.push(
`slack tokens should be unset (got bot=${cfg?.slack?.botToken ?? "unset"}, app=${cfg?.slack?.appToken ?? "unset"})`,
);
}
if (cfg?.wizard?.lastRunCommand !== "configure") {
errors.push(
`wizard.lastRunCommand mismatch (got ${cfg?.wizard?.lastRunCommand ?? "unset"})`,
);
}
if (errors.length > 0) {
console.error(errors.join("\n"));
@@ -433,7 +394,7 @@ NODE
}
JSON
run_wizard_cmd skills "$home_dir" "node dist/index.js configure" send_skills_flow
run_wizard_cmd skills "$home_dir" "node dist/index.js configure --section skills" send_skills_flow
config_path="$HOME/.clawdbot/clawdbot.json"
assert_file "$config_path"
@@ -462,6 +423,20 @@ if (errors.length > 0) {
NODE
}
assert_log_not_contains() {
local file_path="$1"
local needle="$2"
if grep -q "$needle" "$file_path"; then
echo "Unexpected log output: $needle"
exit 1
fi
}
validate_local_basic_log() {
local log_path="$1"
assert_log_not_contains "$log_path" "systemctl --user unavailable"
}
run_case_local_basic
run_case_remote_non_interactive
run_case_reset
+2 -2
View File
@@ -272,8 +272,8 @@ if [[ "${SKIP_GATEWAY_PACKAGE:-0}" != "1" ]]; then
fi
rm -rf "$RELAY_BUILD_DIR"
echo "🧪 Smoke testing bundled relay QR modules"
"$RELAY_OUT" --smoke qr >/dev/null
echo "🧪 Verifying bundled relay (version)"
"$RELAY_OUT" --version >/dev/null
echo "🎨 Copying gateway A2UI host assets"
rm -rf "$RELAY_DIR/a2ui"
-39
View File
@@ -1,39 +0,0 @@
# Showcase: what your personal assistant can do
Highlights from #showcase (Jan 2–5, 2026). Curated for “wow” factor + concrete links.
## Clawdhub projects (formerly Clawdis)
- **xuezh** — Chinese learning engine + Clawdbot skill for pronunciation feedback and study flows. <span class="showcase-link"><a href="https://github.com/joshp123/xuezh">github.com/joshp123/xuezh</a><span class="showcase-preview"><img src="/assets/showcase/xuezh-pronunciation.jpeg" alt="xuezh pronunciation feedback in Clawdbot" loading="lazy" decoding="async" /></span></span>
- **gohome** — Nix-native home automation with Clawdbot as the interface, plus Grafana dashboards. <span class="showcase-link"><a href="https://github.com/joshp123/gohome">github.com/joshp123/gohome</a><span class="showcase-preview"><img src="/assets/showcase/gohome-grafana.png" alt="GoHome Grafana dashboard" loading="lazy" decoding="async" /></span></span>
- **Roborock skill for GoHome** — Vacuum control plugin with gRPC actions + metrics. <span class="showcase-link"><a href="https://github.com/joshp123/gohome/tree/main/plugins/roborock">github.com/joshp123/gohome/tree/main/plugins/roborock</a><span class="showcase-preview"><img src="/assets/showcase/roborock-screenshot.jpg" alt="GoHome Roborock status screenshot" loading="lazy" decoding="async" /></span></span>
- **padel-cli** — Playtomic availability + booking CLI with a Clawdbot plugin output. <span class="showcase-link"><a href="https://github.com/joshp123/padel-cli">github.com/joshp123/padel-cli</a><span class="showcase-preview"><img src="/assets/showcase/padel-screenshot.jpg" alt="padel-cli availability screenshot" loading="lazy" decoding="async" /></span></span>
## Automation & real-world outcomes
- **Grocery autopilot (Picnic)** — Skill built around an unofficial Picnic API client. Pulls order history, infers preferred brands, maps recipes to cart, completes order in minutes. https://github.com/timkrase/clawdis-picnic-skill
- **Grocery autopilot (Picnic, alt)** — Another Picnic-based skill built via the `picnic-api` package. https://github.com/MRVDH/picnic-api
- **German rail planning** — Go CLI for Deutsche Bahn; skill picks best connections given time windows and preferences. https://github.com/timkrase/dbrest-cli + https://github.com/timkrase/clawdis-skills/tree/main/db-bahn (link check pending)
- **Accounting intake** — Collect PDFs from email, prep for tax consultant (monthly accounting batch). (No link shared.)
## Knowledge & memory systems
- **WhatsApp memory vault** — Ingests full exports, transcribes 1k+ voice notes, cross‑checks with git logs, outputs linked MD reports + ongoing indexing. (No link shared.)
- **Karakeep semantic search** — Sidecar adds vector search to Karakeep bookmarks (Qdrant + OpenAI/Ollama), includes Clawdis skill. https://github.com/jamesbrooksco/karakeep-semantic-search
- **Inside‑Out‑2 style memory** — Separate memory manager app turns session files into memories → beliefs → self model. (No link shared.)
## Voice, docs, and assistants on the phone
- **Clawdia phone bridge** — Vapi voice assistant ↔ Clawdis HTTP bridge; near‑real‑time phone calls. https://github.com/alejandroOPI/clawdia-bridge
- **Google Docs edit skill** — Rich‑text editing skill built fast with Claude Code. (No link shared.)
- **OpenRouter transcription skill** — Multi‑lingual audio transcription via OpenRouter (Gemini etc). ClawdHub: https://clawdhub.com/obviyus/openrouter-transcribe (user/slug link)
## Infrastructure & deployment
- **Home Assistant OS gateway add‑on** — Clawdbot gateway running on HA OS (Raspberry Pi), with SSH tunnel support + persistent state in /config. https://github.com/ngutman/clawdbot-ha-addon
- **Home Assistant skill** — Control/automate HA via ClawdHub. https://clawdhub.com/skills/homeassistant
- **Nix packaging** — Batteries‑included nixified clawdis config. https://github.com/joshp123/nix-clawdis
- **CalDAV skill** — khal/vdirsyncer based calendar skill. ClawdHub: caldav-calendar → https://clawdhub.com/skills/caldav-calendar
## Home + hardware
- **Roborock integration** — Plugin for robot vacuum control. https://github.com/joshp123/gohome/tree/main/plugins/roborock
## Community builds (non‑Clawdis but made with/around it)
- **StarSwap marketplace** — Full astronomy gear marketplace. https://star-swap.com/
---
+8
View File
@@ -85,6 +85,10 @@ describe("resolveAgentConfig", () => {
tools: {
allow: ["read"],
deny: ["bash", "write", "edit"],
elevated: {
enabled: false,
allowFrom: { whatsapp: ["+15555550123"] },
},
},
},
],
@@ -94,6 +98,10 @@ describe("resolveAgentConfig", () => {
expect(result?.tools).toEqual({
allow: ["read"],
deny: ["bash", "write", "edit"],
elevated: {
enabled: false,
allowFrom: { whatsapp: ["+15555550123"] },
},
});
});
+176
View File
@@ -11,6 +11,7 @@ import {
CODEX_CLI_PROFILE_ID,
calculateAuthProfileCooldownMs,
ensureAuthProfileStore,
markAuthProfileFailure,
resolveAuthProfileOrder,
} from "./auth-profiles.js";
@@ -151,6 +152,49 @@ describe("resolveAuthProfileOrder", () => {
expect(order).toEqual(["anthropic:work", "anthropic:default"]);
});
it("pushes disabled profiles to the end even with store order", () => {
const now = Date.now();
const order = resolveAuthProfileOrder({
store: {
...store,
order: { anthropic: ["anthropic:default", "anthropic:work"] },
usageStats: {
"anthropic:default": {
disabledUntil: now + 60_000,
disabledReason: "billing",
},
"anthropic:work": { lastUsed: 1 },
},
},
provider: "anthropic",
});
expect(order).toEqual(["anthropic:work", "anthropic:default"]);
});
it("pushes disabled profiles to the end even with configured order", () => {
const now = Date.now();
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: { anthropic: ["anthropic:default", "anthropic:work"] },
profiles: cfg.auth.profiles,
},
},
store: {
...store,
usageStats: {
"anthropic:default": {
disabledUntil: now + 60_000,
disabledReason: "billing",
},
"anthropic:work": { lastUsed: 1 },
},
},
provider: "anthropic",
});
expect(order).toEqual(["anthropic:work", "anthropic:default"]);
});
it("normalizes z.ai aliases in auth.order", () => {
const order = resolveAuthProfileOrder({
cfg: {
@@ -397,6 +441,138 @@ describe("auth profile cooldowns", () => {
});
});
describe("markAuthProfileFailure", () => {
it("disables billing failures for ~5 hours by default", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-"));
try {
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
const startedAt = Date.now();
await markAuthProfileFailure({
store,
profileId: "anthropic:default",
reason: "billing",
agentDir,
});
const disabledUntil =
store.usageStats?.["anthropic:default"]?.disabledUntil;
expect(typeof disabledUntil).toBe("number");
const remainingMs = (disabledUntil as number) - startedAt;
expect(remainingMs).toBeGreaterThan(4.5 * 60 * 60 * 1000);
expect(remainingMs).toBeLessThan(5.5 * 60 * 60 * 1000);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("honors per-provider billing backoff overrides", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-"));
try {
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
const startedAt = Date.now();
await markAuthProfileFailure({
store,
profileId: "anthropic:default",
reason: "billing",
agentDir,
cfg: {
auth: {
cooldowns: {
billingBackoffHoursByProvider: { Anthropic: 1 },
billingMaxHours: 2,
},
},
} as never,
});
const disabledUntil =
store.usageStats?.["anthropic:default"]?.disabledUntil;
expect(typeof disabledUntil).toBe("number");
const remainingMs = (disabledUntil as number) - startedAt;
expect(remainingMs).toBeGreaterThan(0.8 * 60 * 60 * 1000);
expect(remainingMs).toBeLessThan(1.2 * 60 * 60 * 1000);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("resets backoff counters outside the failure window", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-"));
try {
const authPath = path.join(agentDir, "auth-profiles.json");
const now = Date.now();
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
},
usageStats: {
"anthropic:default": {
errorCount: 9,
failureCounts: { billing: 3 },
lastFailureAt: now - 48 * 60 * 60 * 1000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
await markAuthProfileFailure({
store,
profileId: "anthropic:default",
reason: "billing",
agentDir,
cfg: {
auth: { cooldowns: { failureWindowHours: 24 } },
} as never,
});
expect(store.usageStats?.["anthropic:default"]?.errorCount).toBe(1);
expect(
store.usageStats?.["anthropic:default"]?.failureCounts?.billing,
).toBe(1);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
});
describe("external CLI credential sync", () => {
it("syncs Claude CLI credentials into anthropic:claude-cli", async () => {
const agentDir = fs.mkdtempSync(
+200 -28
View File
@@ -72,11 +72,22 @@ export type AuthProfileCredential =
| TokenCredential
| OAuthCredential;
export type AuthProfileFailureReason =
| "auth"
| "rate_limit"
| "billing"
| "timeout"
| "unknown";
/** Per-profile usage statistics for round-robin and cooldown tracking */
export type ProfileUsageStats = {
lastUsed?: number;
cooldownUntil?: number;
disabledUntil?: number;
disabledReason?: AuthProfileFailureReason;
errorCount?: number;
failureCounts?: Partial<Record<AuthProfileFailureReason, number>>;
lastFailureAt?: number;
};
export type AuthProfileStore = {
@@ -772,8 +783,9 @@ export function isProfileInCooldown(
profileId: string,
): boolean {
const stats = store.usageStats?.[profileId];
if (!stats?.cooldownUntil) return false;
return Date.now() < stats.cooldownUntil;
if (!stats) return false;
const unusableUntil = resolveProfileUnusableUntil(stats);
return unusableUntil ? Date.now() < unusableUntil : false;
}
/**
@@ -796,6 +808,9 @@ export async function markAuthProfileUsed(params: {
lastUsed: Date.now(),
errorCount: 0,
cooldownUntil: undefined,
disabledUntil: undefined,
disabledReason: undefined,
failureCounts: undefined,
};
return true;
},
@@ -812,6 +827,9 @@ export async function markAuthProfileUsed(params: {
lastUsed: Date.now(),
errorCount: 0,
cooldownUntil: undefined,
disabledUntil: undefined,
disabledReason: undefined,
failureCounts: undefined,
};
saveAuthProfileStore(store, agentDir);
}
@@ -824,34 +842,163 @@ export function calculateAuthProfileCooldownMs(errorCount: number): number {
);
}
type ResolvedAuthCooldownConfig = {
billingBackoffMs: number;
billingMaxMs: number;
failureWindowMs: number;
};
function resolveAuthCooldownConfig(params: {
cfg?: ClawdbotConfig;
providerId: string;
}): ResolvedAuthCooldownConfig {
const defaults = {
billingBackoffHours: 5,
billingMaxHours: 24,
failureWindowHours: 24,
} as const;
const resolveHours = (value: unknown, fallback: number) =>
typeof value === "number" && Number.isFinite(value) && value > 0
? value
: fallback;
const cooldowns = params.cfg?.auth?.cooldowns;
const billingOverride = (() => {
const map = cooldowns?.billingBackoffHoursByProvider;
if (!map) return undefined;
for (const [key, value] of Object.entries(map)) {
if (normalizeProviderId(key) === params.providerId) return value;
}
return undefined;
})();
const billingBackoffHours = resolveHours(
billingOverride ?? cooldowns?.billingBackoffHours,
defaults.billingBackoffHours,
);
const billingMaxHours = resolveHours(
cooldowns?.billingMaxHours,
defaults.billingMaxHours,
);
const failureWindowHours = resolveHours(
cooldowns?.failureWindowHours,
defaults.failureWindowHours,
);
return {
billingBackoffMs: billingBackoffHours * 60 * 60 * 1000,
billingMaxMs: billingMaxHours * 60 * 60 * 1000,
failureWindowMs: failureWindowHours * 60 * 60 * 1000,
};
}
function calculateAuthProfileBillingDisableMsWithConfig(params: {
errorCount: number;
baseMs: number;
maxMs: number;
}): number {
const normalized = Math.max(1, params.errorCount);
const baseMs = Math.max(60_000, params.baseMs);
const maxMs = Math.max(baseMs, params.maxMs);
const exponent = Math.min(normalized - 1, 10);
const raw = baseMs * 2 ** exponent;
return Math.min(maxMs, raw);
}
function resolveProfileUnusableUntil(stats: ProfileUsageStats): number | null {
const values = [stats.cooldownUntil, stats.disabledUntil]
.filter((value): value is number => typeof value === "number")
.filter((value) => Number.isFinite(value) && value > 0);
if (values.length === 0) return null;
return Math.max(...values);
}
export function resolveProfileUnusableUntilForDisplay(
store: AuthProfileStore,
profileId: string,
): number | null {
const stats = store.usageStats?.[profileId];
if (!stats) return null;
return resolveProfileUnusableUntil(stats);
}
function computeNextProfileUsageStats(params: {
existing: ProfileUsageStats;
now: number;
reason: AuthProfileFailureReason;
cfgResolved: ResolvedAuthCooldownConfig;
}): ProfileUsageStats {
const windowMs = params.cfgResolved.failureWindowMs;
const windowExpired =
typeof params.existing.lastFailureAt === "number" &&
params.existing.lastFailureAt > 0 &&
params.now - params.existing.lastFailureAt > windowMs;
const baseErrorCount = windowExpired ? 0 : (params.existing.errorCount ?? 0);
const nextErrorCount = baseErrorCount + 1;
const failureCounts = windowExpired
? {}
: { ...params.existing.failureCounts };
failureCounts[params.reason] = (failureCounts[params.reason] ?? 0) + 1;
const updatedStats: ProfileUsageStats = {
...params.existing,
errorCount: nextErrorCount,
failureCounts,
lastFailureAt: params.now,
};
if (params.reason === "billing") {
const billingCount = failureCounts.billing ?? 1;
const backoffMs = calculateAuthProfileBillingDisableMsWithConfig({
errorCount: billingCount,
baseMs: params.cfgResolved.billingBackoffMs,
maxMs: params.cfgResolved.billingMaxMs,
});
updatedStats.disabledUntil = params.now + backoffMs;
updatedStats.disabledReason = "billing";
} else {
const backoffMs = calculateAuthProfileCooldownMs(nextErrorCount);
updatedStats.cooldownUntil = params.now + backoffMs;
}
return updatedStats;
}
/**
* Mark a profile as failed/rate-limited. Applies exponential backoff cooldown.
* Cooldown times: 1min, 5min, 25min, max 1 hour.
* Uses store lock to avoid overwriting concurrent usage updates.
* Mark a profile as failed for a specific reason. Billing failures are treated
* as "disabled" (longer backoff) vs the regular cooldown window.
*/
export async function markAuthProfileCooldown(params: {
export async function markAuthProfileFailure(params: {
store: AuthProfileStore;
profileId: string;
reason: AuthProfileFailureReason;
cfg?: ClawdbotConfig;
agentDir?: string;
}): Promise<void> {
const { store, profileId, agentDir } = params;
const { store, profileId, reason, agentDir, cfg } = params;
const updated = await updateAuthProfileStoreWithLock({
agentDir,
updater: (freshStore) => {
if (!freshStore.profiles[profileId]) return false;
const profile = freshStore.profiles[profileId];
if (!profile) return false;
freshStore.usageStats = freshStore.usageStats ?? {};
const existing = freshStore.usageStats[profileId] ?? {};
const errorCount = (existing.errorCount ?? 0) + 1;
// Exponential backoff: 1min, 5min, 25min, capped at 1h
const backoffMs = calculateAuthProfileCooldownMs(errorCount);
const now = Date.now();
const providerKey = normalizeProviderId(profile.provider);
const cfgResolved = resolveAuthCooldownConfig({
cfg,
providerId: providerKey,
});
freshStore.usageStats[profileId] = {
...existing,
errorCount,
cooldownUntil: Date.now() + backoffMs,
};
freshStore.usageStats[profileId] = computeNextProfileUsageStats({
existing,
now,
reason,
cfgResolved,
});
return true;
},
});
@@ -863,19 +1010,42 @@ export async function markAuthProfileCooldown(params: {
store.usageStats = store.usageStats ?? {};
const existing = store.usageStats[profileId] ?? {};
const errorCount = (existing.errorCount ?? 0) + 1;
const now = Date.now();
const providerKey = normalizeProviderId(
store.profiles[profileId]?.provider ?? "",
);
const cfgResolved = resolveAuthCooldownConfig({
cfg,
providerId: providerKey,
});
// Exponential backoff: 1min, 5min, 25min, capped at 1h
const backoffMs = calculateAuthProfileCooldownMs(errorCount);
store.usageStats[profileId] = {
...existing,
errorCount,
cooldownUntil: Date.now() + backoffMs,
};
store.usageStats[profileId] = computeNextProfileUsageStats({
existing,
now,
reason,
cfgResolved,
});
saveAuthProfileStore(store, agentDir);
}
/**
* Mark a profile as failed/rate-limited. Applies exponential backoff cooldown.
* Cooldown times: 1min, 5min, 25min, max 1 hour.
* Uses store lock to avoid overwriting concurrent usage updates.
*/
export async function markAuthProfileCooldown(params: {
store: AuthProfileStore;
profileId: string;
agentDir?: string;
}): Promise<void> {
await markAuthProfileFailure({
store: params.store,
profileId: params.profileId,
reason: "unknown",
agentDir: params.agentDir,
});
}
/**
* Clear cooldown for a profile (e.g., manual reset).
* Uses store lock to avoid overwriting concurrent usage updates.
@@ -973,7 +1143,8 @@ export function resolveAuthProfileOrder(params: {
const inCooldown: Array<{ profileId: string; cooldownUntil: number }> = [];
for (const profileId of deduped) {
const cooldownUntil = store.usageStats?.[profileId]?.cooldownUntil;
const cooldownUntil =
resolveProfileUnusableUntil(store.usageStats?.[profileId] ?? {}) ?? 0;
if (
typeof cooldownUntil === "number" &&
Number.isFinite(cooldownUntil) &&
@@ -1057,7 +1228,8 @@ function orderProfilesByMode(
const cooldownSorted = inCooldown
.map((profileId) => ({
profileId,
cooldownUntil: store.usageStats?.[profileId]?.cooldownUntil ?? now,
cooldownUntil:
resolveProfileUnusableUntil(store.usageStats?.[profileId] ?? {}) ?? now,
}))
.sort((a, b) => a.cooldownUntil - b.cooldownUntil)
.map((entry) => entry.profileId);
+11 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resetProcessRegistryForTests } from "./bash-process-registry.js";
import {
bashTool,
@@ -50,6 +50,16 @@ beforeEach(() => {
});
describe("bash tool backgrounding", () => {
const originalShell = process.env.SHELL;
beforeEach(() => {
if (!isWin) process.env.SHELL = "/bin/bash";
});
afterEach(() => {
if (!isWin) process.env.SHELL = originalShell;
});
it(
"backgrounds after yield and can be polled",
async () => {
+21 -2
View File
@@ -9,13 +9,19 @@ import { shouldLogVerbose } from "../globals.js";
import { createSubsystemLogger } from "../logging.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
import { FailoverError, resolveFailoverStatus } from "./failover-error.js";
import {
buildBootstrapContextFiles,
classifyFailoverReason,
type EmbeddedContextFile,
isFailoverErrorMessage,
} from "./pi-embedded-helpers.js";
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
import { buildAgentSystemPrompt } from "./system-prompt.js";
import { loadWorkspaceBootstrapFiles } from "./workspace.js";
import {
filterBootstrapFilesForSession,
loadWorkspaceBootstrapFiles,
} from "./workspace.js";
const log = createSubsystemLogger("agent/claude-cli");
const CLAUDE_CLI_QUEUE_KEY = "global";
@@ -310,6 +316,16 @@ async function runClaudeCliOnce(params: {
}
if (result.code !== 0) {
const err = result.stderr.trim() || stdout || "Claude CLI failed.";
if (isFailoverErrorMessage(err)) {
const reason = classifyFailoverReason(err) ?? "unknown";
const status = resolveFailoverStatus(reason);
throw new FailoverError(err, {
reason,
provider: "claude-cli",
model: params.modelId,
status,
});
}
throw new Error(err);
}
const parsed = parseClaudeCliJson(stdout);
@@ -353,7 +369,10 @@ export async function runClaudeCliAgent(params: {
.filter(Boolean)
.join("\n");
const bootstrapFiles = await loadWorkspaceBootstrapFiles(workspaceDir);
const bootstrapFiles = filterBootstrapFilesForSession(
await loadWorkspaceBootstrapFiles(workspaceDir),
params.sessionKey ?? params.sessionId,
);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const systemPrompt = buildSystemPrompt({
workspaceDir,
@@ -0,0 +1,146 @@
import { describe, expect, it, vi } from "vitest";
const loadSessionStoreMock = vi.fn();
const saveSessionStoreMock = vi.fn();
vi.mock("../config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/sessions.js")>();
return {
...actual,
loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath),
saveSessionStore: (storePath: string, store: Record<string, unknown>) =>
saveSessionStoreMock(storePath, store),
resolveStorePath: () => "/tmp/sessions.json",
};
});
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({
session: { mainKey: "main", scope: "per-sender" },
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
models: {},
},
},
}),
};
});
vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog: async () => [
{
provider: "anthropic",
id: "claude-opus-4-5",
name: "Opus",
contextWindow: 200000,
},
{
provider: "anthropic",
id: "claude-sonnet-4-5",
name: "Sonnet",
contextWindow: 200000,
},
],
}));
vi.mock("../agents/auth-profiles.js", () => ({
ensureAuthProfileStore: () => ({ profiles: {} }),
resolveAuthProfileDisplayLabel: () => undefined,
resolveAuthProfileOrder: () => [],
}));
vi.mock("../agents/model-auth.js", () => ({
resolveEnvApiKey: () => null,
getCustomProviderApiKey: () => null,
resolveModelAuthMode: () => "api-key",
}));
vi.mock("../infra/provider-usage.js", () => ({
resolveUsageProviderId: () => undefined,
loadProviderUsageSummary: async () => ({
updatedAt: Date.now(),
providers: [],
}),
formatUsageSummaryLine: () => null,
}));
import { createClawdbotTools } from "./clawdbot-tools.js";
describe("session_status tool", () => {
it("returns a status card for the current session", async () => {
loadSessionStoreMock.mockReset();
saveSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({
main: {
sessionId: "s1",
updatedAt: 10,
},
});
const tool = createClawdbotTools({ agentSessionKey: "main" }).find(
(candidate) => candidate.name === "session_status",
);
expect(tool).toBeDefined();
if (!tool) throw new Error("missing session_status tool");
const result = await tool.execute("call1", {});
const details = result.details as { ok?: boolean; statusText?: string };
expect(details.ok).toBe(true);
expect(details.statusText).toContain("ClawdBot");
expect(details.statusText).toContain("🧠 Model:");
});
it("errors for unknown session keys", async () => {
loadSessionStoreMock.mockReset();
saveSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({
main: { sessionId: "s1", updatedAt: 10 },
});
const tool = createClawdbotTools({ agentSessionKey: "main" }).find(
(candidate) => candidate.name === "session_status",
);
expect(tool).toBeDefined();
if (!tool) throw new Error("missing session_status tool");
await expect(tool.execute("call2", { sessionKey: "nope" })).rejects.toThrow(
"Unknown sessionKey",
);
expect(saveSessionStoreMock).not.toHaveBeenCalled();
});
it("resets per-session model override via model=default", async () => {
loadSessionStoreMock.mockReset();
saveSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({
main: {
sessionId: "s1",
updatedAt: 10,
providerOverride: "anthropic",
modelOverride: "claude-sonnet-4-5",
authProfileOverride: "p1",
},
});
const tool = createClawdbotTools({ agentSessionKey: "main" }).find(
(candidate) => candidate.name === "session_status",
);
expect(tool).toBeDefined();
if (!tool) throw new Error("missing session_status tool");
await tool.execute("call3", { model: "default" });
expect(saveSessionStoreMock).toHaveBeenCalled();
const [, savedStore] = saveSessionStoreMock.mock.calls.at(-1) as [
string,
Record<string, unknown>,
];
const saved = savedStore.main as Record<string, unknown>;
expect(saved.providerOverride).toBeUndefined();
expect(saved.modelOverride).toBeUndefined();
expect(saved.authProfileOverride).toBeUndefined();
});
});
+56 -2
View File
@@ -23,6 +23,43 @@ vi.mock("../config/config.js", async (importOriginal) => {
import { createClawdbotTools } from "./clawdbot-tools.js";
describe("sessions tools", () => {
it("uses number (not integer) in tool schemas for Gemini compatibility", () => {
const tools = createClawdbotTools();
const byName = (name: string) => {
const tool = tools.find((candidate) => candidate.name === name);
expect(tool).toBeDefined();
if (!tool) throw new Error(`missing ${name} tool`);
return tool;
};
const schemaProp = (toolName: string, prop: string) => {
const tool = byName(toolName);
const schema = tool.parameters as {
anyOf?: unknown;
oneOf?: unknown;
properties?: Record<string, unknown>;
};
expect(schema.anyOf).toBeUndefined();
expect(schema.oneOf).toBeUndefined();
const properties = schema.properties ?? {};
const value = properties[prop] as { type?: unknown } | undefined;
expect(value).toBeDefined();
if (!value) throw new Error(`missing ${toolName} schema prop: ${prop}`);
return value;
};
expect(schemaProp("sessions_history", "limit").type).toBe("number");
expect(schemaProp("sessions_list", "limit").type).toBe("number");
expect(schemaProp("sessions_list", "activeMinutes").type).toBe("number");
expect(schemaProp("sessions_list", "messageLimit").type).toBe("number");
expect(schemaProp("sessions_send", "timeoutSeconds").type).toBe("number");
expect(schemaProp("sessions_spawn", "runTimeoutSeconds").type).toBe(
"number",
);
expect(schemaProp("sessions_spawn", "timeoutSeconds").type).toBe("number");
});
it("sessions_list filters kinds and includes messages", async () => {
callGatewayMock.mockReset();
callGatewayMock.mockImplementation(async (opts: unknown) => {
@@ -206,7 +243,11 @@ describe("sessions tools", () => {
message: "ping",
timeoutSeconds: 0,
});
expect(fire.details).toMatchObject({ status: "accepted", runId: "run-1" });
expect(fire.details).toMatchObject({
status: "accepted",
runId: "run-1",
delivery: { status: "pending", mode: "announce" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -219,6 +260,7 @@ describe("sessions tools", () => {
expect(waited.details).toMatchObject({
status: "ok",
reply: "done",
delivery: { status: "pending", mode: "announce" },
});
expect(typeof (waited.details as { runId?: string }).runId).toBe("string");
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -231,7 +273,10 @@ describe("sessions tools", () => {
);
expect(agentCalls).toHaveLength(8);
for (const call of agentCalls) {
expect(call.params).toMatchObject({ lane: "nested" });
expect(call.params).toMatchObject({
lane: "nested",
provider: "webchat",
});
}
expect(
agentCalls.some(
@@ -357,6 +402,15 @@ describe("sessions tools", () => {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const agentCalls = calls.filter((call) => call.method === "agent");
expect(agentCalls).toHaveLength(4);
for (const call of agentCalls) {
expect(call.params).toMatchObject({
lane: "nested",
provider: "webchat",
});
}
const replySteps = calls.filter(
(call) =>
call.method === "agent" &&
+15 -1
View File
@@ -57,6 +57,7 @@ describe("subagents", () => {
const params = request.params as {
message?: string;
sessionKey?: string;
provider?: string;
timeout?: number;
};
const message = params?.message ?? "";
@@ -67,6 +68,7 @@ describe("subagents", () => {
childRunId = runId;
childSessionKey = sessionKey;
sessionLastAssistantText.set(sessionKey, "result");
expect(params?.provider).toBe("discord");
expect(params?.timeout).toBe(1);
}
return {
@@ -155,12 +157,24 @@ describe("subagents", () => {
const agentCalls = calls.filter((call) => call.method === "agent");
expect(agentCalls).toHaveLength(2);
const first = agentCalls[0]?.params as
| { lane?: string; deliver?: boolean; sessionKey?: string }
| {
lane?: string;
deliver?: boolean;
sessionKey?: string;
provider?: string;
}
| undefined;
expect(first?.lane).toBe("subagent");
expect(first?.deliver).toBe(false);
expect(first?.provider).toBe("discord");
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
const second = agentCalls[1]?.params as
| { provider?: string; deliver?: boolean; lane?: string }
| undefined;
expect(second?.lane).toBe("nested");
expect(second?.deliver).toBe(false);
expect(second?.provider).toBe("webchat");
expect(sendParams.provider).toBe("discord");
expect(sendParams.to).toBe("channel:req");
+19 -1
View File
@@ -1,4 +1,5 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { GatewayMessageProvider } from "../utils/message-provider.js";
import { createAgentsListTool } from "./tools/agents-list-tool.js";
import { createBrowserTool } from "./tools/browser-tool.js";
import { createCanvasTool } from "./tools/canvas-tool.js";
@@ -8,6 +9,7 @@ import { createGatewayTool } from "./tools/gateway-tool.js";
import { createImageTool } from "./tools/image-tool.js";
import { createMessageTool } from "./tools/message-tool.js";
import { createNodesTool } from "./tools/nodes-tool.js";
import { createSessionStatusTool } from "./tools/session-status-tool.js";
import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
@@ -16,11 +18,19 @@ import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
export function createClawdbotTools(options?: {
browserControlUrl?: string;
agentSessionKey?: string;
agentProvider?: string;
agentProvider?: GatewayMessageProvider;
agentAccountId?: string;
agentDir?: string;
sandboxed?: boolean;
config?: ClawdbotConfig;
/** Current channel ID for auto-threading (Slack). */
currentChannelId?: string;
/** Current thread timestamp for auto-threading (Slack). */
currentThreadTs?: string;
/** Reply-to mode for Slack auto-threading. */
replyToMode?: "off" | "first" | "all";
/** Mutable ref to track if a reply was sent (for "first" mode). */
hasRepliedRef?: { value: boolean };
}): AnyAgentTool[] {
const imageTool = createImageTool({
config: options?.config,
@@ -34,6 +44,10 @@ export function createClawdbotTools(options?: {
createMessageTool({
agentAccountId: options?.agentAccountId,
config: options?.config,
currentChannelId: options?.currentChannelId,
currentThreadTs: options?.currentThreadTs,
replyToMode: options?.replyToMode,
hasRepliedRef: options?.hasRepliedRef,
}),
createGatewayTool({
agentSessionKey: options?.agentSessionKey,
@@ -58,6 +72,10 @@ export function createClawdbotTools(options?: {
agentProvider: options?.agentProvider,
sandboxed: options?.sandboxed,
}),
createSessionStatusTool({
agentSessionKey: options?.agentSessionKey,
config: options?.config,
}),
...(imageTool ? [imageTool] : []),
];
}
+135
View File
@@ -0,0 +1,135 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import {
CONTEXT_WINDOW_HARD_MIN_TOKENS,
CONTEXT_WINDOW_WARN_BELOW_TOKENS,
evaluateContextWindowGuard,
resolveContextWindowInfo,
} from "./context-window-guard.js";
describe("context-window-guard", () => {
it("blocks below 16k (model metadata)", () => {
const info = resolveContextWindowInfo({
cfg: undefined,
provider: "openrouter",
modelId: "tiny",
modelContextWindow: 8000,
defaultTokens: 200_000,
});
const guard = evaluateContextWindowGuard({ info });
expect(guard.source).toBe("model");
expect(guard.tokens).toBe(8000);
expect(guard.shouldWarn).toBe(true);
expect(guard.shouldBlock).toBe(true);
});
it("warns below 32k but does not block at 16k+", () => {
const info = resolveContextWindowInfo({
cfg: undefined,
provider: "openai",
modelId: "small",
modelContextWindow: 24_000,
defaultTokens: 200_000,
});
const guard = evaluateContextWindowGuard({ info });
expect(guard.tokens).toBe(24_000);
expect(guard.shouldWarn).toBe(true);
expect(guard.shouldBlock).toBe(false);
});
it("does not warn at 32k+ (model metadata)", () => {
const info = resolveContextWindowInfo({
cfg: undefined,
provider: "openai",
modelId: "ok",
modelContextWindow: 64_000,
defaultTokens: 200_000,
});
const guard = evaluateContextWindowGuard({ info });
expect(guard.shouldWarn).toBe(false);
expect(guard.shouldBlock).toBe(false);
});
it("uses models.providers.*.models[].contextWindow when present", () => {
const cfg = {
models: {
providers: {
openrouter: {
baseUrl: "http://localhost",
apiKey: "x",
models: [
{
id: "tiny",
name: "tiny",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 12_000,
maxTokens: 256,
},
],
},
},
},
} satisfies ClawdbotConfig;
const info = resolveContextWindowInfo({
cfg,
provider: "openrouter",
modelId: "tiny",
modelContextWindow: undefined,
defaultTokens: 200_000,
});
const guard = evaluateContextWindowGuard({ info });
expect(info.source).toBe("modelsConfig");
expect(guard.shouldBlock).toBe(true);
});
it("falls back to agents.defaults.contextTokens", () => {
const cfg = {
agents: { defaults: { contextTokens: 20_000 } },
} satisfies ClawdbotConfig;
const info = resolveContextWindowInfo({
cfg,
provider: "anthropic",
modelId: "whatever",
modelContextWindow: undefined,
defaultTokens: 200_000,
});
const guard = evaluateContextWindowGuard({ info });
expect(info.source).toBe("agentContextTokens");
expect(guard.shouldWarn).toBe(true);
expect(guard.shouldBlock).toBe(false);
});
it("uses default when nothing else is available", () => {
const info = resolveContextWindowInfo({
cfg: undefined,
provider: "anthropic",
modelId: "unknown",
modelContextWindow: undefined,
defaultTokens: 200_000,
});
const guard = evaluateContextWindowGuard({ info });
expect(info.source).toBe("default");
expect(guard.shouldWarn).toBe(false);
expect(guard.shouldBlock).toBe(false);
});
it("allows overriding thresholds", () => {
const info = { tokens: 10_000, source: "model" as const };
const guard = evaluateContextWindowGuard({
info,
warnBelowTokens: 12_000,
hardMinTokens: 9_000,
});
expect(guard.shouldWarn).toBe(true);
expect(guard.shouldBlock).toBe(false);
});
it("exports thresholds as expected", () => {
expect(CONTEXT_WINDOW_HARD_MIN_TOKENS).toBe(16_000);
expect(CONTEXT_WINDOW_WARN_BELOW_TOKENS).toBe(32_000);
});
});
+84
View File
@@ -0,0 +1,84 @@
import type { ClawdbotConfig } from "../config/config.js";
export const CONTEXT_WINDOW_HARD_MIN_TOKENS = 16_000;
export const CONTEXT_WINDOW_WARN_BELOW_TOKENS = 32_000;
export type ContextWindowSource =
| "model"
| "modelsConfig"
| "agentContextTokens"
| "default";
export type ContextWindowInfo = {
tokens: number;
source: ContextWindowSource;
};
function normalizePositiveInt(value: unknown): number | null {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
const int = Math.floor(value);
return int > 0 ? int : null;
}
export function resolveContextWindowInfo(params: {
cfg: ClawdbotConfig | undefined;
provider: string;
modelId: string;
modelContextWindow?: number;
defaultTokens: number;
}): ContextWindowInfo {
const fromModel = normalizePositiveInt(params.modelContextWindow);
if (fromModel) return { tokens: fromModel, source: "model" };
const fromModelsConfig = (() => {
const providers = params.cfg?.models?.providers as
| Record<
string,
{ models?: Array<{ id?: string; contextWindow?: number }> }
>
| undefined;
const providerEntry = providers?.[params.provider];
const models = Array.isArray(providerEntry?.models)
? providerEntry.models
: [];
const match = models.find((m) => m?.id === params.modelId);
return normalizePositiveInt(match?.contextWindow);
})();
if (fromModelsConfig)
return { tokens: fromModelsConfig, source: "modelsConfig" };
const fromAgentConfig = normalizePositiveInt(
params.cfg?.agents?.defaults?.contextTokens,
);
if (fromAgentConfig)
return { tokens: fromAgentConfig, source: "agentContextTokens" };
return { tokens: Math.floor(params.defaultTokens), source: "default" };
}
export type ContextWindowGuardResult = ContextWindowInfo & {
shouldWarn: boolean;
shouldBlock: boolean;
};
export function evaluateContextWindowGuard(params: {
info: ContextWindowInfo;
warnBelowTokens?: number;
hardMinTokens?: number;
}): ContextWindowGuardResult {
const warnBelow = Math.max(
1,
Math.floor(params.warnBelowTokens ?? CONTEXT_WINDOW_WARN_BELOW_TOKENS),
);
const hardMin = Math.max(
1,
Math.floor(params.hardMinTokens ?? CONTEXT_WINDOW_HARD_MIN_TOKENS),
);
const tokens = Math.max(0, Math.floor(params.info.tokens));
return {
...params.info,
tokens,
shouldWarn: tokens > 0 && tokens < warnBelow,
shouldBlock: tokens > 0 && tokens < hardMin,
};
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
coerceToFailoverError,
describeFailoverError,
resolveFailoverReasonFromError,
} from "./failover-error.js";
describe("failover-error", () => {
it("infers failover reason from HTTP status", () => {
expect(resolveFailoverReasonFromError({ status: 402 })).toBe("billing");
expect(resolveFailoverReasonFromError({ statusCode: "429" })).toBe(
"rate_limit",
);
expect(resolveFailoverReasonFromError({ status: 403 })).toBe("auth");
expect(resolveFailoverReasonFromError({ status: 408 })).toBe("timeout");
});
it("infers timeout from common node error codes", () => {
expect(resolveFailoverReasonFromError({ code: "ETIMEDOUT" })).toBe(
"timeout",
);
expect(resolveFailoverReasonFromError({ code: "ECONNRESET" })).toBe(
"timeout",
);
});
it("coerces failover-worthy errors into FailoverError with metadata", () => {
const err = coerceToFailoverError("credit balance too low", {
provider: "anthropic",
model: "claude-opus-4-5",
});
expect(err?.name).toBe("FailoverError");
expect(err?.reason).toBe("billing");
expect(err?.status).toBe(402);
expect(err?.provider).toBe("anthropic");
expect(err?.model).toBe("claude-opus-4-5");
});
it("describes non-Error values consistently", () => {
const described = describeFailoverError(123);
expect(described.message).toBe("123");
expect(described.reason).toBeUndefined();
});
});
+169
View File
@@ -0,0 +1,169 @@
import {
classifyFailoverReason,
type FailoverReason,
} from "./pi-embedded-helpers.js";
export class FailoverError extends Error {
readonly reason: FailoverReason;
readonly provider?: string;
readonly model?: string;
readonly profileId?: string;
readonly status?: number;
readonly code?: string;
constructor(
message: string,
params: {
reason: FailoverReason;
provider?: string;
model?: string;
profileId?: string;
status?: number;
code?: string;
cause?: unknown;
},
) {
super(message, { cause: params.cause });
this.name = "FailoverError";
this.reason = params.reason;
this.provider = params.provider;
this.model = params.model;
this.profileId = params.profileId;
this.status = params.status;
this.code = params.code;
}
}
export function isFailoverError(err: unknown): err is FailoverError {
return err instanceof FailoverError;
}
export function resolveFailoverStatus(
reason: FailoverReason,
): number | undefined {
switch (reason) {
case "billing":
return 402;
case "rate_limit":
return 429;
case "auth":
return 401;
case "timeout":
return 408;
default:
return undefined;
}
}
function getStatusCode(err: unknown): number | undefined {
if (!err || typeof err !== "object") return undefined;
const candidate =
(err as { status?: unknown; statusCode?: unknown }).status ??
(err as { statusCode?: unknown }).statusCode;
if (typeof candidate === "number") return candidate;
if (typeof candidate === "string" && /^\d+$/.test(candidate)) {
return Number(candidate);
}
return undefined;
}
function getErrorCode(err: unknown): string | undefined {
if (!err || typeof err !== "object") return undefined;
const candidate = (err as { code?: unknown }).code;
if (typeof candidate !== "string") return undefined;
const trimmed = candidate.trim();
return trimmed ? trimmed : undefined;
}
function getErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === "string") return err;
if (
typeof err === "number" ||
typeof err === "boolean" ||
typeof err === "bigint"
) {
return String(err);
}
if (typeof err === "symbol") return err.description ?? "";
if (err && typeof err === "object") {
const message = (err as { message?: unknown }).message;
if (typeof message === "string") return message;
}
return "";
}
export function resolveFailoverReasonFromError(
err: unknown,
): FailoverReason | null {
if (isFailoverError(err)) return err.reason;
const status = getStatusCode(err);
if (status === 402) return "billing";
if (status === 429) return "rate_limit";
if (status === 401 || status === 403) return "auth";
if (status === 408) return "timeout";
const code = (getErrorCode(err) ?? "").toUpperCase();
if (
["ETIMEDOUT", "ESOCKETTIMEDOUT", "ECONNRESET", "ECONNABORTED"].includes(
code,
)
) {
return "timeout";
}
const message = getErrorMessage(err);
if (!message) return null;
return classifyFailoverReason(message);
}
export function describeFailoverError(err: unknown): {
message: string;
reason?: FailoverReason;
status?: number;
code?: string;
} {
if (isFailoverError(err)) {
return {
message: err.message,
reason: err.reason,
status: err.status,
code: err.code,
};
}
const message = getErrorMessage(err) || String(err);
return {
message,
reason: resolveFailoverReasonFromError(err) ?? undefined,
status: getStatusCode(err),
code: getErrorCode(err),
};
}
export function coerceToFailoverError(
err: unknown,
context?: {
provider?: string;
model?: string;
profileId?: string;
},
): FailoverError | null {
if (isFailoverError(err)) return err;
const reason = resolveFailoverReasonFromError(err);
if (!reason) return null;
const message = getErrorMessage(err) || String(err);
const status = getStatusCode(err) ?? resolveFailoverStatus(reason);
const code = getErrorCode(err);
return new FailoverError(message, {
reason,
provider: context?.provider,
model: context?.model,
profileId: context?.profileId,
status,
code,
cause: err instanceof Error ? err : undefined,
});
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveMessagePrefix, resolveResponsePrefix } from "./identity.js";
describe("message prefix resolution", () => {
it("returns configured messagePrefix override", () => {
const cfg: ClawdbotConfig = {};
expect(
resolveMessagePrefix(cfg, "main", {
configured: "[x]",
hasAllowFrom: true,
}),
).toBe("[x]");
expect(
resolveMessagePrefix(cfg, "main", {
configured: "",
hasAllowFrom: false,
}),
).toBe("");
});
it("defaults messagePrefix based on allowFrom + identity", () => {
const cfg: ClawdbotConfig = {
agents: { list: [{ id: "main", identity: { name: "Richbot" } }] },
};
expect(resolveMessagePrefix(cfg, "main", { hasAllowFrom: true })).toBe("");
expect(resolveMessagePrefix(cfg, "main", { hasAllowFrom: false })).toBe(
"[Richbot]",
);
});
it("falls back to [clawdbot] when identity is missing", () => {
const cfg: ClawdbotConfig = {};
expect(resolveMessagePrefix(cfg, "main", { hasAllowFrom: false })).toBe(
"[clawdbot]",
);
});
});
describe("response prefix resolution", () => {
it("does not apply any default when unset", () => {
const cfg: ClawdbotConfig = {
agents: { list: [{ id: "main", identity: { name: "Richbot" } }] },
};
expect(resolveResponsePrefix(cfg, "main")).toBeUndefined();
});
it("returns explicit responsePrefix when set", () => {
const cfg: ClawdbotConfig = { messages: { responsePrefix: "PFX" } };
expect(resolveResponsePrefix(cfg, "main")).toBe("PFX");
});
it("supports responsePrefix: auto (identity-derived opt-in)", () => {
const withIdentity: ClawdbotConfig = {
agents: { list: [{ id: "main", identity: { name: "Richbot" } }] },
messages: { responsePrefix: "auto" },
};
expect(resolveResponsePrefix(withIdentity, "main")).toBe("[Richbot]");
const withoutIdentity: ClawdbotConfig = {
messages: { responsePrefix: "auto" },
};
expect(resolveResponsePrefix(withoutIdentity, "main")).toBeUndefined();
});
});
+9 -4
View File
@@ -32,9 +32,9 @@ export function resolveIdentityNamePrefix(
export function resolveMessagePrefix(
cfg: ClawdbotConfig,
agentId: string,
opts?: { hasAllowFrom?: boolean; fallback?: string },
opts?: { configured?: string; hasAllowFrom?: boolean; fallback?: string },
): string {
const configured = cfg.messages?.messagePrefix;
const configured = opts?.configured ?? cfg.messages?.messagePrefix;
if (configured !== undefined) return configured;
const hasAllowFrom = opts?.hasAllowFrom === true;
@@ -50,8 +50,13 @@ export function resolveResponsePrefix(
agentId: string,
): string | undefined {
const configured = cfg.messages?.responsePrefix;
if (configured !== undefined) return configured;
return resolveIdentityNamePrefix(cfg, agentId);
if (configured !== undefined) {
if (configured === "auto") {
return resolveIdentityNamePrefix(cfg, agentId);
}
return configured;
}
return undefined;
}
export function resolveEffectiveMessagesConfig(
+2
View File
@@ -0,0 +1,2 @@
export const AGENT_LANE_NESTED = "nested" as const;
export const AGENT_LANE_SUBAGENT = "subagent" as const;
+1
View File
@@ -139,6 +139,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
minimax: "MINIMAX_API_KEY",
zai: "ZAI_API_KEY",
mistral: "MISTRAL_API_KEY",
"opencode-zen": "OPENCODE_ZEN_API_KEY",
};
const envVar = envMap[provider];
if (!envVar) return null;
+135
View File
@@ -0,0 +1,135 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { runWithModelFallback } from "./model-fallback.js";
function makeCfg(overrides: Partial<ClawdbotConfig> = {}): ClawdbotConfig {
return {
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: ["anthropic/claude-haiku-3-5"],
},
},
},
...overrides,
} as ClawdbotConfig;
}
describe("runWithModelFallback", () => {
it("does not fall back on non-auth errors", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(new Error("bad request"))
.mockResolvedValueOnce("ok");
await expect(
runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
}),
).rejects.toThrow("bad request");
expect(run).toHaveBeenCalledTimes(1);
});
it("falls back on auth errors", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(Object.assign(new Error("nope"), { status: 401 }))
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("falls back on 402 payment required", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(
Object.assign(new Error("payment required"), { status: 402 }),
)
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("falls back on billing errors", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(
new Error(
"LLM request rejected: Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits.",
),
)
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("appends the configured primary as a last fallback", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: [],
},
},
},
});
const run = vi
.fn()
.mockRejectedValueOnce(
Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }),
)
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
provider: "openrouter",
model: "meta-llama/llama-3.3-70b:free",
run,
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(result.provider).toBe("openai");
expect(result.model).toBe("gpt-4.1-mini");
});
});
+38 -4
View File
@@ -1,11 +1,18 @@
import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import {
coerceToFailoverError,
describeFailoverError,
isFailoverError,
} from "./failover-error.js";
import {
buildModelAliasIndex,
modelKey,
parseModelRef,
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "./model-selection.js";
import type { FailoverReason } from "./pi-embedded-helpers.js";
type ModelCandidate = {
provider: string;
@@ -16,6 +23,9 @@ type FallbackAttempt = {
provider: string;
model: string;
error: string;
reason?: FailoverReason;
status?: number;
code?: string;
};
function isAbortError(err: unknown): boolean {
@@ -119,6 +129,13 @@ function resolveFallbackCandidates(params: {
}): ModelCandidate[] {
const provider = params.provider.trim() || DEFAULT_PROVIDER;
const model = params.model.trim() || DEFAULT_MODEL;
const primary = params.cfg
? resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
})
: null;
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg ?? {},
defaultProvider: DEFAULT_PROVIDER,
@@ -160,6 +177,10 @@ function resolveFallbackCandidates(params: {
addCandidate(resolved.ref, true);
}
if (primary?.provider && primary.model) {
addCandidate({ provider: primary.provider, model: primary.model }, false);
}
return candidates;
}
@@ -197,16 +218,27 @@ export async function runWithModelFallback<T>(params: {
};
} catch (err) {
if (isAbortError(err)) throw err;
lastError = err;
const normalized =
coerceToFailoverError(err, {
provider: candidate.provider,
model: candidate.model,
}) ?? err;
if (!isFailoverError(normalized)) throw err;
lastError = normalized;
const described = describeFailoverError(normalized);
attempts.push({
provider: candidate.provider,
model: candidate.model,
error: err instanceof Error ? err.message : String(err),
error: described.message,
reason: described.reason,
status: described.status,
code: described.code,
});
await params.onError?.({
provider: candidate.provider,
model: candidate.model,
error: err,
error: normalized,
attempt: i + 1,
total: candidates.length,
});
@@ -219,7 +251,9 @@ export async function runWithModelFallback<T>(params: {
? attempts
.map(
(attempt) =>
`${attempt.provider}/${attempt.model}: ${attempt.error}`,
`${attempt.provider}/${attempt.model}: ${attempt.error}${
attempt.reason ? ` (${attempt.reason})` : ""
}`,
)
.join(" | ")
: "unknown";
+133
View File
@@ -1,10 +1,13 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_PROVIDER } from "./defaults.js";
import {
buildAllowedModelSet,
modelKey,
parseModelRef,
resolveAllowedModelRef,
resolveHooksGmailModel,
} from "./model-selection.js";
const catalog = [
@@ -70,3 +73,133 @@ describe("parseModelRef", () => {
});
});
});
describe("resolveHooksGmailModel", () => {
it("returns null when hooks.gmail.model is not set", () => {
const cfg = {} satisfies ClawdbotConfig;
const result = resolveHooksGmailModel({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
expect(result).toBeNull();
});
it("returns null when hooks.gmail.model is empty", () => {
const cfg = {
hooks: { gmail: { model: "" } },
} satisfies ClawdbotConfig;
const result = resolveHooksGmailModel({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
expect(result).toBeNull();
});
it("parses provider/model from hooks.gmail.model", () => {
const cfg = {
hooks: { gmail: { model: "openrouter/meta-llama/llama-3.3-70b:free" } },
} satisfies ClawdbotConfig;
const result = resolveHooksGmailModel({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
expect(result).toEqual({
provider: "openrouter",
model: "meta-llama/llama-3.3-70b:free",
});
});
it("resolves alias from agent.models", () => {
const cfg = {
agents: {
defaults: {
models: {
"anthropic/claude-sonnet-4-1": { alias: "Sonnet" },
},
},
},
hooks: { gmail: { model: "Sonnet" } },
} satisfies ClawdbotConfig;
const result = resolveHooksGmailModel({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
expect(result).toEqual({
provider: "anthropic",
model: "claude-sonnet-4-1",
});
});
it("uses default provider when model omits provider", () => {
const cfg = {
hooks: { gmail: { model: "claude-haiku-3-5" } },
} satisfies ClawdbotConfig;
const result = resolveHooksGmailModel({
cfg,
defaultProvider: "anthropic",
});
expect(result).toEqual({
provider: "anthropic",
model: "claude-haiku-3-5",
});
});
});
describe("resolveAllowedModelRef", () => {
it("resolves aliases when allowed", () => {
const cfg = {
agents: {
defaults: {
models: {
"anthropic/claude-sonnet-4-1": { alias: "Sonnet" },
},
},
},
} satisfies ClawdbotConfig;
const resolved = resolveAllowedModelRef({
cfg,
catalog: [
{
provider: "anthropic",
id: "claude-sonnet-4-1",
name: "Sonnet",
},
],
raw: "Sonnet",
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
});
expect("error" in resolved).toBe(false);
if ("ref" in resolved) {
expect(resolved.ref).toEqual({
provider: "anthropic",
model: "claude-sonnet-4-1",
});
}
});
it("rejects disallowed models", () => {
const cfg = {
agents: {
defaults: {
models: {
"openai/gpt-4": { alias: "GPT4" },
},
},
},
} satisfies ClawdbotConfig;
const resolved = resolveAllowedModelRef({
cfg,
catalog: [
{ provider: "openai", id: "gpt-4", name: "GPT-4" },
{ provider: "anthropic", id: "claude-sonnet-4-1", name: "Sonnet" },
],
raw: "anthropic/claude-sonnet-4-1",
defaultProvider: "openai",
defaultModel: "gpt-4",
});
expect(resolved).toEqual({
error: "model not allowed: anthropic/claude-sonnet-4-1",
});
});
});
+95
View File
@@ -197,6 +197,76 @@ export function buildAllowedModelSet(params: {
return { allowAny: false, allowedCatalog, allowedKeys };
}
export type ModelRefStatus = {
key: string;
inCatalog: boolean;
allowAny: boolean;
allowed: boolean;
};
export function getModelRefStatus(params: {
cfg: ClawdbotConfig;
catalog: ModelCatalogEntry[];
ref: ModelRef;
defaultProvider: string;
defaultModel?: string;
}): ModelRefStatus {
const allowed = buildAllowedModelSet({
cfg: params.cfg,
catalog: params.catalog,
defaultProvider: params.defaultProvider,
defaultModel: params.defaultModel,
});
const key = modelKey(params.ref.provider, params.ref.model);
return {
key,
inCatalog: params.catalog.some(
(entry) => modelKey(entry.provider, entry.id) === key,
),
allowAny: allowed.allowAny,
allowed: allowed.allowAny || allowed.allowedKeys.has(key),
};
}
export function resolveAllowedModelRef(params: {
cfg: ClawdbotConfig;
catalog: ModelCatalogEntry[];
raw: string;
defaultProvider: string;
defaultModel?: string;
}):
| { ref: ModelRef; key: string }
| {
error: string;
} {
const trimmed = params.raw.trim();
if (!trimmed) return { error: "invalid model: empty" };
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
defaultProvider: params.defaultProvider,
});
const resolved = resolveModelRefFromString({
raw: trimmed,
defaultProvider: params.defaultProvider,
aliasIndex,
});
if (!resolved) return { error: `invalid model: ${trimmed}` };
const status = getModelRefStatus({
cfg: params.cfg,
catalog: params.catalog,
ref: resolved.ref,
defaultProvider: params.defaultProvider,
defaultModel: params.defaultModel,
});
if (!status.allowed) {
return { error: `model not allowed: ${status.key}` };
}
return { ref: resolved.ref, key: status.key };
}
export function resolveThinkingDefault(params: {
cfg: ClawdbotConfig;
provider: string;
@@ -211,3 +281,28 @@ export function resolveThinkingDefault(params: {
if (candidate?.reasoning) return "low";
return "off";
}
/**
* Resolve the model configured for Gmail hook processing.
* Returns null if hooks.gmail.model is not set.
*/
export function resolveHooksGmailModel(params: {
cfg: ClawdbotConfig;
defaultProvider: string;
}): ModelRef | null {
const hooksModel = params.cfg.hooks?.gmail?.model;
if (!hooksModel?.trim()) return null;
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
defaultProvider: params.defaultProvider,
});
const resolved = resolveModelRefFromString({
raw: hooksModel,
defaultProvider: params.defaultProvider,
aliasIndex,
});
return resolved?.ref ?? null;
}
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import {
getOpencodeZenStaticFallbackModels,
OPENCODE_ZEN_MODEL_ALIASES,
resolveOpencodeZenAlias,
resolveOpencodeZenModelApi,
} from "./opencode-zen-models.js";
describe("resolveOpencodeZenAlias", () => {
it("resolves opus alias", () => {
expect(resolveOpencodeZenAlias("opus")).toBe("claude-opus-4-5");
});
it("resolves gpt5 alias", () => {
expect(resolveOpencodeZenAlias("gpt5")).toBe("gpt-5.2");
});
it("resolves gemini alias", () => {
expect(resolveOpencodeZenAlias("gemini")).toBe("gemini-3-pro");
});
it("returns input if no alias exists", () => {
expect(resolveOpencodeZenAlias("some-unknown-model")).toBe(
"some-unknown-model",
);
});
it("is case-insensitive", () => {
expect(resolveOpencodeZenAlias("OPUS")).toBe("claude-opus-4-5");
expect(resolveOpencodeZenAlias("Gpt5")).toBe("gpt-5.2");
});
});
describe("resolveOpencodeZenModelApi", () => {
it("returns openai-completions for all models (OpenCode Zen is OpenAI-compatible)", () => {
expect(resolveOpencodeZenModelApi("claude-opus-4-5")).toBe(
"openai-completions",
);
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe(
"openai-completions",
);
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe(
"openai-completions",
);
});
});
describe("getOpencodeZenStaticFallbackModels", () => {
it("returns an array of models", () => {
const models = getOpencodeZenStaticFallbackModels();
expect(Array.isArray(models)).toBe(true);
expect(models.length).toBeGreaterThan(0);
});
it("includes Claude, GPT, and Gemini models", () => {
const models = getOpencodeZenStaticFallbackModels();
const ids = models.map((m) => m.id);
expect(ids).toContain("claude-opus-4-5");
expect(ids).toContain("gpt-5.2");
expect(ids).toContain("gemini-3-pro");
});
it("returns valid ModelDefinitionConfig objects", () => {
const models = getOpencodeZenStaticFallbackModels();
for (const model of models) {
expect(model.id).toBeDefined();
expect(model.name).toBeDefined();
expect(typeof model.reasoning).toBe("boolean");
expect(Array.isArray(model.input)).toBe(true);
expect(model.cost).toBeDefined();
expect(typeof model.contextWindow).toBe("number");
expect(typeof model.maxTokens).toBe("number");
}
});
});
describe("OPENCODE_ZEN_MODEL_ALIASES", () => {
it("has expected aliases", () => {
expect(OPENCODE_ZEN_MODEL_ALIASES.opus).toBe("claude-opus-4-5");
expect(OPENCODE_ZEN_MODEL_ALIASES.sonnet).toBe("claude-sonnet-4-20250514");
expect(OPENCODE_ZEN_MODEL_ALIASES.gpt5).toBe("gpt-5.2");
expect(OPENCODE_ZEN_MODEL_ALIASES.o1).toBe("o1-2025-04-16");
expect(OPENCODE_ZEN_MODEL_ALIASES.gemini).toBe("gemini-3-pro");
});
});
Loaded 100 of 308 files, more files were not shown because too many files have changed in this diff. Show more