Compare commits

..
Author SHA1 Message Date
Peter Steinberger 4e341d1354 chore(pnpm): refresh lockfile for patches 2026-01-11 04:59:06 +00:00
Peter Steinberger 323200b551 test(live): harden gateway probes 2026-01-11 04:46:30 +00:00
Peter Steinberger dbe156e881 fix(agents): sanitize transcripts for strict tool APIs 2026-01-11 04:46:18 +00:00
Peter Steinberger f00038b383 fix(testing): stabilize live model runs 2026-01-11 04:22:35 +00:00
Peter Steinberger 3b6739d3e9 docs: clarify reactions + timeout 2026-01-11 04:22:35 +00:00
Peter Steinberger d7055f8fd2 docs: align reaction semantics 2026-01-11 04:22:35 +00:00
Peter Steinberger 1fc213468b docs: note moderation reasons 2026-01-11 04:22:35 +00:00
Peter Steinberger af1749f3b3 docs: scope discord-only read flag 2026-01-11 04:22:35 +00:00
Peter Steinberger 2b15e952c2 docs: expand imessage targets 2026-01-11 04:22:35 +00:00
Peter Steinberger 343b6ac31b feat: add onboard reset option 2026-01-11 05:04:36 +01:00
Peter Steinberger 9046296ed3 fix: clarify sub-agent sandbox limits 2026-01-11 05:04:14 +01:00
Peter Steinberger b4e9a0c975 style: add blank line after note imports 2026-01-11 05:01:50 +01:00
Peter Steinberger 71791d5a6a fix: restore ZAI provider preference 2026-01-11 04:58:37 +01:00
Peter Steinberger 7acdaad04e style: fix note import spacing 2026-01-11 04:54:19 +01:00
Peter Steinberger b7ac9095e6 fix: skip tool-only reasoning replay 2026-01-11 04:52:16 +01:00
Peter Steinberger f42fca667c docs: avoid hardcoded pinned version 2026-01-11 04:46:27 +01:00
Peter Steinberger be3648c511 fix: patch openai-responses replay + docs 2026-01-11 04:45:37 +01:00
Peter Steinberger 5fa682d8f0 fix(macos): show connecting state for remote tunnel 2026-01-11 04:45:37 +01:00
Peter Steinberger 30348e41c6 test: stabilize doctor + sandbox tests 2026-01-11 04:45:04 +01:00
Peter Steinberger 7343597075 chore: keep gate green 2026-01-11 04:42:44 +01:00
Peter Steinberger 0b2ff4cfd9 chore(release): consolidate into 2026.1.10 2026-01-11 04:42:01 +01:00
Peter Steinberger 50e62122bb chore: format sandbox skills test 2026-01-11 04:39:42 +01:00
Peter Steinberger eeae5ce7fd fix: stabilize notes and reasoning replay 2026-01-11 04:37:06 +01:00
Peter Steinberger edb3651c32 docs: add telegram reactions 2026-01-11 03:30:09 +00:00
Peter Steinberger 3155e4fd5a docs: clarify agent providers and polls 2026-01-11 03:30:09 +00:00
Peter Steinberger 5a443dfa53 docs: fix session key examples 2026-01-11 03:30:09 +00:00
Peter Steinberger cfdca57551 docs: align messaging and node docs 2026-01-11 03:30:09 +00:00
Peter Steinberger 473f7df658 docs: fix provider session key examples 2026-01-11 03:30:09 +00:00
Peter Steinberger 57e6a9a762 fix: clamp z.ai developer role 2026-01-11 04:28:30 +01:00
Peter Steinberger 7660a78330 fix: mirror skills for read-only sandbox 2026-01-11 04:24:19 +01:00
Peter Steinberger 29884f8d6f fix: wrap clack notes for cleaner boxes 2026-01-11 04:23:43 +01:00
Peter Steinberger 76c5bff7d6 test: cover whoami command 2026-01-11 04:20:34 +01:00
Peter Steinberger f74ead8d43 docs(changelog): consolidate 2026.1.11 2026-01-11 04:17:37 +01:00
Peter Steinberger bfd0dcde35 Merge pull request #629 from pasogott/feature/whatsapp-ack-reaction
feat(whatsapp): add acknowledgment reactions
2026-01-11 03:11:38 +00:00
Peter Steinberger 38604acd94 fix: tighten WhatsApp ack reactions and migrate config (#629) (thanks @pasogott) 2026-01-11 04:11:04 +01:00
sheeek c928df7237 fix: remove any casts in backward compat code 2026-01-11 04:10:43 +01:00
sheeek 30b4c14296 style: fix biome linting in ack-reaction tests 2026-01-11 04:10:43 +01:00
sheeek 2daead27cf feat(whatsapp): redesign ack-reaction as whatsapp-specific feature
- Move config from messages.ackReaction to whatsapp.ackReaction
- New structure: {emoji, direct, group} with granular control
- Support per-account overrides in whatsapp.accounts.*.ackReaction
- Add Zod schema validation for new config
- Maintain backward compatibility with old messages.ackReaction format
- Update tests to new config structure (14 tests, all passing)
- Add comprehensive documentation in docs/providers/whatsapp.md
- Timing: reactions sent immediately upon message receipt (before bot reply)

Breaking changes:
- Config moved from messages.ackReaction to whatsapp.ackReaction
- Scope values changed: 'all'/'direct'/'group-all'/'group-mentions'
  → direct: boolean + group: 'always'/'mentions'/'never'
- Old config still supported via fallback for smooth migration
2026-01-11 04:10:43 +01:00
sheeek d38b232724 chore: fix linting issues in ack-reaction feature
- Remove unused mock variables in tests
- Remove unused ackReactionScope variables in simple test cases
- Fix line length for ackReactionScope declaration
- All lint checks passing (0 warnings, 0 errors)
- All tests passing (8/8)
2026-01-11 04:10:43 +01:00
sheeek c3587d6cae fix(whatsapp): ack reaction logic for group activation 'always' mode
- Fix bug where ack reaction was not sent when group activation is 'always'
- When requireMention=false (activation: always), always send reaction
- Add test case for activation='always' scenario
- Update inline comments for clarity
2026-01-11 04:10:43 +01:00
sheeek b3b507c6ea feat(whatsapp): add ack reaction support after successful replies
- Add automatic emoji reactions on inbound WhatsApp messages
- Support all ackReactionScope modes: all, direct, group-all, group-mentions
- Reaction is sent AFTER successful reply (unlike Telegram/Discord)
- Errors are logged with proper context
- Add comprehensive test suite for ack reaction logic

Config usage:
  messages:
    ackReaction: "👀"
    ackReactionScope: "group-mentions"  # default

Closes: WhatsApp ack-reaction feature request
2026-01-11 04:10:42 +01:00
Peter Steinberger 7879a58f4b docs: consolidate 2026.1.10 notes 2026-01-11 04:08:33 +01:00
Peter Steinberger 579b00503f style: format onboard providers 2026-01-11 04:08:26 +01:00
Peter Steinberger 36a21ae9b0 fix: improve telegram configuration safety 2026-01-11 03:57:52 +01:00
Peter Steinberger 11f897b7df fix(gateway): show connect vs RPC status 2026-01-11 03:57:52 +01:00
Peter Steinberger 054a6d301c Merge pull request #694 from antons/fix/heartbeat-reasoning
Fix/heartbeat reasoning
2026-01-11 02:52:12 +00:00
Peter Steinberger 1f9b4e3af6 fix: send heartbeat reasoning (#694) (thanks @antons) 2026-01-11 03:51:51 +01:00
Peter Steinberger 4ce2e73521 fix: improve provider issue formatting 2026-01-11 03:51:51 +01:00
Anton Sotkov c7caa9a87d fix: deliver reasoning alongside HEARTBEAT_OK 2026-01-11 03:51:51 +01:00
Anton Sotkov 7a518166bb fix: persist reasoning across session resets 2026-01-11 03:51:51 +01:00
Peter Steinberger 89291c384b fix(macos): improve onboarding discovery 2026-01-11 03:51:08 +01:00
Peter Steinberger 9d802abd9a test: cover docker setup env plumbing 2026-01-11 03:45:45 +01:00
Peter Steinberger 480bf916e2 fix(status): simplify footer guidance 2026-01-11 03:44:28 +01:00
Peter Steinberger 9a4021a277 Merge pull request #703 from mteam88/openrouter-auth-config
Openrouter auth config (AI)
2026-01-11 02:44:24 +00:00
Peter Steinberger 2b07a2a8ab fix: stabilize onboarding auth tests (#703) (thanks @mteam88) 2026-01-11 03:42:27 +01:00
Matthew 77bc11f91c chore: format OpenRouter auth edits 2026-01-11 03:35:45 +01:00
Matthew 7890bd7369 CLI: reuse OpenRouter credentials 2026-01-11 03:35:45 +01:00
Matthew b6982236a6 CLI: add OpenRouter auth choice 2026-01-11 03:35:45 +01:00
Peter Steinberger 6c54977c15 chore(release): 2026.1.11-6 2026-01-11 03:35:28 +01:00
Peter Steinberger 494f41d575 docs(status): make status first-step 2026-01-11 03:34:33 +01:00
Peter Steinberger cffec07329 Merge pull request #697 from gabriel-trigo/feat/docker-apt-packages
feat(docker): optional apt packages in docker-setup
2026-01-11 02:28:36 +00:00
Peter Steinberger 6833e3de5d fix: harden docker apt install (#697) (thanks @gabriel-trigo) 2026-01-11 03:27:48 +01:00
Peter Steinberger 20b4e2b859 fix: stabilize live probes and docs 2026-01-11 02:26:39 +00:00
Gabriel Trigo ff14e743ea feat(docker): optional apt packages in docker-setup 2026-01-11 03:26:05 +01:00
Peter Steinberger 6668805aca fix(agents): enforce single-writer session files 2026-01-11 02:25:45 +00:00
Peter Steinberger 3a113b7752 fix: stabilize cli runner output 2026-01-11 02:25:45 +00:00
Peter Steinberger e229a36e9f docs: update changelog for codex cli 2026-01-11 02:25:22 +00:00
Peter Steinberger f5670cae06 fix(macos): include optional relay deps 2026-01-11 03:22:46 +01:00
Peter Steinberger cc79c507f6 docs: add link preference for peter 2026-01-11 03:21:28 +01:00
Peter Steinberger b0b3896941 Merge pull request #695 from jeffersonwarrior/jeff/no-sign-launchagent
macOS: stabilize launchagent in --no-sign
2026-01-11 02:20:32 +00:00
Peter Steinberger 9b6bc0e66b fix: reset unsigned launchd overrides (#695) (thanks @jeffersonwarrior) 2026-01-11 03:19:24 +01:00
Peter Steinberger f8d168bde0 docs: clarify gateway remote node flow 2026-01-11 03:17:06 +01:00
Jefferson Warrior 325ed80252 scripts: simplify no-sign steps 2026-01-11 03:12:36 +01:00
Jefferson Warrior e43abd3f14 macos: keep launchagent stable on --no-sign 2026-01-11 03:12:36 +01:00
Peter Steinberger d9645b4802 Merge pull request #701 from bjesuiter/fix/update-progress-logs
feat(update): add progress spinner during update steps
2026-01-11 02:03:27 +00:00
Peter Steinberger 5ec3663748 fix: guard update spinner output (#701) (thanks @bjesuiter) 2026-01-11 03:03:09 +01:00
Peter Steinberger 84d9c5f5e5 fix(macos): stabilize onboarding discovery 2026-01-11 03:02:47 +01:00
Benjamin Jesuiter f3bd6e4957 fix(update): use git status --porcelain for dirty check cross-platform 2026-01-11 03:00:43 +01:00
Benjamin Jesuiter 6cb55eaaa7 feat(update): show stderr for failed steps 2026-01-11 03:00:43 +01:00
Benjamin Jesuiter 3f27b23d5a fix(update): remove command hint from step labels 2026-01-11 03:00:43 +01:00
Benjamin Jesuiter 4102e2f1b8 refactor(update): simplify progress with proper exit codes 2026-01-11 03:00:43 +01:00
Benjamin Jesuiter 35d42be828 fix(update): show skipped status with warning indicator for dirty repo 2026-01-11 03:00:43 +01:00
Benjamin Jesuiter 6a2b8328df fix(update): restore reason in summary 2026-01-11 03:00:43 +01:00
Benjamin Jesuiter cc8e6e00a0 fix(update): hide steps in summary when shown live, fix command hint 2026-01-11 03:00:43 +01:00
Benjamin Jesuiter 6e0c1cb051 fix(update): show each step with spinner as it runs 2026-01-11 03:00:43 +01:00
Benjamin Jesuiter 8f9aa3e8c5 fix(progress): start spinner immediately when delayMs is 0 2026-01-11 03:00:43 +01:00
Benjamin Jesuiter 88c404bcfc feat(update): add progress spinner during update steps 2026-01-11 03:00:43 +01:00
Peter Steinberger 920436da65 fix(macos): add gateway discovery refresh 2026-01-11 02:45:42 +01:00
Peter Steinberger 4759633df1 fix(cli): keep build/lint green 2026-01-11 02:44:24 +01:00
Peter Steinberger e824b3514b fix(status): improve diagnostics and output 2026-01-11 02:42:24 +01:00
Peter Steinberger 2e2f05a0e1 docs: add quick setup blocks to chat providers 2026-01-11 02:40:38 +01:00
Peter Steinberger 02270abc87 feat: add codex cli backend 2026-01-11 01:39:30 +00:00
Peter Steinberger 2cc0d8c058 fix(macos): wrap usage provider errors 2026-01-11 02:35:53 +01:00
Peter Steinberger 340d1c64b4 docs: add provider hub and model provider pages 2026-01-11 02:27:37 +01:00
Peter Steinberger 2d74119a08 test: cover auto-reply command gating 2026-01-11 02:27:16 +01:00
Peter Steinberger e0bf86f06c feat: improve gateway services and auto-reply commands 2026-01-11 02:27:16 +01:00
Peter Steinberger df55d45b6f chore: update changelog for command gating 2026-01-11 02:27:16 +01:00
Peter Steinberger 305ef06090 docs: fix gateway diagram spacing 2026-01-11 02:24:23 +01:00
Peter Steinberger a665382060 chore(deps): sync pnpm lock patch hash 2026-01-11 02:08:56 +01:00
Peter Steinberger 49f99e200a docs: add FAQ for Anthropic setup-token and Codex auth 2026-01-11 02:05:35 +01:00
Peter Steinberger fa0f2b971f fix(macos): wrap usage errors in menu 2026-01-11 02:04:27 +01:00
Peter Steinberger fe46a2663b docs: clarify browser allowlist defaults and risks 2026-01-11 02:00:30 +01:00
Peter Steinberger a32021dc3e fix: inject image paths for cli backends 2026-01-11 00:55:22 +00:00
Peter Steinberger 4cf3e84b39 test: add CLI backend image probe 2026-01-11 00:55:22 +00:00
Peter Steinberger 24c3ab6fe0 fix: unblock claude-cli live runs 2026-01-11 00:55:22 +00:00
Peter Steinberger d8f1124d59 feat: add CLI backend fallback 2026-01-11 00:55:22 +00:00
Peter Steinberger 07be761779 feat: add sandbox browser control allowlists 2026-01-11 01:52:32 +01:00
Peter Steinberger b0b4b33b6b fix: update gateway auth docs and clients 2026-01-11 01:51:24 +01:00
Peter Steinberger d33285a9cd fix: harden gateway auth defaults 2026-01-11 01:51:24 +01:00
Peter Steinberger 49e7004664 fix(macos): group usage by selected model 2026-01-11 01:51:04 +01:00
Peter Steinberger 0637e4b2a5 chore(release): 2026.1.11-4 2026-01-11 01:46:41 +01:00
Peter Steinberger 3e6d27ac4e fix(status): show gateway auth when reachable 2026-01-11 01:46:37 +01:00
Peter Steinberger 506cc9e7a1 chore(release): 2026.1.11-3 2026-01-11 01:38:15 +01:00
Peter Steinberger 21ba04755b fix(macos): onboarding location + layout 2026-01-11 01:36:00 +01:00
Peter Steinberger cbac9fe4ac chore(release): 2026.1.11-2 2026-01-11 01:34:51 +01:00
Peter Steinberger b339097179 style: format browser tool wiring 2026-01-11 01:34:45 +01:00
Peter Steinberger 07eed3de56 docs(status): add diagnostics commands 2026-01-11 01:31:56 +01:00
Peter Steinberger 326fb04d12 feat: add browser target selection for sandboxed agents 2026-01-11 01:31:56 +01:00
Peter Steinberger d2098e4492 fix(macos): avoid discovery retries during tests 2026-01-11 01:16:39 +01:00
Peter Steinberger 362fc3e235 Merge pull request #692 from peschee/fix/whatsapp-lid-mention-detection
fix(whatsapp): pass authDir to jidToE164 for LID mention detection
2026-01-11 00:16:03 +00:00
Peter Steinberger 6444258ad3 fix: handle WhatsApp LID mentions (#692) (thanks @peschee) 2026-01-11 01:14:57 +01:00
Peter Steinberger 3dbd6766ab fix(macos): improve onboarding discovery + restart onboarding 2026-01-11 01:13:53 +01:00
Peter Steinberger 318f59ec3e fix(status): show token previews 2026-01-11 01:11:46 +01:00
Peter Steinberger 57dafec0ec docs(status): add troubleshooting footer 2026-01-11 01:06:58 +01:00
Peter Steinberger 518dfd4e42 fix(status): provider setup vs warn 2026-01-11 01:05:06 +01:00
Peter Siska 9984248f51 fix formatting 2026-01-11 01:04:10 +01:00
Peter Siska 9cb1bfa1c1 fix(whatsapp): pass authDir to jidToE164 for LID mention detection
WhatsApp group mentions using the new Linked ID format (@lid) were not
being detected because jidToE164() was called without the authDir needed
to find the LID reverse mapping files.

Now isBotMentioned() and debugMention() accept an optional authDir
parameter, which is passed through from account.authDir.
2026-01-11 01:04:10 +01:00
Peter Steinberger 5fa3ac1e01 fix(status): full-width tables + better diagnosis 2026-01-11 00:54:27 +01:00
Peter Steinberger f3882671c9 fix(macos): avoid hiding gateways by substring match 2026-01-11 00:47:01 +01:00
Peter Steinberger 7c76561569 fix: dedupe inbound messages across providers 2026-01-11 00:12:25 +01:00
Peter Steinberger bd2002010c Merge pull request #580 from jeffersonwarrior/fix/restart-mac-signing-auto-detection
feat: add auto-signing detection to restart-mac.sh
2026-01-10 22:48:56 +00:00
Peter Steinberger 317e15c746 fix: harden restart-mac signing (#580) (thanks @jeffersonwarrior) 2026-01-10 23:48:33 +01:00
Peter Steinberger 40f818ff5e fix(ci): resync pnpm patch hash 2026-01-10 23:48:15 +01:00
Peter Steinberger 1d9199b529 style(test): format update-cli test 2026-01-10 23:46:11 +01:00
Jefferson Warrior cb213b55f6 feat: add auto-signing detection to restart-mac.sh 2026-01-10 23:45:36 +01:00
Peter Steinberger 7a52a93d08 Merge pull request #683 from benithors/macos-model-picker-search
macOS: model picker saves provider/model IDs
2026-01-10 22:43:25 +00:00
Peter Steinberger d4a93bc25c fix: normalize model picker refs (#683) (thanks @benithors) 2026-01-10 23:43:06 +01:00
benithors 3853f632e5 fix: restore pi-ai patch hash 2026-01-10 23:42:37 +01:00
benithors 7fb0b4e1eb macOS: fix model picker formatting + protocol sync 2026-01-10 23:42:24 +01:00
benithors 04951b0629 Config: add searchable model picker with provider/model hints 2026-01-10 23:42:24 +01:00
Peter Steinberger eff092268a fix(test): avoid update-cli import timeout 2026-01-10 23:40:27 +01:00
Peter Steinberger b977e8a284 fix(ci): sync pnpm patch hash 2026-01-10 23:39:41 +01:00
Peter Steinberger 621f710d60 fix(mac): add tailnet discovery fallback and debug CLI 2026-01-10 23:39:27 +01:00
Shadow c731a87d07 Discord: add fetch message action 2026-01-10 16:38:20 -06:00
Peter Steinberger 786eac1d6f test(cli): avoid update-cli import timeout 2026-01-10 23:35:04 +01:00
Peter Steinberger 1eb50ffac4 feat(status): improve status output 2026-01-10 23:32:07 +01:00
Peter Steinberger 67b7877bbf docs(changelog): drop self-thanks (#691) 2026-01-10 22:27:41 +00:00
Peter Steinberger 3166cc911b Heartbeat: optional reasoning delivery (#690)
* feat: expose heartbeat reasoning output

* docs(changelog): mention heartbeat reasoning toggle
2026-01-10 22:26:20 +00:00
Peter Steinberger 5adbeb1bad Merge pull request #688 from theglove44/fix/thinking-blocks-leak
fix(agents): strip <thought> and <antthinking> tags from output
2026-01-10 22:25:37 +00:00
Peter Steinberger 4d0e74ab6c fix: cover extra thinking tags (#688) (thanks @theglove44) 2026-01-10 23:23:23 +01:00
Chris Taylor a580639abf fix(agents): strip <thought> and <antthinking> tags from output 2026-01-10 23:19:58 +01:00
Peter Steinberger 494743a4e5 feat: run doctor after restart 2026-01-10 23:14:55 +01:00
Peter Steinberger 4eb6aec016 Merge pull request #687 from evalexpr/fix/usage-limit-fallback
fix: add 'usage limit' to rate limit detection patterns
2026-01-10 22:13:19 +00:00
Peter Steinberger 5a47d6ffc3 docs: add changelog entry for usage limit failover (#687) (thanks @evalexpr) 2026-01-10 23:12:27 +01:00
Jonathan WilkinsandClaude Opus 4.5 0afa370869 fix: add 'usage limit' to rate limit detection patterns
OpenAI/ChatGPT returns "You have hit your ChatGPT usage limit (plus plan)"
when users exceed their plan quota. This error wasn't being recognized as a
rate limit, so fallback to alternative models wasn't triggering.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:11:06 +01:00
Peter Steinberger 08cc8f2281 refactor(agents): extract transcript repair module 2026-01-10 22:07:25 +00:00
Peter Steinberger 708f04b02f fix: keep mock openai responses requests 2026-01-10 22:56:08 +01:00
Peter Steinberger 1c257f170a Gateway: disable OpenAI HTTP chat completions by default (#686)
* feat(gateway): disable OpenAI chat completions HTTP by default

* test(gateway): deflake mock OpenAI tool-calling

* docs(changelog): note OpenAI HTTP endpoint default-off
2026-01-10 21:55:54 +00:00
Peter Steinberger 06052640e8 Merge pull request #685 from carlulsoe/fix/daemon-restart-feedback
fix(cli): improve daemon restart feedback [AI-assisted]
2026-01-10 21:53:00 +00:00
Peter Steinberger fa61699f9a fix: polish restart feedback + stabilize tests (#685) (thanks @carlulsoe) 2026-01-10 22:52:09 +01:00
Peter Steinberger 98377c7c6b fix(agents): harden tool transcript repair 2026-01-10 21:45:15 +00:00
Peter Steinberger 805a29252e test: add setup-token live smoke 2026-01-10 21:45:15 +00:00
f699dc3777 fix(cli): improve daemon restart feedback
- runDaemonRestart() now returns Promise<boolean> indicating success
- update command only shows success when restart actually happened
- Fixes missing reasoningLevel type in compactEmbeddedPiSession

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Carl Ulsøe Christensen <carlulsoe@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:44:31 +01:00
Peter Steinberger ad17966e2f fix(doctor): warn on opencode overrides 2026-01-10 22:44:31 +01:00
Peter Steinberger 2a86e40730 fix: keep docker home volume mounts 2026-01-10 22:42:57 +01:00
Gabriel Trigo b5cd758c21 feat: add optional home volume and extra mounts 2026-01-10 22:40:57 +01:00
Peter Steinberger 56b11ad5a8 docs(gateway): rename OpenAI HTTP endpoint doc 2026-01-10 22:39:06 +01:00
Peter Steinberger 1110d96769 feat(gateway): add config toggle for chat completions endpoint 2026-01-10 22:39:06 +01:00
Peter Steinberger 050c1c5391 fix(agents): include reasoningLevel in compaction params 2026-01-10 22:39:06 +01:00
Peter Steinberger 357891a063 Merge pull request #676 from ngutman/fix/macos-bridge-tunnel-health
fix(macos): stabilize bridge tunnels
2026-01-10 21:27:34 +00:00
Peter Steinberger 66bc003126 fix: harden mac bridge disconnect handling (#676) (thanks @ngutman) 2026-01-10 22:27:09 +01:00
Nimrod Gutman 55d2608808 fix(macos): stabilize bridge tunnels 2026-01-10 22:26:47 +01:00
Ruby a6a9930a34 fix: enable block streaming for all providers (#684) 2026-01-10 15:25:55 -06:00
Peter Steinberger 6d70524aa8 fix: add reasoning visibility hint 2026-01-10 22:24:22 +01:00
Peter Steinberger ee5acd6d4b fix: move attach-only toggle to General settings 2026-01-10 22:21:40 +01:00
Peter Steinberger aa30995aa1 test(live): add provider filters + google skip rules 2026-01-10 21:16:59 +00:00
Peter Steinberger d45c27e51f chore(protocol): regenerate GatewayModels.swift 2026-01-10 22:15:06 +01:00
Peter Steinberger 67fdee6d6b docs(changelog): note OpenAI HTTP endpoint 2026-01-10 22:11:50 +01:00
Peter Steinberger 0d00d6dfd4 style(gateway): format openai-http 2026-01-10 22:11:15 +01:00
Peter Steinberger 6546a1a23a feat(gateway): allow agent via model 2026-01-10 22:11:12 +01:00
Peter Steinberger 72d4317d7f fix(docs): dedupe /sandbox redirects 2026-01-10 22:11:10 +01:00
Peter Steinberger 6d01d70c24 docs(gateway): add OpenAI HTTP API to docs nav 2026-01-10 22:11:07 +01:00
Peter Steinberger dafa8a2881 feat(gateway): add OpenAI-compatible HTTP endpoint 2026-01-10 22:11:04 +01:00
Peter Steinberger ab314a22e0 chore: refresh pi-ai patch repro note 2026-01-10 20:55:57 +00:00
Peter Steinberger c65114be1a docs(testing): add google live recipes 2026-01-10 20:55:57 +00:00
Shadow 19d9e7ac05 Docs: fix internal links 2026-01-10 14:51:33 -06:00
Peter Steinberger d19972b317 fix(openai): drop reasoning replay for tool-only turns 2026-01-10 20:44:23 +00:00
Peter Steinberger 9790b39d80 feat(gateway): add agent image attachments + live probe 2026-01-10 20:44:23 +00:00
Peter Steinberger b9b1bc2726 test: relax reasoning replay expectations 2026-01-10 21:43:52 +01:00
Peter Steinberger 8a194b4abc fix: align opencode-zen provider setup 2026-01-10 21:38:18 +01:00
Peter Steinberger 46e00ad5e7 fix: describe sandboxed elevated in prompt 2026-01-10 21:37:15 +01:00
Peter Steinberger 3389231ecb feat(doctor): offer update first 2026-01-10 21:34:59 +01:00
Peter Steinberger d772ff06c8 test: update openai responses reasoning replay 2026-01-10 21:20:26 +01:00
Peter Steinberger d9290137bc fix: add whatsapp sender ids to group context 2026-01-10 21:09:08 +01:00
Peter Steinberger 686b3f884c fix: expose WhatsApp sender ids in group context 2026-01-10 21:09:08 +01:00
Peter Steinberger 914216eca4 fix(ci): sync pnpm patchedDependencies hash 2026-01-10 21:07:53 +01:00
Peter Steinberger 0ef429f532 feat: color docs search output 2026-01-10 21:02:27 +01:00
Peter Steinberger 9bd5d4355c docs: expand testing guide 2026-01-10 19:53:34 +00:00
Peter Steinberger 1bd5500832 feat: add colored CLI docs links 2026-01-10 20:51:03 +01:00
Peter Steinberger cf192f8551 style: biome format 2026-01-10 19:47:17 +00:00
Peter Steinberger afede929b3 test: harden gateway tool probes 2026-01-10 19:46:13 +00:00
Peter Steinberger d44bb41d27 fix: replay OpenAI reasoning for tool calls 2026-01-10 19:46:13 +00:00
Peter Steinberger fa346d7b78 fix: accept Z_AI_API_KEY for zai 2026-01-10 19:46:13 +00:00
Peter Steinberger ec1047583a Merge pull request #640 from mcinteerj/fix/whatsapp-group-reactions
fix(whatsapp): enable reactions in group chats
2026-01-10 19:44:05 +00:00
Peter Steinberger 7e6fa94720 fix: update WhatsApp history assertions (#640) (thanks @mcinteerj) 2026-01-10 20:41:30 +01:00
Jake 4933113252 fix(whatsapp): preserve group message IDs and normalize reaction participants 2026-01-10 20:36:32 +01:00
Peter Steinberger 2f050b197e docs: document clawdbot update 2026-01-10 20:33:02 +01:00
Peter Steinberger 4c4c167416 fix(update): harden root selection 2026-01-10 20:33:02 +01:00
Claude 777fb6b7bb CLI: add clawdbot update command and --update flag 2026-01-10 20:33:02 +01:00
Peter Steinberger 9f9098406c feat(sandbox): add sandbox explain inspector 2026-01-10 20:28:43 +01:00
Peter Steinberger 4533dd6e5d test: add image attachment regression coverage 2026-01-10 20:25:38 +01:00
Peter Steinberger 212b13b099 fix: repair tool-use history for anthropic 2026-01-10 19:15:57 +00:00
Peter Steinberger c409edd3fa Merge pull request #670 from cristip73/fix/ios-image-attachments
fix: enable image attachments in chat messages for Claude API
2026-01-10 19:07:35 +00:00
Peter Steinberger 193ebba657 fix: sniff chat attachment mime (#670) (thanks @cristip73) 2026-01-10 20:06:33 +01:00
Peter Steinberger fac4951f27 docs: add group flow diagram 2026-01-10 20:05:22 +01:00
cristip73andClaude Opus 4.5 c4e76eb635 fix: enable image attachments in chat messages for Claude API
Images were previously converted to markdown data URLs which Claude API
treats as plain text, not as actual images.

Changes:
- Add parseMessageWithAttachments() that returns {message, images[]}
- Pass images through the stack to session.prompt() as content blocks
- Filter null/empty attachments before parsing
- Strip data URL prefix if client sends it

This enables iOS and other clients to send images that Claude can actually see.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:00:25 +01:00
Peter Steinberger 0279f09459 fix: avoid duplicate cli banners 2026-01-10 19:57:24 +01:00
Peter Steinberger 831de9ba8f docs: clarify group chat behavior 2026-01-10 19:56:46 +01:00
Peter Steinberger 7947059884 test(gateway): deflake temp HOME cleanup 2026-01-10 19:53:04 +01:00
Peter Steinberger 1fe9f648b1 feat(hooks): allow gmail tailscale target URLs 2026-01-10 19:19:43 +01:00
Peter Steinberger 0335bccd91 docs: clarify tailscale gmail path forwarding 2026-01-10 19:19:43 +01:00
Peter Steinberger f9347235e4 docs: call out group history limits 2026-01-10 19:19:31 +01:00
Peter Steinberger b1664ec9c7 test: rename signal reaction-only test (#637) 2026-01-10 19:19:05 +01:00
Peter Steinberger 801e7dd811 docs: update changelog for unified history 2026-01-10 19:16:26 +01:00
Peter Steinberger 82f71d25e5 refactor: centralize history context wrapping 2026-01-10 19:16:26 +01:00
Peter Steinberger b977ae19af chore: fix lint and typing 2026-01-10 19:16:25 +01:00
Peter Steinberger c0a010335b docs: document history context overrides 2026-01-10 19:16:25 +01:00
Peter Steinberger d41372b9d9 feat: unify provider history context 2026-01-10 19:16:25 +01:00
Peter Steinberger 8c1d39064d test: adjust tool id sanitization 2026-01-10 18:15:15 +00:00
Peter Steinberger 7c925aa5a0 style: format helper 2026-01-10 18:15:15 +00:00
Peter Steinberger 651a9e9be4 fix: restore minimax tool calling 2026-01-10 18:15:15 +00:00
Peter Steinberger c264e98c62 fix(deps): patch pi-ai tool calling 2026-01-10 18:15:15 +00:00
Peter Steinberger 1ac7338d9a Merge pull request #637 from neist/main
fix(signal): handle reactions in dataMessage.reaction format
2026-01-10 18:14:30 +00:00
Peter Steinberger 8dbb22cc93 fix: signal handle dataMessage.reaction safely (#637) (thanks @neist) 2026-01-10 19:13:23 +01:00
Kasper Neist Christjansen 59e6064006 fix(signal): handle reactions in dataMessage.reaction format (#1)
* fix(signal): handle reactions inside dataMessage.reaction

Signal reactions can arrive in two formats:
1. envelope.reactionMessage (already handled)
2. envelope.dataMessage.reaction (now handled)

The signal-cli SSE events use the second format, which was being
misinterpreted as a message with attachments, leading to 'broken
media / attachments' errors.

Changes:
- Add reaction property to SignalDataMessage type
- Check both envelope.reactionMessage and dataMessage.reaction
- Improve body content detection to properly identify reaction-only messages
- Add test for dataMessage.reaction format

* fix(signal): reaction notifications work when account is phone number

When reactionNotifications mode is 'own', notifications would never fire
because resolveSignalReactionTarget() returned a UUID but
shouldEmitSignalReactionNotification() compared it against the account
phone number, which never matched.

The fix:
- Add optional 'phone' field to SignalReactionTarget type
- Extract phone number first in resolveSignalReactionTarget(), include
  it even when UUID is present
- In shouldEmitSignalReactionNotification() 'own' mode, check phone
  match first before falling back to UUID comparison

This ensures reactions to your own messages are properly detected when
the Signal account is configured as a phone number and the reaction
event contains both targetAuthor (phone) and targetAuthorUuid.

* fix(signal): include phone in reaction target for own-mode matching

When targetAuthorUuid is present, also store targetAuthor phone number
in the reaction target. This allows own-mode reaction notifications to
match when comparing account phone against UUID-based targets.
2026-01-10 19:10:39 +01:00
Peter Steinberger f648267dd9 docs: add changelog for gmail tailscale fix 2026-01-10 18:52:17 +01:00
Anton Sotkov 26ce65995f fix(gmail): keep tailscale serve path at root
The default Gmail hook path configured by `clawdbot hooks gmail setup` is `/gmail-pubsub`. Tailscale strips the mount path before proxying, so the request lands on `/` and the hook 404s under the default configuration.

When Tailscale is enabled, always listen on `/` internally and keep the public URL on the configured path (defaulting to `/gmail-pubsub`). This makes default and custom paths work reliably.

Alternative (not implemented here): call tailscale with a full target URL so the backend keeps the path, e.g. `tailscale funnel --set-path /gmail-pubsub http://127.0.0.1:8788/gmail-pubsub`. We did not take this path because it requires changing the CLI invocation to pass URLs (not ports) plus extra validation, which is a larger behavior change.
2026-01-10 18:51:12 +01:00
Shadow 0de3bb36d5 Deps: drop carbon patch 2026-01-10 11:40:28 -06:00
Shadow 755c031f6a Deps: bump carbon beta 2026-01-10 11:40:27 -06:00
Peter Steinberger 7ac628a697 Merge pull request #666 from roshanasingh4/fix/652-cron-wakeMode-now-waits-for-agent
[AI-assisted] fix(cron): wait for heartbeat to complete when wakeMode is "now"
2026-01-10 17:39:14 +00:00
Peter Steinberger 7dd0899856 fix: voicewake respects state dir override (#666) (thanks @roshanasingh4) 2026-01-10 18:32:09 +01:00
Peter Steinberger 8dd8818e08 style: swiftformat GatewayEnvironment 2026-01-10 18:31:36 +01:00
Peter Steinberger f1a1032cd6 fix: serialize telegram media-group processing 2026-01-10 18:31:36 +01:00
Peter Steinberger b383fbeed3 fix: cron wakeMode now waits for heartbeat (#666) (thanks @roshanasingh4) 2026-01-10 18:31:35 +01:00
Roshan Singh 91c870a0c4 fix(cron): wait for heartbeat to complete when wakeMode is "now"
Fixes #652

When cron jobs with sessionTarget:"main" have wakeMode:"now",
they were being marked as completed immediately without waiting for the
agent to actually process the system event.

The issue was that requestHeartbeatNow() is fire-and-forget and
doesn't wait for the heartbeat to complete. The job would finish
with durationMs: 0 before the agent had a chance to run.

This fix:
- Adds runHeartbeatOnce to CronServiceDeps
- Wires it up in gateway/server.ts to load config and pass runtime
- Modifies executeJob() to call runHeartbeatOnce when wakeMode:"now"
- Waits for heartbeat to complete and maps status to cron result:
  * "ran" → "ok"
  * "skipped" → "skipped"
  * "failed" → "error"
- Falls back to old behavior for wakeMode:"next-heartbeat" or if
  runHeartbeatOnce is not available (backward compatibility)

Benefits:
- Jobs now have accurate durationMs reflecting actual processing time
- Jobs are correctly marked with "error" status if heartbeat fails
- Prevents race condition where job completes before agent runs

[AI-assisted] - Generated with z.ai GLM-4.7
[Tested: Lightly tested - Logic validated with test scenarios, code quality checks passed, integration testing requires live Clawdbot instance]
2026-01-10 18:31:35 +01:00
Peter Steinberger 5a57cbe571 Merge pull request #667 from rubyrunsstuff/fix/discord-forwarded-snapshots
Discord: include forwarded message snapshots
2026-01-10 17:31:26 +00:00
Peter Steinberger 6480ef369f fix: telegram draft chunking defaults (#667) (thanks @rubyrunsstuff) 2026-01-10 18:30:06 +01:00
Peter Steinberger 2455a2b26a Merge pull request #669 from magimetal/opencode-zen-model-defaults
feat(opencode-zen): update models with sensible defaults
2026-01-10 17:27:05 +00:00
Peter Steinberger 2d105d16f8 fix(opencode-zen): keep legacy aliases + rationale (#669) (thanks @magimetal) 2026-01-10 18:25:43 +01:00
Ruby 7a836c9ff0 Discord: include forwarded message snapshots 2026-01-10 18:23:30 +01:00
Magi Metal 738269eb74 feat(opencode-zen): update models with sensible defaults
- Replace model catalog with 11 current models: gpt-5.1-codex, claude-opus-4-5,
  gemini-3-pro, alpha-glm-4.7, gpt-5.1-codex-mini, gpt-5.1, glm-4.7-free,
  gemini-3-flash, gpt-5.1-codex-max, minimax-m2.1-free, gpt-5.2
- Add accurate per-token costs from OpenCode Zen pricing
- Add accurate context windows and output limits
- Update aliases for new model families (codex, glm, minimax)
- Remove deprecated models (sonnet, haiku, o-series, gemini-2.5)
2026-01-10 18:22:26 +01:00
Peter Steinberger 9b5ce2530a Merge pull request #665 from sebslight/fix/cloud-code-assist-schema-and-tool-ids
fix(agents): harden Cloud Code Assist compatibility
2026-01-10 17:07:58 +00:00
Peter Steinberger 38d6930fbe Merge pull request #664 from azade-c/fix/use-state-dir-for-nodes-voicewake
fix: use resolveStateDir() for node-pairing and voicewake storage
2026-01-10 17:07:28 +00:00
Peter Steinberger 0d98e93253 fix: harden cloud code assist tool schema sanitizing (#665) (thanks @sebslight) 2026-01-10 18:07:26 +01:00
Sebastian Barrios 64babcac7a fix(agents): harden Cloud Code Assist compatibility
- Expand schema scrubber to strip additional constraint keywords rejected
  by Cloud Code Assist (examples, minLength, maxLength, minimum, maximum,
  multipleOf, pattern, format, minItems, maxItems, uniqueItems,
  minProperties, maxProperties)
- Extend tool call ID sanitization to cover toolUse and toolCall block
  types (previously only functionCall was sanitized)
- Update pi-tools test to include 'examples' in unsupported keywords

Fixes 400 errors when using google-antigravity/claude-opus-4-5-thinking:
- tools.N.custom.input_schema: JSON schema is invalid
- messages.N.content.N.tool_use.id: String should match pattern
2026-01-10 18:06:35 +01:00
Peter Steinberger 464f0645a8 fix: stabilize telegram media tests (#664) (thanks @azade-c) 2026-01-10 18:06:05 +01:00
Peter Steinberger ef08c3f038 fix(agents): stabilize cli creds cache + bash cwd 2026-01-10 18:02:21 +01:00
Azade 48ad3bbbe6 fix: use resolveStateDir() for node-pairing and voicewake storage
Both node-pairing.ts and voicewake.ts were using a local defaultBaseDir()
that hardcoded ~/.clawdbot, ignoring CLAWDBOT_STATE_DIR.

This caused nodes/paired.json and settings/voicewake.json to be stored
in ~/.clawdbot instead of the configured state directory.

Fixes the bug where paired nodes config was stored in a different
location than the rest of the gateway state.
2026-01-10 17:55:30 +01:00
Peter Steinberger 843ff5f2d4 fix(sessions): tolerate ENOENT during lock 2026-01-10 17:50:53 +01:00
Peter Steinberger 60bf349201 fix(sessions): lock store saves; wait for bash close 2026-01-10 17:47:12 +01:00
Peter Steinberger a54706a063 fix: throttle cli credential sync 2026-01-10 17:44:03 +01:00
Peter Steinberger 6cc8570369 docs: expand TypeBox protocol guide 2026-01-10 17:38:34 +01:00
Peter Steinberger dd958fddfc docs: clarify model picks and auth setup 2026-01-10 17:36:54 +01:00
Peter Steinberger 12722acb55 feat: wizard model picker (#611, thanks @jonasjancarik) 2026-01-10 16:32:59 +00:00
Jonáš Jančařík 687a10b8cc fix: map opencode-zen preferred provider 2026-01-10 16:32:59 +00:00
Jonáš Jančařík 9f80d8ec7c fix: skip model picker when auth choice preset 2026-01-10 16:32:59 +00:00
Jonáš Jančařík dcc41e932d feat: add shared model picker to configure/onboarding 2026-01-10 16:32:59 +00:00
Peter Steinberger e3cd431551 fix(auto-reply): RawBody commands + locked session updates (#643) 2026-01-10 17:32:31 +01:00
Peter Steinberger e2ea02160d test: add workspace path regressions 2026-01-10 17:28:43 +01:00
Peter Steinberger 89b20baafe docs: update changelog 2026-01-10 16:23:53 +00:00
Peter Steinberger e2733d21bf refactor(ios): require bridge stable ID 2026-01-10 16:23:53 +00:00
Peter Steinberger 701e146c06 refactor(shared): default ToolDisplay config 2026-01-10 16:23:53 +00:00
Peter Steinberger 8bc9209094 refactor(apple): share AsyncTimeout helper 2026-01-10 16:23:53 +00:00
Peter Steinberger a1533a17f7 fix(gateway): harden chat abort semantics 2026-01-10 17:23:27 +01:00
Peter Steinberger 84d64f9395 Merge pull request #446 from tony-freedomology/feat/human-delay
feat(agent): add human-like delay between block replies
2026-01-10 16:16:52 +00:00
Peter Steinberger fb03149df4 fix: finalize human delay config typing (#446) (thanks @tony-freedomology) 2026-01-10 17:15:27 +01:00
LloydandClaude Opus 4.5 ab994d2c63 feat(agent): add human-like delay between block replies
Adds `agent.humanDelay` config option to create natural rhythm between
streamed message bubbles. When enabled, introduces a random delay
(default 800-2500ms) between block replies, making multi-message
responses feel more like natural human texting.

Config example:
```json
{
  "agent": {
    "blockStreamingDefault": "on",
    "humanDelay": {
      "enabled": true,
      "minMs": 800,
      "maxMs": 2500
    }
  }
}
```

- First message sends immediately
- Subsequent messages wait a random delay before sending
- Works with iMessage, Signal, and Discord providers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:12:50 +01:00
Peter Steinberger a1ded60bca Merge pull request #642 from mukhtharcm/fix/workspace-path-resolution
fix(tools): resolve Read/Write/Edit paths against workspace directory
2026-01-10 16:11:13 +00:00
Peter Steinberger f62f4b6703 fix: log workspace tool path resolution (#642) (thanks @mukhtharcm) 2026-01-10 17:09:56 +01:00
Muhammed Mukhthar CM de5b75eff6 fix(tools): resolve Read/Write/Edit paths against workspace directory
Previously, Read/Write/Edit tools used the global tool instances from
pi-coding-agent which had process.cwd() baked in at import time. Since
the gateway starts from /root/dev/ai/clawdbot, relative paths like
'SOUL.md' would incorrectly resolve there instead of the agent's
workspace (/root/clawd).

This fix:
- Adds workspaceDir option to createClawdbotCodingTools
- Creates fresh Read/Write/Edit tools bound to workspaceDir
- Adds cwd option to Bash tool defaults for consistency
- Passes effectiveWorkspace from pi-embedded-runner

Absolute paths and ~/... paths are unaffected. Sandboxed sessions
continue to use sandbox root as before.

Includes tests for Read/Write/Edit workspace path resolution.
2026-01-10 17:08:56 +01:00
Peter Steinberger bf0184d0cf docs: update changelog (#662) 2026-01-10 16:04:32 +00:00
Peter Steinberger 64525f825c chore(docs): quiet docs build output 2026-01-10 16:04:32 +00:00
Peter Steinberger 5805bb051b fix(android): enforce strict lint checks 2026-01-10 16:04:32 +00:00
Peter Steinberger ef3bab5a74 fix(macos): improve activity tool labels 2026-01-10 16:04:32 +00:00
Peter Steinberger f428ed9038 fix(ios): enable strict concurrency checks 2026-01-10 16:04:32 +00:00
Kristijan JovanovskiandClaude Opus 4.5 e4fea2b80b fix(ios): add Swift 6 strict concurrency compatibility
Applies the same Swift 6 compatibility patterns from PR #166 (macOS) to the iOS app.

Changes:
- LocationService.swift: Added Sendable constraint to withTimeout<T> generic,
  made CLLocationManagerDelegate methods nonisolated with Task { @MainActor in }
  pattern to safely access MainActor state
- TalkModeManager.swift: Fixed OSLog string interpolation to avoid operator
  overload issues with OSLogMessage in Swift 6

Addresses #164

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 16:04:32 +00:00
Peter Steinberger d1943a9337 chore: format reply session 2026-01-10 17:03:17 +01:00
Peter Steinberger d781508952 fix: make chat.send non-blocking 2026-01-10 17:02:28 +01:00
Peter Steinberger 22144cd51b docs(changelog): note native /model fix 2026-01-10 16:52:14 +01:00
Peter Steinberger b7fdc266ad test(auto-reply): cover native /model session routing 2026-01-10 16:50:32 +01:00
Peter Steinberger b99eb4c9f3 fix(auto-reply): apply native commands to target session 2026-01-10 16:48:53 +01:00
Peter Steinberger 239e9bafc8 docs: use providers login 2026-01-10 16:44:59 +01:00
Peter Steinberger 8978ac425e fix: harden cli credential sync 2026-01-10 16:37:54 +01:00
Peter Steinberger 81f9093c3c fix(pairing): accept positional provider args 2026-01-10 16:36:43 +01:00
Peter Steinberger 41c8bdfada feat: add ZAI auth choice 2026-01-10 16:32:21 +01:00
Peter Steinberger 8b47368167 fix: harden cli credential sync 2026-01-10 16:25:40 +01:00
Peter Steinberger e60c3fc1b3 fix: doctor ignore install dir in legacy workspace check 2026-01-10 16:23:35 +01:00
Peter Steinberger db5e4b986b Merge pull request #650 from henrino3/showcase-2026-01-10
docs(showcase): add ParentPay, R2 Upload, iOS TestFlight, Oura Health
2026-01-10 15:18:03 +00:00
Peter Steinberger 78532d76bd test: clean up lint warnings 2026-01-10 16:17:02 +01:00
Peter Steinberger 04f2972b4a fix: update showcase changelog (#650) (thanks @henrino3) 2026-01-10 16:16:48 +01:00
henrymascot e87ce9c680 docs(showcase): add ParentPay, R2 Upload, iOS TestFlight, Oura Health
New community showcase entries from Discord #showcase:
- ParentPay School Meals (@George5562) - UK school meal automation
- R2 Upload (@julianengel) - presigned URL file sharing
- iOS App via Telegram (@coard) - full iOS app built via chat
- Oura Ring Health Assistant (@AS) - health/calendar integration
2026-01-10 16:15:55 +01:00
Peter Steinberger 53a0c966a5 refactor: unify configure auth choice 2026-01-10 16:14:49 +01:00
Peter Steinberger d6d5c5ccd1 docs: remove legacy bundled gateway doc 2026-01-10 16:03:36 +01:00
Peter Steinberger 001a19eb2c refactor: tidy mac bundled gateway packaging 2026-01-10 16:03:36 +01:00
Peter Steinberger 43b530ca1c fix(agents): suppress partial replies with reasoning 2026-01-10 16:03:17 +01:00
Peter Steinberger 44564df028 refactor(sessions): add mergeSessionEntry 2026-01-10 16:03:17 +01:00
Peter Steinberger 70c1732dd1 refactor: centralize messaging dedupe helpers 2026-01-10 16:02:56 +01:00
Peter Steinberger 99e9e506be Merge pull request #654 from radek-paclt/fix/claude-cli-oauth-refresh
fix(auth): enable OAuth refresh for Claude CLI credentials
2026-01-10 14:51:29 +00:00
Peter Steinberger 5a93447294 fix: prevent claude-cli oauth downgrade (#654) (thanks @radek-paclt) 2026-01-10 15:50:25 +01:00
Radek PacltandClaude a39951d463 fix(auth): enable OAuth refresh for Claude CLI credentials
When Claude CLI credentials (anthropic:claude-cli) expire, automatically
refresh using the stored refresh token instead of failing with
"No credentials found" error.

Changes:
- Read refreshToken from Claude CLI and store as OAuth credential type
- Implement bidirectional sync: after refresh, write new tokens back to
  Claude Code storage (file on Linux/Windows, Keychain on macOS)
- Prefer OAuth over Token credentials (enables auto-refresh capability)
- Maintain backward compatibility for credentials without refreshToken

This enables long-running agents to operate autonomously without manual
re-authentication when OAuth tokens expire.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-10 15:40:27 +01:00
Peter Steinberger 1281c1d155 Merge pull request #655 from antons/fix/reasoning-imsg
Fix reasoning in iMessage
2026-01-10 14:39:14 +00:00
Peter Steinberger 236f8560b3 fix: reasoning iMessage sessions + final reply (#655) (thanks @antons) 2026-01-10 15:31:57 +01:00
Peter Steinberger ae3711bfbd Merge pull request #659 from mickahouan/fix/dedupe-message-tool
Fix: avoid duplicate replies when message tool sends
2026-01-10 14:30:02 +00:00
Peter Steinberger 449bee9645 fix: bundle node runtime for mac app 2026-01-10 15:28:37 +01:00
Peter Steinberger 4d146ea2f5 fix: dedupe message tool replies (#659) (thanks @mickahouan) 2026-01-10 15:28:13 +01:00
Anton Sotkov 3b5149ca39 fix: send only final answer with reasoning
When reasoning is enabled on non‑block providers, we now ignore interim streaming chunks and send only the final assistant answer at completion, so replies aren’t partial or duplicated.
2026-01-10 15:28:04 +01:00
Anton Sotkov 4c86da044e fix(sessions): persist reasoning/elevated across DMs 2026-01-10 15:28:04 +01:00
Mickaël Ahouansou d01e06f09a Fix: dedupe message tool sends 2026-01-10 15:25:20 +01:00
henrymascot c782404bee docs(showcase): add Adam H's multi-agent swarm
- 14+ Clawdbot agents under single gateway
- Opus 4.5 orchestrator + Codex workers
- Self-maintaining agent architecture
- Open-sourced clawdspace sandbox
2026-01-10 14:21:18 +00:00
Peter Steinberger 6019c1e718 Merge pull request #656 from mneves75/feat/minimax-api-auth-v2
Config: add MiniMax direct API authentication option
2026-01-10 14:13:24 +00:00
Peter Steinberger 65c2532cd5 fix: minimax apiKey optional for providers (#656) (thanks @mneves75) 2026-01-10 15:08:12 +01:00
mneves75 3e2e3eb023 Config: add MiniMax direct API authentication option
Makes apiKey optional in ModelProviderConfig so MiniMax can use auth
profiles or environment variables (MINIMAX_API_KEY) instead of requiring
explicit config.

Changes:
- src/config/types.ts: apiKey changed from required to optional
- src/config/zod-schema.ts: use z.string().min(1).optional() for validation
- src/commands/configure.ts: add minimax-api auth choice in wizard
- src/commands/onboard-auth.ts: MiniMax provider omits apiKey (uses env/auth)
- patches/@mariozechner__pi-ai@0.42.1.patch: map minimax → MINIMAX_API_KEY

Auth Resolution Order (unchanged):
1. Auth profiles (highest priority)
2. Environment variables (MINIMAX_API_KEY, etc.)
3. Custom provider apiKey from models.json (lowest priority)
2026-01-10 10:57:09 -03:00
0258c746bc docs(telegram): Add @userinfobot tip with privacy note (#649)
* docs(telegram): Add @userinfobot tip with screenshot and privacy note

* docs: adjust telegram userinfobot tip

---------

Co-authored-by: sheeek <gitlab@ott.team>
Co-authored-by: Ayaan Zaidi <zaidi@uplause.io>
2026-01-10 19:21:51 +05:30
Peter Steinberger 920b3880c1 test: add elevated mode regressions 2026-01-10 05:31:48 +01:00
Peter Steinberger 66db6c749d fix: persist elevated off override 2026-01-10 05:23:46 +01:00
Peter Steinberger e4abd06094 chore(release): update appcast for 2026.1.9 2026-01-10 05:23:34 +01:00
Peter Steinberger 98abf3983a docs(changelog): move 2026.1.10 fixes 2026-01-10 04:19:43 +00:00
Peter Steinberger 10102e1cf2 docs(changelog): note regression coverage 2026-01-10 04:14:39 +00:00
Peter Steinberger a057f6a306 test(docker): add multi-container gateway network smoke 2026-01-10 04:14:39 +00:00
Peter Steinberger 2045395ccb test(live): add optional write/bash probes 2026-01-10 04:14:39 +00:00
Peter Steinberger d3674f4d6c test(onboard): cover remote + lan token flows 2026-01-10 04:14:39 +00:00
Peter Steinberger cdb915d527 chore: normalize Clawdbot naming 2026-01-10 05:14:09 +01:00
Peter Steinberger a7c8341452 feat: show more session flags 2026-01-10 05:14:07 +01:00
Peter Steinberger 7b478909b2 chore(release): bump to 2026.1.10 2026-01-10 04:55:43 +01:00
Peter Steinberger 7b392ca74b test(onboard): gateway token auth flow 2026-01-10 03:54:29 +00:00
Peter Steinberger eee04fa2ce fix(onboard): persist gateway token in config 2026-01-10 03:54:25 +00:00
Peter Steinberger 025f794f0f docs(testing): document onboarding + wizard regressions 2026-01-10 03:47:44 +00:00
Peter Steinberger 4fac94f259 test(gateway): add wizard e2e + isolate live suite 2026-01-10 03:44:21 +00:00
Peter Steinberger 0f409cb99d test(telegram): force real timers for media groups 2026-01-10 03:44:21 +00:00
Peter Steinberger 241bc737cf test(telegram): deflake media groups on windows 2026-01-10 04:43:04 +01:00
Peter Steinberger cc8d3d331a fix(release): include whatsapp in npm pack 2026-01-10 04:34:24 +01:00
Peter Steinberger 5ec2018c8a fix(release): include msteams in npm pack 2026-01-10 04:30:56 +01:00
Peter Steinberger 6b9e1b9dbb docs: finalize 2026.1.9 changelog top 2026-01-10 04:25:54 +01:00
Peter Steinberger 16b4df4a9d docs(changelog): note oauth tool fix 2026-01-10 04:25:54 +01:00
Peter Steinberger 2a0d8f6e38 test(gateway): avoid real OpenAI in mock test 2026-01-10 03:25:45 +00:00
Peter Steinberger 056c4ae622 test(telegram): harden media tests 2026-01-10 04:18:05 +01:00
Peter Steinberger 6274adce3a fix(tools): keep canonical tool names for oauth 2026-01-10 04:18:00 +01:00
Peter Steinberger 2772b39e4a docs: rewrite changelog for 2026.1.9 2026-01-10 04:17:30 +01:00
Peter Steinberger 4f79122068 test: stabilize gateway mock + lint 2026-01-10 03:14:13 +00:00
Peter Steinberger 7376d1e6c9 feat: improve tui status output 2026-01-10 04:07:15 +01:00
Peter Steinberger f918d30a58 docs(testing): refresh live docker runners 2026-01-10 03:06:07 +00:00
Peter Steinberger 782863ea6c test(gateway): add offline tool-calling regression 2026-01-10 03:06:02 +00:00
Peter Steinberger cc1c5f800f fix(tools): harden schemas and oauth tool names 2026-01-10 03:05:56 +00:00
Peter Steinberger f241859c98 fix: relax fetch typing for Bun 2026-01-10 04:01:06 +01:00
Peter Steinberger 8466e53b5d fix: restore Anthropic OAuth tool dispatch 2026-01-10 04:01:00 +01:00
Peter Steinberger 8e63cd9a76 feat: improve tui status line 2026-01-10 03:53:32 +01:00
Peter Steinberger 895cd06ecc chore: add dev gateway/tui scripts 2026-01-10 03:53:32 +01:00
Peter Steinberger 2cfed7952e test(telegram): relax media group timeouts on Windows 2026-01-10 02:32:03 +00:00
Peter Steinberger 8a5fb796c0 test(live): probe Read tool via gateway 2026-01-10 02:25:24 +00:00
Peter Steinberger 34664601e0 fix(auto-reply): default audioAsVoice to false 2026-01-10 02:25:19 +00:00
Peter Steinberger d693f02fa7 fix(msteams): download image attachments reliably 2026-01-10 02:25:14 +00:00
Peter Steinberger 003cda73e8 style: fix biome formatting 2026-01-10 02:11:43 +00:00
Peter Steinberger afe6f182ca feat: show effective config in /debug 2026-01-10 03:10:14 +01:00
Peter Steinberger 5a6ae2624e docs: add /config get alias 2026-01-10 03:10:14 +01:00
Peter Steinberger 2dc7872ad1 feat: auto-start sandbox browser 2026-01-10 02:06:21 +00:00
Peter Steinberger 8b579c91a5 feat: add /config chat config updates 2026-01-10 03:01:27 +01:00
Peter Steinberger 63b0a16357 feat(cli): colorize gateway health + daemon output 2026-01-10 03:01:27 +01:00
Peter Steinberger f28a4a34ad refactor: unify inline directives and media fetch 2026-01-10 03:01:04 +01:00
Peter Steinberger 4075895c4c refactor: consolidate reply/media helpers 2026-01-10 02:41:16 +01:00
Peter Steinberger 9cd2662a86 refactor: centralize WhatsApp target normalization 2026-01-10 02:39:52 +01:00
Peter Steinberger 8f8caa8d89 test(telegram): relax media group test timeouts 2026-01-10 02:37:59 +01:00
Peter Steinberger 991f6dda38 Merge pull request #631 from imfing/fix-normalize
fix(whatsapp): normalize targets for groups and E.164
2026-01-10 01:27:33 +00:00
Peter Steinberger 2485701835 fix: hide tui reasoning label when off 2026-01-10 01:25:15 +00:00
Peter Steinbergerandneist f634db5c17 fix: signal own reactions match uuid + phone (#632) (thanks @neist)
Co-authored-by: neist <1029724+neist@users.noreply.github.com>
2026-01-10 02:24:57 +01:00
Xin f0700e9778 fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing) 2026-01-10 02:22:13 +01:00
Peter Steinberger 172fc777ed ci: use node 22.x 2026-01-10 02:20:20 +01:00
Peter Steinberger a6822e1210 ci: pin node to engine minimum 2026-01-10 02:17:59 +01:00
Peter Steinberger 5b8f2911df chore: sync pnpm patches after pull 2026-01-10 02:17:19 +01:00
Peter Steinberger ede3cd78c8 docs: document testing kit 2026-01-10 01:15:47 +00:00
Peter Steinberger 092d357187 Merge pull request #633 from levifig/feat/remove-ack-after-reply
feat: add removeAckAfterReply option (Discord, Slack, Telegram)
2026-01-10 01:14:33 +00:00
Peter Steinberger 38e2362be6 fix: remove ack reactions after reply (#633) (thanks @levifig) 2026-01-10 02:14:14 +01:00
Levi Figueira b5858c0148 feat: add removeAckAfterReply option for Discord, Slack, and Telegram
Add `messages.removeAckAfterReply` config option to automatically remove
acknowledgment reactions after the bot sends a reply, reducing visual
clutter while still providing immediate feedback.

Platforms: Discord, Slack, Telegram

Implementation:
- Added removeAckAfterReply boolean field to MessagesConfig (default: false)
- Track ack reaction state in all three platform handlers
- Remove ack reaction after successful reply delivery
- Graceful error handling with verbose logging

Platform-specific:
- Discord: uses removeReactionDiscord()
- Slack: uses removeSlackReaction()
- Telegram: uses setMessageReaction() with empty array

Closes #627
2026-01-10 02:13:46 +01:00
Peter Steinberger a29f5dda2e test(live): gateway smoke across profile-key models 2026-01-10 01:09:41 +00:00
Peter Steinberger 3ed877a813 fix: sandbox browser CDP proxy 2026-01-10 01:09:41 +00:00
Peter Steinberger 28b7e87c99 fix(pnpm): regenerate patchedDependencies 2026-01-10 01:09:41 +00:00
Peter Steinberger 623d1e11f1 refactor: centralize session agent resolution 2026-01-10 01:57:54 +01:00
Peter Steinberger f4b3869f45 Merge pull request #490 from jarvis-medmatic/feat/audio-as-voice-tag
feat(telegram): `[[audio_as_voice]]` tag support
2026-01-10 00:52:02 +00:00
Peter Steinberger c56b2f4bc1 fix: honor audio_as_voice streaming + parse tests (#490) (thanks @jarvis-medmatic) 2026-01-10 01:50:33 +01:00
Peter Steinberger c75d77e36c ci: add clean install check 2026-01-10 01:50:32 +01:00
Peter Steinberger 45ff927980 ci: enforce frozen pnpm install 2026-01-10 01:49:24 +01:00
Peter Steinberger a4ea47be37 fix: refresh @buape/carbon patch 2026-01-10 01:48:42 +01:00
Peter Steinberger cb10682d3e fix(openai): avoid invalid reasoning replay 2026-01-10 00:45:10 +00:00
JarvisandManuel Hettich 5fedfd8d15 chore: format audioAsVoice updates
Co-authored-by: Manuel Hettich <17690367+ManuelHettich@users.noreply.github.com>
2026-01-10 01:44:57 +01:00
JarvisandManuel Hettich 8c7d1781bc fix(pi): preserve audio_as_voice tag
- Allow flag-only chunks so audio_as_voice propagates
- Keep reply item scan aware of empty audio tag blocks

Co-authored-by: Manuel Hettich <17690367+ManuelHettich@users.noreply.github.com>
2026-01-10 01:43:51 +01:00
Peter Steinberger 626b085c85 docs: improve Hetzner 24/7 VPS guide 2026-01-10 01:43:28 +01:00
Jarvis 9a7f050568 refactor(telegram): centralize voice decisions
- Share voice compatibility decision logic across send + bot flows
- Keep voice fallback logging consistent
- Simplify voice handling in the audio send path
2026-01-10 01:43:23 +01:00
Jarvis ce786762db fix(telegram): guard voice note sends 2026-01-10 01:43:01 +01:00
JarvisandManuel Hettich 2f036f7173 fix(audio): preserve audioAsVoice propagation
- Keep audioAsVoice-only payloads from being filtered out
- Allow empty payloads through when they carry the flag
- Remove temporary debug logs around audioAsVoice buffering

Co-authored-by: Manuel Hettich <17690367+ManuelHettich@users.noreply.github.com>
2026-01-10 01:42:09 +01:00
JarvisandManuel Hettich 05a99aa49b feat(telegram): buffer audio blocks for [[audio_as_voice]] tag support
- Add [[audio_as_voice]] detection to splitMediaFromOutput()
- Pass audioAsVoice through onBlockReply callback chain
- Buffer audio blocks during streaming, flush at end with correct flag
- Non-audio media still streams immediately
- Fix: emit payloads with audioAsVoice flag even if text is empty

Co-authored-by: Manuel Hettich <17690367+ManuelHettich@users.noreply.github.com>
2026-01-10 01:41:18 +01:00
Peter Steinberger 60bd65dfac Merge pull request #630 from adam91holt/fix/heartbeat-default-agent-only
fix: only inject heartbeat prompt for default agent
2026-01-10 00:34:13 +00:00
Peter Steinberger 43975a39dc fix: gate heartbeat prompt to default agent sessions (#630) (thanks @adam91holt) 2026-01-10 01:32:58 +01:00
Adam HoltandClaude Opus 4.5 c91ec2aab7 fix: only inject heartbeat prompt for default agent
The heartbeat prompt from agents.defaults.heartbeat.prompt was being
injected into the system prompt for ALL agents, causing non-default
agents to read the default agent's identity files and adopt its persona.

Now the heartbeat prompt is only included when the session's agent ID
matches the configured default agent. Other agents receive no heartbeat
section in their system prompt.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 01:32:30 +01:00
Peter Steinberger 5898304fa0 fix: abort runs between tool calls 2026-01-10 01:26:25 +01:00
Peter Steinberger a0a64a625e docs: document OpenCode Zen config (#623) 2026-01-10 01:26:25 +01:00
Peter Steinberger 53ec8e36cb refactor: centralize failover error parsing 2026-01-10 01:26:06 +01:00
Peter Steinberger 82ffcfb181 fix(web): show all WhatsApp shared contacts 2026-01-10 00:22:31 +00:00
Peter Steinberger 20d4773f14 fix(agents): require raw for gateway config.apply (#566) (thanks @sircrumpet) 2026-01-10 00:21:15 +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
675 changed files with 49014 additions and 7847 deletions

No files matched your search

+116 -4
View File
@@ -5,6 +5,57 @@ on:
pull_request:
jobs:
install-check:
runs-on: blacksmith-4vcpu-ubuntu-2404
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: 22.x
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 (frozen)
env:
CI: true
run: |
export PATH="$NODE_BIN:$PATH"
which node
node -v
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
checks:
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
@@ -54,7 +105,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
node-version: 22.x
check-latest: true
- name: Setup Bun
@@ -85,7 +136,7 @@ jobs:
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
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
@@ -133,7 +184,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
node-version: 22.x
check-latest: true
- name: Setup Bun
@@ -164,11 +215,72 @@ jobs:
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
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- 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: 22.x
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 --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --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
View File
@@ -1,5 +1,6 @@
node_modules
.env
docker-compose.extra.yml
dist
*.bun-build
pnpm-lock.yaml
+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
+8 -1
View File
@@ -9,6 +9,7 @@
- Docs are hosted on Mintlify (docs.clawd.bot).
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
## Build, Test, and Development Commands
@@ -34,6 +35,8 @@
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (Clawdbot-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
- Full kit + what’s covered: `docs/testing.md`.
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
- Mobile: before using a simulator, check for connected real devices (iOS + Android) and prefer them when available.
@@ -41,6 +44,7 @@
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- Group related changes; avoid bundling unrelated refactors.
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
@@ -65,6 +69,9 @@
## 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.
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- 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 +94,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.
+146 -125
View File
@@ -1,130 +1,151 @@
# Changelog
## Unreleased
## 2026.1.10
- 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
- CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
- 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
- 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
- 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
- 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
- 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).
- CLI: move `clawdbot message` to subcommands (`message send|poll|…`), fold Discord/Slack/Telegram/WhatsApp tools into `message`, and require `--provider` unless only one provider is configured.
- CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging.
- 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
- 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.
- Agent: enable adaptive context pruning by default for tool-result trimming.
- 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
- Doctor: run legacy state migrations in non-interactive mode without prompts.
- Cron: parse Telegram topic targets for isolated delivery. (#478) — thanks @nachoiacovino
- 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
- 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: fix /status usage summary filtering for the active provider.
- 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.
- Status: show provider usage windows when auth uses token-based OAuth (e.g. Claude setup-token).
- Agent: promote `<think>`/`<thinking>` tag reasoning into structured thinking blocks so `/reasoning` works consistently for OpenAI-compat providers.
- macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy
- WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj
- Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini).
- Control UI: logs tab opens at the newest entries (bottom).
- Control UI: default to relative paths for control UI assets. (#569) — thanks @bjesuiter
- 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
- 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: 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
- 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
- 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
- Onboarding: avoid “token expired” for Codex CLI when expiry is heuristic.
- Onboarding: QuickStart jumps straight into provider selection with Telegram preselected when unset.
- Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker).
- Onboarding: clarify WhatsApp owner number prompt and label pairing phone number.
- Auto-reply: normalize routed replies to drop NO_REPLY and apply response prefixes.
- Commands: add /debug for runtime config overrides (memory-only).
- Daemon runtime: remove Bun from selection options.
- CLI: restore hidden `gateway-daemon` alias for legacy launchd configs.
- Onboarding/Configure: add OpenAI API key flow that stores in shared `~/.clawdbot/.env` for launchd; simplify Anthropic token prompt order.
- Configure/Onboarding: show Control UI docs with gateway reachability status and only offer to open when a gateway is detected; default model prompt now prefers Opus 4.5 for Anthropic auth.
- Control UI: show skill install progress + per-skill results, hide install once binaries present. (#445) — thanks @pkrmf
- Providers/Doctor: surface Discord privileged intent (Message Content) misconfiguration with actionable warnings.
- Providers/Doctor: warn when Telegram config expects unmentioned group messages but Bot API privacy mode is likely enabled; surface WhatsApp login/disconnect hints.
- Providers/Doctor: add last inbound/outbound activity timestamps in `providers status` and extend `--probe` with Discord channel permission + Telegram group membership audits.
- Docs: add provider troubleshooting index (`/providers/troubleshooting`) and link it from the main troubleshooting guide.
- Docs: clarify model allowlist errors and add safety notes for verbose/reasoning in groups.
- Docs: add Ansible installation guide. (#545) — thanks @pasogott
- Telegram: include the user id in DM pairing messages and label it clearly in `clawdbot pairing list --provider telegram`.
- Apps: refresh iOS/Android/macOS app icons for Clawdbot branding. (#521) — thanks @fishfisher
- 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
- 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.
- Status: fix Claude usage snapshots when `anthropic:default` is a setup-token lacking `user:profile` by preferring `anthropic:claude-cli`; optional claude.ai fallback via `CLAUDE_AI_SESSION_KEY` / `CLAUDE_WEB_COOKIE`.
- Commands: allow /elevated off in groups without a mention; keep /elevated on mention-gated.
- Commands: keep multi-directive messages from clearing directive handling.
- Commands: warn when /elevated runs in direct (unsandboxed) runtime.
- Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond.
- Commands: return /status in directive-only multi-line messages.
- Models: fall back to configured models when the provider catalog is unavailable.
- Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) — thanks @neist
- Agent: bypass Anthropic OAuth tool-name blocks by capitalizing built-ins and keeping pruning tool matching case-insensitive. (#553) — thanks @andrewting19
- Commands/Tools: disable /restart and gateway restart tool by default (enable with commands.restart=true).
- Gateway/CLI: add `clawdbot gateway discover` (Bonjour scan on `local.` + `clawdbot.internal.`) with `--timeout` and `--json`. — thanks @steipete
- Gateway/CLI: make `clawdbot gateway status` human-readable by default, add `--json`, and probe localhost + configured remote (warn on multiple gateways). — thanks @steipete
- Gateway/CLI: support remote loopback gateways via SSH tunnel in `clawdbot gateway status` (`--ssh` / `--ssh-auto`). — thanks @steipete
- Gateway/Discovery: include `gatewayPort`/`sshPort`/`cliPath` in wide-area Bonjour records, and add a tailnet DNS fallback for `gateway discover` when split DNS isn’t configured. — thanks @steipete
- CLI: add global `--no-color` (and respect `NO_COLOR=1`) to disable ANSI output. — thanks @steipete
- CLI: centralize lobster palette + apply it to onboarding/config prompts. — thanks @steipete
- Gateway/CLI: add `clawdbot gateway --dev/--reset` to auto-create a dev config/workspace with a robot identity (no BOOTSTRAP.md), and reset wipes config/creds/sessions/workspace (subcommand --dev no longer collides with global --dev profile). — thanks @steipete
- Configure: stop prompting to open the Control UI (still shown in onboarding). — thanks @steipete
- Configure: add wizard mode to remove a provider config block. — thanks @steipete
- Onboarding/TUI: prompt to start TUI (best option) when BOOTSTRAP.md exists and add `tui --message` to auto-send the first prompt. — thanks @steipete
- Telegram: suppress grammY getUpdates stack traces; log concise retry message instead. — thanks @steipete
- Gateway/CLI: allow dev profile (`clawdbot --dev`) to auto-create the dev config + workspace. — thanks @steipete
- 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
### Highlights
- CLI: `clawdbot status` now table-based + shows OS/update/gateway/daemon/agents/sessions; `status --all` adds a full read-only debug report (tables, log tails, Tailscale summary, and scan progress via OSC-9 + spinner).
- CLI Backends: add Codex CLI fallback with resume support (text output) and JSONL parsing for new runs, plus a live CLI resume probe.
- CLI: add `clawdbot update` (safe-ish git checkout update) + `--update` shorthand. (#673) — thanks @fm1randa.
- Gateway: add OpenAI-compatible `/v1/chat/completions` HTTP endpoint (auth, SSE streaming, per-agent routing). (#680) — thanks @steipete.
### Changes
- Onboarding/Models: add first-class Z.AI (GLM) auth choice (`zai-api-key`) + `--zai-api-key` flag.
- CLI/Onboarding: add OpenRouter API key auth option in configure/onboard. (#703) — thanks @mteam88.
- Agents: add human-delay pacing between block replies (modes: off/natural/custom, per-agent configurable). (#446) — thanks @tony-freedomology.
- Agents/Browser: add `browser.target` (sandbox/host/custom) with sandbox host-control gating via `agents.defaults.sandbox.browser.allowHostControl`, allowlists for custom control URLs/hosts/ports, and expand browser tool docs (remote control, profiles, internals).
- Onboarding/Models: add catalog-backed default model picker to onboarding + configure. (#611) — thanks @jonasjancarik.
- Agents/OpenCode Zen: update fallback models + defaults, keep legacy alias mappings. (#669) — thanks @magimetal.
- Providers: unify group history context wrappers across providers with per-provider/per-account `historyLimit` overrides (fallback to `messages.groupChat.historyLimit`). Set `0` to disable. (#672) — thanks @steipete.
- Gateway/Heartbeat: optionally deliver heartbeat `Reasoning:` output (`agents.defaults.heartbeat.includeReasoning`). (#690)
- Docker: allow optional home volume + extra bind mounts in `docker-setup.sh`. (#679) — thanks @gabriel-trigo.
### Fixes
- CLI/Status: expand tables to full terminal width; clarify provider setup vs runtime warnings; richer per-provider detail; token previews in `status` while keeping `status --all` redacted; add troubleshooting link footer; keep log tails pasteable; show gateway auth used when reachable; surface provider runtime errors (Signal/iMessage/Slack); harden `tailscale status --json` parsing; make `status --all` scan progress determinate; and replace the footer with a 3-line “Next steps” recommendation (share/debug/probe).
- CLI/Gateway: clarify that `clawdbot gateway status` reports RPC health (connect + RPC) and shows RPC failures separately from connect failures.
- CLI/Update: gate progress spinner on stdout TTY and align clean-check step label. (#701) — thanks @bjesuiter.
- Telegram: add `/whoami` + `/id` commands to reveal sender id for allowlists; allow `@username` and prefixed ids in `allowFrom` prompts (with stability warning).
- Control UI: stop auto-writing `telegram.groups["*"]` and warn/confirm before enabling wildcard groups.
- WhatsApp: send ack reactions only for handled messages and ignore legacy `messages.ackReaction` (doctor copies to `whatsapp.ackReaction`). (#629) — thanks @pasogott.
- Sandbox/Skills: mirror skills into sandbox workspaces for read-only mounts so SKILL.md stays accessible.
- Terminal/Table: ANSI-safe wrapping to prevent table clipping/color loss; add regression coverage.
- Docker: allow optional apt packages during image build and document the build arg. (#697) — thanks @gabriel-trigo.
- Gateway/Heartbeat: deliver reasoning even when the main heartbeat reply is `HEARTBEAT_OK`. (#694) — thanks @antons.
- macOS: clear unsigned launchd overrides on signed restarts and warn via doctor when attach-only/disable markers are set. (#695) — thanks @jeffersonwarrior.
- Agents: enforce single-writer session locks and drop orphan tool results to prevent tool-call ID failures (MiniMax/Anthropic-compatible APIs).
- Docs: make `clawdbot status` the first diagnostic step, clarify `status --deep` behavior, and document `/whoami` + `/id`.
- Docs/Testing: clarify live tool+image probes and how to list your testable `provider/model` ids.
- Tests/Live: make gateway bash+read probes resilient to provider formatting while still validating real tool calls.
- WhatsApp: detect @lid mentions in groups using authDir reverse mapping + resolve self JID E.164 for mention gating. (#692) — thanks @peschee.
- Gateway/Auth: default to token auth on loopback during onboarding, add doctor token generation flow, and tighten audio transcription config to Whisper-only.
- Providers: dedupe inbound messages across providers to avoid duplicate LLM runs on redeliveries/reconnects. (#689) — thanks @adam91holt.
- Agents: strip `<thought>`/`<antthinking>` tags from hidden reasoning output and cover tag variants in tests. (#688) — thanks @theglove44.
- macOS: save model picker selections as normalized provider/model IDs and keep manual entries aligned. (#683) — thanks @benithors.
- Agents: recognize "usage limit" errors as rate limits for failover. (#687) — thanks @evalexpr.
- CLI: avoid success message when daemon restart is skipped. (#685) — thanks @carlulsoe.
- Commands: disable `/config` + `/debug` by default; gate via `commands.config`/`commands.debug` and hide from native registration/help output.
- Agents/System: clarify that sub-agents remain sandboxed and cannot use elevated host access.
- Gateway: disable the OpenAI-compatible `/v1/chat/completions` endpoint by default; enable via `gateway.http.endpoints.chatCompletions.enabled=true`.
- macOS: stabilize bridge tunnels, guard invoke senders on disconnect, and drain stdout/stderr to avoid deadlocks. (#676) — thanks @ngutman.
- Agents/System: clarify sandboxed runtime in system prompt and surface elevated availability when sandboxed.
- Auto-reply: prefer `RawBody` for command/directive parsing (WhatsApp + Discord) and prevent fallback runs from clobbering concurrent session updates. (#643) — thanks @mcinteerj.
- WhatsApp: fix group reactions by preserving message IDs and sender JIDs in history; normalize participant phone numbers to JIDs in outbound reactions. (#640) — thanks @mcinteerj.
- WhatsApp: expose group participant IDs to the model so reactions can target the right sender.
- Cron: `wakeMode: "now"` waits for heartbeat completion (and retries when the main lane is busy). (#666) — thanks @roshanasingh4.
- Agents/OpenAI: fix Responses tool-only → follow-up turn handling (avoid standalone `reasoning` items that trigger 400 “required following item”) and replay reasoning items in Responses/Codex Responses history for tool-call-only turns.
- Sandbox: add `clawdbot sandbox explain` (effective policy inspector + fix-it keys); improve “sandbox jail” tool-policy/elevated errors with actionable config key paths; link to docs.
- Hooks/Gmail: keep Tailscale serve path at `/` while preserving the public path. (#668) — thanks @antons.
- Hooks/Gmail: allow Tailscale target URLs to preserve internal serve paths.
- Auth: update Claude Code keychain credentials in-place during refresh sync; share JSON file helpers; add CLI fallback coverage.
- Auth: throttle external CLI credential syncs (Claude/Codex), reduce Keychain reads, and skip sync when cached credentials are still fresh.
- CLI: respect `CLAWDBOT_STATE_DIR` for node pairing + voice wake settings storage. (#664) — thanks @azade-c.
- Onboarding/Gateway: persist non-interactive gateway token auth in config; add WS wizard + gateway tool-calling regression coverage.
- Gateway/Control UI: make `chat.send` non-blocking, wire Stop to `chat.abort`, and treat `/stop` as an out-of-band abort. (#653)
- Gateway/Control UI: allow `chat.abort` without `runId` (abort active runs), suppress post-abort chat streaming, and prune stuck chat runs. (#653)
- Gateway/Control UI: sniff image attachments for chat.send, drop non-images, and log mismatches. (#670) — thanks @cristip73.
- macOS: force `restart-mac.sh --sign` to require identities and keep bundled Node signed for relay verification. (#580) — thanks @jeffersonwarrior.
- Gateway/Agent: accept image attachments on `agent` (multimodal message) and add live gateway image probe (`CLAWDBOT_LIVE_GATEWAY_IMAGE_PROBE=1`).
- CLI: `clawdbot sessions` now includes `elev:*` + `usage:*` flags in the table output.
- CLI/Pairing: accept positional provider for `pairing list|approve` (npm-run compatible); update docs/bot hints.
- Branding: normalize user-facing “ClawdBot”/“CLAWDBOT” → “Clawdbot” (CLI, status, docs).
- Auto-reply: fix native `/model` not updating the actual chat session (Telegram/Slack/Discord). (#646)
- Doctor: offer to run `clawdbot update` first on git installs (keeps doctor output aligned with latest).
- Doctor: avoid false legacy workspace warning when install dir is `~/clawdbot`. (#660)
- iMessage: fix reasoning persistence across DMs; avoid partial/duplicate replies when reasoning is enabled. (#655) — thanks @antons.
- Models/Auth: allow MiniMax API configs without `models.providers.minimax.apiKey` (auth profiles / `MINIMAX_API_KEY`). (#656) — thanks @mneves75.
- Agents: avoid duplicate replies when the message tool sends. (#659) — thanks @mickahouan.
- Agents: harden Cloud Code Assist tool ID sanitization (toolUse/toolCall/toolResult) and scrub extra JSON Schema constraints. (#665) — thanks @sebslight.
- Agents: sanitize tool results + Cloud Code Assist tool IDs at context-build time (prevents mid-run strict-provider request rejects).
- Agents/Tools: resolve workspace-relative Read/Write/Edit paths; align bash default cwd. (#642) — thanks @mukhtharcm.
- Discord: include forwarded message snapshots in agent session context. (#667) — thanks @rubyrunsstuff.
- Telegram: add `telegram.draftChunk` to tune draft streaming chunking for `streamMode: "block"`. (#667) — thanks @rubyrunsstuff.
- Tests/Agents: add regression coverage for workspace tool path resolution and bash cwd defaults.
- iOS/Android: enable stricter concurrency/lint checks; fix Swift 6 strict concurrency issues + Android lint errors (ExifInterface, obsolete SDK check). (#662) — thanks @KristijanJovanovski.
- iOS/macOS: share `AsyncTimeout`, require explicit `bridgeStableID` on connect, and harden tool display defaults (avoids missing-resource label fallbacks).
- Telegram: serialize media-group processing to avoid missed albums under load.
- Signal: handle `dataMessage.reaction` events (signal-cli SSE) to avoid broken attachment errors. (#637) — thanks @neist.
- Docs: showcase entries for ParentPay, R2 Upload, iOS TestFlight, and Oura Health. (#650) — thanks @henrino3.
- Agents: repair session transcripts by dropping duplicate tool results across the whole history (unblocks Anthropic-compatible APIs after retries).
- Tests/Live: reset the gateway session between model runs to avoid cross-provider transcript incompatibilities (notably OpenAI Responses reasoning replay rules).
## 2026.1.9
### Highlights
- Microsoft Teams provider: polling, attachments, outbound CLI send, per-channel policy.
- Models/Auth expansion: OpenCode Zen + MiniMax API onboarding; token auth profiles + auth order; OAuth health in doctor/status.
- CLI/Gateway UX: message subcommands, gateway discover/status/SSH, /config + /debug, sandbox CLI.
- Provider reliability sweep: WhatsApp contact cards/targets, Telegram audio-as-voice + streaming, Signal reactions, Slack threading, Discord stability.
- Auto-reply + status: block-streaming controls, reasoning handling, usage/cost reporting.
- Control UI/TUI: queued messages, session links, reasoning view, mobile polish, logs UX.
### Breaking
- CLI: `clawdbot message` now subcommands (`message send|poll|...`) and requires `--provider` unless only one provider configured.
- Commands/Tools: `/restart` and gateway restart tool disabled by default; enable with `commands.restart=true`.
### New Features and Changes
- Models/Auth: OpenCode Zen onboarding (#623) — thanks @magimetal; MiniMax Anthropic-compatible API + hosted onboarding (#590, #495) — thanks @mneves75, @tobiasbischoff.
- Models/Auth: setup-token + token auth profiles; `clawdbot models auth order {get,set,clear}`; per-agent auth candidates in `/model status`; OAuth expiry checks in doctor/status.
- Agent/System: claude-cli runner; `session_status` tool (and sandbox allow); adaptive context pruning default; system prompt messaging guidance + no auto self-update; eligible skills list injection; sub-agent context trimmed.
- Commands: `/commands` list; `/models` alias; `/usage` alias; `/debug` runtime overrides + effective config view; `/config` chat updates + `/config get`; `config --section`.
- CLI/Gateway: unified message tool + message subcommands; gateway discover (local + wide-area DNS-SD) with JSON/timeout; gateway status human-readable + JSON + SSH loopback; wide-area records include gatewayPort/sshPort/cliPath + tailnet DNS fallback.
- CLI UX: logs output modes (pretty/plain/JSONL) + colorized health/daemon output; global `--no-color`; lobster palette in onboarding/config.
- Dev ergonomics: gateway `--dev/--reset` + dev profile auto-config; C-3PO dev templates; dev gateway/TUI helper scripts.
- Sandbox/Workspace: sandbox list/recreate commands; sync skills into sandbox workspace; sandbox browser auto-start.
- Config/Onboarding: inline env vars; OpenAI API key flow to shared `~/.clawdbot/.env`; Opus 4.5 default prompt for Anthropic auth; QuickStart auto-install gateway (Node-only) + provider picker tweaks + skip-systemd flags; TUI bootstrap prompt (`tui --message`); remove Bun runtime choice.
- Providers: Microsoft Teams provider (polling, attachments, outbound sends, requireMention, config reload/DM policy). (#404) — thanks @onutc
- Providers: WhatsApp broadcast groups for multi-agent replies (#547) — thanks @pasogott; inbound media size cap configurable (#505) — thanks @koala73; identity-based message prefixes (#578) — thanks @p6l-richard.
- Providers: Telegram inline keyboard buttons + callback payload routing (#491) — thanks @azade-c; cron topic delivery targets (#474/#478) — thanks @mitschabaude-bot, @nachoiacovino; `[[audio_as_voice]]` tag support (#490) — thanks @jarvis-medmatic.
- Providers: Signal reactions + notifications with allowlist support.
- Status/Usage: /status cost reporting + `/cost` lines; auth profile snippet; provider usage windows.
- Control UI: mobile responsiveness (#558) — thanks @carlulsoe; queued messages + Enter-to-send (#527) — thanks @YuriNachos; session links (#471) — thanks @HazAT; reasoning view; skill install feedback (#445) — thanks @pkrmf; chat layout refresh (#475) — thanks @rahthakor; docs link + new session button; drop explicit `ui:install`.
- TUI: agent picker + agents list RPC; improved status line.
- Doctor/Daemon: audit/repair flows, permissions checks, supervisor config audits; provider status probes + warnings for Discord intents and Telegram privacy; last activity timestamps; gateway restart guidance.
- Docs: Hetzner Docker VPS guide + cross-links (#556/#592) — thanks @Iamadig; Ansible guide (#545) — thanks @pasogott; provider troubleshooting index; hook parameter expansion (#532) — thanks @mcinteerj; model allowlist notes; OAuth deep dive; showcase refresh.
- Apps/Branding: refreshed iOS/Android/macOS icons (#521) — thanks @fishfisher.
### Fixes
- Packaging: include MS Teams send module in npm tarball.
- Sandbox/Browser: auto-start CDP endpoint; proxy CDP out of container for attachOnly; relax Bun fetch typing; align sandbox list output with config images.
- Agents/Runtime: gate heartbeat prompt to default sessions; /stop aborts between tool calls; require explicit system-event session keys; guard small context windows; fix model fallback stringification; sessions_spawn inherits provider; failover on billing/credits; respect auth cooldown ordering; restore Anthropic OAuth tool dispatch + tool-name bypass; avoid OpenAI invalid reasoning replay; harden Gmail hook model defaults.
- Agent history/schema: strip/skip empty assistant/error blocks to prevent session corruption/Claude 400s; scrub unsupported JSON Schema keywords + sanitize tool call IDs for Cloud Code Assist; simplify Gemini-compatible tool/session schemas; require raw for config.apply.
- Auto-reply/Streaming: default audioAsVoice false; preserve audio_as_voice propagation + buffer audio blocks + guard voice notes; block reply ordering (timeout) + forced-block fence-safe; avoid chunk splits inside parentheses + fence-close breaks + invalid UTF-16 truncation; preserve inline directive spacing + allow whitespace in reply tags; filter NO_REPLY prefixes + normalize routed replies; suppress <think> leakage with separate Reasoning; block streaming defaults (off by default, minChars/idle tuning) + coalesced blocks; dedupe followup queue; restore explicit responsePrefix default.
- Status/Commands: provider prefix in /status model display; usage filtering + provider mapping; auth label + usage snapshots (claude-cli fallback + optional claude.ai); show Verbose/Elevated only when enabled; compact usage/cost line + restore emoji-rich status; /status in directive-only + multi-directive handling; mention-bypass elevated handling; surface provider usage errors; wire /usage to /status; restore hidden gateway-daemon alias; fallback /model list when catalog unavailable.
- WhatsApp: vCard/contact cards (prefer FN, include numbers, show all contacts, keep summary counts, better empty summaries); preserve group JIDs + normalize targets; resolve @lid mappings/JIDs (Baileys/auth-dir) + inbound mapping; route queued replies to sender; improve web listener errors + remove provider name from errors; record outbound activity account id; fix web media fetch errors; broadcast group history consistency.
- Telegram: keep streamMode draft-only; long-poll conflict retries + update dedupe; grammY fetch mismatch fixes + restrict native fetch to Bun; suppress getUpdates stack traces; include user id in pairing; audio_as_voice handling fixes.
- Discord/Slack: thread context helpers + forum thread starters; avoid category parent overrides; gateway reconnect logs + HELLO timeout + stop provider after reconnect exhaustion; DM recipient parsing for numeric IDs; remove incorrect limited warning; reply threading + mrkdwn edge cases; remove ack reactions after reply; gateway debug event visibility.
- Signal: reaction handling safety; own-reaction matching (uuid+phone); UUID-only senders accepted; ignore reaction-only messages.
- MS Teams: download image attachments reliably; fix top-level replies; stop on shutdown + honor chunk limits; normalize poll providers/deps; pairing label fixes.
- iMessage: isolate group-ish threads by chat_id.
- Gateway/Daemon/Doctor: atomic config writes; repair gateway service entrypoint + install switches; non-interactive legacy migrations; systemd unit alignment + KillMode=process; node bridge keepalive/pings; Launch at Login persistence; bundle ClawdbotKit resources + Swift 6.2 compat dylib; relay version check + remove smoke test; regen Swift GatewayModels + keep agent provider string; cron jobId alias + channel alias migration + main session key normalization; heartbeat Telegram accountId resolution; avoid WhatsApp fallback for internal runs; gateway listener error wording; serveBaseUrl param; honor gateway --dev; fix wide-area discovery updates; align agents.defaults schema; provider account metadata in daemon status; refresh Carbon patch for gateway fixes; restore doctor prompter initialValue handling.
- Control UI/TUI: persist per-session verbose off + hide tool cards; logs tab opens at bottom; relative asset paths + landing cleanup; session labels lookup/persistence; stop pinning main session in recents; start logs at bottom; TUI status bar refresh + timeout handling + hide reasoning label when off.
- Onboarding/Configure: QuickStart single-select provider picker; avoid Codex CLI false-expiry warnings; clarify WhatsApp owner prompt; fix Minimax hosted onboarding (agents.defaults + msteams heartbeat target); remove configure Control UI prompt; honor gateway --dev flag.
### Maintenance
- Dependencies: bump pi-* stack to 0.42.2.
- Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj.
- Build: Docker build cache layer (#605) — thanks @zknicker.
- Auth: enable OAuth token refresh for Claude CLI credentials (`anthropic:claude-cli`) with bidirectional sync back to Claude Code storage (file on Linux/Windows, Keychain on macOS). This allows long-running agents to operate autonomously without manual re-authentication (#654 — thanks @radek-paclt).
## 2026.1.8
@@ -141,7 +162,7 @@
- Previously, if you didn’t configure an allowlist, your bot could be **open to anyone** (especially discoverable Telegram bots).
- New default: DM pairing (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`).
- To keep old “open to everyone” behavior: set `dmPolicy="open"` and include `"*"` in the relevant `allowFrom` (Discord/Slack: `discord.dm.allowFrom` / `slack.dm.allowFrom`).
- Approve requests via `clawdbot pairing list --provider <provider>` + `clawdbot pairing approve --provider <provider> <code>`.
- Approve requests via `clawdbot pairing list <provider>` + `clawdbot pairing approve <provider> <code>`.
- Sandbox: default `agent.sandbox.scope` to `"agent"` (one container/workspace per agent). Use `"session"` for per-session isolation; `"shared"` disables cross-session isolation.
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only).
- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup.
+14 -1
View File
@@ -8,9 +8,22 @@ RUN corepack enable
WORKDIR /app
COPY . .
ARG CLAWDBOT_DOCKER_APT_PACKAGES=""
RUN if [ -n "$CLAWDBOT_DOCKER_APT_PACKAGES" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $CLAWDBOT_DOCKER_APT_PACKAGES && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
fi
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
+1
View File
@@ -14,6 +14,7 @@ RUN apt-get update \
jq \
novnc \
python3 \
socat \
websockify \
x11vnc \
xvfb \
+23 -19
View File
@@ -1,7 +1,7 @@
# 🦞 CLAWDBOT — Personal AI Assistant
# 🦞 Clawdbot — Personal AI Assistant
<p align="center">
<img src="https://raw.githubusercontent.com/clawdbot/clawdbot/main/docs/whatsapp-clawd.jpg" alt="CLAWDBOT" width="400">
<img src="https://raw.githubusercontent.com/clawdbot/clawdbot/main/docs/whatsapp-clawd.jpg" alt="Clawdbot" width="400">
</p>
<p align="center">
@@ -98,7 +98,7 @@ Full security guide: [Security](https://docs.clawd.bot/security)
Default behavior on Telegram/WhatsApp/Signal/iMessage/Discord/Slack:
- **DM pairing** (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
- Approve with: `clawdbot pairing approve --provider <provider> <code>` (then the sender is added to a local allowlist store).
- Approve with: `clawdbot pairing approve <provider> <code>` (then the sender is added to a local allowlist store).
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the provider allowlist (`allowFrom` / `discord.dm.allowFrom` / `slack.dm.allowFrom`).
Run `clawdbot doctor` to surface risky/misconfigured DM policies.
@@ -159,15 +159,16 @@ WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
│
▼
┌───────────────────────────────┐
│ Gateway │ ws://127.0.0.1:18789
│ (control plane) │ bridge: tcp://0.0.0.0:18790
│ Gateway │
│ (control plane) │
│ ws://127.0.0.1:18789 │
└──────────────┬────────────────┘
│
├─ Pi agent (RPC)
├─ CLI (clawdbot …)
├─ WebChat UI
├─ macOS app
└─ iOS/Android nodes
└─ iOS / Android nodes
```
## Key subsystems
@@ -312,7 +313,7 @@ Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxi
### [WhatsApp](https://docs.clawd.bot/whatsapp)
- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
- Link the device: `pnpm clawdbot providers login` (stores creds in `~/.clawdbot/credentials`).
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
- If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
@@ -457,16 +458,19 @@ 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/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/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/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/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/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/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/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/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a>
<a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></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/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/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/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/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/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a>
<a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/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/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/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/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/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/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/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/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a>
<a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/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/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/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/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a>
<a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a>
<a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/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>
</p>
+64 -41
View File
@@ -1,55 +1,78 @@
<?xml version="1.0" standalone="yes"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdis</title>
<title>Clawdbot</title>
<item>
<title>2026.1.5-3</title>
<pubDate>Mon, 05 Jan 2026 04:30:46 +0100</pubDate>
<title>2026.1.9</title>
<pubDate>Sat, 10 Jan 2026 05:23:02 +0100</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>3095</sparkle:version>
<sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
<sparkle:version>4607</sparkle:version>
<sparkle:shortVersionString>2026.1.9</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
<description><![CDATA[<h2>Clawdbot 2026.1.9</h2>
<h3>Highlights</h3>
<ul>
<li>Microsoft Teams provider: polling, attachments, outbound CLI send, per-channel policy.</li>
<li>Models/Auth expansion: OpenCode Zen + MiniMax API onboarding; token auth profiles + auth order; OAuth health in doctor/status.</li>
<li>CLI/Gateway UX: message subcommands, gateway discover/status/SSH, /config + /debug, sandbox CLI.</li>
<li>Provider reliability sweep: WhatsApp contact cards/targets, Telegram audio-as-voice + streaming, Signal reactions, Slack threading, Discord stability.</li>
<li>Auto-reply + status: block-streaming controls, reasoning handling, usage/cost reporting.</li>
<li>Control UI/TUI: queued messages, session links, reasoning view, mobile polish, logs UX.</li>
</ul>
<h3>Breaking</h3>
<ul>
<li>CLI: <code>clawdbot message</code> now subcommands (<code>message send|poll|...</code>) and requires <code>--provider</code> unless only one provider configured.</li>
<li>Commands/Tools: <code>/restart</code> and gateway restart tool disabled by default; enable with <code>commands.restart=true</code>.</li>
</ul>
<h3>New Features and Changes</h3>
<ul>
<li>Models/Auth: OpenCode Zen onboarding (#623) — thanks @magimetal; MiniMax Anthropic-compatible API + hosted onboarding (#590, #495) — thanks @mneves75, @tobiasbischoff.</li>
<li>Models/Auth: setup-token + token auth profiles; <code>clawdbot models auth order {get,set,clear}</code>; per-agent auth candidates in <code>/model status</code>; OAuth expiry checks in doctor/status.</li>
<li>Agent/System: claude-cli runner; <code>session_status</code> tool (and sandbox allow); adaptive context pruning default; system prompt messaging guidance + no auto self-update; eligible skills list injection; sub-agent context trimmed.</li>
<li>Commands: <code>/commands</code> list; <code>/models</code> alias; <code>/usage</code> alias; <code>/debug</code> runtime overrides + effective config view; <code>/config</code> chat updates + <code>/config get</code>; <code>config --section</code>.</li>
<li>CLI/Gateway: unified message tool + message subcommands; gateway discover (local + wide-area DNS-SD) with JSON/timeout; gateway status human-readable + JSON + SSH loopback; wide-area records include gatewayPort/sshPort/cliPath + tailnet DNS fallback.</li>
<li>CLI UX: logs output modes (pretty/plain/JSONL) + colorized health/daemon output; global <code>--no-color</code>; lobster palette in onboarding/config.</li>
<li>Dev ergonomics: gateway <code>--dev/--reset</code> + dev profile auto-config; C-3PO dev templates; dev gateway/TUI helper scripts.</li>
<li>Sandbox/Workspace: sandbox list/recreate commands; sync skills into sandbox workspace; sandbox browser auto-start.</li>
<li>Config/Onboarding: inline env vars; OpenAI API key flow to shared <code>~/.clawdbot/.env</code>; Opus 4.5 default prompt for Anthropic auth; QuickStart auto-install gateway (Node-only) + provider picker tweaks + skip-systemd flags; TUI bootstrap prompt (<code>tui --message</code>); remove Bun runtime choice.</li>
<li>Providers: Microsoft Teams provider (polling, attachments, outbound sends, requireMention, config reload/DM policy). (#404) — thanks @onutc</li>
<li>Providers: WhatsApp broadcast groups for multi-agent replies (#547) — thanks @pasogott; inbound media size cap configurable (#505) — thanks @koala73; identity-based message prefixes (#578) — thanks @p6l-richard.</li>
<li>Providers: Telegram inline keyboard buttons + callback payload routing (#491) — thanks @azade-c; cron topic delivery targets (#474/#478) — thanks @mitschabaude-bot, @nachoiacovino; <code>[[audio_as_voice]]</code> tag support (#490) — thanks @jarvis-medmatic.</li>
<li>Providers: Signal reactions + notifications with allowlist support.</li>
<li>Status/Usage: /status cost reporting + <code>/cost</code> lines; auth profile snippet; provider usage windows.</li>
<li>Control UI: mobile responsiveness (#558) — thanks @carlulsoe; queued messages + Enter-to-send (#527) — thanks @YuriNachos; session links (#471) — thanks @HazAT; reasoning view; skill install feedback (#445) — thanks @pkrmf; chat layout refresh (#475) — thanks @rahthakor; docs link + new session button; drop explicit <code>ui:install</code>.</li>
<li>TUI: agent picker + agents list RPC; improved status line.</li>
<li>Doctor/Daemon: audit/repair flows, permissions checks, supervisor config audits; provider status probes + warnings for Discord intents and Telegram privacy; last activity timestamps; gateway restart guidance.</li>
<li>Docs: Hetzner Docker VPS guide + cross-links (#556/#592) — thanks @Iamadig; Ansible guide (#545) — thanks @pasogott; provider troubleshooting index; hook parameter expansion (#532) — thanks @mcinteerj; model allowlist notes; OAuth deep dive; showcase refresh.</li>
<li>Apps/Branding: refreshed iOS/Android/macOS icons (#521) — thanks @fishfisher.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid <code>ERR_MODULE_NOT_FOUND</code> in Node 25 npx installs.</li>
<li>Packaging: include MS Teams send module in npm tarball.</li>
<li>Sandbox/Browser: auto-start CDP endpoint; proxy CDP out of container for attachOnly; relax Bun fetch typing; align sandbox list output with config images.</li>
<li>Agents/Runtime: gate heartbeat prompt to default sessions; /stop aborts between tool calls; require explicit system-event session keys; guard small context windows; fix model fallback stringification; sessions_spawn inherits provider; failover on billing/credits; respect auth cooldown ordering; restore Anthropic OAuth tool dispatch + tool-name bypass; avoid OpenAI invalid reasoning replay; harden Gmail hook model defaults.</li>
<li>Agent history/schema: strip/skip empty assistant/error blocks to prevent session corruption/Claude 400s; scrub unsupported JSON Schema keywords + sanitize tool call IDs for Cloud Code Assist; simplify Gemini-compatible tool/session schemas; require raw for config.apply.</li>
<li>Auto-reply/Streaming: default audioAsVoice false; preserve audio_as_voice propagation + buffer audio blocks + guard voice notes; block reply ordering (timeout) + forced-block fence-safe; avoid chunk splits inside parentheses + fence-close breaks + invalid UTF-16 truncation; preserve inline directive spacing + allow whitespace in reply tags; filter NO_REPLY prefixes + normalize routed replies; suppress <think> leakage with separate Reasoning; block streaming defaults (off by default, minChars/idle tuning) + coalesced blocks; dedupe followup queue; restore explicit responsePrefix default.</li>
<li>Status/Commands: provider prefix in /status model display; usage filtering + provider mapping; auth label + usage snapshots (claude-cli fallback + optional claude.ai); show Verbose/Elevated only when enabled; compact usage/cost line + restore emoji-rich status; /status in directive-only + multi-directive handling; mention-bypass elevated handling; surface provider usage errors; wire /usage to /status; restore hidden gateway-daemon alias; fallback /model list when catalog unavailable.</li>
<li>WhatsApp: vCard/contact cards (prefer FN, include numbers, show all contacts, keep summary counts, better empty summaries); preserve group JIDs + normalize targets; resolve @lid mappings/JIDs (Baileys/auth-dir) + inbound mapping; route queued replies to sender; improve web listener errors + remove provider name from errors; record outbound activity account id; fix web media fetch errors; broadcast group history consistency.</li>
<li>Telegram: keep streamMode draft-only; long-poll conflict retries + update dedupe; grammY fetch mismatch fixes + restrict native fetch to Bun; suppress getUpdates stack traces; include user id in pairing; audio_as_voice handling fixes.</li>
<li>Discord/Slack: thread context helpers + forum thread starters; avoid category parent overrides; gateway reconnect logs + HELLO timeout + stop provider after reconnect exhaustion; DM recipient parsing for numeric IDs; remove incorrect limited warning; reply threading + mrkdwn edge cases; remove ack reactions after reply; gateway debug event visibility.</li>
<li>Signal: reaction handling safety; own-reaction matching (uuid+phone); UUID-only senders accepted; ignore reaction-only messages.</li>
<li>MS Teams: download image attachments reliably; fix top-level replies; stop on shutdown + honor chunk limits; normalize poll providers/deps; pairing label fixes.</li>
<li>iMessage: isolate group-ish threads by chat_id.</li>
<li>Gateway/Daemon/Doctor: atomic config writes; repair gateway service entrypoint + install switches; non-interactive legacy migrations; systemd unit alignment + KillMode=process; node bridge keepalive/pings; Launch at Login persistence; bundle ClawdbotKit resources + Swift 6.2 compat dylib; relay version check + remove smoke test; regen Swift GatewayModels + keep agent provider string; cron jobId alias + channel alias migration + main session key normalization; heartbeat Telegram accountId resolution; avoid WhatsApp fallback for internal runs; gateway listener error wording; serveBaseUrl param; honor gateway --dev; fix wide-area discovery updates; align agents.defaults schema; provider account metadata in daemon status; refresh Carbon patch for gateway fixes; restore doctor prompter initialValue handling.</li>
<li>Control UI/TUI: persist per-session verbose off + hide tool cards; logs tab opens at bottom; relative asset paths + landing cleanup; session labels lookup/persistence; stop pinning main session in recents; start logs at bottom; TUI status bar refresh + timeout handling + hide reasoning label when off.</li>
<li>Onboarding/Configure: QuickStart single-select provider picker; avoid Codex CLI false-expiry warnings; clarify WhatsApp owner prompt; fix Minimax hosted onboarding (agents.defaults + msteams heartbeat target); remove configure Control UI prompt; honor gateway --dev flag.</li>
</ul>
<h3>Maintenance</h3>
<ul>
<li>Dependencies: bump pi-* stack to 0.42.2.</li>
<li>Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj.</li>
<li>Build: Docker build cache layer (#605) — thanks @zknicker.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-3/Clawdbot-2026.1.5-3.zip" length="160800596" type="application/octet-stream" sparkle:edSignature="P8U3nvIFpbGmRItT/NGPmJ/i370OMVvDHYQL/znYsLI0MrbGfXgMGEvR5A0uwW+cJevlX/hrJLiY51zo4rAMBg=="/>
</item>
<item>
<title>2026.1.5-3</title>
<pubDate>Mon, 05 Jan 2026 03:57:59 +0100</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>3091</sparkle:version>
<sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
<h3>Fixes</h3>
<ul>
<li>NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid <code>ERR_MODULE_NOT_FOUND</code> in Node 25 npx installs.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-3/Clawdbot-2026.1.5-3.zip" length="160797048" type="application/octet-stream" sparkle:edSignature="5KYFg0SW7liwLxLJbfzd2KsAxbX06gMH0rH/W3a4V0p4N48hjz4AsSrfFLdGZSnW+6XaJjC3MN6Ynh+l7kffDQ=="/>
</item>
<item>
<title>2026.1.5-2</title>
<pubDate>Mon, 05 Jan 2026 03:51:30 +0100</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>3089</sparkle:version>
<sparkle:shortVersionString>2026.1.5-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.5-2</h2>
<h3>Fixes</h3>
<ul>
<li>NPM package: include <code>dist/sessions</code> so <code>clawdbot agent</code> resolves session helpers in npx installs.</li>
<li>Node 25: avoid unsupported directory import by targeting <code>qrcode-terminal/vendor/QRCode/*.js</code> modules.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-2/Clawdbot-2026.1.5-2.zip" length="150250417" type="application/octet-stream" sparkle:edSignature="ntHNmwyHrv6cPk6NAKOT3AUkwdt5ZadrGU6mJK4GmVxi44uIMT3ZXluvnqK9SxXQwA0H0dXjiGMS/cg8NbgqDA=="/>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.9/Clawdbot-2026.1.9.zip" length="164165451" type="application/octet-stream" sparkle:edSignature="jlfk60gLuMIEOru5kFe7K7lLwsx1RBZGdUnkOR7FR3GAfLum7ocUb+QUO0ZtllUoGPEK0N1yc5BLB702LgLtAQ=="/>
</item>
</channel>
</rss>
+3
View File
@@ -49,6 +49,7 @@ android {
lint {
disable += setOf("IconLauncherShape")
warningsAsErrors = true
}
testOptions {
@@ -72,6 +73,7 @@ androidComponents {
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
}
@@ -100,6 +102,7 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
implementation("androidx.security:security-crypto:1.1.0")
implementation("androidx.exifinterface:exifinterface:1.4.2")
// CameraX (for node.invoke camera.* parity)
implementation("androidx.camera:camera-core:1.5.2")
@@ -1,8 +1,26 @@
package com.clawdbot.android
import android.app.Application
import android.os.StrictMode
class NodeApp : Application() {
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
}
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
.build(),
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectAll()
.penaltyLog()
.build(),
)
}
}
}
@@ -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"
}
@@ -8,7 +8,7 @@ import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.util.Base64
import android.content.pm.PackageManager
import android.media.ExifInterface
import androidx.exifinterface.media.ExifInterface
import androidx.lifecycle.LifecycleOwner
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
@@ -122,13 +122,7 @@ class ScreenRecordManager(private val context: Context) {
)
}
private fun createMediaRecorder(): MediaRecorder =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}
private fun createMediaRecorder(): MediaRecorder = MediaRecorder(context)
private suspend fun ensureMicPermission() {
val granted =
@@ -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) ->
+4 -12
View File
@@ -161,18 +161,10 @@ actor BridgeClient {
purpose: String,
_ op: @escaping @Sendable () async throws -> T) async throws -> T
{
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await op()
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000)
throw TimeoutError(purpose: purpose, seconds: seconds)
}
let result = try await group.next()!
group.cancelAll()
return result
}
try await AsyncTimeout.withTimeout(
seconds: Double(seconds),
onTimeout: { TimeoutError(purpose: purpose, seconds: seconds) },
operation: op)
}
private func startAndWaitForReady(_ connection: NWConnection, queue: DispatchQueue) async throws {
@@ -115,7 +115,11 @@ final class BridgeConnectionController {
self.didAutoConnect = true
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
self.startAutoConnect(endpoint: endpoint, token: token, instanceId: instanceId)
self.startAutoConnect(
endpoint: endpoint,
bridgeStableID: BridgeEndpointID.stableID(endpoint),
token: token,
instanceId: instanceId)
return
}
@@ -132,7 +136,11 @@ final class BridgeConnectionController {
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
self.didAutoConnect = true
self.startAutoConnect(endpoint: target.endpoint, token: token, instanceId: instanceId)
self.startAutoConnect(
endpoint: target.endpoint,
bridgeStableID: target.stableID,
token: token,
instanceId: instanceId)
}
private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
@@ -171,7 +179,12 @@ final class BridgeConnectionController {
"bridge-token.\(instanceId)"
}
private func startAutoConnect(endpoint: NWEndpoint, token: String, instanceId: String) {
private func startAutoConnect(
endpoint: NWEndpoint,
bridgeStableID: String,
token: String,
instanceId: String)
{
guard let appModel else { return }
Task { [weak self] in
guard let self else { return }
@@ -192,7 +205,10 @@ final class BridgeConnectionController {
service: "com.clawdbot.bridge",
account: self.keychainAccount(instanceId: instanceId))
}
appModel.connectToBridge(endpoint: endpoint, hello: self.makeHello(token: resolvedToken))
appModel.connectToBridge(
endpoint: endpoint,
bridgeStableID: bridgeStableID,
hello: self.makeHello(token: resolvedToken))
} catch {
await MainActor.run {
appModel.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
+4 -14
View File
@@ -321,20 +321,10 @@ actor BridgeSession {
seconds: Double,
operation: @escaping @Sendable () async throws -> T) async throws -> T
{
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw TimeoutError(message: "UNAVAILABLE: connection timeout")
}
guard let first = try await group.next() else {
throw TimeoutError(message: "UNAVAILABLE: connection timeout")
}
group.cancelAll()
return first
}
try await AsyncTimeout.withTimeout(
seconds: seconds,
onTimeout: { TimeoutError(message: "UNAVAILABLE: connection timeout") },
operation: operation)
}
private static func makeStateStream(for connection: NWConnection) -> AsyncStream<NWConnection.State> {
+2 -2
View File
@@ -35,10 +35,10 @@
<string>Clawdbot can capture photos or short video clips when requested via the bridge.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Clawdbot discovers and connects to your Clawdbot bridge on the local network.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Clawdbot uses your location when you allow location sharing.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Clawdbot can share your location in the background when you enable Always.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Clawdbot uses your location when you allow location sharing.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Clawdbot needs microphone access for voice wake.</string>
<key>NSSpeechRecognitionUsageDescription</key>
+27 -31
View File
@@ -86,24 +86,11 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
}
}
private func withTimeout<T>(
private func withTimeout<T: Sendable>(
timeoutMs: Int,
operation: @escaping () async throws -> T) async throws -> T
operation: @escaping @Sendable () async throws -> T) async throws -> T
{
if timeoutMs == 0 {
return try await operation()
}
return try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
throw Error.timeout
}
let result = try await group.next()!
group.cancelAll()
return result
}
try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: { Error.timeout }, operation: operation)
}
private static func accuracyValue(_ accuracy: ClawdbotLocationAccuracy) -> CLLocationAccuracy {
@@ -117,26 +104,35 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
}
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
if let cont = self.authContinuation {
self.authContinuation = nil
cont.resume(returning: manager.authorizationStatus)
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
Task { @MainActor in
if let cont = self.authContinuation {
self.authContinuation = nil
cont.resume(returning: status)
}
}
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
if let latest = locations.last {
cont.resume(returning: latest)
} else {
cont.resume(throwing: Error.unavailable)
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
let locs = locations
Task { @MainActor in
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
if let latest = locs.last {
cont.resume(returning: latest)
} else {
cont.resume(throwing: Error.unavailable)
}
}
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
cont.resume(throwing: error)
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
let err = error
Task { @MainActor in
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
cont.resume(throwing: err)
}
}
}
+4 -3
View File
@@ -204,12 +204,14 @@ final class NodeAppModel {
func connectToBridge(
endpoint: NWEndpoint,
bridgeStableID: String,
hello: BridgeHello)
{
self.bridgeTask?.cancel()
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
let id = bridgeStableID.trimmingCharacters(in: .whitespacesAndNewlines)
self.connectedBridgeID = id.isEmpty ? BridgeEndpointID.stableID(endpoint) : id
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
@@ -330,8 +332,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
}
}
@@ -425,6 +425,7 @@ struct SettingsTab: View {
self.appModel.connectToBridge(
endpoint: bridge.endpoint,
bridgeStableID: bridge.stableID,
hello: BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
@@ -499,6 +500,7 @@ struct SettingsTab: View {
self.appModel.connectToBridge(
endpoint: endpoint,
bridgeStableID: BridgeEndpointID.stableID(endpoint),
hello: BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
+5 -8
View File
@@ -288,9 +288,8 @@ final class TalkModeManager: NSObject {
self.chatSubscribedSessionKeys.insert(key)
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
} catch {
self.logger.warning(
"chat.subscribe failed sessionKey=\(key, privacy: .public) " +
"err=\(error.localizedDescription, privacy: .public)")
let err = error.localizedDescription
self.logger.warning("chat.subscribe failed key=\(key, privacy: .public) err=\(err, privacy: .public)")
}
}
@@ -528,9 +527,8 @@ final class TalkModeManager: NSObject {
self.lastPlaybackWasPCM = false
result = await self.mp3Player.play(stream: stream)
}
self.logger.info(
"elevenlabs stream finished=\(result.finished, privacy: .public) " +
"dur=\(Date().timeIntervalSince(started), privacy: .public)s")
let duration = Date().timeIntervalSince(started)
self.logger.info("elevenlabs stream finished=\(result.finished, privacy: .public) dur=\(duration, privacy: .public)s")
if !result.finished, let interruptedAt = result.interruptedAt {
self.lastInterruptedAtSeconds = interruptedAt
}
@@ -651,8 +649,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] = [:]
@@ -175,7 +175,6 @@ private func withKeychainValues<T>(
}
@Test @MainActor func makeHelloBuildsCapsAndCommands() {
let defaults = UserDefaults.standard
let voiceWakeKey = VoiceWakePreferences.enabledKey
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
+24
View File
@@ -5,6 +5,10 @@ options:
iOS: "17.0"
xcodeVersion: "16.0"
settings:
base:
SWIFT_VERSION: "6.0"
packages:
ClawdbotKit:
path: ../shared/ClawdbotKit
@@ -68,11 +72,15 @@ targets:
PRODUCT_BUNDLE_IDENTIFIER: com.clawdbot.ios
PROVISIONING_PROFILE_SPECIFIER: "com.clawdbot.ios Development"
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
ENABLE_APPINTENTS_METADATA: NO
info:
path: Sources/Info.plist
properties:
CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.1.9"
CFBundleVersion: "20260109"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -84,8 +92,20 @@ targets:
NSBonjourServices:
- _clawdbot-bridge._tcp
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the bridge.
NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing.
NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always.
NSMicrophoneUsageDescription: Clawdbot needs microphone access for voice wake.
NSSpeechRecognitionUsageDescription: Clawdbot uses on-device speech recognition for voice wake.
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
ClawdbotTests:
type: bundle.unit-test
@@ -96,13 +116,17 @@ targets:
- target: Clawdbot
- package: Swabble
product: SwabbleKit
- sdk: AppIntents.framework
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.clawdbot.ios.tests
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Clawdbot.app/Clawdbot"
BUNDLE_LOADER: "$(TEST_HOST)"
info:
path: Tests/Info.plist
properties:
CFBundleDisplayName: ClawdbotTests
CFBundleShortVersionString: "2026.1.9"
CFBundleVersion: "20260109"
+22
View File
@@ -10,7 +10,9 @@ let package = Package(
],
products: [
.library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]),
.library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]),
.executable(name: "Clawdbot", targets: ["Clawdbot"]),
.executable(name: "clawdbot-mac-discovery", targets: ["ClawdbotDiscoveryCLI"]),
],
dependencies: [
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
@@ -36,10 +38,20 @@ let package = Package(
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.target(
name: "ClawdbotDiscovery",
dependencies: [
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
],
path: "Sources/ClawdbotDiscovery",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "Clawdbot",
dependencies: [
"ClawdbotIPC",
"ClawdbotDiscovery",
"ClawdbotProtocol",
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
.product(name: "ClawdbotChatUI", package: "ClawdbotKit"),
@@ -61,11 +73,21 @@ let package = Package(
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "ClawdbotDiscoveryCLI",
dependencies: [
"ClawdbotDiscovery",
],
path: "Sources/ClawdbotDiscoveryCLI",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.testTarget(
name: "ClawdbotIPCTests",
dependencies: [
"ClawdbotIPC",
"Clawdbot",
"ClawdbotDiscovery",
"ClawdbotProtocol",
.product(name: "SwabbleKit", package: "swabble"),
],
+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 {
+222 -45
View File
@@ -12,11 +12,12 @@ struct ConfigSettings: View {
"Clawd uses a separate Chrome profile and ports (default 18791/18792) "
+ "so it won’t interfere with your daily browser."
@State private var configModel: String = ""
@State private var customModel: String = ""
@State private var configSaving = false
@State private var hasLoaded = false
@State private var models: [ModelChoice] = []
@State private var modelsLoading = false
@State private var modelSearchQuery: String = ""
@State private var isModelPickerOpen = false
@State private var modelError: String?
@State private var modelsSourceLabel: String?
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
@@ -36,10 +37,10 @@ struct ConfigSettings: View {
@State private var talkInterruptOnSpeech: Bool = true
@State private var talkApiKey: String = ""
@State private var gatewayApiKeyFound = false
@FocusState private var modelSearchFocused: Bool
private struct ConfigDraft {
let configModel: String
let customModel: String
let heartbeatMinutes: Int?
let heartbeatBody: String
let browserEnabled: Bool
@@ -106,8 +107,7 @@ struct ConfigSettings: View {
GridRow {
self.gridLabel("Model")
VStack(alignment: .leading, spacing: 6) {
self.modelPicker
self.customModelField
self.modelPickerField
self.modelMetaLabels
}
}
@@ -116,37 +116,114 @@ struct ConfigSettings: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
private var modelPicker: some View {
Picker("Model", selection: self.$configModel) {
ForEach(self.models) { choice in
Text("\(choice.name) — \(choice.provider.uppercased())")
.tag(choice.id)
private var modelPickerField: some View {
Button {
guard !self.modelsLoading else { return }
self.isModelPickerOpen = true
} label: {
HStack(spacing: 8) {
Text(self.modelPickerLabel)
.foregroundStyle(self.modelPickerLabelIsPlaceholder ? .secondary : .primary)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 8)
Image(systemName: "chevron.up.chevron.down")
.foregroundStyle(.secondary)
}
Text("Manual entry…").tag("__custom__")
.padding(.vertical, 6)
.padding(.horizontal, 8)
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 6)
.fill(
Color(nsColor: .textBackgroundColor)))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(
Color.secondary.opacity(0.25),
lineWidth: 1))
.popover(isPresented: self.$isModelPickerOpen, arrowEdge: .bottom) {
self.modelPickerPopover
}
.labelsHidden()
.frame(maxWidth: .infinity)
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
.onChange(of: self.configModel) { _, _ in
self.autosaveConfig()
.onChange(of: self.isModelPickerOpen) { _, isOpen in
if isOpen {
self.modelSearchQuery = ""
self.modelSearchFocused = true
}
}
}
@ViewBuilder
private var customModelField: some View {
if self.configModel == "__custom__" {
TextField("Enter model ID", text: self.$customModel)
private var modelPickerPopover: some View {
VStack(alignment: .leading, spacing: 10) {
TextField("Search models", text: self.$modelSearchQuery)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.onChange(of: self.customModel) { _, newValue in
self.configModel = newValue
self.autosaveConfig()
.focused(self.$modelSearchFocused)
.controlSize(.small)
.onSubmit {
if let exact = self.exactMatchForQuery() {
self.selectModel(exact)
return
}
if let manual = self.manualEntryCandidate {
self.selectManualModel(manual)
return
}
if self.modelSearchMatches.count == 1 {
self.selectModel(self.modelSearchMatches[0])
}
}
List {
if self.modelSearchMatches.isEmpty {
Text("No models match \"\(self.modelSearchQuery)\"")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
ForEach(self.modelSearchMatches) { choice in
Button {
self.selectModel(choice)
} label: {
HStack(spacing: 8) {
Text(choice.name)
.lineLimit(1)
Spacer(minLength: 8)
Text(choice.provider.uppercased())
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.vertical, 2)
.padding(.horizontal, 6)
.background(Color.secondary.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 4))
}
.padding(.vertical, 2)
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
}
}
if let manual = self.manualEntryCandidate {
Button("Use \"\(manual)\"") {
self.selectManualModel(manual)
}
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
}
}
.listStyle(.inset)
}
.frame(width: 340, height: 260)
.padding(8)
}
@ViewBuilder
private var modelMetaLabels: some View {
if self.shouldShowProviderHintForSelection {
self.statusLine(label: "Tip: prefer provider/model (e.g. openai-codex/gpt-5.2)", color: .orange)
}
if let contextLabel = self.selectedContextLabel {
Text(contextLabel)
.font(.footnote)
@@ -403,10 +480,8 @@ struct ConfigSettings: View {
}()
if !loadedModel.isEmpty {
self.configModel = loadedModel
self.customModel = loadedModel
} else {
self.configModel = SessionLoader.fallbackModel
self.customModel = SessionLoader.fallbackModel
}
if let heartbeatEvery {
@@ -459,7 +534,6 @@ struct ConfigSettings: View {
defer { self.configSaving = false }
let configModel = self.configModel
let customModel = self.customModel
let heartbeatMinutes = self.heartbeatMinutes
let heartbeatBody = self.heartbeatBody
let browserEnabled = self.browserEnabled
@@ -472,7 +546,6 @@ struct ConfigSettings: View {
let draft = ConfigDraft(
configModel: configModel,
customModel: customModel,
heartbeatMinutes: heartbeatMinutes,
heartbeatBody: heartbeatBody,
browserEnabled: browserEnabled,
@@ -498,8 +571,7 @@ struct ConfigSettings: View {
var browser = root["browser"] as? [String: Any] ?? [:]
var talk = root["talk"] as? [String: Any] ?? [:]
let chosenModel = (draft.configModel == "__custom__" ? draft.customModel : draft.configModel)
.trimmingCharacters(in: .whitespacesAndNewlines)
let chosenModel = draft.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedModel = chosenModel
if !trimmedModel.isEmpty {
var model = defaults["model"] as? [String: Any] ?? [:]
@@ -678,23 +750,11 @@ struct ConfigSettings: View {
timeoutMs: 15000)
self.models = res.models
self.modelsSourceLabel = "gateway"
if !self.configModel.isEmpty,
!res.models.contains(where: { $0.id == self.configModel })
{
self.customModel = self.configModel
self.configModel = "__custom__"
}
} catch {
do {
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
self.models = loaded
self.modelsSourceLabel = "local fallback"
if !self.configModel.isEmpty,
!loaded.contains(where: { $0.id == self.configModel })
{
self.customModel = self.configModel
self.configModel = "__custom__"
}
} catch {
self.modelError = error.localizedDescription
self.models = []
@@ -707,11 +767,129 @@ struct ConfigSettings: View {
let models: [ModelChoice]
}
private var modelSearchMatches: [ModelChoice] {
let raw = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !raw.isEmpty else { return self.models }
let tokens = raw
.split(whereSeparator: { $0.isWhitespace })
.map { token in
token.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
}
.filter { !$0.isEmpty }
guard !tokens.isEmpty else { return self.models }
return self.models.filter { choice in
let haystack = [
choice.id,
choice.name,
choice.provider,
self.modelRef(for: choice),
]
.joined(separator: " ")
.lowercased()
return tokens.allSatisfy { haystack.contains($0) }
}
}
private var selectedModelChoice: ModelChoice? {
guard !self.configModel.isEmpty else { return nil }
return self.models.first(where: { self.matchesConfigModel($0) })
}
private var modelPickerLabel: String {
if let choice = self.selectedModelChoice {
return "\(choice.name) — \(choice.provider.uppercased())"
}
if !self.configModel.isEmpty { return self.configModel }
return "Select model"
}
private var modelPickerLabelIsPlaceholder: Bool {
self.configModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var manualEntryCandidate: String? {
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
guard !cleaned.isEmpty else { return nil }
guard !self.isKnownModelRef(cleaned) else { return nil }
return cleaned
}
private func isKnownModelRef(_ value: String) -> Bool {
let needle = value.lowercased()
return self.models.contains { choice in
choice.id.lowercased() == needle
|| self.modelRef(for: choice).lowercased() == needle
}
}
private func modelRef(for choice: ModelChoice) -> String {
let id = choice.id.trimmingCharacters(in: .whitespacesAndNewlines)
let provider = choice.provider.trimmingCharacters(in: .whitespacesAndNewlines)
guard !provider.isEmpty else { return id }
let normalizedProvider = provider.lowercased()
if id.lowercased().hasPrefix("\(normalizedProvider)/") {
return id
}
return "\(normalizedProvider)/\(id)"
}
private func matchesConfigModel(_ choice: ModelChoice) -> Bool {
let configured = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
guard !configured.isEmpty else { return false }
if configured.caseInsensitiveCompare(choice.id) == .orderedSame { return true }
let ref = self.modelRef(for: choice)
return configured.caseInsensitiveCompare(ref) == .orderedSame
}
private func exactMatchForQuery() -> ModelChoice? {
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%")).lowercased()
guard !cleaned.isEmpty else { return nil }
return self.models.first(where: { choice in
let id = choice.id.lowercased()
if id == cleaned { return true }
return self.modelRef(for: choice).lowercased() == cleaned
})
}
private var shouldShowProviderHint: Bool {
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
return !cleaned.contains("/")
}
private var shouldShowProviderHintForSelection: Bool {
let trimmed = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
return !trimmed.contains("/")
}
private func selectModel(_ choice: ModelChoice) {
self.configModel = self.modelRef(for: choice)
self.autosaveConfig()
self.isModelPickerOpen = false
}
private func selectManualModel(_ value: String) {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let slash = trimmed.firstIndex(of: "/") {
let provider = trimmed[..<slash].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let model = trimmed[trimmed.index(after: slash)...].trimmingCharacters(in: .whitespacesAndNewlines)
self.configModel = provider.isEmpty ? String(model) : "\(provider)/\(model)"
} else {
self.configModel = trimmed
}
self.autosaveConfig()
self.isModelPickerOpen = false
}
private var selectedContextLabel: String? {
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
guard
!chosenId.isEmpty,
let choice = self.models.first(where: { $0.id == chosenId }),
let choice = self.selectedModelChoice,
let context = choice.contextWindow
else {
return nil
@@ -722,8 +900,7 @@ struct ConfigSettings: View {
}
private var selectedAnthropicAuthMode: AnthropicAuthMode? {
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
guard !chosenId.isEmpty, let choice = self.models.first(where: { $0.id == chosenId }) else { return nil }
guard let choice = self.selectedModelChoice else { return nil }
guard choice.provider.lowercased() == "anthropic" else { return nil }
return AnthropicAuthResolver.resolve()
}
@@ -108,6 +108,7 @@ final class ControlChannel {
self.logger.info(
"control channel configure mode=remote " +
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
self.state = .connecting
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
await self.configure()
} catch {
@@ -182,7 +183,7 @@ final class ControlChannel {
{
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
return
"Gateway rejected token; set CLAWDBOT_GATEWAY_TOKEN in the mac app environment " +
"Gateway rejected token; set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) " +
"or clear it on the gateway. " +
"Reason: \(reason)"
}
@@ -5,6 +5,7 @@ import SwiftUI
enum DebugActions {
private static let verboseDefaultsKey = "clawdbot.debug.verboseMain"
private static let sessionMenuLimit = 12
private static let onboardingSeenKey = "clawdbot.onboardingSeen"
@MainActor
static func openAgentEventsWindow() {
@@ -183,6 +184,14 @@ enum DebugActions {
NSApp.terminate(nil)
}
@MainActor
static func restartOnboarding() {
UserDefaults.standard.set(false, forKey: self.onboardingSeenKey)
UserDefaults.standard.set(0, forKey: onboardingVersionKey)
AppStateStore.shared.onboardingSeen = false
OnboardingController.shared.restart()
}
@MainActor
private static func resolveSessionStorePath() -> String {
let defaultPath = SessionLoader.defaultStorePath
@@ -28,7 +28,6 @@ struct DebugSettings: View {
@State private var tunnelResetInFlight = false
@State private var tunnelResetStatus: String?
@State private var pendingKill: DebugActions.PortListener?
@AppStorage(attachExistingGatewayOnlyKey) private var attachExistingGatewayOnly: Bool = false
@AppStorage(debugFileLogEnabledKey) private var diagnosticsFileLogEnabled: Bool = false
@AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue
@@ -145,16 +144,6 @@ struct DebugSettings: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Attach only")
Toggle("", isOn: self.$attachExistingGatewayOnly)
.labelsHidden()
.toggleStyle(.checkbox)
.help(
"When enabled in local mode, the mac app will only connect " +
"to an already-running gateway " +
"and will not start one itself.")
}
}
let key = DeepLinkHandler.currentKey()
@@ -497,6 +486,7 @@ struct DebugSettings: View {
HStack(spacing: 8) {
Button("Restart app") { DebugActions.restartApp() }
Button("Restart onboarding") { DebugActions.restartOnboarding() }
Button("Reveal app in Finder") { self.revealApp() }
Spacer(minLength: 0)
}
@@ -782,7 +772,7 @@ struct DebugSettings: View {
}
private var canRestartGateway: Bool {
self.state.connectionMode == .local && !self.attachExistingGatewayOnly
self.state.connectionMode == .local && !self.state.attachExistingGatewayOnly
}
private func configURL() -> URL {
@@ -1,3 +1,4 @@
import ClawdbotDiscovery
import SwiftUI
struct GatewayDiscoveryInlineList: View {
@@ -3,6 +3,7 @@ import OSLog
enum GatewayEndpointState: Sendable, Equatable {
case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?)
case connecting(mode: AppState.ConnectionMode, detail: String)
case unavailable(mode: AppState.ConnectionMode, reason: String)
}
@@ -14,6 +15,7 @@ enum GatewayEndpointState: Sendable, Equatable {
actor GatewayEndpointStore {
static let shared = GatewayEndpointStore()
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
private static let remoteConnectingDetail = "Connecting to remote gateway…"
struct Deps: Sendable {
let mode: @Sendable () async -> AppState.ConnectionMode
@@ -26,7 +28,13 @@ actor GatewayEndpointStore {
static let live = Deps(
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
token: { ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] },
token: {
let root = ClawdbotConfigFile.loadDict()
return GatewayEndpointStore.resolveGatewayToken(
isRemote: CommandResolver.connectionModeIsRemote(),
root: root,
env: ProcessInfo.processInfo.environment)
},
password: {
let root = ClawdbotConfigFile.loadDict()
return GatewayEndpointStore.resolveGatewayPassword(
@@ -83,11 +91,46 @@ actor GatewayEndpointStore {
return nil
}
private static func resolveGatewayToken(
isRemote: Bool,
root: [String: Any],
env: [String: String]) -> String?
{
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
if isRemote {
if let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let token = remote["token"] as? String
{
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return value
}
}
return nil
}
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any],
let token = auth["token"] as? String
{
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return value
}
}
return nil
}
private let deps: Deps
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
private var state: GatewayEndpointState
private var subscribers: [UUID: AsyncStream<GatewayEndpointState>.Continuation] = [:]
private var remoteEnsure: (token: UUID, task: Task<UInt16, Error>)?
init(deps: Deps = .live) {
self.deps = deps
@@ -115,7 +158,8 @@ actor GatewayEndpointStore {
token: token,
password: password)
case .remote:
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
self.state = .connecting(mode: .remote, detail: Self.remoteConnectingDetail)
Task { await self.setMode(.remote) }
case .unconfigured:
self.state = .unavailable(mode: .unconfigured, reason: "Gateway not configured")
}
@@ -144,6 +188,7 @@ actor GatewayEndpointStore {
let password = self.deps.password()
switch mode {
case .local:
self.cancelRemoteEnsure()
let port = self.deps.localPort()
let host = await self.deps.localHost()
self.setState(.ready(
@@ -154,15 +199,18 @@ actor GatewayEndpointStore {
case .remote:
let port = await self.deps.remotePortIfRunning()
guard let port else {
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
self.kickRemoteEnsureIfNeeded(detail: Self.remoteConnectingDetail)
return
}
self.cancelRemoteEnsure()
self.setState(.ready(
mode: .remote,
url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
token: token,
password: password))
case .unconfigured:
self.cancelRemoteEnsure()
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
}
}
@@ -176,8 +224,10 @@ actor GatewayEndpointStore {
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
let port = try await self.deps.ensureRemoteTunnel()
await self.setMode(.remote)
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Missing tunnel port"])
}
return port
}
@@ -186,6 +236,11 @@ actor GatewayEndpointStore {
switch self.state {
case let .ready(_, url, token, password):
return (url, token, password)
case let .connecting(mode, _):
guard mode == .remote else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
}
return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
case let .unavailable(mode, reason):
guard mode == .remote else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason])
@@ -193,21 +248,73 @@ actor GatewayEndpointStore {
// Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet),
// recreate it on demand so callers can recover without a manual reconnect.
do {
self.logger.info(
"endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)")
let forwarded = try await self.deps.ensureRemoteTunnel()
let token = self.deps.token()
let password = self.deps.password()
let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
return (url, token, password)
} catch {
let msg = "\(reason) (\(error.localizedDescription))"
self.setState(.unavailable(mode: .remote, reason: msg))
self.logger.error("remote control tunnel ensure failed \(msg, privacy: .public)")
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg])
self.logger.info(
"endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)")
return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
}
}
private func cancelRemoteEnsure() {
self.remoteEnsure?.task.cancel()
self.remoteEnsure = nil
}
private func kickRemoteEnsureIfNeeded(detail: String) {
if self.remoteEnsure != nil {
self.setState(.connecting(mode: .remote, detail: detail))
return
}
let deps = self.deps
let token = UUID()
let task = Task.detached(priority: .utility) { try await deps.ensureRemoteTunnel() }
self.remoteEnsure = (token: token, task: task)
self.setState(.connecting(mode: .remote, detail: detail))
}
private func ensureRemoteConfig(detail: String) async throws -> GatewayConnection.Config {
let mode = await self.deps.mode()
guard mode == .remote else {
throw NSError(
domain: "RemoteTunnel",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
self.kickRemoteEnsureIfNeeded(detail: detail)
guard let ensure = self.remoteEnsure else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
}
do {
let forwarded = try await ensure.task.value
let stillRemote = await self.deps.mode() == .remote
guard stillRemote else {
throw NSError(domain: "RemoteTunnel", code: 1, userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
if self.remoteEnsure?.token == ensure.token {
self.remoteEnsure = nil
}
let token = self.deps.token()
let password = self.deps.password()
let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
return (url, token, password)
} catch let err as CancellationError {
if self.remoteEnsure?.token == ensure.token {
self.remoteEnsure = nil
}
throw err
} catch {
if self.remoteEnsure?.token == ensure.token {
self.remoteEnsure = nil
}
let msg = "Remote control tunnel failed (\(error.localizedDescription))"
self.setState(.unavailable(mode: .remote, reason: msg))
self.logger.error("remote control tunnel ensure failed \(msg, privacy: .public)")
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg])
}
}
@@ -228,6 +335,11 @@ actor GatewayEndpointStore {
self.logger
.debug(
"resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)")
case let .connecting(mode, detail):
let modeDesc = String(describing: mode)
self.logger
.debug(
"endpoint connecting mode=\(modeDesc, privacy: .public) detail=\(detail, privacy: .public)")
case let .unavailable(mode, reason):
let modeDesc = String(describing: mode)
self.logger
@@ -64,6 +64,7 @@ struct GatewayCommandResolution {
enum GatewayEnvironment {
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.env")
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
private static let bundledGatewayLabel = "Bundled gateway"
static func bundledGatewayExecutable() -> String? {
guard let res = Bundle.main.resourceURL else { return nil }
@@ -71,6 +72,12 @@ enum GatewayEnvironment {
return FileManager.default.isExecutableFile(atPath: path) ? path : nil
}
static func bundledNodeExecutable() -> String? {
guard let res = Bundle.main.resourceURL else { return nil }
let path = res.appendingPathComponent("Relay/node").path
return FileManager.default.isExecutableFile(atPath: path) ? path : nil
}
static func gatewayPort() -> Int {
if let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PORT"] {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -107,13 +114,15 @@ enum GatewayEnvironment {
if let bundled = self.bundledGatewayExecutable() {
let installed = self.readGatewayVersion(binary: bundled)
let bundledNode = self.bundledNodeExecutable()
let bundledNodeVersion = bundledNode.flatMap { self.readNodeVersion(binary: $0) }
if let expected, let installed, !installed.compatible(with: expected) {
let message =
"Bundled gateway \(installed.description) is incompatible with app " +
"\(expected.description); rebuild the app bundle."
let message = self.bundledGatewayIncompatibleMessage(
installed: installed,
expected: expected)
return GatewayEnvironmentStatus(
kind: .incompatible(found: installed.description, required: expected.description),
nodeVersion: nil,
nodeVersion: bundledNodeVersion,
gatewayVersion: installed.description,
requiredGateway: expected.description,
message: message)
@@ -121,10 +130,12 @@ enum GatewayEnvironment {
let gatewayVersionText = installed?.description ?? "unknown"
return GatewayEnvironmentStatus(
kind: .ok,
nodeVersion: nil,
nodeVersion: bundledNodeVersion,
gatewayVersion: gatewayVersionText,
requiredGateway: expected?.description,
message: "Bundled gateway \(gatewayVersionText) (bun)")
message: self.bundledGatewayStatusMessage(
gatewayVersion: gatewayVersionText,
nodeVersion: bundledNodeVersion))
}
let projectRoot = CommandResolver.projectRoot()
@@ -351,4 +362,41 @@ enum GatewayEnvironment {
else { return nil }
return Semver.parse(version)
}
private static func readNodeVersion(binary: String) -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: binary)
process.arguments = ["--version"]
process.environment = ["PATH": CommandResolver.preferredPaths().joined(separator: ":")]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readToEndSafely()
let raw = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "^v", with: "", options: .regularExpression)
return raw?.isEmpty == false ? raw : nil
} catch {
return nil
}
}
private static func bundledGatewayStatusMessage(
gatewayVersion: String,
nodeVersion: String?) -> String
{
"\(self.bundledGatewayLabel) \(gatewayVersion) (node \(nodeVersion ?? "unknown"))"
}
private static func bundledGatewayIncompatibleMessage(
installed: Semver,
expected: Semver) -> String
{
"\(self.bundledGatewayLabel) \(installed.description) is incompatible with app " +
"\(expected.description); rebuild the app bundle."
}
}
@@ -4,6 +4,7 @@ enum GatewayLaunchAgentManager {
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway"
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
private static var plistURL: URL {
FileManager.default.homeDirectoryForCurrentUser
@@ -50,6 +51,10 @@ enum GatewayLaunchAgentManager {
}
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
if enabled, self.isLaunchAgentWriteDisabled() {
self.logger.info("launchd enable skipped (attach-only or disable marker set)")
return nil
}
if enabled {
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.legacyPlistURL)
@@ -204,7 +209,20 @@ enum GatewayLaunchAgentManager {
private static func preferredGatewayToken() -> String? {
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
if !trimmed.isEmpty {
return trimmed
}
let root = ClawdbotConfigFile.loadDict()
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any],
let token = auth["token"] as? String
{
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return value
}
}
return nil
}
private static func preferredGatewayPassword() -> String? {
@@ -288,6 +306,17 @@ enum GatewayLaunchAgentManager {
}
}
extension GatewayLaunchAgentManager {
private static func isLaunchAgentWriteDisabled() -> Bool {
if UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey) {
return true
}
let marker = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(self.disableLaunchAgentMarker)
return FileManager.default.fileExists(atPath: marker.path)
}
}
#if DEBUG
extension GatewayLaunchAgentManager {
static func _testGatewayExecutablePath(bundlePath: String) -> String {
@@ -239,7 +239,7 @@ final class GatewayProcessManager {
let lower = message.lowercased()
if self.isGatewayAuthFailure(error) {
return """
Gateway on port \(port) rejected auth. Set CLAWDBOT_GATEWAY_TOKEN in the app \
Gateway on port \(port) rejected auth. Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) \
to match the running gateway (or clear it on the gateway) and retry.
"""
}
@@ -1,4 +1,5 @@
import AppKit
import ClawdbotDiscovery
import ClawdbotIPC
import ClawdbotKit
import CoreLocation
@@ -12,7 +13,8 @@ struct GeneralSettings: View {
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
private let healthStore = HealthStore.shared
private let gatewayManager = GatewayProcessManager.shared
@State private var gatewayDiscovery = GatewayDiscoveryModel()
@State private var gatewayDiscovery = GatewayDiscoveryModel(
localDisplayName: InstanceIdentity.displayName)
@State private var isInstallingCLI = false
@State private var cliStatus: String?
@State private var cliInstalled = false
@@ -28,10 +30,22 @@ struct GeneralSettings: View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 18) {
if !self.state.onboardingSeen {
Text("Complete onboarding to finish setup")
.font(.callout.weight(.semibold))
.foregroundColor(.accentColor)
.padding(.bottom, 2)
Button {
DebugActions.restartOnboarding()
} label: {
HStack(spacing: 8) {
Label("Complete onboarding to finish setup", systemImage: "arrow.counterclockwise")
.font(.callout.weight(.semibold))
.foregroundStyle(Color.accentColor)
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.bottom, 2)
}
VStack(alignment: .leading, spacing: 12) {
@@ -150,13 +164,18 @@ struct GeneralSettings: View {
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
guard mode != .off else { return true }
guard CLLocationManager.locationServicesEnabled() else {
await MainActor.run { LocationPermissionHelper.openSettings() }
return false
}
let status = CLLocationManager().authorizationStatus
// Note: macOS only supports authorizedAlways, not authorizedWhenInUse (iOS only)
if status == .authorizedAlways {
let requireAlways = mode == .always
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
return true
}
let updated = await LocationPermissionRequester.shared.request(always: mode == .always)
return updated == .authorizedAlways
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
}
private var connectionSection: some View {
@@ -185,6 +204,11 @@ struct GeneralSettings: View {
if !self.isNixMode {
self.gatewayInstallerCard
}
SettingsToggleRow(
title: "Attach only",
subtitle: "Use this when the gateway runs externally; the mac app will only attach " +
"to an already-running gateway and won't start one locally.",
binding: self.$state.attachExistingGatewayOnly)
TailscaleIntegrationSection(
connectionMode: self.state.connectionMode,
isPaused: self.state.isPaused)
@@ -283,6 +283,11 @@ struct MenuContent: View {
Label("Restart Gateway", systemImage: "arrow.clockwise")
}
}
Button {
DebugActions.restartOnboarding()
} label: {
Label("Restart Onboarding", systemImage: "arrow.counterclockwise")
}
Button {
DebugActions.restartApp()
} label: {
@@ -111,6 +111,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
let width = self.initialWidth(for: menu)
let isConnected = self.isControlChannelConnected
let channelState = ControlChannel.shared.state
var cursor = insertIndex
var headerView: NSView?
@@ -133,7 +134,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(
count: rows.count,
statusText: isConnected ? nil : "Gateway disconnected")),
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
width: width,
highlighted: false)
headerItem.view = hosted
@@ -166,7 +167,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
headerItem.isEnabled = false
let statusText = isConnected
? (self.cachedErrorText ?? "Loading sessions…")
: "Gateway disconnected"
: self.controlChannelStatusText(for: channelState)
let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(
count: 0,
@@ -218,6 +219,14 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
cursor += 1
}
if case .connecting = ControlChannel.shared.state {
menu.insertItem(
self.makeMessageItem(text: "Connecting…", symbolName: "circle.dashed", width: width),
at: cursor)
cursor += 1
return
}
guard self.isControlChannelConnected else { return }
if let error = self.nodesStore.lastError?.nonEmpty {
@@ -272,18 +281,36 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
}
var cursor = cursor
if cursor > 0, !menu.items[cursor - 1].isSeparatorItem {
let separator = NSMenuItem.separator()
separator.tag = self.tag
menu.insertItem(separator, at: cursor)
cursor += 1
}
let headerItem = NSMenuItem()
headerItem.tag = self.tag
headerItem.isEnabled = false
headerItem.view = self.makeHostedView(
rootView: AnyView(MenuUsageHeaderView(
count: rows.count,
statusText: errorText)),
count: rows.count)),
width: width,
highlighted: false)
menu.insertItem(headerItem, at: cursor)
cursor += 1
if let errorText = errorText?.nonEmpty, !rows.isEmpty {
menu.insertItem(
self.makeMessageItem(
text: errorText,
symbolName: "exclamationmark.triangle",
width: width,
maxLines: 2),
at: cursor)
cursor += 1
}
if rows.isEmpty {
menu.insertItem(
self.makeMessageItem(text: errorText ?? "No usage available", symbolName: "minus", width: width),
@@ -292,6 +319,28 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
return cursor
}
if let selectedProvider = self.selectedUsageProviderId,
let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }),
rows.count > 1
{
let others = rows.filter { $0.providerId.lowercased() != selectedProvider }
let item = NSMenuItem()
item.tag = self.tag
item.isEnabled = true
if !others.isEmpty {
item.submenu = self.buildUsageOverflowMenu(rows: others, width: width)
}
item.view = self.makeHostedView(
rootView: AnyView(UsageMenuLabelView(row: primary, width: width, showsChevron: !others.isEmpty)),
width: width,
highlighted: true)
menu.insertItem(item, at: cursor)
cursor += 1
return cursor
}
for row in rows {
let item = NSMenuItem()
item.tag = self.tag
@@ -307,11 +356,34 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
return cursor
}
private var selectedUsageProviderId: String? {
guard let model = self.cachedSnapshot?.defaults.model.nonEmpty else { return nil }
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
guard let slash = trimmed.firstIndex(of: "/") else { return nil }
let provider = trimmed[..<slash].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return provider.nonEmpty
}
private var usageRows: [UsageRow] {
guard let summary = self.cachedUsageSummary else { return [] }
return summary.primaryRows()
}
private func buildUsageOverflowMenu(rows: [UsageRow], width: CGFloat) -> NSMenu {
let menu = NSMenu()
for row in rows {
let item = NSMenuItem()
item.tag = self.tag
item.isEnabled = false
item.view = self.makeHostedView(
rootView: AnyView(UsageMenuLabelView(row: row, width: width)),
width: width,
highlighted: false)
menu.addItem(item)
}
return menu
}
private var isControlChannelConnected: Bool {
#if DEBUG
if let override = self.testControlChannelConnected { return override }
@@ -320,6 +392,19 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
return false
}
private func controlChannelStatusText(for state: ControlChannel.ConnectionState) -> String {
switch state {
case .connected:
return "Loading sessions…"
case .connecting:
return "Connecting…"
case let .degraded(message):
return message.nonEmpty ?? "Gateway disconnected"
case .disconnected:
return "Gateway disconnected"
}
}
private func gatewayEntry() -> NodeInfo? {
let mode = AppStateStore.shared.connectionMode
let isConnected = self.isControlChannelConnected
@@ -391,18 +476,31 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
return item
}
private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem {
private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem {
let view = AnyView(
Label(text, systemImage: symbolName)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 18)
.padding(.trailing, 12)
.padding(.vertical, 6)
.frame(minWidth: 300, alignment: .leading))
HStack(alignment: .top, spacing: 8) {
Image(systemName: symbolName)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 14, alignment: .leading)
.padding(.top, 1)
Text(text)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
.lineLimit(maxLines)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: true)
.layoutPriority(1)
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 0)
}
.padding(.leading, 18)
.padding(.trailing, 12)
.padding(.vertical, 6)
.frame(width: max(1, width), alignment: .leading))
let item = NSMenuItem()
item.tag = self.tag
@@ -938,6 +1036,12 @@ extension MenuSessionsInjector {
self.cacheUpdatedAt = Date()
}
func setTestingUsageSummary(_ summary: GatewayUsageSummary?, errorText: String? = nil) {
self.cachedUsageSummary = summary
self.cachedUsageErrorText = errorText
self.usageCacheUpdatedAt = Date()
}
func injectForTesting(into menu: NSMenu) {
self.inject(into: menu)
}
@@ -2,7 +2,6 @@ import SwiftUI
struct MenuUsageHeaderView: View {
let count: Int
let statusText: String?
private let paddingTop: CGFloat = 8
private let paddingBottom: CGFloat = 6
@@ -20,14 +19,6 @@ struct MenuUsageHeaderView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
if let statusText, !statusText.isEmpty {
Text(statusText)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
.padding(.top, self.paddingTop)
.padding(.bottom, self.paddingBottom)
@@ -20,12 +20,14 @@ actor MacNodeBridgeSession {
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private let clock = ContinuousClock()
private var disconnectHandler: (@Sendable (String) async -> Void)?
private var connection: NWConnection?
private var queue: DispatchQueue?
private var buffer = Data()
private var pendingRPC: [String: CheckedContinuation<BridgeRPCResponse, Error>] = [:]
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:]
private var invokeTasks: [UUID: Task<Void, Never>] = [:]
private var pingTask: Task<Void, Never>?
private var lastPongAt: ContinuousClock.Instant?
@@ -35,10 +37,12 @@ actor MacNodeBridgeSession {
endpoint: NWEndpoint,
hello: BridgeHello,
onConnected: (@Sendable (String) async -> Void)? = nil,
onDisconnected: (@Sendable (String) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
async throws
{
await self.disconnect()
self.disconnectHandler = onDisconnected
self.state = .connecting
let params = NWParameters.tcp
@@ -83,6 +87,7 @@ actor MacNodeBridgeSession {
let data = line.data(using: .utf8),
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
else {
self.logger.error("node bridge hello failed (unexpected response)")
await self.disconnect()
throw NSError(domain: "Bridge", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response",
@@ -97,53 +102,67 @@ actor MacNodeBridgeSession {
} else if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
self.state = .failed(message: "\(err.code): \(err.message)")
self.logger.error("node bridge hello error: \(err.code, privacy: .public)")
await self.disconnect()
throw NSError(domain: "Bridge", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
])
} else {
self.state = .failed(message: "Unexpected bridge response")
self.logger.error("node bridge hello failed (unexpected frame)")
await self.disconnect()
throw NSError(domain: "Bridge", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response",
])
}
while true {
guard let next = try await self.receiveLine() else { break }
guard let nextData = next.data(using: .utf8) else { continue }
guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue }
do {
while true {
guard let next = try await self.receiveLine() else { break }
guard let nextData = next.data(using: .utf8) else { continue }
guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue }
switch nextBase.type {
case "res":
let res = try self.decoder.decode(BridgeRPCResponse.self, from: nextData)
if let cont = self.pendingRPC.removeValue(forKey: res.id) {
cont.resume(returning: res)
switch nextBase.type {
case "res":
let res = try self.decoder.decode(BridgeRPCResponse.self, from: nextData)
if let cont = self.pendingRPC.removeValue(forKey: res.id) {
cont.resume(returning: res)
}
case "event":
let evt = try self.decoder.decode(BridgeEventFrame.self, from: nextData)
self.broadcastServerEvent(evt)
case "ping":
let ping = try self.decoder.decode(BridgePing.self, from: nextData)
try await self.send(BridgePong(type: "pong", id: ping.id))
case "pong":
let pong = try self.decoder.decode(BridgePong.self, from: nextData)
self.notePong(pong)
case "invoke":
let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData)
let taskID = UUID()
let task = Task { [weak self] in
let res = await onInvoke(req)
guard let self else { return }
await self.sendInvokeResponse(res, taskID: taskID)
}
self.invokeTasks[taskID] = task
default:
continue
}
case "event":
let evt = try self.decoder.decode(BridgeEventFrame.self, from: nextData)
self.broadcastServerEvent(evt)
case "ping":
let ping = try self.decoder.decode(BridgePing.self, from: nextData)
try await self.send(BridgePong(type: "pong", id: ping.id))
case "pong":
let pong = try self.decoder.decode(BridgePong.self, from: nextData)
self.notePong(pong)
case "invoke":
let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData)
let res = await onInvoke(req)
try await self.send(res)
default:
continue
}
}
await self.disconnect()
await self.handleDisconnect(reason: "connection closed")
} catch {
self.logger.error(
"node bridge receive failed: \(error.localizedDescription, privacy: .public)")
await self.handleDisconnect(reason: "receive failed")
throw error
}
}
func sendEvent(event: String, payloadJSON: String?) async throws {
@@ -205,6 +224,8 @@ actor MacNodeBridgeSession {
self.pingTask?.cancel()
self.pingTask = nil
self.lastPongAt = nil
self.disconnectHandler = nil
self.cancelInvokeTasks()
self.connection?.cancel()
self.connection = nil
@@ -312,6 +333,7 @@ actor MacNodeBridgeSession {
private func startPingLoop() {
self.pingTask?.cancel()
self.lastPongAt = self.clock.now
self.logger.debug("node bridge ping loop started")
self.pingTask = Task { [weak self] in
guard let self else { return }
await self.runPingLoop()
@@ -331,9 +353,12 @@ 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)).")
await self.disconnect()
let ageDescription = String(describing: age)
let message =
"Node bridge heartbeat timed out; disconnecting " +
"(age: \(ageDescription, privacy: .public))."
self.logger.warning(message)
await self.handleDisconnect(reason: "ping timeout")
return
}
}
@@ -342,9 +367,12 @@ 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)).")
await self.disconnect()
let errorDescription = String(describing: error)
let message =
"Node bridge ping send failed; disconnecting " +
"(error: \(errorDescription, privacy: .public))."
self.logger.warning(message)
await self.handleDisconnect(reason: "ping send failed")
return
}
}
@@ -358,17 +386,50 @@ 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)).")
await self.disconnect()
let errorDescription = String(describing: error)
let message =
"Node bridge connection failed; disconnecting " +
"(error: \(errorDescription, privacy: .public))."
self.logger.warning(message)
await self.handleDisconnect(reason: "connection failed")
case .cancelled:
self.logger.warning("Node bridge connection cancelled; disconnecting.")
await self.disconnect()
await self.handleDisconnect(reason: "connection cancelled")
default:
break
}
}
private func handleDisconnect(reason: String) async {
self.logger.info("node bridge disconnect reason=\(reason, privacy: .public)")
if let handler = self.disconnectHandler {
await handler(reason)
}
await self.disconnect()
}
private func logInvokeSendFailure(_ error: Error) {
self.logger.error(
"node bridge invoke response send failed: \(error.localizedDescription, privacy: .public)")
}
private func sendInvokeResponse(_ response: BridgeInvokeResponse, taskID: UUID) async {
defer { self.invokeTasks[taskID] = nil }
if Task.isCancelled { return }
do {
try await self.send(response)
} catch {
await self.logInvokeSendFailure(error)
}
}
private func cancelInvokeTasks() {
for task in self.invokeTasks.values {
task.cancel()
}
self.invokeTasks.removeAll()
}
private static func makeStateStream(
for connection: NWConnection) -> AsyncStream<NWConnection.State>
{
@@ -1,3 +1,4 @@
import ClawdbotDiscovery
import ClawdbotKit
import Foundation
import Network
@@ -61,12 +62,17 @@ final class MacNodeModeCoordinator {
retryDelay = 1_000_000_000
do {
let hello = await self.makeHello()
self.logger.info(
"mac node bridge connecting endpoint=\(endpoint, privacy: .public)")
try await self.session.connect(
endpoint: endpoint,
hello: hello,
onConnected: { [weak self] serverName in
self?.logger.info("mac node connected to \(serverName, privacy: .public)")
},
onDisconnected: { reason in
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
},
onInvoke: { [weak self] req in
guard let self else {
return BridgeInvokeResponse(
@@ -80,7 +86,8 @@ final class MacNodeModeCoordinator {
if await self.tryPair(endpoint: endpoint, error: error) {
continue
}
self.logger.error("mac node bridge connect failed: \(error.localizedDescription, privacy: .public)")
self.logger.error(
"mac node bridge connect failed: \(error.localizedDescription, privacy: .public)")
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
retryDelay = min(retryDelay * 2, 10_000_000_000)
}
@@ -285,15 +292,33 @@ final class MacNodeModeCoordinator {
let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode })
if mode == .remote {
do {
if self.tunnel == nil || self.tunnel?.process.isRunning == false {
let remotePort = Self.remoteBridgePort()
self.tunnel = try await RemotePortTunnel.create(
remotePort: remotePort,
allowRemoteUrlOverride: false)
if let tunnel = self.tunnel,
tunnel.process.isRunning,
let localPort = tunnel.localPort
{
let healthy = await self.bridgeTunnelHealthy(localPort: localPort, timeoutSeconds: 1.0)
if healthy, let port = NWEndpoint.Port(rawValue: localPort) {
self.logger.info(
"reusing mac node bridge tunnel localPort=\(localPort, privacy: .public)")
return .hostPort(host: "127.0.0.1", port: port)
}
self.logger.error(
"mac node bridge tunnel unhealthy localPort=\(localPort, privacy: .public); restarting")
tunnel.terminate()
self.tunnel = nil
}
let remotePort = Self.remoteBridgePort()
self.tunnel = try await RemotePortTunnel.create(
remotePort: remotePort,
allowRemoteUrlOverride: false)
if let localPort = self.tunnel?.localPort,
let port = NWEndpoint.Port(rawValue: localPort)
{
self.logger.info(
"mac node bridge tunnel ready " +
"localPort=\(localPort, privacy: .public) " +
"remotePort=\(remotePort, privacy: .public)")
return .hostPort(host: "127.0.0.1", port: port)
}
} catch {
@@ -311,6 +336,21 @@ final class MacNodeModeCoordinator {
return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds)
}
@MainActor
private static func handleBridgeDisconnect(reason: String) async {
guard reason.localizedCaseInsensitiveContains("ping") else { return }
let coordinator = MacNodeModeCoordinator.shared
coordinator.logger.error(
"mac node bridge disconnected (\(reason, privacy: .public)); resetting tunnel")
coordinator.tunnel?.terminate()
coordinator.tunnel = nil
}
private func bridgeTunnelHealthy(localPort: UInt16, timeoutSeconds: Double) async -> Bool {
guard let port = NWEndpoint.Port(rawValue: localPort) else { return false }
return await Self.probeEndpoint(.hostPort(host: "127.0.0.1", port: port), timeoutSeconds: timeoutSeconds)
}
private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
final class DiscoveryState: @unchecked Sendable {
let lock = NSLock()
@@ -222,7 +222,15 @@ actor MacNodeRuntime {
(Self.locationPreciseEnabled() ? .precise : .balanced)
let services = await self.mainActorServices()
let status = await services.locationAuthorizationStatus()
if status != .authorizedAlways {
let hasPermission = switch mode {
case .always:
status == .authorizedAlways
case .whileUsing:
status == .authorizedAlways
case .off:
false
}
if !hasPermission {
return BridgeInvokeResponse(
id: req.id,
ok: false,
@@ -1,4 +1,5 @@
import AppKit
import ClawdbotDiscovery
import ClawdbotIPC
import ClawdbotProtocol
import Foundation
@@ -533,7 +534,7 @@ final class NodePairingApprovalPrompter {
return SSHTarget(host: host, port: port)
}
let model = GatewayDiscoveryModel()
let model = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
model.start()
defer { model.stop() }
+14 -3
View File
@@ -1,5 +1,6 @@
import AppKit
import ClawdbotChatUI
import ClawdbotDiscovery
import ClawdbotIPC
import Combine
import Observation
@@ -31,7 +32,7 @@ final class OnboardingController {
let hosting = NSHostingController(rootView: OnboardingView())
let window = NSWindow(contentViewController: hosting)
window.title = UIStrings.welcomeTitle
window.setContentSize(NSSize(width: 630, height: 684))
window.setContentSize(NSSize(width: OnboardingView.windowWidth, height: OnboardingView.windowHeight))
window.styleMask = [.titled, .closable, .fullSizeContentView]
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
@@ -47,6 +48,11 @@ final class OnboardingController {
self.window?.close()
self.window = nil
}
func restart() {
self.close()
self.show()
}
}
struct OnboardingView: View {
@@ -92,7 +98,10 @@ struct OnboardingView: View {
@Bindable var state: AppState
var permissionMonitor: PermissionMonitor
let pageWidth: CGFloat = 630
static let windowWidth: CGFloat = 630
static let windowHeight: CGFloat = 752 // ~+10% to fit full onboarding content
let pageWidth: CGFloat = Self.windowWidth
let contentHeight: CGFloat = 460
let connectionPageIndex = 1
let anthropicAuthPageIndex = 2
@@ -156,7 +165,9 @@ struct OnboardingView: View {
init(
state: AppState = AppStateStore.shared,
permissionMonitor: PermissionMonitor = .shared,
discoveryModel: GatewayDiscoveryModel = GatewayDiscoveryModel())
discoveryModel: GatewayDiscoveryModel = GatewayDiscoveryModel(
localDisplayName: InstanceIdentity.displayName,
filterLocalGateways: false))
{
self.state = state
self.permissionMonitor = permissionMonitor
@@ -1,4 +1,5 @@
import AppKit
import ClawdbotDiscovery
import ClawdbotIPC
import SwiftUI
@@ -27,7 +27,7 @@ extension OnboardingView {
Spacer(minLength: 0)
self.navigationBar
}
.frame(width: self.pageWidth, height: 684)
.frame(width: self.pageWidth, height: Self.windowHeight)
.background(Color(NSColor.windowBackgroundColor))
.onAppear {
self.currentPage = 0
@@ -173,6 +173,22 @@ extension OnboardingView {
.shadow(color: .black.opacity(0.06), radius: 8, y: 3))
}
func onboardingGlassCard(
spacing: CGFloat = 12,
padding: CGFloat = 16,
@ViewBuilder _ content: () -> some View) -> some View
{
let shape = RoundedRectangle(cornerRadius: 16, style: .continuous)
return VStack(alignment: .leading, spacing: spacing) {
content()
}
.padding(padding)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.clear)
.clipShape(shape)
.overlay(shape.strokeBorder(Color.white.opacity(0.10), lineWidth: 1))
}
func featureRow(title: String, subtitle: String, systemImage: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: systemImage)
@@ -33,7 +33,7 @@ extension OnboardingView {
if shouldMonitor, !self.monitoringDiscovery {
self.monitoringDiscovery = true
Task { @MainActor in
try? await Task.sleep(nanoseconds: 550_000_000)
try? await Task.sleep(nanoseconds: 150_000_000)
guard self.monitoringDiscovery else { return }
self.gatewayDiscovery.start()
await self.refreshLocalGatewayProbe()
@@ -1,5 +1,6 @@
import AppKit
import ClawdbotChatUI
import ClawdbotDiscovery
import ClawdbotIPC
import SwiftUI
@@ -115,6 +116,11 @@ extension OnboardingView {
.foregroundStyle(.secondary)
if self.gatewayDiscovery.gateways.isEmpty {
ProgressView().controlSize(.small)
Button("Refresh") {
self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0)
}
.buttonStyle(.link)
.help("Retry Tailscale discovery (DNS-SD).")
}
Spacer(minLength: 0)
}
@@ -648,7 +654,7 @@ extension OnboardingView {
.frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard(padding: 8) {
self.onboardingGlassCard(padding: 8) {
ClawdbotChatView(viewModel: self.onboardingChatModel, style: .onboarding)
.frame(maxHeight: .infinity)
}
@@ -1,3 +1,4 @@
import ClawdbotDiscovery
import SwiftUI
#if DEBUG
@@ -5,7 +6,7 @@ import SwiftUI
extension OnboardingView {
static func exerciseForTesting() {
let state = AppState(preview: true)
let discovery = GatewayDiscoveryModel()
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
discovery.statusText = "Searching..."
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
displayName: "Test Bridge",
@@ -10,6 +10,18 @@ import Speech
import UserNotifications
enum PermissionManager {
static func isLocationAuthorized(status: CLAuthorizationStatus, requireAlways: Bool) -> Bool {
if requireAlways { return status == .authorizedAlways }
switch status {
case .authorizedAlways, .authorizedWhenInUse:
return true
case .authorized: // deprecated, but still shows up on some macOS versions
return true
default:
return false
}
}
static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] {
var results: [Capability: Bool] = [:]
for cap in caps {
@@ -138,18 +150,23 @@ enum PermissionManager {
}
private static func ensureLocation(interactive: Bool) async -> Bool {
guard CLLocationManager.locationServicesEnabled() else {
if interactive {
await MainActor.run { LocationPermissionHelper.openSettings() }
}
return false
}
let status = CLLocationManager().authorizationStatus
switch status {
// Note: macOS only supports authorizedAlways, not authorizedWhenInUse (iOS only)
case .authorizedAlways:
case .authorizedAlways, .authorizedWhenInUse, .authorized:
return true
case .notDetermined:
guard interactive else { return false }
let updated = await LocationPermissionRequester.shared.request(always: false)
return updated == .authorizedAlways
return self.isLocationAuthorized(status: updated, requireAlways: false)
case .denied, .restricted:
if interactive {
LocationPermissionHelper.openSettings()
await MainActor.run { LocationPermissionHelper.openSettings() }
}
return false
@unknown default:
@@ -202,8 +219,8 @@ enum PermissionManager {
case .location:
let status = CLLocationManager().authorizationStatus
// Note: macOS only supports authorizedAlways
results[cap] = status == .authorizedAlways
results[cap] = CLLocationManager.locationServicesEnabled()
&& self.isLocationAuthorized(status: status, requireAlways: false)
}
}
return results
@@ -275,6 +292,7 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
static let shared = LocationPermissionRequester()
private let manager = CLLocationManager()
private var continuation: CheckedContinuation<CLAuthorizationStatus, Never>?
private var timeoutTask: Task<Void, Never>?
override init() {
super.init()
@@ -282,23 +300,71 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
}
func request(always: Bool) async -> CLAuthorizationStatus {
if always {
self.manager.requestAlwaysAuthorization()
} else {
self.manager.requestWhenInUseAuthorization()
let current = self.manager.authorizationStatus
if PermissionManager.isLocationAuthorized(status: current, requireAlways: always) {
return current
}
return await withCheckedContinuation { cont in
self.continuation = cont
self.timeoutTask?.cancel()
self.timeoutTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 3_000_000_000)
await MainActor.run { [weak self] in
guard let self else { return }
guard self.continuation != nil else { return }
LocationPermissionHelper.openSettings()
self.finish(status: self.manager.authorizationStatus)
}
}
if always {
self.manager.requestAlwaysAuthorization()
} else {
self.manager.requestWhenInUseAuthorization()
}
// On macOS, requesting an actual fix makes the prompt more reliable.
self.manager.requestLocation()
}
}
private func finish(status: CLAuthorizationStatus) {
self.timeoutTask?.cancel()
self.timeoutTask = nil
guard let cont = self.continuation else { return }
self.continuation = nil
cont.resume(returning: status)
}
// nonisolated for Swift 6 strict concurrency compatibility
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
Task { @MainActor in
guard let cont = self.continuation else { return }
self.continuation = nil
cont.resume(returning: status)
self.finish(status: status)
}
}
// Legacy callback (still used on some macOS versions / configurations).
nonisolated func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
Task { @MainActor in
self.finish(status: status)
}
}
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
let status = manager.authorizationStatus
Task { @MainActor in
if status == .denied || status == .restricted {
LocationPermissionHelper.openSettings()
}
self.finish(status: status)
}
}
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
let status = manager.authorizationStatus
Task { @MainActor in
self.finish(status: status)
}
}
}
@@ -15,7 +15,7 @@ struct PermissionsSettings: View {
.padding(.horizontal, 2)
.padding(.vertical, 6)
Button("Show onboarding") { self.showOnboarding() }
Button("Restart onboarding") { self.showOnboarding() }
.buttonStyle(.bordered)
Spacer()
}
@@ -41,7 +41,8 @@ final class RemotePortTunnel {
static func create(
remotePort: Int,
preferredLocalPort: UInt16? = nil,
allowRemoteUrlOverride: Bool = true) async throws -> RemotePortTunnel
allowRemoteUrlOverride: Bool = true,
allowRandomLocalPort: Bool = true) async throws -> RemotePortTunnel
{
let settings = CommandResolver.connectionSettings()
guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else {
@@ -51,7 +52,9 @@ final class RemotePortTunnel {
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not configured"])
}
let localPort = try await Self.findPort(preferred: preferredLocalPort)
let localPort = try await Self.findPort(
preferred: preferredLocalPort,
allowRandom: allowRandomLocalPort)
let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines)
let remotePortOverride =
allowRemoteUrlOverride && remotePort == GatewayEnvironment.gatewayPort()
@@ -172,8 +175,16 @@ final class RemotePortTunnel {
return trimmed.split(separator: ".").first.map(String.init) ?? trimmed
}
private static func findPort(preferred: UInt16?) async throws -> UInt16 {
private static func findPort(preferred: UInt16?, allowRandom: Bool) async throws -> UInt16 {
if let preferred, self.portIsFree(preferred) { return preferred }
if let preferred, !allowRandom {
throw NSError(
domain: "RemotePortTunnel",
code: 5,
userInfo: [
NSLocalizedDescriptionKey: "Local port \(preferred) is unavailable",
])
}
return try await withCheckedThrowingContinuation { cont in
let queue = DispatchQueue(label: "com.clawdbot.remote.tunnel.port", qos: .utility)
@@ -7,8 +7,15 @@ actor RemoteTunnelManager {
private let logger = Logger(subsystem: "com.clawdbot", category: "remote-tunnel")
private var controlTunnel: RemotePortTunnel?
private var restartInFlight = false
private var lastRestartAt: Date?
private let restartBackoffSeconds: TimeInterval = 2.0
func controlTunnelPortIfRunning() async -> UInt16? {
if self.restartInFlight {
self.logger.info("control tunnel restart in flight; skipping reuse check")
return nil
}
if let tunnel = self.controlTunnel,
tunnel.process.isRunning,
let local = tunnel.localPort
@@ -18,6 +25,7 @@ actor RemoteTunnelManager {
return local
}
self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting")
await self.beginRestart()
tunnel.terminate()
self.controlTunnel = nil
}
@@ -34,6 +42,11 @@ actor RemoteTunnelManager {
"pid=\(desc.pid, privacy: .public)")
return desiredPort
}
if self.restartInFlight {
self.logger.info("control tunnel restart in flight; skip stale tunnel cleanup")
return nil
}
await self.beginRestart()
await self.cleanupStaleTunnel(desc: desc, port: desiredPort)
}
return nil
@@ -56,12 +69,15 @@ actor RemoteTunnelManager {
"identitySet=\(identitySet, privacy: .public)")
if let local = await self.controlTunnelPortIfRunning() { return local }
await self.waitForRestartBackoffIfNeeded()
let desiredPort = UInt16(GatewayEnvironment.gatewayPort())
let tunnel = try await RemotePortTunnel.create(
remotePort: GatewayEnvironment.gatewayPort(),
preferredLocalPort: desiredPort)
preferredLocalPort: desiredPort,
allowRandomLocalPort: false)
self.controlTunnel = tunnel
self.endRestart()
let resolvedPort = tunnel.localPort ?? desiredPort
self.logger.info("ssh tunnel ready localPort=\(resolvedPort, privacy: .public)")
return tunnel.localPort ?? desiredPort
@@ -83,6 +99,35 @@ actor RemoteTunnelManager {
return false
}
private func beginRestart() async {
guard !self.restartInFlight else { return }
self.restartInFlight = true
self.lastRestartAt = Date()
self.logger.info("control tunnel restart started")
Task { [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(self.restartBackoffSeconds * 1_000_000_000))
await self.endRestart()
}
}
private func endRestart() {
if self.restartInFlight {
self.restartInFlight = false
self.logger.info("control tunnel restart finished")
}
}
private func waitForRestartBackoffIfNeeded() async {
guard let last = self.lastRestartAt else { return }
let elapsed = Date().timeIntervalSince(last)
let remaining = self.restartBackoffSeconds - elapsed
guard remaining > 0 else { return }
self.logger.info(
"control tunnel restart backoff \(remaining, privacy: .public)s")
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
}
private func cleanupStaleTunnel(desc: PortGuardian.Descriptor, port: UInt16) async {
let pid = desc.pid
self.logger.error(
@@ -47,10 +47,14 @@
<string>Clawdbot captures the screen when the agent needs screenshots for context.</string>
<key>NSCameraUsageDescription</key>
<string>Clawdbot can capture photos or short video clips when requested by the agent.</string>
<key>NSLocationUsageDescription</key>
<string>Clawdbot can share your location when requested by the agent.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Clawdbot needs the mic for Voice Wake tests and agent audio capture.</string>
<key>NSLocationUsageDescription</key>
<string>Clawdbot can share your location when requested by the agent.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Clawdbot can share your location when requested by the agent.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Clawdbot can share your location when requested by the agent.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Clawdbot needs the mic for Voice Wake tests and agent audio capture.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Clawdbot uses speech recognition to detect your Voice Wake trigger phrase.</string>
<key>NSAppleEventsUsageDescription</key>
@@ -58,7 +58,7 @@ struct SettingsRootView: View {
PermissionsSettings(
status: self.permissionMonitor.status,
refresh: self.refreshPerms,
showOnboarding: { OnboardingController.shared.show() })
showOnboarding: { DebugActions.restartOnboarding() })
.tabItem { Label("Permissions", systemImage: "lock.shield") }
.tag(SettingsTab.permissions)
@@ -50,10 +50,13 @@ enum ShellExecutor {
errorMessage: "failed to start: \(error.localizedDescription)")
}
let outTask = Task { stdoutPipe.fileHandleForReading.readToEndSafely() }
let errTask = Task { stderrPipe.fileHandleForReading.readToEndSafely() }
let waitTask = Task { () -> ShellResult in
process.waitUntilExit()
let out = stdoutPipe.fileHandleForReading.readToEndSafely()
let err = stderrPipe.fileHandleForReading.readToEndSafely()
let out = await outTask.value
let err = await errTask.value
let status = Int(process.terminationStatus)
return ShellResult(
stdout: String(bytes: out, encoding: .utf8) ?? "",
+8 -1
View File
@@ -21,6 +21,7 @@ struct GatewayUsageSummary: Codable {
struct UsageRow: Identifiable {
let id: String
let providerId: String
let displayName: String
let plan: String?
let windowLabel: String?
@@ -28,6 +29,11 @@ struct UsageRow: Identifiable {
let resetAt: Date?
let error: String?
var hasError: Bool {
if let error, !error.isEmpty { return true }
return false
}
var titleText: String {
if let plan, !plan.isEmpty { return "\(self.displayName) (\(plan))" }
return self.displayName
@@ -40,7 +46,6 @@ struct UsageRow: Identifiable {
}
func detailText(now: Date = .init()) -> String {
if let error, !error.isEmpty { return error }
guard let remaining = self.remainingPercent else { return "No data" }
var parts = ["\(remaining)% left"]
if let windowLabel, !windowLabel.isEmpty { parts.append(windowLabel) }
@@ -73,6 +78,7 @@ extension GatewayUsageSummary {
if let error = provider.error, provider.windows.isEmpty {
return UsageRow(
id: provider.provider,
providerId: provider.provider,
displayName: provider.displayName,
plan: provider.plan,
windowLabel: nil,
@@ -87,6 +93,7 @@ extension GatewayUsageSummary {
return UsageRow(
id: "\(provider.provider)-\(window.label)",
providerId: provider.provider,
displayName: provider.displayName,
plan: provider.plan,
windowLabel: window.label,
@@ -3,12 +3,19 @@ import SwiftUI
struct UsageMenuLabelView: View {
let row: UsageRow
let width: CGFloat
var showsChevron: Bool = false
@Environment(\.menuItemHighlighted) private var isHighlighted
private let paddingLeading: CGFloat = 22
private let paddingTrailing: CGFloat = 14
private let barHeight: CGFloat = 6
private var primaryTextColor: Color { .primary }
private var secondaryTextColor: Color { .secondary }
private var primaryTextColor: Color {
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
}
private var secondaryTextColor: Color {
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
@@ -30,12 +37,31 @@ struct UsageMenuLabelView: View {
Spacer(minLength: 4)
Text(self.row.detailText())
.font(.caption.monospacedDigit())
if !self.row.hasError {
Text(self.row.detailText())
.font(.caption.monospacedDigit())
.foregroundStyle(self.secondaryTextColor)
.lineLimit(1)
.truncationMode(.tail)
.layoutPriority(2)
}
if self.showsChevron {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(self.secondaryTextColor)
.padding(.leading, 2)
}
}
if let error = self.row.error?.nonEmpty {
Text(error)
.font(.caption)
.foregroundStyle(self.secondaryTextColor)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(2)
.multilineTextAlignment(.leading)
.lineLimit(2)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.vertical, 10)
@@ -219,6 +219,7 @@ final class WorkActivityStore {
if let detail = display.detailLine, !detail.isEmpty {
return "\(display.label): \(detail)"
}
return display.label
}
@@ -2,8 +2,8 @@ import ClawdbotKit
import Foundation
import Network
enum BridgeEndpointID {
static func stableID(_ endpoint: NWEndpoint) -> String {
public enum BridgeEndpointID {
public static func stableID(_ endpoint: NWEndpoint) -> String {
switch endpoint {
case let .service(name, type, domain, _):
// Keep stable across encoded/decoded differences (e.g. \032 for spaces).
@@ -14,7 +14,7 @@ enum BridgeEndpointID {
}
}
static func prettyDescription(_ endpoint: NWEndpoint) -> String {
public static func prettyDescription(_ endpoint: NWEndpoint) -> String {
BonjourEscapes.decode(String(describing: endpoint))
}
@@ -6,43 +6,79 @@ import OSLog
@MainActor
@Observable
final class GatewayDiscoveryModel {
struct LocalIdentity: Equatable {
var hostTokens: Set<String>
var displayTokens: Set<String>
public final class GatewayDiscoveryModel {
public struct LocalIdentity: Equatable, Sendable {
public var hostTokens: Set<String>
public var displayTokens: Set<String>
public init(hostTokens: Set<String>, displayTokens: Set<String>) {
self.hostTokens = hostTokens
self.displayTokens = displayTokens
}
}
struct DiscoveredGateway: Identifiable, Equatable {
var id: String { self.stableID }
var displayName: String
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var gatewayPort: Int?
var cliPath: String?
var stableID: String
var debugID: String
var isLocal: Bool
public struct DiscoveredGateway: Identifiable, Equatable, Sendable {
public var id: String { self.stableID }
public var displayName: String
public var lanHost: String?
public var tailnetDns: String?
public var sshPort: Int
public var gatewayPort: Int?
public var cliPath: String?
public var stableID: String
public var debugID: String
public var isLocal: Bool
public init(
displayName: String,
lanHost: String? = nil,
tailnetDns: String? = nil,
sshPort: Int,
gatewayPort: Int? = nil,
cliPath: String? = nil,
stableID: String,
debugID: String,
isLocal: Bool)
{
self.displayName = displayName
self.lanHost = lanHost
self.tailnetDns = tailnetDns
self.sshPort = sshPort
self.gatewayPort = gatewayPort
self.cliPath = cliPath
self.stableID = stableID
self.debugID = debugID
self.isLocal = isLocal
}
}
var gateways: [DiscoveredGateway] = []
var statusText: String = "Idle"
public var gateways: [DiscoveredGateway] = []
public var statusText: String = "Idle"
private var browsers: [String: NWBrowser] = [:]
private var resultsByDomain: [String: Set<NWBrowser.Result>] = [:]
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
private var statesByDomain: [String: NWBrowser.State] = [:]
private var localIdentity: LocalIdentity
private let localDisplayName: String?
private let filterLocalGateways: Bool
private var resolvedTXTByID: [String: [String: String]] = [:]
private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:]
private var wideAreaFallbackTask: Task<Void, Never>?
private var wideAreaFallbackGateways: [DiscoveredGateway] = []
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-discovery")
init() {
self.localIdentity = Self.buildLocalIdentityFast()
public init(
localDisplayName: String? = nil,
filterLocalGateways: Bool = true)
{
self.localDisplayName = localDisplayName
self.filterLocalGateways = filterLocalGateways
self.localIdentity = Self.buildLocalIdentityFast(displayName: localDisplayName)
self.refreshLocalIdentity()
}
func start() {
public func start() {
if !self.browsers.isEmpty { return }
for domain in ClawdbotBonjour.bridgeServiceDomains {
@@ -72,9 +108,24 @@ final class GatewayDiscoveryModel {
self.browsers[domain] = browser
browser.start(queue: DispatchQueue(label: "com.clawdbot.macos.gateway-discovery.\(domain)"))
}
self.scheduleWideAreaFallback()
}
func stop() {
public func refreshWideAreaFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
Task.detached(priority: .utility) { [weak self] in
guard let self else { return }
let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: timeoutSeconds)
await MainActor.run { [weak self] in
guard let self else { return }
self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain)
self.recomputeGateways()
}
}
}
public func stop() {
for browser in self.browsers.values {
browser.cancel()
}
@@ -85,15 +136,52 @@ final class GatewayDiscoveryModel {
self.resolvedTXTByID = [:]
self.pendingTXTResolvers.values.forEach { $0.cancel() }
self.pendingTXTResolvers = [:]
self.wideAreaFallbackTask?.cancel()
self.wideAreaFallbackTask = nil
self.wideAreaFallbackGateways = []
self.gateways = []
self.statusText = "Stopped"
}
private func mapWideAreaBeacons(_ beacons: [WideAreaGatewayBeacon], domain: String) -> [DiscoveredGateway] {
beacons.map { beacon in
let stableID = "wide-area|\(domain)|\(beacon.instanceName)"
let isLocal = Self.isLocalGateway(
lanHost: beacon.lanHost,
tailnetDns: beacon.tailnetDns,
displayName: beacon.displayName,
serviceName: beacon.instanceName,
local: self.localIdentity)
return DiscoveredGateway(
displayName: beacon.displayName,
lanHost: beacon.lanHost,
tailnetDns: beacon.tailnetDns,
sshPort: beacon.sshPort ?? 22,
gatewayPort: beacon.gatewayPort,
cliPath: beacon.cliPath,
stableID: stableID,
debugID: "\(beacon.instanceName)@\(beacon.host):\(beacon.port)",
isLocal: isLocal)
}
}
private func recomputeGateways() {
self.gateways = self.gatewaysByDomain.values
.flatMap(\.self)
.filter { !$0.isLocal }
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
let primary = self.sortedDeduped(gateways: self.gatewaysByDomain.values.flatMap(\.self))
let primaryFiltered = self.filterLocalGateways ? primary.filter { !$0.isLocal } : primary
if !primaryFiltered.isEmpty {
self.gateways = primaryFiltered
return
}
// Bonjour can return only "local" results for the wide-area domain (or no results at all),
// which makes onboarding look empty even though Tailscale DNS-SD can already see bridges.
guard !self.wideAreaFallbackGateways.isEmpty else {
self.gateways = primaryFiltered
return
}
let combined = self.sortedDeduped(gateways: primary + self.wideAreaFallbackGateways)
self.gateways = self.filterLocalGateways ? combined.filter { !$0.isLocal } : combined
}
private func updateGateways(for domain: String) {
@@ -146,6 +234,74 @@ final class GatewayDiscoveryModel {
isLocal: isLocal)
}
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
if domain == ClawdbotBonjour.wideAreaBridgeServiceDomain,
self.hasUsableWideAreaResults
{
self.wideAreaFallbackGateways = []
}
}
private func scheduleWideAreaFallback() {
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
if Self.isRunningTests { return }
guard self.wideAreaFallbackTask == nil else { return }
self.wideAreaFallbackTask = Task.detached(priority: .utility) { [weak self] in
guard let self else { return }
var attempt = 0
let startedAt = Date()
while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 {
let hasResults = await MainActor.run {
self.hasUsableWideAreaResults
}
if hasResults { return }
// Wide-area discovery can be racy (Tailscale not yet up, DNS zone not
// published yet). Retry with a short backoff while onboarding is open.
let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: 2.0)
if !beacons.isEmpty {
await MainActor.run { [weak self] in
guard let self else { return }
self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain)
self.recomputeGateways()
}
return
}
attempt += 1
let backoff = min(8.0, 0.6 + (Double(attempt) * 0.7))
try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000))
}
}
}
private var hasUsableWideAreaResults: Bool {
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false }
if !self.filterLocalGateways { return true }
return gateways.contains(where: { !$0.isLocal })
}
private func sortedDeduped(gateways: [DiscoveredGateway]) -> [DiscoveredGateway] {
var seen = Set<String>()
let deduped = gateways.filter { gateway in
if seen.contains(gateway.stableID) { return false }
seen.insert(gateway.stableID)
return true
}
return deduped.sorted {
$0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending
}
}
private nonisolated static var isRunningTests: Bool {
// Keep discovery background work from running forever during SwiftPM test runs.
if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true }
let env = ProcessInfo.processInfo.environment
return env["XCTestConfigurationFilePath"] != nil
|| env["XCTestBundlePath"] != nil
|| env["XCTestSessionIdentifier"] != nil
}
private func updateGatewaysForAllDomains() {
@@ -208,15 +364,15 @@ final class GatewayDiscoveryModel {
return merged
}
struct GatewayTXT: Equatable {
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var gatewayPort: Int?
var cliPath: String?
public struct GatewayTXT: Equatable {
public var lanHost: String?
public var tailnetDns: String?
public var sshPort: Int
public var gatewayPort: Int?
public var cliPath: String?
}
static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT {
public static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT {
var lanHost: String?
var tailnetDns: String?
var sshPort = 22
@@ -256,7 +412,7 @@ final class GatewayDiscoveryModel {
cliPath: cliPath)
}
static func buildSSHTarget(user: String, host: String, port: Int) -> String {
public static func buildSSHTarget(user: String, host: String, port: Int) -> String {
var target = "\(user)@\(host)"
if port != 22 {
target += ":\(port)"
@@ -324,7 +480,7 @@ final class GatewayDiscoveryModel {
return titled.isEmpty ? normalized : titled
}
nonisolated static func isLocalGateway(
public nonisolated static func isLocalGateway(
lanHost: String?,
tailnetDns: String?,
displayName: String?,
@@ -346,18 +502,19 @@ final class GatewayDiscoveryModel {
{
return true
}
if let service = normalizeServiceToken(serviceName) {
for token in local.hostTokens where service.contains(token) {
return true
}
if let serviceHost = normalizeServiceHostToken(serviceName),
local.hostTokens.contains(serviceHost)
{
return true
}
return false
}
private func refreshLocalIdentity() {
let fastIdentity = self.localIdentity
let displayName = self.localDisplayName
Task.detached(priority: .utility) {
let slowIdentity = Self.buildLocalIdentitySlow()
let slowIdentity = Self.buildLocalIdentitySlow(displayName: displayName)
let merged = Self.mergeLocalIdentity(fast: fastIdentity, slow: slowIdentity)
await MainActor.run { [weak self] in
guard let self else { return }
@@ -377,7 +534,7 @@ final class GatewayDiscoveryModel {
displayTokens: fast.displayTokens.union(slow.displayTokens))
}
private nonisolated static func buildLocalIdentityFast() -> LocalIdentity {
private nonisolated static func buildLocalIdentityFast(displayName: String?) -> LocalIdentity {
var hostTokens: Set<String> = []
var displayTokens: Set<String> = []
@@ -386,14 +543,14 @@ final class GatewayDiscoveryModel {
hostTokens.insert(token)
}
if let token = normalizeDisplayToken(InstanceIdentity.displayName) {
if let token = normalizeDisplayToken(displayName) {
displayTokens.insert(token)
}
return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens)
}
private nonisolated static func buildLocalIdentitySlow() -> LocalIdentity {
private nonisolated static func buildLocalIdentitySlow(displayName: String?) -> LocalIdentity {
var hostTokens: Set<String> = []
var displayTokens: Set<String> = []
@@ -403,6 +560,10 @@ final class GatewayDiscoveryModel {
hostTokens.insert(token)
}
if let token = normalizeDisplayToken(displayName) {
displayTokens.insert(token)
}
if let token = normalizeDisplayToken(Host.current().localizedName) {
displayTokens.insert(token)
}
@@ -434,11 +595,14 @@ final class GatewayDiscoveryModel {
return trimmed.lowercased()
}
private nonisolated static func normalizeServiceToken(_ raw: String?) -> String? {
private nonisolated static func normalizeServiceHostToken(_ raw: String?) -> String? {
guard let raw else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
return trimmed.lowercased()
let prettified = Self.prettifyInstanceName(raw)
let strippedBridge = prettified.replacingOccurrences(
of: #"\s*-?\s*bridge$"#,
with: "",
options: .regularExpression)
return normalizeHostToken(strippedBridge)
}
}
@@ -0,0 +1,376 @@
import ClawdbotKit
import Foundation
struct WideAreaGatewayBeacon: Sendable, Equatable {
var instanceName: String
var displayName: String
var host: String
var port: Int
var lanHost: String?
var tailnetDns: String?
var gatewayPort: Int?
var bridgePort: Int?
var sshPort: Int?
var cliPath: String?
}
enum WideAreaGatewayDiscovery {
private static let maxCandidates = 40
private static let digPath = "/usr/bin/dig"
private static let defaultTimeoutSeconds: TimeInterval = 0.2
private static let nameserverProbeConcurrency = 6
struct DiscoveryContext: Sendable {
var tailscaleStatus: @Sendable () -> String?
var dig: @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?
static let live = DiscoveryContext(
tailscaleStatus: { readTailscaleStatus() },
dig: { args, timeout in
runDig(args: args, timeout: timeout)
})
}
static func discover(
timeoutSeconds: TimeInterval = 2.0,
context: DiscoveryContext = .live) -> [WideAreaGatewayBeacon]
{
let startedAt = Date()
let remaining = {
timeoutSeconds - Date().timeIntervalSince(startedAt)
}
guard let ips = collectTailnetIPv4s(
statusJson: context.tailscaleStatus()).nonEmpty else { return [] }
var candidates = Array(ips.prefix(self.maxCandidates))
guard let nameserver = findNameserver(
candidates: &candidates,
remaining: remaining,
dig: context.dig)
else {
return []
}
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)"
guard let ptrLines = context.dig(
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
!ptrLines.isEmpty
else {
return []
}
var beacons: [WideAreaGatewayBeacon] = []
for raw in ptrLines {
let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if ptr.isEmpty { continue }
let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr
let suffix = "._clawdbot-bridge._tcp.\(domainTrimmed)"
let rawInstanceName = ptrName.hasSuffix(suffix)
? String(ptrName.dropLast(suffix.count))
: ptrName
let instanceName = self.decodeDnsSdEscapes(rawInstanceName)
guard let srv = context.dig(
["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "SRV"],
min(defaultTimeoutSeconds, remaining()))
else { continue }
guard let (host, port) = parseSrv(srv) else { continue }
let txtRaw = context.dig(
["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "TXT"],
min(self.defaultTimeoutSeconds, remaining()))
let txtTokens = txtRaw.map(self.parseTxtTokens) ?? []
let txt = self.mapTxt(tokens: txtTokens)
let displayName = txt["displayName"] ?? instanceName
let beacon = WideAreaGatewayBeacon(
instanceName: instanceName,
displayName: displayName,
host: host,
port: port,
lanHost: txt["lanHost"],
tailnetDns: txt["tailnetDns"],
gatewayPort: parseInt(txt["gatewayPort"]),
bridgePort: parseInt(txt["bridgePort"]),
sshPort: parseInt(txt["sshPort"]),
cliPath: txt["cliPath"])
beacons.append(beacon)
}
return beacons
}
private static func collectTailnetIPv4s(statusJson: String?) -> [String] {
guard let statusJson else { return [] }
let decoder = JSONDecoder()
guard let data = statusJson.data(using: .utf8),
let status = try? decoder.decode(TailscaleStatus.self, from: data)
else { return [] }
var ips: [String] = []
ips.append(contentsOf: status.selfNode?.resolvedIPs ?? [])
if let peers = status.peer {
for peer in peers.values {
ips.append(contentsOf: peer.resolvedIPs)
}
}
var seen = Set<String>()
let ordered = ips.filter { value in
guard self.isTailnetIPv4(value) else { return false }
if seen.contains(value) { return false }
seen.insert(value)
return true
}
return ordered
}
private static func readTailscaleStatus() -> String? {
let candidates = [
"/usr/local/bin/tailscale",
"/opt/homebrew/bin/tailscale",
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
"tailscale",
]
var output: String?
for candidate in candidates {
if let result = run(
path: candidate,
args: ["status", "--json"],
timeout: 0.7)
{
output = result
break
}
}
return output
}
private static func findNameserver(
candidates: inout [String],
remaining: () -> TimeInterval,
dig: @escaping @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?) -> String?
{
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)"
let ips = candidates
candidates.removeAll(keepingCapacity: true)
if ips.isEmpty { return nil }
final class ProbeState: @unchecked Sendable {
let lock = NSLock()
var nextIndex = 0
var found: String?
}
let state = ProbeState()
let deadline = Date().addingTimeInterval(max(0, remaining()))
let workerCount = min(self.nameserverProbeConcurrency, ips.count)
let group = DispatchGroup()
for _ in 0..<workerCount {
group.enter()
DispatchQueue.global(qos: .utility).async {
defer { group.leave() }
while Date() < deadline {
state.lock.lock()
if state.found != nil {
state.lock.unlock()
return
}
let i = state.nextIndex
state.nextIndex += 1
state.lock.unlock()
if i >= ips.count { return }
let ip = ips[i]
let budget = deadline.timeIntervalSinceNow
if budget <= 0 { return }
if let stdout = dig(
["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"],
min(defaultTimeoutSeconds, budget)),
stdout.split(whereSeparator: \.isNewline).isEmpty == false
{
state.lock.lock()
if state.found == nil {
state.found = ip
}
state.lock.unlock()
return
}
}
}
}
_ = group.wait(timeout: .now() + max(0.0, remaining()))
return state.found
}
private static func runDig(args: [String], timeout: TimeInterval) -> String? {
self.run(path: self.digPath, args: args, timeout: timeout)
}
private static func run(path: String, args: [String], timeout: TimeInterval) -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: path)
process.arguments = args
let outPipe = Pipe()
let errPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
do {
try process.run()
} catch {
return nil
}
let deadline = Date().addingTimeInterval(timeout)
while process.isRunning, Date() < deadline {
Thread.sleep(forTimeInterval: 0.02)
}
if process.isRunning {
process.terminate()
}
process.waitUntilExit()
let data = (try? outPipe.fileHandleForReading.readToEnd()) ?? Data()
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
return output?.isEmpty == false ? output : nil
}
private static func parseSrv(_ stdout: String) -> (String, Int)? {
let line = stdout
.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.first(where: { !$0.isEmpty })
guard let line else { return nil }
let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
guard parts.count >= 4 else { return nil }
guard let port = Int(parts[2]), port > 0 else { return nil }
let host = parts[3].hasSuffix(".") ? String(parts[3].dropLast()) : parts[3]
return (host, port)
}
private static func parseTxtTokens(_ stdout: String) -> [String] {
let lines = stdout.split(whereSeparator: \.isNewline)
var tokens: [String] = []
for raw in lines {
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if line.isEmpty { continue }
let matches = line.matches(of: /"([^"]*)"/)
for match in matches {
tokens.append(self.unescapeTxt(String(match.1)))
}
}
return tokens
}
private static func unescapeTxt(_ value: String) -> String {
value
.replacingOccurrences(of: "\\\\", with: "\\")
.replacingOccurrences(of: "\\\"", with: "\"")
.replacingOccurrences(of: "\\n", with: "\n")
}
private static func mapTxt(tokens: [String]) -> [String: String] {
var out: [String: String] = [:]
for token in tokens {
guard let idx = token.firstIndex(of: "=") else { continue }
let key = String(token[..<idx]).trimmingCharacters(in: .whitespacesAndNewlines)
let rawValue = String(token[token.index(after: idx)...])
.trimmingCharacters(in: .whitespacesAndNewlines)
let value = self.decodeDnsSdEscapes(rawValue)
if !key.isEmpty { out[key] = value }
}
return out
}
private static func parseInt(_ value: String?) -> Int? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return Int(trimmed)
}
private static func isTailnetIPv4(_ value: String) -> Bool {
let parts = value.split(separator: ".")
if parts.count != 4 { return false }
let octets = parts.compactMap { Int($0) }
if octets.count != 4 { return false }
let a = octets[0]
let b = octets[1]
return a == 100 && b >= 64 && b <= 127
}
private static func decodeDnsSdEscapes(_ value: String) -> String {
var bytes: [UInt8] = []
var pending = ""
func flushPending() {
guard !pending.isEmpty else { return }
bytes.append(contentsOf: pending.utf8)
pending = ""
}
let chars = Array(value)
var i = 0
while i < chars.count {
let ch = chars[i]
if ch == "\\", i + 3 < chars.count {
let digits = String(chars[(i + 1)...(i + 3)])
if digits.allSatisfy(\.isNumber),
let byte = UInt8(digits)
{
flushPending()
bytes.append(byte)
i += 4
continue
}
}
pending.append(ch)
i += 1
}
flushPending()
if bytes.isEmpty { return value }
if let decoded = String(bytes: bytes, encoding: .utf8) {
return decoded
}
return value
}
}
private struct TailscaleStatus: Decodable {
struct Node: Decodable {
let tailscaleIPs: [String]?
var resolvedIPs: [String] {
self.tailscaleIPs ?? []
}
private enum CodingKeys: String, CodingKey {
case tailscaleIPs = "TailscaleIPs"
}
}
let selfNode: Node?
let peer: [String: Node]?
private enum CodingKeys: String, CodingKey {
case selfNode = "Self"
case peer = "Peer"
}
}
extension Collection {
fileprivate var nonEmpty: Self? { isEmpty ? nil : self }
}
@@ -0,0 +1,150 @@
import ClawdbotDiscovery
import Foundation
struct DiscoveryOptions {
var timeoutMs: Int = 2000
var json: Bool = false
var includeLocal: Bool = false
var help: Bool = false
static func parse(_ args: [String]) -> DiscoveryOptions {
var opts = DiscoveryOptions()
var i = 0
while i < args.count {
let arg = args[i]
switch arg {
case "-h", "--help":
opts.help = true
case "--json":
opts.json = true
case "--include-local":
opts.includeLocal = true
case "--timeout":
let next = (i + 1 < args.count) ? args[i + 1] : nil
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
opts.timeoutMs = max(100, parsed)
i += 1
}
default:
break
}
i += 1
}
return opts
}
}
struct DiscoveryOutput: Encodable {
struct Gateway: Encodable {
var displayName: String
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var gatewayPort: Int?
var cliPath: String?
var stableID: String
var debugID: String
var isLocal: Bool
}
var status: String
var timeoutMs: Int
var includeLocal: Bool
var count: Int
var gateways: [Gateway]
}
@main
struct ClawdbotDiscoveryCLI {
static func main() async {
let opts = DiscoveryOptions.parse(Array(CommandLine.arguments.dropFirst()))
if opts.help {
print("""
clawdbot-mac-discovery
Usage:
clawdbot-mac-discovery [--timeout <ms>] [--json] [--include-local]
Options:
--timeout <ms> Discovery window in milliseconds (default: 2000)
--json Emit JSON
--include-local Include gateways considered local
-h, --help Show help
""")
return
}
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
let model = GatewayDiscoveryModel(
localDisplayName: displayName,
filterLocalGateways: !opts.includeLocal)
await MainActor.run {
model.start()
}
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
try? await Task.sleep(nanoseconds: nanos)
let gateways = await MainActor.run { model.gateways }
let status = await MainActor.run { model.statusText }
await MainActor.run {
model.stop()
}
if opts.json {
let payload = DiscoveryOutput(
status: status,
timeoutMs: opts.timeoutMs,
includeLocal: opts.includeLocal,
count: gateways.count,
gateways: gateways.map {
DiscoveryOutput.Gateway(
displayName: $0.displayName,
lanHost: $0.lanHost,
tailnetDns: $0.tailnetDns,
sshPort: $0.sshPort,
gatewayPort: $0.gatewayPort,
cliPath: $0.cliPath,
stableID: $0.stableID,
debugID: $0.debugID,
isLocal: $0.isLocal)
})
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(payload),
let json = String(data: data, encoding: .utf8)
{
print(json)
} else {
print("{\"error\":\"failed to encode JSON\"}")
}
return
}
print("Gateway Discovery (macOS NWBrowser)")
print("Status: \(status)")
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
if gateways.isEmpty { return }
for gateway in gateways {
let hosts = [gateway.tailnetDns, gateway.lanHost]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: ", ")
print("- \(gateway.displayName)")
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
print(" ssh: \(gateway.sshPort)")
if let port = gateway.gatewayPort {
print(" gatewayPort: \(port)")
}
if let cliPath = gateway.cliPath {
print(" cliPath: \(cliPath)")
}
print(" isLocal: \(gateway.isLocal)")
print(" stableID: \(gateway.stableID)")
print(" debugID: \(gateway.debugID)")
}
}
}
@@ -421,6 +421,7 @@ public struct AgentParams: Codable, Sendable {
public let sessionkey: String?
public let thinking: String?
public let deliver: Bool?
public let attachments: [AnyCodable]?
public let provider: String?
public let timeout: Int?
public let lane: String?
@@ -436,6 +437,7 @@ public struct AgentParams: Codable, Sendable {
sessionkey: String?,
thinking: String?,
deliver: Bool?,
attachments: [AnyCodable]?,
provider: String?,
timeout: Int?,
lane: String?,
@@ -450,6 +452,7 @@ public struct AgentParams: Codable, Sendable {
self.sessionkey = sessionkey
self.thinking = thinking
self.deliver = deliver
self.attachments = attachments
self.provider = provider
self.timeout = timeout
self.lane = lane
@@ -465,6 +468,7 @@ public struct AgentParams: Codable, Sendable {
case sessionkey = "sessionKey"
case thinking
case deliver
case attachments
case provider
case timeout
case lane
@@ -1629,11 +1633,11 @@ public struct ChatSendParams: Codable, Sendable {
public struct ChatAbortParams: Codable, Sendable {
public let sessionkey: String
public let runid: String
public let runid: String?
public init(
sessionkey: String,
runid: String
runid: String?
) {
self.sessionkey = sessionkey
self.runid = runid
@@ -1,5 +1,5 @@
import ClawdbotDiscovery
import Testing
@testable import Clawdbot
@Suite
@MainActor
@@ -64,6 +64,24 @@ struct GatewayDiscoveryModelTests {
local: local))
}
@Test func serviceNameDoesNotFalsePositiveOnSubstringHostToken() {
let local = GatewayDiscoveryModel.LocalIdentity(
hostTokens: ["steipete"],
displayTokens: [])
#expect(!GatewayDiscoveryModel.isLocalGateway(
lanHost: nil,
tailnetDns: nil,
displayName: nil,
serviceName: "steipetacstudio (Clawdbot)",
local: local))
#expect(GatewayDiscoveryModel.isLocalGateway(
lanHost: nil,
tailnetDns: nil,
displayName: nil,
serviceName: "steipete (Clawdbot)",
local: local))
}
@Test func parsesGatewayTXTFields() {
let parsed = GatewayDiscoveryModel.parseGatewayTXT([
"lanHost": " studio.local ",
@@ -57,6 +57,25 @@ struct LowCoverageHelperTests {
#expect(result.timedOut == true)
}
@Test func shellExecutorDrainsStdoutAndStderr() async {
let script = """
i=0
while [ $i -lt 2000 ]; do
echo "stdout-$i"
echo "stderr-$i" 1>&2
i=$((i+1))
done
"""
let result = await ShellExecutor.runDetailed(
command: ["/bin/sh", "-c", script],
cwd: nil,
env: nil,
timeout: 2)
#expect(result.success == true)
#expect(result.stdout.contains("stdout-1999"))
#expect(result.stderr.contains("stderr-1999"))
}
@Test func pairedNodesStorePersists() async throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true)
@@ -1,3 +1,4 @@
import Foundation
import Testing
@testable import Clawdbot
@@ -16,4 +17,3 @@ struct MacNodeBridgeSessionTests {
}
}
}
@@ -1,3 +1,4 @@
import ClawdbotDiscovery
import SwiftUI
import Testing
@testable import Clawdbot
@@ -6,7 +7,7 @@ import Testing
@MainActor
struct MasterDiscoveryMenuSmokeTests {
@Test func inlineListBuildsBodyWhenEmpty() {
let discovery = GatewayDiscoveryModel()
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
discovery.statusText = "Searching…"
discovery.gateways = []
@@ -15,7 +16,7 @@ struct MasterDiscoveryMenuSmokeTests {
}
@Test func inlineListBuildsBodyWithMasterAndSelection() {
let discovery = GatewayDiscoveryModel()
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
discovery.statusText = "Found 1"
discovery.gateways = [
GatewayDiscoveryModel.DiscoveredGateway(
@@ -23,6 +24,8 @@ struct MasterDiscoveryMenuSmokeTests {
lanHost: "office.local",
tailnetDns: "office.tailnet-123.ts.net",
sshPort: 2222,
gatewayPort: nil,
cliPath: nil,
stableID: "office",
debugID: "office",
isLocal: false),
@@ -34,7 +37,7 @@ struct MasterDiscoveryMenuSmokeTests {
}
@Test func menuBuildsBodyWithMasters() {
let discovery = GatewayDiscoveryModel()
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
discovery.statusText = "Found 2"
discovery.gateways = [
GatewayDiscoveryModel.DiscoveredGateway(
@@ -42,6 +45,8 @@ struct MasterDiscoveryMenuSmokeTests {
lanHost: "a.local",
tailnetDns: nil,
sshPort: 22,
gatewayPort: nil,
cliPath: nil,
stableID: "a",
debugID: "a",
isLocal: false),
@@ -50,6 +55,8 @@ struct MasterDiscoveryMenuSmokeTests {
lanHost: nil,
tailnetDns: "b.ts.net",
sshPort: 22,
gatewayPort: nil,
cliPath: nil,
stableID: "b",
debugID: "b",
isLocal: false),
@@ -23,7 +23,7 @@ struct MenuSessionsInjectorTests {
let injector = MenuSessionsInjector()
injector.setTestingControlChannelConnected(true)
let defaults = SessionDefaults(model: "claude-opus-4-5", contextTokens: 200_000)
let defaults = SessionDefaults(model: "anthropic/claude-opus-4-5", contextTokens: 200_000)
let rows = [
SessionRow(
id: "main",
@@ -66,6 +66,24 @@ struct MenuSessionsInjectorTests {
rows: rows)
injector.setTestingSnapshot(snapshot, errorText: nil)
let usage = GatewayUsageSummary(
updatedAt: Date().timeIntervalSince1970 * 1000,
providers: [
GatewayUsageProvider(
provider: "anthropic",
displayName: "Claude",
windows: [GatewayUsageWindow(label: "5h", usedPercent: 12, resetAt: nil)],
plan: "Pro",
error: nil),
GatewayUsageProvider(
provider: "openai-codex",
displayName: "Codex",
windows: [GatewayUsageWindow(label: "day", usedPercent: 3, resetAt: nil)],
plan: nil,
error: nil),
])
injector.setTestingUsageSummary(usage, errorText: nil)
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: ""))
menu.addItem(.separator())
@@ -73,5 +91,6 @@ struct MenuSessionsInjectorTests {
injector.injectForTesting(into: menu)
#expect(menu.items.contains { $0.tag == 9_415_557 })
#expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem })
}
}
@@ -1,3 +1,4 @@
import ClawdbotDiscovery
import SwiftUI
import Testing
@testable import Clawdbot
@@ -10,7 +11,7 @@ struct OnboardingViewSmokeTests {
let view = OnboardingView(
state: state,
permissionMonitor: PermissionMonitor.shared,
discoveryModel: GatewayDiscoveryModel())
discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName))
_ = view.body
}
@@ -0,0 +1,20 @@
import CoreLocation
import Testing
@testable import Clawdbot
@Suite("PermissionManager Location")
struct PermissionManagerLocationTests {
@Test("authorizedAlways counts for both modes")
func authorizedAlwaysCountsForBothModes() {
#expect(PermissionManager.isLocationAuthorized(status: .authorizedAlways, requireAlways: false))
#expect(PermissionManager.isLocationAuthorized(status: .authorizedAlways, requireAlways: true))
}
@Test("other statuses not authorized")
func otherStatusesNotAuthorized() {
#expect(!PermissionManager.isLocationAuthorized(status: .notDetermined, requireAlways: false))
#expect(!PermissionManager.isLocationAuthorized(status: .denied, requireAlways: false))
#expect(!PermissionManager.isLocationAuthorized(status: .restricted, requireAlways: false))
}
}
@@ -0,0 +1,49 @@
import Testing
@testable import ClawdbotDiscovery
@Suite
struct WideAreaGatewayDiscoveryTests {
@Test func discoversBeaconFromTailnetDnsSdFallback() {
let statusJson = """
{
"Self": { "TailscaleIPs": ["100.69.232.64"] },
"Peer": {
"peer-1": { "TailscaleIPs": ["100.123.224.76"] }
}
}
"""
let context = WideAreaGatewayDiscovery.DiscoveryContext(
tailscaleStatus: { statusJson },
dig: { args, _ in
let recordType = args.last ?? ""
let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? ""
if recordType == "PTR" {
if nameserver == "@100.123.224.76" {
return "steipetacstudio-bridge._clawdbot-bridge._tcp.clawdbot.internal.\n"
}
return ""
}
if recordType == "SRV" {
return "0 0 18790 steipetacstudio.clawdbot.internal."
}
if recordType == "TXT" {
return "\"displayName=Peter\\226\\128\\153s Mac Studio (Clawdbot)\" \"transport=bridge\" \"bridgePort=18790\" \"gatewayPort=18789\" \"tailnetDns=peters-mac-studio-1.sheep-coho.ts.net\" \"cliPath=/Users/steipete/clawdbot/src/entry.ts\""
}
return ""
})
let beacons = WideAreaGatewayDiscovery.discover(
timeoutSeconds: 2.0,
context: context)
#expect(beacons.count == 1)
let beacon = beacons[0]
let expectedDisplay = "Peter\u{2019}s Mac Studio (Clawdbot)"
#expect(beacon.displayName == expectedDisplay)
#expect(beacon.bridgePort == 18790)
#expect(beacon.gatewayPort == 18789)
#expect(beacon.tailnetDns == "peters-mac-studio-1.sheep-coho.ts.net")
#expect(beacon.cliPath == "/Users/steipete/clawdbot/src/entry.ts")
}
}
@@ -52,8 +52,10 @@ public struct ClawdbotChatView: View {
public var body: some View {
ZStack {
ClawdbotChatTheme.background
.ignoresSafeArea()
if self.style == .standard {
ClawdbotChatTheme.background
.ignoresSafeArea()
}
VStack(spacing: Layout.stackSpacing) {
self.messageList
@@ -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,12 +1,16 @@
import Foundation
enum AsyncTimeout {
static func withTimeout<T: Sendable>(
public enum AsyncTimeout {
public static func withTimeout<T: Sendable>(
seconds: Double,
onTimeout: @escaping @Sendable () -> Error,
operation: @escaping @Sendable () async throws -> T) async throws -> T
{
let clamped = max(0, seconds)
if clamped == 0 {
return try await operation()
}
return try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
@@ -19,4 +23,14 @@ enum AsyncTimeout {
throw onTimeout()
}
}
public static func withTimeoutMs<T: Sendable>(
timeoutMs: Int,
onTimeout: @escaping @Sendable () -> Error,
operation: @escaping @Sendable () async throws -> T) async throws -> T
{
let clamped = max(0, timeoutMs)
let seconds = Double(clamped) / 1000.0
return try await self.withTimeout(seconds: seconds, onTimeout: onTimeout, operation: operation)
}
}
@@ -91,16 +91,85 @@ public enum ToolDisplayRegistry {
private static func loadConfig() -> ToolDisplayConfig {
guard let url = ClawdbotKitResources.bundle.url(forResource: "tool-display", withExtension: "json") else {
return ToolDisplayConfig(version: nil, fallback: nil, tools: nil)
return self.defaultConfig()
}
do {
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(ToolDisplayConfig.self, from: data)
} catch {
return ToolDisplayConfig(version: nil, fallback: nil, tools: nil)
return self.defaultConfig()
}
}
private static func defaultConfig() -> ToolDisplayConfig {
ToolDisplayConfig(
version: 1,
fallback: ToolDisplaySpec(
emoji: "🧩",
title: nil,
label: nil,
detailKeys: [
"command",
"path",
"url",
"targetUrl",
"targetId",
"ref",
"element",
"node",
"nodeId",
"id",
"requestId",
"to",
"channelId",
"guildId",
"userId",
"name",
"query",
"pattern",
"messageId",
],
actions: nil),
tools: [
"bash": ToolDisplaySpec(
emoji: "🛠️",
title: "Bash",
label: nil,
detailKeys: ["command"],
actions: nil),
"read": ToolDisplaySpec(
emoji: "📖",
title: "Read",
label: nil,
detailKeys: ["path"],
actions: nil),
"write": ToolDisplaySpec(
emoji: "✍️",
title: "Write",
label: nil,
detailKeys: ["path"],
actions: nil),
"edit": ToolDisplaySpec(
emoji: "📝",
title: "Edit",
label: nil,
detailKeys: ["path"],
actions: nil),
"attach": ToolDisplaySpec(
emoji: "📎",
title: "Attach",
label: nil,
detailKeys: ["path", "url", "fileName"],
actions: nil),
"process": ToolDisplaySpec(
emoji: "🧰",
title: "Process",
label: nil,
detailKeys: ["sessionId"],
actions: nil),
])
}
private static func titleFromName(_ name: String) -> String {
let cleaned = name.replacingOccurrences(of: "_", with: " ").trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return "Tool" }
@@ -122,8 +191,10 @@ public enum ToolDisplayRegistry {
private static func readDetail(_ args: AnyCodable?) -> String? {
guard let path = valueForKeyPath(args, path: "path") as? String else { return nil }
let offset = self.valueForKeyPath(args, path: "offset") as? Double
let limit = self.valueForKeyPath(args, path: "limit") as? Double
let offsetAny = self.valueForKeyPath(args, path: "offset")
let limitAny = self.valueForKeyPath(args, path: "limit")
let offset = (offsetAny as? Double) ?? (offsetAny as? Int).map(Double.init)
let limit = (limitAny as? Double) ?? (limitAny as? Int).map(Double.init)
if let offset, let limit {
let end = offset + limit
return "\(path):\(Int(offset))-\(Int(end))"
+92 -9
View File
@@ -3,7 +3,10 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_FILE="$ROOT_DIR/docker-compose.yml"
EXTRA_COMPOSE_FILE="$ROOT_DIR/docker-compose.extra.yml"
IMAGE_NAME="${CLAWDBOT_IMAGE:-clawdbot:local}"
EXTRA_MOUNTS="${CLAWDBOT_EXTRA_MOUNTS:-}"
HOME_VOLUME_NAME="${CLAWDBOT_HOME_VOLUME:-}"
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
@@ -27,6 +30,7 @@ export CLAWDBOT_GATEWAY_PORT="${CLAWDBOT_GATEWAY_PORT:-18789}"
export CLAWDBOT_BRIDGE_PORT="${CLAWDBOT_BRIDGE_PORT:-18790}"
export CLAWDBOT_GATEWAY_BIND="${CLAWDBOT_GATEWAY_BIND:-lan}"
export CLAWDBOT_IMAGE="$IMAGE_NAME"
export CLAWDBOT_DOCKER_APT_PACKAGES="${CLAWDBOT_DOCKER_APT_PACKAGES:-}"
if [[ -z "${CLAWDBOT_GATEWAY_TOKEN:-}" ]]; then
if command -v openssl >/dev/null 2>&1; then
@@ -41,6 +45,78 @@ PY
fi
export CLAWDBOT_GATEWAY_TOKEN
COMPOSE_FILES=("$COMPOSE_FILE")
COMPOSE_ARGS=()
write_extra_compose() {
local home_volume="$1"
shift
local -a mounts=("$@")
local mount
cat >"$EXTRA_COMPOSE_FILE" <<'YAML'
services:
clawdbot-gateway:
volumes:
YAML
if [[ -n "$home_volume" ]]; then
printf ' - %s:/home/node\n' "$home_volume" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s:/home/node/.clawdbot\n' "$CLAWDBOT_CONFIG_DIR" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s:/home/node/clawd\n' "$CLAWDBOT_WORKSPACE_DIR" >>"$EXTRA_COMPOSE_FILE"
fi
for mount in "${mounts[@]}"; do
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
done
cat >>"$EXTRA_COMPOSE_FILE" <<'YAML'
clawdbot-cli:
volumes:
YAML
if [[ -n "$home_volume" ]]; then
printf ' - %s:/home/node\n' "$home_volume" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s:/home/node/.clawdbot\n' "$CLAWDBOT_CONFIG_DIR" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s:/home/node/clawd\n' "$CLAWDBOT_WORKSPACE_DIR" >>"$EXTRA_COMPOSE_FILE"
fi
for mount in "${mounts[@]}"; do
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
done
if [[ -n "$home_volume" && "$home_volume" != *"/"* ]]; then
cat >>"$EXTRA_COMPOSE_FILE" <<YAML
volumes:
${home_volume}:
YAML
fi
}
VALID_MOUNTS=()
if [[ -n "$EXTRA_MOUNTS" ]]; then
IFS=',' read -r -a mounts <<<"$EXTRA_MOUNTS"
for mount in "${mounts[@]}"; do
mount="${mount#"${mount%%[![:space:]]*}"}"
mount="${mount%"${mount##*[![:space:]]}"}"
if [[ -n "$mount" ]]; then
VALID_MOUNTS+=("$mount")
fi
done
fi
if [[ -n "$HOME_VOLUME_NAME" || ${#VALID_MOUNTS[@]} -gt 0 ]]; then
write_extra_compose "$HOME_VOLUME_NAME" "${VALID_MOUNTS[@]}"
COMPOSE_FILES+=("$EXTRA_COMPOSE_FILE")
fi
for compose_file in "${COMPOSE_FILES[@]}"; do
COMPOSE_ARGS+=("-f" "$compose_file")
done
COMPOSE_HINT="docker compose"
for compose_file in "${COMPOSE_FILES[@]}"; do
COMPOSE_HINT+=" -f ${compose_file}"
done
ENV_FILE="$ROOT_DIR/.env"
upsert_env() {
local file="$1"
@@ -84,10 +160,17 @@ upsert_env "$ENV_FILE" \
CLAWDBOT_BRIDGE_PORT \
CLAWDBOT_GATEWAY_BIND \
CLAWDBOT_GATEWAY_TOKEN \
CLAWDBOT_IMAGE
CLAWDBOT_IMAGE \
CLAWDBOT_EXTRA_MOUNTS \
CLAWDBOT_HOME_VOLUME \
CLAWDBOT_DOCKER_APT_PACKAGES
echo "==> Building Docker image: $IMAGE_NAME"
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/Dockerfile" "$ROOT_DIR"
docker build \
--build-arg "CLAWDBOT_DOCKER_APT_PACKAGES=${CLAWDBOT_DOCKER_APT_PACKAGES}" \
-t "$IMAGE_NAME" \
-f "$ROOT_DIR/Dockerfile" \
"$ROOT_DIR"
echo ""
echo "==> Onboarding (interactive)"
@@ -98,21 +181,21 @@ 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 "${COMPOSE_ARGS[@]}" run --rm clawdbot-cli onboard --no-install-daemon
echo ""
echo "==> Provider setup (optional)"
echo "WhatsApp (QR):"
echo " docker compose -f $COMPOSE_FILE run --rm clawdbot-cli providers login"
echo " ${COMPOSE_HINT} run --rm clawdbot-cli providers login"
echo "Telegram (bot token):"
echo " docker compose -f $COMPOSE_FILE run --rm clawdbot-cli providers add --provider telegram --token <token>"
echo " ${COMPOSE_HINT} run --rm clawdbot-cli providers add --provider telegram --token <token>"
echo "Discord (bot token):"
echo " docker compose -f $COMPOSE_FILE run --rm clawdbot-cli providers add --provider discord --token <token>"
echo " ${COMPOSE_HINT} run --rm clawdbot-cli providers add --provider discord --token <token>"
echo "Docs: https://docs.clawd.bot/providers"
echo ""
echo "==> Starting gateway"
docker compose -f "$COMPOSE_FILE" up -d clawdbot-gateway
docker compose "${COMPOSE_ARGS[@]}" up -d clawdbot-gateway
echo ""
echo "Gateway running with host port mapping."
@@ -122,5 +205,5 @@ echo "Workspace: $CLAWDBOT_WORKSPACE_DIR"
echo "Token: $CLAWDBOT_GATEWAY_TOKEN"
echo ""
echo "Commands:"
echo " docker compose -f $COMPOSE_FILE logs -f clawdbot-gateway"
echo " docker compose -f $COMPOSE_FILE exec clawdbot-gateway node dist/index.js health --token \"$CLAWDBOT_GATEWAY_TOKEN\""
echo " ${COMPOSE_HINT} logs -f clawdbot-gateway"
echo " ${COMPOSE_HINT} exec clawdbot-gateway node dist/index.js health --token \"$CLAWDBOT_GATEWAY_TOKEN\""
+1 -1
View File
@@ -1,4 +1,4 @@
title: "CLAWDBOT Docs"
title: "Clawdbot Docs"
description: "A TypeScript/Node gateway + macOS/iOS/Android companions for WhatsApp (web) and Telegram (bot)."
markdown: kramdown
highlighter: rouge
+2 -2
View File
@@ -35,7 +35,7 @@
<header class="shell">
<div class="shell__frame" role="banner">
<div class="shell__titlebar">
<div class="brand" aria-label="CLAWDBOT docs terminal">
<div class="brand" aria-label="Clawdbot docs terminal">
<img
class="brand__logo"
src="{{ "/assets/pixel-lobster.svg" | relative_url }}"
@@ -45,7 +45,7 @@
decoding="async"
/>
<div class="brand__text">
<div class="brand__name">CLAWDBOT</div>
<div class="brand__name">Clawdbot</div>
<div class="brand__hint">docs // lobster terminal</div>
</div>
</div>
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: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

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

+24 -2
View File
@@ -13,7 +13,7 @@ Goal: Gmail watch -> Pub/Sub push -> `gog gmail watch serve` -> Clawdbot webhook
- `gcloud` installed and logged in ([install guide](https://docs.cloud.google.com/sdk/docs/install-sdk)).
- `gog` (gogcli) installed and authorized for the Gmail account ([gogcli.sh](https://gogcli.sh/)).
- Clawdbot hooks enabled (see [`docs/webhook.md`](https://docs.clawd.bot/automation/webhook)).
- Clawdbot hooks enabled (see [Webhooks](/automation/webhook)).
- `tailscale` logged in ([tailscale.com](https://tailscale.com/)). Supported setup uses Tailscale Funnel for the public HTTPS endpoint.
Other tunnel services can work, but are DIY/unsupported and require manual wiring.
Right now, Tailscale is what we support.
@@ -65,8 +65,27 @@ 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)).
under `hooks.transformsDir` (see [Webhooks](/automation/webhook)).
## Wizard (recommended)
@@ -86,6 +105,9 @@ Path note: when `tailscale.mode` is enabled, Clawdbot automatically sets
`hooks.gmail.serve.path` to `/` and keeps the public path at
`hooks.gmail.tailscale.path` (default `/gmail-pubsub`) because Tailscale
strips the set-path prefix before proxying.
If you need the backend to receive the prefixed path, set
`hooks.gmail.tailscale.target` (or `--tailscale-target`) to a full URL like
`http://127.0.0.1:8788/gmail-pubsub` and match `hooks.gmail.serve.path`.
Want a custom endpoint? Use `--push-endpoint <url>` or `--tailscale off`.
+2 -2
View File
@@ -72,7 +72,7 @@ Payload:
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging provider. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
- `provider` optional (string): The messaging service for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `msteams`. Defaults to `last`.
- `to` optional (string): The recipient identifier for the provider (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack). Defaults to the last recipient in the main session.
- `to` optional (string): The recipient identifier for the provider (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack, conversation ID for MS Teams). Defaults to the last recipient in the main session.
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
- `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds.
@@ -97,7 +97,7 @@ Mapping options (summary):
- Set `deliver: true` + `provider`/`to` on mappings to route replies to a chat surface
(`provider` defaults to `last` and falls back to WhatsApp).
- `clawdbot hooks gmail setup` writes `hooks.gmail` config for `clawdbot hooks gmail run`.
See [`docs/gmail-pubsub.md`](https://docs.clawd.bot/automation/gmail-pubsub) for the full Gmail watch flow.
See [Gmail Pub/Sub](/automation/gmail-pubsub) for the full Gmail watch flow.
## Responses
+407
View File
@@ -0,0 +1,407 @@
---
summary: "Broadcast a WhatsApp message to multiple agents"
read_when:
- Configuring broadcast groups
- Debugging multi-agent replies in WhatsApp
status: experimental
---
# 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 or DM — all using one phone number.
Current scope: **WhatsApp only** (web provider).
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`). Keys are WhatsApp peer ids:
- group chats: group JID (e.g. `120363403215116621@g.us`)
- DMs: E.164 phone number (e.g. `+15551234567`)
```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)
+2 -1
View File
@@ -39,6 +39,7 @@ Notes:
- `--password <password>`: password override (also sets `CLAWDBOT_GATEWAY_PASSWORD` for the process).
- `--tailscale <off|serve|funnel>`: expose the Gateway via Tailscale.
- `--tailscale-reset-on-exit`: reset Tailscale serve/funnel config on shutdown.
- `--allow-unconfigured`: allow gateway start without `gateway.mode=local` in config.
- `--dev`: create a dev config + workspace if missing (skips BOOTSTRAP.md).
- `--reset`: reset dev config + credentials + sessions + workspace (requires `--dev`).
- `--force`: kill any existing listener on the selected port before starting.
@@ -77,7 +78,7 @@ clawdbot gateway health --url ws://127.0.0.1:18789
- your configured remote gateway (if set), and
- localhost (loopback) **even if remote is configured**.
If multiple gateways are reachable, it prints all of them and warns this is an unconventional setup (usually you want only one gateway).
If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use profiles for redundancy, but most installs still run a single gateway.
```bash
clawdbot gateway status
+66 -11
View File
@@ -14,6 +14,7 @@ This page describes the current CLI behavior. If commands change, update this do
- `--dev`: isolate state under `~/.clawdbot-dev` and shift default ports.
- `--profile <name>`: isolate state under `~/.clawdbot-<name>`.
- `--no-color`: disable ANSI colors.
- `--update`: shorthand for `clawdbot update` (source installs only).
- `-V`, `--version`, `-v`: print version and exit.
## Output styling
@@ -47,9 +48,11 @@ clawdbot [--dev] [--profile <name>] <command>
onboard
configure (alias: config)
doctor
update
providers
list
status
logs
add
remove
login
@@ -72,6 +75,14 @@ clawdbot [--dev] [--profile <name>] <command>
health
status
discover
daemon
status
install
uninstall
start
stop
restart
logs
models
list
status
@@ -81,6 +92,12 @@ clawdbot [--dev] [--profile <name>] <command>
fallbacks list|add|remove|clear
image-fallbacks list|add|remove|clear
scan
auth add|setup-token|paste-token
auth order get|set|clear
sandbox
list
recreate
explain
wake
cron
status
@@ -104,7 +121,8 @@ clawdbot [--dev] [--profile <name>] <command>
run
notify
camera list|snap|clip
canvas snapshot
canvas snapshot|present|hide|navigate|eval
canvas a2ui push|reset
screen record
location get
browser
@@ -153,7 +171,8 @@ Chat messages support `/...` commands (text and native). See [/tools/slash-comma
Highlights:
- `/status` for quick diagnostics.
- `/debug` for runtime-only config overrides (memory, not disk).
- `/config` for persisted config changes.
- `/debug` for runtime-only config overrides (memory, not disk; requires `commands.debug: true`).
## Setup + onboarding
@@ -175,9 +194,11 @@ Interactive wizard to set up gateway, workspace, and skills.
Options:
- `--workspace <dir>`
- `--reset` (reset config + credentials + sessions + workspace before wizard)
- `--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>`
- `--flow <quickstart|advanced>`
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|zai-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`)
@@ -185,7 +206,9 @@ Options:
- `--anthropic-api-key <key>`
- `--openai-api-key <key>`
- `--gemini-api-key <key>`
- `--zai-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>`
@@ -196,9 +219,12 @@ Options:
- `--tailscale <off|serve|funnel>`
- `--tailscale-reset-on-exit`
- `--install-daemon`
- `--no-install-daemon` (alias: `--skip-daemon`)
- `--daemon-runtime <node|bun>`
- `--skip-providers`
- `--skip-skills`
- `--skip-health`
- `--skip-ui`
- `--node-manager <npm|pnpm|bun>`
- `--json`
@@ -217,19 +243,20 @@ Options:
## Provider helpers
### `providers`
Manage chat provider accounts (WhatsApp/Telegram/Discord/Slack/Signal/iMessage).
Manage chat provider accounts (WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams).
Subcommands:
- `providers list`: show configured chat providers and auth profiles (Claude Code + Codex CLI OAuth sync included).
- `providers status`: check gateway reachability and provider health (`--probe` to verify credentials and run small provider audits; use `status --deep` for local-only probes).
- `providers status`: check gateway reachability and provider health (`--probe` runs extra checks; use `clawdbot health` or `clawdbot status --deep` for gateway health probes).
- Tip: `providers status` prints warnings with suggested fixes when it can detect common misconfigurations (then points you to `clawdbot doctor`).
- `providers logs`: show recent provider logs from the gateway log file.
- `providers add`: wizard-style setup when no flags are passed; flags switch to non-interactive mode.
- `providers remove`: disable by default; pass `--delete` to remove config entries without prompts.
- `providers login`: interactive provider login (WhatsApp Web only).
- `providers logout`: log out of a provider session (WhatsApp Web only).
Common options:
- `--provider <name>`: `whatsapp|telegram|discord|slack|signal|imessage`
- `--provider <name>`: `whatsapp|telegram|discord|slack|signal|imessage|msteams`
- `--account <id>`: provider account id (default `default`)
- `--name <label>`: display name for the account
@@ -246,6 +273,11 @@ Common options:
- `--no-usage`: skip provider usage/quota snapshots (OAuth/API-backed only).
- `--json`: output JSON (includes usage unless `--no-usage` is set).
`providers logs` options:
- `--provider <name|all>` (default `all`)
- `--lines <n>` (default `200`)
- `--json`
OAuth sync sources:
- Claude Code → `anthropic:claude-cli`
- macOS: Keychain item "Claude Code-credentials" (choose "Always Allow" to avoid launchd prompts)
@@ -282,14 +314,14 @@ Tip: use `npx clawdhub` to search, install, and sync skills.
Approve DM pairing requests across providers.
Subcommands:
- `pairing list --provider <telegram|signal|imessage|discord|slack|whatsapp> [--json]`
- `pairing approve --provider <...> <code> [--notify]`
- `pairing list <provider> [--json]`
- `pairing approve <provider> <code> [--notify]`
### `hooks gmail`
Gmail Pub/Sub hook setup + runner. See [/automation/gmail-pubsub](/automation/gmail-pubsub).
Subcommands:
- `hooks gmail setup` (requires `--account <email>`; supports `--project`, `--topic`, `--subscription`, `--label`, `--hook-url`, `--hook-token`, `--push-token`, `--bind`, `--port`, `--path`, `--include-body`, `--max-bytes`, `--renew-minutes`, `--tailscale`, `--tailscale-path`, `--push-endpoint`, `--json`)
- `hooks gmail setup` (requires `--account <email>`; supports `--project`, `--topic`, `--subscription`, `--label`, `--hook-url`, `--hook-token`, `--push-token`, `--bind`, `--port`, `--path`, `--include-body`, `--max-bytes`, `--renew-minutes`, `--tailscale`, `--tailscale-path`, `--tailscale-target`, `--push-endpoint`, `--json`)
- `hooks gmail run` (runtime overrides for the same flags)
### `dns setup`
@@ -372,10 +404,12 @@ Show linked session health and recent recipients.
Options:
- `--json`
- `--all` (full diagnosis; read-only, pasteable)
- `--deep` (probe providers)
- `--usage` (show provider usage/quota)
- `--timeout <ms>`
- `--verbose`
- `--debug` (alias for `--verbose`)
### Usage tracking
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
@@ -426,8 +460,11 @@ Options:
- `--reset` (reset dev config + credentials + sessions + workspace)
- `--force` (kill existing listener on port)
- `--verbose`
- `--claude-cli-logs`
- `--ws-log <auto|full|compact>`
- `--compact` (alias for `--ws-log compact`)
- `--raw-stream`
- `--raw-stream-path <path>`
### `daemon`
Manage the Gateway service (launchd/systemd/schtasks).
@@ -443,7 +480,7 @@ Subcommands:
Notes:
- `daemon status` probes the Gateway RPC by default using the daemon’s resolved port/config (override with `--url/--token/--password`).
- `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting.
- `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans).
- `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named Clawdbot services are treated as first-class and aren't flagged as "extra".
- `daemon status` prints which config path the CLI uses vs which config the daemon likely uses (service env), plus the resolved probe target URL.
- `daemon install` defaults to Node runtime; use `--runtime bun` only when WhatsApp is disabled.
- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`.
@@ -471,6 +508,7 @@ Subcommands:
- `gateway call <method> [--params <json>]`
- `gateway health`
- `gateway status`
- `gateway discover`
Common RPCs:
- `config.apply` (validate + write config + restart + wake)
@@ -490,6 +528,10 @@ clawdbot models status
### `models` (root)
`clawdbot models` is an alias for `models status`.
Root options:
- `--status-json` (alias for `models status --json`)
- `--status-plain` (alias for `models status --plain`)
### `models list`
Options:
- `--all`
@@ -540,12 +582,25 @@ Options:
- `--max-candidates <n>`
- `--timeout <ms>`
- `--concurrency <n>`
- `--no-probe`
- `--yes`
- `--no-input`
- `--set-default`
- `--set-image`
- `--json`
### `models auth add|setup-token|paste-token`
Options:
- `add`: interactive auth helper
- `setup-token`: `--provider <name>` (default `anthropic`), `--yes`
- `paste-token`: `--provider <name>`, `--profile-id <id>`, `--expires-in <duration>`
### `models auth order get|set|clear`
Options:
- `get`: `--provider <name>`, `--agent <id>`, `--json`
- `set`: `--provider <name>`, `--agent <id>`, `<profileIds...>`
- `clear`: `--provider <name>`, `--agent <id>`
## Cron + wake
### `wake`
@@ -673,5 +728,5 @@ Options:
- `--deliver`
- `--thinking <level>`
- `--message <text>`
- `--timeout-ms <ms>`
- `--timeout-ms <ms>` (defaults to `agents.defaults.timeoutSeconds`)
- `--history-limit <n>`
+50 -16
View File
@@ -24,9 +24,10 @@ 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)
- Signal: E.164, `group:<id>`, or `signal:+E.164`
- iMessage: handle or `chat_id:<id>`
- Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are rejected)
- Slack: `channel:<id>` or `user:<id>` (raw channel id is accepted)
- Signal: `+E.164`, `group:<id>`, `signal:+E.164`, `signal:group:<id>`, or `username:<name>`/`u:<name>`
- iMessage: handle, `chat_id:<id>`, `chat_guid:<guid>`, or `chat_identifier:<id>`
- MS Teams: conversation id (`19:...@thread.tacv2`) or `conversation:<id>` or `user:<aad-object-id>`
## Common flags
@@ -42,60 +43,82 @@ Target formats (`--to`):
### Core
- `send`
- Providers: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
- 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`)
- Telegram only: `--thread-id` (forum topic id)
- Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field)
- WhatsApp only: `--gif-playback`
- `poll`
- Providers: WhatsApp/Discord/MS Teams
- Required: `--to`, `--poll-question`, `--poll-option` (repeat)
- Optional: `--poll-multi`, `--poll-duration-hours`, `--message`
- Optional: `--poll-multi`
- Discord only: `--poll-duration-hours`, `--message`
- `react`
- Required: `--to`, `--message-id`
- Providers: Discord/Slack/Telegram/WhatsApp
- Required: `--message-id`, `--to` or `--channel-id`
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--channel-id`
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
- WhatsApp only: `--participant`, `--from-me`
- `reactions`
- Required: `--to`, `--message-id`
- Providers: Discord/Slack
- Required: `--message-id`, `--to` or `--channel-id`
- Optional: `--limit`, `--channel-id`
- `read`
- Required: `--to`
- Optional: `--limit`, `--before`, `--after`, `--around`, `--channel-id`
- Providers: Discord/Slack
- Required: `--to` or `--channel-id`
- Optional: `--limit`, `--before`, `--after`, `--channel-id`
- Discord only: `--around`
- `edit`
- Required: `--to`, `--message-id`, `--message`
- Providers: Discord/Slack
- Required: `--message-id`, `--message`, `--to` or `--channel-id`
- Optional: `--channel-id`
- `delete`
- Required: `--to`, `--message-id`
- Providers: Discord/Slack
- Required: `--message-id`, `--to` or `--channel-id`
- Optional: `--channel-id`
- `pin` / `unpin`
- Required: `--to`, `--message-id`
- Providers: Discord/Slack
- Required: `--message-id`, `--to` or `--channel-id`
- Optional: `--channel-id`
- `pins` (list)
- Required: `--to`
- Providers: Discord/Slack
- Required: `--to` or `--channel-id`
- Optional: `--channel-id`
- `permissions`
- Required: `--to`
- Providers: Discord
- Required: `--to` or `--channel-id`
- Optional: `--channel-id`
- `search`
- Providers: Discord
- Required: `--guild-id`, `--query`
- Optional: `--channel-id`, `--channel-ids` (repeat), `--author-id`, `--author-ids` (repeat), `--limit`
### Threads
- `thread create`
- Providers: Discord
- Required: `--thread-name`, `--to` (channel id) or `--channel-id`
- Optional: `--message-id`, `--auto-archive-min`
- `thread list`
- Providers: Discord
- Required: `--guild-id`
- Optional: `--channel-id`, `--include-archived`, `--before`, `--limit`
- `thread reply`
- Providers: Discord
- Required: `--to` (thread id), `--message`
- Optional: `--media`, `--reply-to`
@@ -103,18 +126,22 @@ Target formats (`--to`):
- `emoji list`
- Discord: `--guild-id`
- Slack: no extra flags
- `emoji upload`
- Providers: Discord
- Required: `--guild-id`, `--emoji-name`, `--media`
- Optional: `--role-ids` (repeat)
### Stickers
- `sticker send`
- Providers: Discord
- Required: `--to`, `--sticker-id` (repeat)
- Optional: `--message`
- `sticker upload`
- Providers: Discord
- Required: `--guild-id`, `--sticker-name`, `--sticker-desc`, `--sticker-tags`, `--media`
### Roles / Channels / Members / Voice
@@ -134,9 +161,10 @@ Target formats (`--to`):
### Moderation (Discord)
- `timeout`: `--guild-id`, `--user-id` (+ `--duration-min` or `--until`)
- `kick`: `--guild-id`, `--user-id`
- `ban`: `--guild-id`, `--user-id` (+ `--delete-days`)
- `timeout`: `--guild-id`, `--user-id` (optional `--duration-min` or `--until`; omit both to clear timeout)
- `kick`: `--guild-id`, `--user-id` (+ `--reason`)
- `ban`: `--guild-id`, `--user-id` (+ `--delete-days`, `--reason`)
- `timeout` also supports `--reason`
## Examples
@@ -174,3 +202,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"}] ]'
```
+36 -16
View File
@@ -1,13 +1,31 @@
---
title: Sandbox CLI
summary: "Manage sandbox containers and inspect effective sandbox policy"
read_when: "You are managing sandbox containers or debugging sandbox/tool-policy behavior."
status: active
---
# Sandbox CLI
Manage Docker-based sandbox containers for isolated agent execution.
## Overview
ClawdBot can run agents in isolated Docker containers for security. The `sandbox` commands help you manage these containers, especially after updates or configuration changes.
Clawdbot can run agents in isolated Docker containers for security. The `sandbox` commands help you manage these containers, especially after updates or configuration changes.
## Commands
### `clawdbot sandbox explain`
Inspect the **effective** sandbox mode/scope/workspace access, sandbox tool policy, and elevated gates (with fix-it config key paths).
```bash
clawdbot sandbox explain
clawdbot sandbox explain --session agent:main:main
clawdbot sandbox explain --agent work
clawdbot sandbox explain --json
```
### `clawdbot sandbox list`
List all sandbox containers with their status and configuration.
@@ -56,7 +74,7 @@ docker pull clawdbot-sandbox:latest
docker tag clawdbot-sandbox:latest clawdbot-sandbox:bookworm-slim
# Update config to use new image
# Edit clawdbot.config.json: agent.sandbox.docker.image
# Edit config: agents.defaults.sandbox.docker.image (or agents.list[].sandbox.docker.image)
# Recreate containers
clawdbot sandbox recreate --all
@@ -65,7 +83,7 @@ clawdbot sandbox recreate --all
### After changing sandbox configuration
```bash
# Edit clawdbot.config.json: agent.sandbox.*
# Edit config: agents.defaults.sandbox.* (or agents.list[].sandbox.*)
# Recreate to apply new config
clawdbot sandbox recreate --all
@@ -89,22 +107,24 @@ clawdbot sandbox recreate --agent alfred
## Configuration
Sandbox settings are in `clawdbot.config.json`:
Sandbox settings live in `~/.clawdbot/clawdbot.json` under `agents.defaults.sandbox` (per-agent overrides go in `agents.list[].sandbox`):
```jsonc
{
"agent": {
"sandbox": {
"mode": "all", // off, non-main, all
"scope": "agent", // session, agent, shared
"docker": {
"image": "clawdbot-sandbox:bookworm-slim",
"containerPrefix": "clawdbot-sbx-"
// ... more Docker options
},
"prune": {
"idleHours": 24, // Auto-prune after 24h idle
"maxAgeDays": 7 // Auto-prune after 7 days
"agents": {
"defaults": {
"sandbox": {
"mode": "all", // off, non-main, all
"scope": "agent", // session, agent, shared
"docker": {
"image": "clawdbot-sandbox:bookworm-slim",
"containerPrefix": "clawdbot-sbx-"
// ... more Docker options
},
"prune": {
"idleHours": 24, // Auto-prune after 24h idle
"maxAgeDays": 7 // Auto-prune after 7 days
}
}
}
}
+47
View File
@@ -0,0 +1,47 @@
---
summary: "CLI reference for `clawdbot update` (safe-ish source update + optional daemon restart)"
read_when:
- You want to update a source checkout safely
- You need to understand `--update` shorthand behavior
---
# `clawdbot update`
Safely update a **source checkout** (git install) of Clawdbot.
If you installed via **npm/pnpm/bun** (global install, no git metadata), use the package manager flow in [Updating](/install/updating).
## Usage
```bash
clawdbot update
clawdbot update --restart
clawdbot update --json
clawdbot --update
```
## Options
- `--restart`: restart the Gateway daemon after a successful update.
- `--json`: print machine-readable `UpdateRunResult` JSON.
- `--timeout <seconds>`: per-step timeout (default is 1200s).
## What it does (git checkout)
High-level:
1. Requires a clean worktree (no uncommitted changes).
2. Fetches and rebases against `@{upstream}`.
3. Installs deps (pnpm/bun/npm depending on the checkout).
4. Builds + builds the Control UI.
5. Runs `clawdbot doctor` as the final “safe update” check.
## `--update` shorthand
`clawdbot --update` rewrites to `clawdbot update` (useful for shells and launcher scripts).
## See also
- `clawdbot doctor` (offers to run update first on git checkouts)
- [Updating](/install/updating)
- [CLI reference](/cli)
Loaded 100 of 675 files, more files were not shown because too many files have changed in this diff. Show more