Compare commits

..
1090 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 37389005fc docs: note hetzner cross-links in changelog (#592) 2026-01-09 18:21:53 +01:00
Peter Steinberger 6904a79d1d docs: link Hetzner guide from install/platforms 2026-01-09 18:21:28 +01:00
Peter Steinberger 62bf779c14 Merge pull request #556 from Iamadig/docs/hetzner-guide
Docs/hetzner guide
2026-01-09 17:18:18 +00:00
Peter Steinberger 5c13dcdb7b Merge pull request #590 from mneves75/feat/minimax-anthropic-api
feat: Add MiniMax Anthropic-compatible API support (minimax-api)
2026-01-09 17:18:09 +00:00
Peter Steinberger 706cbe89ec fix: align Hetzner docker guide with gateway runtime (#556) (thanks @Iamadig) 2026-01-09 18:17:19 +01:00
Peter Steinberger a6a469435a fix: finalize minimax-api onboarding (#590) (thanks @mneves75) 2026-01-09 18:17:13 +01:00
Peter Steinberger 834e2b82f3 docs: comment doctor switch e2e 2026-01-09 18:16:59 +01:00
Peter Steinberger 805969b598 Merge branch 'main' into fix/imessage-groupish-threads 2026-01-09 17:16:44 +00:00
Peter Steinberger 18bc9db6b3 Merge pull request #561 from mukhtharcm/fix/filter-empty-error-messages
fix: filter empty error messages to prevent session corruption
2026-01-09 17:15:15 +00:00
AG 258184232d docs: add Hetzner deployment guide 2026-01-09 18:15:02 +01:00
Peter Steinberger 6156402c1a fix: scope 429 fast-fail to antigravity (#561) (thanks @mukhtharcm) 2026-01-09 18:13:47 +01:00
mneves75andClaude 9279795c47 feat: Add MiniMax Anthropic-compatible API support (minimax-api)
Add --auth-choice minimax-api for direct MiniMax API usage at
https://api.minimax.io/anthropic using the anthropic-messages API.

Changes:
- Add applyMinimaxApiConfig() function with provider/model config
- Add minimax-api to AuthChoice type and CLI options
- Add handler and non-interactive support
- Fix duplicate minimax entry in envMap
- Update live test to use anthropic-messages API
- Add 11 unit tests covering all edge cases
- Document configuration in gateway docs

Test results:
- 11/11 unit tests pass
- 1/1 live API test passes (verified with real API key)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-09 18:13:18 +01:00
Muhammed Mukhthar CM d0450bb425 fix(antigravity): add fail-fast on 429 rate limit
When Antigravity returns 429, throw immediately instead of waiting for the
server-provided retry delay (which can be 10+ minutes). This lets clawdbot
quickly rotate to another account.

This patch was previously in PR #454 but was accidentally removed in 0dbb569
when bumping to pi-ai 0.40.0. The upstream release did NOT include this fix.

Context: Antigravity rate limits cause pi-ai to sleep for the full retry
delay inside the request, blocking the thread. Clawdbot's timeout would
eventually fire, but waiting 10+ minutes is unacceptable UX.

Bundled with the empty error message filter since both handle 429 recovery.
2026-01-09 18:10:23 +01:00
Muhammed Mukhthar CM 1cfe409a09 fix: filter empty error messages to prevent session corruption
When 429/500 errors occur during tool execution, empty assistant messages
with stopReason='error' and content=[] get persisted to the session file.

These break the tool_use -> tool_result chain that Claude/Gemini require:
- Claude expects every tool_use block to have a matching tool_result
- Empty error messages inserted mid-sequence violate this invariant
- Results in: 'tool_use ids were found without tool_result blocks'

This patch filters out empty error messages when building session context,
allowing sessions to recover gracefully from transient API errors.

Evidence from production:
- 113 of 170 sessions had empty error messages
- Session 30764430 demonstrated recovery: 429 at 14:30:11 IST,
  resumed successfully at 14:30:22, completed at 14:30:34

Sorry Peter, one more patch! 🙈
2026-01-09 18:10:23 +01:00
NickSpisak_andShadow cfcff68e91 feat(discord): add channel/category management actions (#487)
Co-authored-by: Shadow <shadow@clawd.bot>
2026-01-09 11:05:58 -06:00
Shadow d588bb00d4 fix: update carbon version for gateway fixes 2026-01-09 10:56:13 -06:00
Peter Steinberger e74656fa71 Merge pull request #589 from clawdbot/chore/commands-registry-guards
fix: harden slash command registry
2026-01-09 16:56:01 +00:00
Peter Steinberger d64ed620d5 Merge pull request #588 from clawdbot/refactor/discord-thread-context
refactor: consolidate discord thread context handling
2026-01-09 16:54:14 +00:00
Peter Steinberger 377885bd36 style: format doctor gateway services 2026-01-09 17:53:57 +01:00
Peter Steinberger cf1e0d743c fix: harden slash command registry 2026-01-09 17:53:24 +01:00
Peter Steinberger b23ce86c81 docs: update changelog for #588 2026-01-09 17:52:47 +01:00
Peter Steinberger cae8e8d6e8 refactor: consolidate discord thread context handling 2026-01-09 17:52:47 +01:00
Peter Steinberger 24af8adcf9 Merge pull request #587 from clawdbot/refactor/hook-agent-normalize
Hooks: normalize agent provider handling
2026-01-09 16:52:32 +00:00
Peter Steinberger c3083f0186 feat: add setup-token + token auth 2026-01-09 17:50:34 +01:00
Peter Steinberger 083877d286 fix: audit doctor service entrypoint 2026-01-09 17:50:28 +01:00
Peter Steinberger 044ea90dde fix: repair doctor service install switches 2026-01-09 17:50:28 +01:00
Peter Steinberger 9c9f89e9be Merge pull request #497 from lc0rp-contrib/commands-list-clean
feat(commands): add /commands to list available commands
2026-01-09 16:46:32 +00:00
Luke 401f2a77e3 Merge branch 'main' into commands-list-clean 2026-01-09 11:46:08 -05:00
Peter Steinberger b1ddf1f048 refactor: share hook provider types 2026-01-09 17:45:42 +01:00
Peter Steinberger 51725a71a3 refactor: normalize hook agent providers 2026-01-09 17:40:36 +01:00
Peter Steinberger f146c9ef16 Merge pull request #585 from clawdbot/shadow/forum
Discord: fix forum thread starters
2026-01-09 16:35:33 +00:00
Peter Steinberger abfd5719d6 fix: cache discord channel lookups for thread starters (#585) (thanks @thewilloftheshadow) 2026-01-09 17:35:16 +01:00
Peter Steinberger 3a0cc3c927 Merge pull request #533 from mcinteerj/hooks-deliver-true-by-default
Hooks: default agent delivery to true
2026-01-09 16:35:09 +00:00
Peter Steinberger 40fcfc9479 test: cover hook deliver default (#533) (thanks @mcinteerj) 2026-01-09 17:34:22 +01:00
Jake cac467a2df Hooks: default agent delivery to true 2026-01-09 17:32:42 +01:00
Shadow 6ef9fc64d7 Discord: fix forum thread starters 2026-01-09 17:32:14 +01:00
Peter Steinberger ee70a1d1fb fix(status): use claude-cli token for usage 2026-01-09 16:24:55 +00:00
Peter Steinberger 1478473537 refactor(commands): canonicalize text command aliases 2026-01-09 17:22:46 +01:00
Peter Steinberger c82ebd3ef3 docs(commands): document /usage slash command 2026-01-09 17:18:01 +01:00
Peter Steinberger 1838582546 refactor(auto-reply): centralize chat command aliases 2026-01-09 17:16:52 +01:00
Peter Steinberger d372fac9c6 refactor: streamline reply tag parsing 2026-01-09 17:14:40 +01:00
Peter Steinberger cef13aa705 Merge pull request #586 from clawdbot/temp/landpr-492
fix(commands): wire /usage alias to /status
2026-01-09 16:14:11 +00:00
Peter Steinberger f4ec53dcb9 refactor(sessions): dedupe sessions.resolve 2026-01-09 17:13:38 +01:00
Peter Steinberger 68ad27e31c fix(commands): wire /usage to status (#492) (thanks @lc0rp) 2026-01-09 17:10:53 +01:00
Peter Steinberger 613d866296 chore(docker): pass claude web session env 2026-01-09 16:07:40 +00:00
Luke 4658f937cc Merge branch 'main' into commands-list-clean 2026-01-09 11:06:06 -05:00
Peter Steinberger 67af3c3291 Merge pull request #560 from mcinteerj/fix/reply-tags-whitespace
Auto-Reply: relax regex for reply tags to allow whitespace
2026-01-09 16:05:17 +00:00
Luke 98b875cd0f Merge branch 'main' into commands-list-clean 2026-01-09 11:04:23 -05:00
Peter Steinberger 7d9300e0f5 fix: allow whitespace in reply tags (#560) (thanks @mcinteerj) 2026-01-09 17:03:44 +01:00
Peter Steinberger c892fd174e refactor(sessions): add sessions.resolve + label helper (#570) 2026-01-09 17:02:57 +01:00
LK 08caf7b9fc feat(commands): add /usage alias for /status 2026-01-09 17:02:29 +01:00
Jake 4381b03412 Auto-Reply: relax regex for reply tags to allow whitespace 2026-01-09 17:01:58 +01:00
Peter Steinberger d099dabf37 refactor: centralize slack threading helpers 2026-01-09 16:01:53 +00:00
Peter Steinberger d0b06b4334 fix: avoid object stringification in session labels 2026-01-09 16:57:50 +01:00
Peter Steinberger be48233bc4 chore: format 2026-01-09 16:55:51 +01:00
Peter Steinberger fd9e2d3def feat: remove provider config in configure 2026-01-09 16:55:47 +01:00
Peter Steinberger c643ce2a7a feat: add /debug runtime overrides 2026-01-09 16:55:16 +01:00
Peter Steinberger 36bdec0f2c refactor(messages): centralize per-agent prefixes 2026-01-09 16:54:54 +01:00
Peter Steinberger 8341b662af refactor(test): temp home env + normalize status 2026-01-09 16:50:09 +01:00
Peter Steinberger e8d75a39bc ci: drop output sanitize wrapper 2026-01-09 16:49:12 +01:00
Peter Steinberger 02b945cc95 refactor(ui): split mobile layout css 2026-01-09 16:49:02 +01:00
Peter Steinberger c8b15af979 refactor(test): centralize temp home + polling 2026-01-09 16:49:02 +01:00
Peter Steinberger eb73b4e58e chore: make pnpm test non-watch 2026-01-09 16:49:02 +01:00
Peter Steinberger 7d518e336e feat(sessions): label lookup tightening (#570) (thanks @azade-c) 2026-01-09 16:42:09 +01:00
Peter Steinberger 09b602b4ec style: format trigger test 2026-01-09 16:41:05 +01:00
Peter Steinberger 4ffbd9802a refactor(test): consolidate temp home + vitest setup 2026-01-09 16:41:05 +01:00
Peter Steinberger 1eecce9a15 Merge pull request #578 from p6l-richard/feature/identity-based-message-prefix
fix(messages): derive messagePrefix from identity.name
2026-01-09 15:40:52 +00:00
Peter Steinberger 66bbb723c5 fix: derive prefixes from routed identity (#578) (thanks @p6l-richard) 2026-01-09 16:39:32 +01:00
Peter Steinberger 8de1c449ee Merge pull request #558 from carlulsoe/mobile-ui-improvements
feat(ui): improve mobile responsiveness [AI-assisted]
2026-01-09 15:39:19 +00:00
Peter Steinberger facf5c09a0 fix: honor slack reply threading (#574, thanks @bolismauro) 2026-01-09 15:38:43 +00:00
Peter Steinberger b3e0fafe50 fix: stabilize windows CI (#558) (thanks @carlulsoe) 2026-01-09 16:37:58 +01:00
Mauro Bolis 96149d1f71 fix: honor slack reply threading 2026-01-09 15:35:54 +00:00
Peter Steinberger 014a4d51a6 feat(status): add claude.ai usage fallback 2026-01-09 15:34:58 +00:00
Peter Steinberger 922ca2ee1c fix(status): surface provider usage errors 2026-01-09 15:34:58 +00:00
KitandCarl Ulsoe 480aa406bb feat(ui): improve mobile responsiveness
- Add @media (max-width: 600px) breakpoint for mobile-specific styles
- Compact header: smaller title, hidden subtitle, minimal status pill
- Horizontal scrollable nav: all items in one row, swipeable
- Hide redundant page titles on mobile
- Hide docs button on mobile (saves space)
- Smaller theme toggle icons
- Tighter spacing on cards, stats, forms
- Better chat layout: full-width session selector, compact compose
- Single-column log entries on small screens

Desktop layout remains unchanged.

Co-authored-by: Carl Ulsoe <34673973+carlulsoe@users.noreply.github.com>
2026-01-09 16:32:55 +01:00
Richard Poelderl 43848b7b43 feat(messages): also derive responsePrefix from identity.name
When identity.name is configured and responsePrefix is not explicitly set,
automatically default responsePrefix to [identity.name].

This means users only need to set their identity once:
  { identity: { name: "MyBot" } }

And outbound messages will automatically be prefixed with [MyBot].
2026-01-09 16:31:20 +01:00
Richard Poelderl 8112b276c0 feat(messages): derive messagePrefix from identity.name
When identity.name is configured, use it for the default messagePrefix
instead of hardcoded '[clawdbot]'. Falls back to 'clawdbot' if not set.

This allows users to customize how their bot identifies itself in messages
by setting identity.name in their config or IDENTITY.md.
2026-01-09 16:31:01 +01:00
Peter Steinberger f436808735 refactor: consolidate schema scrub + test harness 2026-01-09 16:27:28 +01:00
Peter Steinberger 7957196924 feat: add TUI bootstrap start 2026-01-09 16:25:29 +01:00
Josh Palmer 25babbfdc4 🤖 codex: fix duplicate agentDir (no-issue) 2026-01-09 16:23:34 +01:00
Emanuel Stadler 277225dc45 Chore: ignore .vscode 2026-01-09 15:21:30 +00:00
Peter Steinberger d3ca84e14c feat: add dev workspace templates 2026-01-09 16:13:41 +01:00
Peter Steinberger 07430eb33d Merge pull request #569 from bjesuiter/ui-build-default-to-relative-path
fix(ui): default to relative paths for control UI assets
2026-01-09 14:47:46 +00:00
Peter Steinberger 9af5b13803 test: make withTempHome cross-platform 2026-01-09 15:47:26 +01:00
Peter Steinberger cfb369d727 docs: add dev gateway flow 2026-01-09 15:47:22 +01:00
Peter Steinberger 2679768447 Merge pull request #577 from clawdbot/fix/node-bridge-keepalive
fix: harden node bridge keepalive
2026-01-09 14:46:59 +00:00
Peter Steinberger d746304371 docs: changelog for keepalive hardening (#577) 2026-01-09 15:46:45 +01:00
Peter Steinberger f5cc6bb283 fix: harden node bridge keepalive 2026-01-09 15:46:45 +01:00
Peter Steinberger 6177c2d575 fix: auto-create dev config for dev profile 2026-01-09 15:45:11 +01:00
Peter Steinberger 616293f8a7 Merge pull request #567 from erikpr1994/fix/gemini-schema-sanitization
fix(agents): remove unsupported JSON Schema keywords for Cloud Code Assist API
2026-01-09 14:44:38 +00:00
Peter Steinberger d77dee50c9 Merge pull request #570 from azade-c/feat/sessions-label
feat(sessions): expose label in sessions.list and support label lookup in sessions_send
2026-01-09 14:44:23 +00:00
Peter Steinberger c4c0f1349a fix: keep build green after main rebase (#570) (thanks @azade-c) 2026-01-09 15:40:36 +01:00
Peter Steinberger 17a7d4e8dd test: stabilize Windows test env (#567) (thanks @erikpr1994) 2026-01-09 15:40:02 +01:00
Peter Steinberger 2aeeeff65f ci: sanitize CI test output 2026-01-09 15:39:16 +01:00
Peter Steinberger d17141b859 fix(status): show usage for token auth profiles 2026-01-09 14:36:46 +00:00
Peter Steinberger 1afa48fcdf style(models): biome format auth order 2026-01-09 14:36:46 +00:00
Peter Steinberger 7cb9e95a53 docs: document auth profile selection 2026-01-09 14:36:46 +00:00
Peter Steinberger 407a3c2c10 fix: quiet telegram getUpdates errors 2026-01-09 15:35:39 +01:00
Peter Steinberger 5f4eb8b509 style: format cli files 2026-01-09 15:34:41 +01:00
Peter Steinberger d28c266771 fix: sanitize Windows test output 2026-01-09 15:32:55 +01:00
Peter Steinberger d5b826ffc8 fix: restore openUrl import 2026-01-09 15:32:55 +01:00
Peter Steinberger 59d942c9ec fix: unblock CI on main (#569) (thanks @bjesuiter) 2026-01-09 15:32:55 +01:00
Peter Steinberger 92b792b3f0 fix: land #569 (thanks @bjesuiter) 2026-01-09 15:32:55 +01:00
Benjamin Jesuiter 7dcf19d902 fix(ui): default to relative paths for control UI assets
Changes the default base path from "/" to "./" so the control UI works
correctly when served under a custom basePath (e.g., /jbclawd/).

Previously, assets were referenced with absolute paths like /assets/...,
which failed when the UI was served under a subpath. With relative paths
(./assets/...), the browser resolves them relative to the HTML location,
making the UI work regardless of the configured basePath.
2026-01-09 15:32:55 +01:00
Peter Steinberger f7e8cd8ac8 chore: regen protocol models (#570) (thanks @azade-c) 2026-01-09 15:32:49 +01:00
Peter Steinberger 56e77f6843 fix: sessions label lookup and persistence (#570) (thanks @azade-c) 2026-01-09 15:32:49 +01:00
Azade e24e0cf364 test(sessions): add tests for sessions_send label lookup
- Test finding session by label
- Test error when label not found
- Test error when neither sessionKey nor label provided
2026-01-09 15:32:49 +01:00
Azade 3133c7c84e feat(sessions): expose label in sessions.list and support label lookup in sessions_send
- Add `label` field to session entries and expose it in `sessions.list`
- Display label column in the web UI sessions table
- Support `label` parameter in `sessions_send` for lookup by label instead of sessionKey

- `sessions.patch`: Accept and store `label` field
- `sessions.list`: Return `label` in session entries
- `sessions_spawn`: Pass label through to registry and announce flow
- `sessions_send`: Accept optional `label` param, lookup session by label if sessionKey not provided
- `agent` method: Accept `label` and `spawnedBy` params (stored in session entry)

- Add `label` column to sessions table in web UI

- Changed session store writes to merge with existing entry (`{ ...existing, ...new }`)
  to preserve fields like `label` that might be set separately

We attempted to implement label persistence "properly" by passing the label
through the `agent` call and storing it during session initialization. However,
the auto-reply flow has multiple write points that overwrite the session entry,
and making all of them merge-aware proved unreliable.

The working solution patches the label in the `finally` block of
`runSubagentAnnounceFlow`, after all other session writes complete.
This is a workaround but robust - the patch happens at the very end,
just before potential cleanup.

A future refactor could make session writes consistently merge-based,
which would allow the cleaner approach of setting label at spawn time.

```typescript
// Spawn with label
sessions_spawn({ task: "...", label: "my-worker" })

// Later, find by label
sessions_send({ label: "my-worker", message: "continue..." })

// Or use sessions_list to see labels
sessions_list() // includes label field in response
```
2026-01-09 15:32:49 +01:00
Peter Steinberger b85854d0fe Merge pull request #575 from clawdbot/blacksmith-migration-944f15e 2026-01-09 14:31:53 +00:00
Peter Steinberger 4b7d9c72df docs: emphasize setup-token 2026-01-09 15:29:50 +01:00
Peter Steinberger e612aedbff docs: prefer setup-token for Anthropic 2026-01-09 15:27:49 +01:00
Peter Steinberger 80ff2dc77d fix: honor gateway --dev flag 2026-01-09 15:25:51 +01:00
Peter Steinberger c228df8f90 fix: rebase onto main + restore build/lint (#567) (thanks @erikpr1994) 2026-01-09 15:23:41 +01:00
Peter Steinberger fd3cbd96a8 test: sanitize Windows CI vitest runner output (#567) (thanks @erikpr1994) 2026-01-09 15:19:49 +01:00
Peter Steinberger cb96deb517 test: harden Windows CI output sanitization (#567) (thanks @erikpr1994) 2026-01-09 15:19:49 +01:00
Peter Steinberger f58b3d082f test: sanitize Windows CI buffer output (#567) (thanks @erikpr1994) 2026-01-09 15:19:49 +01:00
Peter Steinberger 090d16392b test: sanitize Windows CI output (#567) 2026-01-09 15:19:49 +01:00
Peter Steinberger 760e9b3df5 fix: avoid Windows runner unicode crash (#567) 2026-01-09 15:19:49 +01:00
Peter Steinberger 63f5fa47de fix: avoid invalid UTF-16 in truncation (#567) 2026-01-09 15:19:49 +01:00
Peter Steinberger fd535a50d3 fix: scrub tool schemas for Cloud Code Assist (#567) (thanks @erikpr1994) 2026-01-09 15:19:49 +01:00
Erik e9217181c1 fix(agents): remove unsupported JSON Schema keywords for Cloud Code Assist API
Cloud Code Assist API requires strict JSON Schema draft 2020-12 compliance
and rejects keywords like patternProperties, additionalProperties, $schema,
$id, $ref, $defs, and definitions.

This extends cleanSchemaForGemini to:
- Remove all unsupported keywords from tool schemas
- Add oneOf literal flattening (matching existing anyOf behavior)
- Add test to verify no unsupported keywords remain in tool schemas
2026-01-09 15:19:48 +01:00
Peter Steinberger 2c5ec94843 test: stabilize sandbox/doctor tests 2026-01-09 15:17:20 +01:00
Peter Steinberger fc7580ab5e fix: remove configure control-ui prompt 2026-01-09 15:17:20 +01:00
Peter Steinberger adb3bc2577 fix: reset dev gateway setup 2026-01-09 15:17:20 +01:00
Peter Steinberger 3e400ff9f2 feat(models): add per-agent auth order overrides 2026-01-09 14:07:45 +00:00
blacksmith-sh[bot] a596f32a8e Migrate workflows to Blacksmith 2026-01-09 14:06:46 +00:00
Peter Steinberger 944f15e401 fix(discovery): decode dns-sd escaped UTF-8 2026-01-09 14:56:01 +01:00
Peter Steinberger 02aeff8efc style(gateway): multiline discovery output 2026-01-09 14:43:46 +01:00
Peter Steinberger b5c32a4c79 fix(onboarding): remove redundant model spread fallback 2026-01-09 14:16:37 +01:00
Peter Steinberger 6f91bcafdb fix(config): align agents defaults schema 2026-01-09 14:13:52 +01:00
Peter Steinberger 576f8d4681 Merge pull request #572 from ngutman/fix/mac-node-bridge-ping
fix(macos): add node bridge ping loop (AI-assisted)
2026-01-09 13:03:03 +00:00
Peter Steinberger cb86d0d6d4 fix: land mac node bridge ping loop (#572) (thanks @ngutman) 2026-01-09 14:01:20 +01:00
Peter Steinberger f6f178ddee style(telegram): format bot init 2026-01-09 13:57:06 +01:00
Peter Steinberger b3d640b978 fix(onboarding): remove duplicate minimax constants 2026-01-09 13:56:16 +01:00
Peter Steinberger 0fc7a06913 fix(discovery): silence oxlint warning 2026-01-09 13:55:28 +01:00
Peter Steinberger e38cdb3133 docs(cli): fix sandbox links 2026-01-09 13:55:28 +01:00
Peter Steinberger b21e62f072 style: format gateway discovery 2026-01-09 13:55:28 +01:00
Peter Steinberger b0672da396 fix(gateway): fix wide-area discovery update 2026-01-09 13:55:27 +01:00
Peter Steinberger c2d185aab7 fix: normalize routed replies 2026-01-09 13:55:27 +01:00
Peter Steinberger bfadc8f4ee style: format onboard providers 2026-01-09 13:54:53 +01:00
Peter Steinberger 1716c01bdf feat(gateway): improve wide-area discovery 2026-01-09 13:54:53 +01:00
Nimrod Gutman 975aa5bf82 fix(macos): add node bridge ping loop 2026-01-09 13:50:38 +01:00
Peter SteinbergerandMark Pors 7b81d97ec2 feat: wire multi-agent config and routing
Co-authored-by: Mark Pors <1078320+pors@users.noreply.github.com>
2026-01-09 12:48:42 +00:00
Peter Steinberger 81beda0772 feat: migrate agent config to multi-agent layout 2026-01-09 12:44:29 +00:00
Peter Steinberger 82b342e77b fix: respect auth cooldown with auth.order 2026-01-09 12:44:29 +00:00
Peter Steinberger 4166cd12b6 Merge pull request #495 from tobiasbischoff/main
Onboarding: add hosted MiniMax M2.1 API key option
2026-01-09 12:43:00 +00:00
Peter Steinberger ac922e83d3 Merge branch 'main' into tobias-sync 2026-01-09 13:42:34 +01:00
Peter Steinberger dc6f22c2c5 fix: add hosted minimax onboarding (#495, thanks @tobiasbischoff) 2026-01-09 13:40:01 +01:00
Tobias Bischoff 897685a2de Telegram: cast fetch to grammy client type 2026-01-09 13:39:28 +01:00
Tobias Bischoff a0bb2bccaf Onboarding: add MiniMax hosted API key option 2026-01-09 13:39:28 +01:00
Josh Palmer 7b79823b24 Adjust UI install for offline pnpm fetch (#568)
* 🤖 codex: make ui build install prod deps for offline pnpm (issue-pnpm-offline)

* 🤖 codex: ensure ui:test installs dev deps (issue-pnpm-offline)
2026-01-09 13:38:46 +01:00
Peter Steinberger 17a7dfc966 chore: update dev identity default 2026-01-09 13:34:45 +01:00
Peter Steinberger 22d517a520 fix: clarify WhatsApp owner number prompts 2026-01-09 13:32:52 +01:00
Peter Steinberger 201c879772 fix(sandbox): compare list to config images (#563) - thanks @pasogott 2026-01-09 13:29:47 +01:00
sheeek 7883491ce2 test(sandbox): add unit tests for formatter functions
Add comprehensive tests for sandbox-formatters.ts (20 tests):
- formatStatus: running/stopped with emojis
- formatSimpleStatus: running/stopped without emojis
- formatImageMatch: match/mismatch indicators
- formatAge: seconds, minutes, hours, days with edge cases
- countRunning: count items by running state
- countMismatches: count items by image mismatch

All pure functions now covered. 152 LOC tests added.

Total test count: 39 tests (19 integration + 20 unit)
Test coverage increased from ~66% to ~80%.
2026-01-09 13:23:16 +01:00
sheeek 7f02b62bba style(sandbox): fix linting errors
- Remove unused normalizeOptions function
- Fix line length violations (format long lines)
- Fix import order (alphabetical)
- Format function signatures for readability

All lint checks now passing.
2026-01-09 13:23:16 +01:00
sheeek b0c97d6178 refactor(sandbox-cli): improve structure and reduce duplication
Improvements:
- Extract help text examples into EXAMPLES constant object
- Add createRunner() helper to reduce try-catch boilerplate
- Add normalizeOptions() helper (prepared for future use)
- Fix command names from 'clawd' to 'clawdbot' throughout
- Update main sandbox command to show help by default
- Better organize code with clear section comments

Increases from 82 to 137 LOC but with much better organization
and reduced duplication in error handling.
2026-01-09 13:23:16 +01:00
sheeek 1c757ae35e refactor(sandbox): use extracted display and formatter modules
Update sandbox.ts to import and use functions from:
- sandbox-display.ts for all UI output
- sandbox-formatters.ts for data formatting

Removes 141 LOC of display/formatting code from sandbox.ts,
reducing it from 351 to 210 LOC (-40%).

Core business logic now clearer and more focused.
2026-01-09 13:23:16 +01:00
sheeek 81c55be19b refactor(sandbox): extract display logic into separate module
Move all display functions to sandbox-display.ts:
- displayContainers, displayBrowsers with generic displayItems helper
- displaySummary with image mismatch warnings
- displayRecreatePreview, displayRecreateResult

Uses DisplayConfig pattern to reduce duplication between container
and browser display logic. 156 LOC.
2026-01-09 13:23:16 +01:00
sheeek dd0104290e refactor(sandbox): extract formatters into separate module
Move formatting utilities to sandbox-formatters.ts:
- formatStatus, formatSimpleStatus, formatImageMatch, formatAge
- countRunning, countMismatches helper functions
- ContainerItem type definition

Improves modularity and reusability. 49 LOC.
2026-01-09 13:23:16 +01:00
sheeek 6ca34c1259 docs(sandbox): fix command name from clawd to clawdbot
Update all command examples in documentation to use 'clawdbot' instead
of 'clawd' for consistency with actual CLI command name.
2026-01-09 13:23:16 +01:00
sheeek c3932c8508 test(sandbox): add comprehensive test suite for CLI commands
Add 19 tests covering sandboxListCommand and sandboxRecreateCommand:
- List command: human/JSON output, browser flag, error handling
- Recreate command: validation, filtering (session/agent), confirmation flow
- Factory functions (createContainer, createBrowser) reduce duplication
- Helper functions (expectLogContains, setupDefaultMocks) improve readability

All tests passing. 365 LOC with ~66% production code coverage.
2026-01-09 13:23:16 +01:00
sheeek 00b77421dd refactor: improve sandbox commands code structure
Improvements:
- Extract validation into separate function
- Split display logic from business logic
- Create reusable container matcher for agent filtering
- Abstract status/image formatting into helpers
- Reduce code duplication between containers and browsers
- Extract container removal into generic function
- Add type safety with FilteredContainers type
- Improve readability with smaller, focused functions

Changes:
- validateRecreateOptions(): Validate mutual exclusivity
- fetchAndFilterContainers(): Fetch + filter in one place
- createAgentMatcher(): Reusable agent filter predicate
- displayContainers/Browsers(): Dedicated display functions
- displaySummary/RecreatePreview/Result(): Clear separation
- removeContainer(): Generic removal with error handling
- Format helpers: formatStatus, formatImageMatch, etc.
- Count helpers: countRunning, countMismatches

Result: 85 more lines but much better maintainability and testability.
2026-01-09 13:23:16 +01:00
sheeek bcd52ee546 chore: update CHANGELOG for sandbox CLI commands 2026-01-09 13:23:16 +01:00
sheeek 75927d736a docs: add sandbox CLI documentation 2026-01-09 13:22:51 +01:00
sheeek 5e78d5a21f feat: add sandbox CLI commands for container management
Add 'clawd sandbox list' and 'clawd sandbox recreate' commands to manage
sandbox containers. This fixes the issue where containers continue using
old images/configs after updates.

Problem:
- When sandbox Docker images or configs are updated, existing containers
  keep running with old settings
- Containers are only recreated after 24h inactivity (pruning)
- If agents are used regularly, old containers run indefinitely

Solution:
- 'clawd sandbox list': Show all containers with status, age, and image match
- 'clawd sandbox recreate': Force container removal (recreated on next use)
  - Supports --all, --session, --agent, --browser filters
  - Requires confirmation unless --force is used

Implementation:
- Added helper functions to sandbox.ts (list/remove containers)
- Created sandbox-cli.ts following existing CLI patterns
- Created commands/sandbox.ts with list and recreate logic
- Integrated into program.ts

Use case: After updating sandbox images or changing sandbox config,
run 'clawd sandbox recreate --all' to ensure fresh containers.
2026-01-09 13:22:51 +01:00
Peter SteinbergerandOnur ae6f268987 refactor(msteams): validate send recipient
Co-authored-by: Onur <onutc@users.noreply.github.com>
2026-01-09 11:22:59 +01:00
Peter Steinberger 08cceb6435 fix(config): add gateway remote ssh fields 2026-01-09 11:18:37 +01:00
Peter Steinberger 6b107e9e74 refactor(msteams): consolidate stores and send context 2026-01-09 11:18:33 +01:00
Peter Steinberger 6d223303eb refactor(msteams): extract sdk + storage helpers 2026-01-09 11:07:32 +01:00
Peter Steinberger 8875dbd449 refactor(msteams): split monitor handler and poll store 2026-01-09 11:07:32 +01:00
Peter Steinberger 475d598ecb fix: normalize poll providers and msteams deps 2026-01-09 11:07:32 +01:00
Peter Steinberger e55358c65d feat: finalize msteams polls + outbound parity 2026-01-09 11:07:32 +01:00
Onur a2bba7ef51 fix(msteams): add msteams to pairing provider labels 2026-01-09 11:07:32 +01:00
Onur 7f326ae4ae fix: sync doctor-prompter.ts with main 2026-01-09 11:07:17 +01:00
Onur 13b47e6047 fix(doctor): restore initialValue behavior in non-interactive mode
The repair/force commit added an early return that broke non-interactive
mode - migrations would return false instead of using initialValue.
2026-01-09 11:07:01 +01:00
Onur 0bc50abd73 style: format quickstart note and media-note test 2026-01-09 11:07:00 +01:00
Onur c469fac8ef Tests: speed up media note prompt setup 2026-01-09 11:07:00 +01:00
Onur a542a285a3 Wizard: preserve QuickStart gateway settings 2026-01-09 11:07:00 +01:00
Onur eb908985f7 Gateway: fix reload tests for MS Teams provider 2026-01-09 11:07:00 +01:00
Onur 1df7bfefe7 MSTeams: stop on shutdown and honor chunk limit 2026-01-09 11:07:00 +01:00
Onur 9f9b6044aa docs(msteams): expand setup guide with missing details
- Add Azure Bot deprecation notice (multi-tenant deprecated after 2025-07-31)
- Add RSC vs Graph API comparison table
- Add Known Limitations section (webhook timeouts, formatting limits)
- Add Reply Style section explaining threads vs posts channel UI
- Add Updating an existing app section with version bump steps
- Add icon sizes and zip instructions to manifest setup
- Expand troubleshooting with manifest upload errors
2026-01-09 11:07:00 +01:00
Onur 73c1d11eb9 Remove tmp files 2026-01-09 11:07:00 +01:00
Onur ea1cd2c734 wip 2026-01-09 11:07:00 +01:00
Onur 54d82e4e83 wip [skip ci] 2026-01-09 11:07:00 +01:00
Onur 3cb3460398 add doc 2026-01-09 11:07:00 +01:00
Onur 96b4d885ac wip [skip ci] 2026-01-09 11:07:00 +01:00
Onur 678d704341 image works in DM 2026-01-09 11:07:00 +01:00
Onur 15e6761035 wip [skip ci] 2026-01-09 11:06:49 +01:00
Onur e67ca92443 MS Teams: ingest inbound image attachments 2026-01-09 11:06:49 +01:00
Onur 2ab5890eab wip 2026-01-09 11:06:49 +01:00
Onur d6256a388e feat(msteams): wire up proactive messaging in routeReply for queued replies 2026-01-09 11:06:49 +01:00
Onur 04b1eb57eb MS Teams: fix top-level replies (agent reference) 2026-01-09 11:06:39 +01:00
Onur 8d096ef85d Tests: stabilize file watchers 2026-01-09 11:06:39 +01:00
Onur a2bab7d923 MS Teams: refactor provider + replyStyle + reliability 2026-01-09 11:06:38 +01:00
Onur 81f81be816 feat(msteams): add replyStyle config for thread vs top-level replies
- Add replyStyle config at global, team, and channel levels
- "thread" replies to the original message (for Posts layout channels)
- "top-level" posts as a new message (for Threads layout channels)
- Default based on requireMention: false → top-level, true → thread
- DMs always use thread style (direct reply)
2026-01-09 11:06:28 +01:00
Onur 269a3c4000 feat(msteams): add outbound sends and fix reply delivery
- Add sendMessageMSTeams for proactive messaging via CLI/gateway
- Wire msteams into outbound delivery, heartbeat targets, and gateway send
- Fix reply delivery to use SDK's getConversationReference() for proper
  bot info, avoiding "Activity Recipient undefined" errors
- Use proactive messaging for replies to post as top-level messages
  (not threaded) by omitting activityId from conversation reference
- Add lazy logger in send.ts to avoid test initialization issues
2026-01-09 11:06:14 +01:00
Onur 2c7d5c82f3 feat(msteams): add per-channel requireMention config
- Add teams/channels config structure to MSTeamsConfig
- Implement requireMention check in monitor.ts
  - Resolution order: channel > team > global > default (true)
- Update zod schema for validation
- Document RSC permissions for receiving all messages without @mention
- Document Graph API Proxy pattern for historical message access
- Document private channel limitations
- Document team/channel ID format (use URL path, not groupId)
2026-01-09 11:05:46 +01:00
Onur 7d72fcf7f8 add notes [skip ci] 2026-01-09 11:05:34 +01:00
Onur e0812f8c4d feat(msteams): add config reload, DM policy, proper shutdown
- Add msteams to config-reload.ts (ProviderKind, ReloadAction, rules)
- Add msteams to PairingProvider for pairing code support
- Create conversation-store.ts for storing ConversationReference
- Implement DM policy check (disabled/pairing/open/allowlist)
- Fix WasMentioned to check actual bot mentions via entities
- Fix server shutdown by using custom Express server with httpServer.close()
- Pass authConfig to CloudAdapter for outbound call authentication
- Improve error logging with JSON serialization
2026-01-09 11:05:34 +01:00
Onur 1c73d45106 feat(msteams): wire agent integration for Teams messages
- Integrate dispatchReplyFromConfig() for full agent routing
- Add msteams to TextChunkProvider and OriginatingChannelType
- Add msteams case to route-reply (proactive not yet supported)
- Strip @mention HTML tags from Teams messages
- Fix session key to exclude messageid suffix
- Add typing indicator support
- Add proper logging for debugging
2026-01-09 11:05:34 +01:00
Onur 951789e9fa wip [skip ci] 2026-01-09 11:05:34 +01:00
Onur d9cbecac7f feat(msteams): add MS Teams provider skeleton
- Add Microsoft 365 Agents SDK packages (@microsoft/agents-hosting,
  @microsoft/agents-hosting-express, @microsoft/agents-hosting-extensions-teams)
- Add MSTeamsConfig type and zod schema
- Create src/msteams/ provider with monitor, token, send, probe
- Wire provider into gateway (server-providers.ts, server.ts)
- Add msteams to all provider type unions (hooks, queue, cron, etc.)
- Update implementation guide with new SDK and progress
2026-01-09 11:05:34 +01:00
Onur 7274d6e757 docs: add detailed Azure Bot creation fields [skip ci]
- Direct link to Azure Bot creation page
- Field-by-field table for Project details
- Pricing tier options
- Microsoft App ID settings (Single Tenant, Create new)
- Note about SDK version requirement
2026-01-09 11:03:32 +01:00
Onur 0bb9756e0c docs: add useful links to Azure setup section [skip ci] 2026-01-09 11:03:32 +01:00
Onur 8e02c53df2 docs: add Azure Bot setup instructions
Added section 2.5 with step-by-step Azure setup:
- Create Azure Bot (single-tenant)
- Get credentials (appId, appPassword, tenantId)
- Configure messaging endpoint
- Enable Teams channel
- Local dev tunnel options (ngrok, Tailscale)
- Teams App manifest template
- Config example
2026-01-09 11:03:32 +01:00
Onur 051da852a2 docs: rename to msteams-implementation-guide.md 2026-01-09 11:03:32 +01:00
Onur 3b53a84459 docs: rewrite MS Teams research as implementation guide
Codex (gpt-5.2 xhigh) rewrote the doc:

- Added MVP scope definition
- Verified repo conventions against actual codebase
- Added 2025/2026 Microsoft guidance (CloudAdapter, single-tenant default)
- Concrete code examples (monitor, send, webhook, adapter)
- Detailed integration checklist (all files to modify)
- 9 MS Teams gotchas to plan for
- 12 actionable implementation steps
- Current references (2026-01)
2026-01-09 11:03:32 +01:00
Onur 01e737e90e docs: add MS Teams provider research document
Initial research and implementation guide for adding msteams as a new
messaging provider. Includes:

- Provider structure patterns from existing implementations
- Gateway integration requirements
- Config types and validation schemas
- Onboarding flow patterns
- MS Teams Bot Framework SDK considerations
- Files to create/modify checklist

This is exploratory work - implementation plan to follow.
2026-01-09 11:03:32 +01:00
Peter Steinberger fe69bc9439 fix(config): allow gateway.remote ssh fields 2026-01-09 10:50:19 +01:00
Peter Steinberger d258c68ca1 feat: add gateway dev config options 2026-01-09 10:39:00 +01:00
Luke 750dea3edf Merge branch 'main' into commands-list-clean 2026-01-09 04:21:47 -05:00
Luke K (pr-0f3t) 5f4df5e336 Auto-reply: drop native command flag 2026-01-09 04:14:36 -05:00
Peter Steinberger 0c167e85af style: sort configure imports 2026-01-09 10:03:52 +01:00
Peter Steinberger e0c310d056 chore: bump versions to 2026.1.9 2026-01-09 10:02:15 +01:00
Peter Steinberger 827e68eadd feat: improve auth setup flows 2026-01-09 09:59:58 +01:00
Peter Steinberger 3db52c972d fix: repair typing for thinking promotion 2026-01-09 08:37:38 +00:00
Peter Steinberger 17ccf53eb1 fix: normalize <think> reasoning blocks 2026-01-09 08:30:05 +00:00
Peter Steinberger 5b50c97939 feat(cli): improve gateway status output 2026-01-09 09:27:42 +01:00
Peter Steinberger 89132fdd25 chore(lint): biome import order 2026-01-09 09:27:42 +01:00
Peter Steinberger 8dd2286e3e docs: add message examples to cli index 2026-01-09 09:14:31 +01:00
Luke aa64abe84c Merge branch 'main' into commands-list-clean 2026-01-09 03:12:08 -05:00
Peter Steinberger 9998293dff docs: summarize message subcommands 2026-01-09 09:08:55 +01:00
Peter Steinberger 8d67bd2889 feat: apply lobster palette to prompts 2026-01-09 09:05:24 +01:00
Peter Steinberger f9be9ad426 feat: switch message cli to subcommands 2026-01-09 09:00:41 +01:00
Peter Steinberger 7ed53e243d fix(cli): clear lint warnings 2026-01-09 08:51:22 +01:00
Peter Steinberger 0a31112fb4 docs(changelog): mention gateway discover 2026-01-09 08:46:50 +01:00
Peter Steinberger 732972be2b style: format control ui assets log 2026-01-09 08:32:15 +01:00
Peter Steinberger 721183e259 feat: unify message cli and tools 2026-01-09 08:30:24 +01:00
Peter Steinberger 77d4bb8dfe feat: update token auth flow 2026-01-09 08:13:05 +01:00
Peter Steinberger a4d6638f89 fix: import outbound delivery types 2026-01-09 08:13:04 +01:00
Peter Steinberger c32e3c467d fix: map CLI deps for outbound sends 2026-01-09 08:13:04 +01:00
Peter Steinberger 185727c696 style: fix lint formatting 2026-01-09 08:13:04 +01:00
Peter Steinberger 37cbcc97d3 feat: support token auth profiles 2026-01-09 08:13:04 +01:00
Peter Steinberger eced473e05 feat: add models auth commands 2026-01-09 08:13:04 +01:00
Peter Steinberger af1f6fab29 chore: add lobster CLI banner art 2026-01-09 08:13:04 +01:00
Peter Steinberger 8808d8c84c docs: add gateway CLI doc 2026-01-09 08:13:04 +01:00
Peter Steinberger 266643bb94 feat(gateway): discover on local + wide-area DNS-SD 2026-01-09 08:13:04 +01:00
Peter Steinberger 2062165cd3 chore: add configure section hints 2026-01-09 08:13:04 +01:00
Peter Steinberger c807c7e72d chore: clarify multi-agent reporting 2026-01-09 08:13:04 +01:00
Peter Steinberger 581da80209 chore: log gateway reload and signals 2026-01-09 08:13:04 +01:00
Peter SteinbergerandAndrew Ting 394027dffb docs: add special thanks for OAuth fix
Co-authored-by: Andrew Ting <andrewtingnyc@gmail.com>
2026-01-09 08:13:04 +01:00
Peter Steinberger 60b282cf1d fix: improve web media fetch errors 2026-01-09 07:09:15 +00:00
Peter Steinberger c46bab35df fix: drop explicit ui:install step 2026-01-09 07:02:42 +00:00
Miles 27952bb94d imessage: isolate group-ish threads by chat_id 2026-01-09 00:01:01 -06:00
Shadow 95c2ccbd7b fix: remove incorrect discord limited warning 2026-01-08 23:59:40 -06:00
Peter Steinberger 458cbcfb41 docs: note restart disabled by default 2026-01-09 05:52:44 +00:00
Peter Steinberger 0a026fea1c fix: disable restart by default 2026-01-09 05:49:30 +00:00
Peter Steinberger db22207014 feat: add message tool and CLI 2026-01-09 06:44:20 +01:00
Peter Steinberger 48a1b07097 fix: promote selected auth profile order 2026-01-09 05:36:14 +00:00
Peter Steinberger 8e35ad5484 style: format after gate 2026-01-09 05:27:54 +00:00
Peter Steinberger 482364aa92 test: stabilize claude cli serialization test 2026-01-09 05:27:50 +00:00
Peter Steinberger 3aa5f45094 chore: update dependencies 2026-01-09 05:25:08 +00:00
Peter Steinberger 31a2830f7d fix: support /models alias for /model 2026-01-09 06:24:40 +01:00
Peter Steinberger 133124e023 fix: strip thinking tags in chat UI 2026-01-09 05:24:11 +00:00
Peter Steinberger 7842109609 fix: report auth label from configured order 2026-01-09 05:17:27 +00:00
Peter Steinberger f9fe067f68 fix: normalize anthropic model ids 2026-01-09 05:07:16 +00:00
Peter Steinberger 9114331218 fix: serialize claude cli runs 2026-01-09 04:58:21 +00:00
Peter Steinberger aa5e75e853 fix: align tool rename fallout 2026-01-09 05:54:34 +01:00
ClaudeandClaude Opus 4.5 333832c2e1 fix: bypass Anthropic OAuth token blocking for tool names
Anthropic blocks specific lowercase tool names (bash, read, write, edit)
when using OAuth tokens. This fix:

1. Renames blocked tools to capitalized versions (Bash, Read, Write, Edit)
   in pi-tools.ts via renameBlockedToolsForOAuth()

2. Passes all tools as customTools in splitSdkTools() to bypass
   pi-coding-agent's built-in tool filtering, which expects lowercase names

The capitalized names work with both OAuth tokens and regular API keys.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 05:48:54 +01:00
Peter Steinberger a69a863090 chore: update pi deps 2026-01-09 05:38:21 +01:00
Luke a4806e9417 Merge branch 'main' into commands-list-clean 2026-01-08 23:21:31 -05:00
Peter Steinberger ef1ce5d9a8 fix: avoid claude-cli session id collisions 2026-01-09 04:20:58 +00:00
Luke K (pr-0f3t) 5d34e27078 Revert: non-PR file edits 2026-01-08 23:19:44 -05:00
Peter Steinberger 64fc5fa9fc fix: allow default model outside allowlist 2026-01-09 04:18:30 +00:00
Luke K (pr-0f3t) 16f893c46b Tests: align google shared expectations 2026-01-08 23:12:37 -05:00
Peter Steinberger 7e4d5c9f84 test: align status and google-shared expectations 2026-01-09 05:03:12 +01:00
Luke 1be8a39442 Merge branch 'main' into commands-list-clean 2026-01-08 22:58:54 -05:00
Peter Steinberger 709d5d9cd6 fix: add spacing to daemon status output 2026-01-09 03:52:51 +00:00
Peter Steinberger 9a8fe4d683 feat(agent): add claude cli runner 2026-01-09 04:52:37 +01:00
Luke K (pr-0f3t) 1309cee124 Auto-reply: fix status command lint 2026-01-08 22:51:35 -05:00
Luke K (pr-0f3t) 8aa80fa464 Auto-reply: tidy help message 2026-01-08 22:48:46 -05:00
Peter Steinberger 8e27ea7371 feat: add raw stream logging flags 2026-01-09 03:45:21 +00:00
Peter Steinberger 69546d563d fix: combine status usage and cost line 2026-01-09 03:45:01 +00:00
Peter Steinberger 16cfd24967 fix: colorize daemon status output 2026-01-09 03:22:02 +00:00
Luke b97a9235e0 Merge branch 'main' into commands-list-clean 2026-01-08 22:19:52 -05:00
Peter Steinberger 3e19f82af2 fix: repair status reply call 2026-01-09 03:18:20 +00:00
Peter Steinberger d8a23cf5ab fix: restore emoji-rich status output 2026-01-09 03:15:33 +00:00
Luke K (pr-0f3t) 6c8b2a8dc9 Auto-reply: organize status imports 2026-01-08 22:14:24 -05:00
Peter Steinberger 875b849322 feat(ui): link sessions to chat page (#471) — thanks @HazAT 2026-01-09 04:07:56 +01:00
Daniel Griesser 2e1dee197a feat(ui): link sessions to chat page
- Session names in the Sessions table are now clickable links
- Clicking navigates to /chat?session=<key> with that session loaded
- Global sessions excluded (not real conversations)
- Added .session-link CSS styling (accent color, underline on hover)
- Chat page reads 'session' query param and cleans URL after applying
2026-01-09 04:05:33 +01:00
Peter Steinberger b3b84ffefa fix: fallback /model list when catalog is unavailable 2026-01-09 03:56:19 +01:00
Luke e52a2888cc Merge branch 'main' into commands-list-clean 2026-01-08 21:47:09 -05:00
Peter Steinberger 0cbc5fea93 fix: keep status for directive-only messages 2026-01-09 03:46:00 +01:00
Peter Steinberger dc81d0a649 fix: improve /status auth label 2026-01-09 03:39:02 +01:00
Peter Steinberger 75d193a284 test: update google shared expectations 2026-01-09 02:34:16 +00:00
Peter Steinberger 3c79d5c711 fix: keep /status usage filtering 2026-01-09 03:31:02 +01:00
Peter Steinberger 151523f47b feat: add usage cost reporting 2026-01-09 02:29:54 +00:00
Peter Steinberger dfbee10377 docs: clarify sandbox non-main behavior 2026-01-09 03:23:36 +01:00
Peter Steinberger 1a295d9460 fix: improve status usage filtering and directives 2026-01-09 03:18:47 +01:00
Peter Steinberger 84f668f9c5 test: align google-shared expectations with pi-ai 0.40.0 2026-01-09 03:12:32 +01:00
Peter Steinberger 468889abef fix: refine status usage and elevated directives 2026-01-09 03:09:56 +01:00
Peter Steinberger 8a3e100ad1 test: update google-shared expectations 2026-01-09 03:04:16 +01:00
Peter Steinberger fb1ec2cfbf Merge pull request #526 from neist/feat/system-prompt-messaging-guide
feat(system-prompt): add messaging guidance section
2026-01-09 02:02:04 +00:00
Peter Steinberger 362f62f325 docs: update changelog and clawtributors for #526 2026-01-09 03:00:42 +01:00
Kasper Neist 3b78870f33 feat(system-prompt): add messaging guidance section
Adds a brief 'Messaging' section to the system prompt to guide agents on:
- Reply in session = auto-routes to source provider
- Cross-session = use sessions_send
- Never use bash/curl for provider messaging

This helps prevent agents from using shell workarounds for messaging
when Clawdbot already handles routing internally.
2026-01-09 03:00:40 +01:00
Luke c678216ac2 Merge branch 'main' into commands-list-clean 2026-01-08 20:58:26 -05:00
Peter Steinberger 4827c283a6 docs: expand logging guidance 2026-01-09 02:56:29 +01:00
Peter Steinberger 0a722de65c test: cover log line parsing 2026-01-09 02:54:04 +01:00
Peter Steinberger 3a99ac7e9a fix: honor mention-bypass for group commands 2026-01-09 02:52:44 +01:00
Peter Steinberger efa5f0bfe0 feat: improve logs output and docs 2026-01-09 02:51:17 +01:00
Peter Steinberger e1789ba9e5 fix: set gemini default model for api-key auth (#489) - thanks @jonasjancarik 2026-01-09 02:49:42 +01:00
Jonáš Jančařík 302d51fd40 feat: add Gemini API key onboarding 2026-01-09 02:45:15 +01:00
Peter Steinberger 340d5d03f2 docs: note PR 541 and add clawtributor 2026-01-09 02:38:03 +01:00
Ogulcan Celik 7bb0841f42 fix: prevent systemd hang on restart with podman sandboxes
Add KillMode=process to generated systemd unit file. Without this,
podman's conmon processes (which monitor sandbox containers) block
shutdown since they're children of the gateway process.

This preserves the desired behavior of keeping containers alive
across restarts while preventing systemd from waiting indefinitely.
2026-01-09 02:37:00 +01:00
Peter Steinberger a738ade0ec chore: update dependencies 2026-01-09 02:34:39 +01:00
Kasper Neist 514b85ba33 feat(system-prompt): add messaging guidance section
Adds a brief 'Messaging' section to the system prompt to guide agents on:
- Reply in session = auto-routes to source provider
- Cross-session = use sessions_send
- Never use bash/curl for provider messaging

This helps prevent agents from using shell workarounds for messaging
when Clawdbot already handles routing internally.
2026-01-09 02:21:55 +01:00
Jake 0dbb569187 chore: bump Pi to 0.40.0 and revert PR #454 pi-ai patch
- Upgrade @mariozechner/pi-* packages from 0.38.0 to 0.40.0
- Remove pi-ai patch (upstream 429 fix in 0.40.0 supersedes PR #454)
- Update CHANGELOG
2026-01-09 02:12:19 +01:00
Peter Steinberger 75af0e61da docs: thank pasogott for Ansible guide (#545) 2026-01-09 02:10:19 +01:00
sheeek b8afbfbb44 docs: add Ansible installation guide 2026-01-09 02:09:02 +01:00
Peter Steinberger 14096fb629 docs: add model allowlist + reasoning safety notes 2026-01-09 02:07:33 +01:00
Peter Steinberger 9c33080f12 Merge pull request #534 from mcinteerj/fix/whatsapp-queued-routing
WhatsApp: fix routing for queued messages
2026-01-09 01:05:16 +00:00
Peter Steinberger 97635a8966 test: assert WhatsApp queued routing context (#534) thanks @mcinteerj 2026-01-09 02:04:05 +01:00
Peter Steinberger f2246df875 fix: route WhatsApp queued replies to sender (#534) 2026-01-09 02:03:30 +01:00
Jake a52e75d978 WhatsApp: fix routing for queued messages by using correct OriginatingTo 2026-01-09 02:01:33 +01:00
Peter Steinberger 7f12548615 docs: explain model allowlist errors 2026-01-09 01:59:22 +01:00
Peter Steinberger 57d3d6bd78 docs: clarify workspace vs sandbox 2026-01-09 01:54:28 +01:00
Peter Steinberger 455723375e fix: show directive options on query 2026-01-09 01:40:44 +01:00
Peter Steinberger 8ee7223587 docs: add clawdhub install/sync guidance 2026-01-09 01:38:19 +01:00
Peter Steinberger ae9f5ecc2d docs: clarify per-agent vs shared skills 2026-01-09 01:37:24 +01:00
Peter Steinberger 948ce5eb5f feat(models): add oauth auth health 2026-01-09 00:34:38 +00:00
L36 Server bcec534e5e docs: add authentication to gateway navigation 2026-01-09 00:34:38 +00:00
L36 Server 4ca2677815 docs: add authentication guide with 1-year token setup 2026-01-09 00:34:38 +00:00
L36 Server 6484195bfe scripts: add auth management and Termux widget scripts 2026-01-09 00:34:38 +00:00
Luke daafa9e3bc Merge branch 'main' into commands-list-clean 2026-01-08 19:33:56 -05:00
Peter Steinberger 8f21f34b1c docs(changelog): note webchat queue (#527) 2026-01-09 01:32:43 +01:00
Peter Steinberger 94c61aa19d feat(webchat): queue outgoing messages 2026-01-09 01:30:38 +01:00
Yurii ChukhlibandClaude e84cafec8a feat(webchat): change Enter key to send message
Changes webchat textarea behavior:
- Enter key now sends message (was Cmd/Ctrl+Enter)
- Shift+Enter allows line breaks
- Updated placeholder text to reflect new behavior

Fixes #411

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-09 01:30:37 +01:00
Jake bd4bc16192 WhatsApp: fix routing for queued messages by using correct OriginatingTo 2026-01-09 13:28:31 +13:00
Peter SteinbergerandJonathan D. Rhyne ab1896dc13 docs: add PR #519 changelog
Co-authored-by: Jonathan D. Rhyne <jonathan@pspdfkit.com>
2026-01-09 01:16:57 +01:00
Peter Steinberger 9b901889ee docs: rewrite TUI guide 2026-01-09 01:05:10 +01:00
Jonathan D. Rhyne debdd2aa95 fix(tui): status bar not updating after /verbose, /reasoning, /think commands
The status bar refresh failed because refreshSessionInfo() compared
currentSessionKey (e.g. 'main') against the canonical session keys
returned by the gateway (e.g. 'agent:default:main').

This fix uses parseAgentSessionKey to also match canonical keys by
their 'rest' component, allowing 'main' to match 'agent:default:main'.

Fixes: status bar showing stale values after setting changes
AI-assisted: yes (Claude)
Testing: lightly tested (build passes, logic verified)
2026-01-09 01:05:10 +01:00
Peter Steinberger 714e170c16 feat(tui): add agent picker and agents list rpc 2026-01-09 01:05:10 +01:00
Peter Steinberger a5f0f62e0d docs: fix models doc merge marker 2026-01-09 00:58:50 +01:00
Peter Steinberger e5dbba7b67 fix: sort imports for lint 2026-01-09 00:57:17 +01:00
Peter Steinberger 7a0614c850 Merge pull request #539 from clawdbot/fix/inline-directive-spacing
Preserve spacing after inline directives
2026-01-08 23:51:44 +00:00
Peter Steinberger 27adabbeea test: cover reasoning directive spacing (thanks @joshp123) (#539) 2026-01-09 00:51:05 +01:00
Peter Steinberger f666f60731 fix: preserve inline directive spacing tests (thanks @joshp123) (#539) 2026-01-09 00:37:40 +01:00
Josh Palmer 43545a4864 🤖 codex: preserve spacing after inline directives (issue-telegram-inline-spacing) 2026-01-09 00:37:40 +01:00
Peter Steinberger 4e92ccc0dd style: format 2026-01-09 00:33:09 +01:00
Peter Steinberger 71309c064a fix: drop redundant telegram audit union 2026-01-09 00:33:09 +01:00
Peter Steinberger 01b8f4582f fix: align discord monitor + whatsapp activity 2026-01-09 00:33:09 +01:00
Peter Steinberger e09708e82d feat: sync skills into sandbox workspace 2026-01-09 00:33:09 +01:00
Peter Steinberger 561fa99d95 feat(providers): show status spinner message 2026-01-08 23:30:38 +00:00
Peter Steinberger 7ece3717e6 refactor(pairing): centralize reply formatting 2026-01-08 23:29:23 +00:00
Peter Steinberger e952f7df96 docs: drop Updated headers 2026-01-09 00:20:32 +01:00
Peter Steinberger a5cb51282c docs: clarify WhatsApp number modes 2026-01-09 00:19:51 +01:00
Peter Steinberger e0439df4ce feat(pairing): show sender ids across providers 2026-01-08 23:19:13 +00:00
Peter Steinberger 7c7858a519 refactor(signal): normalize sender identity 2026-01-08 23:08:25 +00:00
Kasper Neist 9e9f2babeb fix(signal): use sourceUuid as fallback when sourceNumber is null
Users without a visible phone number (like some Signal users)
were being silently dropped. Now falls back to UUID for sender ID.

- Add sourceUuid to SignalEnvelope type
- Use sourceUuid when sourceNumber is not available
- Only check against bot account when sourceNumber exists (avoid UUID comparison issues)
2026-01-08 23:08:25 +00:00
Peter Steinberger 490bbd10fc feat(telegram): show user id in pairing 2026-01-09 00:01:23 +01:00
Peter Steinberger 829782c42c docs(changelog): note provider troubleshooting index 2026-01-08 23:56:54 +01:00
Peter Steinberger db7ea832a7 docs: add PR #476 to changelog 2026-01-08 23:52:55 +01:00
gupsammy 12231e3f59 chore: add .local to gitignore 2026-01-08 23:52:55 +01:00
gupsammy df1fdbeb2b docs: add 3 showcase entries from Discord 2026-01-08 23:52:55 +01:00
Shadow 9f8336c92b Discord: harden gateway resilience 2026-01-08 16:52:44 -06:00
Peter Steinberger 769b76cd40 test(provider-activity): restore real timers 2026-01-08 23:52:07 +01:00
Peter Steinberger 69f8af530d feat(providers): improve doctor + status probes 2026-01-08 23:48:37 +01:00
Peter Steinberger 41d484d239 test: cover browser and canvas verbose summaries 2026-01-08 23:48:37 +01:00
Peter Steinberger 2f1ce51b2c test: stabilize doctor + bash tool tests 2026-01-08 23:44:54 +01:00
Peter Steinberger 4360d1106d Merge pull request #532 from mcinteerj/docs/webhook-params-descriptions
docs: expand parameter descriptions for agent/wake hooks
2026-01-08 22:38:34 +00:00
Peter Steinberger 4655360546 chore: update changelog for #532 (thanks @mcinteerj) 2026-01-08 23:38:17 +01:00
Jake 89d01f15a5 docs: expand parameter descriptions for agent/wake hooks 2026-01-08 23:38:17 +01:00
Peter Steinberger b1d4bd0f78 docs: require code-verified answers 2026-01-08 23:33:41 +01:00
Peter Steinberger 6f6af83033 Merge pull request #521 from fishfisher/update-app-icons
chore(apps): rebrand app icons from Clawdis to Clawdbot
2026-01-08 22:31:55 +00:00
Peter Steinberger dc9fdf335d chore: update changelog and clawtributors (#521, thanks @fishfisher) 2026-01-08 23:31:24 +01:00
Erik von Essen FisherandClaude Sonnet 4.5 fb0c566b2a Rebrand app icons from Clawdis to Clawdbot
Replace app icons with new Clawdbot branding (lobster-in-phone-booth design) across iOS, Android, and macOS.

Changes:
- iOS: Updated all 14 icon sizes in AppIcon.appiconset (20px to 1024px)
- Android: Updated launcher icons for all density buckets (mdpi to xxxhdpi)
- macOS: Updated Icon.icon bundle and regenerated Clawdbot.icns

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-08 23:31:24 +01:00
Peter Steinberger cdfae0b9d3 fix(daemon): normalize audit paths 2026-01-08 22:28:51 +00:00
Peter Steinberger 3965c5b4d2 docs: naming sweep and platform wording 2026-01-08 23:25:51 +01:00
Peter Steinberger ed80860c34 chore(format): apply biome fixes 2026-01-08 22:22:17 +00:00
Peter Steinberger 1f215848be chore(test): align auth choice options formatting 2026-01-08 23:18:33 +01:00
Peter Steinberger dc1992cbb5 docs: note elevated no-op when unsandboxed 2026-01-08 23:18:33 +01:00
Peter Steinberger b01d7e39d5 fix(onboarding): preflight claude cli keychain 2026-01-08 23:18:33 +01:00
Peter Steinberger d38a8d7076 docs: clarify elevated sandbox behavior 2026-01-08 23:18:33 +01:00
Peter Steinberger 36fa3c3cd3 fix: improve ws close diagnostics 2026-01-08 22:18:07 +00:00
Peter Steinberger 1cf8503017 fix(daemon): audit runtime best practices 2026-01-08 22:18:07 +00:00
Jake 95015ddea6 docs: expand parameter descriptions for agent/wake hooks 2026-01-09 11:14:52 +13:00
Peter Steinberger cd2f3bd355 feat(status): add Telegram/WhatsApp troubleshooting warnings 2026-01-08 23:14:11 +01:00
Peter Steinberger a33271d374 refactor: share paren-aware chunk break scan 2026-01-08 23:09:34 +01:00
Peter Steinberger 7392387ec1 feat(status): warn on Discord message content intent 2026-01-08 23:07:34 +01:00
Peter Steinberger a6c309824e docs: refresh and simplify docs 2026-01-08 23:07:20 +01:00
Peter Steinberger 88dca1afdf docs: fix Mintlify parse error 2026-01-08 23:05:26 +01:00
Peter Steinberger b48ed56635 test: add DM elevated regression coverage 2026-01-08 22:58:15 +01:00
Peter Steinberger 014667e00b fix: tighten group elevated targeting 2026-01-08 22:57:18 +01:00
Peter Steinberger cda2025c49 fix: align env config schema 2026-01-08 22:57:18 +01:00
Peter Steinberger 59f89678b2 feat: allow inline env vars in config 2026-01-08 22:57:18 +01:00
Peter Steinberger 9a1267b530 fix(doctor): run legacy migrations in non-interactive 2026-01-08 21:37:22 +00:00
Peter Steinberger 5b46214379 refactor(cron): centralize telegram targets + delivery helpers 2026-01-08 21:36:53 +00:00
Peter Steinberger 5939dff092 fix: avoid synthesizing agent defaults 2026-01-08 22:26:29 +01:00
Luke ab28eb5109 Merge branch 'main' into commands-list-clean 2026-01-08 16:25:56 -05:00
Peter Steinberger 2c7d64232e feat: enable adaptive context pruning by default 2026-01-08 22:23:03 +01:00
Luke 5965708f1d Merge branch 'main' into commands-list-clean 2026-01-08 16:22:25 -05:00
Peter Steinberger d58cdf7c9f fix: normalize gateway cli option strings 2026-01-08 22:18:06 +01:00
Peter Steinberger 35ba99c245 fix: honor non-interactive legacy migrations 2026-01-08 22:13:57 +01:00
Peter Steinberger e75ca23e7d test(cron): cover telegram topic delivery (#478) (thanks @nachoiacovino) 2026-01-08 21:13:39 +00:00
Nacho Iacovino 5391f1141e fix(cron): parse :topic: suffix for Telegram topic delivery
When cron jobs specify a Telegram topic in the 'to' field like
'-1003420657307:topic:60', the delivery code now correctly parses
this into separate chatId and messageThreadId values.

Previously, the entire string was passed as chatId, causing messages
to fail delivery to forum topics.
2026-01-08 21:12:15 +00:00
Peter Steinberger 699ba410fe fix: resolve WhatsApp LID inbound mapping 2026-01-08 22:08:54 +01:00
Peter Steinberger da48a9907b docs: add beginner paths for docker and linux vps 2026-01-08 21:56:47 +01:00
Josh Palmer 53b81d07c6 🤖 codex: add changelog entry for #210 2026-01-08 21:54:04 +01:00
Josh Palmer 2fae8eda66 🤖 codex: strip empty assistant blocks from history (#210) 2026-01-08 21:53:33 +01:00
Peter Steinberger 884e734809 feat(doctor): audit config + state permissions 2026-01-08 21:51:34 +01:00
Peter Steinberger 13ddd40a59 docs: clarify sandboxing config option 2026-01-08 21:51:21 +01:00
Peter Steinberger d55750189e docs: add sandboxing page and cross-links 2026-01-08 21:49:26 +01:00
Peter Steinberger 2d4ec35e1c feat(doctor): add repair/force flows 2026-01-08 21:47:35 +01:00
Peter Steinberger 712a7dddf6 docs(cron): clarify jobId vs id 2026-01-08 21:43:22 +01:00
Peter Steinberger 10e46c6d5e docs(cron): rewrite cron jobs documentation 2026-01-08 21:39:03 +01:00
Peter Steinberger 0373574f0b refactor(telegram): centralize target parsing 2026-01-08 21:38:59 +01:00
Peter Steinberger 88039a69a2 style(cli): format daemon config warnings 2026-01-08 21:38:54 +01:00
Peter Steinberger ea748f9440 fix(doctor): handle missing service readCommand 2026-01-08 21:38:51 +01:00
Luke a574ae5415 Merge branch 'main' into commands-list-clean 2026-01-08 15:35:17 -05:00
Peter Steinberger 23e34dd598 docs: clarify doctor + launchd service guidance 2026-01-08 21:34:28 +01:00
Peter Steinberger 01641b34ea feat(doctor): audit supervisor config + docs 2026-01-08 21:28:48 +01:00
Peter Steinberger d0c4ce6749 docs(changelog): note PR #483 (thanks @AbhisekBasu1) 2026-01-08 20:26:18 +00:00
Peter Steinberger 58a9e35233 style(cron): format telegram target tests 2026-01-08 21:23:07 +01:00
Peter Steinberger 5fea53d89c fix(cron): parse telegram targets with prefixes (thanks @mitschabaude-bot) 2026-01-08 21:23:07 +01:00
Gregor's Bot 2afb75d508 fix(cron): support Telegram topic/thread ID in delivery target
When delivering cron job output to Telegram, the 'to' field now supports
specifying a topic (forum thread) ID in addition to the chat ID.

Supported formats:
- chatId (plain chat ID or @username)
- chatId:topicId (chat ID with numeric topic ID)
- chatId:topic:topicId (alternative format with explicit marker)

This enables cron jobs to deliver messages to specific forum topics
rather than always going to the main/general topic.

Adds parseTelegramTarget helper function with unit tests.

(cherry picked from commit 24a6595e81)
2026-01-08 21:23:07 +01:00
Tobias Bischoff 7cffa65376 Merge branch 'main' into main 2026-01-08 21:16:47 +01:00
Peter Steinberger f63496b389 Merge pull request #479 from azade-c/docs/systemd-service-comments
fix(daemon): align systemd unit with documentation
2026-01-08 20:16:32 +00:00
Peter Steinberger 9632ebed7b docs: note systemd unit alignment (#479) - thanks @azade-c 2026-01-08 21:16:19 +01:00
Azade 3a8d8bc6a4 fix(daemon): align systemd unit with documentation
Align generated systemd service file with docs:
https://docs.clawd.bot/gateway#supervision-systemd-user-unit

Adds:
- After=network-online.target
- Wants=network-online.target
- RestartSec=5
2026-01-08 21:16:19 +01:00
Tobias Bischoff 634f1389a4 Merge branch 'main' into main 2026-01-08 21:15:06 +01:00
Erik von Essen FisherandClaude Sonnet 4.5 7363bd1ad7 Rebrand app icons from Clawdis to Clawdbot
Replace app icons with new Clawdbot branding (lobster-in-phone-booth design) across iOS, Android, and macOS.

Changes:
- iOS: Updated all 14 icon sizes in AppIcon.appiconset (20px to 1024px)
- Android: Updated launcher icons for all density buckets (mdpi to xxxhdpi)
- macOS: Updated Icon.icon bundle and regenerated Clawdbot.icns

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-08 21:15:02 +01:00
Peter Steinberger 0c74ef25d6 refactor: normalize skills UI status keys 2026-01-08 20:13:24 +00:00
Peter Steinberger 5a2242ee92 docs(changelog): follow-up #516 (thanks @YuriNachos) 2026-01-08 21:08:34 +01:00
Peter Steinberger 31fb5867e8 refactor(outbound): centralize telegram account defaults 2026-01-08 21:05:01 +01:00
Peter Steinberger e90c00768f Merge pull request #445 from pkrmf/ui-skill-install-feedback
UI: improve skill install feedback
2026-01-08 20:04:32 +00:00
Peter Steinberger dc4c174f4e docs: add changelog for skill install feedback (PR #445, thanks @pkrmf) 2026-01-08 20:04:13 +00:00
Peter Steinberger 7905a27416 style: format gateway-cli log lines 2026-01-08 20:04:10 +00:00
Marc Terns 965615a46c UI: improve skill install feedback 2026-01-08 20:01:38 +00:00
Peter Steinberger 04e0e10411 fix: restore hidden gateway-daemon alias 2026-01-08 19:54:23 +00:00
Peter Steinberger 28e3e51335 Merge pull request #516 from YuriNachos/fix/heartbeat-telegram-account-id
fix(heartbeat): pass accountId for Telegram delivery
2026-01-08 19:48:44 +00:00
Peter Steinberger 5372c1ab54 Merge branch 'main' into fix/heartbeat-telegram-account-id 2026-01-08 20:48:29 +01:00
Peter Steinberger 871c9e5286 fix(heartbeat): telegram accountId + cron jobId compat (#516, thanks @YuriNachos) 2026-01-08 20:47:22 +01:00
Yurii ChukhlibandClaude 9d42972b8a fix(heartbeat): pass accountId for Telegram delivery
Heartbeat Telegram delivery was failing when the bot token was
configured only via telegram.botToken in config (without TELEGRAM_BOT_TOKEN
environment variable).

Root cause: deliverOutboundPayloads was called without accountId parameter,
so sendMessageTelegram couldn't determine which account to use and couldn't
find the token from config.

Fix: Resolve default Telegram accountId when provider is "telegram" and pass
it to deliverOutboundPayloads. This follows the same pattern used elsewhere in
the codebase (e.g., cron uses resolveTelegramToken).

Changes:
- Added import for resolveDefaultTelegramAccountId
- Added accountId resolution for telegram provider
- Updated deliverOutboundPayloads call to include accountId

Fixes #318

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-08 20:47:22 +01:00
Yurii ChukhlibandClaude 4dac298ae2 fix(cron): use jobId parameter instead of id for AI tool schema
Fixes parameter mismatch between AI tool schema and internal validation.
The TypeBox schema now uses `jobId` for update/remove/run/runs actions,
matching what users expect based on the returned job objects.

Changes:
- Changed parameter from `id` to `jobId` in TypeBox schema for update/remove/run/runs
- Updated execute function to read `jobId` parameter
- Updated tests to use `jobId` in input parameters

The gateway protocol still uses `id` internally - the tool now maps
`jobId` from the AI to `id` for the gateway call.

Fixes #185

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-08 20:47:22 +01:00
Yurii ChukhlibandClaude 2f9306b98a fix(heartbeat): pass accountId for Telegram delivery
Heartbeat Telegram delivery was failing when the bot token was
configured only via telegram.botToken in config (without TELEGRAM_BOT_TOKEN
environment variable).

Root cause: deliverOutboundPayloads was called without accountId parameter,
so sendMessageTelegram couldn't determine which account to use and couldn't
find the token from config.

Fix: Resolve default Telegram accountId when provider is "telegram" and pass
it to deliverOutboundPayloads. This follows the same pattern used elsewhere in
the codebase (e.g., cron uses resolveTelegramToken).

Changes:
- Added import for resolveDefaultTelegramAccountId
- Added accountId resolution for telegram provider
- Updated deliverOutboundPayloads call to include accountId

Fixes #318

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-08 20:40:00 +01:00
Peter Steinberger 28a0864de8 Merge pull request #499 from philipp-spiess/fix/paren-chunking
Avoid splitting outbound chunks inside parentheses
2026-01-08 19:39:52 +00:00
Peter Steinberger 4a918ccf47 fix: avoid splitting outbound chunks inside parentheses (#499) — thanks @philipp-spiess 2026-01-08 20:39:28 +01:00
Philipp Spiess 4082b90aa4 Chunking: avoid splits inside parentheses 2026-01-08 20:38:00 +01:00
Peter Steinberger 491f928a2e Merge pull request #473 from gupsammy/fix/macos-app-crash-bundle-resources
fix(macos): prevent crash from missing ClawdbotKit resources and Swift 6.2 library
2026-01-08 19:28:12 +00:00
Yurii ChukhlibandClaude 265ef6d0c6 fix(cron): use jobId parameter instead of id for AI tool schema
Fixes parameter mismatch between AI tool schema and internal validation.
The TypeBox schema now uses `jobId` for update/remove/run/runs actions,
matching what users expect based on the returned job objects.

Changes:
- Changed parameter from `id` to `jobId` in TypeBox schema for update/remove/run/runs
- Updated execute function to read `jobId` parameter
- Updated tests to use `jobId` in input parameters

The gateway protocol still uses `id` internally - the tool now maps
`jobId` from the AI to `id` for the gateway call.

Fixes #185

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-08 20:28:03 +01:00
Peter Steinberger 9052160af3 Merge pull request #512 from YuriNachos/fix/telegram-fetch-type-errors
fix(telegram): resolve fetch type errors with grammY Bot constructor
2026-01-08 19:27:20 +00:00
Peter Steinberger f1bc178141 fix: land macos resource bundle guard (#473) — thanks @gupsammy 2026-01-08 19:24:20 +00:00
gupsammyandClaude Opus 4.5 29e9a574b0 fix(macos): prevent crash from missing ClawdbotKit resources and Swift library
The macOS app was crashing in two scenarios:

1. Bundle.module crash (fixes #213): When the first tool event arrived,
   ToolDisplayRegistry tried to load config via ClawdbotKitResources.bundle,
   which used Bundle.module directly. In packaged apps, Bundle.module
   couldn't find the resource bundle at the expected path, causing a
   fatal assertion failure after ~40-80 minutes of runtime.

2. dyld crash (fixes #417): Swift 6.2 requires libswiftCompatibilitySpan.dylib
   but SwiftPM doesn't bundle it automatically, causing immediate crash on
   launch with "Library not loaded" error.

Changes:
- ClawdbotKitResources.swift: Replace direct Bundle.module access with a
  safe locator that checks multiple paths and falls back gracefully
- package-mac-app.sh: Copy ClawdbotKit_ClawdbotKit.bundle to Resources
- package-mac-app.sh: Copy libswiftCompatibilitySpan.dylib from Xcode
  toolchain to Frameworks

Tested on macOS 26.2 with Swift 6.2 - app launches and runs without crashes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 19:24:20 +00:00
Peter Steinberger ba19173b96 test: cover status model provider prefix 2026-01-08 20:23:38 +01:00
Peter Steinberger 393c414e90 refactor: split discord gateway helpers 2026-01-08 20:23:06 +01:00
Peter SteinbergerandYuri Chukhlib bf67b29a0e fix(telegram): resolve grammY fetch type mismatch (#512)
Co-authored-by: Yuri Chukhlib <yuri.v.chu@gmail.com>
2026-01-08 20:18:17 +01:00
Peter Steinberger 43e0462405 docs: add changelog for #506 2026-01-08 20:13:45 +01:00
Keith the Silly Goose 7866203c5c fix(status): include provider prefix in model display
The /status command was showing 'anthropic/claude-sonnet-4-5' even when
using 'google-antigravity/claude-sonnet-4-5' because buildStatusMessage
received only the model name without the provider prefix.

When resolveConfiguredModelRef parsed a model string without a slash,
it fell back to DEFAULT_PROVIDER ('anthropic'), causing the misleading
display.

Fix: Pass the full 'provider/model' string to buildStatusMessage so
the provider is correctly extracted and displayed.

🪿 Investigated and submitted by Keith the Silly Goose
2026-01-08 20:11:37 +01:00
Yuri Chukhlib d1ccdeb5f5 Merge branch 'main' into fix/telegram-fetch-type-errors 2026-01-08 20:09:00 +01:00
Peter Steinberger c54f2a122a fix: update changelog + prompt test 2026-01-08 20:08:27 +01:00
Josh Palmer b7c900739e 🤖 codex: handle discord gateway error events (#504) 2026-01-08 20:06:02 +01:00
Yurii Chukhlib d05de07fdd fix(telegram): resolve fetch type errors with grammY Bot constructor
Add proper type assertion for ApiClientOptions["fetch"] to resolve
TypeScript compilation errors when passing fetch implementation to
grammY's Bot constructor. This follows the same pattern already used
in bot.ts.

Fixes #465
2026-01-08 19:31:06 +01:00
Josh Palmer cc94db458c 🤖 codex: fix block reply ordering (#503)
What: serialize block reply sends, make typing non-blocking, add timeout fallback + abort-aware routing, and add regression tests.
Why: prevent out-of-order streamed blocks while keeping final fallback on timeouts.
Tests: ./node_modules/.bin/vitest run src/auto-reply/reply.block-streaming.test.ts src/auto-reply/reply/route-reply.test.ts
Tests: corepack pnpm lint && corepack pnpm build (pass). corepack pnpm test (ran locally; failure observed during run).

Co-authored-by: Josh Palmer <joshp123@users.noreply.github.com>
2026-01-08 19:30:24 +01:00
Gregor's Bot 7450aed663 fix: rename ClaudeBot to Clawdbot in system prompt (#502) 2026-01-08 11:24:00 -06:00
Philipp Spiess 5fe39307cd Chunking: avoid splits inside parentheses 2026-01-08 16:52:50 +01:00
Luke K (pr-0f3t) 713eb898f6 Removed compact command changes from elsewhere 2026-01-08 10:41:09 -05:00
Luke 8445c9a5e8 Removing stray commits 2026-01-08 10:33:11 -05:00
LK 559e175b38 feat(commands): add /commands slash list 2026-01-08 16:23:36 +01:00
Tobias Bischoff 3149d6d331 Telegram: cast fetch to grammy client type 2026-01-08 15:16:53 +01:00
Tobias Bischoff ecd4c9c4f5 Onboarding: add MiniMax hosted API key option 2026-01-08 15:10:18 +01:00
Peter Steinberger c20a12aa7b fix: format onboarding hint 2026-01-08 12:25:39 +01:00
Azade 1f7dbb6a48 fix(daemon): align systemd unit with documentation
Align generated systemd service file with docs:
https://docs.clawd.bot/gateway#supervision-systemd-user-unit

Adds:
- After=network-online.target
- Wants=network-online.target
- RestartSec=5
2026-01-08 11:24:47 +00:00
Peter Steinberger 4c5440ccab chore: bump version to 2026.1.8-2 2026-01-08 12:23:01 +01:00
Peter Steinberger a564558a67 chore: drop bun from daemon runtime options 2026-01-08 12:20:45 +01:00
Peter Steinberger 51c2d29693 feat: auto-install gateway in quickstart 2026-01-08 12:19:45 +01:00
Peter Steinberger 8ed412db22 feat: streamline quickstart provider selection 2026-01-08 12:19:16 +01:00
Peter Steinberger e210069dbd fix: soften codex oauth expiry hint 2026-01-08 12:16:09 +01:00
Peter Steinberger 4dde870121 docs: split onboarding mode hints 2026-01-08 12:14:36 +01:00
Peter Steinberger 959a19d8ae fix: clean lint issues 2026-01-08 11:07:11 +00:00
Peter Steinberger 63bad210c9 docs: tighten onboarding quickstart hint 2026-01-08 12:06:45 +01:00
Peter Steinberger 4c09acb2a9 feat: add telegram topic delivery for cron 2026-01-08 11:05:28 +00:00
Peter Steinberger 874c7465a5 fix: surface control ui config in daemon status 2026-01-08 11:05:28 +00:00
Peter Steinberger e7b3cc3d3d fix: add provider account metadata 2026-01-08 11:05:28 +00:00
Peter Steinberger fd04bdb9cf chore: tighten self-update guidance 2026-01-08 12:04:29 +01:00
Peter Steinberger 63ff5819b1 fix: retry telegram poll conflicts 2026-01-08 12:04:21 +01:00
Peter Steinberger 03605bfa6a chore: refine control ui links and composer 2026-01-08 12:04:11 +01:00
Peter Steinberger d42444928b chore: add provider logs command 2026-01-08 12:01:06 +01:00
Peter Steinberger a483e58860 feat: add quickstart onboarding defaults 2026-01-08 11:54:40 +01:00
Peter Steinberger f24a4626e3 fix(config): reject shared agentDir 2026-01-08 11:54:26 +01:00
Peter Steinberger aa34d7d5f2 chore: sync pnpm lockfile 2026-01-08 10:46:12 +00:00
Peter Steinberger af20abf78d fix(ui): start logs at bottom 2026-01-08 11:42:28 +01:00
Peter Steinberger 390b102563 chore: show dm policy + allowlist preview 2026-01-08 11:25:17 +01:00
gupsammyandClaude Opus 4.5 908fb94f0b fix(macos): prevent crash from missing ClawdbotKit resources and Swift library
The macOS app was crashing in two scenarios:

1. Bundle.module crash (fixes #213): When the first tool event arrived,
   ToolDisplayRegistry tried to load config via ClawdbotKitResources.bundle,
   which used Bundle.module directly. In packaged apps, Bundle.module
   couldn't find the resource bundle at the expected path, causing a
   fatal assertion failure after ~40-80 minutes of runtime.

2. dyld crash (fixes #417): Swift 6.2 requires libswiftCompatibilitySpan.dylib
   but SwiftPM doesn't bundle it automatically, causing immediate crash on
   launch with "Library not loaded" error.

Changes:
- ClawdbotKitResources.swift: Replace direct Bundle.module access with a
  safe locator that checks multiple paths and falls back gracefully
- package-mac-app.sh: Copy ClawdbotKit_ClawdbotKit.bundle to Resources
- package-mac-app.sh: Copy libswiftCompatibilitySpan.dylib from Xcode
  toolchain to Frameworks

Tested on macOS 26.2 with Swift 6.2 - app launches and runs without crashes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 15:51:07 +05:30
Peter Steinberger 8803787e48 chore: fallback providers status when gateway down 2026-01-08 11:05:03 +01:00
Peter Steinberger a851444a1d chore: expand provider details in status 2026-01-08 11:02:13 +01:00
Peter Steinberger e8698fb9ef chore: show dashboard url in status 2026-01-08 10:51:55 +01:00
Peter Steinberger 840c64084e docs: refresh daemon troubleshooting guidance 2026-01-08 10:42:23 +01:00
Peter Steinberger af2accf5e3 test: force bun fetch path in telegram send 2026-01-08 10:37:29 +01:00
Peter Steinberger fa501e7730 fix: import path in doctor 2026-01-08 10:36:20 +01:00
Peter Steinberger dab3b3442f chore: bump version to 2026.1.8-1 2026-01-08 10:35:35 +01:00
Peter Steinberger c410e2fa47 docs: expand Telegram setup notes 2026-01-08 09:34:05 +00:00
Peter Steinberger 2567281846 docs: document hook model overrides 2026-01-08 09:34:05 +00:00
Peter Steinberger 73988506fe feat: allow hook model overrides 2026-01-08 09:33:42 +00:00
Peter Steinberger e6f8e1e531 fix(telegram): restrict native fetch to bun 2026-01-08 10:31:16 +01:00
Peter Steinberger 4019fafc2c chore: add spacing to status output 2026-01-08 10:30:57 +01:00
Peter Steinberger 21ef1761c6 chore: prompt to install or start gateway 2026-01-08 10:29:48 +01:00
Peter Steinberger f6b1dc452a chore: surface daemon status help 2026-01-08 10:29:44 +01:00
Peter Steinberger a54442866a chore: refine daemon runtime hint 2026-01-08 10:29:40 +01:00
Peter Steinberger b80b3a38bf chore: clarify telegram docs links 2026-01-08 10:29:36 +01:00
Peter Steinberger 1232eb6f5e docs: prefer npm for global install 2026-01-08 10:07:13 +01:00
Peter Steinberger 712cfa94aa docs: recommend global install and daemon onboarding 2026-01-08 09:59:10 +01:00
Peter Steinberger bfd3eb46c7 docs: drop legacy telegram pairing mention 2026-01-08 09:57:03 +01:00
Peter Steinberger bce3cc992f fix: group /model list output 2026-01-08 09:44:59 +01:00
Peter Steinberger 6c9dd731a9 docs: add makeup alias 2026-01-08 09:39:20 +01:00
Peter Steinberger 0b611fc354 docs: add OAuth deep dive 2026-01-08 09:29:29 +01:00
Marc Terns 871e888283 UI: improve skill install feedback 2026-01-07 21:21:32 -06:00
983 changed files with 89908 additions and 15727 deletions

No files matched your search

+119 -7
View File
@@ -5,8 +5,59 @@ 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: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
fail-fast: false
matrix:
@@ -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,13 +136,13 @@ 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-windows:
runs-on: windows-latest
runs-on: blacksmith-4vcpu-windows-2025
defaults:
run:
shell: bash
@@ -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
@@ -412,7 +524,7 @@ jobs:
PY
android:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
fail-fast: false
matrix:
+37
View File
@@ -0,0 +1,37 @@
name: Workflow Sanity
on:
pull_request:
push:
jobs:
no-tabs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fail on tabs in workflow files
run: |
python - <<'PY'
from __future__ import annotations
import pathlib
import sys
root = pathlib.Path(".github/workflows")
bad: list[str] = []
for path in sorted(root.rglob("*.yml")):
if b"\t" in path.read_bytes():
bad.append(str(path))
for path in sorted(root.rglob("*.yaml")):
if b"\t" in path.read_bytes():
bad.append(str(path))
if bad:
print("Tabs found in workflow file(s):")
for path in bad:
print(f"- {path}")
sys.exit(1)
PY
+5
View File
@@ -1,5 +1,6 @@
node_modules
.env
docker-compose.extra.yml
dist
*.bun-build
pnpm-lock.yaml
@@ -50,3 +51,7 @@ apps/ios/*.dSYM.zip
# provisioning profiles (local)
apps/ios/*.mobileprovision
.env
# Local untracked files
.local/
.vscode/
+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
+19 -5
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
@@ -28,11 +29,14 @@
- Add brief code comments for tricky or non-obvious logic.
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
- Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` for CLI command, package/binary, paths, and config keys.
## Testing Guidelines
- 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.
@@ -40,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`.
@@ -62,6 +67,11 @@
- Rebrand/migration issues (Clawdis → Clawdbot) or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
## 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.
@@ -80,24 +90,28 @@
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
- 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.
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
- **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/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.
- Voice wake forwarding tips:
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
- For manual `clawdbot send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
## Exclamation Mark Escaping Workaround
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot send` with messages containing exclamation marks, use heredoc syntax:
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
```bash
# WRONG - will send "Hello\\!" with backslash
clawdbot send --to "+1234" --message 'Hello!'
clawdbot message send --to "+1234" --message 'Hello!'
# CORRECT - use heredoc to avoid escaping
clawdbot send --to "+1234" --message "$(cat <<'EOF'
clawdbot message send --to "+1234" --message "$(cat <<'EOF'
Hello!
EOF
)"
+146 -3
View File
@@ -1,8 +1,151 @@
# Changelog
## Unreleased
## 2026.1.10
- (nothing yet)
### 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
@@ -19,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>` (Telegram also supports `clawdbot telegram pairing ...`).
- 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 \
+58 -41
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">
@@ -37,25 +37,18 @@ Model note: while any model is supported, I strongly recommend **Anthropic Pro/M
- Models config + CLI: [Models](https://docs.clawd.bot/models)
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.clawd.bot/model-failover)
## Recommended setup (from source)
## Install (recommended)
Do **not** download prebuilt binaries. Run from source.
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
Runtime: **Node ≥22**.
```bash
# Clone this repo
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
npm install -g clawdbot@latest
# or: pnpm add -g clawdbot@latest
pnpm install
pnpm ui:install
pnpm ui:build
pnpm build
pnpm clawdbot onboard
clawdbot onboard --install-daemon
```
Note: `pnpm clawdbot ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `clawdbot` binary.
The wizard installs the Gateway daemon (launchd/systemd user service) so it stays running.
## Quick start (TL;DR)
@@ -64,23 +57,38 @@ Runtime: **Node ≥22**.
Full beginner guide (auth, pairing, providers): [Getting started](https://docs.clawd.bot/getting-started)
```bash
pnpm clawdbot onboard
clawdbot onboard --install-daemon
pnpm clawdbot gateway --port 18789 --verbose
# Dev loop (auto-reload on TS changes)
pnpm gateway:watch
clawdbot gateway --port 18789 --verbose
# Send a message
pnpm clawdbot send --to +1234567890 --message "Hello from Clawdbot"
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
pnpm clawdbot agent --message "Ship checklist" --thinking high
clawdbot agent --message "Ship checklist" --thinking high
```
Upgrading? [Updating guide](https://docs.clawd.bot/updating) (and run `clawdbot doctor`).
If you run from source, prefer `pnpm clawdbot …` (not global `clawdbot`).
## From source (development)
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
```bash
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
pnpm install
pnpm ui:build # auto-installs UI deps on first run
pnpm build
pnpm clawdbot onboard --install-daemon
# Dev loop (auto-reload on TS changes)
pnpm gateway:watch
```
Note: `pnpm clawdbot ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `clawdbot` binary.
## Security defaults (DM access)
@@ -90,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.
@@ -151,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
@@ -231,11 +240,12 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc
Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):
- `/status` — health + session info (group shows activation mode)
- `/status` — compact session status (model + tokens, cost when available)
- `/new` or `/reset` — reset the session
- `/compact` — compact session context (summary)
- `/think <level>` — off|minimal|low|medium|high
- `/verbose on|off`
- `/cost on|off` — append per-response token/cost usage lines
- `/restart` — restart the gateway (owner-only in groups)
- `/activation mention|always` — group activation toggle (groups only)
@@ -275,7 +285,7 @@ Runbook: [iOS connect](https://docs.clawd.bot/ios).
## Agent workspace + skills
- Workspace root: `~/clawd` (configurable via `agent.workspace`).
- Workspace root: `~/clawd` (configurable via `agents.defaults.workspace`).
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
- Skills: `~/clawd/skills/<skill>/SKILL.md`.
@@ -296,14 +306,14 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
## Security model (important)
- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you.
- **Group/channel safety:** set `agent.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
- **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxing](https://docs.clawd.bot/docker) · [Sandbox config](https://docs.clawd.bot/configuration)
### [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.
@@ -443,17 +453,24 @@ by Peter Steinberger and the community.
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
AI/vibe-coded PRs welcome! 🤖
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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>
+5 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "com.clawdbot.android"
minSdk = 31
targetSdk = 36
versionCode = 20260108
versionName = "2026.1.8"
versionCode = 20260109
versionName = "2026.1.9"
}
buildTypes {
@@ -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) ->
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 46 KiB

+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> {
+4 -4
View File
@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.8</string>
<string>2026.1.9</string>
<key>CFBundleVersion</key>
<string>20260108</string>
<string>20260109</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
@@ -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]) {
+2 -2
View File
@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.8</string>
<string>2026.1.9</string>
<key>CFBundleVersion</key>
<string>20260108</string>
<string>20260109</string>
</dict>
</plist>
+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"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

+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 {
@@ -81,22 +81,33 @@ enum ClawdbotConfigFile {
static func agentWorkspace() -> String? {
let root = self.loadDict()
let agent = root["agent"] as? [String: Any]
return agent?["workspace"] as? String
let agents = root["agents"] as? [String: Any]
let defaults = agents?["defaults"] as? [String: Any]
return defaults?["workspace"] as? String
}
static func setAgentWorkspace(_ workspace: String?) {
var root = self.loadDict()
var agent = root["agent"] as? [String: Any] ?? [:]
var agents = root["agents"] as? [String: Any] ?? [:]
var defaults = agents["defaults"] as? [String: Any] ?? [:]
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
agent.removeValue(forKey: "workspace")
defaults.removeValue(forKey: "workspace")
} else {
agent["workspace"] = trimmed
defaults["workspace"] = trimmed
}
if defaults.isEmpty {
agents.removeValue(forKey: "defaults")
} else {
agents["defaults"] = defaults
}
if agents.isEmpty {
root.removeValue(forKey: "agents")
} else {
root["agents"] = agents
}
root["agent"] = agent
self.saveDict(root)
self.logger.debug("agent workspace updated set=\(!trimmed.isEmpty)")
self.logger.debug("agents.defaults.workspace updated set=\(!trimmed.isEmpty)")
}
static func gatewayPassword() -> String? {
+269 -55
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)
@@ -387,22 +464,33 @@ struct ConfigSettings: View {
private func loadConfig() async {
let parsed = await ConfigStore.load()
let agent = parsed["agent"] as? [String: Any]
let heartbeatMinutes = agent?["heartbeatMinutes"] as? Int
let heartbeatBody = agent?["heartbeatBody"] as? String
let agents = parsed["agents"] as? [String: Any]
let defaults = agents?["defaults"] as? [String: Any]
let heartbeat = defaults?["heartbeat"] as? [String: Any]
let heartbeatEvery = heartbeat?["every"] as? String
let heartbeatBody = heartbeat?["prompt"] as? String
let browser = parsed["browser"] as? [String: Any]
let talk = parsed["talk"] as? [String: Any]
let loadedModel = (agent?["model"] as? String) ?? ""
let loadedModel: String = {
if let raw = defaults?["model"] as? String { return raw }
if let modelDict = defaults?["model"] as? [String: Any],
let primary = modelDict["primary"] as? String { return primary }
return ""
}()
if !loadedModel.isEmpty {
self.configModel = loadedModel
self.customModel = loadedModel
} else {
self.configModel = SessionLoader.fallbackModel
self.customModel = SessionLoader.fallbackModel
}
if let heartbeatMinutes { self.heartbeatMinutes = heartbeatMinutes }
if let heartbeatEvery {
let digits = heartbeatEvery.trimmingCharacters(in: .whitespacesAndNewlines)
.prefix { $0.isNumber }
if let minutes = Int(digits) {
self.heartbeatMinutes = minutes
}
}
if let heartbeatBody, !heartbeatBody.isEmpty { self.heartbeatBody = heartbeatBody }
if let browser {
@@ -446,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
@@ -459,7 +546,6 @@ struct ConfigSettings: View {
let draft = ConfigDraft(
configModel: configModel,
customModel: customModel,
heartbeatMinutes: heartbeatMinutes,
heartbeatBody: heartbeatBody,
browserEnabled: browserEnabled,
@@ -480,25 +566,48 @@ struct ConfigSettings: View {
@MainActor
private static func buildAndSaveConfig(_ draft: ConfigDraft) async -> String? {
var root = await ConfigStore.load()
var agent = root["agent"] as? [String: Any] ?? [:]
var agents = root["agents"] as? [String: Any] ?? [:]
var defaults = agents["defaults"] as? [String: Any] ?? [:]
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 { agent["model"] = trimmedModel }
if !trimmedModel.isEmpty {
var model = defaults["model"] as? [String: Any] ?? [:]
model["primary"] = trimmedModel
defaults["model"] = model
var models = defaults["models"] as? [String: Any] ?? [:]
if models[trimmedModel] == nil {
models[trimmedModel] = [:]
}
defaults["models"] = models
}
if let heartbeatMinutes = draft.heartbeatMinutes {
agent["heartbeatMinutes"] = heartbeatMinutes
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
heartbeat["every"] = "\(heartbeatMinutes)m"
defaults["heartbeat"] = heartbeat
}
let trimmedBody = draft.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedBody.isEmpty {
agent["heartbeatBody"] = trimmedBody
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
heartbeat["prompt"] = trimmedBody
defaults["heartbeat"] = heartbeat
}
root["agent"] = agent
if defaults.isEmpty {
agents.removeValue(forKey: "defaults")
} else {
agents["defaults"] = defaults
}
if agents.isEmpty {
root.removeValue(forKey: "agents")
} else {
root["agents"] = agents
}
browser["enabled"] = draft.browserEnabled
let trimmedUrl = draft.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -641,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 = []
@@ -670,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
@@ -685,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)
@@ -1,6 +1,7 @@
import ClawdbotKit
import Foundation
import Network
import OSLog
actor MacNodeBridgeSession {
private struct TimeoutError: LocalizedError {
@@ -15,14 +16,20 @@ actor MacNodeBridgeSession {
case failed(message: String)
}
private let logger = Logger(subsystem: "com.clawdbot", category: "node.bridge-session")
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?
private(set) var state: State = .idle
@@ -30,14 +37,22 @@ 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
params.includePeerToPeer = true
let tcpOptions = NWProtocolTCP.Options()
tcpOptions.enableKeepalive = true
tcpOptions.keepaliveIdle = 30
tcpOptions.keepaliveInterval = 15
tcpOptions.keepaliveCount = 3
params.defaultProtocolStack.transportProtocol = tcpOptions
let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session")
self.connection = connection
@@ -47,6 +62,10 @@ actor MacNodeBridgeSession {
connection.start(queue: queue)
try await Self.waitForReady(stateStream, timeoutSeconds: 6)
connection.stateUpdateHandler = { [weak self] state in
guard let self else { return }
Task { await self.handleConnectionState(state) }
}
try await AsyncTimeout.withTimeout(
seconds: 6,
@@ -68,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",
@@ -77,53 +97,72 @@ actor MacNodeBridgeSession {
if base.type == "hello-ok" {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName)
self.startPingLoop()
await onConnected?(ok.serverName)
} 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 "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 {
@@ -182,6 +221,12 @@ actor MacNodeBridgeSession {
}
func disconnect() async {
self.pingTask?.cancel()
self.pingTask = nil
self.lastPongAt = nil
self.disconnectHandler = nil
self.cancelInvokeTasks()
self.connection?.cancel()
self.connection = nil
self.queue = nil
@@ -239,12 +284,17 @@ actor MacNodeBridgeSession {
}
private func send(_ obj: some Encodable) async throws {
guard let connection = self.connection else {
throw NSError(domain: "Bridge", code: 15, userInfo: [
NSLocalizedDescriptionKey: "not connected",
])
}
let data = try self.encoder.encode(obj)
var line = Data()
line.append(data)
line.append(0x0A)
try await withCheckedThrowingContinuation(isolation: self) { (cont: CheckedContinuation<Void, Error>) in
self.connection?.send(content: line, completion: .contentProcessed { err in
connection.send(content: line, completion: .contentProcessed { err in
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
})
}
@@ -280,6 +330,106 @@ 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()
}
}
private func runPingLoop() async {
let interval: Duration = .seconds(15)
let timeout: Duration = .seconds(45)
while !Task.isCancelled {
try? await Task.sleep(for: interval)
guard self.connection != nil else { return }
if let last = self.lastPongAt {
let now = self.clock.now
if now > last.advanced(by: timeout) {
let age = last.duration(to: now)
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
}
}
let id = UUID().uuidString
do {
try await self.send(BridgePing(type: "ping", id: id))
} catch {
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
}
}
}
private func notePong(_ pong: BridgePong) {
_ = pong
self.lastPongAt = self.clock.now
}
private func handleConnectionState(_ state: NWConnection.State) async {
switch state {
case let .failed(error):
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.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)
}
@@ -607,7 +613,7 @@ extension OnboardingView {
let saved = await self.saveAgentWorkspace(AgentWorkspace.displayPath(for: url))
if saved {
self.workspaceStatus =
"Saved to ~/.clawdbot/clawdbot.json (agent.workspace)"
"Saved to ~/.clawdbot/clawdbot.json (agents.defaults.workspace)"
}
}
}
@@ -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",
@@ -69,8 +69,9 @@ extension OnboardingView {
private func loadAgentWorkspace() async -> String? {
let root = await ConfigStore.load()
let agent = root["agent"] as? [String: Any]
return agent?["workspace"] as? String
let agents = root["agents"] as? [String: Any]
let defaults = agents?["defaults"] as? [String: Any]
return defaults?["workspace"] as? String
}
@discardableResult
@@ -86,17 +87,23 @@ extension OnboardingView {
@MainActor
private static func buildAndSaveWorkspace(_ workspace: String?) async -> (Bool, String?) {
var root = await ConfigStore.load()
var agent = root["agent"] as? [String: Any] ?? [:]
var agents = root["agents"] as? [String: Any] ?? [:]
var defaults = agents["defaults"] as? [String: Any] ?? [:]
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
agent.removeValue(forKey: "workspace")
defaults.removeValue(forKey: "workspace")
} else {
agent["workspace"] = trimmed
defaults["workspace"] = trimmed
}
if agent.isEmpty {
root.removeValue(forKey: "agent")
if defaults.isEmpty {
agents.removeValue(forKey: "defaults")
} else {
root["agent"] = agent
agents["defaults"] = defaults
}
if agents.isEmpty {
root.removeValue(forKey: "agents")
} else {
root["agents"] = agents
}
do {
try await ConfigStore.save(root)
@@ -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(
Binary file not shown.
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.8</string>
<string>2026.1.9</string>
<key>CFBundleVersion</key>
<string>20260108</string>
<string>20260109</string>
<key>CFBundleIconFile</key>
<string>Clawdbot</string>
<key>CFBundleURLTypes</key>
@@ -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,11 +421,14 @@ 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?
public let extrasystemprompt: String?
public let idempotencykey: String
public let label: String?
public let spawnedby: String?
public init(
message: String,
@@ -434,11 +437,14 @@ public struct AgentParams: Codable, Sendable {
sessionkey: String?,
thinking: String?,
deliver: Bool?,
attachments: [AnyCodable]?,
provider: String?,
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
idempotencykey: String
idempotencykey: String,
label: String?,
spawnedby: String?
) {
self.message = message
self.to = to
@@ -446,11 +452,14 @@ 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
self.extrasystemprompt = extrasystemprompt
self.idempotencykey = idempotencykey
self.label = label
self.spawnedby = spawnedby
}
private enum CodingKeys: String, CodingKey {
case message
@@ -459,11 +468,14 @@ public struct AgentParams: Codable, Sendable {
case sessionkey = "sessionKey"
case thinking
case deliver
case attachments
case provider
case timeout
case lane
case extrasystemprompt = "extraSystemPrompt"
case idempotencykey = "idempotencyKey"
case label
case spawnedby = "spawnedBy"
}
}
@@ -663,35 +675,78 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int?
public let includeglobal: Bool?
public let includeunknown: Bool?
public let label: String?
public let spawnedby: String?
public let agentid: String?
public init(
limit: Int?,
activeminutes: Int?,
includeglobal: Bool?,
includeunknown: Bool?,
spawnedby: String?
label: String?,
spawnedby: String?,
agentid: String?
) {
self.limit = limit
self.activeminutes = activeminutes
self.includeglobal = includeglobal
self.includeunknown = includeunknown
self.label = label
self.spawnedby = spawnedby
self.agentid = agentid
}
private enum CodingKeys: String, CodingKey {
case limit
case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown"
case label
case spawnedby = "spawnedBy"
case agentid = "agentId"
}
}
public struct SessionsResolveParams: Codable, Sendable {
public let key: String?
public let label: String?
public let agentid: String?
public let spawnedby: String?
public let includeglobal: Bool?
public let includeunknown: Bool?
public init(
key: String?,
label: String?,
agentid: String?,
spawnedby: String?,
includeglobal: Bool?,
includeunknown: Bool?
) {
self.key = key
self.label = label
self.agentid = agentid
self.spawnedby = spawnedby
self.includeglobal = includeglobal
self.includeunknown = includeunknown
}
private enum CodingKeys: String, CodingKey {
case key
case label
case agentid = "agentId"
case spawnedby = "spawnedBy"
case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown"
}
}
public struct SessionsPatchParams: Codable, Sendable {
public let key: String
public let label: AnyCodable?
public let thinkinglevel: AnyCodable?
public let verboselevel: AnyCodable?
public let reasoninglevel: AnyCodable?
public let responseusage: AnyCodable?
public let elevatedlevel: AnyCodable?
public let model: AnyCodable?
public let spawnedby: AnyCodable?
@@ -700,9 +755,11 @@ public struct SessionsPatchParams: Codable, Sendable {
public init(
key: String,
label: AnyCodable?,
thinkinglevel: AnyCodable?,
verboselevel: AnyCodable?,
reasoninglevel: AnyCodable?,
responseusage: AnyCodable?,
elevatedlevel: AnyCodable?,
model: AnyCodable?,
spawnedby: AnyCodable?,
@@ -710,9 +767,11 @@ public struct SessionsPatchParams: Codable, Sendable {
groupactivation: AnyCodable?
) {
self.key = key
self.label = label
self.thinkinglevel = thinkinglevel
self.verboselevel = verboselevel
self.reasoninglevel = reasoninglevel
self.responseusage = responseusage
self.elevatedlevel = elevatedlevel
self.model = model
self.spawnedby = spawnedby
@@ -721,9 +780,11 @@ public struct SessionsPatchParams: Codable, Sendable {
}
private enum CodingKeys: String, CodingKey {
case key
case label
case thinkinglevel = "thinkingLevel"
case verboselevel = "verboseLevel"
case reasoninglevel = "reasoningLevel"
case responseusage = "responseUsage"
case elevatedlevel = "elevatedLevel"
case model
case spawnedby = "spawnedBy"
@@ -1100,6 +1161,51 @@ public struct WebLoginWaitParams: Codable, Sendable {
}
}
public struct AgentSummary: Codable, Sendable {
public let id: String
public let name: String?
public init(
id: String,
name: String?
) {
self.id = id
self.name = name
}
private enum CodingKeys: String, CodingKey {
case id
case name
}
}
public struct AgentsListParams: Codable, Sendable {
}
public struct AgentsListResult: Codable, Sendable {
public let defaultid: String
public let mainkey: String
public let scope: AnyCodable
public let agents: [AgentSummary]
public init(
defaultid: String,
mainkey: String,
scope: AnyCodable,
agents: [AgentSummary]
) {
self.defaultid = defaultid
self.mainkey = mainkey
self.scope = scope
self.agents = agents
}
private enum CodingKeys: String, CodingKey {
case defaultid = "defaultId"
case mainkey = "mainKey"
case scope
case agents
}
}
public struct ModelChoice: Codable, Sendable {
public let id: String
public let name: String
@@ -1527,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)
Loaded 100 of 983 files, more files were not shown because too many files have changed in this diff. Show more