Compare commits

..
Author SHA1 Message Date
Peter Steinberger dcfde2822a fix: refine Slack user token routing (#981) (thanks @jalehman) 2026-01-16 00:11:12 +00:00
Josh Lehman bb568b2a45 feat(slack): add userToken for read-only access to DMs and private channels
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes

User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.

Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 23:57:39 +00:00
Peter SteinbergerandTyler Yust 10eb1beccf fix: tighten session entry updates
Co-authored-by: Tyler Yust <tyler6204@users.noreply.github.com>
2026-01-15 23:44:32 +00:00
Peter SteinbergerandTyler Yust a4b347b454 feat: refine subagents + add chat.inject
Co-authored-by: Tyler Yust <tyler6204@users.noreply.github.com>
2026-01-15 23:44:31 +00:00
Peter SteinbergerandTyler Yust 688a0ce439 refactor: harden session store updates
Co-authored-by: Tyler Yust <tyler6204@users.noreply.github.com>
2026-01-15 23:41:34 +00:00
Peter Steinberger 35492f8513 docs: add inbound debounce config example 2026-01-15 23:20:14 +00:00
Peter Steinberger db9be87d94 refactor: centralize daemon path resolution 2026-01-15 23:19:52 +00:00
juanpablodlcandPeter Steinberger 4a99b9b651 feat(whatsapp): add debounceMs for batching rapid messages (#971)
* feat(whatsapp): add debounceMs for batching rapid messages

Add a `debounceMs` configuration option to WhatsApp channel settings
that batches rapid consecutive messages from the same sender into a
single response. This prevents triggering separate agent runs for
each message when a user sends multiple short messages in quick
succession (e.g., "Hey!", "how are you?", "I was wondering...").

Changes:
- Add `debounceMs` config to WhatsAppConfig and WhatsAppAccountConfig
- Implement message buffering in `monitorWebInbox` with:
  - Map-based buffer keyed by sender (DM) or chat ID (groups)
  - Debounce timer that resets on each new message
  - Message combination with newline separator
  - Single message optimization (no modification if only one message)
- Wire `debounceMs` through account resolution and monitor tuning
- Add UI hints and schema documentation

Usage example:
{
  "channels": {
    "whatsapp": {
      "debounceMs": 5000  // 5 second window
    }
  }
}

Default behavior: `debounceMs: 0` (disabled by default)

Verified: All existing tests pass (3204 tests), TypeScript compilation
succeeds with no errors.

Implemented with assistance from AI coding tools.

Closes #967

* chore: wip inbound debounce

* fix: debounce inbound messages across channels (#971) (thanks @juanpablodlc)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-15 23:07:19 +00:00
Peter Steinberger 7dea403302 chore: purge DS_Store files 2026-01-15 22:59:16 +00:00
Peter Steinberger 10731dfee3 docs: add sync up shorthand 2026-01-15 22:58:34 +00:00
Peter Steinberger d00f2d9c0c test: cover model picker ordering and openrouter index 2026-01-15 22:56:11 +00:00
Peter Steinberger 8b89980a89 feat(date-time): standardize time context and tool timestamps 2026-01-15 22:27:06 +00:00
634a429c50 fix(model-picker): list each provider/model combo separately (#970)
* fix(model-picker): list each provider/model combo separately

Previously, /model grouped models by name and showed all providers
that offer the same model (e.g. 'claude-sonnet-4-5 — anthropic, google-antigravity').
This was confusing because:
1. Users couldn't tell which provider would be used when selecting by number
2. The display implied choice between providers but selection was automatic

Now each provider/model combination is listed separately so users
can explicitly select the exact provider they want.

- Remove model grouping in buildModelPickerItems
- Display format changed from 'model — providers' to 'provider/model'
- pickProviderForModel now returns the single provider directly
- Updated tests to reflect new behavior

* fix: simplify model picker entries (#970) (thanks @mcinteerj)

---------

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

Closes #344

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

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

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

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

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

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

* Make subagent announce structured and include run outcome

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

---------

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

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

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

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

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

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

Co-authored-by: George Pickett <gpickett00@gmail.com>
2026-01-15 03:51:55 +00:00
Peter Steinberger a6e780b2f6 feat: add browser snapshot modes 2026-01-15 03:50:57 +00:00
Peter Steinberger 4e48d0a431 docs: document personal DMs vs public groups 2026-01-15 03:48:14 +00:00
Peter Steinberger c289a88f50 fix(security): force hono 4.11.4 2026-01-15 03:40:02 +00:00
Peter Steinberger 4ce495cb2c Merge pull request #849 from ndraiman/fix/cli-launchd-enable-before-bootstrap
fix(daemon): enable launchd service before bootstrap
2026-01-15 03:36:14 +00:00
Peter Steinberger 6f5fc2276a chore(packaging): align workspace patch version 2026-01-15 03:33:00 +00:00
Cash Williams 5283872e00 Make timezone and 24 hour clock explicit in system prompt 2026-01-13 12:42:37 -06:00
688 changed files with 33569 additions and 5759 deletions

No files matched your search

+6 -1
View File
@@ -6,6 +6,7 @@
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`). - Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
- Tests: colocated `*.test.ts`. - Tests: colocated `*.test.ts`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`. - Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`). - Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
## Docs Linking (Mintlify) ## Docs Linking (Mintlify)
@@ -59,6 +60,9 @@
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list. - When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README. - After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
## Shorthand Commands
- `sync up`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
### PR Workflow (Review vs Land) ### PR Workflow (Review vs Land)
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code. - **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this! - **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
@@ -70,10 +74,11 @@
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples. - Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
## Troubleshooting ## Troubleshooting
- Rebrand/migration issues (Clawdis → Clawdbot) or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`). - Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
## Agent-Specific Notes ## Agent-Specific Notes
- Vocabulary: "makeup" = "mac app". - Vocabulary: "makeup" = "mac app".
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
- When answering questions, respond with high-confidence answers only: verify in code; do not guess. - When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency. - Never update the Carbon dependency.
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`). - Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
+46 -2
View File
@@ -1,16 +1,56 @@
# Changelog # Changelog
## 2026.1.15 ## 2026.1.15 (unreleased)
- Slack: add optional user token routing for read access with safe write fallback. (#981) — thanks @jalehman.
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
## 2026.1.14-1
### Highlights
- Web search: `web_search`/`web_fetch` tools (Brave API) + first-time setup in onboarding/configure.
- Browser control: Chrome extension relay takeover mode + remote browser control via `clawdbot browser serve`.
- Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy.
### Changes ### Changes
- Security: add `clawdbot security audit` (`--deep`) and surface it in `status --all` and `doctor`. - Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor`.
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor` (includes browser control exposure checks).
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`. - Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
- Docs: expand gateway security hardening guidance and incident response checklist. - Docs: expand gateway security hardening guidance and incident response checklist.
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf. - Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia. - Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill.
- CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips.
- Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control via `clawdbot browser serve` + `browser.controlToken`.
### Fixes ### Fixes
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06. - Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06. - Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight. - Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman. - Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman.
@@ -19,6 +59,8 @@
- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer. - Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer.
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr. - Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”). - Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
## 2026.1.14 ## 2026.1.14
@@ -77,6 +119,7 @@
- Memory: new `clawdbot memory` CLI plus `memory_search`/`memory_get` tools with snippets + line ranges; index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default. - Memory: new `clawdbot memory` CLI plus `memory_search`/`memory_get` tools with snippets + line ranges; index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default.
- Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config. - Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config.
- Tools/Sandbox: add tool profiles + group shorthands; support tool-policy groups in `tools.sandbox.tools`; drop legacy `memory` shorthand; allow Docker bind mounts via `docker.binds`. (#790) — thanks @akonyer. - Tools/Sandbox: add tool profiles + group shorthands; support tool-policy groups in `tools.sandbox.tools`; drop legacy `memory` shorthand; allow Docker bind mounts via `docker.binds`. (#790) — thanks @akonyer.
- Tools: add provider/model-specific tool policy overrides (`tools.byProvider`) to trim tool exposure per provider.
- Tools: add browser `scrollintoview` action; allow Claude/Gemini tool param aliases; allow thinking `xhigh` for GPT-5.2/Codex with safe downgrades. (#793) — thanks @hsrvc; (#444) — thanks @grp06. - Tools: add browser `scrollintoview` action; allow Claude/Gemini tool param aliases; allow thinking `xhigh` for GPT-5.2/Codex with safe downgrades. (#793) — thanks @hsrvc; (#444) — thanks @grp06.
- Gateway/CLI: add Tailscale binary discovery, custom bind mode, and probe auth retry; add `clawdbot dashboard` auto-open flow; default native slash commands to `"auto"` with per-provider overrides. (#740) — thanks @jeffersonwarrior. - Gateway/CLI: add Tailscale binary discovery, custom bind mode, and probe auth retry; add `clawdbot dashboard` auto-open flow; default native slash commands to `"auto"` with per-provider overrides. (#740) — thanks @jeffersonwarrior.
- Auth/Onboarding: add Chutes OAuth (PKCE + refresh + onboarding choice); normalize API key inputs; default TUI onboarding to `deliver: false`. (#726) — thanks @FrieSei; (#791) — thanks @roshanasingh4. - Auth/Onboarding: add Chutes OAuth (PKCE + refresh + onboarding choice); normalize API key inputs; default TUI onboarding to `deliver: false`. (#726) — thanks @FrieSei; (#791) — thanks @roshanasingh4.
@@ -89,6 +132,7 @@
### Fixes ### Fixes
- Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds. - Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds.
- Tools: apply global tool allow/deny even when agent-specific tool policy is set.
- Models/Providers: treat credential validation failures as auth errors to trigger fallback; normalize `${ENV_VAR}` apiKey values and auto-fill missing provider keys; preserve explicit GitHub Copilot provider config + agent-dir auth profiles. (#822) — thanks @sebslight; (#705) — thanks @TAGOOZ. - Models/Providers: treat credential validation failures as auth errors to trigger fallback; normalize `${ENV_VAR}` apiKey values and auto-fill missing provider keys; preserve explicit GitHub Copilot provider config + agent-dir auth profiles. (#822) — thanks @sebslight; (#705) — thanks @TAGOOZ.
- Auth: drop invalid auth profiles from ordering so environment keys can still be used for providers like MiniMax. - Auth: drop invalid auth profiles from ordering so environment keys can still be used for providers like MiniMax.
- Gemini: normalize Gemini 3 ids to preview variants; strip Gemini CLI tool call/response ids; downgrade missing `thought_signature`; strip Claude `msg_*` thought_signature fields to avoid base64 decode errors. (#795) — thanks @thewilloftheshadow; (#783) — thanks @ananth-vardhan-cn; (#793) — thanks @hsrvc; (#805) — thanks @marcmarg. - Gemini: normalize Gemini 3 ids to preview variants; strip Gemini CLI tool call/response ids; downgrade missing `thought_signature`; strip Claude `msg_*` thought_signature fields to avoid base64 decode errors. (#795) — thanks @thewilloftheshadow; (#783) — thanks @ananth-vardhan-cn; (#793) — thanks @hsrvc; (#805) — thanks @marcmarg.
Submodule Peekaboo updated: c1243a7978...95ad7532c1.
+22 -18
View File
@@ -468,25 +468,29 @@ AI/vibe-coded PRs welcome! 🤖
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix. Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
Core contributors:
- @cpojer — Telegram onboarding UX + docs
Thanks to all clawtributors: Thanks to all clawtributors:
<p align="left"> <p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/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/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/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></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/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/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/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a>
<a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a>
<a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/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/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a>
<a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="Sebastian Barrios" title="Sebastian Barrios"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a>
<a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a>
<a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a>
<a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a>
<a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a>
<a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/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/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a>
<a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a>
<a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/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/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a>
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a>
<a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/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=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a>
<a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a>
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/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/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/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
<a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
<a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a>
</p> </p>
+15
View File
@@ -0,0 +1,15 @@
# Security Policy
If you believe you’ve found a security issue in Clawdbot, please report it privately.
## Reporting
- Email: `steipete@gmail.com`
- What to include: reproduction steps, impact assessment, and (if possible) a minimal PoC.
## Operational Guidance
For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see:
- `https://docs.clawd.bot/gateway/security`
@@ -34,8 +34,7 @@ extension AttributedString {
var ranges: [Range<AttributedString.Index>] = [] var ranges: [Range<AttributedString.Index>] = []
for wordRange in wordRanges { for wordRange in wordRanges {
if let lastRange = ranges.last, if let lastRange = ranges.last,
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength self[lastRange].characters.count + self[wordRange].characters.count <= maxLength {
{
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
} else { } else {
ranges.append(wordRange) ranges.append(wordRange)
@@ -13,8 +13,7 @@ public actor TranscriptsStore {
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
fileURL = dir.appendingPathComponent("transcripts.log") fileURL = dir.appendingPathComponent("transcripts.log")
if let data = try? Data(contentsOf: fileURL), if let data = try? Data(contentsOf: fileURL),
let text = String(data: data, encoding: .utf8) let text = String(data: data, encoding: .utf8) {
{
entries = text.split(separator: "\n").map(String.init).suffix(limit) entries = text.split(separator: "\n").map(String.init).suffix(limit)
} }
} }
@@ -24,8 +24,7 @@ public struct WakeWordGateConfig: Sendable, Equatable {
public init( public init(
triggers: [String], triggers: [String],
minPostTriggerGap: TimeInterval = 0.45, minPostTriggerGap: TimeInterval = 0.45,
minCommandLength: Int = 1) minCommandLength: Int = 1) {
{
self.triggers = triggers self.triggers = triggers
self.minPostTriggerGap = minPostTriggerGap self.minPostTriggerGap = minPostTriggerGap
self.minCommandLength = minCommandLength self.minCommandLength = minCommandLength
@@ -57,6 +56,12 @@ public enum WakeWordGate {
let tokens: [String] let tokens: [String]
} }
private struct MatchCandidate {
let index: Int
let triggerEnd: TimeInterval
let gap: TimeInterval
}
public static func match( public static func match(
transcript: String, transcript: String,
segments: [WakeWordSegment], segments: [WakeWordSegment],
@@ -68,7 +73,7 @@ public enum WakeWordGate {
let tokens = normalizeSegments(segments) let tokens = normalizeSegments(segments)
guard !tokens.isEmpty else { return nil } guard !tokens.isEmpty else { return nil }
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)? var best: MatchCandidate?
for trigger in triggerTokens { for trigger in triggerTokens {
let count = trigger.tokens.count let count = trigger.tokens.count
@@ -84,7 +89,7 @@ public enum WakeWordGate {
if let best, i <= best.index { continue } if let best, i <= best.index { continue }
best = (i, triggerEnd, gap) best = MatchCandidate(index: i, triggerEnd: triggerEnd, gap: gap)
} }
} }
@@ -24,7 +24,7 @@ enum CLIRegistry {
subcommands: [ subcommands: [
descriptor(for: ServiceInstall.self), descriptor(for: ServiceInstall.self),
descriptor(for: ServiceUninstall.self), descriptor(for: ServiceUninstall.self),
descriptor(for: ServiceStatus.self), descriptor(for: ServiceStatus.self)
]) ])
let doctorDesc = descriptor(for: DoctorCommand.self) let doctorDesc = descriptor(for: DoctorCommand.self)
let setupDesc = descriptor(for: SetupCommand.self) let setupDesc = descriptor(for: SetupCommand.self)
@@ -54,7 +54,7 @@ enum CLIRegistry {
startDesc, startDesc,
stopDesc, stopDesc,
restartDesc, restartDesc,
statusDesc, statusDesc
]) ])
return [root] return [root]
} }
@@ -25,7 +25,7 @@ private enum LaunchdHelper {
"Label": label, "Label": label,
"ProgramArguments": [executable, "serve"], "ProgramArguments": [executable, "serve"],
"RunAtLoad": true, "RunAtLoad": true,
"KeepAlive": true, "KeepAlive": true
] ]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: plistURL) try data.write(to: plistURL)
+112 -67
View File
@@ -25,78 +25,123 @@ private func dispatch(invocation: CommandInvocation) async throws {
switch first { switch first {
case "swabble": case "swabble":
guard path.count >= 2 else { throw CommanderProgramError.missingSubcommand(command: "swabble") } try await dispatchSwabble(parsed: parsed, path: path)
let sub = path[1]
switch sub {
case "serve":
var cmd = ServeCommand(parsed: parsed)
try await cmd.run()
case "transcribe":
var cmd = TranscribeCommand(parsed: parsed)
try await cmd.run()
case "test-hook":
var cmd = TestHookCommand(parsed: parsed)
try await cmd.run()
case "mic":
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "mic") }
let micSub = path[2]
if micSub == "list" {
var cmd = MicList(parsed: parsed)
try await cmd.run()
} else if micSub == "set" {
var cmd = MicSet(parsed: parsed)
try await cmd.run()
} else {
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
}
case "service":
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "service") }
let svcSub = path[2]
switch svcSub {
case "install":
var cmd = ServiceInstall()
try await cmd.run()
case "uninstall":
var cmd = ServiceUninstall()
try await cmd.run()
case "status":
var cmd = ServiceStatus()
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
}
case "doctor":
var cmd = DoctorCommand(parsed: parsed)
try await cmd.run()
case "setup":
var cmd = SetupCommand(parsed: parsed)
try await cmd.run()
case "health":
var cmd = HealthCommand(parsed: parsed)
try await cmd.run()
case "tail-log":
var cmd = TailLogCommand(parsed: parsed)
try await cmd.run()
case "start":
var cmd = StartCommand()
try await cmd.run()
case "stop":
var cmd = StopCommand()
try await cmd.run()
case "restart":
var cmd = RestartCommand()
try await cmd.run()
case "status":
var cmd = StatusCommand()
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
}
default: default:
throw CommanderProgramError.unknownCommand(first) throw CommanderProgramError.unknownCommand(first)
} }
} }
@available(macOS 26.0, *)
@MainActor
private func dispatchSwabble(parsed: ParsedValues, path: [String]) async throws {
let sub = try subcommand(path, index: 1, command: "swabble")
switch sub {
case "mic":
try await dispatchMic(parsed: parsed, path: path)
case "service":
try await dispatchService(path: path)
default:
let handlers = swabbleHandlers(parsed: parsed)
guard let handler = handlers[sub] else {
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
}
try await handler()
}
}
@available(macOS 26.0, *)
@MainActor
private func swabbleHandlers(parsed: ParsedValues) -> [String: () async throws -> Void] {
[
"serve": {
var cmd = ServeCommand(parsed: parsed)
try await cmd.run()
},
"transcribe": {
var cmd = TranscribeCommand(parsed: parsed)
try await cmd.run()
},
"test-hook": {
var cmd = TestHookCommand(parsed: parsed)
try await cmd.run()
},
"doctor": {
var cmd = DoctorCommand(parsed: parsed)
try await cmd.run()
},
"setup": {
var cmd = SetupCommand(parsed: parsed)
try await cmd.run()
},
"health": {
var cmd = HealthCommand(parsed: parsed)
try await cmd.run()
},
"tail-log": {
var cmd = TailLogCommand(parsed: parsed)
try await cmd.run()
},
"start": {
var cmd = StartCommand()
try await cmd.run()
},
"stop": {
var cmd = StopCommand()
try await cmd.run()
},
"restart": {
var cmd = RestartCommand()
try await cmd.run()
},
"status": {
var cmd = StatusCommand()
try await cmd.run()
}
]
}
@available(macOS 26.0, *)
@MainActor
private func dispatchMic(parsed: ParsedValues, path: [String]) async throws {
let micSub = try subcommand(path, index: 2, command: "mic")
switch micSub {
case "list":
var cmd = MicList(parsed: parsed)
try await cmd.run()
case "set":
var cmd = MicSet(parsed: parsed)
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
}
}
@available(macOS 26.0, *)
@MainActor
private func dispatchService(path: [String]) async throws {
let svcSub = try subcommand(path, index: 2, command: "service")
switch svcSub {
case "install":
var cmd = ServiceInstall()
try await cmd.run()
case "uninstall":
var cmd = ServiceUninstall()
try await cmd.run()
case "status":
var cmd = ServiceStatus()
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
}
}
private func subcommand(_ path: [String], index: Int, command: String) throws -> String {
guard path.count > index else {
throw CommanderProgramError.missingSubcommand(command: command)
}
return path[index]
}
if #available(macOS 26.0, *) { if #available(macOS 26.0, *) {
let exitCode = await runCLI() let exitCode = await runCLI()
exit(exitCode) exit(exitCode)
+172 -17
View File
@@ -2,6 +2,178 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0"> <rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel> <channel>
<title>Clawdbot</title> <title>Clawdbot</title>
<item>
<title>2026.1.14-1</title>
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5825</sparkle:version>
<sparkle:shortVersionString>2026.1.14-1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.14-1</h2>
<h3>Highlights</h3>
<ul>
<li>Web search: <code>web_search</code>/<code>web_fetch</code> tools (Brave API) + first-time setup in onboarding/configure.</li>
<li>Browser control: Chrome extension relay takeover mode + remote browser control via <code>clawdbot browser serve</code>.</li>
<li>Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.</li>
<li>Security: expanded <code>clawdbot security audit</code> (+ <code>--fix</code>), detect-secrets CI scan, and a <code>SECURITY.md</code> reporting policy.</li>
</ul>
<h3>Changes</h3>
<h4>Web Tools</h4>
<ul>
<li>Tools: add <code>web_search</code>/<code>web_fetch</code> (Brave API), including helpful setup hints when the key is missing.</li>
<li>Tools: enable <code>web_fetch</code> by default (unless explicitly disabled in config).</li>
<li>CLI/Docs: add <code>clawdbot configure --section web</code> for storing Brave API keys and update onboarding tips.</li>
</ul>
<h4>Browser / Control UI</h4>
<ul>
<li>Browser: add Chrome extension relay takeover mode (toolbar button) + <code>clawdbot browser serve</code> remote control + <code>browser.controlToken</code>.</li>
<li>Browser: ship a built-in <code>chrome</code> profile for extension relay and start the relay automatically when running locally.</li>
<li>Browser: default <code>browser.defaultProfile</code> to <code>chrome</code> (existing Chrome takeover mode).</li>
<li>Browser: add <code>clawdbot browser extension install/path</code> and copy extension path to clipboard.</li>
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
<li>Control UI: show raw any-map entries in config views; move Docs link into the left nav.</li>
</ul>
<h4>Plugins</h4>
<ul>
<li>Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.</li>
<li>Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.</li>
<li>Channels: add Matrix plugin (external) with docs + onboarding hooks.</li>
<li>Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.</li>
</ul>
<h4>Security</h4>
<ul>
<li>Security: expand <code>clawdbot security audit</code> checks and publish a <code>SECURITY.md</code> reporting policy.</li>
<li>Security: extend <code>clawdbot security audit --fix</code> to tighten more sensitive state paths.</li>
<li>Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.</li>
</ul>
<h4>Onboarding / Daemon</h4>
<ul>
<li>Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require <code>--accept-risk</code> for <code>--non-interactive</code>.</li>
<li>Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.</li>
</ul>
<h4>Auth / Usage / Config</h4>
<ul>
<li>Usage: add MiniMax coding plan usage tracking.</li>
<li>Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.</li>
<li>Agents: add optional auth-profile copy prompt on <code>agents add</code> and improve auth error messaging.</li>
<li>Auth: add dynamic template variables to <code>messages.responsePrefix</code>. (#928) — thanks @sebslight.</li>
<li>Config: add <code>channels.<provider>.configWrites</code> gating for channel-initiated config writes; migrate Slack channel IDs.</li>
</ul>
<h4>Channels</h4>
<ul>
<li>Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.</li>
<li>WhatsApp: add <code>channels.whatsapp.sendReadReceipts</code> to disable auto read receipts. (#882) — thanks @chrisrodz.</li>
</ul>
<h4>Docs</h4>
<ul>
<li>Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.</li>
<li>Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.</li>
<li>Docs: expand gateway security hardening guidance and incident response checklist.</li>
<li>Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.</li>
<li>Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)</li>
<li>Docs: add per-command CLI doc pages and link them from <code>clawdbot <command> --help</code>.</li>
<li>Docs: add multi-gateway guide (sidebar + nav).</li>
</ul>
<h3>Fixes</h3>
<h4>Gateway / Daemon / Sessions</h4>
<ul>
<li>Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.</li>
<li>Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare <code>main</code> alias).</li>
<li>Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.</li>
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
<li>Browser: persist role snapshot refs per CDP target so <code>snapshot</code> → <code>act</code> clicks work even if Playwright returns a different Page instance.</li>
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
<li>Daemon: clear persisted launchd disabled state before bootstrap (fixes <code>daemon install</code> after uninstall). (#849) — thanks @ndraiman.</li>
<li>Sessions: return deep clones (<code>structuredClone</code>) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.</li>
<li>Heartbeat: keep <code>updatedAt</code> monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.</li>
<li>Agent: clear run context after CLI runs (<code>clearAgentRunContext</code>) to avoid runaway contexts. (#934) — thanks @ronak-guliani.</li>
<li>Gateway/Dev: ensure <code>pnpm gateway:dev</code> always uses the dev profile config + state (<code>~/.clawdbot-dev</code>).</li>
</ul>
<h4>CLI / Onboarding</h4>
<ul>
<li>Onboarding: show web search setup at the end (not the beginning).</li>
<li>Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.</li>
<li>Health: colorize “not configured” provider lines for easier scanning.</li>
</ul>
<h4>Control UI / TUI</h4>
<ul>
<li>Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)</li>
<li>UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.</li>
<li>UI: always apply <code>?token=</code> from URL (fixes unauthorized after re-onboard).</li>
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
<li>TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.</li>
<li>TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.</li>
<li>TUI: show LLM error messages (rate limits, auth, etc.) instead of <code>(no output)</code>.</li>
</ul>
<h4>Agents / Auth / Tools / Sandbox</h4>
<ul>
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
<li>Logging: tolerate <code>EIO</code> from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.</li>
<li>Sandbox: restore <code>docker.binds</code> config validation and preserve configured PATH for <code>docker exec</code>. (#873) — thanks @akonyer.</li>
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
</ul>
<h4>macOS / Apps</h4>
<ul>
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
<li>macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.</li>
<li>macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)</li>
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
<li>macOS: fix cron preview/testing payload to use <code>channel</code> key. (#867) — thanks @wes-davis.</li>
<li>macOS: update cron testing channel arg. (#896) — thanks @ngutman.</li>
</ul>
<h4>Channels / Messaging</h4>
<ul>
<li>Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)</li>
<li>Slack: respect <code>channels.slack.requireMention</code> default when resolving channel mention gating. (#850) — thanks @evalexpr.</li>
<li>Slack: drop Socket Mode events with mismatched <code>api_app_id</code>/<code>team_id</code>. (#889) — thanks @roshanasingh4.</li>
<li>Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.</li>
<li>Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.</li>
<li>Telegram: honor <code>channels.telegram.timeoutSeconds</code> for grammY API requests. (#863) — thanks @Snaver.</li>
<li>Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).</li>
<li>Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.</li>
<li>Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.</li>
<li>Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.</li>
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
<li>Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.</li>
<li>iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)</li>
<li>WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.</li>
<li>WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.</li>
<li>WhatsApp: harden owner command auth.</li>
<li>Auto-reply: treat trailing <code>NO_REPLY</code> tokens as silent replies.</li>
</ul>
<h4>Config / Doctor / Packaging</h4>
<ul>
<li>Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).</li>
<li>Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).</li>
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
</item>
<item> <item>
<title>2026.1.12-2</title> <title>2026.1.12-2</title>
<pubDate>Tue, 13 Jan 2026 10:05:25 +0000</pubDate> <pubDate>Tue, 13 Jan 2026 10:05:25 +0000</pubDate>
@@ -35,22 +207,5 @@
]]></description> ]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/> <enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/>
</item> </item>
<item>
<title>2026.1.11-2</title>
<pubDate>Mon, 12 Jan 2026 10:25:53 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5210</sparkle:version>
<sparkle:shortVersionString>2026.1.11-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.11-2</h2>
<h3>Fixes</h3>
<ul>
<li>Installer: ensure the CLI entrypoint is executable after npm installs.</li>
<li>Packaging: include <code>dist/plugins/</code> in the npm package to avoid missing module errors.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-2/Clawdbot-2026.1.11-2.zip" length="19860732" type="application/octet-stream" sparkle:edSignature="0UG+d9v3Qf5F9vs/KozUB404WpHjFBQRVoRuhwtzF8kpU7jJmmGlQzh1c61E+LMN4fHcljpxIwHHrvvIfRyrCw=="/>
</item>
</channel> </channel>
</rss> </rss>
@@ -27,6 +27,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val remoteAddress: StateFlow<String?> = runtime.remoteAddress val remoteAddress: StateFlow<String?> = runtime.remoteAddress
val isForeground: StateFlow<Boolean> = runtime.isForeground val isForeground: StateFlow<Boolean> = runtime.isForeground
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
val mainSessionKey: StateFlow<String> = runtime.mainSessionKey
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
@@ -138,7 +139,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.handleCanvasA2UIActionFromWebView(payloadJson) runtime.handleCanvasA2UIActionFromWebView(payloadJson)
} }
fun loadChat(sessionKey: String = "main") { fun loadChat(sessionKey: String) {
runtime.loadChat(sessionKey) runtime.loadChat(sessionKey)
} }
@@ -78,7 +78,7 @@ class NodeRuntime(context: Context) {
payloadJson = payloadJson =
buildJsonObject { buildJsonObject {
put("message", JsonPrimitive(command)) put("message", JsonPrimitive(command))
put("sessionKey", JsonPrimitive(mainSessionKey.value)) put("sessionKey", JsonPrimitive(resolveMainSessionKey()))
put("thinking", JsonPrimitive(chatThinkingLevel.value)) put("thinking", JsonPrimitive(chatThinkingLevel.value))
put("deliver", JsonPrimitive(false)) put("deliver", JsonPrimitive(false))
}.toString(), }.toString(),
@@ -142,12 +142,13 @@ class NodeRuntime(context: Context) {
private val session = private val session =
BridgeSession( BridgeSession(
scope = scope, scope = scope,
onConnected = { name, remote -> onConnected = { name, remote, mainSessionKey ->
_statusText.value = "Connected" _statusText.value = "Connected"
_serverName.value = name _serverName.value = name
_remoteAddress.value = remote _remoteAddress.value = remote
_isConnected.value = true _isConnected.value = true
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
applyMainSessionKey(mainSessionKey)
scope.launch { refreshBrandingFromGateway() } scope.launch { refreshBrandingFromGateway() }
scope.launch { refreshWakeWordsFromGateway() } scope.launch { refreshWakeWordsFromGateway() }
maybeNavigateToA2uiOnConnect() maybeNavigateToA2uiOnConnect()
@@ -172,11 +173,31 @@ class NodeRuntime(context: Context) {
_remoteAddress.value = null _remoteAddress.value = null
_isConnected.value = false _isConnected.value = false
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
_mainSessionKey.value = "main" if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
_mainSessionKey.value = "main"
}
val mainKey = resolveMainSessionKey()
talkMode.setMainSessionKey(mainKey)
chat.applyMainSessionKey(mainKey)
chat.onDisconnected(message) chat.onDisconnected(message)
showLocalCanvasOnDisconnect() showLocalCanvasOnDisconnect()
} }
private fun applyMainSessionKey(candidate: String?) {
val trimmed = candidate?.trim().orEmpty()
if (trimmed.isEmpty()) return
if (isCanonicalMainSessionKey(_mainSessionKey.value)) return
if (_mainSessionKey.value == trimmed) return
_mainSessionKey.value = trimmed
talkMode.setMainSessionKey(trimmed)
chat.applyMainSessionKey(trimmed)
}
private fun resolveMainSessionKey(): String {
val trimmed = _mainSessionKey.value.trim()
return if (trimmed.isEmpty()) "main" else trimmed
}
private fun maybeNavigateToA2uiOnConnect() { private fun maybeNavigateToA2uiOnConnect() {
val a2uiUrl = resolveA2uiHostUrl() ?: return val a2uiUrl = resolveA2uiHostUrl() ?: return
val current = canvas.currentUrl()?.trim().orEmpty() val current = canvas.currentUrl()?.trim().orEmpty()
@@ -559,7 +580,7 @@ class NodeRuntime(context: Context) {
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" } (userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
val contextJson = (userActionObj["context"] as? JsonObject)?.toString() val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
val sessionKey = "main" val sessionKey = resolveMainSessionKey()
val message = val message =
ClawdbotCanvasA2UIAction.formatAgentMessage( ClawdbotCanvasA2UIAction.formatAgentMessage(
actionName = name, actionName = name,
@@ -607,8 +628,9 @@ class NodeRuntime(context: Context) {
} }
} }
fun loadChat(sessionKey: String = "main") { fun loadChat(sessionKey: String) {
chat.load(sessionKey) val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() }
chat.load(key)
} }
fun refreshChat() { fun refreshChat() {
@@ -701,7 +723,7 @@ class NodeRuntime(context: Context) {
val raw = ui?.get("seamColor").asStringOrNull()?.trim() val raw = ui?.get("seamColor").asStringOrNull()?.trim()
val sessionCfg = config?.get("session").asObjectOrNull() val sessionCfg = config?.get("session").asObjectOrNull()
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()) val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
_mainSessionKey.value = mainKey applyMainSessionKey(mainKey)
val parsed = parseHexColorArgb(raw) val parsed = parseHexColorArgb(raw)
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB _seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
@@ -4,3 +4,10 @@ internal fun normalizeMainKey(raw: String?): String {
val trimmed = raw?.trim() val trimmed = raw?.trim()
return if (!trimmed.isNullOrEmpty()) trimmed else "main" return if (!trimmed.isNullOrEmpty()) trimmed else "main"
} }
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
val trimmed = raw?.trim().orEmpty()
if (trimmed.isEmpty()) return false
if (trimmed == "global") return true
return trimmed.startsWith("agent:")
}
@@ -31,7 +31,7 @@ import java.util.concurrent.ConcurrentHashMap
class BridgeSession( class BridgeSession(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val onConnected: (serverName: String, remoteAddress: String?) -> Unit, private val onConnected: (serverName: String, remoteAddress: String?, mainSessionKey: String?) -> Unit,
private val onDisconnected: (message: String) -> Unit, private val onDisconnected: (message: String) -> Unit,
private val onEvent: (event: String, payloadJson: String?) -> Unit, private val onEvent: (event: String, payloadJson: String?) -> Unit,
private val onInvoke: suspend (InvokeRequest) -> InvokeResult, private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
@@ -64,6 +64,7 @@ class BridgeSession(
private val writeLock = Mutex() private val writeLock = Mutex()
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>() private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
@Volatile private var canvasHostUrl: String? = null @Volatile private var canvasHostUrl: String? = null
@Volatile private var mainSessionKey: String? = null
private var desired: Pair<BridgeEndpoint, Hello>? = null private var desired: Pair<BridgeEndpoint, Hello>? = null
private var job: Job? = null private var job: Job? = null
@@ -90,11 +91,13 @@ class BridgeSession(
job?.cancelAndJoin() job?.cancelAndJoin()
job = null job = null
canvasHostUrl = null canvasHostUrl = null
mainSessionKey = null
onDisconnected("Offline") onDisconnected("Offline")
} }
} }
fun currentCanvasHostUrl(): String? = canvasHostUrl fun currentCanvasHostUrl(): String? = canvasHostUrl
fun currentMainSessionKey(): String? = mainSessionKey
suspend fun sendEvent(event: String, payloadJson: String?) { suspend fun sendEvent(event: String, payloadJson: String?) {
val conn = currentConnection ?: return val conn = currentConnection ?: return
@@ -212,7 +215,9 @@ class BridgeSession(
"hello-ok" -> { "hello-ok" -> {
val name = first["serverName"].asStringOrNull() ?: "Bridge" val name = first["serverName"].asStringOrNull() ?: "Bridge"
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null } val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null }
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint) canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
mainSessionKey = rawMainSessionKey
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked". // Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
runCatching { runCatching {
@@ -222,7 +227,7 @@ class BridgeSession(
) )
} }
} }
onConnected(name, conn.remoteAddress) onConnected(name, conn.remoteAddress, rawMainSessionKey)
} }
"error" -> { "error" -> {
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE" val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
@@ -71,12 +71,21 @@ class ChatController(
_sessionId.value = null _sessionId.value = null
} }
fun load(sessionKey: String = "main") { fun load(sessionKey: String) {
val key = sessionKey.trim().ifEmpty { "main" } val key = sessionKey.trim().ifEmpty { "main" }
_sessionKey.value = key _sessionKey.value = key
scope.launch { bootstrap(forceHealth = true) } scope.launch { bootstrap(forceHealth = true) }
} }
fun applyMainSessionKey(mainSessionKey: String) {
val trimmed = mainSessionKey.trim()
if (trimmed.isEmpty()) return
if (_sessionKey.value == trimmed) return
if (_sessionKey.value != "main") return
_sessionKey.value = trimmed
scope.launch { bootstrap(forceHealth = true) }
}
fun refresh() { fun refresh() {
scope.launch { bootstrap(forceHealth = true) } scope.launch { bootstrap(forceHealth = true) }
} }
@@ -44,6 +44,7 @@ import com.clawdbot.android.chat.ChatSessionEntry
fun ChatComposer( fun ChatComposer(
sessionKey: String, sessionKey: String,
sessions: List<ChatSessionEntry>, sessions: List<ChatSessionEntry>,
mainSessionKey: String,
healthOk: Boolean, healthOk: Boolean,
thinkingLevel: String, thinkingLevel: String,
pendingRunCount: Int, pendingRunCount: Int,
@@ -61,7 +62,7 @@ fun ChatComposer(
var showThinkingMenu by remember { mutableStateOf(false) } var showThinkingMenu by remember { mutableStateOf(false) }
var showSessionMenu by remember { mutableStateOf(false) } var showSessionMenu by remember { mutableStateOf(false) }
val sessionOptions = resolveSessionChoices(sessionKey, sessions) val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
val currentSessionLabel = val currentSessionLabel =
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
@@ -33,13 +33,14 @@ fun ChatSheetContent(viewModel: MainViewModel) {
val pendingRunCount by viewModel.pendingRunCount.collectAsState() val pendingRunCount by viewModel.pendingRunCount.collectAsState()
val healthOk by viewModel.chatHealthOk.collectAsState() val healthOk by viewModel.chatHealthOk.collectAsState()
val sessionKey by viewModel.chatSessionKey.collectAsState() val sessionKey by viewModel.chatSessionKey.collectAsState()
val mainSessionKey by viewModel.mainSessionKey.collectAsState()
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState() val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState() val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState() val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val sessions by viewModel.chatSessions.collectAsState() val sessions by viewModel.chatSessions.collectAsState()
LaunchedEffect(Unit) { LaunchedEffect(mainSessionKey) {
viewModel.loadChat("main") viewModel.loadChat(mainSessionKey)
viewModel.refreshChatSessions(limit = 200) viewModel.refreshChatSessions(limit = 200)
} }
@@ -85,6 +86,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
ChatComposer( ChatComposer(
sessionKey = sessionKey, sessionKey = sessionKey,
sessions = sessions, sessions = sessions,
mainSessionKey = mainSessionKey,
healthOk = healthOk, healthOk = healthOk,
thinkingLevel = thinkingLevel, thinkingLevel = thinkingLevel,
pendingRunCount = pendingRunCount, pendingRunCount = pendingRunCount,
@@ -2,20 +2,23 @@ package com.clawdbot.android.ui.chat
import com.clawdbot.android.chat.ChatSessionEntry import com.clawdbot.android.chat.ChatSessionEntry
private const val MAIN_SESSION_KEY = "main"
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
fun resolveSessionChoices( fun resolveSessionChoices(
currentSessionKey: String, currentSessionKey: String,
sessions: List<ChatSessionEntry>, sessions: List<ChatSessionEntry>,
mainSessionKey: String,
nowMs: Long = System.currentTimeMillis(), nowMs: Long = System.currentTimeMillis(),
): List<ChatSessionEntry> { ): List<ChatSessionEntry> {
val current = currentSessionKey.trim() val mainKey = mainSessionKey.trim().ifEmpty { "main" }
val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it }
val aliasKey = if (mainKey == "main") null else "main"
val cutoff = nowMs - RECENT_WINDOW_MS val cutoff = nowMs - RECENT_WINDOW_MS
val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L } val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L }
val recent = mutableListOf<ChatSessionEntry>() val recent = mutableListOf<ChatSessionEntry>()
val seen = mutableSetOf<String>() val seen = mutableSetOf<String>()
for (entry in sorted) { for (entry in sorted) {
if (aliasKey != null && entry.key == aliasKey) continue
if (!seen.add(entry.key)) continue if (!seen.add(entry.key)) continue
if ((entry.updatedAtMs ?: 0L) < cutoff) continue if ((entry.updatedAtMs ?: 0L) < cutoff) continue
recent.add(entry) recent.add(entry)
@@ -23,13 +26,13 @@ fun resolveSessionChoices(
val result = mutableListOf<ChatSessionEntry>() val result = mutableListOf<ChatSessionEntry>()
val included = mutableSetOf<String>() val included = mutableSetOf<String>()
val mainEntry = sorted.firstOrNull { it.key == MAIN_SESSION_KEY } val mainEntry = sorted.firstOrNull { it.key == mainKey }
if (mainEntry != null) { if (mainEntry != null) {
result.add(mainEntry) result.add(mainEntry)
included.add(MAIN_SESSION_KEY) included.add(mainKey)
} else if (current == MAIN_SESSION_KEY) { } else if (current == mainKey) {
result.add(ChatSessionEntry(key = MAIN_SESSION_KEY, updatedAtMs = null)) result.add(ChatSessionEntry(key = mainKey, updatedAtMs = null))
included.add(MAIN_SESSION_KEY) included.add(mainKey)
} }
for (entry in recent) { for (entry in recent) {
@@ -21,6 +21,7 @@ import android.speech.tts.UtteranceProgressListener
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.clawdbot.android.bridge.BridgeSession import com.clawdbot.android.bridge.BridgeSession
import com.clawdbot.android.isCanonicalMainSessionKey
import com.clawdbot.android.normalizeMainKey import com.clawdbot.android.normalizeMainKey
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
@@ -116,6 +117,13 @@ class TalkModeManager(
chatSubscribedSessionKey = null chatSubscribedSessionKey = null
} }
fun setMainSessionKey(sessionKey: String?) {
val trimmed = sessionKey?.trim().orEmpty()
if (trimmed.isEmpty()) return
if (isCanonicalMainSessionKey(mainSessionKey)) return
mainSessionKey = trimmed
}
fun setEnabled(enabled: Boolean) { fun setEnabled(enabled: Boolean) {
if (_isEnabled.value == enabled) return if (_isEnabled.value == enabled) return
_isEnabled.value = enabled _isEnabled.value = enabled
@@ -827,7 +835,9 @@ class TalkModeManager(
val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull() val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
mainSessionKey = mainKey if (!isCanonicalMainSessionKey(mainSessionKey)) {
mainSessionKey = mainKey
}
defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
voiceAliases = aliases voiceAliases = aliases
if (!voiceOverrideActive) currentVoiceId = defaultVoiceId if (!voiceOverrideActive) currentVoiceId = defaultVoiceId
@@ -30,7 +30,7 @@ class BridgeSessionTest {
val session = val session =
BridgeSession( BridgeSession(
scope = scope, scope = scope,
onConnected = { _, _ -> connected.complete(Unit) }, onConnected = { _, _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ }, onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ }, onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) }, onInvoke = { BridgeSession.InvokeResult.ok(null) },
@@ -97,7 +97,7 @@ class BridgeSessionTest {
val session = val session =
BridgeSession( BridgeSession(
scope = scope, scope = scope,
onConnected = { _, _ -> connected.complete(Unit) }, onConnected = { _, _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ }, onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ }, onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) }, onInvoke = { BridgeSession.InvokeResult.ok(null) },
@@ -167,7 +167,7 @@ class BridgeSessionTest {
val session = val session =
BridgeSession( BridgeSession(
scope = scope, scope = scope,
onConnected = { _, _ -> connected.complete(Unit) }, onConnected = { _, _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ }, onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ }, onEvent = { _, _ -> /* ignore */ },
onInvoke = { throw IllegalStateException("FOO_BAR: boom") }, onInvoke = { throw IllegalStateException("FOO_BAR: boom") },
@@ -239,7 +239,7 @@ class BridgeSessionTest {
val session = val session =
BridgeSession( BridgeSession(
scope = scope, scope = scope,
onConnected = { _, _ -> connected.countDown() }, onConnected = { _, _, _ -> connected.countDown() },
onDisconnected = { /* ignore */ }, onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ }, onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) }, onInvoke = { BridgeSession.InvokeResult.ok(null) },
@@ -19,7 +19,7 @@ class SessionFiltersTest {
ChatSessionEntry(key = "recent-2", updatedAtMs = recent2), ChatSessionEntry(key = "recent-2", updatedAtMs = recent2),
) )
val result = resolveSessionChoices("main", sessions, nowMs = now).map { it.key } val result = resolveSessionChoices("main", sessions, mainSessionKey = "main", nowMs = now).map { it.key }
assertEquals(listOf("main", "recent-1", "recent-2"), result) assertEquals(listOf("main", "recent-1", "recent-2"), result)
} }
@@ -29,7 +29,7 @@ class SessionFiltersTest {
val recent = now - 10 * 60 * 1000L val recent = now - 10 * 60 * 1000L
val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent)) val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent))
val result = resolveSessionChoices("custom", sessions, nowMs = now).map { it.key } val result = resolveSessionChoices("custom", sessions, mainSessionKey = "main", nowMs = now).map { it.key }
assertEquals(listOf("main", "custom"), result) assertEquals(listOf("main", "custom"), result)
} }
} }
+10 -2
View File
@@ -26,6 +26,7 @@ actor BridgeSession {
private(set) var state: State = .idle private(set) var state: State = .idle
private var canvasHostUrl: String? private var canvasHostUrl: String?
private var mainSessionKey: String?
func currentCanvasHostUrl() -> String? { func currentCanvasHostUrl() -> String? {
self.canvasHostUrl self.canvasHostUrl
@@ -68,7 +69,7 @@ actor BridgeSession {
func connect( func connect(
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello, hello: BridgeHello,
onConnected: (@Sendable (String) async -> Void)? = nil, onConnected: (@Sendable (String, String?) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
async throws async throws
{ {
@@ -107,7 +108,9 @@ actor BridgeSession {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data) let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName) self.state = .connected(serverName: ok.serverName)
self.canvasHostUrl = ok.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines) self.canvasHostUrl = ok.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines)
await onConnected?(ok.serverName) let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
self.mainSessionKey = (mainKey?.isEmpty == false) ? mainKey : nil
await onConnected?(ok.serverName, self.mainSessionKey)
} else if base.type == "error" { } else if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data) let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
self.state = .failed(message: "\(err.code): \(err.message)") self.state = .failed(message: "\(err.code): \(err.message)")
@@ -217,6 +220,7 @@ actor BridgeSession {
self.queue = nil self.queue = nil
self.buffer = Data() self.buffer = Data()
self.canvasHostUrl = nil self.canvasHostUrl = nil
self.mainSessionKey = nil
let pending = self.pendingRPC.values let pending = self.pendingRPC.values
self.pendingRPC.removeAll() self.pendingRPC.removeAll()
@@ -234,6 +238,10 @@ actor BridgeSession {
self.state = .idle self.state = .idle
} }
func currentMainSessionKey() -> String? {
self.mainSessionKey
}
private func beginRPC( private func beginRPC(
id: String, id: String,
request: BridgeRPCRequest, request: BridgeRPCRequest,
+1 -1
View File
@@ -6,7 +6,7 @@ struct ChatSheet: View {
@State private var viewModel: ClawdbotChatViewModel @State private var viewModel: ClawdbotChatViewModel
private let userAccent: Color? private let userAccent: Color?
init(bridge: BridgeSession, sessionKey: String = "main", userAccent: Color? = nil) { init(bridge: BridgeSession, sessionKey: String, userAccent: Color? = nil) {
let transport = IOSBridgeChatTransport(bridge: bridge) let transport = IOSBridgeChatTransport(bridge: bridge)
self._viewModel = State( self._viewModel = State(
initialValue: ClawdbotChatViewModel( initialValue: ClawdbotChatViewModel(
+27 -5
View File
@@ -109,7 +109,7 @@ final class NodeAppModel {
let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name
let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased() let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
let contextJSON = ClawdbotCanvasA2UIAction.compactJSON(userAction["context"]) let contextJSON = ClawdbotCanvasA2UIAction.compactJSON(userAction["context"])
let sessionKey = "main" let sessionKey = self.mainSessionKey
let messageContext = ClawdbotCanvasA2UIAction.AgentMessageContext( let messageContext = ClawdbotCanvasA2UIAction.AgentMessageContext(
actionName: name, actionName: name,
@@ -232,12 +232,15 @@ final class NodeAppModel {
try await self.bridge.connect( try await self.bridge.connect(
endpoint: endpoint, endpoint: endpoint,
hello: hello, hello: hello,
onConnected: { [weak self] serverName in onConnected: { [weak self] serverName, mainSessionKey in
guard let self else { return } guard let self else { return }
await MainActor.run { await MainActor.run {
self.bridgeStatusText = "Connected" self.bridgeStatusText = "Connected"
self.bridgeServerName = serverName self.bridgeServerName = serverName
} }
await MainActor.run {
self.applyMainSessionKey(mainSessionKey)
}
if let addr = await self.bridge.currentRemoteAddress() { if let addr = await self.bridge.currentRemoteAddress() {
await MainActor.run { await MainActor.run {
self.bridgeRemoteAddress = addr self.bridgeRemoteAddress = addr
@@ -286,7 +289,10 @@ final class NodeAppModel {
self.bridgeRemoteAddress = nil self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil self.connectedBridgeID = nil
self.seamColorHex = nil self.seamColorHex = nil
self.mainSessionKey = "main" if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = "main"
self.talkMode.updateMainSessionKey(self.mainSessionKey)
}
self.showLocalCanvasOnDisconnect() self.showLocalCanvasOnDisconnect()
} }
} }
@@ -303,10 +309,23 @@ final class NodeAppModel {
self.bridgeRemoteAddress = nil self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil self.connectedBridgeID = nil
self.seamColorHex = nil self.seamColorHex = nil
self.mainSessionKey = "main" if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = "main"
self.talkMode.updateMainSessionKey(self.mainSessionKey)
}
self.showLocalCanvasOnDisconnect() self.showLocalCanvasOnDisconnect()
} }
private func applyMainSessionKey(_ key: String?) {
let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let current = self.mainSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
if SessionKey.isCanonicalMainSessionKey(current) { return }
if trimmed == current { return }
self.mainSessionKey = trimmed
self.talkMode.updateMainSessionKey(trimmed)
}
var seamColor: Color { var seamColor: Color {
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
} }
@@ -335,7 +354,10 @@ final class NodeAppModel {
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String) let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
await MainActor.run { await MainActor.run {
self.seamColorHex = raw.isEmpty ? nil : raw self.seamColorHex = raw.isEmpty ? nil : raw
self.mainSessionKey = mainKey if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = mainKey
self.talkMode.updateMainSessionKey(mainKey)
}
} }
} catch { } catch {
// ignore // ignore
+7
View File
@@ -5,4 +5,11 @@ enum SessionKey {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "main" : trimmed return trimmed.isEmpty ? "main" : trimmed
} }
static func isCanonicalMainSessionKey(_ value: String?) -> Bool {
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return false }
if trimmed == "global" { return true }
return trimmed.hasPrefix("agent:")
}
} }
+11 -1
View File
@@ -53,6 +53,13 @@ final class TalkModeManager: NSObject {
self.bridge = bridge self.bridge = bridge
} }
func updateMainSessionKey(_ sessionKey: String?) {
let trimmed = (sessionKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
if SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { return }
self.mainSessionKey = trimmed
}
func setEnabled(_ enabled: Bool) { func setEnabled(_ enabled: Bool) {
self.isEnabled = enabled self.isEnabled = enabled
if enabled { if enabled {
@@ -649,7 +656,10 @@ final class TalkModeManager: NSObject {
guard let config = json["config"] as? [String: Any] else { return } guard let config = json["config"] as? [String: Any] else { return }
let talk = config["talk"] as? [String: Any] let talk = config["talk"] as? [String: Any]
let session = config["session"] as? [String: Any] let session = config["session"] as? [String: Any]
self.mainSessionKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String) let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = mainKey
}
self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let aliases = talk?["voiceAliases"] as? [String: Any] { if let aliases = talk?["voiceAliases"] as? [String: Any] {
var resolved: [String: String] = [:] var resolved: [String: String] = [:]
@@ -307,8 +307,8 @@ enum AgentWorkspace {
} }
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
urls.append(cwd.appendingPathComponent("docs") urls.append(cwd.appendingPathComponent("docs")
.appendingPathComponent(self.templateDirname) .appendingPathComponent(self.templateDirname)
.appendingPathComponent(named)) .appendingPathComponent(named))
return urls return urls
} }
@@ -109,8 +109,8 @@ struct AnthropicAuthControls: View {
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.disabled(self.busy || self.connectionMode != .local || self.code .disabled(self.busy || self.connectionMode != .local || self.code
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty) .isEmpty)
} }
} }
@@ -228,7 +228,7 @@ enum ClawdbotOAuthStore {
static func oauthDir() -> URL { static func oauthDir() -> URL {
if let override = ProcessInfo.processInfo.environment[self.clawdbotOAuthDirEnv]? if let override = ProcessInfo.processInfo.environment[self.clawdbotOAuthDirEnv]?
.trimmingCharacters(in: .whitespacesAndNewlines), .trimmingCharacters(in: .whitespacesAndNewlines),
!override.isEmpty !override.isEmpty
{ {
let expanded = NSString(string: override).expandingTildeInPath let expanded = NSString(string: override).expandingTildeInPath
return URL(fileURLWithPath: expanded, isDirectory: true) return URL(fileURLWithPath: expanded, isDirectory: true)
+4 -4
View File
@@ -264,8 +264,8 @@ final class AppState {
} }
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
let configHasRemoteUrl = !(configRemoteUrl? let configHasRemoteUrl = !(configRemoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true) .isEmpty ?? true)
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey) let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
let resolvedConnectionMode: ConnectionMode = if let configMode { let resolvedConnectionMode: ConnectionMode = if let configMode {
@@ -356,8 +356,8 @@ final class AppState {
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String
let hasRemoteUrl = !(remoteUrl? let hasRemoteUrl = !(remoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true) .isEmpty ?? true)
let desiredMode: ConnectionMode? = switch modeRaw { let desiredMode: ConnectionMode? = switch modeRaw {
case "local": case "local":
@@ -282,7 +282,12 @@ actor BridgeConnectionHandler {
do { do {
try await self.send(BridgePairOk(type: "pair-ok", token: token)) try await self.send(BridgePairOk(type: "pair-ok", token: token))
self.isAuthenticated = true self.isAuthenticated = true
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName)) let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
try await self.send(
BridgeHelloOk(
type: "hello-ok",
serverName: serverName,
mainSessionKey: mainSessionKey))
} catch { } catch {
self.logger.error("bridge send pair-ok failed: \(error.localizedDescription, privacy: .public)") self.logger.error("bridge send pair-ok failed: \(error.localizedDescription, privacy: .public)")
} }
@@ -298,7 +303,12 @@ actor BridgeConnectionHandler {
case .ok: case .ok:
self.isAuthenticated = true self.isAuthenticated = true
do { do {
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName)) let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
try await self.send(
BridgeHelloOk(
type: "hello-ok",
serverName: serverName,
mainSessionKey: mainSessionKey))
} catch { } catch {
self.logger.error("bridge send hello-ok failed: \(error.localizedDescription, privacy: .public)") self.logger.error("bridge send hello-ok failed: \(error.localizedDescription, privacy: .public)")
} }
@@ -182,12 +182,12 @@ actor BridgeServer {
?? "main" ?? "main"
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( _ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: text, message: text,
sessionKey: sessionKey, sessionKey: sessionKey,
thinking: "low", thinking: "low",
deliver: false, deliver: false,
to: nil, to: nil,
channel: .last)) channel: .last))
case "agent.request": case "agent.request":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
@@ -208,12 +208,12 @@ actor BridgeServer {
let channel = GatewayAgentChannel(raw: link.channel) let channel = GatewayAgentChannel(raw: link.channel)
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( _ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: message, message: message,
sessionKey: sessionKey, sessionKey: sessionKey,
thinking: thinking, thinking: thinking,
deliver: link.deliver, deliver: link.deliver,
to: to, to: to,
channel: channel)) channel: channel))
default: default:
break break
@@ -55,7 +55,7 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
guard let name = ClawdbotCanvasA2UIAction.extractActionName(userAction) else { return } guard let name = ClawdbotCanvasA2UIAction.extractActionName(userAction) else { return }
let actionId = let actionId =
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? UUID().uuidString ?? UUID().uuidString
canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)") canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)")
@@ -39,13 +39,13 @@ final class CanvasFileWatcher: @unchecked Sendable {
kFSEventStreamCreateFlagNoDefer) kFSEventStreamCreateFlagNoDefer)
guard let stream = FSEventStreamCreate( guard let stream = FSEventStreamCreate(
kCFAllocatorDefault, kCFAllocatorDefault,
Self.callback, Self.callback,
&context, &context,
paths, paths,
FSEventStreamEventId(kFSEventStreamEventIdSinceNow), FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
0.05, 0.05,
flags) flags)
else { else {
retainedSelf.release() retainedSelf.release()
return return
@@ -242,8 +242,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
} }
guard let url = CanvasScheme.makeURL( guard let url = CanvasScheme.makeURL(
session: CanvasWindowController.sanitizeSessionKey(self.sessionKey), session: CanvasWindowController.sanitizeSessionKey(self.sessionKey),
path: trimmed) path: trimmed)
else { else {
canvasWindowLogger canvasWindowLogger
.error( .error(
@@ -125,13 +125,13 @@ enum CommandResolver {
// fnm // fnm
bins.append(contentsOf: self.versionedNodeBinPaths( bins.append(contentsOf: self.versionedNodeBinPaths(
base: home.appendingPathComponent(".local/share/fnm/node-versions"), base: home.appendingPathComponent(".local/share/fnm/node-versions"),
suffix: "installation/bin")) suffix: "installation/bin"))
// nvm // nvm
bins.append(contentsOf: self.versionedNodeBinPaths( bins.append(contentsOf: self.versionedNodeBinPaths(
base: home.appendingPathComponent(".nvm/versions/node"), base: home.appendingPathComponent(".nvm/versions/node"),
suffix: "bin")) suffix: "bin"))
return bins return bins
} }
@@ -45,13 +45,13 @@ final class ConfigFileWatcher: @unchecked Sendable {
kFSEventStreamCreateFlagNoDefer) kFSEventStreamCreateFlagNoDefer)
guard let stream = FSEventStreamCreate( guard let stream = FSEventStreamCreate(
kCFAllocatorDefault, kCFAllocatorDefault,
Self.callback, Self.callback,
&context, &context,
paths, paths,
FSEventStreamEventId(kFSEventStreamEventIdSinceNow), FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
0.05, 0.05,
flags) flags)
else { else {
retainedSelf.release() retainedSelf.release()
return return
@@ -10,7 +10,7 @@ struct ConfigSettings: View {
"When enabled, the browser server will only connect if the clawd browser is already running." "When enabled, the browser server will only connect if the clawd browser is already running."
private static let browserProfileNote = private static let browserProfileNote =
"Clawd uses a separate Chrome profile and ports (default 18791/18792) " "Clawd uses a separate Chrome profile and ports (default 18791/18792) "
+ "so it won’t interfere with your daily browser." + "so it won’t interfere with your daily browser."
@State private var configModel: String = "" @State private var configModel: String = ""
@State private var configSaving = false @State private var configSaving = false
@State private var hasLoaded = false @State private var hasLoaded = false
@@ -97,8 +97,8 @@ extension ConfigSettings {
Text("Clawdbot CLI config") Text("Clawdbot CLI config")
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
Text(self.isNixMode Text(self.isNixMode
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild." ? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
: "Edit ~/.clawdbot/clawdbot.json (agent / session / routing / messages).") : "Edit ~/.clawdbot/clawdbot.json (agent / session / routing / messages).")
.font(.callout) .font(.callout)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -753,9 +753,9 @@ extension ConfigSettings {
do { do {
let res: ModelsListResult = let res: ModelsListResult =
try await GatewayConnection.shared try await GatewayConnection.shared
.requestDecoded( .requestDecoded(
method: .modelsList, method: .modelsList,
timeoutMs: 15000) timeoutMs: 15000)
self.models = res.models self.models = res.models
self.modelsSourceLabel = "gateway" self.modelsSourceLabel = "gateway"
} catch { } catch {
@@ -792,8 +792,8 @@ extension ConfigSettings {
choice.provider, choice.provider,
self.modelRef(for: choice), self.modelRef(for: choice),
] ]
.joined(separator: " ") .joined(separator: " ")
.lowercased() .lowercased()
return tokens.allSatisfy { haystack.contains($0) } return tokens.allSatisfy { haystack.contains($0) }
} }
} }
+10 -1
View File
@@ -19,6 +19,7 @@ enum ConfigStore {
} }
private static let overrideStore = OverrideStore() private static let overrideStore = OverrideStore()
@MainActor private static var lastHash: String?
private static func isRemoteMode() async -> Bool { private static func isRemoteMode() async -> Bool {
let overrides = await self.overrideStore.overrides let overrides = await self.overrideStore.overrides
@@ -75,6 +76,7 @@ enum ConfigStore {
method: .configGet, method: .configGet,
params: nil, params: nil,
timeoutMs: 8000) timeoutMs: 8000)
self.lastHash = snap.hash
return snap.config?.mapValues { $0.foundationValue } ?? [:] return snap.config?.mapValues { $0.foundationValue } ?? [:]
} catch { } catch {
return nil return nil
@@ -83,17 +85,24 @@ enum ConfigStore {
@MainActor @MainActor
private static func saveToGateway(_ root: [String: Any]) async throws { private static func saveToGateway(_ root: [String: Any]) async throws {
if self.lastHash == nil {
_ = await self.loadFromGateway()
}
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else { guard let raw = String(data: data, encoding: .utf8) else {
throw NSError(domain: "ConfigStore", code: 1, userInfo: [ throw NSError(domain: "ConfigStore", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode config.", NSLocalizedDescriptionKey: "Failed to encode config.",
]) ])
} }
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)] var params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
if let baseHash = self.lastHash {
params["baseHash"] = AnyCodable(baseHash)
}
_ = try await GatewayConnection.shared.requestRaw( _ = try await GatewayConnection.shared.requestRaw(
method: .configSet, method: .configSet,
params: params, params: params,
timeoutMs: 10000) timeoutMs: 10000)
_ = await self.loadFromGateway()
} }
#if DEBUG #if DEBUG
@@ -53,8 +53,8 @@ final class ConnectionModeCoordinator {
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
let settings = CommandResolver.connectionSettings() let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote( try await ControlChannel.shared.configure(mode: .remote(
target: settings.target, target: settings.target,
identity: settings.identity)) identity: settings.identity))
} catch { } catch {
self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)") self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)")
} }
@@ -377,18 +377,18 @@ extension ConnectionsSettings {
case .telegram: case .telegram:
return self return self
.date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)? .date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
.lastProbeAt) .lastProbeAt)
case .discord: case .discord:
return self return self
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)? .date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.lastProbeAt) .lastProbeAt)
case .signal: case .signal:
return self return self
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt) .date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
case .imessage: case .imessage:
return self return self
.date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)? .date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)?
.lastProbeAt) .lastProbeAt)
} }
} }
@@ -2,6 +2,16 @@ import ClawdbotProtocol
import Foundation import Foundation
extension ConnectionsStore { extension ConnectionsStore {
var isTelegramTokenLocked: Bool {
self.snapshot?.decodeChannel("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
.tokenSource == "env"
}
var isDiscordTokenLocked: Bool {
self.snapshot?.decodeChannel("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.tokenSource == "env"
}
func loadConfig() async { func loadConfig() async {
do { do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded( let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
@@ -12,6 +22,7 @@ extension ConnectionsStore {
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json." ? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
: nil : nil
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:] self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
self.configHash = snap.hash
self.configLoaded = true self.configLoaded = true
self.applyUIConfig(snap) self.applyUIConfig(snap)
@@ -27,17 +38,30 @@ extension ConnectionsStore {
private func applyUIConfig(_ snap: ConfigSnapshot) { private func applyUIConfig(_ snap: ConfigSnapshot) {
let ui = snap.config?[ let ui = snap.config?[
"ui", "ui",
]?.dictionaryValue ]?.dictionaryValue
let rawSeam = ui?[ let rawSeam = ui?[
"seamColor", "seamColor",
]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" ]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
} }
private func resolveChannelConfig(_ snap: ConfigSnapshot, key: String) -> [String: AnyCodable]? {
if let channels = snap.config?["channels"]?.dictionaryValue,
let entry = channels[key]?.dictionaryValue
{
return entry
}
return snap.config?[key]?.dictionaryValue
}
private func applyTelegramConfig(_ snap: ConfigSnapshot) { private func applyTelegramConfig(_ snap: ConfigSnapshot) {
let telegram = snap.config?["telegram"]?.dictionaryValue let telegram = self.resolveChannelConfig(snap, key: "telegram")
self.telegramToken = telegram?["botToken"]?.stringValue ?? "" self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true let groups = telegram?["groups"]?.dictionaryValue
let defaultGroup = groups?["*"]?.dictionaryValue
self.telegramRequireMention = defaultGroup?["requireMention"]?.boolValue
?? telegram?["requireMention"]?.boolValue
?? true
self.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue) self.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue)
self.telegramProxy = telegram?["proxy"]?.stringValue ?? "" self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? "" self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
@@ -46,7 +70,7 @@ extension ConnectionsStore {
} }
private func applyDiscordConfig(_ snap: ConfigSnapshot) { private func applyDiscordConfig(_ snap: ConfigSnapshot) {
let discord = snap.config?["discord"]?.dictionaryValue let discord = self.resolveChannelConfig(snap, key: "discord")
self.discordEnabled = discord?["enabled"]?.boolValue ?? true self.discordEnabled = discord?["enabled"]?.boolValue ?? true
self.discordToken = discord?["token"]?.stringValue ?? "" self.discordToken = discord?["token"]?.stringValue ?? ""
@@ -122,7 +146,7 @@ extension ConnectionsStore {
} }
private func applySignalConfig(_ snap: ConfigSnapshot) { private func applySignalConfig(_ snap: ConfigSnapshot) {
let signal = snap.config?["signal"]?.dictionaryValue let signal = self.resolveChannelConfig(snap, key: "signal")
self.signalEnabled = signal?["enabled"]?.boolValue ?? true self.signalEnabled = signal?["enabled"]?.boolValue ?? true
self.signalAccount = signal?["account"]?.stringValue ?? "" self.signalAccount = signal?["account"]?.stringValue ?? ""
self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? "" self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? ""
@@ -139,7 +163,7 @@ extension ConnectionsStore {
} }
private func applyIMessageConfig(_ snap: ConfigSnapshot) { private func applyIMessageConfig(_ snap: ConfigSnapshot) {
let imessage = snap.config?["imessage"]?.dictionaryValue let imessage = self.resolveChannelConfig(snap, key: "imessage")
self.imessageEnabled = imessage?["enabled"]?.boolValue ?? true self.imessageEnabled = imessage?["enabled"]?.boolValue ?? true
self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? "" self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? ""
self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? "" self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? ""
@@ -150,6 +174,15 @@ extension ConnectionsStore {
self.imessageMediaMaxMb = self.numberString(from: imessage?["mediaMaxMb"]) self.imessageMediaMaxMb = self.numberString(from: imessage?["mediaMaxMb"])
} }
private func channelConfigRoot(for key: String) -> [String: Any] {
if let channels = self.configRoot["channels"] as? [String: Any],
let entry = channels[key] as? [String: Any]
{
return entry
}
return self.configRoot[key] as? [String: Any] ?? [:]
}
func saveTelegramConfig() async { func saveTelegramConfig() async {
guard !self.isSavingConfig else { return } guard !self.isSavingConfig else { return }
self.isSavingConfig = true self.isSavingConfig = true
@@ -158,30 +191,24 @@ extension ConnectionsStore {
await self.loadConfig() await self.loadConfig()
} }
var telegram: [String: Any] = (self.configRoot["telegram"] as? [String: Any]) ?? [:] var telegram: [String: Any] = [:]
let token = self.trimmed(self.telegramToken) if !self.isTelegramTokenLocked {
if token.isEmpty { self.setPatchString(&telegram, key: "botToken", value: self.telegramToken)
telegram.removeValue(forKey: "botToken")
} else {
telegram["botToken"] = token
} }
telegram["requireMention"] = NSNull()
telegram["requireMention"] = self.telegramRequireMention telegram["groups"] = [
"*": [
"requireMention": self.telegramRequireMention,
],
]
let allow = self.splitCsv(self.telegramAllowFrom) let allow = self.splitCsv(self.telegramAllowFrom)
if allow.isEmpty { self.setPatchList(&telegram, key: "allowFrom", values: allow)
telegram.removeValue(forKey: "allowFrom") self.setPatchString(&telegram, key: "proxy", value: self.telegramProxy)
} else { self.setPatchString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
telegram["allowFrom"] = allow self.setPatchString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
} self.setPatchString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
self.setOptionalString(&telegram, key: "proxy", value: self.telegramProxy) await self.persistChannelPatch("telegram", payload: telegram)
self.setOptionalString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
self.setOptionalString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
self.setOptionalString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
self.setSection("telegram", payload: telegram)
await self.persistConfig()
} }
func saveDiscordConfig() async { func saveDiscordConfig() async {
@@ -192,9 +219,9 @@ extension ConnectionsStore {
await self.loadConfig() await self.loadConfig()
} }
let discord = self.buildDiscordConfig() let base = self.channelConfigRoot(for: "discord")
self.setSection("discord", payload: discord) let discord = self.buildDiscordPatch(base: base)
await self.persistConfig() await self.persistChannelPatch("discord", payload: discord)
} }
func saveSignalConfig() async { func saveSignalConfig() async {
@@ -205,42 +232,23 @@ extension ConnectionsStore {
await self.loadConfig() await self.loadConfig()
} }
var signal: [String: Any] = (self.configRoot["signal"] as? [String: Any]) ?? [:] var signal: [String: Any] = [:]
if self.signalEnabled { self.setPatchBool(&signal, key: "enabled", value: self.signalEnabled, defaultValue: true)
signal.removeValue(forKey: "enabled") self.setPatchString(&signal, key: "account", value: self.signalAccount)
} else { self.setPatchString(&signal, key: "httpUrl", value: self.signalHttpUrl)
signal["enabled"] = false self.setPatchString(&signal, key: "httpHost", value: self.signalHttpHost)
} self.setPatchNumber(&signal, key: "httpPort", value: self.signalHttpPort)
self.setPatchString(&signal, key: "cliPath", value: self.signalCliPath)
self.setOptionalString(&signal, key: "account", value: self.signalAccount) self.setPatchBool(&signal, key: "autoStart", value: self.signalAutoStart, defaultValue: true)
self.setOptionalString(&signal, key: "httpUrl", value: self.signalHttpUrl) self.setPatchString(&signal, key: "receiveMode", value: self.signalReceiveMode)
self.setOptionalString(&signal, key: "httpHost", value: self.signalHttpHost) self.setPatchBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments, defaultValue: false)
self.setOptionalNumber(&signal, key: "httpPort", value: self.signalHttpPort) self.setPatchBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories, defaultValue: false)
self.setOptionalString(&signal, key: "cliPath", value: self.signalCliPath) self.setPatchBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts, defaultValue: false)
if self.signalAutoStart {
signal.removeValue(forKey: "autoStart")
} else {
signal["autoStart"] = false
}
self.setOptionalString(&signal, key: "receiveMode", value: self.signalReceiveMode)
self.setOptionalBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments)
self.setOptionalBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories)
self.setOptionalBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts)
let allow = self.splitCsv(self.signalAllowFrom) let allow = self.splitCsv(self.signalAllowFrom)
if allow.isEmpty { self.setPatchList(&signal, key: "allowFrom", values: allow)
signal.removeValue(forKey: "allowFrom") self.setPatchNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
} else {
signal["allowFrom"] = allow
}
self.setOptionalNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb) await self.persistChannelPatch("signal", payload: signal)
self.setSection("signal", payload: signal)
await self.persistConfig()
} }
func saveIMessageConfig() async { func saveIMessageConfig() async {
@@ -251,147 +259,168 @@ extension ConnectionsStore {
await self.loadConfig() await self.loadConfig()
} }
var imessage: [String: Any] = (self.configRoot["imessage"] as? [String: Any]) ?? [:] var imessage: [String: Any] = [:]
if self.imessageEnabled { self.setPatchBool(&imessage, key: "enabled", value: self.imessageEnabled, defaultValue: true)
imessage.removeValue(forKey: "enabled") self.setPatchString(&imessage, key: "cliPath", value: self.imessageCliPath)
} else { self.setPatchString(&imessage, key: "dbPath", value: self.imessageDbPath)
imessage["enabled"] = false
}
self.setOptionalString(&imessage, key: "cliPath", value: self.imessageCliPath)
self.setOptionalString(&imessage, key: "dbPath", value: self.imessageDbPath)
let service = self.trimmed(self.imessageService) let service = self.trimmed(self.imessageService)
if service.isEmpty || service == "auto" { if service.isEmpty || service == "auto" {
imessage.removeValue(forKey: "service") imessage["service"] = NSNull()
} else { } else {
imessage["service"] = service imessage["service"] = service
} }
self.setOptionalString(&imessage, key: "region", value: self.imessageRegion) self.setPatchString(&imessage, key: "region", value: self.imessageRegion)
let allow = self.splitCsv(self.imessageAllowFrom) let allow = self.splitCsv(self.imessageAllowFrom)
if allow.isEmpty { self.setPatchList(&imessage, key: "allowFrom", values: allow)
imessage.removeValue(forKey: "allowFrom")
} else {
imessage["allowFrom"] = allow
}
self.setOptionalBool(&imessage, key: "includeAttachments", value: self.imessageIncludeAttachments) self.setPatchBool(
self.setOptionalNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb) &imessage,
key: "includeAttachments",
value: self.imessageIncludeAttachments,
defaultValue: false)
self.setPatchNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
self.setSection("imessage", payload: imessage) await self.persistChannelPatch("imessage", payload: imessage)
await self.persistConfig()
} }
private func buildDiscordConfig() -> [String: Any] { private func buildDiscordPatch(base: [String: Any]) -> [String: Any] {
var discord: [String: Any] = (self.configRoot["discord"] as? [String: Any]) ?? [:] var discord: [String: Any] = [:]
if self.discordEnabled { self.setPatchBool(&discord, key: "enabled", value: self.discordEnabled, defaultValue: true)
discord.removeValue(forKey: "enabled") if !self.isDiscordTokenLocked {
} else { self.setPatchString(&discord, key: "token", value: self.discordToken)
discord["enabled"] = false
} }
self.setOptionalString(&discord, key: "token", value: self.discordToken)
if let dm = self.buildDiscordDmConfig(base: discord["dm"] as? [String: Any] ?? [:]) { if let dm = self.buildDiscordDmPatch() {
discord["dm"] = dm discord["dm"] = dm
} else { } else {
discord.removeValue(forKey: "dm") discord["dm"] = NSNull()
} }
self.setOptionalNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb) self.setPatchNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
self.setOptionalInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true) self.setPatchInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
self.setOptionalInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false) self.setPatchInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
let replyToMode = self.trimmed(self.discordReplyToMode) let replyToMode = self.trimmed(self.discordReplyToMode)
if replyToMode.isEmpty || replyToMode == "off" { if replyToMode.isEmpty || replyToMode == "off" || !["first", "all"].contains(replyToMode) {
discord.removeValue(forKey: "replyToMode") discord["replyToMode"] = NSNull()
} else if ["first", "all"].contains(replyToMode) {
discord["replyToMode"] = replyToMode
} else { } else {
discord.removeValue(forKey: "replyToMode") discord["replyToMode"] = replyToMode
} }
if let guilds = self.buildDiscordGuildsConfig() { let baseGuilds = base["guilds"] as? [String: Any] ?? [:]
if let guilds = self.buildDiscordGuildsPatch(base: baseGuilds) {
discord["guilds"] = guilds discord["guilds"] = guilds
} else { } else {
discord.removeValue(forKey: "guilds") discord["guilds"] = NSNull()
} }
if let actions = self.buildDiscordActionsConfig(base: discord["actions"] as? [String: Any] ?? [:]) { if let actions = self.buildDiscordActionsPatch() {
discord["actions"] = actions discord["actions"] = actions
} else { } else {
discord.removeValue(forKey: "actions") discord["actions"] = NSNull()
} }
if let slash = self.buildDiscordSlashConfig(base: discord["slashCommand"] as? [String: Any] ?? [:]) { if let slash = self.buildDiscordSlashPatch() {
discord["slashCommand"] = slash discord["slashCommand"] = slash
} else { } else {
discord.removeValue(forKey: "slashCommand") discord["slashCommand"] = NSNull()
} }
return discord return discord
} }
private func buildDiscordDmConfig(base: [String: Any]) -> [String: Any]? { private func buildDiscordDmPatch() -> [String: Any]? {
var dm = base var dm: [String: Any] = [:]
if self.discordDmEnabled { self.setPatchBool(&dm, key: "enabled", value: self.discordDmEnabled, defaultValue: true)
dm.removeValue(forKey: "enabled")
} else {
dm["enabled"] = false
}
let allow = self.splitCsv(self.discordAllowFrom) let allow = self.splitCsv(self.discordAllowFrom)
if allow.isEmpty { self.setPatchList(&dm, key: "allowFrom", values: allow)
dm.removeValue(forKey: "allowFrom") self.setPatchBool(&dm, key: "groupEnabled", value: self.discordGroupEnabled, defaultValue: false)
} else {
dm["allowFrom"] = allow
}
if self.discordGroupEnabled {
dm["groupEnabled"] = true
} else {
dm.removeValue(forKey: "groupEnabled")
}
let groupChannels = self.splitCsv(self.discordGroupChannels) let groupChannels = self.splitCsv(self.discordGroupChannels)
if groupChannels.isEmpty { self.setPatchList(&dm, key: "groupChannels", values: groupChannels)
dm.removeValue(forKey: "groupChannels")
} else {
dm["groupChannels"] = groupChannels
}
return dm.isEmpty ? nil : dm return dm.isEmpty ? nil : dm
} }
private func buildDiscordGuildsConfig() -> [String: Any]? { private func buildDiscordGuildsPatch(base: [String: Any]) -> Any? {
let guilds: [String: Any] = self.discordGuilds.reduce(into: [:]) { result, entry in if self.discordGuilds.isEmpty {
let key = self.trimmed(entry.key) return NSNull()
guard !key.isEmpty else { return }
var payload: [String: Any] = [:]
let slug = self.trimmed(entry.slug)
if !slug.isEmpty { payload["slug"] = slug }
if entry.requireMention { payload["requireMention"] = true }
if ["off", "own", "all", "allowlist"].contains(entry.reactionNotifications) {
payload["reactionNotifications"] = entry.reactionNotifications
}
let users = self.splitCsv(entry.users)
if !users.isEmpty { payload["users"] = users }
let channels: [String: Any] = entry.channels.reduce(into: [:]) { channelsResult, channel in
let channelKey = self.trimmed(channel.key)
guard !channelKey.isEmpty else { return }
var channelPayload: [String: Any] = [:]
if !channel.allow { channelPayload["allow"] = false }
if channel.requireMention { channelPayload["requireMention"] = true }
channelsResult[channelKey] = channelPayload
}
if !channels.isEmpty { payload["channels"] = channels }
result[key] = payload
} }
return guilds.isEmpty ? nil : guilds var patch: [String: Any] = [:]
let baseKeys = Set(base.keys)
var formKeys = Set<String>()
for entry in self.discordGuilds {
let key = self.trimmed(entry.key)
guard !key.isEmpty else { continue }
formKeys.insert(key)
let baseGuild = base[key] as? [String: Any] ?? [:]
patch[key] = self.buildDiscordGuildPatch(entry, base: baseGuild)
}
for key in baseKeys.subtracting(formKeys) {
patch[key] = NSNull()
}
return patch.isEmpty ? NSNull() : patch
} }
private func buildDiscordActionsConfig(base: [String: Any]) -> [String: Any]? { private func buildDiscordGuildPatch(_ entry: DiscordGuildForm, base: [String: Any]) -> [String: Any] {
var actions = base var payload: [String: Any] = [:]
let slug = self.trimmed(entry.slug)
if slug.isEmpty {
payload["slug"] = NSNull()
} else {
payload["slug"] = slug
}
if entry.requireMention {
payload["requireMention"] = true
} else {
payload["requireMention"] = NSNull()
}
if ["off", "all", "allowlist"].contains(entry.reactionNotifications) {
payload["reactionNotifications"] = entry.reactionNotifications
} else {
payload["reactionNotifications"] = NSNull()
}
let users = self.splitCsv(entry.users)
self.setPatchList(&payload, key: "users", values: users)
let baseChannels = base["channels"] as? [String: Any] ?? [:]
if let channels = self.buildDiscordChannelsPatch(base: baseChannels, forms: entry.channels) {
payload["channels"] = channels
} else {
payload["channels"] = NSNull()
}
return payload
}
private func buildDiscordChannelsPatch(base: [String: Any], forms: [DiscordGuildChannelForm]) -> Any? {
if forms.isEmpty {
return NSNull()
}
var patch: [String: Any] = [:]
let baseKeys = Set(base.keys)
var formKeys = Set<String>()
for channel in forms {
let channelKey = self.trimmed(channel.key)
guard !channelKey.isEmpty else { continue }
formKeys.insert(channelKey)
var channelPayload: [String: Any] = [:]
self.setPatchBool(&channelPayload, key: "allow", value: channel.allow, defaultValue: true)
self.setPatchBool(
&channelPayload,
key: "requireMention",
value: channel.requireMention,
defaultValue: false)
patch[channelKey] = channelPayload
}
for key in baseKeys.subtracting(formKeys) {
patch[key] = NSNull()
}
return patch.isEmpty ? NSNull() : patch
}
private func buildDiscordActionsPatch() -> [String: Any]? {
var actions: [String: Any] = [:]
self.setAction(&actions, key: "reactions", value: self.discordActionReactions, defaultValue: true) self.setAction(&actions, key: "reactions", value: self.discordActionReactions, defaultValue: true)
self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true) self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true)
self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true) self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true)
@@ -410,52 +439,44 @@ extension ConnectionsStore {
return actions.isEmpty ? nil : actions return actions.isEmpty ? nil : actions
} }
private func buildDiscordSlashConfig(base: [String: Any]) -> [String: Any]? { private func buildDiscordSlashPatch() -> [String: Any]? {
var slash = base var slash: [String: Any] = [:]
if self.discordSlashEnabled { self.setPatchBool(&slash, key: "enabled", value: self.discordSlashEnabled, defaultValue: false)
slash["enabled"] = true self.setPatchString(&slash, key: "name", value: self.discordSlashName)
} else { self.setPatchString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
slash.removeValue(forKey: "enabled") self.setPatchBool(&slash, key: "ephemeral", value: self.discordSlashEphemeral, defaultValue: true)
}
self.setOptionalString(&slash, key: "name", value: self.discordSlashName)
self.setOptionalString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
if self.discordSlashEphemeral {
slash.removeValue(forKey: "ephemeral")
} else {
slash["ephemeral"] = false
}
return slash.isEmpty ? nil : slash return slash.isEmpty ? nil : slash
} }
private func persistConfig() async { private func persistChannelPatch(_ channelId: String, payload: [String: Any]) async {
do { do {
guard let baseHash = self.configHash else {
self.configStatus = "Config hash missing; reload and retry."
return
}
let data = try JSONSerialization.data( let data = try JSONSerialization.data(
withJSONObject: self.configRoot, withJSONObject: ["channels": [channelId: payload]],
options: [.prettyPrinted, .sortedKeys]) options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else { guard let raw = String(data: data, encoding: .utf8) else {
self.configStatus = "Failed to encode config." self.configStatus = "Failed to encode config."
return return
} }
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)] let params: [String: AnyCodable] = [
"raw": AnyCodable(raw),
"baseHash": AnyCodable(baseHash),
]
_ = try await GatewayConnection.shared.requestRaw( _ = try await GatewayConnection.shared.requestRaw(
method: .configSet, method: .configPatch,
params: params, params: params,
timeoutMs: 10000) timeoutMs: 10000)
self.configStatus = "Saved to ~/.clawdbot/clawdbot.json." self.configStatus = "Saved to ~/.clawdbot/clawdbot.json."
await self.loadConfig()
await self.refresh(probe: true) await self.refresh(probe: true)
} catch { } catch {
self.configStatus = error.localizedDescription self.configStatus = error.localizedDescription
} }
} }
private func setSection(_ key: String, payload: [String: Any]) {
if payload.isEmpty {
self.configRoot.removeValue(forKey: key)
} else {
self.configRoot[key] = payload
}
}
private func stringList(from values: [AnyCodable]?) -> String { private func stringList(from values: [AnyCodable]?) -> String {
guard let values else { return "" } guard let values else { return "" }
let strings = values.compactMap { entry -> String? in let strings = values.compactMap { entry -> String? in
@@ -492,25 +513,29 @@ extension ConnectionsStore {
value.trimmingCharacters(in: .whitespacesAndNewlines) value.trimmingCharacters(in: .whitespacesAndNewlines)
} }
private func setOptionalString(_ target: inout [String: Any], key: String, value: String) { private func setPatchString(_ target: inout [String: Any], key: String, value: String) {
let trimmed = self.trimmed(value) let trimmed = self.trimmed(value)
if trimmed.isEmpty { if trimmed.isEmpty {
target.removeValue(forKey: key) target[key] = NSNull()
} else { } else {
target[key] = trimmed target[key] = trimmed
} }
} }
private func setOptionalNumber(_ target: inout [String: Any], key: String, value: String) { private func setPatchNumber(_ target: inout [String: Any], key: String, value: String) {
let trimmed = self.trimmed(value) let trimmed = self.trimmed(value)
if trimmed.isEmpty { if trimmed.isEmpty {
target.removeValue(forKey: key) target[key] = NSNull()
} else if let number = Double(trimmed) { return
}
if let number = Double(trimmed) {
target[key] = number target[key] = number
} else {
target[key] = NSNull()
} }
} }
private func setOptionalInt( private func setPatchInt(
_ target: inout [String: Any], _ target: inout [String: Any],
key: String, key: String,
value: String, value: String,
@@ -518,26 +543,39 @@ extension ConnectionsStore {
{ {
let trimmed = self.trimmed(value) let trimmed = self.trimmed(value)
if trimmed.isEmpty { if trimmed.isEmpty {
target.removeValue(forKey: key) target[key] = NSNull()
return return
} }
guard let number = Int(trimmed) else { guard let number = Int(trimmed) else {
target.removeValue(forKey: key) target[key] = NSNull()
return return
} }
let isValid = allowZero ? number >= 0 : number > 0 let isValid = allowZero ? number >= 0 : number > 0
guard isValid else { guard isValid else {
target.removeValue(forKey: key) target[key] = NSNull()
return return
} }
target[key] = number target[key] = number
} }
private func setOptionalBool(_ target: inout [String: Any], key: String, value: Bool) { private func setPatchBool(
if value { _ target: inout [String: Any],
target[key] = true key: String,
value: Bool,
defaultValue: Bool)
{
if value == defaultValue {
target[key] = NSNull()
} else { } else {
target.removeValue(forKey: key) target[key] = value
}
}
private func setPatchList(_ target: inout [String: Any], key: String, values: [String]) {
if values.isEmpty {
target[key] = NSNull()
} else {
target[key] = values
} }
} }
@@ -548,7 +586,7 @@ extension ConnectionsStore {
defaultValue: Bool) defaultValue: Bool)
{ {
if value == defaultValue { if value == defaultValue {
actions.removeValue(forKey: key) actions[key] = NSNull()
} else { } else {
actions[key] = value actions[key] = value
} }
@@ -180,6 +180,7 @@ struct ConfigSnapshot: Codable {
let path: String? let path: String?
let exists: Bool? let exists: Bool?
let raw: String? let raw: String?
let hash: String?
let parsed: AnyCodable? let parsed: AnyCodable?
let valid: Bool? let valid: Bool?
let config: [String: AnyCodable]? let config: [String: AnyCodable]?
@@ -307,6 +308,7 @@ final class ConnectionsStore {
var pollTask: Task<Void, Never>? var pollTask: Task<Void, Never>?
var configRoot: [String: Any] = [:] var configRoot: [String: Any] = [:]
var configLoaded = false var configLoaded = false
var configHash: String?
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) { init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview self.isPreview = isPreview
@@ -189,20 +189,20 @@ enum CritterIconRenderer {
canvas.context.setFillColor(NSColor.labelColor.cgColor) canvas.context.setFillColor(NSColor.labelColor.cgColor)
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: geometry.bodyRect, roundedRect: geometry.bodyRect,
cornerWidth: geometry.bodyCorner, cornerWidth: geometry.bodyCorner,
cornerHeight: geometry.bodyCorner, cornerHeight: geometry.bodyCorner,
transform: nil)) transform: nil))
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: geometry.leftEarRect, roundedRect: geometry.leftEarRect,
cornerWidth: geometry.earCorner, cornerWidth: geometry.earCorner,
cornerHeight: geometry.earCorner, cornerHeight: geometry.earCorner,
transform: nil)) transform: nil))
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: geometry.rightEarRect, roundedRect: geometry.rightEarRect,
cornerWidth: geometry.earCorner, cornerWidth: geometry.earCorner,
cornerHeight: geometry.earCorner, cornerHeight: geometry.earCorner,
transform: nil)) transform: nil))
for i in 0..<4 { for i in 0..<4 {
let x = geometry.legStartX + CGFloat(i) * (geometry.legW + geometry.legSpacing) let x = geometry.legStartX + CGFloat(i) * (geometry.legW + geometry.legSpacing)
@@ -213,10 +213,10 @@ enum CritterIconRenderer {
width: geometry.legW, width: geometry.legW,
height: geometry.legH * geometry.legHeightScale) height: geometry.legH * geometry.legHeightScale)
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: rect, roundedRect: rect,
cornerWidth: geometry.legW * 0.34, cornerWidth: geometry.legW * 0.34,
cornerHeight: geometry.legW * 0.34, cornerHeight: geometry.legW * 0.34,
transform: nil)) transform: nil))
} }
canvas.context.fillPath() canvas.context.fillPath()
} }
@@ -252,15 +252,15 @@ enum CritterIconRenderer {
height: holeH) height: holeH)
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: leftHoleRect, roundedRect: leftHoleRect,
cornerWidth: holeCorner, cornerWidth: holeCorner,
cornerHeight: holeCorner, cornerHeight: holeCorner,
transform: nil)) transform: nil))
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: rightHoleRect, roundedRect: rightHoleRect,
cornerWidth: holeCorner, cornerWidth: holeCorner,
cornerHeight: holeCorner, cornerHeight: holeCorner,
transform: nil)) transform: nil))
} }
if options.eyesClosedLines { if options.eyesClosedLines {
@@ -278,41 +278,41 @@ enum CritterIconRenderer {
width: lineW, width: lineW,
height: lineH) height: lineH)
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: leftRect, roundedRect: leftRect,
cornerWidth: corner, cornerWidth: corner,
cornerHeight: corner, cornerHeight: corner,
transform: nil)) transform: nil))
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: rightRect, roundedRect: rightRect,
cornerWidth: corner, cornerWidth: corner,
cornerHeight: corner, cornerHeight: corner,
transform: nil)) transform: nil))
} else { } else {
let eyeOpen = max(0.05, 1 - options.blink) let eyeOpen = max(0.05, 1 - options.blink)
let eyeH = canvas.snapY(geometry.bodyRect.height * 0.26 * eyeOpen) let eyeH = canvas.snapY(geometry.bodyRect.height * 0.26 * eyeOpen)
let left = CGMutablePath() let left = CGMutablePath()
left.move(to: CGPoint( left.move(to: CGPoint(
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2), x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
y: canvas.snapY(leftCenter.y - eyeH))) y: canvas.snapY(leftCenter.y - eyeH)))
left.addLine(to: CGPoint( left.addLine(to: CGPoint(
x: canvas.snapX(leftCenter.x + geometry.eyeW / 2), x: canvas.snapX(leftCenter.x + geometry.eyeW / 2),
y: canvas.snapY(leftCenter.y))) y: canvas.snapY(leftCenter.y)))
left.addLine(to: CGPoint( left.addLine(to: CGPoint(
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2), x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
y: canvas.snapY(leftCenter.y + eyeH))) y: canvas.snapY(leftCenter.y + eyeH)))
left.closeSubpath() left.closeSubpath()
let right = CGMutablePath() let right = CGMutablePath()
right.move(to: CGPoint( right.move(to: CGPoint(
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2), x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
y: canvas.snapY(rightCenter.y - eyeH))) y: canvas.snapY(rightCenter.y - eyeH)))
right.addLine(to: CGPoint( right.addLine(to: CGPoint(
x: canvas.snapX(rightCenter.x - geometry.eyeW / 2), x: canvas.snapX(rightCenter.x - geometry.eyeW / 2),
y: canvas.snapY(rightCenter.y))) y: canvas.snapY(rightCenter.y)))
right.addLine(to: CGPoint( right.addLine(to: CGPoint(
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2), x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
y: canvas.snapY(rightCenter.y + eyeH))) y: canvas.snapY(rightCenter.y + eyeH)))
right.closeSubpath() right.closeSubpath()
canvas.context.addPath(left) canvas.context.addPath(left)
@@ -121,12 +121,12 @@ extension CritterStatusLabel {
} }
return Image(nsImage: CritterIconRenderer.makeIcon( return Image(nsImage: CritterIconRenderer.makeIcon(
blink: self.blinkAmount, blink: self.blinkAmount,
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0), legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
earWiggle: self.earWiggle, earWiggle: self.earWiggle,
earScale: self.earBoostActive ? 1.9 : 1.0, earScale: self.earBoostActive ? 1.9 : 1.0,
earHoles: self.earBoostActive, earHoles: self.earBoostActive,
badge: badge)) badge: badge))
} }
private func resetMotion() { private func resetMotion() {
@@ -11,15 +11,15 @@ struct CronJobEditor: View {
let labelColumnWidth: CGFloat = 160 let labelColumnWidth: CGFloat = 160
static let introText = static let introText =
"Create a schedule that wakes clawd via the Gateway. " "Create a schedule that wakes clawd via the Gateway. "
+ "Use an isolated session for agent turns so your main chat stays clean." + "Use an isolated session for agent turns so your main chat stays clean."
static let sessionTargetNote = static let sessionTargetNote =
"Main jobs post a system event into the current main session. " "Main jobs post a system event into the current main session. "
+ "Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/Discord/etc)." + "Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/Discord/etc)."
static let scheduleKindNote = static let scheduleKindNote =
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression." "“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
static let isolatedPayloadNote = static let isolatedPayloadNote =
"Isolated jobs always run an agent turn. The result can be delivered to a channel, " "Isolated jobs always run an agent turn. The result can be delivered to a channel, "
+ "and a short summary is posted back to your main chat." + "and a short summary is posted back to your main chat."
static let mainPayloadNote = static let mainPayloadNote =
"System events are injected into the current main session. Agent turns require an isolated session target." "System events are injected into the current main session. Agent turns require an isolated session target."
static let mainSummaryNote = static let mainSummaryNote =
+7 -7
View File
@@ -70,13 +70,13 @@ enum CronSchedule: Codable, Equatable {
enum CronPayload: Codable, Equatable { enum CronPayload: Codable, Equatable {
case systemEvent(text: String) case systemEvent(text: String)
case agentTurn( case agentTurn(
message: String, message: String,
thinking: String?, thinking: String?,
timeoutSeconds: Int?, timeoutSeconds: Int?,
deliver: Bool?, deliver: Bool?,
channel: String?, channel: String?,
to: String?, to: String?,
bestEffortDeliver: Bool?) bestEffortDeliver: Bool?)
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver
@@ -26,8 +26,8 @@ extension CronSettings {
}) })
} }
.alert("Delete cron job?", isPresented: Binding( .alert("Delete cron job?", isPresented: Binding(
get: { self.confirmDelete != nil }, get: { self.confirmDelete != nil },
set: { if !$0 { self.confirmDelete = nil } })) set: { if !$0 { self.confirmDelete = nil } }))
{ {
Button("Cancel", role: .cancel) { self.confirmDelete = nil } Button("Cancel", role: .cancel) { self.confirmDelete = nil }
Button("Delete", role: .destructive) { Button("Delete", role: .destructive) {
@@ -42,9 +42,9 @@ extension CronSettings {
} }
} }
.onChange(of: self.store.selectedJobId) { _, newValue in .onChange(of: self.store.selectedJobId) { _, newValue in
guard let newValue else { return } guard let newValue else { return }
Task { await self.store.refreshRuns(jobId: newValue) } Task { await self.store.refreshRuns(jobId: newValue) }
} }
} }
var schedulerBanner: some View { var schedulerBanner: some View {
@@ -69,8 +69,8 @@ extension CronSettings {
Spacer() Spacer()
HStack(spacing: 8) { HStack(spacing: 8) {
Toggle("Enabled", isOn: Binding( Toggle("Enabled", isOn: Binding(
get: { job.enabled }, get: { job.enabled },
set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } })) set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } }))
.toggleStyle(.switch) .toggleStyle(.switch)
.labelsHidden() .labelsHidden()
Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } } Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } }
@@ -102,8 +102,8 @@ enum DebugActions {
_ = try await RemoteTunnelManager.shared.ensureControlTunnel() _ = try await RemoteTunnelManager.shared.ensureControlTunnel()
let settings = CommandResolver.connectionSettings() let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote( try await ControlChannel.shared.configure(mode: .remote(
target: settings.target, target: settings.target,
identity: settings.identity)) identity: settings.identity))
} catch { } catch {
// ControlChannel will surface a degraded state; also refresh health to update the menu text. // ControlChannel will surface a degraded state; also refresh health to update the menu text.
Task { await HealthStore.shared.refresh(onDemand: true) } Task { await HealthStore.shared.refresh(onDemand: true) }
@@ -127,8 +127,8 @@ enum DebugActions {
_ = try await RemoteTunnelManager.shared.ensureControlTunnel() _ = try await RemoteTunnelManager.shared.ensureControlTunnel()
let settings = CommandResolver.connectionSettings() let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote( try await ControlChannel.shared.configure(mode: .remote(
target: settings.target, target: settings.target,
identity: settings.identity)) identity: settings.identity))
await HealthStore.shared.refresh(onDemand: true) await HealthStore.shared.refresh(onDemand: true)
return .success("SSH tunnel reset.") return .success("SSH tunnel reset.")
} catch { } catch {
@@ -107,9 +107,9 @@ enum DeviceModelCatalog {
private static func loadMapping(resourceName: String) -> [String: String] { private static func loadMapping(resourceName: String) -> [String: String] {
guard let url = self.resourceBundle?.url( guard let url = self.resourceBundle?.url(
forResource: resourceName, forResource: resourceName,
withExtension: "json", withExtension: "json",
subdirectory: self.resourceSubdirectory) subdirectory: self.resourceSubdirectory)
else { return [:] } else { return [:] }
do { do {
@@ -113,17 +113,17 @@ actor GatewayChannelActor {
self.task = nil self.task = nil
await self.failPending(NSError( await self.failPending(NSError(
domain: "Gateway", domain: "Gateway",
code: 0, code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"])) userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
let waiters = self.connectWaiters let waiters = self.connectWaiters
self.connectWaiters.removeAll() self.connectWaiters.removeAll()
for waiter in waiters { for waiter in waiters {
waiter.resume(throwing: NSError( waiter.resume(throwing: NSError(
domain: "Gateway", domain: "Gateway",
code: 0, code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"])) userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
} }
} }
@@ -55,6 +55,7 @@ actor GatewayConnection {
case channelsStatus = "channels.status" case channelsStatus = "channels.status"
case configGet = "config.get" case configGet = "config.get"
case configSet = "config.set" case configSet = "config.set"
case configPatch = "config.patch"
case wizardStart = "wizard.start" case wizardStart = "wizard.start"
case wizardNext = "wizard.next" case wizardNext = "wizard.next"
case wizardCancel = "wizard.cancel" case wizardCancel = "wizard.cancel"
@@ -232,7 +233,18 @@ actor GatewayConnection {
func canvasHostUrl() async -> String? { func canvasHostUrl() async -> String? {
guard let snapshot = self.lastSnapshot else { return nil } guard let snapshot = self.lastSnapshot else { return nil }
let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private func sessionDefaultString(_ defaults: [String: AnyCodable]?, key: String) -> String {
(defaults?[key]?.stringValue ?? "")
.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
}
func cachedMainSessionKey() -> String? {
guard let snapshot = self.lastSnapshot else { return nil }
let trimmed = self.sessionDefaultString(snapshot.snapshot.sessiondefaults, key: "mainSessionKey")
return trimmed.isEmpty ? nil : trimmed return trimmed.isEmpty ? nil : trimmed
} }
@@ -267,12 +279,35 @@ actor GatewayConnection {
private func broadcast(_ push: GatewayPush) { private func broadcast(_ push: GatewayPush) {
if case let .snapshot(snapshot) = push { if case let .snapshot(snapshot) = push {
self.lastSnapshot = snapshot self.lastSnapshot = snapshot
if let mainSessionKey = self.cachedMainSessionKey() {
Task { @MainActor in
WorkActivityStore.shared.setMainSessionKey(mainSessionKey)
}
}
} }
for (_, continuation) in self.subscribers { for (_, continuation) in self.subscribers {
continuation.yield(push) continuation.yield(push)
} }
} }
private func canonicalizeSessionKey(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
guard !trimmed.isEmpty else { return trimmed }
guard let defaults = self.lastSnapshot?.snapshot.sessiondefaults else { return trimmed }
let mainSessionKey = self.sessionDefaultString(defaults, key: "mainSessionKey")
guard !mainSessionKey.isEmpty else { return trimmed }
let mainKey = self.sessionDefaultString(defaults, key: "mainKey")
let defaultAgentId = self.sessionDefaultString(defaults, key: "defaultAgentId")
let isMainAlias =
trimmed == "main" ||
(!mainKey.isEmpty && trimmed == mainKey) ||
trimmed == mainSessionKey ||
(!defaultAgentId.isEmpty &&
(trimmed == "agent:\(defaultAgentId):main" ||
(mainKey.isEmpty == false && trimmed == "agent:\(defaultAgentId):\(mainKey)")))
return isMainAlias ? mainSessionKey : trimmed
}
private func configure(url: URL, token: String?, password: String?) async { private func configure(url: URL, token: String?, password: String?) async {
if self.client != nil, self.configuredURL == url, self.configuredToken == token, if self.client != nil, self.configuredURL == url, self.configuredToken == token,
self.configuredPassword == password self.configuredPassword == password
@@ -331,6 +366,9 @@ extension GatewayConnection {
} }
func mainSessionKey(timeoutMs: Double = 15000) async -> String { func mainSessionKey(timeoutMs: Double = 15000) async -> String {
if let cached = self.cachedMainSessionKey() {
return cached
}
do { do {
let data = try await self.requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs) let data = try await self.requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs)
return try Self.mainSessionKey(fromConfigGetData: data) return try Self.mainSessionKey(fromConfigGetData: data)
@@ -361,10 +399,11 @@ extension GatewayConnection {
func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) { func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) {
let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return (false, "message empty") } guard !trimmed.isEmpty else { return (false, "message empty") }
let sessionKey = self.canonicalizeSessionKey(invocation.sessionKey)
var params: [String: AnyCodable] = [ var params: [String: AnyCodable] = [
"message": AnyCodable(trimmed), "message": AnyCodable(trimmed),
"sessionKey": AnyCodable(invocation.sessionKey), "sessionKey": AnyCodable(sessionKey),
"thinking": AnyCodable(invocation.thinking ?? "default"), "thinking": AnyCodable(invocation.thinking ?? "default"),
"deliver": AnyCodable(invocation.deliver), "deliver": AnyCodable(invocation.deliver),
"to": AnyCodable(invocation.to ?? ""), "to": AnyCodable(invocation.to ?? ""),
@@ -394,14 +433,14 @@ extension GatewayConnection {
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?) idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
{ {
await self.sendAgent(GatewayAgentInvocation( await self.sendAgent(GatewayAgentInvocation(
message: message, message: message,
sessionKey: sessionKey, sessionKey: sessionKey,
thinking: thinking, thinking: thinking,
deliver: deliver, deliver: deliver,
to: to, to: to,
channel: channel, channel: channel,
timeoutSeconds: timeoutSeconds, timeoutSeconds: timeoutSeconds,
idempotencyKey: idempotencyKey)) idempotencyKey: idempotencyKey))
} }
func sendSystemEvent(_ params: [String: AnyCodable]) async { func sendSystemEvent(_ params: [String: AnyCodable]) async {
@@ -468,7 +507,8 @@ extension GatewayConnection {
limit: Int? = nil, limit: Int? = nil,
timeoutMs: Int? = nil) async throws -> ClawdbotChatHistoryPayload timeoutMs: Int? = nil) async throws -> ClawdbotChatHistoryPayload
{ {
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(sessionKey)] let resolvedKey = self.canonicalizeSessionKey(sessionKey)
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)]
if let limit { params["limit"] = AnyCodable(limit) } if let limit { params["limit"] = AnyCodable(limit) }
let timeout = timeoutMs.map { Double($0) } let timeout = timeoutMs.map { Double($0) }
return try await self.requestDecoded( return try await self.requestDecoded(
@@ -485,8 +525,9 @@ extension GatewayConnection {
attachments: [ClawdbotChatAttachmentPayload], attachments: [ClawdbotChatAttachmentPayload],
timeoutMs: Int = 30000) async throws -> ClawdbotChatSendResponse timeoutMs: Int = 30000) async throws -> ClawdbotChatSendResponse
{ {
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
var params: [String: AnyCodable] = [ var params: [String: AnyCodable] = [
"sessionKey": AnyCodable(sessionKey), "sessionKey": AnyCodable(resolvedKey),
"message": AnyCodable(message), "message": AnyCodable(message),
"thinking": AnyCodable(thinking), "thinking": AnyCodable(thinking),
"idempotencyKey": AnyCodable(idempotencyKey), "idempotencyKey": AnyCodable(idempotencyKey),
@@ -512,10 +553,11 @@ extension GatewayConnection {
} }
func chatAbort(sessionKey: String, runId: String) async throws -> Bool { func chatAbort(sessionKey: String, runId: String) async throws -> Bool {
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? } struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? }
let res: AbortResponse = try await self.requestDecoded( let res: AbortResponse = try await self.requestDecoded(
method: .chatAbort, method: .chatAbort,
params: ["sessionKey": AnyCodable(sessionKey), "runId": AnyCodable(runId)]) params: ["sessionKey": AnyCodable(resolvedKey), "runId": AnyCodable(runId)])
return res.aborted ?? false return res.aborted ?? false
} }
@@ -27,7 +27,7 @@ struct GatewayDiscoveryInlineList: View {
ForEach(self.discovery.gateways.prefix(6)) { gateway in ForEach(self.discovery.gateways.prefix(6)) { gateway in
let target = self.suggestedSSHTarget(gateway) let target = self.suggestedSSHTarget(gateway)
let selected = (target != nil && self.currentTarget? let selected = (target != nil && self.currentTarget?
.trimmingCharacters(in: .whitespacesAndNewlines) == target) .trimmingCharacters(in: .whitespacesAndNewlines) == target)
Button { Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
@@ -61,8 +61,8 @@ struct GatewayDiscoveryInlineList: View {
.background( .background(
RoundedRectangle(cornerRadius: 10, style: .continuous) RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(self.rowBackground( .fill(self.rowBackground(
selected: selected, selected: selected,
hovered: self.hoveredGatewayID == gateway.id))) hovered: self.hoveredGatewayID == gateway.id)))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous) RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder( .strokeBorder(
@@ -33,14 +33,16 @@ actor GatewayEndpointStore {
return GatewayEndpointStore.resolveGatewayToken( return GatewayEndpointStore.resolveGatewayToken(
isRemote: CommandResolver.connectionModeIsRemote(), isRemote: CommandResolver.connectionModeIsRemote(),
root: root, root: root,
env: ProcessInfo.processInfo.environment) env: ProcessInfo.processInfo.environment,
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
}, },
password: { password: {
let root = ClawdbotConfigFile.loadDict() let root = ClawdbotConfigFile.loadDict()
return GatewayEndpointStore.resolveGatewayPassword( return GatewayEndpointStore.resolveGatewayPassword(
isRemote: CommandResolver.connectionModeIsRemote(), isRemote: CommandResolver.connectionModeIsRemote(),
root: root, root: root,
env: ProcessInfo.processInfo.environment) env: ProcessInfo.processInfo.environment,
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
}, },
localPort: { GatewayEnvironment.gatewayPort() }, localPort: { GatewayEnvironment.gatewayPort() },
localHost: { localHost: {
@@ -60,7 +62,8 @@ actor GatewayEndpointStore {
private static func resolveGatewayPassword( private static func resolveGatewayPassword(
isRemote: Bool, isRemote: Bool,
root: [String: Any], root: [String: Any],
env: [String: String]) -> String? env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot?) -> String?
{ {
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? "" let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -88,13 +91,19 @@ actor GatewayEndpointStore {
return pw return pw
} }
} }
if let password = launchdSnapshot?.password?.trimmingCharacters(in: .whitespacesAndNewlines),
!password.isEmpty
{
return password
}
return nil return nil
} }
private static func resolveGatewayToken( private static func resolveGatewayToken(
isRemote: Bool, isRemote: Bool,
root: [String: Any], root: [String: Any],
env: [String: String]) -> String? env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot?) -> String?
{ {
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? "" let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -122,6 +131,11 @@ actor GatewayEndpointStore {
return value return value
} }
} }
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
return token
}
return nil return nil
} }
@@ -192,10 +206,10 @@ actor GatewayEndpointStore {
let port = self.deps.localPort() let port = self.deps.localPort()
let host = await self.deps.localHost() let host = await self.deps.localHost()
self.setState(.ready( self.setState(.ready(
mode: .local, mode: .local,
url: URL(string: "ws://\(host):\(port)")!, url: URL(string: "ws://\(host):\(port)")!,
token: token, token: token,
password: password)) password: password))
case .remote: case .remote:
let port = await self.deps.remotePortIfRunning() let port = await self.deps.remotePortIfRunning()
guard let port else { guard let port else {
@@ -205,10 +219,10 @@ actor GatewayEndpointStore {
} }
self.cancelRemoteEnsure() self.cancelRemoteEnsure()
self.setState(.ready( self.setState(.ready(
mode: .remote, mode: .remote,
url: URL(string: "ws://127.0.0.1:\(Int(port))")!, url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
token: token, token: token,
password: password)) password: password))
case .unconfigured: case .unconfigured:
self.cancelRemoteEnsure() self.cancelRemoteEnsure()
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured")) self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
@@ -430,9 +444,19 @@ extension GatewayEndpointStore {
static func _testResolveGatewayPassword( static func _testResolveGatewayPassword(
isRemote: Bool, isRemote: Bool,
root: [String: Any], root: [String: Any],
env: [String: String]) -> String? env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String?
{ {
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env) self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot)
}
static func _testResolveGatewayToken(
isRemote: Bool,
root: [String: Any],
env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String?
{
self.resolveGatewayToken(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot)
} }
static func _testResolveGatewayBindMode( static func _testResolveGatewayBindMode(
@@ -306,6 +306,10 @@ enum GatewayLaunchAgentManager {
password: snapshot.password) password: snapshot.password)
} }
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
LaunchAgentPlist.snapshot(url: self.plistURL)
}
private static func ensureEnabled() async { private static func ensureEnabled() async {
let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
guard result.status != 0 else { return } guard result.status != 0 else { return }
@@ -8,7 +8,7 @@ enum GatewayPayloadDecoding {
} }
static func decodeIfPresent<T: Decodable>(_ payload: ClawdbotProtocol.AnyCodable?, as _: T.Type = T.self) throws static func decodeIfPresent<T: Decodable>(_ payload: ClawdbotProtocol.AnyCodable?, as _: T.Type = T.self) throws
-> T? -> T?
{ {
guard let payload else { return nil } guard let payload else { return nil }
return try self.decode(payload, as: T.self) return try self.decode(payload, as: T.self)
@@ -238,7 +238,7 @@ struct GeneralSettings: View {
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || self.state.remoteTarget .disabled(self.remoteStatus == .checking || self.state.remoteTarget
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
} }
GatewayDiscoveryInlineList( GatewayDiscoveryInlineList(
@@ -627,8 +627,8 @@ extension GeneralSettings {
let originalMode = AppStateStore.shared.connectionMode let originalMode = AppStateStore.shared.connectionMode
do { do {
try await ControlChannel.shared.configure(mode: .remote( try await ControlChannel.shared.configure(mode: .remote(
target: settings.target, target: settings.target,
identity: settings.identity)) identity: settings.identity))
let data = try await ControlChannel.shared.health(timeout: 10) let data = try await ControlChannel.shared.health(timeout: 10)
if decodeHealthSnapshot(from: data) != nil { if decodeHealthSnapshot(from: data) != nil {
self.remoteStatus = .ok self.remoteStatus = .ok
@@ -13,7 +13,7 @@ enum InstanceIdentity {
let defaults = Self.defaults let defaults = Self.defaults
if let existing = defaults.string(forKey: instanceIdKey)? if let existing = defaults.string(forKey: instanceIdKey)?
.trimmingCharacters(in: .whitespacesAndNewlines), .trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty !existing.isEmpty
{ {
return existing return existing
} }
+27 -8
View File
@@ -1,9 +1,25 @@
import Foundation import Foundation
enum LogLocator { enum LogLocator {
private static let logDir = URL(fileURLWithPath: "/tmp/clawdbot") private static var logDir: URL {
private static let stdoutLog = logDir.appendingPathComponent("clawdbot-stdout.log") if let override = ProcessInfo.processInfo.environment["CLAWDBOT_LOG_DIR"], !override.isEmpty {
private static let gatewayLog = logDir.appendingPathComponent("clawdbot-gateway.log") return URL(fileURLWithPath: override)
}
return URL(fileURLWithPath: "/tmp/clawdbot")
}
private static var stdoutLog: URL {
logDir.appendingPathComponent("clawdbot-stdout.log")
}
private static var gatewayLog: URL {
logDir.appendingPathComponent("clawdbot-gateway.log")
}
private static func ensureLogDirExists() {
try? FileManager.default.createDirectory(at: self.logDir, withIntermediateDirectories: true)
}
private static func modificationDate(for url: URL) -> Date { private static func modificationDate(for url: URL) -> Date {
(try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
@@ -11,11 +27,12 @@ enum LogLocator {
/// Returns the newest log file under /tmp/clawdbot/ (rolling or stdout), or nil if none exist. /// Returns the newest log file under /tmp/clawdbot/ (rolling or stdout), or nil if none exist.
static func bestLogFile() -> URL? { static func bestLogFile() -> URL? {
self.ensureLogDirExists()
let fm = FileManager.default let fm = FileManager.default
let files = (try? fm.contentsOfDirectory( let files = (try? fm.contentsOfDirectory(
at: self.logDir, at: self.logDir,
includingPropertiesForKeys: [.contentModificationDateKey], includingPropertiesForKeys: [.contentModificationDateKey],
options: [.skipsHiddenFiles])) ?? [] options: [.skipsHiddenFiles])) ?? []
return files return files
.filter { $0.lastPathComponent.hasPrefix("clawdbot") && $0.pathExtension == "log" } .filter { $0.lastPathComponent.hasPrefix("clawdbot") && $0.pathExtension == "log" }
@@ -26,11 +43,13 @@ enum LogLocator {
/// Path to use for launchd stdout/err. /// Path to use for launchd stdout/err.
static var launchdLogPath: String { static var launchdLogPath: String {
stdoutLog.path self.ensureLogDirExists()
return stdoutLog.path
} }
/// Path to use for the Gateway launchd job stdout/err. /// Path to use for the Gateway launchd job stdout/err.
static var launchdGatewayLogPath: String { static var launchdGatewayLogPath: String {
gatewayLog.path self.ensureLogDirExists()
return gatewayLog.path
} }
} }
@@ -102,11 +102,14 @@ struct MenuContent: View {
} }
if self.state.canvasEnabled { if self.state.canvasEnabled {
Button { Button {
if self.state.canvasPanelVisible { Task { @MainActor in
CanvasManager.shared.hideAll() if self.state.canvasPanelVisible {
} else { CanvasManager.shared.hideAll()
// Don't force a navigation on re-open: preserve the current web view state. } else {
_ = try? CanvasManager.shared.show(sessionKey: "main", path: nil) let sessionKey = await GatewayConnection.shared.mainSessionKey()
// Don't force a navigation on re-open: preserve the current web view state.
_ = try? CanvasManager.shared.show(sessionKey: sessionKey, path: nil)
}
} }
} label: { } label: {
Label( Label(
@@ -51,9 +51,9 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
let initialWidth = self.initialCardWidth(for: menu) let initialWidth = self.initialCardWidth(for: menu)
let initial = AnyView(ContextMenuCardView( let initial = AnyView(ContextMenuCardView(
rows: initialRows, rows: initialRows,
statusText: initialStatusText, statusText: initialStatusText,
isLoading: initialIsLoading)) isLoading: initialIsLoading))
let hosting = NSHostingView(rootView: initial) let hosting = NSHostingView(rootView: initial)
hosting.frame.size.width = max(1, initialWidth) hosting.frame.size.width = max(1, initialWidth)
@@ -103,6 +103,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
extension MenuSessionsInjector { extension MenuSessionsInjector {
// MARK: - Injection // MARK: - Injection
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
private func inject(into menu: NSMenu) { private func inject(into menu: NSMenu) {
// Remove any previous injected items. // Remove any previous injected items.
@@ -120,13 +121,15 @@ extension MenuSessionsInjector {
if let snapshot = self.cachedSnapshot { if let snapshot = self.cachedSnapshot {
let now = Date() let now = Date()
let mainKey = self.mainSessionKey
let rows = snapshot.rows.filter { row in let rows = snapshot.rows.filter { row in
if row.key == "main" { return true } if row.key == "main", mainKey != "main" { return false }
if row.key == mainKey { return true }
guard let updatedAt = row.updatedAt else { return false } guard let updatedAt = row.updatedAt else { return false }
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
}.sorted { lhs, rhs in }.sorted { lhs, rhs in
if lhs.key == "main" { return true } if lhs.key == mainKey { return true }
if rhs.key == "main" { return false } if rhs.key == mainKey { return false }
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
} }
@@ -135,8 +138,8 @@ extension MenuSessionsInjector {
headerItem.isEnabled = false headerItem.isEnabled = false
let hosted = self.makeHostedView( let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView( rootView: AnyView(MenuSessionsHeaderView(
count: rows.count, count: rows.count,
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))), statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
width: width, width: width,
highlighted: false) highlighted: false)
headerItem.view = hosted headerItem.view = hosted
@@ -172,8 +175,8 @@ extension MenuSessionsInjector {
: self.controlChannelStatusText(for: channelState) : self.controlChannelStatusText(for: channelState)
let hosted = self.makeHostedView( let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView( rootView: AnyView(MenuSessionsHeaderView(
count: 0, count: 0,
statusText: statusText)), statusText: statusText)),
width: width, width: width,
highlighted: false) highlighted: false)
headerItem.view = hosted headerItem.view = hosted
@@ -296,7 +299,7 @@ extension MenuSessionsInjector {
headerItem.isEnabled = false headerItem.isEnabled = false
headerItem.view = self.makeHostedView( headerItem.view = self.makeHostedView(
rootView: AnyView(MenuUsageHeaderView( rootView: AnyView(MenuUsageHeaderView(
count: rows.count)), count: rows.count)),
width: width, width: width,
highlighted: false) highlighted: false)
menu.insertItem(headerItem, at: cursor) menu.insertItem(headerItem, at: cursor)
@@ -469,11 +472,11 @@ extension MenuSessionsInjector {
item.tag = self.tag item.tag = self.tag
item.isEnabled = false item.isEnabled = false
let view = AnyView(SessionMenuPreviewView( let view = AnyView(SessionMenuPreviewView(
sessionKey: sessionKey, sessionKey: sessionKey,
width: width, width: width,
maxItems: 10, maxItems: 10,
maxLines: maxLines, maxLines: maxLines,
title: title)) title: title))
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false) item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
return item return item
} }
@@ -593,10 +596,10 @@ extension MenuSessionsInjector {
let width = self.submenuWidth() let width = self.submenuWidth()
menu.addItem(self.makeSessionPreviewItem( menu.addItem(self.makeSessionPreviewItem(
sessionKey: row.key, sessionKey: row.key,
title: "Recent messages (last 10)", title: "Recent messages (last 10)",
width: width, width: width,
maxLines: 3)) maxLines: 3))
let morePreview = NSMenuItem(title: "More preview…", action: nil, keyEquivalent: "") let morePreview = NSMenuItem(title: "More preview…", action: nil, keyEquivalent: "")
morePreview.submenu = self.buildPreviewSubmenu(sessionKey: row.key, width: width) morePreview.submenu = self.buildPreviewSubmenu(sessionKey: row.key, width: width)
@@ -645,7 +648,7 @@ extension MenuSessionsInjector {
compact.representedObject = row.key compact.representedObject = row.key
menu.addItem(compact) menu.addItem(compact)
if row.key != "main", row.key != "global" { if row.key != self.mainSessionKey, row.key != "global" {
let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "") let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "")
del.target = self del.target = self
del.representedObject = row.key del.representedObject = row.key
@@ -700,10 +703,10 @@ extension MenuSessionsInjector {
private func buildPreviewSubmenu(sessionKey: String, width: CGFloat) -> NSMenu { private func buildPreviewSubmenu(sessionKey: String, width: CGFloat) -> NSMenu {
let menu = NSMenu() let menu = NSMenu()
menu.addItem(self.makeSessionPreviewItem( menu.addItem(self.makeSessionPreviewItem(
sessionKey: sessionKey, sessionKey: sessionKey,
title: "Recent messages (expanded)", title: "Recent messages (expanded)",
width: width, width: width,
maxLines: 8)) maxLines: 8))
return menu return menu
} }
@@ -760,9 +763,9 @@ extension MenuSessionsInjector {
!commands.isEmpty !commands.isEmpty
{ {
menu.addItem(self.makeNodeMultilineItem( menu.addItem(self.makeNodeMultilineItem(
label: "Commands", label: "Commands",
value: commands.joined(separator: ", "), value: commands.joined(separator: ", "),
width: width)) width: width))
} }
return menu return menu
@@ -852,9 +855,9 @@ extension MenuSessionsInjector {
guard let key = sender.representedObject as? String else { return } guard let key = sender.representedObject as? String else { return }
Task { @MainActor in Task { @MainActor in
guard SessionActions.confirmDestructiveAction( guard SessionActions.confirmDestructiveAction(
title: "Reset session?", title: "Reset session?",
message: "Starts a new session id for “\(key)”.", message: "Starts a new session id for “\(key)”.",
action: "Reset") action: "Reset")
else { return } else { return }
do { do {
@@ -871,9 +874,9 @@ extension MenuSessionsInjector {
guard let key = sender.representedObject as? String else { return } guard let key = sender.representedObject as? String else { return }
Task { @MainActor in Task { @MainActor in
guard SessionActions.confirmDestructiveAction( guard SessionActions.confirmDestructiveAction(
title: "Compact session log?", title: "Compact session log?",
message: "Keeps the last 400 lines; archives the old file.", message: "Keeps the last 400 lines; archives the old file.",
action: "Compact") action: "Compact")
else { return } else { return }
do { do {
@@ -890,9 +893,9 @@ extension MenuSessionsInjector {
guard let key = sender.representedObject as? String else { return } guard let key = sender.representedObject as? String else { return }
Task { @MainActor in Task { @MainActor in
guard SessionActions.confirmDestructiveAction( guard SessionActions.confirmDestructiveAction(
title: "Delete session?", title: "Delete session?",
message: "Deletes the “\(key)” entry and archives its transcript.", message: "Deletes the “\(key)” entry and archives its transcript.",
action: "Delete") action: "Delete")
else { return } else { return }
do { do {
@@ -36,10 +36,10 @@ actor MacNodeBridgeSession {
func connect( func connect(
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello, hello: BridgeHello,
onConnected: (@Sendable (String) async -> Void)? = nil, onConnected: (@Sendable (String, String?) async -> Void)? = nil,
onDisconnected: (@Sendable (String) async -> Void)? = nil, onDisconnected: (@Sendable (String) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
async throws async throws
{ {
await self.disconnect() await self.disconnect()
self.disconnectHandler = onDisconnected self.disconnectHandler = onDisconnected
@@ -77,15 +77,15 @@ actor MacNodeBridgeSession {
}) })
guard let line = try await AsyncTimeout.withTimeout( guard let line = try await AsyncTimeout.withTimeout(
seconds: 6, seconds: 6,
onTimeout: { onTimeout: {
TimeoutError(message: "operation timed out") TimeoutError(message: "operation timed out")
}, },
operation: { operation: {
try await self.receiveLine() try await self.receiveLine()
}), }),
let data = line.data(using: .utf8), let data = line.data(using: .utf8),
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data) let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
else { else {
self.logger.error("node bridge hello failed (unexpected response)") self.logger.error("node bridge hello failed (unexpected response)")
await self.disconnect() await self.disconnect()
@@ -98,7 +98,8 @@ actor MacNodeBridgeSession {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data) let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName) self.state = .connected(serverName: ok.serverName)
self.startPingLoop() self.startPingLoop()
await onConnected?(ok.serverName) let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
await onConnected?(ok.serverName, mainKey?.isEmpty == false ? mainKey : nil)
} else if base.type == "error" { } else if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data) let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
self.state = .failed(message: "\(err.code): \(err.message)") self.state = .failed(message: "\(err.code): \(err.message)")
@@ -67,8 +67,11 @@ final class MacNodeModeCoordinator {
try await self.session.connect( try await self.session.connect(
endpoint: endpoint, endpoint: endpoint,
hello: hello, hello: hello,
onConnected: { [weak self] serverName in onConnected: { [weak self] serverName, mainSessionKey in
self?.logger.info("mac node connected to \(serverName, privacy: .public)") self?.logger.info("mac node connected to \(serverName, privacy: .public)")
if let mainSessionKey {
await self?.runtime.updateMainSessionKey(mainSessionKey)
}
}, },
onDisconnected: { reason in onDisconnected: { reason in
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason) await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
@@ -386,10 +389,10 @@ final class MacNodeModeCoordinator {
let preferred = BridgeDiscoveryPreferences.preferredStableID() let preferred = BridgeDiscoveryPreferences.preferredStableID()
if let preferred, if let preferred,
let match = results.first(where: { let match = results.first(where: {
if case .service = $0.endpoint { if case .service = $0.endpoint {
return BridgeEndpointID.stableID($0.endpoint) == preferred return BridgeEndpointID.stableID($0.endpoint) == preferred
} }
return false return false
}) })
{ {
state.finish(match.endpoint) state.finish(match.endpoint)
@@ -7,6 +7,7 @@ actor MacNodeRuntime {
private let cameraCapture = CameraCaptureService() private let cameraCapture = CameraCaptureService()
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)? private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
private var mainSessionKey: String = "main"
init( init(
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = { makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
@@ -16,6 +17,12 @@ actor MacNodeRuntime {
self.makeMainActorServices = makeMainActorServices self.makeMainActorServices = makeMainActorServices
} }
func updateMainSessionKey(_ sessionKey: String) {
let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.mainSessionKey = trimmed
}
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
let command = req.command let command = req.command
if self.isCanvasCommand(command), !Self.canvasEnabled() { if self.isCanvasCommand(command), !Self.canvasEnabled() {
@@ -72,28 +79,32 @@ actor MacNodeRuntime {
let placement = params.placement.map { let placement = params.placement.map {
CanvasPlacement(x: $0.x, y: $0.y, width: $0.width, height: $0.height) CanvasPlacement(x: $0.x, y: $0.y, width: $0.width, height: $0.height)
} }
let sessionKey = self.mainSessionKey
try await MainActor.run { try await MainActor.run {
_ = try CanvasManager.shared.showDetailed( _ = try CanvasManager.shared.showDetailed(
sessionKey: "main", sessionKey: sessionKey,
target: url, target: url,
placement: placement) placement: placement)
} }
return BridgeInvokeResponse(id: req.id, ok: true) return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdbotCanvasCommand.hide.rawValue: case ClawdbotCanvasCommand.hide.rawValue:
let sessionKey = self.mainSessionKey
await MainActor.run { await MainActor.run {
CanvasManager.shared.hide(sessionKey: "main") CanvasManager.shared.hide(sessionKey: sessionKey)
} }
return BridgeInvokeResponse(id: req.id, ok: true) return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdbotCanvasCommand.navigate.rawValue: case ClawdbotCanvasCommand.navigate.rawValue:
let params = try Self.decodeParams(ClawdbotCanvasNavigateParams.self, from: req.paramsJSON) let params = try Self.decodeParams(ClawdbotCanvasNavigateParams.self, from: req.paramsJSON)
let sessionKey = self.mainSessionKey
try await MainActor.run { try await MainActor.run {
_ = try CanvasManager.shared.show(sessionKey: "main", path: params.url) _ = try CanvasManager.shared.show(sessionKey: sessionKey, path: params.url)
} }
return BridgeInvokeResponse(id: req.id, ok: true) return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdbotCanvasCommand.evalJS.rawValue: case ClawdbotCanvasCommand.evalJS.rawValue:
let params = try Self.decodeParams(ClawdbotCanvasEvalParams.self, from: req.paramsJSON) let params = try Self.decodeParams(ClawdbotCanvasEvalParams.self, from: req.paramsJSON)
let sessionKey = self.mainSessionKey
let result = try await CanvasManager.shared.eval( let result = try await CanvasManager.shared.eval(
sessionKey: "main", sessionKey: sessionKey,
javaScript: params.javaScript) javaScript: params.javaScript)
let payload = try Self.encodePayload(["result": result] as [String: String]) let payload = try Self.encodePayload(["result": result] as [String: String])
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
@@ -109,7 +120,8 @@ actor MacNodeRuntime {
}() }()
let quality = params?.quality ?? 0.9 let quality = params?.quality ?? 0.9
let path = try await CanvasManager.shared.snapshot(sessionKey: "main", outPath: nil) let sessionKey = self.mainSessionKey
let path = try await CanvasManager.shared.snapshot(sessionKey: sessionKey, outPath: nil)
defer { try? FileManager.default.removeItem(atPath: path) } defer { try? FileManager.default.removeItem(atPath: path) }
let data = try Data(contentsOf: URL(fileURLWithPath: path)) let data = try Data(contentsOf: URL(fileURLWithPath: path))
guard let image = NSImage(data: data) else { guard let image = NSImage(data: data) else {
@@ -169,10 +181,10 @@ actor MacNodeRuntime {
var height: Int var height: Int
} }
let payload = try Self.encodePayload(SnapPayload( let payload = try Self.encodePayload(SnapPayload(
format: (params.format ?? .jpg).rawValue, format: (params.format ?? .jpg).rawValue,
base64: res.data.base64EncodedString(), base64: res.data.base64EncodedString(),
width: Int(res.size.width), width: Int(res.size.width),
height: Int(res.size.height))) height: Int(res.size.height)))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdbotCameraCommand.clip.rawValue: case ClawdbotCameraCommand.clip.rawValue:
let params = (try? Self.decodeParams(ClawdbotCameraClipParams.self, from: req.paramsJSON)) ?? let params = (try? Self.decodeParams(ClawdbotCameraClipParams.self, from: req.paramsJSON)) ??
@@ -192,10 +204,10 @@ actor MacNodeRuntime {
var hasAudio: Bool var hasAudio: Bool
} }
let payload = try Self.encodePayload(ClipPayload( let payload = try Self.encodePayload(ClipPayload(
format: (params.format ?? .mp4).rawValue, format: (params.format ?? .mp4).rawValue,
base64: data.base64EncodedString(), base64: data.base64EncodedString(),
durationMs: res.durationMs, durationMs: res.durationMs,
hasAudio: res.hasAudio)) hasAudio: res.hasAudio))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdbotCameraCommand.list.rawValue: case ClawdbotCameraCommand.list.rawValue:
let devices = await self.cameraCapture.listDevices() let devices = await self.cameraCapture.listDevices()
@@ -300,12 +312,12 @@ actor MacNodeRuntime {
var hasAudio: Bool var hasAudio: Bool
} }
let payload = try Self.encodePayload(ScreenPayload( let payload = try Self.encodePayload(ScreenPayload(
format: "mp4", format: "mp4",
base64: data.base64EncodedString(), base64: data.base64EncodedString(),
durationMs: params.durationMs, durationMs: params.durationMs,
fps: params.fps, fps: params.fps,
screenIndex: params.screenIndex, screenIndex: params.screenIndex,
hasAudio: res.hasAudio)) hasAudio: res.hasAudio))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
} }
@@ -319,7 +331,8 @@ actor MacNodeRuntime {
private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
try await self.ensureA2UIHost() try await self.ensureA2UIHost()
let json = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: """ let sessionKey = self.mainSessionKey
let json = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: """
(() => { (() => {
if (!globalThis.clawdbotA2UI) return JSON.stringify({ ok: false, error: "missing clawdbotA2UI" }); if (!globalThis.clawdbotA2UI) return JSON.stringify({ ok: false, error: "missing clawdbotA2UI" });
return JSON.stringify(globalThis.clawdbotA2UI.reset()); return JSON.stringify(globalThis.clawdbotA2UI.reset());
@@ -358,7 +371,8 @@ actor MacNodeRuntime {
} }
})() })()
""" """
let resultJSON = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: js) let sessionKey = self.mainSessionKey
let resultJSON = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: js)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
} }
@@ -369,8 +383,9 @@ actor MacNodeRuntime {
NSLocalizedDescriptionKey: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", NSLocalizedDescriptionKey: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
]) ])
} }
let sessionKey = self.mainSessionKey
_ = try await MainActor.run { _ = try await MainActor.run {
try CanvasManager.shared.show(sessionKey: "main", path: a2uiUrl) try CanvasManager.shared.show(sessionKey: sessionKey, path: a2uiUrl)
} }
if await self.isA2UIReady(poll: true) { return } if await self.isA2UIReady(poll: true) { return }
throw NSError(domain: "Canvas", code: 31, userInfo: [ throw NSError(domain: "Canvas", code: 31, userInfo: [
@@ -389,7 +404,8 @@ actor MacNodeRuntime {
let deadline = poll ? Date().addingTimeInterval(6.0) : Date() let deadline = poll ? Date().addingTimeInterval(6.0) : Date()
while true { while true {
do { do {
let ready = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: """ let sessionKey = self.mainSessionKey
let ready = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: """
(() => String(Boolean(globalThis.clawdbotA2UI)))() (() => String(Boolean(globalThis.clawdbotA2UI)))()
""") """)
let trimmed = ready.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = ready.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -438,12 +454,12 @@ actor MacNodeRuntime {
} }
let payload = try Self.encodePayload(RunPayload( let payload = try Self.encodePayload(RunPayload(
exitCode: result.exitCode, exitCode: result.exitCode,
timedOut: result.timedOut, timedOut: result.timedOut,
success: result.success, success: result.success,
stdout: result.stdout, stdout: result.stdout,
stderr: result.stderr, stderr: result.stderr,
error: result.errorMessage)) error: result.errorMessage))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
} }
@@ -560,8 +576,8 @@ actor MacNodeRuntime {
case .jpeg: case .jpeg:
let clamped = min(1.0, max(0.05, quality)) let clamped = min(1.0, max(0.05, quality))
guard let data = rep.representation( guard let data = rep.representation(
using: .jpeg, using: .jpeg,
properties: [.compressionFactor: clamped]) properties: [.compressionFactor: clamped])
else { else {
throw NSError(domain: "Canvas", code: 24, userInfo: [ throw NSError(domain: "Canvas", code: 24, userInfo: [
NSLocalizedDescriptionKey: "jpeg encode failed", NSLocalizedDescriptionKey: "jpeg encode failed",
@@ -454,7 +454,7 @@ final class NodePairingApprovalPrompter {
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings() let settings = await center.notificationSettings()
guard settings.authorizationStatus == .authorized || guard settings.authorizationStatus == .authorized ||
settings.authorizationStatus == .provisional settings.authorizationStatus == .provisional
else { else {
return return
} }
@@ -547,7 +547,7 @@ final class NodePairingApprovalPrompter {
let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first
guard let gateway else { return nil } guard let gateway else { return nil }
let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ??
gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty) gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
guard let host, !host.isEmpty else { return nil } guard let host, !host.isEmpty else { return nil }
let port = gateway.sshPort > 0 ? gateway.sshPort : 22 let port = gateway.sshPort > 0 ? gateway.sshPort : 22
return SSHTarget(host: host, port: port) return SSHTarget(host: host, port: port)
@@ -58,6 +58,13 @@ final class OnboardingWizardModel {
func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async { func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async {
guard self.sessionId == nil, !self.isStarting else { return } guard self.sessionId == nil, !self.isStarting else { return }
guard mode == .local else { return } guard mode == .local else { return }
if self.shouldSkipWizard() {
self.sessionId = nil
self.currentStep = nil
self.status = "done"
self.errorMessage = nil
return
}
self.isStarting = true self.isStarting = true
self.errorMessage = nil self.errorMessage = nil
self.lastStartMode = mode self.lastStartMode = mode
@@ -177,6 +184,33 @@ final class OnboardingWizardModel {
Task { await self.startIfNeeded(mode: mode, workspace: self.lastStartWorkspace) } Task { await self.startIfNeeded(mode: mode, workspace: self.lastStartWorkspace) }
return true return true
} }
private func shouldSkipWizard() -> Bool {
let root = ClawdbotConfigFile.loadDict()
if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty {
return true
}
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any]
{
if let mode = auth["mode"] as? String,
!mode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
if let token = auth["token"] as? String,
!token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
if let password = auth["password"] as? String,
!password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
}
return false
}
} }
struct OnboardingWizardStepView: View { struct OnboardingWizardStepView: View {
@@ -77,10 +77,10 @@ final class PeekabooBridgeHostCoordinator {
var infoCF: CFDictionary? var infoCF: CFDictionary?
guard SecCodeCopySigningInformation( guard SecCodeCopySigningInformation(
staticCode, staticCode,
SecCSFlags(rawValue: kSecCSSigningInformation), SecCSFlags(rawValue: kSecCSSigningInformation),
&infoCF) == errSecSuccess, &infoCF) == errSecSuccess,
let info = infoCF as? [String: Any] let info = infoCF as? [String: Any]
else { else {
return nil return nil
} }
@@ -106,9 +106,9 @@ private final class ClawdbotPeekabooBridgeServices: PeekabooBridgeServiceProvidi
let feedbackClient: any AutomationFeedbackClient = NoopAutomationFeedbackClient() let feedbackClient: any AutomationFeedbackClient = NoopAutomationFeedbackClient()
let snapshots = InMemorySnapshotManager(options: .init( let snapshots = InMemorySnapshotManager(options: .init(
snapshotValidityWindow: 600, snapshotValidityWindow: 600,
maxSnapshots: 50, maxSnapshots: 50,
deleteArtifactsOnCleanup: false)) deleteArtifactsOnCleanup: false))
let applications = ApplicationService(feedbackClient: feedbackClient) let applications = ApplicationService(feedbackClient: feedbackClient)
let screenCapture = ScreenCaptureService(loggingService: logging) let screenCapture = ScreenCaptureService(loggingService: logging)
@@ -158,10 +158,10 @@ actor PortGuardian {
mode: mode, mode: mode,
listeners: listeners) listeners: listeners)
reports.append(Self.buildReport( reports.append(Self.buildReport(
port: port, port: port,
listeners: listeners, listeners: listeners,
mode: mode, mode: mode,
tunnelHealthy: tunnelHealthy)) tunnelHealthy: tunnelHealthy))
} }
return reports return reports
@@ -105,8 +105,8 @@ final class RemotePortTunnel {
return return
} }
guard let line = String(data: data, encoding: .utf8)? guard let line = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines), .trimmingCharacters(in: .whitespacesAndNewlines),
!line.isEmpty !line.isEmpty
else { return } else { return }
Self.logger.error("ssh tunnel stderr: \(line, privacy: .public)") Self.logger.error("ssh tunnel stderr: \(line, privacy: .public)")
} }
@@ -42,11 +42,11 @@ struct RuntimeResolution {
enum RuntimeResolutionError: Error { enum RuntimeResolutionError: Error {
case notFound(searchPaths: [String]) case notFound(searchPaths: [String])
case unsupported( case unsupported(
kind: RuntimeKind, kind: RuntimeKind,
found: RuntimeVersion, found: RuntimeVersion,
required: RuntimeVersion, required: RuntimeVersion,
path: String, path: String,
searchPaths: [String]) searchPaths: [String])
case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String]) case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String])
} }
@@ -65,21 +65,21 @@ enum RuntimeLocator {
} }
guard let rawVersion = readVersion(of: binary, pathEnv: pathEnv) else { guard let rawVersion = readVersion(of: binary, pathEnv: pathEnv) else {
return .failure(.versionParse( return .failure(.versionParse(
kind: runtime, kind: runtime,
raw: "(unreadable)", raw: "(unreadable)",
path: binary, path: binary,
searchPaths: searchPaths)) searchPaths: searchPaths))
} }
guard let parsed = RuntimeVersion.from(string: rawVersion) else { guard let parsed = RuntimeVersion.from(string: rawVersion) else {
return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths)) return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths))
} }
guard parsed >= self.minNode else { guard parsed >= self.minNode else {
return .failure(.unsupported( return .failure(.unsupported(
kind: runtime, kind: runtime,
found: parsed, found: parsed,
required: self.minNode, required: self.minNode,
path: binary, path: binary,
searchPaths: searchPaths)) searchPaths: searchPaths))
} }
return .success(RuntimeResolution(kind: runtime, path: binary, version: parsed)) return .success(RuntimeResolution(kind: runtime, path: binary, version: parsed))
@@ -251,11 +251,11 @@ private final class StreamRecorder: NSObject, SCStreamOutput, SCStreamDelegate,
if let err = self.writer.error { if let err = self.writer.error {
cont cont
.resume(throwing: ScreenRecordService.ScreenRecordError .resume(throwing: ScreenRecordService.ScreenRecordError
.writeFailed(err.localizedDescription)) .writeFailed(err.localizedDescription))
} else if self.writer.status != .completed { } else if self.writer.status != .completed {
cont cont
.resume(throwing: ScreenRecordService.ScreenRecordError .resume(throwing: ScreenRecordService.ScreenRecordError
.writeFailed("Failed to finalize video")) .writeFailed("Failed to finalize video"))
} else { } else {
cont.resume() cont.resume()
} }
@@ -54,9 +54,9 @@ enum SoundEffectCatalog {
var map: [String: URL] = [:] var map: [String: URL] = [:]
for root in Self.searchRoots { for root in Self.searchRoots {
guard let contents = try? FileManager.default.contentsOfDirectory( guard let contents = try? FileManager.default.contentsOfDirectory(
at: root, at: root,
includingPropertiesForKeys: nil, includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]) options: [.skipsHiddenFiles])
else { continue } else { continue }
for url in contents where Self.allowedExtensions.contains(url.pathExtension.lowercased()) { for url in contents where Self.allowedExtensions.contains(url.pathExtension.lowercased()) {
@@ -88,9 +88,9 @@ enum SoundEffectPlayer {
static func sound(from bookmark: Data) -> NSSound? { static func sound(from bookmark: Data) -> NSSound? {
var stale = false var stale = false
guard let url = try? URL( guard let url = try? URL(
resolvingBookmarkData: bookmark, resolvingBookmarkData: bookmark,
options: [.withoutUI, .withSecurityScope], options: [.withoutUI, .withSecurityScope],
bookmarkDataIsStale: &stale) bookmarkDataIsStale: &stale)
else { return nil } else { return nil }
let scoped = url.startAccessingSecurityScopedResource() let scoped = url.startAccessingSecurityScopedResource()
@@ -354,9 +354,9 @@ actor TalkModeRuntime {
"session=\(sessionKey, privacy: .public)") "session=\(sessionKey, privacy: .public)")
guard let assistantText = await self.waitForAssistantText( guard let assistantText = await self.waitForAssistantText(
sessionKey: sessionKey, sessionKey: sessionKey,
since: startedAt, since: startedAt,
timeoutSeconds: 45) timeoutSeconds: 45)
else { else {
self.logger.warning("talk assistant text missing after timeout") self.logger.warning("talk assistant text missing after timeout")
await self.startListening() await self.startListening()
@@ -48,12 +48,12 @@ enum VoiceWakeForwarder {
let payload = Self.prefixedTranscript(transcript) let payload = Self.prefixedTranscript(transcript)
let deliver = options.channel.shouldDeliver(options.deliver) let deliver = options.channel.shouldDeliver(options.deliver)
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: payload, message: payload,
sessionKey: options.sessionKey, sessionKey: options.sessionKey,
thinking: options.thinking, thinking: options.thinking,
deliver: deliver, deliver: deliver,
to: options.to, to: options.to,
channel: options.channel)) channel: options.channel))
if result.ok { if result.ok {
self.logger.info("voice wake forward ok") self.logger.info("voice wake forward ok")
@@ -493,10 +493,10 @@ actor VoiceWakeRuntime {
config: WakeWordGateConfig) -> WakeWordGateMatch? config: WakeWordGateConfig) -> WakeWordGateMatch?
{ {
guard let command = VoiceWakeTextUtils.textOnlyCommand( guard let command = VoiceWakeTextUtils.textOnlyCommand(
transcript: transcript, transcript: transcript,
triggers: triggers, triggers: triggers,
minCommandLength: config.minCommandLength, minCommandLength: config.minCommandLength,
trimWake: Self.trimmedAfterTrigger) trimWake: Self.trimmedAfterTrigger)
else { return nil } else { return nil }
return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command) return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
} }
@@ -519,9 +519,9 @@ actor VoiceWakeRuntime {
guard let lastSeenAt, let lastText else { return } guard let lastSeenAt, let lastText else { return }
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return } guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
guard let match = self.textOnlyFallbackMatch( guard let match = self.textOnlyFallbackMatch(
transcript: lastText, transcript: lastText,
triggers: triggers, triggers: triggers,
config: gateConfig) config: gateConfig)
else { return } else { return }
if let cooldown = self.cooldownUntil, Date() < cooldown { if let cooldown = self.cooldownUntil, Date() < cooldown {
return return
@@ -155,7 +155,7 @@ struct VoiceWakeSettings: View {
Label("Add word", systemImage: "plus") Label("Add word", systemImage: "plus")
} }
.disabled(self.state.swabbleTriggerWords .disabled(self.state.swabbleTriggerWords
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) .contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers } Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers }
} }
@@ -440,21 +440,21 @@ struct VoiceWakeSettings: View {
{ idx, localeID in { idx, localeID in
HStack(spacing: 8) { HStack(spacing: 8) {
Picker("Extra \(idx + 1)", selection: Binding( Picker("Extra \(idx + 1)", selection: Binding(
get: { localeID }, get: { localeID },
set: { newValue in set: { newValue in
guard self.state guard self.state
.voiceWakeAdditionalLocaleIDs.indices .voiceWakeAdditionalLocaleIDs.indices
.contains(idx) else { return } .contains(idx) else { return }
self.state self.state
.voiceWakeAdditionalLocaleIDs[idx] = .voiceWakeAdditionalLocaleIDs[idx] =
newValue newValue
})) { })) {
ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in
Text(self.friendlyName(for: Locale(identifier: id))).tag(id) Text(self.friendlyName(for: Locale(identifier: id))).tag(id)
}
} }
.labelsHidden() }
.frame(width: 220) .labelsHidden()
.frame(width: 220)
Button { Button {
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return } guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
@@ -360,10 +360,10 @@ final class VoiceWakeTester {
config: WakeWordGateConfig) -> WakeWordGateMatch? config: WakeWordGateConfig) -> WakeWordGateMatch?
{ {
guard let command = VoiceWakeTextUtils.textOnlyCommand( guard let command = VoiceWakeTextUtils.textOnlyCommand(
transcript: transcript, transcript: transcript,
triggers: triggers, triggers: triggers,
minCommandLength: config.minCommandLength, minCommandLength: config.minCommandLength,
trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) }) trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) })
else { return nil } else { return nil }
return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command) return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
} }
@@ -408,9 +408,9 @@ final class VoiceWakeTester {
guard let lastSeenAt, let lastText else { return } guard let lastSeenAt, let lastText else { return }
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return } guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
guard let match = self.textOnlyFallbackMatch( guard let match = self.textOnlyFallbackMatch(
transcript: lastText, transcript: lastText,
triggers: triggers, triggers: triggers,
config: WakeWordGateConfig(triggers: triggers)) else { return } config: WakeWordGateConfig(triggers: triggers)) else { return }
self.holdingAfterDetect = true self.holdingAfterDetect = true
self.detectedText = match.command self.detectedText = match.command
self.logger.info("voice wake detected (test, silence) (len=\(match.command.count))") self.logger.info("voice wake detected (test, silence) (len=\(match.command.count))")
@@ -92,8 +92,8 @@ struct MacGatewayChatTransport: ClawdbotChatTransport, Sendable {
switch push { switch push {
case let .snapshot(hello): case let .snapshot(hello):
let ok = (try? JSONDecoder().decode( let ok = (try? JSONDecoder().decode(
ClawdbotGatewayHealthOK.self, ClawdbotGatewayHealthOK.self,
from: JSONEncoder().encode(hello.snapshot.health)))?.ok ?? true from: JSONEncoder().encode(hello.snapshot.health)))?.ok ?? true
return .health(ok: ok) return .health(ok: ok)
case let .event(evt): case let .event(evt):
@@ -101,16 +101,16 @@ struct MacGatewayChatTransport: ClawdbotChatTransport, Sendable {
case "health": case "health":
guard let payload = evt.payload else { return nil } guard let payload = evt.payload else { return nil }
let ok = (try? JSONDecoder().decode( let ok = (try? JSONDecoder().decode(
ClawdbotGatewayHealthOK.self, ClawdbotGatewayHealthOK.self,
from: JSONEncoder().encode(payload)))?.ok ?? true from: JSONEncoder().encode(payload)))?.ok ?? true
return .health(ok: ok) return .health(ok: ok)
case "tick": case "tick":
return .tick return .tick
case "chat": case "chat":
guard let payload = evt.payload else { return nil } guard let payload = evt.payload else { return nil }
guard let chat = try? JSONDecoder().decode( guard let chat = try? JSONDecoder().decode(
ClawdbotChatEventPayload.self, ClawdbotChatEventPayload.self,
from: JSONEncoder().encode(payload)) from: JSONEncoder().encode(payload))
else { else {
return nil return nil
} }
@@ -118,8 +118,8 @@ struct MacGatewayChatTransport: ClawdbotChatTransport, Sendable {
case "agent": case "agent":
guard let payload = evt.payload else { return nil } guard let payload = evt.payload else { return nil }
guard let agent = try? JSONDecoder().decode( guard let agent = try? JSONDecoder().decode(
ClawdbotAgentEventPayload.self, ClawdbotAgentEventPayload.self,
from: JSONEncoder().encode(payload)) from: JSONEncoder().encode(payload))
else { else {
return nil return nil
} }
@@ -157,9 +157,9 @@ final class WebChatSwiftUIWindowController {
let vm = ClawdbotChatViewModel(sessionKey: sessionKey, transport: transport) let vm = ClawdbotChatViewModel(sessionKey: sessionKey, transport: transport)
let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex) let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex)
self.hosting = NSHostingController(rootView: ClawdbotChatView( self.hosting = NSHostingController(rootView: ClawdbotChatView(
viewModel: vm, viewModel: vm,
showsSessionSwitcher: true, showsSessionSwitcher: true,
userAccent: accent)) userAccent: accent))
self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting) self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting)
self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController) self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController)
} }
@@ -28,9 +28,11 @@ final class WorkActivityStore {
private var currentSessionKey: String? private var currentSessionKey: String?
private var toolSeqBySession: [String: Int] = [:] private var toolSeqBySession: [String: Int] = [:]
private let mainSessionKey = "main" private var mainSessionKeyStorage = "main"
private let toolResultGrace: TimeInterval = 2.0 private let toolResultGrace: TimeInterval = 2.0
var mainSessionKey: String { self.mainSessionKeyStorage }
func handleJob(sessionKey: String, state: String) { func handleJob(sessionKey: String, state: String) {
let isStart = state.lowercased() == "started" || state.lowercased() == "streaming" let isStart = state.lowercased() == "started" || state.lowercased() == "streaming"
if isStart { if isStart {
@@ -129,6 +131,17 @@ final class WorkActivityStore {
self.refreshDerivedState() self.refreshDerivedState()
} }
func setMainSessionKey(_ sessionKey: String) {
let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard trimmed != self.mainSessionKeyStorage else { return }
self.mainSessionKeyStorage = trimmed
if let current = self.currentSessionKey, !self.isActive(sessionKey: current) {
self.pickNextSession()
}
self.refreshDerivedState()
}
private func clearJob(sessionKey: String) { private func clearJob(sessionKey: String) {
guard self.jobs[sessionKey] != nil else { return } guard self.jobs[sessionKey] != nil else { return }
self.jobs.removeValue(forKey: sessionKey) self.jobs.removeValue(forKey: sessionKey)
@@ -151,8 +164,8 @@ final class WorkActivityStore {
private func pickNextSession() { private func pickNextSession() {
// Prefer main if present. // Prefer main if present.
if self.isActive(sessionKey: self.mainSessionKey) { if self.isActive(sessionKey: self.mainSessionKeyStorage) {
self.currentSessionKey = self.mainSessionKey self.currentSessionKey = self.mainSessionKeyStorage
return return
} }
@@ -163,7 +176,7 @@ final class WorkActivityStore {
} }
private func role(for sessionKey: String) -> SessionRole { private func role(for sessionKey: String) -> SessionRole {
sessionKey == self.mainSessionKey ? .main : .other sessionKey == self.mainSessionKeyStorage ? .main : .other
} }
private func isActive(sessionKey: String) -> Bool { private func isActive(sessionKey: String) -> Bool {
@@ -41,12 +41,12 @@ enum WideAreaGatewayDiscovery {
} }
guard let ips = collectTailnetIPv4s( guard let ips = collectTailnetIPv4s(
statusJson: context.tailscaleStatus()).nonEmpty else { return [] } statusJson: context.tailscaleStatus()).nonEmpty else { return [] }
var candidates = Array(ips.prefix(self.maxCandidates)) var candidates = Array(ips.prefix(self.maxCandidates))
guard let nameserver = findNameserver( guard let nameserver = findNameserver(
candidates: &candidates, candidates: &candidates,
remaining: remaining, remaining: remaining,
dig: context.dig) dig: context.dig)
else { else {
return [] return []
} }
@@ -55,9 +55,9 @@ enum WideAreaGatewayDiscovery {
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)" let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)"
guard let ptrLines = context.dig( guard let ptrLines = context.dig(
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"], ["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline), min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
!ptrLines.isEmpty !ptrLines.isEmpty
else { else {
return [] return []
} }
@@ -74,8 +74,8 @@ enum WideAreaGatewayDiscovery {
let instanceName = self.decodeDnsSdEscapes(rawInstanceName) let instanceName = self.decodeDnsSdEscapes(rawInstanceName)
guard let srv = context.dig( guard let srv = context.dig(
["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "SRV"], ["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "SRV"],
min(defaultTimeoutSeconds, remaining())) min(defaultTimeoutSeconds, remaining()))
else { continue } else { continue }
guard let (host, port) = parseSrv(srv) else { continue } guard let (host, port) = parseSrv(srv) else { continue }
@@ -198,7 +198,7 @@ enum WideAreaGatewayDiscovery {
if let stdout = dig( if let stdout = dig(
["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"], ["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"],
min(defaultTimeoutSeconds, budget)), min(defaultTimeoutSeconds, budget)),
stdout.split(whereSeparator: \.isNewline).isEmpty == false stdout.split(whereSeparator: \.isNewline).isEmpty == false
{ {
state.lock.lock() state.lock.lock()
if state.found == nil { if state.found == nil {
+13 -13
View File
@@ -107,18 +107,18 @@ public enum CanvasA2UICommand: String, Codable, Sendable {
public enum Request: Sendable { public enum Request: Sendable {
case notify( case notify(
title: String, title: String,
body: String, body: String,
sound: String?, sound: String?,
priority: NotificationPriority?, priority: NotificationPriority?,
delivery: NotificationDelivery?) delivery: NotificationDelivery?)
case ensurePermissions([Capability], interactive: Bool) case ensurePermissions([Capability], interactive: Bool)
case runShell( case runShell(
command: [String], command: [String],
cwd: String?, cwd: String?,
env: [String: String]?, env: [String: String]?,
timeoutSec: Double?, timeoutSec: Double?,
needsScreenRecording: Bool) needsScreenRecording: Bool)
case status case status
case agent(message: String, thinking: String?, session: String?, deliver: Bool, to: String?) case agent(message: String, thinking: String?, session: String?, deliver: Bool, to: String?)
case rpcStatus case rpcStatus
@@ -410,6 +410,6 @@ extension Request: Codable {
// Shared transport settings // Shared transport settings
public let controlSocketPath = public let controlSocketPath =
FileManager.default FileManager.default
.homeDirectoryForCurrentUser .homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/clawdbot/control.sock") .appendingPathComponent("Library/Application Support/clawdbot/control.sock")
.path .path
@@ -252,6 +252,7 @@ public struct Snapshot: Codable, Sendable {
public let uptimems: Int public let uptimems: Int
public let configpath: String? public let configpath: String?
public let statedir: String? public let statedir: String?
public let sessiondefaults: [String: AnyCodable]?
public init( public init(
presence: [PresenceEntry], presence: [PresenceEntry],
@@ -259,7 +260,8 @@ public struct Snapshot: Codable, Sendable {
stateversion: StateVersion, stateversion: StateVersion,
uptimems: Int, uptimems: Int,
configpath: String?, configpath: String?,
statedir: String? statedir: String?,
sessiondefaults: [String: AnyCodable]?
) { ) {
self.presence = presence self.presence = presence
self.health = health self.health = health
@@ -267,6 +269,7 @@ public struct Snapshot: Codable, Sendable {
self.uptimems = uptimems self.uptimems = uptimems
self.configpath = configpath self.configpath = configpath
self.statedir = statedir self.statedir = statedir
self.sessiondefaults = sessiondefaults
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case presence case presence
@@ -275,6 +278,7 @@ public struct Snapshot: Codable, Sendable {
case uptimems = "uptimeMs" case uptimems = "uptimeMs"
case configpath = "configPath" case configpath = "configPath"
case statedir = "stateDir" case statedir = "stateDir"
case sessiondefaults = "sessionDefaults"
} }
} }
@@ -845,42 +849,67 @@ public struct ConfigGetParams: Codable, Sendable {
public struct ConfigSetParams: Codable, Sendable { public struct ConfigSetParams: Codable, Sendable {
public let raw: String public let raw: String
public let basehash: String?
public init( public init(
raw: String raw: String,
basehash: String?
) { ) {
self.raw = raw self.raw = raw
self.basehash = basehash
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case raw case raw
case basehash = "baseHash"
} }
} }
public struct ConfigApplyParams: Codable, Sendable { public struct ConfigApplyParams: Codable, Sendable {
public let raw: String public let raw: String
public let basehash: String?
public let sessionkey: String? public let sessionkey: String?
public let note: String? public let note: String?
public let restartdelayms: Int? public let restartdelayms: Int?
public init( public init(
raw: String, raw: String,
basehash: String?,
sessionkey: String?, sessionkey: String?,
note: String?, note: String?,
restartdelayms: Int? restartdelayms: Int?
) { ) {
self.raw = raw self.raw = raw
self.basehash = basehash
self.sessionkey = sessionkey self.sessionkey = sessionkey
self.note = note self.note = note
self.restartdelayms = restartdelayms self.restartdelayms = restartdelayms
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case raw case raw
case basehash = "baseHash"
case sessionkey = "sessionKey" case sessionkey = "sessionKey"
case note case note
case restartdelayms = "restartDelayMs" case restartdelayms = "restartDelayMs"
} }
} }
public struct ConfigPatchParams: Codable, Sendable {
public let raw: String
public let basehash: String?
public init(
raw: String,
basehash: String?
) {
self.raw = raw
self.basehash = basehash
}
private enum CodingKeys: String, CodingKey {
case raw
case basehash = "baseHash"
}
}
public struct ConfigSchemaParams: Codable, Sendable { public struct ConfigSchemaParams: Codable, Sendable {
} }
@@ -4,22 +4,22 @@ import Testing
@Suite(.serialized) @Suite(.serialized)
@MainActor @MainActor
struct ConnectionsSettingsSmokeTests { struct ConnectionsSettingsSmokeTests {
@Test func connectionsSettingsBuildsBodyWithSnapshot() { @Test func connectionsSettingsBuildsBodyWithSnapshot() {
let store = ConnectionsStore(isPreview: true) let store = ConnectionsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot( store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000, ts: 1_700_000_000_000,
channelOrder: ["whatsapp", "telegram", "signal", "imessage"], channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
channelLabels: [ channelLabels: [
"whatsapp": "WhatsApp", "whatsapp": "WhatsApp",
"telegram": "Telegram", "telegram": "Telegram",
"signal": "Signal", "signal": "Signal",
"imessage": "iMessage", "imessage": "iMessage",
], ],
channels: [ channels: [
"whatsapp": AnyCodable([ "whatsapp": AnyCodable([
"configured": true, "configured": true,
"linked": true, "linked": true,
"authAgeMs": 86_400_000, "authAgeMs": 86_400_000,
"self": ["e164": "+15551234567"], "self": ["e164": "+15551234567"],
"running": true, "running": true,
@@ -70,13 +70,13 @@ import Testing
"lastError": "not configured", "lastError": "not configured",
"probe": ["ok": false, "error": "imsg not found (imsg)"], "probe": ["ok": false, "error": "imsg not found (imsg)"],
"lastProbeAt": 1_700_000_050_000, "lastProbeAt": 1_700_000_050_000,
]), ]),
], ],
channelAccounts: [:], channelAccounts: [:],
channelDefaultAccountId: [ channelDefaultAccountId: [
"whatsapp": "default", "whatsapp": "default",
"telegram": "default", "telegram": "default",
"signal": "default", "signal": "default",
"imessage": "default", "imessage": "default",
]) ])
@@ -93,23 +93,23 @@ import Testing
let view = ConnectionsSettings(store: store) let view = ConnectionsSettings(store: store)
_ = view.body _ = view.body
} }
@Test func connectionsSettingsBuildsBodyWithoutSnapshot() { @Test func connectionsSettingsBuildsBodyWithoutSnapshot() {
let store = ConnectionsStore(isPreview: true) let store = ConnectionsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot( store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000, ts: 1_700_000_000_000,
channelOrder: ["whatsapp", "telegram", "signal", "imessage"], channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
channelLabels: [ channelLabels: [
"whatsapp": "WhatsApp", "whatsapp": "WhatsApp",
"telegram": "Telegram", "telegram": "Telegram",
"signal": "Signal", "signal": "Signal",
"imessage": "iMessage", "imessage": "iMessage",
], ],
channels: [ channels: [
"whatsapp": AnyCodable([ "whatsapp": AnyCodable([
"configured": false, "configured": false,
"linked": false, "linked": false,
"running": false, "running": false,
"connected": false, "connected": false,
"reconnectAttempts": 0, "reconnectAttempts": 0,
@@ -146,13 +146,13 @@ import Testing
"cliPath": "imsg", "cliPath": "imsg",
"probe": ["ok": false, "error": "imsg not found (imsg)"], "probe": ["ok": false, "error": "imsg not found (imsg)"],
"lastProbeAt": 1_700_000_200_000, "lastProbeAt": 1_700_000_200_000,
]), ]),
], ],
channelAccounts: [:], channelAccounts: [:],
channelDefaultAccountId: [ channelDefaultAccountId: [
"whatsapp": "default", "whatsapp": "default",
"telegram": "default", "telegram": "default",
"signal": "default", "signal": "default",
"imessage": "default", "imessage": "default",
]) ])
@@ -1,36 +1,63 @@
import Foundation
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
@Suite(.serialized) @Suite struct GatewayEndpointStoreTests {
struct GatewayEndpointStoreTests { @Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() {
@Test func resolvesLocalHostFromBindModes() { let snapshot = LaunchAgentPlistSnapshot(
#expect(GatewayEndpointStore._testResolveLocalGatewayHost( programArguments: [],
bindMode: "loopback", environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
tailscaleIP: "100.64.0.10") == "127.0.0.1") port: nil,
#expect(GatewayEndpointStore._testResolveLocalGatewayHost( bind: nil,
bindMode: "lan", token: "launchd-token",
tailscaleIP: "100.64.0.10") == "127.0.0.1") password: nil)
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet", let envToken = GatewayEndpointStore._testResolveGatewayToken(
tailscaleIP: "100.64.0.10") == "100.64.0.10") isRemote: false,
#expect(GatewayEndpointStore._testResolveLocalGatewayHost( root: [:],
bindMode: "tailnet", env: ["CLAWDBOT_GATEWAY_TOKEN": "env-token"],
tailscaleIP: nil) == "127.0.0.1") launchdSnapshot: snapshot)
#expect(GatewayEndpointStore._testResolveLocalGatewayHost( #expect(envToken == "env-token")
bindMode: "auto",
tailscaleIP: "100.64.0.10") == "100.64.0.10") let fallbackToken = GatewayEndpointStore._testResolveGatewayToken(
#expect(GatewayEndpointStore._testResolveLocalGatewayHost( isRemote: false,
bindMode: "auto", root: [:],
tailscaleIP: nil) == "127.0.0.1") env: [:],
launchdSnapshot: snapshot)
#expect(fallbackToken == "launchd-token")
} }
@Test func resolvesBindModeFromEnvOrConfig() { @Test func resolveGatewayTokenIgnoresLaunchdInRemoteMode() {
let root: [String: Any] = ["gateway": ["bind": "tailnet"]] let snapshot = LaunchAgentPlistSnapshot(
#expect(GatewayEndpointStore._testResolveGatewayBindMode( programArguments: [],
root: root, environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
env: [:]) == "tailnet") port: nil,
#expect(GatewayEndpointStore._testResolveGatewayBindMode( bind: nil,
root: root, token: "launchd-token",
env: ["CLAWDBOT_GATEWAY_BIND": "lan"]) == "lan") password: nil)
let token = GatewayEndpointStore._testResolveGatewayToken(
isRemote: true,
root: [:],
env: [:],
launchdSnapshot: snapshot)
#expect(token == nil)
}
@Test func resolveGatewayPasswordFallsBackToLaunchd() {
let snapshot = LaunchAgentPlistSnapshot(
programArguments: [],
environment: ["CLAWDBOT_GATEWAY_PASSWORD": "launchd-pass"],
port: nil,
bind: nil,
token: nil,
password: "launchd-pass")
let password = GatewayEndpointStore._testResolveGatewayPassword(
isRemote: false,
root: [:],
env: [:],
launchdSnapshot: snapshot)
#expect(password == "launchd-pass")
} }
} }
@@ -2,9 +2,9 @@ import Foundation
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
@Suite struct HealthDecodeTests { @Suite struct HealthDecodeTests {
private let sampleJSON: String = // minimal but complete payload private let sampleJSON: String = // minimal but complete payload
""" """
{"ts":1733622000,"durationMs":420,"channels":{"whatsapp":{"linked":true,"authAgeMs":120000},"telegram":{"configured":true,"probe":{"ok":true,"elapsedMs":800}}},"channelOrder":["whatsapp","telegram"],"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}} {"ts":1733622000,"durationMs":420,"channels":{"whatsapp":{"linked":true,"authAgeMs":120000},"telegram":{"configured":true,"probe":{"ok":true,"elapsedMs":800}}},"channelOrder":["whatsapp","telegram"],"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}}
""" """
@@ -0,0 +1,24 @@
import Darwin
import Foundation
import Testing
@testable import Clawdbot
@Suite struct LogLocatorTests {
@Test func launchdGatewayLogPathEnsuresTmpDirExists() throws {
let fm = FileManager.default
let baseDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let logDir = baseDir.appendingPathComponent("clawdbot-tests-\(UUID().uuidString)")
setenv("CLAWDBOT_LOG_DIR", logDir.path, 1)
defer {
unsetenv("CLAWDBOT_LOG_DIR")
try? fm.removeItem(at: logDir)
}
_ = LogLocator.launchdGatewayLogPath
var isDir: ObjCBool = false
#expect(fm.fileExists(atPath: logDir.path, isDirectory: &isDir))
#expect(isDir.boolValue == true)
}
}
@@ -100,11 +100,18 @@ public struct BridgeHelloOk: Codable, Sendable {
public let type: String public let type: String
public let serverName: String public let serverName: String
public let canvasHostUrl: String? public let canvasHostUrl: String?
public let mainSessionKey: String?
public init(type: String = "hello-ok", serverName: String, canvasHostUrl: String? = nil) { public init(
type: String = "hello-ok",
serverName: String,
canvasHostUrl: String? = nil,
mainSessionKey: String? = nil)
{
self.type = type self.type = type
self.serverName = serverName self.serverName = serverName
self.canvasHostUrl = canvasHostUrl self.canvasHostUrl = canvasHostUrl
self.mainSessionKey = mainSessionKey
} }
} }
+22
View File
@@ -0,0 +1,22 @@
# Clawdbot Chrome Extension (Browser Relay)
Purpose: attach Clawdbot to an existing Chrome tab so the Gateway can automate it (via the local CDP relay server).
## Dev / load unpacked
1. Build/run Clawdbot Gateway with browser control enabled.
2. Ensure the relay server is reachable at `http://127.0.0.1:18792/` (default).
3. Install the extension to a stable path:
```bash
clawdbot browser extension install
clawdbot browser extension path
```
4. Chrome → `chrome://extensions` → enable “Developer mode”.
5. “Load unpacked” → select the path printed above.
6. Pin the extension. Click the icon on a tab to attach/detach.
## Options
- `Relay port`: defaults to `18792`.
+438
View File
@@ -0,0 +1,438 @@
const DEFAULT_PORT = 18792
const BADGE = {
on: { text: 'ON', color: '#FF5A36' },
off: { text: '', color: '#000000' },
connecting: { text: '…', color: '#F59E0B' },
error: { text: '!', color: '#B91C1C' },
}
/** @type {WebSocket|null} */
let relayWs = null
/** @type {Promise<void>|null} */
let relayConnectPromise = null
let debuggerListenersInstalled = false
let nextSession = 1
/** @type {Map<number, {state:'connecting'|'connected', sessionId?:string, targetId?:string, attachOrder?:number}>} */
const tabs = new Map()
/** @type {Map<string, number>} */
const tabBySession = new Map()
/** @type {Map<string, number>} */
const childSessionToTab = new Map()
/** @type {Map<number, {resolve:(v:any)=>void, reject:(e:Error)=>void}>} */
const pending = new Map()
function nowStack() {
try {
return new Error().stack || ''
} catch {
return ''
}
}
async function getRelayPort() {
const stored = await chrome.storage.local.get(['relayPort'])
const raw = stored.relayPort
const n = Number.parseInt(String(raw || ''), 10)
if (!Number.isFinite(n) || n <= 0 || n > 65535) return DEFAULT_PORT
return n
}
function setBadge(tabId, kind) {
const cfg = BADGE[kind]
void chrome.action.setBadgeText({ tabId, text: cfg.text })
void chrome.action.setBadgeBackgroundColor({ tabId, color: cfg.color })
void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {})
}
async function ensureRelayConnection() {
if (relayWs && relayWs.readyState === WebSocket.OPEN) return
if (relayConnectPromise) return await relayConnectPromise
relayConnectPromise = (async () => {
const port = await getRelayPort()
const httpBase = `http://127.0.0.1:${port}`
const wsUrl = `ws://127.0.0.1:${port}/extension`
// Fast preflight: is the relay server up?
try {
await fetch(`${httpBase}/`, { method: 'HEAD', signal: AbortSignal.timeout(2000) })
} catch (err) {
throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`)
}
const ws = new WebSocket(wsUrl)
relayWs = ws
await new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000)
ws.onopen = () => {
clearTimeout(t)
resolve()
}
ws.onerror = () => {
clearTimeout(t)
reject(new Error('WebSocket connect failed'))
}
ws.onclose = (ev) => {
clearTimeout(t)
reject(new Error(`WebSocket closed (${ev.code} ${ev.reason || 'no reason'})`))
}
})
ws.onmessage = (event) => void onRelayMessage(String(event.data || ''))
ws.onclose = () => onRelayClosed('closed')
ws.onerror = () => onRelayClosed('error')
if (!debuggerListenersInstalled) {
debuggerListenersInstalled = true
chrome.debugger.onEvent.addListener(onDebuggerEvent)
chrome.debugger.onDetach.addListener(onDebuggerDetach)
}
})()
try {
await relayConnectPromise
} finally {
relayConnectPromise = null
}
}
function onRelayClosed(reason) {
relayWs = null
for (const [id, p] of pending.entries()) {
pending.delete(id)
p.reject(new Error(`Relay disconnected (${reason})`))
}
for (const tabId of tabs.keys()) {
void chrome.debugger.detach({ tabId }).catch(() => {})
setBadge(tabId, 'connecting')
void chrome.action.setTitle({
tabId,
title: 'Clawdbot Browser Relay: disconnected (click to re-attach)',
})
}
tabs.clear()
tabBySession.clear()
childSessionToTab.clear()
}
function sendToRelay(payload) {
const ws = relayWs
if (!ws || ws.readyState !== WebSocket.OPEN) {
throw new Error('Relay not connected')
}
ws.send(JSON.stringify(payload))
}
async function maybeOpenHelpOnce() {
try {
const stored = await chrome.storage.local.get(['helpOnErrorShown'])
if (stored.helpOnErrorShown === true) return
await chrome.storage.local.set({ helpOnErrorShown: true })
await chrome.runtime.openOptionsPage()
} catch {
// ignore
}
}
function requestFromRelay(command) {
const id = command.id
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject })
try {
sendToRelay(command)
} catch (err) {
pending.delete(id)
reject(err instanceof Error ? err : new Error(String(err)))
}
})
}
async function onRelayMessage(text) {
/** @type {any} */
let msg
try {
msg = JSON.parse(text)
} catch {
return
}
if (msg && msg.method === 'ping') {
try {
sendToRelay({ method: 'pong' })
} catch {
// ignore
}
return
}
if (msg && typeof msg.id === 'number' && (msg.result !== undefined || msg.error !== undefined)) {
const p = pending.get(msg.id)
if (!p) return
pending.delete(msg.id)
if (msg.error) p.reject(new Error(String(msg.error)))
else p.resolve(msg.result)
return
}
if (msg && typeof msg.id === 'number' && msg.method === 'forwardCDPCommand') {
try {
const result = await handleForwardCdpCommand(msg)
sendToRelay({ id: msg.id, result })
} catch (err) {
sendToRelay({ id: msg.id, error: err instanceof Error ? err.message : String(err) })
}
}
}
function getTabBySessionId(sessionId) {
const direct = tabBySession.get(sessionId)
if (direct) return { tabId: direct, kind: 'main' }
const child = childSessionToTab.get(sessionId)
if (child) return { tabId: child, kind: 'child' }
return null
}
function getTabByTargetId(targetId) {
for (const [tabId, tab] of tabs.entries()) {
if (tab.targetId === targetId) return tabId
}
return null
}
async function attachTab(tabId, opts = {}) {
const debuggee = { tabId }
await chrome.debugger.attach(debuggee, '1.3')
await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch(() => {})
const info = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo'))
const targetInfo = info?.targetInfo
const targetId = String(targetInfo?.targetId || '').trim()
if (!targetId) {
throw new Error('Target.getTargetInfo returned no targetId')
}
const sessionId = `cb-tab-${nextSession++}`
const attachOrder = nextSession
tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder })
tabBySession.set(sessionId, tabId)
void chrome.action.setTitle({
tabId,
title: 'Clawdbot Browser Relay: attached (click to detach)',
})
if (!opts.skipAttachedEvent) {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.attachedToTarget',
params: {
sessionId,
targetInfo: { ...targetInfo, attached: true },
waitingForDebugger: false,
},
},
})
}
setBadge(tabId, 'on')
return { sessionId, targetId }
}
async function detachTab(tabId, reason) {
const tab = tabs.get(tabId)
if (tab?.sessionId && tab?.targetId) {
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.detachedFromTarget',
params: { sessionId: tab.sessionId, targetId: tab.targetId, reason },
},
})
} catch {
// ignore
}
}
if (tab?.sessionId) tabBySession.delete(tab.sessionId)
tabs.delete(tabId)
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
if (parentTabId === tabId) childSessionToTab.delete(childSessionId)
}
try {
await chrome.debugger.detach({ tabId })
} catch {
// ignore
}
setBadge(tabId, 'off')
void chrome.action.setTitle({
tabId,
title: 'Clawdbot Browser Relay (click to attach/detach)',
})
}
async function connectOrToggleForActiveTab() {
const [active] = await chrome.tabs.query({ active: true, currentWindow: true })
const tabId = active?.id
if (!tabId) return
const existing = tabs.get(tabId)
if (existing?.state === 'connected') {
await detachTab(tabId, 'toggle')
return
}
tabs.set(tabId, { state: 'connecting' })
setBadge(tabId, 'connecting')
void chrome.action.setTitle({
tabId,
title: 'Clawdbot Browser Relay: connecting to local relay…',
})
try {
await ensureRelayConnection()
await attachTab(tabId)
} catch (err) {
tabs.delete(tabId)
setBadge(tabId, 'error')
void chrome.action.setTitle({
tabId,
title: 'Clawdbot Browser Relay: relay not running (open options for setup)',
})
void maybeOpenHelpOnce()
// Extra breadcrumbs in chrome://extensions service worker logs.
const message = err instanceof Error ? err.message : String(err)
console.warn('attach failed', message, nowStack())
}
}
async function handleForwardCdpCommand(msg) {
const method = String(msg?.params?.method || '').trim()
const params = msg?.params?.params || undefined
const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined
// Map command to tab
const bySession = sessionId ? getTabBySessionId(sessionId) : null
const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined
const tabId =
bySession?.tabId ||
(targetId ? getTabByTargetId(targetId) : null) ||
(() => {
// No sessionId: pick the first connected tab (stable-ish).
for (const [id, tab] of tabs.entries()) {
if (tab.state === 'connected') return id
}
return null
})()
if (!tabId) throw new Error(`No attached tab for method ${method}`)
/** @type {chrome.debugger.DebuggerSession} */
const debuggee = { tabId }
if (method === 'Runtime.enable') {
try {
await chrome.debugger.sendCommand(debuggee, 'Runtime.disable')
await new Promise((r) => setTimeout(r, 50))
} catch {
// ignore
}
return await chrome.debugger.sendCommand(debuggee, 'Runtime.enable', params)
}
if (method === 'Target.createTarget') {
const url = typeof params?.url === 'string' ? params.url : 'about:blank'
const tab = await chrome.tabs.create({ url, active: false })
if (!tab.id) throw new Error('Failed to create tab')
await new Promise((r) => setTimeout(r, 100))
const attached = await attachTab(tab.id)
return { targetId: attached.targetId }
}
if (method === 'Target.closeTarget') {
const target = typeof params?.targetId === 'string' ? params.targetId : ''
const toClose = target ? getTabByTargetId(target) : tabId
if (!toClose) return { success: false }
try {
await chrome.tabs.remove(toClose)
} catch {
return { success: false }
}
return { success: true }
}
if (method === 'Target.activateTarget') {
const target = typeof params?.targetId === 'string' ? params.targetId : ''
const toActivate = target ? getTabByTargetId(target) : tabId
if (!toActivate) return {}
const tab = await chrome.tabs.get(toActivate).catch(() => null)
if (!tab) return {}
if (tab.windowId) {
await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {})
}
await chrome.tabs.update(toActivate, { active: true }).catch(() => {})
return {}
}
const tabState = tabs.get(tabId)
const mainSessionId = tabState?.sessionId
const debuggerSession =
sessionId && mainSessionId && sessionId !== mainSessionId
? { ...debuggee, sessionId }
: debuggee
return await chrome.debugger.sendCommand(debuggerSession, method, params)
}
function onDebuggerEvent(source, method, params) {
const tabId = source.tabId
if (!tabId) return
const tab = tabs.get(tabId)
if (!tab?.sessionId) return
if (method === 'Target.attachedToTarget' && params?.sessionId) {
childSessionToTab.set(String(params.sessionId), tabId)
}
if (method === 'Target.detachedFromTarget' && params?.sessionId) {
childSessionToTab.delete(String(params.sessionId))
}
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
sessionId: source.sessionId || tab.sessionId,
method,
params,
},
})
} catch {
// ignore
}
}
function onDebuggerDetach(source, reason) {
const tabId = source.tabId
if (!tabId) return
if (!tabs.has(tabId)) return
void detachTab(tabId, reason)
}
chrome.action.onClicked.addListener(() => void connectOrToggleForActiveTab())
chrome.runtime.onInstalled.addListener(() => {
// Useful: first-time instructions.
void chrome.runtime.openOptionsPage()
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 B

+25
View File
@@ -0,0 +1,25 @@
{
"manifest_version": 3,
"name": "Clawdbot Browser Relay",
"version": "0.1.0",
"description": "Attach Clawdbot to your existing Chrome tab via a local CDP relay server.",
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"permissions": ["debugger", "tabs", "activeTab", "storage"],
"host_permissions": ["http://127.0.0.1/*", "http://localhost/*"],
"background": { "service_worker": "background.js", "type": "module" },
"action": {
"default_title": "Clawdbot Browser Relay (click to attach/detach)",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"options_ui": { "page": "options.html", "open_in_tab": true }
}
+197
View File
@@ -0,0 +1,197 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Clawdbot Browser Relay</title>
<style>
:root {
color-scheme: light dark;
--accent: #ff5a36;
--panel: color-mix(in oklab, canvas 92%, canvasText 8%);
--border: color-mix(in oklab, canvasText 18%, transparent);
--muted: color-mix(in oklab, canvasText 70%, transparent);
--shadow: 0 10px 30px color-mix(in oklab, canvasText 18%, transparent);
font-family: ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Rounded",
"SF Pro Display", "Segoe UI", sans-serif;
line-height: 1.4;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(1000px 500px at 10% 0%, color-mix(in oklab, var(--accent) 30%, transparent), transparent 70%),
radial-gradient(900px 450px at 90% 0%, color-mix(in oklab, var(--accent) 18%, transparent), transparent 75%),
canvas;
color: canvasText;
}
.wrap {
max-width: 820px;
margin: 36px auto;
padding: 0 24px 48px 24px;
}
header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.logo {
width: 44px;
height: 44px;
border-radius: 14px;
background: color-mix(in oklab, var(--accent) 18%, transparent);
border: 1px solid color-mix(in oklab, var(--accent) 35%, transparent);
box-shadow: var(--shadow);
display: grid;
place-items: center;
}
.logo img {
width: 28px;
height: 28px;
image-rendering: pixelated;
}
h1 {
font-size: 20px;
margin: 0;
letter-spacing: -0.01em;
}
.subtitle {
margin: 2px 0 0 0;
color: var(--muted);
font-size: 13px;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px;
box-shadow: var(--shadow);
}
.card h2 {
margin: 0 0 10px 0;
font-size: 14px;
letter-spacing: 0.01em;
}
.card p {
margin: 8px 0 0 0;
color: var(--muted);
font-size: 13px;
}
.row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
label {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
input {
width: 160px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: color-mix(in oklab, canvas 92%, canvasText 8%);
color: canvasText;
outline: none;
}
input:focus {
border-color: color-mix(in oklab, var(--accent) 70%, transparent);
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 20%, transparent);
}
button {
padding: 10px 14px;
border-radius: 12px;
border: 1px solid color-mix(in oklab, var(--accent) 55%, transparent);
background: linear-gradient(
180deg,
color-mix(in oklab, var(--accent) 80%, white 20%),
var(--accent)
);
color: white;
font-weight: 650;
letter-spacing: 0.01em;
cursor: pointer;
}
button:active {
transform: translateY(1px);
}
.hint {
margin-top: 10px;
font-size: 12px;
color: var(--muted);
}
code {
font-family: ui-monospace, Menlo, Monaco, Consolas, "SF Mono", monospace;
font-size: 12px;
}
a {
color: color-mix(in oklab, var(--accent) 85%, canvasText 15%);
}
.status {
margin-top: 10px;
font-size: 12px;
color: color-mix(in oklab, var(--accent) 70%, canvasText 30%);
min-height: 16px;
}
.status[data-kind='ok'] {
color: color-mix(in oklab, #16a34a 75%, canvasText 25%);
}
.status[data-kind='error'] {
color: color-mix(in oklab, #ef4444 75%, canvasText 25%);
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo" aria-hidden="true">
<img src="icons/icon128.png" alt="" />
</div>
<div>
<h1>Clawdbot Browser Relay</h1>
<p class="subtitle">Click the toolbar button on a tab to attach / detach.</p>
</div>
</header>
<div class="grid">
<div class="card">
<h2>Getting started</h2>
<p>
If you see a red <code>!</code> badge on the extension icon, the relay server is not reachable.
Start Clawdbot’s browser relay on this machine (Gateway or <code>clawdbot browser serve</code>),
then click the toolbar button again.
</p>
<p>
Full guide (install, remote Gateway, security): <a href="https://docs.clawd.bot/tools/chrome-extension" target="_blank" rel="noreferrer">docs.clawd.bot/tools/chrome-extension</a>
</p>
</div>
<div class="card">
<h2>Relay port</h2>
<label for="port">Port</label>
<div class="row">
<input id="port" inputmode="numeric" pattern="[0-9]*" />
<button id="save" type="button">Save</button>
</div>
<div class="hint">
Default: <code>18792</code>. Extension connects to: <code id="relay-url">http://127.0.0.1:&lt;port&gt;/</code>.
Only change this if your Clawdbot profile uses a different <code>cdpUrl</code> port.
</div>
<div class="status" id="status"></div>
</div>
</div>
<script type="module" src="options.js"></script>
</div>
</body>
</html>
+59
View File
@@ -0,0 +1,59 @@
const DEFAULT_PORT = 18792
function clampPort(value) {
const n = Number.parseInt(String(value || ''), 10)
if (!Number.isFinite(n)) return DEFAULT_PORT
if (n <= 0 || n > 65535) return DEFAULT_PORT
return n
}
function updateRelayUrl(port) {
const el = document.getElementById('relay-url')
if (!el) return
el.textContent = `http://127.0.0.1:${port}/`
}
function setStatus(kind, message) {
const status = document.getElementById('status')
if (!status) return
status.dataset.kind = kind || ''
status.textContent = message || ''
}
async function checkRelayReachable(port) {
const url = `http://127.0.0.1:${port}/`
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), 900)
try {
const res = await fetch(url, { method: 'HEAD', signal: ctrl.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setStatus('ok', `Relay reachable at ${url}`)
} catch {
setStatus(
'error',
`Relay not reachable at ${url}. Start Clawdbot’s browser relay on this machine, then click the toolbar button again.`,
)
} finally {
clearTimeout(t)
}
}
async function load() {
const stored = await chrome.storage.local.get(['relayPort'])
const port = clampPort(stored.relayPort)
document.getElementById('port').value = String(port)
updateRelayUrl(port)
await checkRelayReachable(port)
}
async function save() {
const input = document.getElementById('port')
const port = clampPort(input.value)
await chrome.storage.local.set({ relayPort: port })
input.value = String(port)
updateRelayUrl(port)
await checkRelayReachable(port)
}
document.getElementById('save').addEventListener('click', () => void save())
void load()
Loaded 100 of 688 files, more files were not shown because too many files have changed in this diff. Show more