Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0961d3b82 | ||
|
|
dbd4481bb1 | ||
|
|
236b27cb3a | ||
|
|
1634abf293 | ||
|
|
ca9688b5cc | ||
|
|
e6364d031d | ||
|
|
01c8d099ad | ||
|
|
f6e619f078 | ||
|
|
b2b331230b | ||
|
|
abcca86e4e | ||
|
|
a5d8f89b53 | ||
|
|
a0d2a7232e | ||
|
|
f449115ec5 | ||
|
|
16bc4cdef3 | ||
|
|
23e4ba845c | ||
|
|
d9f9e93dee | ||
|
|
dae34f3a61 | ||
|
|
af61b353a4 | ||
|
|
29476b222d | ||
|
|
e7c16cc0e6 | ||
|
|
3dddbe1053 | ||
|
|
2dea6bfa7e | ||
|
|
04e3bfed35 | ||
|
|
3e32050601 | ||
|
|
7fb45ed9b8 | ||
|
|
b7ba94f0c1 | ||
|
|
5b827528f8 | ||
|
|
409e33d9c2 | ||
|
|
747277d914 | ||
|
|
068dca3366 | ||
|
|
336a1ad9cf | ||
|
|
d42f767d0c | ||
|
|
9b8ae62399 | ||
|
|
7c38b535f6 | ||
|
|
af370ab23e | ||
|
|
1210657fda | ||
|
|
12afec953f | ||
|
|
8e2707e232 | ||
|
|
8befe7f8a7 | ||
|
|
a70fcc8ae0 | ||
|
|
fa521154ff | ||
|
|
dffc1a4dcd | ||
|
|
61e385b331 | ||
|
|
f8f319713f | ||
|
|
bb665bf22c | ||
|
|
f17dcb6213 | ||
|
|
d54c101100 | ||
|
|
96daa51d45 | ||
|
|
6084421ec6 | ||
|
|
bca5c0d569 | ||
|
|
8c3cdba21c | ||
|
|
e274b5a040 | ||
|
|
a139d35fa2 | ||
|
|
8312a19f02 | ||
|
|
fe8b28cdd9 | ||
|
|
10eb1beccf | ||
|
|
a4b347b454 | ||
|
|
688a0ce439 | ||
|
|
6146acbb69 | ||
|
|
35492f8513 | ||
|
|
db9be87d94 | ||
|
|
4a99b9b651 | ||
|
|
7dea403302 | ||
|
|
10731dfee3 | ||
|
|
d00f2d9c0c | ||
|
|
8b89980a89 | ||
|
|
634a429c50 | ||
|
|
bf90815b9e | ||
|
|
2b113c4d6c | ||
|
|
7f6a288bd3 | ||
|
|
daf471c450 | ||
|
|
cb78fa46a1 | ||
|
|
316e8b2eb2 | ||
|
|
4de81ed6c4 | ||
|
|
b6fb24f6d2 | ||
|
|
2b1c26f900 | ||
|
|
47ea9356d1 | ||
|
|
f12c1b391f | ||
|
|
05658b6609 | ||
|
|
dfb6630de1 | ||
|
|
eb7656d68c | ||
|
|
0e1dcf9cb4 | ||
|
|
d05c3d0659 | ||
|
|
36292d3fbb | ||
|
|
1ae344d8a6 | ||
|
|
01c43b0b0c | ||
|
|
fc4aa9a683 | ||
|
|
c043e9767f | ||
|
|
8b48299d8f | ||
|
|
0f27cff247 | ||
|
|
d0455f2683 |
No files matched your search
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
+1
-1
Submodule Peekaboo updated: 95ad7532c1...5c195f5e46.
@@ -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)
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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`.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user