Compare commits

..
Author SHA1 Message Date
Peter Steinberger f0961d3b82 fix: normalize pairing aliases and webhook guard (#991) (thanks @longmaba) 2026-01-16 04:53:40 +00:00
LongandAmp dbd4481bb1 fix(zalo): fix pairing channel detection and webhook payload format
Amp-Thread-ID: https://ampcode.com/threads/T-019bc4e0-fcb1-77be-b0b5-0d498f0c7197
Co-authored-by: Amp <amp@ampcode.com>
2026-01-16 04:46:42 +00:00
Peter Steinberger 236b27cb3a docs: align plugin changelogs 2026-01-16 04:15:48 +00:00
Peter Steinberger 1634abf293 docs: expand msteams plugin changelog 2026-01-16 04:15:48 +00:00
UbuntuandAlphonse-arianee ca9688b5cc feat(session): add dmScope for multi-user DM isolation
Co-authored-by: Alphonse-arianee <Alphonse-arianee@users.noreply.github.com>
2026-01-16 04:13:10 +00:00
Peter Steinberger e6364d031d fix: stabilize windows queue + pairing tests 2026-01-16 04:02:43 +00:00
Peter Steinberger 01c8d099ad fix: repair CI formatting + launchd test 2026-01-16 03:52:47 +00:00
Peter Steinberger f6e619f078 fix: normalize daemon unit paths 2026-01-16 03:47:26 +00:00
Peter Steinberger b2b331230b feat: mac node exec policy + remote skills hot reload 2026-01-16 03:45:06 +00:00
Peter Steinberger abcca86e4e chore: format and sync protocol outputs 2026-01-16 03:30:56 +00:00
Peter SteinbergerandChristoph Nakazawa a5d8f89b53 feat(browser): prefer Chrome default + add Brave/Edge fallbacks
Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
2026-01-16 03:28:56 +00:00
Peter Steinberger a0d2a7232e fix: allow media-only sends 2026-01-16 03:15:26 +00:00
Peter Steinberger f449115ec5 test: extend vitest timeouts 2026-01-16 03:11:16 +00:00
Peter Steinberger 16bc4cdef3 chore: drop legacy Relay signing 2026-01-16 03:11:16 +00:00
Peter SteinbergerandDrake Thomsen 23e4ba845c fix: sanitize user-facing errors and strip final tags
Co-authored-by: Drake Thomsen <drake.thomsen@example.com>
2026-01-16 03:01:23 +00:00
Peter Steinberger d9f9e93dee feat!: move msteams to plugin 2026-01-16 02:59:43 +00:00
Peter Steinberger dae34f3a61 docs: clarify setup-token location 2026-01-16 02:53:40 +00:00
Adam HoltandClaude Opus 4.5 af61b353a4 docs(showcase): update Dream Team card with architecture articles
Combine Multi-Agent Swarm card with new technical write-ups:
- Link to orchestrated-ai-articles repo (manifesto + architecture)
- Keep clawdspace link for agent sandboxing
- Add blog post link

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 02:52:27 +00:00
Peter Steinberger 29476b222d fix: restore status usage summary output 2026-01-16 02:49:18 +00:00
Peter Steinberger e7c16cc0e6 docs: clarify setup-token flows 2026-01-16 02:36:32 +00:00
Peter Steinberger 3dddbe1053 fix: ignore properties in google tool schema warnings 2026-01-16 02:35:55 +00:00
Peter SteinbergerandJohn Young 2dea6bfa7e docs: clarify gog body-file usage
Co-authored-by: John Young <johntheyoung@users.noreply.github.com>
2026-01-16 02:23:41 +00:00
Peter Steinberger 04e3bfed35 docs: clarify Anthropic setup-token 2026-01-16 02:19:57 +00:00
Peter Steinberger 3e32050601 fix: correct final tag strip typing 2026-01-16 02:16:17 +00:00
Peter Steinberger 7fb45ed9b8 fix: strip final tags from session messages 2026-01-16 02:16:17 +00:00
Peter SteinbergerandMax b7ba94f0c1 fix: harden antigravity claude support (#968)
Co-authored-by: Max <rdev@users.noreply.github.com>
2026-01-16 02:16:17 +00:00
Peter Steinberger 5b827528f8 fix: show oauth usage in /status 2026-01-16 02:06:27 +00:00
Peter Steinberger 409e33d9c2 Merge pull request #960 from kkarimi/fix/mac-node-bridge-tunnel-865
macOS: prefer bridge tunnel port in remote mode
2026-01-16 02:00:23 +00:00
Peter Steinberger 747277d914 fix: document macOS remote tunnels (#960) (thanks @kkarimi) 2026-01-16 01:59:14 +00:00
Nima Karimi 068dca3366 Tests: apply suite timeout in node bridge test 2026-01-16 01:56:23 +00:00
Nima Karimi 336a1ad9cf Tests: extend node bridge server timeout 2026-01-16 01:56:23 +00:00
Nima Karimi d42f767d0c SwiftFormat: format macOS sources 2026-01-16 01:56:23 +00:00
Nima Karimi 9b8ae62399 macOS: prefer bridge tunnel port in remote mode 2026-01-16 01:56:06 +00:00
Gustavo Madeira Santana 7c38b535f6 Document inline buttons configuration for Telegram (#984) 2026-01-15 19:55:03 -06:00
Peter Steinberger af370ab23e fix: align config types after upstream changes 2026-01-16 01:49:07 +00:00
Peter Steinberger 1210657fda chore: bump Peekaboo submodule 2026-01-16 01:44:12 +00:00
Peter Steinberger 12afec953f test: stabilize sandbox config tests 2026-01-16 01:44:12 +00:00
Peter Steinberger 8e2707e232 fix: cleanup suspended CLI processes (#978) (thanks @Nachx639) 2026-01-16 01:39:33 +00:00
Tu Nombre RealandClaude Opus 4.5 8befe7f8a7 fix: cleanup suspended Clawdbot CLI processes
Add cleanupSuspendedCliProcesses() to kill accumulated suspended processes
from isolated sessions that don't share sessionIds (e.g., cron jobs).

- Only targets Clawdbot processes (--session-id pattern)
- Only kills suspended processes (state T)
- Only triggers when >10 processes accumulated
- Does not affect user's Claude Code sessions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 01:38:03 +00:00
Peter Steinberger a70fcc8ae0 chore(cli): set process title 2026-01-16 01:33:28 +00:00
Peter Steinberger fa521154ff fix: show disabled channels in onboarding picker 2026-01-16 01:29:25 +00:00
Peter Steinberger dffc1a4dcd fix: switch channel onboarding to single-select loop 2026-01-16 01:21:17 +00:00
Peter Steinberger 61e385b331 feat: add per-agent heartbeat config 2026-01-16 01:17:34 +00:00
Peter Steinberger f8f319713f fix: show provider/model labels in TUI 2026-01-16 01:13:14 +00:00
Peter Steinberger bb665bf22c fix: persist local gateway mode in configure wizard 2026-01-16 01:05:55 +00:00
Peter Steinberger f17dcb6213 fix: refine channel onboarding selection flow 2026-01-16 01:05:50 +00:00
Peter Steinberger d54c101100 docs: update release checklist 2026-01-16 00:42:48 +00:00
Peter Steinberger 96daa51d45 docs: document provider auth plugins 2026-01-16 00:42:48 +00:00
Peter Steinberger 6084421ec6 feat: add provider auth plugins 2026-01-16 00:42:28 +00:00
Peter Steinberger bca5c0d569 refactor: system prompt sections + docs/tests 2026-01-16 00:28:43 +00:00
Peter Steinberger 8c3cdba21c feat: sticky auth profile rotation + usage headers 2026-01-16 00:25:49 +00:00
voidandvoid e274b5a040 fix: heartbeat prompt + dedupe (#980) (thanks @voidserf)
- tighten default heartbeat prompt guidance
- suppress duplicate heartbeat alerts within 24h

Co-authored-by: void <voidserf@users.noreply.github.com>
2026-01-16 00:24:52 +00:00
Josh Lehman a139d35fa2 feat(slack): add userToken for read-only access to DMs and private channels (#981)
- 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-16 00:11:33 +00:00
Peter Steinberger 8312a19f02 fix: handle Telegram General topic thread params (#848) (thanks @azade-c) 2026-01-16 00:08:56 +00:00
Peter Steinberger fe8b28cdd9 Merge pull request #848 from azade-c/fix/telegram-general-topic-messages
fix(telegram): skip message_thread_id for General topic messages
2026-01-16 00:08:07 +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
Azade 6146acbb69 fix(telegram): separate thread params for typing vs messages
Telegram General topic (id=1) has inconsistent API behavior:
- sendMessage: rejects explicit message_thread_id=1
- sendChatAction: requires message_thread_id=1 for typing to show

Split into two helper functions:
- buildTelegramThreadParams: excludes General topic for messages
- buildTypingThreadParams: includes General topic for typing
2026-01-15 23:32:30 +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
Roshan Singh d0455f2683 fix(system-prompt): add CLI quick reference to prevent invented commands 2026-01-15 11:43:22 +00:00
451 changed files with 13127 additions and 2929 deletions

No files matched your search

+3
View File
@@ -60,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 -95
View File
@@ -2,7 +2,41 @@
## 2026.1.15 (unreleased) ## 2026.1.15 (unreleased)
- Agents: add CLI quick reference to the system prompt to avoid invented commands. (#953) — thanks @roshanasingh4. - **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
- TUI: show provider/model labels for the active session and default model.
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
- 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).
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
- Status: restore usage summary line for current provider when no OAuth profiles exist.
- 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.
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
- 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.
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
- 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: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.
- 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.
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
## 2026.1.14-1 ## 2026.1.14-1
@@ -13,98 +47,56 @@
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy. - Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy.
### Changes ### Changes
#### Web Tools
- Tools: add `web_search`/`web_fetch` (Brave API), including helpful setup hints when the key is missing.
- Tools: enable `web_fetch` by default (unless explicitly disabled in config).
- CLI/Docs: add `clawdbot configure --section web` for storing Brave API keys and update onboarding tips.
#### Browser / Control UI
- Browser: add Chrome extension relay takeover mode (toolbar button) + `clawdbot browser serve` remote control + `browser.controlToken`.
- Browser: ship a built-in `chrome` profile for extension relay and start the relay automatically when running locally.
- Browser: default `browser.defaultProfile` to `chrome` (existing Chrome takeover mode).
- Browser: add `clawdbot browser extension install/path` and copy extension path to clipboard.
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
- Control UI: show raw any-map entries in config views; move Docs link into the left nav.
#### Plugins
- Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.
- Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.
- Channels: add Matrix plugin (external) with docs + onboarding hooks.
- Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.
#### Security
- Security: expand `clawdbot security audit` checks and publish a `SECURITY.md` reporting policy.
- Security: extend `clawdbot security audit --fix` to tighten more sensitive state paths.
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
#### Onboarding / Daemon
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
- Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.
#### Auth / Usage / Config
- Usage: add MiniMax coding plan usage tracking.
- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
- Auth: add dynamic template variables to `messages.responsePrefix`. (#928) — thanks @sebslight.
- Config: add `channels.<provider>.configWrites` gating for channel-initiated config writes; migrate Slack channel IDs.
#### Channels
- Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.
- WhatsApp: add `channels.whatsapp.sendReadReceipts` to disable auto read receipts. (#882) — thanks @chrisrodz.
#### Docs
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics. - Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors. - Docs: 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: expand `clawdbot security audit` checks (model hygiene, config includes, plugin allowlists, exposure matrix) and extend `--fix` to tighten more sensitive state paths.
- Security: add `SECURITY.md` reporting policy.
- Channels: add Matrix plugin (external) with docs + onboarding hooks.
- 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`.
- 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.
- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915) - Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
- Docs: add per-command CLI doc pages and link them from `clawdbot <command> --help`. - Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill.
- Docs: add multi-gateway guide (sidebar + nav). - 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.
#### Gateway / Daemon / Sessions
- Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.
- Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare `main` alias).
- Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.
- Browser: add tests for snapshot labels/efficient query params and labeled image responses. - Browser: add tests for snapshot labels/efficient query params and labeled image responses.
- Browser: persist role snapshot refs per CDP target so `snapshot` → `act` clicks work even if Playwright returns a different Page instance.
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
- Packaging: run `pnpm build` on `prepack` so npm publishes include fresh `dist/` output.
- Telegram: register dock native commands with underscores to avoid `BOT_COMMAND_INVALID` (#929, fixes #901) — thanks @grp06.
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors. - Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06. - 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: harden Antigravity Claude history/tool-call sanitization. (#968) — thanks @rdev.
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4. - Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06. - 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.
- Sessions: return deep clones (`structuredClone`) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani. - Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
- Heartbeat: keep `updatedAt` monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani. - Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer.
- Agent: clear run context after CLI runs (`clearAgentRunContext`) to avoid runaway contexts. (#934) — thanks @ronak-guliani. - Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer.
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`). - 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”).
- 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).
#### CLI / Onboarding ## 2026.1.14
- Onboarding: show web search setup at the end (not the beginning).
- Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.
- Health: colorize “not configured” provider lines for easier scanning.
#### Control UI / TUI ### Changes
- Control UI: load cron run history on job selection and clarify empty-state messaging. (#866) - Usage: add MiniMax coding plan usage tracking.
- UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor. - Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.
- UI: always apply `?token=` from URL (fixes unauthorized after re-onboard). - Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)
- Browser: add tests for snapshot labels/efficient query params and labeled image responses. - Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.
- Config: add `channels.<provider>.configWrites` gating for channel-initiated config writes; migrate Slack channel IDs.
### Fixes
- Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor.
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank. - TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states. - TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.
- TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`. - TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`.
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
#### Agents / Auth / Tools / Sandbox #### Agents / Auth / Tools / Sandbox
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams. - Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
@@ -123,34 +115,17 @@
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75. - macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor. - macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
- macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917) - macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)
- macOS: prefer the default bridge tunnel port in remote mode for node bridge connectivity; document macOS remote control + bridge tunnels. (#960, fixes #865) — thanks @kkarimi.
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions. - Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis. - macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
- macOS: update cron testing channel arg. (#896) — thanks @ngutman.
#### Channels / Messaging
- Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4.
- Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver. - Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”). - Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
- Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.
- Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.
- Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko. - Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.
- Telegram: register dock native commands with underscores to avoid `BOT_COMMAND_INVALID` (#929, fixes #901) — thanks @grp06.
- Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose. - Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.
- iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935) - Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4.
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.
- WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester. - WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.
- WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee. - WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.
- WhatsApp: harden owner command auth.
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
#### Config / Doctor / Packaging
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
- Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
- Packaging: run `pnpm build` on `prepack` so npm publishes include fresh `dist/` output.
## 2026.1.13 ## 2026.1.13
Submodule Peekaboo updated: 95ad7532c1...5c195f5e46.
+19 -18
View File
@@ -474,22 +474,23 @@ Core contributors:
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/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/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/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/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/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/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/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/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/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/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/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/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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/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/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/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/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/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/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/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/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/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/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/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/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/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a>
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/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/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/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/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/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/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/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/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a>
<a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/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/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/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/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/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/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/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></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/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></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/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/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p> </p>
@@ -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)
+9 -4
View File
@@ -170,6 +170,10 @@ final class AppState {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } } didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
} }
var systemRunPolicy: SystemRunPolicy {
didSet { self.ifNotPreview { MacNodeConfigFile.setSystemRunPolicy(self.systemRunPolicy) } }
}
/// Tracks whether the Canvas panel is currently visible (not persisted). /// Tracks whether the Canvas panel is currently visible (not persisted).
var canvasPanelVisible: Bool = false var canvasPanelVisible: Bool = false
@@ -264,8 +268,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 {
@@ -292,6 +296,7 @@ final class AppState {
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
self.systemRunPolicy = SystemRunPolicy.load()
self.peekabooBridgeEnabled = UserDefaults.standard self.peekabooBridgeEnabled = UserDefaults.standard
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true .object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
if !self.isPreview { if !self.isPreview {
@@ -356,8 +361,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":
@@ -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) }
} }
} }
@@ -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)
} }
} }
@@ -38,10 +38,10 @@ 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
} }
@@ -26,6 +26,9 @@ let remoteProjectRootKey = "clawdbot.remoteProjectRoot"
let remoteCliPathKey = "clawdbot.remoteCliPath" let remoteCliPathKey = "clawdbot.remoteCliPath"
let canvasEnabledKey = "clawdbot.canvasEnabled" let canvasEnabledKey = "clawdbot.canvasEnabled"
let cameraEnabledKey = "clawdbot.cameraEnabled" let cameraEnabledKey = "clawdbot.cameraEnabled"
let systemRunPolicyKey = "clawdbot.systemRunPolicy"
let systemRunAllowlistKey = "clawdbot.systemRunAllowlist"
let systemRunEnabledKey = "clawdbot.systemRunEnabled"
let locationModeKey = "clawdbot.locationMode" let locationModeKey = "clawdbot.locationMode"
let locationPreciseKey = "clawdbot.locationPreciseEnabled" let locationPreciseKey = "clawdbot.locationPreciseEnabled"
let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled" let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled"
@@ -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"]))
} }
} }
@@ -433,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 {
@@ -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(
@@ -206,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 {
@@ -219,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"))
@@ -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)
@@ -83,6 +83,25 @@ struct GeneralSettings: View {
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.", subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
binding: self.$cameraEnabled) binding: self.$cameraEnabled)
VStack(alignment: .leading, spacing: 6) {
Text("Node Run Commands")
.font(.body)
Picker("", selection: self.$state.systemRunPolicy) {
ForEach(SystemRunPolicy.allCases) { policy in
Text(policy.title).tag(policy)
}
}
.pickerStyle(.segmented)
Text("""
Controls remote command execution on this Mac when it is paired as a node. "Always Ask" prompts on each command; "Always Allow" runs without prompts; "Never" disables `system.run`.
""")
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("Location Access") Text("Location Access")
.font(.body) .font(.body)
@@ -238,7 +257,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 +646,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
} }
+3 -3
View File
@@ -30,9 +30,9 @@ enum LogLocator {
self.ensureLogDirExists() 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" }
@@ -0,0 +1,80 @@
import Foundation
import OSLog
enum MacNodeConfigFile {
private static let logger = Logger(subsystem: "com.clawdbot", category: "mac-node-config")
static func url() -> URL {
ClawdbotPaths.stateDirURL.appendingPathComponent("macos-node.json")
}
static func loadDict() -> [String: Any] {
let url = self.url()
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
do {
let data = try Data(contentsOf: url)
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
self.logger.warning("mac node config JSON root invalid")
return [:]
}
return root
} catch {
self.logger.warning("mac node config read failed: \(error.localizedDescription, privacy: .public)")
return [:]
}
}
static func saveDict(_ dict: [String: Any]) {
do {
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
let url = self.url()
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
} catch {
self.logger.error("mac node config save failed: \(error.localizedDescription, privacy: .public)")
}
}
static func systemRunPolicy() -> SystemRunPolicy? {
let root = self.loadDict()
let systemRun = root["systemRun"] as? [String: Any]
let raw = systemRun?["policy"] as? String
guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil }
return policy
}
static func setSystemRunPolicy(_ policy: SystemRunPolicy) {
var root = self.loadDict()
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
systemRun["policy"] = policy.rawValue
root["systemRun"] = systemRun
self.saveDict(root)
}
static func systemRunAllowlist() -> [String]? {
let root = self.loadDict()
let systemRun = root["systemRun"] as? [String: Any]
return systemRun?["allowlist"] as? [String]
}
static func setSystemRunAllowlist(_ allowlist: [String]) {
let cleaned = allowlist
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
var root = self.loadDict()
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
if cleaned.isEmpty {
systemRun.removeValue(forKey: "allowlist")
} else {
systemRun["allowlist"] = cleaned
}
if systemRun.isEmpty {
root.removeValue(forKey: "systemRun")
} else {
root["systemRun"] = systemRun
}
self.saveDict(root)
}
}
@@ -31,6 +31,12 @@ struct MenuContent: View {
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled) self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
} }
private var systemRunPolicyBinding: Binding<SystemRunPolicy> {
Binding(
get: { self.state.systemRunPolicy },
set: { self.state.systemRunPolicy = $0 })
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: self.activeBinding) { Toggle(isOn: self.activeBinding) {
@@ -68,6 +74,13 @@ struct MenuContent: View {
Toggle(isOn: self.$cameraEnabled) { Toggle(isOn: self.$cameraEnabled) {
Label("Allow Camera", systemImage: "camera") Label("Allow Camera", systemImage: "camera")
} }
Picker(selection: self.systemRunPolicyBinding) {
ForEach(SystemRunPolicy.allCases) { policy in
Text(policy.title).tag(policy)
}
} label: {
Label("Node Run Commands", systemImage: "terminal")
}
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) { Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis") Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
} }
@@ -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 var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
private func inject(into menu: NSMenu) { private func inject(into menu: NSMenu) {
@@ -138,8 +139,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
@@ -175,8 +176,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
@@ -299,7 +300,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)
@@ -472,11 +473,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
} }
@@ -596,10 +597,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)
@@ -703,10 +704,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
} }
@@ -763,9 +764,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
@@ -855,9 +856,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 {
@@ -874,9 +875,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 {
@@ -893,9 +894,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 {
@@ -39,7 +39,7 @@ actor MacNodeBridgeSession {
onConnected: (@Sendable (String, 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()
@@ -37,6 +37,7 @@ final class MacNodeModeCoordinator {
private func run() async { private func run() async {
var retryDelay: UInt64 = 1_000_000_000 var retryDelay: UInt64 = 1_000_000_000
var lastCameraEnabled: Bool? var lastCameraEnabled: Bool?
var lastSystemRunPolicy: SystemRunPolicy?
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
while !Task.isCancelled { while !Task.isCancelled {
if await MainActor.run(body: { AppStateStore.shared.isPaused }) { if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
@@ -53,6 +54,15 @@ final class MacNodeModeCoordinator {
try? await Task.sleep(nanoseconds: 200_000_000) try? await Task.sleep(nanoseconds: 200_000_000)
} }
let systemRunPolicy = SystemRunPolicy.load()
if lastSystemRunPolicy == nil {
lastSystemRunPolicy = systemRunPolicy
} else if lastSystemRunPolicy != systemRunPolicy {
lastSystemRunPolicy = systemRunPolicy
await self.session.disconnect()
try? await Task.sleep(nanoseconds: 200_000_000)
}
guard let endpoint = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else { guard let endpoint = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000)) try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
retryDelay = min(retryDelay * 2, 10_000_000_000) retryDelay = min(retryDelay * 2, 10_000_000_000)
@@ -143,10 +153,13 @@ final class MacNodeModeCoordinator {
ClawdbotCanvasA2UICommand.pushJSONL.rawValue, ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
ClawdbotCanvasA2UICommand.reset.rawValue, ClawdbotCanvasA2UICommand.reset.rawValue,
MacNodeScreenCommand.record.rawValue, MacNodeScreenCommand.record.rawValue,
ClawdbotSystemCommand.run.rawValue,
ClawdbotSystemCommand.notify.rawValue, ClawdbotSystemCommand.notify.rawValue,
] ]
if SystemRunPolicy.load() != .never {
commands.append(ClawdbotSystemCommand.run.rawValue)
}
let capsSet = Set(caps) let capsSet = Set(caps)
if capsSet.contains(ClawdbotCapability.camera.rawValue) { if capsSet.contains(ClawdbotCapability.camera.rawValue) {
commands.append(ClawdbotCameraCommand.list.rawValue) commands.append(ClawdbotCameraCommand.list.rawValue)
@@ -312,9 +325,23 @@ final class MacNodeModeCoordinator {
} }
let remotePort = Self.remoteBridgePort() let remotePort = Self.remoteBridgePort()
let preferredLocalPort = Self.loopbackBridgePort()
if let preferredLocalPort {
self.logger.info(
"mac node bridge tunnel starting " +
"preferredLocalPort=\(preferredLocalPort, privacy: .public) " +
"remotePort=\(remotePort, privacy: .public)")
} else {
self.logger.info(
"mac node bridge tunnel starting " +
"preferredLocalPort=none " +
"remotePort=\(remotePort, privacy: .public)")
}
self.tunnel = try await RemotePortTunnel.create( self.tunnel = try await RemotePortTunnel.create(
remotePort: remotePort, remotePort: remotePort,
allowRemoteUrlOverride: false) preferredLocalPort: preferredLocalPort,
allowRemoteUrlOverride: false,
allowRandomLocalPort: true)
if let localPort = self.tunnel?.localPort, if let localPort = self.tunnel?.localPort,
let port = NWEndpoint.Port(rawValue: localPort) let port = NWEndpoint.Port(rawValue: localPort)
{ {
@@ -389,10 +416,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)
@@ -181,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)) ??
@@ -204,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()
@@ -312,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)
} }
@@ -426,6 +426,44 @@ actor MacNodeRuntime {
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required") return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
} }
let wasAllowlisted = SystemRunAllowlist.contains(command)
switch Self.systemRunPolicy() {
case .never:
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DISABLED: policy=never")
case .always:
break
case .ask:
if !wasAllowlisted {
let services = await self.mainActorServices()
let decision = await services.confirmSystemRun(
command: SystemRunAllowlist.displayString(for: command),
cwd: params.cwd)
switch decision {
case .allowOnce:
break
case .allowAlways:
SystemRunAllowlist.add(command)
case .deny:
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: user denied")
}
}
}
var env = params.env
if wasAllowlisted, let overrides = env {
var merged = ProcessInfo.processInfo.environment
for (key, value) in overrides where key != "PATH" {
merged[key] = value
}
env = merged
}
if params.needsScreenRecording == true { if params.needsScreenRecording == true {
let authorized = await PermissionManager let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false .status([.screenRecording])[.screenRecording] ?? false
@@ -441,7 +479,7 @@ actor MacNodeRuntime {
let result = await ShellExecutor.runDetailed( let result = await ShellExecutor.runDetailed(
command: command, command: command,
cwd: params.cwd, cwd: params.cwd,
env: params.env, env: env,
timeout: timeoutSec) timeout: timeoutSec)
struct RunPayload: Encodable { struct RunPayload: Encodable {
@@ -454,12 +492,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)
} }
@@ -529,6 +567,10 @@ actor MacNodeRuntime {
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
} }
private nonisolated static func systemRunPolicy() -> SystemRunPolicy {
SystemRunPolicy.load()
}
private nonisolated static func locationMode() -> ClawdbotLocationMode { private nonisolated static func locationMode() -> ClawdbotLocationMode {
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off" let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
return ClawdbotLocationMode(rawValue: raw) ?? .off return ClawdbotLocationMode(rawValue: raw) ?? .off
@@ -576,8 +618,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",
@@ -1,7 +1,14 @@
import AppKit
import ClawdbotKit import ClawdbotKit
import CoreLocation import CoreLocation
import Foundation import Foundation
enum SystemRunDecision: Sendable {
case allowOnce
case allowAlways
case deny
}
@MainActor @MainActor
protocol MacNodeRuntimeMainActorServices: Sendable { protocol MacNodeRuntimeMainActorServices: Sendable {
func recordScreen( func recordScreen(
@@ -17,6 +24,8 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
desiredAccuracy: ClawdbotLocationAccuracy, desiredAccuracy: ClawdbotLocationAccuracy,
maxAgeMs: Int?, maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation timeoutMs: Int?) async throws -> CLLocation
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision
} }
@MainActor @MainActor
@@ -57,4 +66,31 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices
maxAgeMs: maxAgeMs, maxAgeMs: maxAgeMs,
timeoutMs: timeoutMs) timeoutMs: timeoutMs)
} }
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Allow this command?"
var details = "Clawdbot wants to run:\n\n\(command)"
let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedCwd.isEmpty {
details += "\n\nWorking directory:\n\(trimmedCwd)"
}
details += "\n\nThis runs on this Mac via node mode."
alert.informativeText = details
alert.addButton(withTitle: "Allow Once")
alert.addButton(withTitle: "Always Allow")
alert.addButton(withTitle: "Don't Allow")
switch alert.runModal() {
case .alertFirstButtonReturn:
return .allowOnce
case .alertSecondButtonReturn:
return .allowAlways
default:
return .deny
}
}
} }
@@ -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)
@@ -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()
@@ -0,0 +1,89 @@
import Foundation
enum SystemRunPolicy: String, CaseIterable, Identifiable {
case never
case ask
case always
var id: String { self.rawValue }
var title: String {
switch self {
case .never:
return "Never"
case .ask:
return "Always Ask"
case .always:
return "Always Allow"
}
}
static func load(from defaults: UserDefaults = .standard) -> SystemRunPolicy {
if let policy = MacNodeConfigFile.systemRunPolicy() {
return policy
}
if let raw = defaults.string(forKey: systemRunPolicyKey),
let policy = SystemRunPolicy(rawValue: raw)
{
MacNodeConfigFile.setSystemRunPolicy(policy)
return policy
}
if let legacy = defaults.object(forKey: systemRunEnabledKey) as? Bool {
let policy: SystemRunPolicy = legacy ? .ask : .never
MacNodeConfigFile.setSystemRunPolicy(policy)
return policy
}
let fallback: SystemRunPolicy = .ask
MacNodeConfigFile.setSystemRunPolicy(fallback)
return fallback
}
}
enum SystemRunAllowlist {
static func key(for argv: [String]) -> String {
let trimmed = argv.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard !trimmed.isEmpty else { return "" }
if let data = try? JSONEncoder().encode(trimmed),
let json = String(data: data, encoding: .utf8)
{
return json
}
return trimmed.joined(separator: " ")
}
static func displayString(for argv: [String]) -> String {
argv.map { arg in
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "\"\"" }
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
if !needsQuotes { return trimmed }
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}.joined(separator: " ")
}
static func load(from defaults: UserDefaults = .standard) -> Set<String> {
if let allowlist = MacNodeConfigFile.systemRunAllowlist() {
return Set(allowlist)
}
if let legacy = defaults.stringArray(forKey: systemRunAllowlistKey), !legacy.isEmpty {
MacNodeConfigFile.setSystemRunAllowlist(legacy)
return Set(legacy)
}
return []
}
static func contains(_ argv: [String], defaults: UserDefaults = .standard) -> Bool {
let key = key(for: argv)
return load(from: defaults).contains(key)
}
static func add(_ argv: [String], defaults: UserDefaults = .standard) {
let key = key(for: argv)
guard !key.isEmpty else { return }
var allowlist = load(from: defaults)
if allowlist.insert(key).inserted {
MacNodeConfigFile.setSystemRunAllowlist(Array(allowlist).sorted())
}
}
}
@@ -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()
.labelsHidden() .frame(width: 220)
.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)
} }
@@ -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
@@ -1679,6 +1679,27 @@ public struct ChatAbortParams: Codable, Sendable {
} }
} }
public struct ChatInjectParams: Codable, Sendable {
public let sessionkey: String
public let message: String
public let label: String?
public init(
sessionkey: String,
message: String,
label: String?
) {
self.sessionkey = sessionkey
self.message = message
self.label = label
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case message
case label
}
}
public struct ChatEvent: Codable, Sendable { public struct ChatEvent: Codable, Sendable {
public let runid: String public let runid: String
public let sessionkey: String public let sessionkey: String
+3 -1
View File
@@ -191,7 +191,7 @@ Notes:
- Your config requires mentions and you didn’t mention it, or - Your config requires mentions and you didn’t mention it, or
- Your guild/channel allowlist denies the channel/user. - Your guild/channel allowlist denies the channel/user.
- **`requireMention: false` but still no replies**: - **`requireMention: false` but still no replies**:
- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or explicitly list channels under `channels.discord.guilds.<id>.channels`. - `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds.<id>.channels` to restrict).
- `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored. - `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored.
- **Permission audits** (`channels status --probe`) only check numeric channel IDs. If you use slugs/names as `channels.discord.guilds.*.channels` keys, the audit can’t verify permissions. - **Permission audits** (`channels status --probe`) only check numeric channel IDs. If you use slugs/names as `channels.discord.guilds.*.channels` keys, the audit can’t verify permissions.
- **DMs don’t work**: `channels.discord.dm.enabled=false`, `channels.discord.dm.policy="disabled"`, or you haven’t been approved yet (`channels.discord.dm.policy="pairing"`). - **DMs don’t work**: `channels.discord.dm.enabled=false`, `channels.discord.dm.policy="disabled"`, or you haven’t been approved yet (`channels.discord.dm.policy="pairing"`).
@@ -352,6 +352,7 @@ Allowlist matching notes:
- Prefixes like `discord:`/`user:` (users) and `channel:` (group DMs) are supported. - Prefixes like `discord:`/`user:` (users) and `channel:` (group DMs) are supported.
- Use `*` to allow any sender/channel. - Use `*` to allow any sender/channel.
- When `guilds.<id>.channels` is present, channels not listed are denied by default. - When `guilds.<id>.channels` is present, channels not listed are denied by default.
- When `guilds.<id>.channels` is omitted, all channels in the allowlisted guild are allowed.
Native command notes: Native command notes:
- The registered commands mirror Clawdbot’s chat commands. - The registered commands mirror Clawdbot’s chat commands.
@@ -362,6 +363,7 @@ The agent can call `discord` with actions like:
- `react` / `reactions` (add or list reactions) - `react` / `reactions` (add or list reactions)
- `sticker`, `poll`, `permissions` - `sticker`, `poll`, `permissions`
- `readMessages`, `sendMessage`, `editMessage`, `deleteMessage` - `readMessages`, `sendMessage`, `editMessage`, `deleteMessage`
- Read/search/pin tool payloads include normalized `timestampMs` (UTC epoch ms) and `timestampUtc` alongside raw Discord `timestamp`.
- `threadCreate`, `threadList`, `threadReply` - `threadCreate`, `threadList`, `threadReply`
- `pinMessage`, `unpinMessage`, `listPins` - `pinMessage`, `unpinMessage`, `listPins`
- `searchMessages`, `memberInfo`, `roleInfo`, `roleAdd`, `roleRemove`, `emojiList` - `searchMessages`, `memberInfo`, `roleInfo`, `roleAdd`, `roleRemove`, `emojiList`
+1 -1
View File
@@ -17,7 +17,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [Slack](/channels/slack) — Bolt SDK; workspace apps. - [Slack](/channels/slack) — Bolt SDK; workspace apps.
- [Signal](/channels/signal) — signal-cli; privacy-focused. - [Signal](/channels/signal) — signal-cli; privacy-focused.
- [iMessage](/channels/imessage) — macOS only; native integration. - [iMessage](/channels/imessage) — macOS only; native integration.
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support. - [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately). - [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately). - [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket. - [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
+45 -17
View File
@@ -3,20 +3,43 @@ summary: "Microsoft Teams bot support status, capabilities, and configuration"
read_when: read_when:
- Working on MS Teams channel features - Working on MS Teams channel features
--- ---
# Microsoft Teams (Bot Framework) # Microsoft Teams (plugin)
> "Abandon all hope, ye who enter here." > "Abandon all hope, ye who enter here."
Updated: 2026-01-08 Updated: 2026-01-16
Status: text + DM attachments are supported; channel/group attachments require Microsoft Graph permissions. Polls are sent via Adaptive Cards. Status: text + DM attachments are supported; channel/group attachments require Microsoft Graph permissions. Polls are sent via Adaptive Cards.
## Plugin required
Microsoft Teams ships as a plugin and is not bundled with the core install.
**Breaking change (2026.1.15):** MS Teams moved out of core. If you use it, you must install the plugin.
Explainable: keeps core installs lighter and lets MS Teams dependencies update independently.
Install via CLI (npm registry):
```bash
clawdbot plugins install @clawdbot/msteams
```
Local checkout (when running from a git repo):
```bash
clawdbot plugins install ./extensions/msteams
```
If you choose Teams during configure/onboarding and a git checkout is detected,
Clawdbot will offer the local install path automatically.
Details: [Plugins](/plugin)
## Quick setup (beginner) ## Quick setup (beginner)
1) Create an **Azure Bot** (App ID + client secret + tenant ID). 1) Install the Microsoft Teams plugin.
2) Configure Clawdbot with those credentials. 2) Create an **Azure Bot** (App ID + client secret + tenant ID).
3) Expose `/api/messages` (port 3978 by default) via a public URL or tunnel. 3) Configure Clawdbot with those credentials.
4) Install the Teams app package and start the gateway. 4) Expose `/api/messages` (port 3978 by default) via a public URL or tunnel.
5) Install the Teams app package and start the gateway.
Minimal config: Minimal config:
```json5 ```json5
@@ -73,11 +96,12 @@ Example:
``` ```
## How it works ## How it works
1. Create an **Azure Bot** (App ID + secret + tenant ID). 1. Install the Microsoft Teams plugin.
2. Build a **Teams app package** that references the bot and includes the RSC permissions below. 2. Create an **Azure Bot** (App ID + secret + tenant ID).
3. Upload/install the Teams app into a team (or personal scope for DMs). 3. Build a **Teams app package** that references the bot and includes the RSC permissions below.
4. Configure `msteams` in `~/.clawdbot/clawdbot.json` (or env vars) and start the gateway. 4. Upload/install the Teams app into a team (or personal scope for DMs).
5. The gateway listens for Bot Framework webhook traffic on `/api/messages` by default. 5. Configure `msteams` in `~/.clawdbot/clawdbot.json` (or env vars) and start the gateway.
6. The gateway listens for Bot Framework webhook traffic on `/api/messages` by default.
## Azure Bot Setup (Prerequisites) ## Azure Bot Setup (Prerequisites)
@@ -166,13 +190,17 @@ This is often easier than hand-editing JSON manifests.
3. Check gateway logs for incoming activity 3. Check gateway logs for incoming activity
## Setup (minimal text-only) ## Setup (minimal text-only)
1. **Bot registration** 1. **Install the Microsoft Teams plugin**
- From npm: `clawdbot plugins install @clawdbot/msteams`
- From a local checkout: `clawdbot plugins install ./extensions/msteams`
2. **Bot registration**
- Create an Azure Bot (see above) and note: - Create an Azure Bot (see above) and note:
- App ID - App ID
- Client secret (App password) - Client secret (App password)
- Tenant ID (single-tenant) - Tenant ID (single-tenant)
2. **Teams app manifest** 3. **Teams app manifest**
- Include a `bot` entry with `botId = <App ID>`. - Include a `bot` entry with `botId = <App ID>`.
- Scopes: `personal`, `team`, `groupChat`. - Scopes: `personal`, `team`, `groupChat`.
- `supportsFiles: true` (required for personal scope file handling). - `supportsFiles: true` (required for personal scope file handling).
@@ -180,7 +208,7 @@ This is often easier than hand-editing JSON manifests.
- Create icons: `outline.png` (32x32) and `color.png` (192x192). - Create icons: `outline.png` (32x32) and `color.png` (192x192).
- Zip all three files together: `manifest.json`, `outline.png`, `color.png`. - Zip all three files together: `manifest.json`, `outline.png`, `color.png`.
3. **Configure Clawdbot** 4. **Configure Clawdbot**
```json ```json
{ {
"msteams": { "msteams": {
@@ -198,12 +226,12 @@ This is often easier than hand-editing JSON manifests.
- `MSTEAMS_APP_PASSWORD` - `MSTEAMS_APP_PASSWORD`
- `MSTEAMS_TENANT_ID` - `MSTEAMS_TENANT_ID`
4. **Bot endpoint** 5. **Bot endpoint**
- Set the Azure Bot Messaging Endpoint to: - Set the Azure Bot Messaging Endpoint to:
- `https://<host>:3978/api/messages` (or your chosen path/port). - `https://<host>:3978/api/messages` (or your chosen path/port).
5. **Run the gateway** 6. **Run the gateway**
- The Teams channel starts automatically when `msteams` config exists and credentials are set. - The Teams channel starts automatically when the plugin is installed and `msteams` config exists with credentials.
## History context ## History context
- `channels.msteams.historyLimit` controls how many recent channel/group messages are wrapped into the prompt. - `channels.msteams.historyLimit` controls how many recent channel/group messages are wrapped into the prompt.
+88 -25
View File
@@ -27,17 +27,17 @@ Minimal config:
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps. 1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`). 2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`). 3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
4) **Event Subscriptions** → enable events and subscribe to: 4) Optional: **OAuth & Permissions** → add **User Token Scopes** (see the read-only list below). Reinstall the app and copy the **User OAuth Token** (`xoxp-...`).
5) **Event Subscriptions** → enable events and subscribe to:
- `message.*` (includes edits/deletes/thread broadcasts) - `message.*` (includes edits/deletes/thread broadcasts)
- `app_mention` - `app_mention`
- `reaction_added`, `reaction_removed` - `reaction_added`, `reaction_removed`
- `member_joined_channel`, `member_left_channel` - `member_joined_channel`, `member_left_channel`
- `channel_id_changed`
- `channel_rename` - `channel_rename`
- `pin_added`, `pin_removed` - `pin_added`, `pin_removed`
5) Invite the bot to channels you want it to read. 6) Invite the bot to channels you want it to read.
6) Slash Commands → create `/clawd` if you use `channels.slack.slashCommand`. If you enable native commands, add one slash command per built-in command (same names as `/help`). Native defaults to off for Slack unless you set `channels.slack.commands.native: true` (global `commands.native` is `"auto"` which leaves Slack off). 7) Slash Commands → create `/clawd` if you use `channels.slack.slashCommand`. If you enable native commands, add one slash command per built-in command (same names as `/help`). Native defaults to off for Slack unless you set `channels.slack.commands.native: true` (global `commands.native` is `"auto"` which leaves Slack off).
7) App Home → enable the **Messages Tab** so users can DM the bot. 8) App Home → enable the **Messages Tab** so users can DM the bot.
Use the manifest below so scopes and events stay in sync. Use the manifest below so scopes and events stay in sync.
@@ -63,27 +63,59 @@ Or via config:
} }
``` ```
## History context ## User token (optional)
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt. Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history,
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). pins, reactions, emoji, member info). By default this stays read-only: reads
- DM history can be limited with `channels.slack.dmHistoryLimit` (user turns). Per-user overrides: `channels.slack.dms["<user_id>"].historyLimit`. prefer the user token when present, and writes still use the bot token unless
you explicitly opt in. Even with `userTokenReadOnly: false`, the bot token stays
preferred for writes when it is available.
## Config writes User tokens are configured in the config file (no env var support). For
By default, Slack is allowed to write config updates triggered by channel events or `/config set|unset`. multi-account, set `channels.slack.accounts.<id>.userToken`.
This happens when: Example with bot + app + user tokens:
- Slack emits `channel_id_changed` (e.g. Slack Connect channel ID changes). Clawdbot can migrate `channels.slack.channels` automatically.
- You run `/config set` or `/config unset` in Slack (requires `commands.config: true`).
Disable with:
```json5 ```json5
{ {
channels: { slack: { configWrites: false } } channels: {
slack: {
enabled: true,
appToken: "xapp-...",
botToken: "xoxb-...",
userToken: "xoxp-..."
}
}
} }
``` ```
Example with userTokenReadOnly explicitly set (allow user token writes):
```json5
{
channels: {
slack: {
enabled: true,
appToken: "xapp-...",
botToken: "xoxb-...",
userToken: "xoxp-...",
userTokenReadOnly: false
}
}
}
```
### Token usage
- Read operations (history, reactions list, pins list, emoji list, member info,
search) prefer the user token when configured, otherwise the bot token.
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
file uploads) use the bot token by default. If `userTokenReadOnly: false` and
no bot token is available, Clawdbot falls back to the user token.
## History context
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
## Manifest (optional) ## Manifest (optional)
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
user scopes if you plan to configure a user token.
```json ```json
{ {
@@ -133,6 +165,21 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i
"commands", "commands",
"files:read", "files:read",
"files:write" "files:write"
],
"user": [
"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"
] ]
} }
}, },
@@ -149,7 +196,6 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i
"reaction_removed", "reaction_removed",
"member_joined_channel", "member_joined_channel",
"member_left_channel", "member_left_channel",
"channel_id_changed",
"channel_rename", "channel_rename",
"pin_added", "pin_added",
"pin_removed" "pin_removed"
@@ -166,7 +212,7 @@ Slack's Conversations API is type-scoped: you only need the scopes for the
conversation types you actually touch (channels, groups, im, mpim). See conversation types you actually touch (channels, groups, im, mpim). See
https://api.channels.slack.com/docs/conversations-api for the overview. https://api.channels.slack.com/docs/conversations-api for the overview.
### Required scopes ### Bot token scopes (required)
- `chat:write` (send/update/delete messages via `chat.postMessage`) - `chat:write` (send/update/delete messages via `chat.postMessage`)
https://api.channels.slack.com/methods/chat.postMessage https://api.channels.slack.com/methods/chat.postMessage
- `im:write` (open DMs via `conversations.open` for user DMs) - `im:write` (open DMs via `conversations.open` for user DMs)
@@ -188,6 +234,17 @@ https://api.channels.slack.com/docs/conversations-api for the overview.
- `files:write` (uploads via `files.uploadV2`) - `files:write` (uploads via `files.uploadV2`)
https://api.channels.slack.com/messaging/files/uploading https://api.channels.slack.com/messaging/files/uploading
### User token scopes (optional, read-only by default)
Add these under **User Token Scopes** if you configure `channels.slack.userToken`.
- `channels:history`, `groups:history`, `im:history`, `mpim:history`
- `channels:read`, `groups:read`, `im:read`, `mpim:read`
- `users:read`
- `reactions:read`
- `pins:read`
- `emoji:read`
- `search:read`
### Not needed today (but likely future) ### Not needed today (but likely future)
- `mpim:write` (only if we add group-DM open/DM start via `conversations.open`) - `mpim:write` (only if we add group-DM open/DM start via `conversations.open`)
- `groups:write` (only if we add private-channel management: create/rename/invite/archive) - `groups:write` (only if we add private-channel management: create/rename/invite/archive)
@@ -269,11 +326,6 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`). The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
### Thread session isolation
Slack thread sessions are isolated by default. Configure with:
- `channels.slack.thread.historyScope`: `thread` (default) keeps per-thread history; `channel` shares history across the channel.
- `channels.slack.thread.inheritParent`: `false` (default) starts a clean thread session; `true` copies the parent channel transcript into the thread session.
### Manual threading tags ### Manual threading tags
For fine-grained control, use these tags in agent responses: For fine-grained control, use these tags in agent responses:
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread). - `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
@@ -320,6 +372,17 @@ Slack tool actions can be gated with `channels.slack.actions.*`:
| memberInfo | enabled | Member info | | memberInfo | enabled | Member info |
| emojiList | enabled | Custom emoji list | | emojiList | enabled | Custom emoji list |
## Security notes
- Writes default to the bot token so state-changing actions stay scoped to the
app's bot permissions and identity.
- Setting `userTokenReadOnly: false` allows the user token to be used for write
operations when a bot token is unavailable, which means actions run with the
installing user's access. Treat the user token as highly privileged and keep
action gates and allowlists tight.
- If you enable user-token writes, make sure the user token includes the write
scopes you expect (`chat:write`, `reactions:write`, `pins:write`,
`files:write`) or those operations will fail.
## Notes ## Notes
- Mention gating is controlled via `channels.slack.channels` (set `requireMention` to `true`); `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions. - Mention gating is controlled via `channels.slack.channels` (set `requireMention` to `true`); `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions.
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`. - Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
+110
View File
@@ -188,11 +188,74 @@ Disable with:
Telegram forum topics include a `message_thread_id` per message. Clawdbot: Telegram forum topics include a `message_thread_id` per message. Clawdbot:
- Appends `:topic:<threadId>` to the Telegram group session key so each topic is isolated. - Appends `:topic:<threadId>` to the Telegram group session key so each topic is isolated.
- Sends typing indicators and replies with `message_thread_id` so responses stay in the topic. - Sends typing indicators and replies with `message_thread_id` so responses stay in the topic.
- General topic (thread id `1`) is special: message sends omit `message_thread_id` (Telegram rejects it), but typing indicators still include it.
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating. - Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
- Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable). - Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable).
Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps the DM session key unchanged, but still uses the thread id for replies/draft streaming when it is present. Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps the DM session key unchanged, but still uses the thread id for replies/draft streaming when it is present.
## Inline Buttons
Telegram supports inline keyboards with callback buttons. Enable this feature via capabilities:
```json5
{
"channels": {
"telegram": {
"capabilities": ["inlineButtons"]
}
}
}
```
For per-account configuration:
```json5
{
"channels": {
"telegram": {
"accounts": {
"main": {
"capabilities": ["inlineButtons"]
}
}
}
}
}
```
### Sending buttons
Use the message tool with the `buttons` parameter:
```json5
{
"action": "send",
"channel": "telegram",
"to": "123456789",
"message": "Choose an option:",
"buttons": [
[
{"text": "Yes", "callback_data": "yes"},
{"text": "No", "callback_data": "no"}
],
[
{"text": "Cancel", "callback_data": "cancel"}
]
]
}
```
When a user clicks a button, the callback data is sent back to the agent as a message with the format:
`callback_data: value`
### Configuration options
Telegram capabilities can be configured at two levels:
- `channels.telegram.capabilities`: Global default capability list applied to all Telegram accounts unless overridden.
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities that override or extend the global defaults for that specific account.
Use the global setting when all Telegram bots/accounts should behave the same. Use per-account configuration when different bots need different behaviors (for example, one account only handles DMs while another is allowed in groups or has extra capabilities).
## Access control (DMs + groups) ## Access control (DMs + groups)
### DM access ### DM access
@@ -297,6 +360,49 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti
- Reaction removal semantics: see [/tools/reactions](/tools/reactions). - Reaction removal semantics: see [/tools/reactions](/tools/reactions).
- Tool gating: `channels.telegram.actions.reactions`, `channels.telegram.actions.sendMessage`, `channels.telegram.actions.deleteMessage` (default: enabled). - Tool gating: `channels.telegram.actions.reactions`, `channels.telegram.actions.sendMessage`, `channels.telegram.actions.deleteMessage` (default: enabled).
## Reaction notifications
**How reactions work:**
Telegram reactions arrive as **separate `message_reaction` events**, not as properties in message payloads. When a user adds a reaction, Clawdbot:
1. Receives the `message_reaction` update from Telegram API
2. Converts it to a **system event** with format: `"Telegram reaction added: {emoji} by {user} on msg {id}"`
3. Enqueues the system event using the **same session key** as regular messages
4. When the next message arrives in that conversation, system events are drained and prepended to the agent's context
The agent sees reactions as **system notifications** in the conversation history, not as message metadata.
**Configuration:**
- `channels.telegram.reactionNotifications`: Controls which reactions trigger notifications
- `"off"` — ignore all reactions (default when not set)
- `"own"` — notify when users react to bot messages (best-effort; in-memory)
- `"all"` — notify for all reactions
- `channels.telegram.reactionLevel`: Controls agent's reaction capability
- `"off"` — agent cannot react to messages
- `"ack"` — bot sends acknowledgment reactions (👀 while processing) (default)
- `"minimal"` — agent can react sparingly (guideline: 1 per 5-10 exchanges)
- `"extensive"` — agent can react liberally when appropriate
**Forum groups:** Reactions in forum groups include `message_thread_id` and use session keys like `agent:main:telegram:group:{chatId}:topic:{threadId}`. This ensures reactions and messages in the same topic stay together.
**Example config:**
```json5
{
channels: {
telegram: {
reactionNotifications: "all", // See all reactions
reactionLevel: "minimal" // Agent can react sparingly
}
}
}
```
**Requirements:**
- Telegram bots must explicitly request `message_reaction` in `allowed_updates` (configured automatically by Clawdbot)
- For webhook mode, reactions are included in the webhook `allowed_updates`
- For polling mode, reactions are included in the `getUpdates` `allowed_updates`
## Delivery targets (CLI/cron) ## Delivery targets (CLI/cron)
- Use a chat id (`123456789`) or a username (`@name`) as the target. - Use a chat id (`123456789`) or a username (`@name`) as the target.
- Example: `clawdbot message send --channel telegram --to 123456789 --message "hi"`. - Example: `clawdbot message send --channel telegram --to 123456789 --message "hi"`.
@@ -348,6 +454,8 @@ Provider options:
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`. - `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group). - `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override. - `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
- `channels.telegram.capabilities`: Enable channel features (e.g., "inlineButtons").
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities.
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`). - `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
- `channels.telegram.textChunkLimit`: outbound chunk size (chars). - `channels.telegram.textChunkLimit`: outbound chunk size (chars).
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming). - `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
@@ -360,6 +468,8 @@ Provider options:
- `channels.telegram.actions.reactions`: gate Telegram tool reactions. - `channels.telegram.actions.reactions`: gate Telegram tool reactions.
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends. - `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes. - `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `off` when not set).
- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `ack` when not set).
Related global options: Related global options:
- `agents.list[].groupChat.mentionPatterns` (mention gating patterns). - `agents.list[].groupChat.mentionPatterns` (mention gating patterns).
+4 -2
View File
@@ -296,8 +296,9 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
## Heartbeats ## Heartbeats
- **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s). - **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s).
- **Agent heartbeat** is global (`agents.defaults.heartbeat.*`) and runs in the main session. - **Agent heartbeat** can be configured per agent (`agents.list[].heartbeat`) or globally
- Uses the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`) + `HEARTBEAT_OK` skip behavior. via `agents.defaults.heartbeat` (fallback when no per-agent entries are set).
- Uses the configured heartbeat prompt (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`) + `HEARTBEAT_OK` skip behavior.
- Delivery defaults to the last used channel (or configured target). - Delivery defaults to the last used channel (or configured target).
## Reconnect behavior ## Reconnect behavior
@@ -330,6 +331,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
- `agents.defaults.heartbeat.model` (optional override) - `agents.defaults.heartbeat.model` (optional override)
- `agents.defaults.heartbeat.target` - `agents.defaults.heartbeat.target`
- `agents.defaults.heartbeat.to` - `agents.defaults.heartbeat.to`
- `agents.list[].heartbeat.*` (per-agent overrides)
- `session.*` (scope, idle, store, mainKey) - `session.*` (scope, idle, store, mainKey)
- `web.enabled` (disable channel startup when false) - `web.enabled` (disable channel startup when false)
- `web.heartbeatSeconds` - `web.heartbeatSeconds`
+3 -1
View File
@@ -11,10 +11,12 @@ Interactive prompt to set up credentials, devices, and agent defaults.
Related: Related:
- Gateway configuration reference: [Configuration](/gateway/configuration) - Gateway configuration reference: [Configuration](/gateway/configuration)
Notes:
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
## Examples ## Examples
```bash ```bash
clawdbot configure clawdbot configure
clawdbot configure --section models --section channels clawdbot configure --section models --section channels
``` ```
+1 -1
View File
@@ -78,7 +78,7 @@ clawdbot gateway health --url ws://127.0.0.1:18789
- your configured remote gateway (if set), and - your configured remote gateway (if set), and
- localhost (loopback) **even if remote is configured**. - localhost (loopback) **even if remote is configured**.
If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use profiles for redundancy, but most installs still run a single gateway. If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use isolated profiles/ports (e.g., a rescue bot), but most installs still run a single gateway.
```bash ```bash
clawdbot gateway status clawdbot gateway status
+1 -1
View File
@@ -785,7 +785,7 @@ Location:
## Browser ## Browser
Browser control CLI (dedicated Chrome/Chromium). See [`clawdbot browser`](/cli/browser) and the [Browser tool](/tools/browser). Browser control CLI (dedicated Chrome/Brave/Edge/Chromium). See [`clawdbot browser`](/cli/browser) and the [Browser tool](/tools/browser).
Common options: Common options:
- `--url <controlUrl>` - `--url <controlUrl>`
+1 -1
View File
@@ -44,7 +44,7 @@ Target formats (`--to`):
- `send` - `send`
- Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams - Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
- Required: `--to`, `--message` - Required: `--to`, plus `--message` or `--media`
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback` - Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
- Telegram only: `--buttons` (requires `"inlineButtons"` in `channels.telegram.capabilities` or `channels.telegram.accounts.<id>.capabilities`) - Telegram only: `--buttons` (requires `"inlineButtons"` in `channels.telegram.capabilities` or `channels.telegram.accounts.<id>.capabilities`)
- Telegram only: `--thread-id` (forum topic id) - Telegram only: `--thread-id` (forum topic id)
+10
View File
@@ -22,6 +22,10 @@ clawdbot models set <model-or-alias>
clawdbot models scan clawdbot models scan
``` ```
`clawdbot models status` shows the resolved default/fallbacks plus an auth overview.
When provider usage snapshots are available, the OAuth/token status section includes
provider usage headers.
## Aliases + fallbacks ## Aliases + fallbacks
```bash ```bash
@@ -33,7 +37,13 @@ clawdbot models fallbacks list
```bash ```bash
clawdbot models auth add clawdbot models auth add
clawdbot models auth login --provider <id>
clawdbot models auth setup-token clawdbot models auth setup-token
clawdbot models auth paste-token clawdbot models auth paste-token
``` ```
`models auth login` runs a provider plugin’s auth flow (OAuth/API key). Use
`clawdbot plugins list` to see which providers are installed.
Notes:
- `setup-token` runs `claude setup-token` on the current machine (requires the Claude Code CLI).
- `paste-token` accepts a token string generated elsewhere.
+1
View File
@@ -20,3 +20,4 @@ clawdbot security audit --deep
clawdbot security audit --fix clawdbot security audit --fix
``` ```
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
+26
View File
@@ -33,6 +33,32 @@ Channels can redeliver the same message after reconnects. Clawdbot keeps a
short-lived cache keyed by channel/account/peer/session/message id so duplicate short-lived cache keyed by channel/account/peer/session/message id so duplicate
deliveries do not trigger another agent run. deliveries do not trigger another agent run.
## Inbound debouncing
Rapid consecutive messages from the **same sender** can be batched into a single
agent turn via `messages.inbound`. Debouncing is scoped per channel + conversation
and uses the most recent message for reply threading/IDs.
Config (global default + per-channel overrides):
```json5
{
messages: {
inbound: {
debounceMs: 2000,
byChannel: {
whatsapp: 5000,
slack: 1500,
discord: 1500
}
}
}
}
```
Notes:
- Debounce applies to **text-only** messages; media/attachments flush immediately.
- Control commands bypass debouncing so they remain standalone.
## Sessions and devices ## Sessions and devices
Sessions are owned by the gateway, not by clients. Sessions are owned by the gateway, not by clients.
+11
View File
@@ -48,6 +48,17 @@ If no explicit order is configured, Clawdbot uses a round‑robin order:
- **Secondary key:** `usageStats.lastUsed` (oldest first, within each type). - **Secondary key:** `usageStats.lastUsed` (oldest first, within each type).
- **Cooldown/disabled profiles** are moved to the end, ordered by soonest expiry. - **Cooldown/disabled profiles** are moved to the end, ordered by soonest expiry.
### Session stickiness (cache-friendly)
Clawdbot **pins the chosen auth profile per session** to keep provider caches warm.
It does **not** rotate on every request. The pinned profile is reused until:
- the session is reset (`/new` / `/reset`)
- a compaction completes (compaction count increments)
- the profile is in cooldown/disabled
Manual selection via `/model …@<profileId>` sets a **user override** for that session
and is not auto‑rotated until a new session starts.
### Why OAuth can “look lost” ### Why OAuth can “look lost”
If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile: If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile:
+1 -1
View File
@@ -152,7 +152,7 @@ JSON includes `auth.oauth` (warn window + profiles) and `auth.providers`
(effective auth per provider). (effective auth per provider).
Use `--check` for automation (exit `1` when missing/expired, `2` when expiring). Use `--check` for automation (exit `1` when missing/expired, `2` when expiring).
Preferred Anthropic auth is the Claude Code CLI setup-token (run on the gateway host): Preferred Anthropic auth is the Claude Code CLI setup-token (run anywhere; paste on the gateway host if needed):
```bash ```bash
claude setup-token claude setup-token
+7
View File
@@ -15,6 +15,13 @@ Clawdbot supports “subscription auth” via OAuth for providers that offer it
- how we **reuse external CLI tokens** (Claude Code / Codex CLI) - how we **reuse external CLI tokens** (Claude Code / Codex CLI)
- how to handle **multiple accounts** (profiles + per-session overrides) - how to handle **multiple accounts** (profiles + per-session overrides)
Clawdbot also supports **provider plugins** that ship their own OAuth or API‑key
flows. Run them via:
```bash
clawdbot models auth login --provider <id>
```
## The token sink (why it exists) ## The token sink (why it exists)
OAuth providers commonly mint a **new refresh token** during login/refresh flows. Some providers (or OAuth clients) can invalidate older refresh tokens when a new one is issued for the same user/app. OAuth providers commonly mint a **new refresh token** during login/refresh flows. Some providers (or OAuth clients) can invalidate older refresh tokens when a new one is issued for the same user/app.
+11 -2
View File
@@ -7,6 +7,11 @@ read_when:
Clawdbot treats **one direct-chat session per agent** as primary. Direct chats collapse to `agent:<agentId>:<mainKey>` (default `main`), while group/channel chats get their own keys. `session.mainKey` is honored. Clawdbot treats **one direct-chat session per agent** as primary. Direct chats collapse to `agent:<agentId>:<mainKey>` (default `main`), while group/channel chats get their own keys. `session.mainKey` is honored.
Use `session.dmScope` to control how **direct messages** are grouped:
- `main` (default): all DMs share the main session for continuity.
- `per-peer`: isolate by sender id across channels.
- `per-channel-peer`: isolate by channel + sender (recommended for multi-user inboxes).
## Gateway is the source of truth ## Gateway is the source of truth
All session state is **owned by the gateway** (the “master” Clawdbot). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files. All session state is **owned by the gateway** (the “master” Clawdbot). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files.
@@ -32,8 +37,11 @@ the workspace is writable. See [Memory](/concepts/memory) and
[Compaction](/concepts/compaction). [Compaction](/concepts/compaction).
## Mapping transports → session keys ## Mapping transports → session keys
- Direct chats collapse to the per-agent primary key: `agent:<agentId>:<mainKey>`. - Direct chats follow `session.dmScope` (default `main`).
- Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation. - `main`: `agent:<agentId>:<mainKey>` (continuity across devices/channels).
- Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation.
- `per-peer`: `agent:<agentId>:dm:<peerId>`.
- `per-channel-peer`: `agent:<agentId>:<channel>:dm:<peerId>`.
- Group chats isolate state: `agent:<agentId>:<channel>:group:<id>` (rooms/channels use `agent:<agentId>:<channel>:channel:<id>`). - Group chats isolate state: `agent:<agentId>:<channel>:group:<id>` (rooms/channels use `agent:<agentId>:<channel>:channel:<id>`).
- Telegram forum topics append `:topic:<threadId>` to the group id for isolation. - Telegram forum topics append `:topic:<threadId>` to the group id for isolation.
- Legacy `group:<id>` keys are still recognized for migration. - Legacy `group:<id>` keys are still recognized for migration.
@@ -77,6 +85,7 @@ Send these as standalone messages so they register.
{ {
session: { session: {
scope: "per-sender", // keep group keys separate scope: "per-sender", // keep group keys separate
dmScope: "main", // DM continuity (set per-channel-peer for shared inboxes)
idleMinutes: 120, idleMinutes: 120,
resetTriggers: ["/new", "/reset"], resetTriggers: ["/new", "/reset"],
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json", store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
+28 -5
View File
@@ -20,12 +20,28 @@ The prompt is intentionally compact and uses fixed sections:
- **Workspace**: working directory (`agents.defaults.workspace`). - **Workspace**: working directory (`agents.defaults.workspace`).
- **Workspace Files (injected)**: indicates bootstrap files are included below. - **Workspace Files (injected)**: indicates bootstrap files are included below.
- **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available. - **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available.
- **Time**: UTC default + the user’s local time (already converted). - **Current Date & Time**: user-local time, timezone, and time format.
- **Reply Tags**: optional reply tag syntax for supported providers. - **Reply Tags**: optional reply tag syntax for supported providers.
- **Heartbeats**: heartbeat prompt and ack behavior. - **Heartbeats**: heartbeat prompt and ack behavior.
- **Runtime**: host, OS, node, model, thinking level (one line). - **Runtime**: host, OS, node, model, thinking level (one line).
- **Reasoning**: current visibility level + /reasoning toggle hint. - **Reasoning**: current visibility level + /reasoning toggle hint.
## Prompt modes
Clawdbot can render smaller system prompts for sub-agents. The runtime sets a
`promptMode` for each run (not a user-facing config):
- `full` (default): includes all sections above.
- `minimal`: used for sub-agents; omits **Skills**, **Memory Recall**, **Clawdbot
Self-Update**, **Model Aliases**, **User Identity**, **Reply Tags**,
**Messaging**, **Silent Replies**, and **Heartbeats**. Tooling, Workspace,
Sandbox, Current Date & Time (when known), Runtime, and injected context stay
available.
- `none`: returns only the base identity line.
When `promptMode=minimal`, extra injected prompts are labeled **Subagent
Context** instead of **Group Chat Context**.
## Workspace bootstrap injection ## Workspace bootstrap injection
Bootstrap files are trimmed and appended under **Project Context** so the model sees identity and profile context without needing explicit reads: Bootstrap files are trimmed and appended under **Project Context** so the model sees identity and profile context without needing explicit reads:
@@ -46,12 +62,19 @@ To inspect how much each injected file contributes (raw vs injected, truncation,
## Time handling ## Time handling
The Time line is compact and explicit: The system prompt includes a dedicated **Current Date & Time** section when user
time or timezone is known. It is explicit about:
- Assume timestamps are **UTC** unless stated. - The user’s **local time** (already converted).
- The listed **user time** is already converted to `agents.defaults.userTimezone` (if set). - The **time zone** used for the conversion.
- The **time format** (12-hour / 24-hour).
Use `agents.defaults.userTimezone` in `~/.clawdbot/clawdbot.json` to change the user time zone. Configure with:
- `agents.defaults.userTimezone`
- `agents.defaults.timeFormat` (`auto` | `12` | `24`)
See [Date & Time](/date-time) for full behavior details.
## Skills ## Skills
+14 -5
View File
@@ -19,10 +19,15 @@ Inbound messages are wrapped in an envelope like:
The timestamp in the envelope is **always UTC**, with minutes precision. The timestamp in the envelope is **always UTC**, with minutes precision.
## Tool payloads (raw provider data) ## Tool payloads (raw provider data + normalized fields)
Tool calls (`channels.discord.readMessages`, `channels.slack.readMessages`, etc.) return **raw provider timestamps**. Tool calls (`channels.discord.readMessages`, `channels.slack.readMessages`, etc.) return **raw provider timestamps**.
These are typically UTC ISO strings (Discord) or UTC epoch strings (Slack). We do not rewrite them. We also attach normalized fields for consistency:
- `timestampMs` (UTC epoch milliseconds)
- `timestampUtc` (ISO 8601 UTC string)
Raw provider fields are preserved.
## User timezone for the system prompt ## User timezone for the system prompt
@@ -31,10 +36,14 @@ unset, Clawdbot resolves the **host timezone at runtime** (no config write).
```json5 ```json5
{ {
agent: { userTimezone: "America/Chicago" } agents: { defaults: { userTimezone: "America/Chicago" } }
} }
``` ```
The system prompt includes: The system prompt includes:
- `User timezone: America/Chicago` - `Current Date & Time` section with local time and timezone
- `Current user time: 2026-01-05 15:26` - `Time format: 12-hour` or `24-hour`
You can control the prompt format with `agents.defaults.timeFormat` (`auto` | `12` | `24`).
See [Date & Time](/date-time) for the full behavior and examples.
+1 -1
View File
@@ -44,7 +44,7 @@ Common methods + events:
| --- | --- | --- | | --- | --- | --- |
| Core | `connect`, `health`, `status` | `connect` must be first | | Core | `connect`, `health`, `status` | `connect` must be first |
| Messaging | `send`, `poll`, `agent`, `agent.wait` | side-effects need `idempotencyKey` | | Messaging | `send`, `poll`, `agent`, `agent.wait` | side-effects need `idempotencyKey` |
| Chat | `chat.history`, `chat.send`, `chat.abort` | WebChat uses these | | Chat | `chat.history`, `chat.send`, `chat.abort`, `chat.inject` | WebChat uses these |
| Sessions | `sessions.list`, `sessions.patch`, `sessions.delete` | session admin | | Sessions | `sessions.list`, `sessions.patch`, `sessions.delete` | session admin |
| Nodes | `node.list`, `node.invoke`, `node.pair.*` | bridge + node actions | | Nodes | `node.list`, `node.invoke`, `node.pair.*` | bridge + node actions |
| Events | `tick`, `presence`, `agent`, `chat`, `health`, `shutdown` | server push | | Events | `tick`, `presence`, `agent`, `chat`, `health`, `shutdown` | server push |
+1 -1
View File
@@ -11,7 +11,7 @@ read_when:
- No estimated costs; only the provider-reported windows. - No estimated costs; only the provider-reported windows.
## Where it shows up ## Where it shows up
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only) and provider quota windows when available. - `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). When OAuth/token profiles exist, the **OAuth/token status block** includes provider usage headers (when available).
- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only). - `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only).
- CLI: `clawdbot status --usage` prints a full per-provider breakdown. - CLI: `clawdbot status --usage` prints a full per-provider breakdown.
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip). - CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
+85
View File
@@ -0,0 +1,85 @@
---
summary: "Date and time handling across envelopes, prompts, tools, and connectors"
read_when:
- You are changing how timestamps are shown to the model or users
- You are debugging time formatting in messages or system prompt output
---
# Date & Time
Clawdbot uses **UTC for transport timestamps** and **user-local time only in the system prompt**.
We avoid rewriting provider timestamps so tools keep their native semantics.
## Message envelopes (UTC)
Inbound messages are wrapped with a UTC timestamp (minute precision):
```
[Provider ... 2026-01-05T21:26Z] message text
```
This envelope timestamp is **always UTC**, regardless of the host timezone.
## System prompt: Current Date & Time
If the user timezone or local time is known, the system prompt includes a dedicated
**Current Date & Time** section:
```
Thursday, January 15th, 2026 — 3:07 PM (America/Chicago)
Time format: 12-hour
```
If only the timezone is known, we still include the section and instruct the model
to assume UTC for unknown time references.
## System event lines (UTC)
Queued system events inserted into agent context are prefixed with a UTC timestamp:
```
System: [2026-01-12T20:19:17Z] Model switched.
```
### Configure user timezone + format
```json5
{
agents: {
defaults: {
userTimezone: "America/Chicago",
timeFormat: "auto" // auto | 12 | 24
}
}
}
```
- `userTimezone` sets the **user-local timezone** for prompt context.
- `timeFormat` controls **12h/24h display** in the prompt. `auto` follows OS prefs.
## Time format detection (auto)
When `timeFormat: "auto"`, Clawdbot inspects the OS preference (macOS/Windows)
and falls back to locale formatting. The detected value is **cached per process**
to avoid repeated system calls.
## Tool payloads + connectors (raw provider time + normalized fields)
Channel tools return **provider-native timestamps** and add normalized fields for consistency:
- `timestampMs`: epoch milliseconds (UTC)
- `timestampUtc`: ISO 8601 UTC string
Raw provider fields are preserved so nothing is lost.
- Slack: epoch-like strings from the API
- Discord: UTC ISO timestamps
- Telegram/WhatsApp: provider-specific numeric/ISO timestamps
If you need local time, convert it downstream using the known timezone.
## Related docs
- [System Prompt](/concepts/system-prompt)
- [Timezones](/concepts/timezone)
- [Messages](/concepts/messages)
+38
View File
@@ -455,6 +455,44 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
} }
``` ```
### Anthropic subscription + API key, MiniMax fallback
```json5
{
auth: {
profiles: {
"anthropic:subscription": {
provider: "anthropic",
mode: "oauth",
email: "user@example.com"
},
"anthropic:api": {
provider: "anthropic",
mode: "api_key"
}
},
order: {
anthropic: ["anthropic:subscription", "anthropic:api"]
}
},
models: {
providers: {
minimax: {
baseUrl: "https://api.minimax.io/anthropic",
api: "anthropic-messages",
apiKey: "${MINIMAX_API_KEY}"
}
}
},
agent: {
workspace: "~/clawd",
model: {
primary: "anthropic/claude-opus-4-5",
fallbacks: ["minimax/MiniMax-M2.1"]
}
}
}
```
### Work bot (restricted access) ### Work bot (restricted access)
```json5 ```json5
{ {
+53 -6
View File
@@ -822,6 +822,31 @@ Controls how inbound messages behave when an agent run is already active.
} }
``` ```
### `messages.inbound`
Debounce rapid inbound messages from the **same sender** so multiple back-to-back
messages become a single agent turn. Debouncing is scoped per channel + conversation
and uses the most recent message for reply threading/IDs.
```json5
{
messages: {
inbound: {
debounceMs: 2000, // 0 disables
byChannel: {
whatsapp: 5000,
slack: 1500,
discord: 1500
}
}
}
}
```
Notes:
- Debounce batches **text-only** messages; media/attachments flush immediately.
- Control commands (e.g. `/queue`, `/new`) bypass debouncing so they stay standalone.
### `commands` (chat command handling) ### `commands` (chat command handling)
Controls how chat commands are enabled across connectors. Controls how chat commands are enabled across connectors.
@@ -1211,6 +1236,17 @@ message envelopes). If unset, Clawdbot uses the host timezone at runtime.
} }
``` ```
### `agents.defaults.timeFormat`
Controls the **time format** shown in the system prompt’s Current Date & Time section.
Default: `auto` (OS preference).
```json5
{
agents: { defaults: { timeFormat: "auto" } } // auto | 12 | 24
}
```
### `messages` ### `messages`
Controls inbound/outbound prefixes and optional ack reactions. Controls inbound/outbound prefixes and optional ack reactions.
@@ -1639,9 +1675,14 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
- `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`. - `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`.
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`. - `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram). - `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md if exists` line if you still want the file read. - `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md` line if you still want the file read.
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 300). - `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 300).
Per-agent heartbeats:
- Set `agents.list[].heartbeat` to enable or override heartbeat settings for a specific agent.
- If any agent entry defines `heartbeat`, **only those agents** run heartbeats; defaults
become the shared baseline for those agents.
Heartbeats run full agent turns. Shorter intervals burn more tokens; be mindful Heartbeats run full agent turns. Shorter intervals burn more tokens; be mindful
of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`. of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
@@ -2220,6 +2261,7 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
{ {
session: { session: {
scope: "per-sender", scope: "per-sender",
dmScope: "main",
idleMinutes: 60, idleMinutes: 60,
resetTriggers: ["/new", "/reset"], resetTriggers: ["/new", "/reset"],
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json // Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
@@ -2244,6 +2286,10 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
Fields: Fields:
- `mainKey`: direct-chat bucket key (default: `"main"`). Useful when you want to “rename” the primary DM thread without changing `agentId`. - `mainKey`: direct-chat bucket key (default: `"main"`). Useful when you want to “rename” the primary DM thread without changing `agentId`.
- Sandbox note: `agents.defaults.sandbox.mode: "non-main"` uses this key to detect the main session. Any session key that does not match `mainKey` (groups/channels) is sandboxed. - Sandbox note: `agents.defaults.sandbox.mode: "non-main"` uses this key to detect the main session. Any session key that does not match `mainKey` (groups/channels) is sandboxed.
- `dmScope`: how DM sessions are grouped (default: `"main"`).
- `main`: all DMs share the main session for continuity.
- `per-peer`: isolate DMs by sender id across channels.
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5). - `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches. - `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow. - `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
@@ -2335,10 +2381,10 @@ Example:
} }
``` ```
### `browser` (clawd-managed Chrome) ### `browser` (clawd-managed browser)
Clawdbot can start a **dedicated, isolated** Chrome/Chromium instance for clawd and expose a small loopback control server. Clawdbot can start a **dedicated, isolated** Chrome/Brave/Edge/Chromium instance for clawd and expose a small loopback control server.
Profiles can point at a **remote** Chrome via `profiles.<name>.cdpUrl`. Remote Profiles can point at a **remote** Chromium-based browser via `profiles.<name>.cdpUrl`. Remote
profiles are attach-only (start/stop/reset are disabled). profiles are attach-only (start/stop/reset are disabled).
`browser.cdpUrl` remains for legacy single-profile configs and as the base `browser.cdpUrl` remains for legacy single-profile configs and as the base
@@ -2350,6 +2396,7 @@ Defaults:
- CDP URL: `http://127.0.0.1:18792` (control URL + 1, legacy single-profile) - CDP URL: `http://127.0.0.1:18792` (control URL + 1, legacy single-profile)
- profile color: `#FF4500` (lobster-orange) - profile color: `#FF4500` (lobster-orange)
- Note: the control server is started by the running gateway (Clawdbot.app menubar, or `clawdbot gateway`). - Note: the control server is started by the running gateway (Clawdbot.app menubar, or `clawdbot gateway`).
- Auto-detect order: Chrome → Brave → Edge → Chromium → Chrome Canary.
```json5 ```json5
{ {
@@ -2367,7 +2414,7 @@ Defaults:
// Advanced: // Advanced:
// headless: false, // headless: false,
// noSandbox: false, // noSandbox: false,
// executablePath: "/usr/bin/chromium", // executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
// attachOnly: false, // set true when tunneling a remote CDP to localhost // attachOnly: false, // set true when tunneling a remote CDP to localhost
} }
} }
@@ -2512,7 +2559,7 @@ Requires full Gateway restart:
### Multi-instance isolation ### Multi-instance isolation
To run multiple gateways on one host, isolate per-instance state + config and use unique ports: To run multiple gateways on one host (for redundancy or a rescue bot), isolate per-instance state + config and use unique ports:
- `CLAWDBOT_CONFIG_PATH` (per-instance config) - `CLAWDBOT_CONFIG_PATH` (per-instance config)
- `CLAWDBOT_STATE_DIR` (sessions/creds) - `CLAWDBOT_STATE_DIR` (sessions/creds)
- `agents.defaults.workspace` (memories) - `agents.defaults.workspace` (memories)
+1 -1
View File
@@ -16,7 +16,7 @@ The design goal is to keep all network discovery/advertising in the **Node Gatew
## Terms ## Terms
- **Gateway**: the single, long-running gateway process that owns state (sessions, pairing, node registry) and runs channels. - **Gateway**: a single long-running gateway process that owns state (sessions, pairing, node registry) and runs channels. Most setups use one per host; isolated multi-gateway setups are possible.
- **Gateway WS (loopback)**: the existing gateway WebSocket control endpoint on `127.0.0.1:18789`. - **Gateway WS (loopback)**: the existing gateway WebSocket control endpoint on `127.0.0.1:18789`.
- **Bridge (direct transport)**: a LAN/tailnet-facing endpoint owned by the gateway that allows authenticated clients/nodes to call a scoped subset of gateway methods. The bridge exists so the gateway can remain loopback-only. - **Bridge (direct transport)**: a LAN/tailnet-facing endpoint owned by the gateway that allows authenticated clients/nodes to call a scoped subset of gateway methods. The bridge exists so the gateway can remain loopback-only.
- **SSH transport (fallback)**: remote control by forwarding `127.0.0.1:18789` over SSH. - **SSH transport (fallback)**: remote control by forwarding `127.0.0.1:18789` over SSH.
+1 -1
View File
@@ -165,7 +165,7 @@ Doctor checks:
### 5) Model auth health (OAuth expiry) ### 5) Model auth health (OAuth expiry)
Doctor inspects OAuth profiles in the auth store, warns when tokens are Doctor inspects OAuth profiles in the auth store, warns when tokens are
expiring/expired, and can refresh them when safe. If the Anthropic Claude Code expiring/expired, and can refresh them when safe. If the Anthropic Claude Code
profile is stale, it suggests `claude setup-token` on the gateway host. profile is stale, it suggests running `claude setup-token` (or pasting a setup-token).
Refresh prompts only appear when running interactively (TTY); `--non-interactive` Refresh prompts only appear when running interactively (TTY); `--non-interactive`
skips refresh attempts. skips refresh attempts.
+1 -1
View File
@@ -9,7 +9,7 @@ read_when:
Last updated: 2025-12-11 Last updated: 2025-12-11
## Why ## Why
- Ensure only one gateway instance runs per host. - Ensure only one gateway instance runs per base port on the same host; additional gateways must use isolated profiles and unique ports.
- Survive crashes/SIGKILL without leaving stale lock files. - Survive crashes/SIGKILL without leaving stale lock files.
- Fail fast with a clear error when the control port is already occupied. - Fail fast with a clear error when the control port is already occupied.
+43 -6
View File
@@ -33,9 +33,9 @@ Example config:
## Defaults ## Defaults
- Interval: `30m` (set `agents.defaults.heartbeat.every`; use `0m` to disable). - Interval: `30m` (set `agents.defaults.heartbeat.every` or per-agent `agents.list[].heartbeat.every`; use `0m` to disable).
- Prompt body (configurable via `agents.defaults.heartbeat.prompt`): - Prompt body (configurable via `agents.defaults.heartbeat.prompt`):
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.` `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
- The heartbeat prompt is sent **verbatim** as the user message. The system - The heartbeat prompt is sent **verbatim** as the user message. The system
prompt includes a “Heartbeat” section and the run is flagged internally. prompt includes a “Heartbeat” section and the run is flagged internally.
@@ -49,8 +49,8 @@ The default prompt is intentionally broad:
by using your configured local timezone (see [/concepts/timezone](/concepts/timezone)). by using your configured local timezone (see [/concepts/timezone](/concepts/timezone)).
If you want a heartbeat to do something very specific (e.g. “check Gmail PubSub If you want a heartbeat to do something very specific (e.g. “check Gmail PubSub
stats” or “verify gateway health”), set `agents.defaults.heartbeat.prompt` to a stats” or “verify gateway health”), set `agents.defaults.heartbeat.prompt` (or
custom body (sent verbatim). `agents.list[].heartbeat.prompt`) to a custom body (sent verbatim).
## Response contract ## Response contract
@@ -77,7 +77,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
includeReasoning: false, // default: false (deliver separate Reasoning: message when available) includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none
to: "+15551234567", // optional channel-specific override to: "+15551234567", // optional channel-specific override
prompt: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.", prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
ackMaxChars: 300 // max chars allowed after HEARTBEAT_OK ackMaxChars: 300 // max chars allowed after HEARTBEAT_OK
} }
} }
@@ -85,6 +85,39 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
} }
``` ```
### Per-agent heartbeats
If any `agents.list[]` entry includes a `heartbeat` block, **only those agents**
run heartbeats. The per-agent block merges on top of `agents.defaults.heartbeat`
(so you can set shared defaults once and override per agent).
Example: two agents, only the second agent runs heartbeats.
```json5
{
agents: {
defaults: {
heartbeat: {
every: "30m",
target: "last"
}
},
list: [
{ id: "main", default: true },
{
id: "ops",
heartbeat: {
every: "1h",
target: "whatsapp",
to: "+15551234567",
prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK."
}
}
]
}
}
```
### Field notes ### Field notes
- `every`: heartbeat interval (duration string; default unit = minutes). - `every`: heartbeat interval (duration string; default unit = minutes).
@@ -100,7 +133,8 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
## Delivery behavior ## Delivery behavior
- Heartbeats run in the **main session** (`main`, or `global` when scope is global). - Heartbeats run in each agent’s **main session** (`agent:<id>:<mainKey>`), or `global`
when `session.scope = "global"`.
- If the main queue is busy, the heartbeat is skipped and retried later. - If the main queue is busy, the heartbeat is skipped and retried later.
- If `target` resolves to no external destination, the run still happens but no - If `target` resolves to no external destination, the run still happens but no
outbound message is sent. outbound message is sent.
@@ -149,6 +183,9 @@ You can enqueue a system event and trigger an immediate heartbeat with:
clawdbot wake --text "Check for urgent follow-ups" --mode now clawdbot wake --text "Check for urgent follow-ups" --mode now
``` ```
If multiple agents have `heartbeat` configured, a manual wake runs each of those
agent heartbeats immediately.
Use `--mode next-heartbeat` to wait for the next scheduled tick. Use `--mode next-heartbeat` to wait for the next scheduled tick.
## Reasoning delivery (optional) ## Reasoning delivery (optional)
+3 -1
View File
@@ -63,6 +63,8 @@ Install metadata is embedded in the service config:
- `CLAWDBOT_SERVICE_KIND=gateway` - `CLAWDBOT_SERVICE_KIND=gateway`
- `CLAWDBOT_SERVICE_VERSION=<version>` - `CLAWDBOT_SERVICE_VERSION=<version>`
Rescue-Bot Pattern: keep a second Gateway isolated with its own profile, state dir, workspace, and base port spacing. Full guide: [Rescue-bot guide](/gateway/multiple-gateways#rescue-bot-guide).
### Dev profile (`--dev`) ### Dev profile (`--dev`)
Fast path: run a fully-isolated dev instance (config/state/workspace) without touching your primary setup. Fast path: run a fully-isolated dev instance (config/state/workspace) without touching your primary setup.
@@ -205,7 +207,7 @@ Notes:
- `daemon status` includes the last gateway error line when the service looks running but the port is closed. - `daemon status` includes the last gateway error line when the service looks running but the port is closed.
- `logs` tails the Gateway file log via RPC (no manual `tail`/`grep` needed). - `logs` tails the Gateway file log via RPC (no manual `tail`/`grep` needed).
- If other gateway-like services are detected, the CLI warns unless they are Clawdbot profile services. - If other gateway-like services are detected, the CLI warns unless they are Clawdbot profile services.
We still recommend **one gateway per machine** unless you need redundant profiles. We still recommend **one gateway per machine** for most setups; use isolated profiles/ports for redundancy or a rescue bot. See [Multiple gateways](/gateway/multiple-gateways).
- Cleanup: `clawdbot daemon uninstall` (current service) and `clawdbot doctor` (legacy migrations). - Cleanup: `clawdbot daemon uninstall` (current service) and `clawdbot doctor` (legacy migrations).
- `daemon install` is a no-op when already installed; use `clawdbot daemon install --force` to reinstall (profile/env/path changes). - `daemon install` is a no-op when already installed; use `clawdbot daemon install --force` to reinstall (profile/env/path changes).
+33 -1
View File
@@ -6,7 +6,7 @@ read_when:
--- ---
# Multiple Gateways (same host) # Multiple Gateways (same host)
Most setups should use one Gateway because a single Gateway can handle multiple messaging connections and agents. If you need stronger isolation or redundancy, run separate Gateways. Both are supported. Most setups should use one Gateway because a single Gateway can handle multiple messaging connections and agents. If you need stronger isolation or redundancy (e.g., a rescue bot), run separate Gateways with isolated profiles/ports.
## Isolation checklist (required) ## Isolation checklist (required)
- `CLAWDBOT_CONFIG_PATH` — per-instance config file - `CLAWDBOT_CONFIG_PATH` — per-instance config file
@@ -37,6 +37,38 @@ clawdbot --profile main daemon install
clawdbot --profile rescue daemon install clawdbot --profile rescue daemon install
``` ```
## Rescue-bot guide
Run a second Gateway on the same host with its own:
- profile/config
- state dir
- workspace
- base port (plus derived ports)
This keeps the rescue bot isolated from the main bot so it can debug or apply config changes if the primary bot is down.
Port spacing: leave at least 20 ports between base ports so the derived bridge/browser/canvas/CDP ports never collide.
### How to install (rescue bot)
```bash
# Main bot (existing or fresh, without --profile param)
# Runs on port 18789 + Chrome CDC/Canvas/... Ports
clawdbot onboard
clawdbot daemon install
# Rescue bot (isolated profile + ports)
clawdbot --profile rescue onboard
# Notes:
# - workspace name will be postfixed with -rescue per default
# - Port should be at least 18789 + 20 Ports,
# better choose completely different base port, like 19789,
# - rest of the onboarding is the same as normal
# To install the daemon (if not happened automatically during onboarding)
clawdbot --profile rescue daemon install
```
## Port mapping (derived) ## Port mapping (derived)
Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`). Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`).
+1 -1
View File
@@ -26,7 +26,7 @@ Flow example (Telegram → node):
- Node returns the result; Gateway replies back out to Telegram. - Node returns the result; Gateway replies back out to Telegram.
Notes: Notes:
- **Nodes do not run the gateway daemon.** Only one gateway should run per host. - **Nodes do not run the gateway daemon.** Only one gateway should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)).
- macOS app “node mode” is just a node client over the Bridge. - macOS app “node mode” is just a node client over the Bridge.
## SSH tunnel (CLI + tools) ## SSH tunnel (CLI + tools)
+28
View File
@@ -52,6 +52,22 @@ When the audit prints findings, treat this as a priority order:
5. **Plugins/extensions**: only load what you explicitly trust. 5. **Plugins/extensions**: only load what you explicitly trust.
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools. 6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
## Node execution (system.run)
If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac:
- Requires node pairing (approval + token).
- Controlled on the Mac via **Settings → "Node Run Commands"**: "Always Ask" (default), "Always Allow", or "Never".
- If you don’t want remote execution, set the policy to "Never" and remove node pairing for that Mac.
## Dynamic skills (watcher / remote nodes)
Clawdbot can refresh the skills list mid-session:
- **Skills watcher**: changes to `SKILL.md` can update the skills snapshot on the next agent turn.
- **Remote nodes**: connecting a macOS node can make macOS-only skills eligible (based on bin probing).
Treat skill folders as **trusted code** and restrict who can modify them.
## The Threat Model ## The Threat Model
Your AI assistant can: Your AI assistant can:
@@ -107,6 +123,18 @@ clawdbot pairing approve <channel> <code>
Details + files on disk: [Pairing](/start/pairing) Details + files on disk: [Pairing](/start/pairing)
## DM session isolation (multi-user mode)
By default, Clawdbot routes **all DMs into the main session** so your assistant has continuity across devices and channels. If **multiple people** can DM the bot (open DMs or a multi-person allowlist), consider isolating DM sessions:
```json5
{
session: { dmScope: "per-channel-peer" }
}
```
This prevents cross-user context leakage while keeping group chats isolated. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration).
## Allowlists (DM + groups) — terminology ## Allowlists (DM + groups) — terminology
Clawdbot has two separate “who can trigger me?” layers: Clawdbot has two separate “who can trigger me?” layers:
+1 -1
View File
@@ -66,7 +66,7 @@ Most operations flow through the **Gateway** (`clawdbot gateway`), a single long
## Network model ## Network model
- **One Gateway per host**: it is the only process allowed to own the WhatsApp Web session. - **One Gateway per host (recommended)**: it is the only process allowed to own the WhatsApp Web session. If you need a rescue bot or strict isolation, run multiple gateways with isolated profiles and ports; see [Multiple gateways](/gateway/multiple-gateways).
- **Loopback-first**: Gateway WS defaults to `ws://127.0.0.1:18789`. - **Loopback-first**: Gateway WS defaults to `ws://127.0.0.1:18789`.
- The wizard now generates a gateway token by default (even for loopback). - The wizard now generates a gateway token by default (even for loopback).
- For Tailnet access, run `clawdbot gateway --bind tailnet --token ...` (token is required for non-loopback binds). - For Tailnet access, run `clawdbot gateway --bind tailnet --token ...` (token is required for non-loopback binds).
+1
View File
@@ -163,6 +163,7 @@ Notes:
- `system.notify` respects notification permission state on the macOS app. - `system.notify` respects notification permission state on the macOS app.
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`. - `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`. - `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
- `system.run` is gated by the macOS app policy (Settings → "Node Run Commands"): "Always Ask" prompts per command, "Always Allow" runs without prompts, and "Never" disables the tool. Denied prompts return `SYSTEM_RUN_DENIED`; disabled returns `SYSTEM_RUN_DISABLED`.
## Permissions map ## Permissions map
+2 -2
View File
@@ -24,8 +24,8 @@ agent (with a session switcher for other sessions).
## How it’s wired ## How it’s wired
- Data plane: Gateway WS methods `chat.history`, `chat.send`, `chat.abort` and - Data plane: Gateway WS methods `chat.history`, `chat.send`, `chat.abort`,
events `chat`, `agent`, `presence`, `tick`, `health`. `chat.inject` and events `chat`, `agent`, `presence`, `tick`, `health`.
- Session: defaults to the primary session (`main`, or `global` when scope is - Session: defaults to the primary session (`main`, or `global` when scope is
global). The UI can switch between sessions. global). The UI can switch between sessions.
- Onboarding uses a dedicated session to keep first‑run setup separate. - Onboarding uses a dedicated session to keep first‑run setup separate.
+58
View File
@@ -54,6 +54,38 @@ The macOS app presents itself as a node. Common commands:
The node reports a `permissions` map so agents can decide what’s allowed. The node reports a `permissions` map so agents can decide what’s allowed.
## Node run policy + allowlist
`system.run` is controlled by the macOS app **Node Run Commands** policy:
- `Always Ask`: prompt per command (default).
- `Always Allow`: run without prompts.
- `Never`: disable `system.run` (tool not advertised).
The policy + allowlist live on the Mac in:
```
~/.clawdbot/macos-node.json
```
Schema:
```json
{
"systemRun": {
"policy": "ask",
"allowlist": [
"[\"/bin/echo\",\"hello\"]"
]
}
}
```
Notes:
- `allowlist` entries are JSON-encoded argv arrays.
- Choosing “Always Allow” in the prompt adds that command to the allowlist.
- Allowlisted runs ignore `PATH` overrides; other env vars are merged with the app’s environment.
## Deep links ## Deep links
The app registers the `clawdbot://` URL scheme for local actions. The app registers the `clawdbot://` URL scheme for local actions.
@@ -110,6 +142,32 @@ Tip: compare against `pnpm clawdbot gateway discover --json` to see whether the
macOS app’s discovery pipeline (NWBrowser + tailnet DNS‑SD fallback) differs from macOS app’s discovery pipeline (NWBrowser + tailnet DNS‑SD fallback) differs from
the Node CLI’s `dns-sd` based discovery. the Node CLI’s `dns-sd` based discovery.
## Remote connection plumbing (SSH tunnels)
When the macOS app runs in **Remote** mode, it opens SSH tunnels so local UI
components can talk to a remote Gateway as if it were on localhost. There are
two independent tunnels:
### Control tunnel (Gateway control/WebSocket port)
- **Purpose:** health checks, status, Web Chat, config, and other control-plane calls.
- **Local port:** the Gateway port (default `18789`), always stable.
- **Remote port:** the same Gateway port on the remote host.
- **Behavior:** no random local port; the app reuses an existing healthy tunnel
or restarts it if needed.
- **SSH shape:** `ssh -N -L <local>:127.0.0.1:<remote>` with BatchMode +
ExitOnForwardFailure + keepalive options.
### Node bridge tunnel (macOS node mode)
- **Purpose:** connect the macOS node to the Gateway **Bridge** protocol (TCP JSONL).
- **Remote port:** `gatewayPort + 1` (default `18790`), derived from the Gateway port.
- **Local port preference:** `CLAWDBOT_BRIDGE_PORT` or the default `18790`.
- **Behavior:** prefer the default bridge port for consistency; fall back to a
random local port if the preferred one is busy. The node then connects to the
resolved local port.
For setup steps, see [macOS remote access](/platforms/mac/remote). For protocol
details, see [Bridge protocol](/gateway/bridge-protocol).
## Related docs ## Related docs
- [Gateway runbook](/gateway) - [Gateway runbook](/gateway)
+52
View File
@@ -35,9 +35,11 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
## Available plugins (official) ## Available plugins (official)
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call` - [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
- [Matrix](/channels/matrix) — `@clawdbot/matrix` - [Matrix](/channels/matrix) — `@clawdbot/matrix`
- [Zalo](/channels/zalo) — `@clawdbot/zalo` - [Zalo](/channels/zalo) — `@clawdbot/zalo`
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
register: register:
@@ -172,6 +174,56 @@ Plugins export either:
- A function: `(api) => { ... }` - A function: `(api) => { ... }`
- An object: `{ id, name, configSchema, register(api) { ... } }` - An object: `{ id, name, configSchema, register(api) { ... } }`
## Provider plugins (model auth)
Plugins can register **model provider auth** flows so users can run OAuth or
API-key setup inside Clawdbot (no external scripts needed).
Register a provider via `api.registerProvider(...)`. Each provider exposes one
or more auth methods (OAuth, API key, device code, etc.). These methods power:
- `clawdbot models auth login --provider <id> [--method <id>]`
Example:
```ts
api.registerProvider({
id: "acme",
label: "AcmeAI",
auth: [
{
id: "oauth",
label: "OAuth",
kind: "oauth",
run: async (ctx) => {
// Run OAuth flow and return auth profiles.
return {
profiles: [
{
profileId: "acme:default",
credential: {
type: "oauth",
provider: "acme",
access: "...",
refresh: "...",
expires: Date.now() + 3600 * 1000,
},
},
],
defaultModel: "acme/opus-1",
};
},
},
],
});
```
Notes:
- `run` receives a `ProviderAuthContext` with `prompter`, `runtime`,
`openUrl`, and `oauth.createVpsAwareHandlers` helpers.
- Return `configPatch` when you need to add default models or provider config.
- Return `defaultModel` so `--set-default` can update agent defaults.
### Register a messaging channel ### Register a messaging channel
Plugins can register **channel plugins** that behave like built‑in channels Plugins can register **channel plugins** that behave like built‑in channels
+24 -2
View File
@@ -38,10 +38,32 @@ clawdbot onboard --anthropic-api-key "$ANTHROPIC_API_KEY"
**Best for:** using your Claude subscription or existing Claude Code CLI login. **Best for:** using your Claude subscription or existing Claude Code CLI login.
### Where to get a setup-token
Setup-tokens are created by the **Claude Code CLI**, not the Anthropic Console. You can run this on **any machine**:
```bash
claude setup-token
```
Paste the token into Clawdbot (wizard: **Anthropic token (paste setup-token)**), or let Clawdbot run the command locally:
```bash
clawdbot onboard --auth-choice setup-token
# or
clawdbot models auth setup-token --provider anthropic
```
If you generated the token on a different machine, paste it:
```bash
clawdbot models auth paste-token --provider anthropic
```
### CLI setup ### CLI setup
```bash ```bash
# Run setup-token on the gateway host (wizard can run it for you) # Run setup-token locally (wizard can run it for you)
clawdbot onboard --auth-choice setup-token clawdbot onboard --auth-choice setup-token
# Reuse Claude Code CLI OAuth credentials if already logged in # Reuse Claude Code CLI OAuth credentials if already logged in
@@ -58,7 +80,7 @@ clawdbot onboard --auth-choice claude-cli
## Notes ## Notes
- The wizard can run `claude setup-token` on the gateway host and store the token. - The wizard can run `claude setup-token` locally and store the token, or you can paste a token generated elsewhere.
- Clawdbot writes `auth.profiles["anthropic:claude-cli"].mode` as `"oauth"` so the profile - Clawdbot writes `auth.profiles["anthropic:claude-cli"].mode` as `"oauth"` so the profile
accepts both OAuth and setup-token credentials. Older configs using `"token"` are accepts both OAuth and setup-token credentials. Older configs using `"token"` are
auto-migrated on load. auto-migrated on load.
+2 -1
View File
@@ -12,6 +12,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
1) **Version & metadata** 1) **Version & metadata**
- [ ] Bump `package.json` version (e.g., `1.1.0`). - [ ] Bump `package.json` version (e.g., `1.1.0`).
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
- [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/provider-web.ts). - [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/provider-web.ts).
- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js) for `clawdbot`. - [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js) for `clawdbot`.
- [ ] If dependencies changed, run `pnpm install` so `pnpm-lock.yaml` is current. - [ ] If dependencies changed, run `pnpm install` so `pnpm-lock.yaml` is current.
@@ -63,7 +64,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
7) **GitHub release + appcast** 7) **GitHub release + appcast**
- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`). - [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`).
- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `clawdbot X.Y.Z`** (not just the tag); body should inline the product-facing bullets from the changelog (no bare links) **and must not repeat the title inside the body**. - [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `clawdbot X.Y.Z`** (not just the tag); body should include the **full** changelog section for that version (Highlights + Changes + Fixes), inline (no bare links), and **must not repeat the title inside the body**.
- [ ] Attach artifacts: `npm pack` tarball (optional), `Clawdbot-X.Y.Z.zip`, and `Clawdbot-X.Y.Z.dSYM.zip` (if generated). - [ ] Attach artifacts: `npm pack` tarball (optional), `Clawdbot-X.Y.Z.zip`, and `Clawdbot-X.Y.Z.dSYM.zip` (if generated).
- [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main). - [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main).
- [ ] From a clean temp directory (no `package.json`), run `npx -y clawdbot@X.Y.Z send --help` to confirm install/CLI entrypoints work. - [ ] From a clean temp directory (no `package.json`), run `npx -y clawdbot@X.Y.Z send --help` to confirm install/CLI entrypoints work.
+1 -1
View File
@@ -108,7 +108,7 @@ Skills provide your tools. When you need one, check its `SKILL.md`. Keep local n
When you receive a heartbeat poll (message matches the configured heartbeat prompt), don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively! When you receive a heartbeat poll (message matches the configured heartbeat prompt), don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
Default heartbeat prompt: Default heartbeat prompt:
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.` `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn. You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
Loaded 100 of 451 files, more files were not shown because too many files have changed in this diff. Show more