Compare commits

..
Author SHA1 Message Date
Peter Steinberger ee2cfa5ca1 fix: harden voice-call webhooks (#1213) (thanks @andrew-kurin) 2026-01-19 06:00:01 +00:00
Ghost 0e1ebc7da1 Voice-call: avoid streaming on notify callbacks 2026-01-19 05:48:06 +00:00
Ghost 0e3e6f6b70 Voice-call: fix Twilio status callbacks 2026-01-19 05:48:06 +00:00
Ghost 337dd41ce9 Voice-call: fix Twilio signature ordering 2026-01-19 05:48:06 +00:00
Ghost 88336ef4ea Voice-call: fix tailscale tunnel path 2026-01-19 05:48:06 +00:00
Peter Steinberger 5ee03c82b4 Merge pull request #1212 from longmaba/fix/ui-build-windows-spawn
fix(ui): enable shell mode for spawn on Windows
2026-01-19 05:43:15 +00:00
Peter Steinberger 111aeb2c4f fix: cover sync ui spawn on Windows (#1212) (thanks @longmaba) 2026-01-19 05:42:42 +00:00
Long 23c2c638b7 fix(ui): enable shell mode for spawn on Windows 2026-01-19 05:41:38 +00:00
Peter Steinberger 6b8299eb33 chore: update package resolutions 2026-01-19 05:40:04 +00:00
Peter Steinbergerandgumadeiras 9822a53649 fix: centralize cli command registry
Co-authored-by: gumadeiras <gumadeiras@users.noreply.github.com>
2026-01-19 05:36:09 +00:00
Peter Steinberger 81d392a2d7 refactor: extract TUI syntax theme and fix changelog 2026-01-19 05:32:53 +00:00
Peter Steinberger dbcec3ffaf docs: clarify session log agent id 2026-01-19 05:27:52 +00:00
Peter Steinberger 50c5231486 refactor: reuse prompt params in context report 2026-01-19 05:27:52 +00:00
Peter Steinberger 55d034358d refactor: unify system prompt runtime params 2026-01-19 05:27:52 +00:00
Peter Steinberger 374da34936 fix: canonicalize legacy session keys 2026-01-19 05:17:31 +00:00
Peter SteinbergerandVignesh Natarajan c578fca687 fix(tui): generic empty-state for searchable pickers (PR #1201, thanks @vignesh07)
Co-authored-by: Vignesh Natarajan <vigneshnatarajan92@gmail.com>
2026-01-19 05:16:06 +00:00
Vignesh Natarajan dd18765b50 feat(tui): add fuzzy search to session and agent pickers
Use SearchableSelectList for /sessions and /agents pickers,
matching the /models picker behavior.

- Session picker: search by session key, display name, or date
- Agent picker: search by agent ID or name

🤖 AI-assisted (Claude)
2026-01-19 05:16:06 +00:00
Peter Steinberger 3e06fe84dc feat: add TUI code block syntax highlighting (#1200) (thanks @vignesh07) 2026-01-19 05:07:23 +00:00
Peter Steinberger 640e19988f Merge pull request #1200 from vignesh07/feat/tui-syntax-highlighting
feat(tui): add syntax highlighting for code blocks
2026-01-19 05:05:51 +00:00
Peter Steinberger de33bc70e7 docs: clarify node_modules guidance 2026-01-19 04:01:36 +00:00
Peter Steinberger 0c8ba6599b fix: add plugin config schema helper 2026-01-19 03:39:36 +00:00
Peter Steinberger d1e9490f95 fix: enforce strict config validation 2026-01-19 03:39:25 +00:00
Peter Steinberger a9fc2ca0ef fix: add git hook setup and stable config hash sorting 2026-01-19 02:02:17 +00:00
Peter Steinberger dd1b08b3e8 fix: add safeguard compaction tool summaries 2026-01-19 01:44:17 +00:00
cpojer af1004ebbd Make tool calls use human language by default. 2026-01-19 01:42:23 +00:00
Peter Steinberger f3516fb316 fix: skip respawn in gateway sigterm test 2026-01-19 01:37:10 +00:00
Peter Steinberger 79d8267413 feat: auto-recreate sandbox containers on config change 2026-01-19 01:35:27 +00:00
Peter Steinberger 99bf65c539 style: apply oxfmt 2026-01-19 01:11:42 +00:00
Peter Steinberger 6a4b5fa4b5 fix: harden windows cli launch 2026-01-19 01:11:39 +00:00
Peter Steinberger 83511c0c09 refactor: consolidate nodes cli error handling 2026-01-19 00:52:31 +00:00
Peter Steinberger 1fec41b3df refactor: share cli runtime error handling 2026-01-19 00:52:31 +00:00
Peter Steinberger c532d161c4 refactor: streamline routed cli setup 2026-01-19 00:52:31 +00:00
Peter Steinberger 989543c9c3 fix: propagate agent run context for subagent announce 2026-01-19 00:45:30 +00:00
Vignesh Natarajan 145adf540f fix: make syntax highlighting tests environment-agnostic
Tests now verify structure and content preservation rather than
checking for specific ANSI escape codes, which may not be present
in CI environments without TTY/color support.
2026-01-18 16:40:06 -08:00
Peter Steinberger 953472bf25 feat: add exec pathPrepend config 2026-01-19 00:35:43 +00:00
Peter Steinberger d9384785a3 fix: stabilize ci checks 2026-01-19 00:34:26 +00:00
2f6b5ffdfe Web: trim HTML error bodies in web_fetch (#1193)
* Web: trim HTML error bodies in web_fetch

* fix: trim web_fetch HTML error bodies (#1193) (thanks @sebslight)

---------

Co-authored-by: Sebastian Slight <sbarrios93@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-19 00:24:16 +00:00
Vignesh Natarajan 0e3c9e4a0e feat(tui): add syntax highlighting for code blocks
Add syntax highlighting to markdown code blocks in the TUI using
cli-highlight with a VS Code Dark-inspired color theme.

Features:
- 191 languages supported via highlight.js
- Auto-detection fallback for unknown languages
- Graceful fallback to plain styling on errors
- VS Code Dark-inspired color palette

Colors:
- Purple: keywords (const, function, if, etc.)
- Teal: built-ins (console, Math, print, etc.)
- Orange: strings
- Green: numbers, comments
- Yellow: function names
- Blue: literals (true, false, null)
- Red: diff deletions
- Light blue: variables, parameters

🤖 AI-assisted (Claude) - fully tested locally
2026-01-18 16:24:14 -08:00
Peter Steinberger 15311c138a macOS: fix onboarding test helper call 2026-01-19 00:19:44 +00:00
Peter Steinberger dec71dbcf1 docs: update README channels + deepwiki badge 2026-01-19 00:17:42 +00:00
Peter Steinberger 5a4482412d fix(plugins): prefer dist plugin-sdk in tests 2026-01-19 00:15:45 +00:00
Peter Steinberger 4cf829608c chore: remove unused program context imports 2026-01-19 00:15:45 +00:00
Peter Steinberger 8de02e6074 test: stabilize sessions_send waits 2026-01-19 00:15:45 +00:00
Peter Steinberger d802844bd6 fix: gate gateway restarts and discord abort reconnects 2026-01-19 00:15:45 +00:00
Peter Steinberger e97bcf4dae refactor(plugins): improve loader resolution 2026-01-19 00:15:44 +00:00
Peter Steinberger dad8e11f1e test: harden gateway mocks and env isolation 2026-01-19 00:15:44 +00:00
Peter Steinberger 50fdd514ae refactor(logging): split config + subsystem imports 2026-01-19 00:15:44 +00:00
Peter Steinberger ee36e12f81 fix: log plugin load errors in gateway 2026-01-19 00:15:24 +00:00
Peter Steinberger 1e5569d56a fix: refine TUI model search rendering 2026-01-19 00:15:16 +00:00
Peter Steinberger 3ce1ee84ac Usage: add cost summaries to /usage + mac menu 2026-01-19 00:05:06 +00:00
Peter Steinberger 1ea3ac0a1d Merge pull request #1197 from chriseidhof/channels
The link should be skills
2026-01-18 23:59:17 +00:00
Peter Steinberger 66b6c9e0e5 chore: document slack bolt import interop 2026-01-18 23:55:36 +00:00
Peter SteinbergerandCorey Henderson b5e99dad1f fix(slack): handle bolt CJS interop (#1191) — thanks @CoreyH
Co-authored-by: Corey Henderson <corey@example.com>
2026-01-18 23:54:50 +00:00
Peter Steinberger 6f5205d826 docs: elevate security audit callout 2026-01-18 23:37:14 +00:00
Peter Steinberger 5f975a4eff Merge pull request #1195 from gumadeiras/main
enhancement: 3x faster CLI invocation, unify boolean/env parsing, streamline CLI startup paths
2026-01-18 23:28:36 +00:00
Peter Steinberger aadfdbc59f chore: update pnpm lockfile 2026-01-18 23:28:21 +00:00
Peter Steinberger d5c8172197 fix: optimize routed CLI path (#1195) (thanks @gumadeiras) 2026-01-18 23:28:09 +00:00
Peter Steinberger 9e804f6f40 Merge pull request #1185 from KrauseFx/improve-anthropic-token-hints
chore(auth): Improve Anthropic token option hints in onboarding wizard
2026-01-18 23:27:58 +00:00
Peter Steinberger bedfc3642d Merge pull request #1198 from vignesh07/feat/tui-model-picker-search
feat(tui): add fuzzy search to model picker 🔍
2026-01-18 23:27:02 +00:00
Peter Steinberger 46dcda1d0c fix: preserve fuzzy ranking in model picker (#1198) (thanks @vignesh07) 2026-01-18 23:26:42 +00:00
Vignesh Natarajan 950f8a04ea fix: prioritize exact substring matches over fuzzy in model search
- Exact substring in label (earliest position wins)
- Word-boundary prefix matches
- Description substring matches
- Fuzzy matching as fallback

This ensures 'opus' shows claude-3-opus before openrouter models.
2026-01-18 23:18:28 +00:00
Vignesh Natarajan de44e0ad33 feat(tui): add fuzzy search to model picker
- Add SearchableSelectList component with fuzzy filtering
- Integrate with /models command for quick model search
- Support up/down navigation while typing
- Uses pi-tui's fuzzyFilter for intelligent matching
2026-01-18 23:18:28 +00:00
Peter Steinberger c639b386da fix: hide menubar usage errors 2026-01-18 23:18:10 +00:00
Gustavo Madeira Santana fac0110e49 removing aux funcs for benchmarking
Leftover functions I was using the benchmark and time CLI calls
2026-01-18 23:10:39 +00:00
Gustavo Madeira Santana 97971f3aef Remove unused import from run-main.ts
Deleted the unused import of hasHelpOrVersion from argv.js to clean up the code.
2026-01-18 23:10:39 +00:00
Gustavo Madeira Santana acb523de86 CLI: streamline startup paths and env parsing
Add shared parseBooleanValue()/isTruthyEnvValue() and apply across CLI, gateway, memory, and live-test flags for consistent env handling.
Introduce route-first fast paths, lazy subcommand registration, and deferred plugin loading to reduce CLI startup overhead.
Centralize config validation via ensureConfigReady() and add config caching/deferred shell env fallback for fewer IO passes.
Harden logger initialization/imports and add focused tests for argv, boolean parsing, frontmatter, and CLI subcommands.
2026-01-18 23:10:39 +00:00
Peter Steinberger 97531f174f fix: import wizard prompter in onboarding adapters 2026-01-18 23:05:05 +00:00
Peter Steinberger ef125d5ba7 docs: update changelog for docs:list guard 2026-01-18 22:53:59 +00:00
Peter Steinberger 86950d3474 fix: guard docs:list when docs dir missing 2026-01-18 22:53:39 +00:00
Peter Steinberger 3a9582bc41 docs: update channel allowlist guidance 2026-01-18 22:52:00 +00:00
Peter Steinberger d198474415 feat: resolve allowlists in channel plugins 2026-01-18 22:52:00 +00:00
Peter Steinberger ace8a1b44e feat: expand dm allowlist onboarding 2026-01-18 22:52:00 +00:00
Peter Steinberger a7be3a9649 fix: honor telegram pairing allowlists for native commands 2026-01-18 22:52:00 +00:00
Peter Steinberger 0d543dd1ff test: update expectations for session reset behavior 2026-01-18 22:51:37 +00:00
Peter Steinberger 404c373153 feat: add agent targeting + reply overrides 2026-01-18 22:50:51 +00:00
Peter Steinberger 024691e4e7 feat(mac): manage node service in remote mode 2026-01-18 22:50:02 +00:00
Peter Steinberger a86d7a2f35 Merge pull request #1196 from vignesh07/feat/tui-waiting-shimmer-clean
feat(tui): animated waiting status with shimmer effect ✨
2026-01-18 22:38:08 +00:00
Peter Steinberger e7e34c442e fix: smooth TUI waiting shimmer (#1196) (thanks @vignesh07) 2026-01-18 22:37:36 +00:00
Peter Steinberger 9b9e8d4ae8 chore: block node_modules commits 2026-01-18 22:28:59 +00:00
Peter Steinberger bf925e5758 chore: rename memory-lancedb extension folder 2026-01-18 22:27:22 +00:00
Peter Steinberger c0c9df4ab7 build: update A2UI bundle hash 2026-01-18 22:26:12 +00:00
Peter Steinberger 6aa90f8b18 build: refresh A2UI bundle 2026-01-18 22:26:12 +00:00
Peter Steinberger 9af1c8a886 fix: patch session store updates 2026-01-18 22:26:12 +00:00
Peter Steinberger ed5ece4120 fix: remove unreachable approval fallback 2026-01-18 22:26:12 +00:00
Peter Steinberger 85d1835476 feat: add live memory index progress 2026-01-18 22:25:08 +00:00
Vignesh Natarajan e85d2dff97 TUI: pick waiting phrase once per waiting session 2026-01-18 22:19:47 +00:00
Vignesh Natarajan fac66d4dda TUI: waiting shimmer helper + tests 2026-01-18 22:19:47 +00:00
Vignesh Natarajan 2e99369113 TUI: add animated waiting status with shimmer 2026-01-18 22:19:47 +00:00
Peter Steinberger 835f9ee575 docs: clarify envelope time work 2026-01-18 22:17:24 +00:00
Peter Steinberger a136c6aa89 Merge pull request #1187 from fayrose/fix/compaction-failure-silent-reset
fix: return user-facing error when session reset after compaction failure
2026-01-18 22:02:36 +00:00
Chris Eidhof af96bac2dd The link should be skills 2026-01-18 22:44:41 +01:00
Peter Steinberger b621d4550b chore: tighten skills prompt rules 2026-01-18 21:30:27 +00:00
Lauren Rosenberg c290217305 fix: add reserveTokensFloor suggestion to compaction error messages
When context limit is exceeded, the error message now suggests
setting agents.defaults.compaction.reserveTokensFloor to 4000
or higher to prevent future occurrences.
2026-01-18 19:37:15 +00:00
Peter Steinberger 769b286cf2 fix: make docs list node-safe 2026-01-18 19:37:13 +00:00
Peter Steinberger 690bb192e6 style: format code 2026-01-18 19:36:46 +00:00
Peter Steinberger 601a052216 fix: unblock bundled plugin load 2026-01-18 19:34:21 +00:00
Peter Steinberger bf3021d266 fix: stabilize logging imports and tests 2026-01-18 19:34:08 +00:00
Peter Steinberger 145b2e5f52 fix: menu preview label colors 2026-01-18 19:04:01 +00:00
Peter Steinberger 4b73dc95c4 fix: normalize envelope options 2026-01-18 18:59:34 +00:00
Peter Steinberger e17cb408a5 fix(cli): load pairing channels after plugins 2026-01-18 18:56:40 +00:00
Peter Steinberger 3cf92152c3 fix: appease tsc in test helpers 2026-01-18 18:54:59 +00:00
Peter Steinberger c50cde2170 fix: clean up lint in gateway tests 2026-01-18 18:51:43 +00:00
Peter Steinberger 7e0bebd669 docs: update clawtributors 2026-01-18 18:51:08 +00:00
Peter Steinberger 7c49326191 fix: satisfy oxlint spread rule 2026-01-18 18:50:52 +00:00
Peter SteinbergerandShiva Prasad 744d1329cb feat: make inbound envelopes configurable
Co-authored-by: Shiva Prasad <shiv19@users.noreply.github.com>
2026-01-18 18:50:37 +00:00
Peter Steinberger 42e6ff4611 feat(cli): show Telegram bot username in status 2026-01-18 18:48:25 +00:00
Peter Steinberger 5f21bf735a chore: switch repo scripts to node 2026-01-18 18:46:18 +00:00
Peter Steinberger ee380e9ab9 fix: run cli scripts via node build runner 2026-01-18 18:43:39 +00:00
Peter Steinberger ab340c82fb fix: stabilize tests and logging 2026-01-18 18:43:31 +00:00
Peter Steinberger 57dd0505a3 Merge pull request #1181 from sebslight/plugins/exclusive-slots
Plugins: auto-select exclusive slots
2026-01-18 18:40:38 +00:00
Peter Steinberger d6f9f1c79a Merge pull request #1182 from zerone0x/fix/issue-1115-filter-openrouter-auto
fix(configure): filter openrouter/auto from model selection list
2026-01-18 18:32:47 +00:00
Peter Steinberger a08a772ffc fix: add model picker regression for openrouter auto (#1182) (thanks @zerone0x) 2026-01-18 18:32:25 +00:00
zerone0xandClaude 2622b1936b fix(configure): filter openrouter/auto from model selection list
The openrouter/auto model is OpenRouter's internal routing feature,
not a callable model. While it's valid as a default (set automatically
during OpenRouter auth flow), showing it in the configure wizard's
model selection causes "Unknown model: openrouter/auto" errors when
users select it manually.

Add a HIDDEN_ROUTER_MODELS set to filter out such internal models
from the selection list while preserving existing functionality.

Fixes #1115

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-18 18:31:55 +00:00
Peter Steinberger c0457e0cc4 fix(mac): load menu session previews 2026-01-18 18:28:48 +00:00
Peter Steinberger ee2f0a175a docs: add Windows installer git mode 2026-01-18 18:26:20 +00:00
Lauren Rosenberg 0e94f0c018 style: apply prettier formatting 2026-01-18 18:21:11 +00:00
Lauren Rosenberg 576485b0c9 fix: return user-facing error when session reset after compaction failure
Previously, when auto-compaction failed due to context overflow, the system
would reset the session and silently continue the execution loop without
sending any response to the user. This made it appear as if messages were
being ignored.

This change ensures users receive a clear error message explaining that
the context limit was exceeded and the conversation has been reset,
consistent with how role ordering conflicts are already handled.

Fixes the silent failure case where message + compaction exceeds context limits.
2026-01-18 18:16:20 +00:00
Peter Steinberger 60efe8ed7b fix: restore bun runners for dev scripts 2026-01-18 18:00:48 +00:00
Peter Steinberger 332a20d9cc fix: update gateway watch runner 2026-01-18 17:55:50 +00:00
Felix Krause 57bf6d5eaf Improve Anthropic token option hints in onboarding wizard 2026-01-18 18:39:14 +01:00
Peter Steinberger f16b0cf80d fix: stabilize ci protocol + openai batch retry 2026-01-18 17:05:27 +00:00
Peter Steinberger a4ee933022 fix: hide macOS usage errors 2026-01-18 16:52:53 +00:00
Peter Steinberger cf7437cb4c fix: unblock macOS exec host build 2026-01-18 16:44:26 +00:00
Peter Steinberger 081123c0e4 feat: route macOS node exec via app IPC 2026-01-18 16:41:44 +00:00
Peter Steinberger 5fe3c36471 fix(build): resolve ts2367 comparisons 2026-01-18 16:35:52 +00:00
Peter Steinberger e06158c645 docs: update changelog 2026-01-18 16:35:52 +00:00
Peter Steinberger 19a8547ecd feat(onboarding): wire plugin-backed auth choices 2026-01-18 16:35:52 +00:00
Peter Steinberger 32ae4566c6 feat(config): auto-enable configured plugins 2026-01-18 16:35:52 +00:00
Peter Steinberger be6a3d4caf fix: unblock build and slack monitor 2026-01-18 16:35:18 +00:00
Peter SteinbergerandDr Alexander Mikhalev 1db0384090 feat(doctor): repair launch agent bootstrap
Co-authored-by: Dr Alexander Mikhalev <alex@metacortex.engineer>
2026-01-18 16:35:18 +00:00
Peter Steinberger d024dceef7 Merge pull request #1180 from andrew-kurin/fix/voice-call-statuscallback
fix(voice-call): resolve StatusCallback with inline TwiML (#864)
2026-01-18 16:34:58 +00:00
Peter Steinberger 5ec499e14c docs: clarify mac gateway launch behavior 2026-01-18 16:29:38 +00:00
Peter Steinberger 0b350d78d5 fix: harden macOS signing flow 2026-01-18 16:28:39 +00:00
Peter Steinberger 96ee027371 feat: list eligible hooks in onboarding 2026-01-18 16:28:39 +00:00
Peter Steinberger ffcf3263c1 fix: exec approvals parsing + boot-md changelog 2026-01-18 16:28:39 +00:00
Sebastian Slight cef6b16d14 Plugins: auto-select exclusive slots 2026-01-18 11:26:50 -05:00
Peter Steinberger d06d440086 docs: clarify macOS node service IPC plan 2026-01-18 16:24:43 +00:00
Peter Steinberger 415fc9092e test(cli): align memory CLI tests 2026-01-18 16:12:10 +00:00
Peter Steinberger 0be9d773cb fix(memory): preserve fallback source id 2026-01-18 16:12:10 +00:00
Peter Steinberger ecb45660e9 fix(cli): avoid empty spreads in approvals CLI 2026-01-18 16:12:10 +00:00
Peter Steinberger f6fefd7f5f fix(exec-approvals): fix command token parsing 2026-01-18 16:12:10 +00:00
Peter SteinbergerandGustavo Madeira Santana 4206b9684b docs(faq): refresh nodes, sessions, memory defaults
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-01-18 16:12:10 +00:00
Peter SteinbergerandGustavo Madeira Santana a4aad1c76a feat(cli): expand memory status across agents
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-01-18 16:12:10 +00:00
Peter SteinbergerandGustavo Madeira Santana 9464774133 feat(memory): add gemini batches + safe reindex
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-01-18 16:12:10 +00:00
Peter SteinbergerandGustavo Madeira Santana be7191879a feat(memory): add gemini embeddings + auto select providers
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-01-18 16:12:10 +00:00
Gustavo Madeira Santana 7252938339 fix(utils): share clamp helpers
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-01-18 16:11:43 +00:00
Peter Steinberger 810394f43b fix: improve remote bin probe logging 2026-01-18 16:09:48 +00:00
Peter Steinberger 835162fb62 fix: retry openai batch indexing 2026-01-18 16:08:22 +00:00
Peter Steinberger 82883095fe docs: explain Copilot provider options 2026-01-18 16:06:48 +00:00
Peter Steinberger 49d8ad3049 feat: surface node core/ui versions in macOS 2026-01-18 16:00:36 +00:00
Peter Steinberger 1721d04405 feat: add node core/ui versions in bridge 2026-01-18 15:59:54 +00:00
Peter Steinberger 633e0d9382 Merge pull request #1164 from ngutman/feat/boot-md
feat(hooks): run BOOT.md on gateway startup
2026-01-18 15:59:53 +00:00
Ghost e156320c51 fix(voice-call): resolve StatusCallback with inline TwiML
- Switch from inline to URL-based TwiML for outbound calls
- Store TwiML content temporarily and serve on webhook request
- Add twimlStorage map and cleanup helper methods
- Fix TwiML serving to handle CallStatus='in-progress' on initial request

Closes #864
2026-01-18 07:51:59 -08:00
Peter Steinberger f06ce98312 refactor: rename lancedb memory plugin 2026-01-18 15:48:05 +00:00
Nimrod Gutman 11b07f4a29 feat(hooks): run boot.md on gateway startup 2026-01-18 11:50:25 +02:00
481 changed files with 30644 additions and 21932 deletions

No files matched your search

+1
View File
@@ -1,4 +1,5 @@
node_modules node_modules
**/node_modules/
.env .env
docker-compose.extra.yml docker-compose.extra.yml
dist dist
+1
View File
@@ -0,0 +1 @@
src/canvas-host/a2ui/a2ui.bundle.js
+7 -19
View File
@@ -8,6 +8,10 @@
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`. - Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them. - Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`). - Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
- Core channel docs: `docs/channels/`
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
## Docs Linking (Mintlify) ## Docs Linking (Mintlify)
- Docs are hosted on Mintlify (docs.clawd.bot). - Docs are hosted on Mintlify (docs.clawd.bot).
@@ -62,7 +66,7 @@
- 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 ## 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`. - `sync`: 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.
@@ -80,6 +84,7 @@
## Agent-Specific Notes ## Agent-Specific Notes
- Vocabulary: "makeup" = "mac app". - Vocabulary: "makeup" = "mac app".
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
- When working on a GitHub Issue or PR, print the full URL at the end of the task. - When working on a GitHub Issue or PR, print the full URL at the end of the task.
- When answering questions, respond with high-confidence answers only: verify in code; do not guess. - When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency. - Never update the Carbon dependency.
@@ -111,8 +116,7 @@
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed). - Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`. - Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema. - Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there. - When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac. - Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel. - Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
- Voice wake forwarding tips: - Voice wake forwarding tips:
@@ -128,19 +132,3 @@
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir). - Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`. - Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
- Kill the tmux session after publish. - Kill the tmux session after publish.
## Exclamation Mark Escaping Workaround
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
```bash
# WRONG - will send "Hello\\!" with backslash
clawdbot message send --to "+1234" --message 'Hello!'
# CORRECT - use heredoc to avoid escaping
clawdbot message send --to "+1234" --message "$(cat <<'EOF'
Hello!
EOF
)"
```
This is a Claude Code quirk, not a clawdbot bug.
+91 -13
View File
@@ -2,30 +2,79 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 2026.1.17-7 ## 2026.1.19-1
### Breaking
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
### Changes
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
- Agents: clarify node_modules read-only guidance in agent instructions.
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
### Fixes
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
- Voice call: keep Twilio notify callbacks out of streaming paths, normalize Tailscale serve paths, and honor ASCII signature ordering. (#1213) — thanks @andrew-kurin.
## 2026.1.18-5
### Changes ### Changes
- Exec approvals: add `clawdbot approvals` CLI for viewing and updating gateway/node allowlists.
- CLI: add `clawdbot service` gateway/node management and a `clawdbot node status` alias.
- Status: show gateway + node service summaries in `clawdbot status` and `status --all`.
- Control UI: add gateway/node target selector for exec approvals.
- Docs: add approvals/service references and refresh node/control UI docs.
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.). - Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels.
- TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07.
- Docs: clarify allowlist input types and onboarding behavior for messaging channels.
### Fixes
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
- Docs: make docs:list fail fast with a clear error if the docs directory is missing.
- macOS: load menu session previews asynchronously so items populate while the menu is open.
- macOS: use label colors for session preview text so previews render in menu subviews.
- macOS: suppress usage error text in the menubar cost view.
- Telegram: honor pairing allowlists for native slash commands.
- TUI: highlight model search matches and stabilize search ordering.
- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195) — thanks @gumadeiras.
- Slack: resolve Bolt import interop for Bun + Node. (#1191) — thanks @CoreyH.
- Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.
- Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.
## 2026.1.18-4
### Changes
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule). - macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
- macOS: stop syncing Peekaboo as a git submodule in postinstall. - macOS: stop syncing Peekaboo as a git submodule in postinstall.
- Swabble: use the tagged Commander Swift package release. - Swabble: use the tagged Commander Swift package release.
- CLI: add `clawdbot acp client` interactive ACP harness for debugging. - CLI: add `clawdbot acp client` interactive ACP harness for debugging.
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK. - Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
- Memory: add native Gemini embeddings provider for memory search. (#1151) - Memory: add native Gemini embeddings provider for memory search. (#1151)
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt. - Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne. - Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
### Fixes
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166) — thanks @AlexMikhalev.
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
## 2026.1.18-3
### Changes
- Exec: add host/security/ask routing for gateway + node exec. - Exec: add host/security/ask routing for gateway + node exec.
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node). - Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle. - macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
- macOS: add approvals socket UI server + node exec lifecycle events. - macOS: add approvals socket UI server + node exec lifecycle events.
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`. - Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
- Nodes: add node daemon service install/status/start/stop/restart. - Nodes: add node daemon service install/status/start/stop/restart.
- Hooks: run `BOOT.md` on gateway startup with the boot-md hook. (#1164) — thanks @ngutman.
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins. - Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639. - Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911. - Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
@@ -40,21 +89,34 @@ Docs: https://docs.clawd.bot
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). - Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI. - macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals - Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
- macOS: add exec-host IPC for node service `system.run` with HMAC + peer UID checks.
### Fixes ### Fixes
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events. - Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
- Exec approvals: parse command tokens correctly for PATH and relative resolution.
- Tools: return a companion-app-required message when node exec is requested with no paired node. - Tools: return a companion-app-required message when node exec is requested with no paired node.
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe. - Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee. - Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
## 2026.1.18-2
### Fixes
- Tests: stabilize plugin SDK resolution and embedded agent timeouts. - Tests: stabilize plugin SDK resolution and embedded agent timeouts.
## 2026.1.18-1
### Changes
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
### Fixes
- Memory: apply OpenAI batch defaults even without explicit remote config. - Memory: apply OpenAI batch defaults even without explicit remote config.
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006) - macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
- Discord: only emit slow listener warnings after 30s. - Discord: only emit slow listener warnings after 30s.
## 2026.1.17-6 ## 2026.1.17-6
@@ -83,6 +145,22 @@ Docs: https://docs.clawd.bot
### Fixes ### Fixes
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864) - Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
## 2026.1.18-1
### Changes
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
### Fixes
- Memory: apply OpenAI batch defaults even without explicit remote config.
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
- Discord: only emit slow listener warnings after 30s.
## 2026.1.17-3 ## 2026.1.17-3
### Changes ### Changes
+27 -26
View File
@@ -11,12 +11,13 @@
<p align="center"> <p align="center">
<a href="https://github.com/clawdbot/clawdbot/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/clawdbot/clawdbot/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a> <a href="https://github.com/clawdbot/clawdbot/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/clawdbot/clawdbot/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
<a href="https://github.com/clawdbot/clawdbot/releases"><img src="https://img.shields.io/github/v/release/clawdbot/clawdbot?include_prereleases&style=for-the-badge" alt="GitHub release"></a> <a href="https://github.com/clawdbot/clawdbot/releases"><img src="https://img.shields.io/github/v/release/clawdbot/clawdbot?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
<a href="https://deepwiki.com/clawdbot/clawdbot"><img src="https://img.shields.io/badge/DeepWiki-clawdbot-111111?style=for-the-badge" alt="DeepWiki"></a>
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a> <a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
</p> </p>
**Clawdbot** is a *personal AI assistant* you run on your own devices. **Clawdbot** is a *personal AI assistant* you run on your own devices.
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant. It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it. If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
@@ -64,7 +65,7 @@ clawdbot gateway --port 18789 --verbose
# Send a message # Send a message
clawdbot message send --to +1234567890 --message "Hello from Clawdbot" clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord/Microsoft Teams) # Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
clawdbot agent --message "Ship checklist" --thinking high clawdbot agent --message "Ship checklist" --thinking high
``` ```
@@ -106,7 +107,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## Highlights ## Highlights
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events. - **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat, macOS, iOS/Android. - **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions). - **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs. - **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui). - **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
@@ -128,7 +129,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio). - [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
### Channels ### Channels
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/webchat). - [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.clawd.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (extension), [Matrix](https://docs.clawd.bot/channels/matrix) (extension), [Zalo](https://docs.clawd.bot/channels/zalo) (extension), [Zalo Personal](https://docs.clawd.bot/channels/zalouser) (extension), [WebChat](https://docs.clawd.bot/web/webchat).
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels). - [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
### Apps + nodes ### Apps + nodes
@@ -159,7 +160,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## How it works (short) ## How it works (short)
``` ```
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Microsoft Teams / WebChat WhatsApp / Telegram / Slack / Discord / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
│ │
▼ ▼
┌───────────────────────────────┐ ┌───────────────────────────────┐
@@ -474,25 +475,25 @@ 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/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/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></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/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/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></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/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/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/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/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/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/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/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/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/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/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/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/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/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
<a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></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/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/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/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/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/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></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/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/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/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/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/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></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/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></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/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></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/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></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/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></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/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></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/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/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></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/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/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/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/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/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></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/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/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></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/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></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/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/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/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></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/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/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/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/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/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/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></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/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/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/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/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/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/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></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/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/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/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/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/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/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></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/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/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></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/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/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/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></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/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/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/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></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/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/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></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/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/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/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/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/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></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/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/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></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/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/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/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/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></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=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></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=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></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/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=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></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/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></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/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/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/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/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/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=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></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/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></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/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/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/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/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/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/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></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/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=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></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/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=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/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/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/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></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/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></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/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=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></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/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></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/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=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/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a>
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/search?q=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/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></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/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p> </p>
+1 -1
View File
@@ -6,7 +6,7 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit", "location" : "https://github.com/steipete/ElevenLabsKit",
"state" : { "state" : {
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d", "revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
"version" : "0.1.0" "version" : "0.1.0"
} }
}, },
+1 -1
View File
@@ -24,7 +24,7 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit", "location" : "https://github.com/steipete/ElevenLabsKit",
"state" : { "state" : {
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d", "revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
"version" : "0.1.0" "version" : "0.1.0"
} }
}, },
+64
View File
@@ -0,0 +1,64 @@
# Clawdbot macOS app (dev + signing)
## Quick dev run
```bash
# from repo root
scripts/restart-mac.sh
```
Options:
```bash
scripts/restart-mac.sh --no-sign # fastest dev; ad-hoc signing (TCC permissions do not stick)
scripts/restart-mac.sh --sign # force code signing (requires cert)
```
## Packaging flow
```bash
scripts/package-mac-app.sh
```
Creates `dist/Clawdbot.app` and signs it via `scripts/codesign-mac-app.sh`.
## Signing behavior
Auto-selects identity (first match):
1) Developer ID Application
2) Apple Distribution
3) Apple Development
4) first available identity
If none found:
- errors by default
- set `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` to ad-hoc sign
## Team ID audit (Sparkle mismatch guard)
After signing, we read the app bundle Team ID and compare every Mach-O inside the app.
If any embedded binary has a different Team ID, signing fails.
Skip the audit:
```bash
SKIP_TEAM_ID_CHECK=1 scripts/package-mac-app.sh
```
## Library validation workaround (dev only)
If Sparkle Team ID mismatch blocks loading (common with Apple Development certs), opt in:
```bash
DISABLE_LIBRARY_VALIDATION=1 scripts/package-mac-app.sh
```
This adds `com.apple.security.cs.disable-library-validation` to app entitlements.
Use for local dev only; keep off for release builds.
## Useful env flags
- `SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"`
- `ALLOW_ADHOC_SIGNING=1` (ad-hoc, TCC permissions do not persist)
- `CODESIGN_TIMESTAMP=off` (offline debug)
- `DISABLE_LIBRARY_VALIDATION=1` (dev-only Sparkle workaround)
- `SKIP_TEAM_ID_CHECK=1` (bypass audit)
@@ -8,6 +8,8 @@ struct BridgeNodeInfo: Sendable {
var displayName: String? var displayName: String?
var platform: String? var platform: String?
var version: String? var version: String?
var coreVersion: String?
var uiVersion: String?
var deviceFamily: String? var deviceFamily: String?
var modelIdentifier: String? var modelIdentifier: String?
var remoteAddress: String? var remoteAddress: String?
@@ -147,6 +149,8 @@ actor BridgeConnectionHandler {
displayName: hello.displayName, displayName: hello.displayName,
platform: hello.platform, platform: hello.platform,
version: hello.version, version: hello.version,
coreVersion: hello.coreVersion,
uiVersion: hello.uiVersion,
deviceFamily: hello.deviceFamily, deviceFamily: hello.deviceFamily,
modelIdentifier: hello.modelIdentifier, modelIdentifier: hello.modelIdentifier,
remoteAddress: self.remoteAddressString(), remoteAddress: self.remoteAddressString(),
@@ -171,6 +175,8 @@ actor BridgeConnectionHandler {
displayName: req.displayName, displayName: req.displayName,
platform: req.platform, platform: req.platform,
version: req.version, version: req.version,
coreVersion: req.coreVersion,
uiVersion: req.uiVersion,
deviceFamily: req.deviceFamily, deviceFamily: req.deviceFamily,
modelIdentifier: req.modelIdentifier, modelIdentifier: req.modelIdentifier,
caps: req.caps, caps: req.caps,
@@ -186,6 +192,8 @@ actor BridgeConnectionHandler {
displayName: enriched.displayName, displayName: enriched.displayName,
platform: enriched.platform, platform: enriched.platform,
version: enriched.version, version: enriched.version,
coreVersion: enriched.coreVersion,
uiVersion: enriched.uiVersion,
deviceFamily: enriched.deviceFamily, deviceFamily: enriched.deviceFamily,
modelIdentifier: enriched.modelIdentifier, modelIdentifier: enriched.modelIdentifier,
remoteAddress: enriched.remoteAddress, remoteAddress: enriched.remoteAddress,
@@ -12,6 +12,9 @@ final class ConnectionModeCoordinator {
func apply(mode: AppState.ConnectionMode, paused: Bool) async { func apply(mode: AppState.ConnectionMode, paused: Bool) async {
switch mode { switch mode {
case .unconfigured: case .unconfigured:
if let error = await NodeServiceManager.stop() {
NodesStore.shared.lastError = "Node service stop failed: \(error)"
}
await RemoteTunnelManager.shared.stopAll() await RemoteTunnelManager.shared.stopAll()
WebChatManager.shared.resetTunnels() WebChatManager.shared.resetTunnels()
GatewayProcessManager.shared.stop() GatewayProcessManager.shared.stop()
@@ -20,6 +23,9 @@ final class ConnectionModeCoordinator {
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) } Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
case .local: case .local:
if let error = await NodeServiceManager.stop() {
NodesStore.shared.lastError = "Node service stop failed: \(error)"
}
await RemoteTunnelManager.shared.stopAll() await RemoteTunnelManager.shared.stopAll()
WebChatManager.shared.resetTunnels() WebChatManager.shared.resetTunnels()
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused) let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
@@ -50,6 +56,9 @@ final class ConnectionModeCoordinator {
WebChatManager.shared.resetTunnels() WebChatManager.shared.resetTunnels()
do { do {
if let error = await NodeServiceManager.start() {
NodesStore.shared.lastError = "Node service start failed: \(error)"
}
_ = 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(
@@ -0,0 +1,99 @@
import Charts
import SwiftUI
struct CostUsageHistoryMenuView: View {
let summary: GatewayCostUsageSummary
let width: CGFloat
var body: some View {
VStack(alignment: .leading, spacing: 10) {
self.header
self.chart
self.footer
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(width: max(1, self.width), alignment: .leading)
}
private var header: some View {
let todayKey = CostUsageMenuDateParser.format(Date())
let todayEntry = self.summary.daily.first { $0.date == todayKey }
let todayCost = CostUsageFormatting.formatUsd(todayEntry?.totalCost) ?? "n/a"
let totalCost = CostUsageFormatting.formatUsd(self.summary.totals.totalCost) ?? "n/a"
return HStack(alignment: .firstTextBaseline, spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text("Today")
.font(.caption2)
.foregroundStyle(.secondary)
Text(todayCost)
.font(.system(size: 14, weight: .semibold))
}
VStack(alignment: .leading, spacing: 2) {
Text("Last \(self.summary.days)d")
.font(.caption2)
.foregroundStyle(.secondary)
Text(totalCost)
.font(.system(size: 14, weight: .semibold))
}
Spacer()
}
}
private var chart: some View {
let entries = self.summary.daily.compactMap { entry -> (Date, Double)? in
guard let date = CostUsageMenuDateParser.parse(entry.date) else { return nil }
return (date, entry.totalCost)
}
return Chart(entries, id: \.0) { entry in
BarMark(
x: .value("Day", entry.0),
y: .value("Cost", entry.1))
.foregroundStyle(Color.accentColor)
.cornerRadius(3)
}
.chartXAxis {
AxisMarks(values: .stride(by: .day, count: 7)) {
AxisGridLine().foregroundStyle(.clear)
AxisValueLabel(format: .dateTime.month().day())
}
}
.chartYAxis {
AxisMarks(position: .leading) {
AxisGridLine()
AxisValueLabel()
}
}
.frame(height: 110)
}
private var footer: some View {
if self.summary.totals.missingCostEntries == 0 {
return AnyView(EmptyView())
}
return AnyView(
Text("Partial: \(self.summary.totals.missingCostEntries) entries missing cost")
.font(.caption2)
.foregroundStyle(.secondary))
}
}
private enum CostUsageMenuDateParser {
static let formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone.current
return formatter
}()
static func parse(_ value: String) -> Date? {
self.formatter.date(from: value)
}
static func format(_ date: Date) -> String {
self.formatter.string(from: date)
}
}
@@ -1,5 +1,6 @@
import AppKit import AppKit
import ClawdbotKit import ClawdbotKit
import CryptoKit
import Darwin import Darwin
import Foundation import Foundation
import OSLog import OSLog
@@ -27,6 +28,49 @@ private struct ExecApprovalSocketDecision: Codable {
var decision: ExecApprovalDecision var decision: ExecApprovalDecision
} }
fileprivate struct ExecHostSocketRequest: Codable {
var type: String
var id: String
var nonce: String
var ts: Int
var hmac: String
var requestJson: String
}
fileprivate struct ExecHostRequest: Codable {
var command: [String]
var rawCommand: String?
var cwd: String?
var env: [String: String]?
var timeoutMs: Int?
var needsScreenRecording: Bool?
var agentId: String?
var sessionKey: String?
}
fileprivate struct ExecHostRunResult: Codable {
var exitCode: Int?
var timedOut: Bool
var success: Bool
var stdout: String
var stderr: String
var error: String?
}
fileprivate struct ExecHostError: Codable {
var code: String
var message: String
var reason: String?
}
fileprivate struct ExecHostResponse: Codable {
var type: String
var id: String
var ok: Bool
var payload: ExecHostRunResult?
var error: ExecHostError?
}
enum ExecApprovalsSocketClient { enum ExecApprovalsSocketClient {
private struct TimeoutError: LocalizedError { private struct TimeoutError: LocalizedError {
var message: String var message: String
@@ -146,6 +190,9 @@ final class ExecApprovalsPromptServer {
token: approvals.token, token: approvals.token,
onPrompt: { request in onPrompt: { request in
await ExecApprovalsPromptPresenter.prompt(request) await ExecApprovalsPromptPresenter.prompt(request)
},
onExec: { request in
await ExecHostExecutor.handle(request)
}) })
server.start() server.start()
self.server = server self.server = server
@@ -206,11 +253,182 @@ enum ExecApprovalsPromptPresenter {
} }
} }
@MainActor
fileprivate enum ExecHostExecutor {
private static let blockedEnvKeys: Set<String> = [
"PATH",
"NODE_OPTIONS",
"PYTHONHOME",
"PYTHONPATH",
"PERL5LIB",
"PERL5OPT",
"RUBYOPT",
]
private static let blockedEnvPrefixes: [String] = [
"DYLD_",
"LD_",
]
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard !command.isEmpty else {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "command required", reason: "invalid"))
}
let displayCommand = ExecCommandFormatter.displayString(
for: command,
rawCommand: request.rawCommand)
let agentId = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedAgent = (agentId?.isEmpty == false) ? agentId : nil
let approvals = ExecApprovalsStore.resolve(agentId: trimmedAgent)
let security = approvals.agent.security
let ask = approvals.agent.ask
let autoAllowSkills = approvals.agent.autoAllowSkills
let env = self.sanitizedEnv(request.env)
let resolution = ExecCommandResolution.resolve(
command: command,
rawCommand: request.rawCommand,
cwd: request.cwd,
env: env)
let allowlistMatch = security == .allowlist
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
: nil
let skillAllow: Bool
if autoAllowSkills, let name = resolution?.executableName {
let bins = await SkillBinsCache.shared.currentBins()
skillAllow = bins.contains(name)
} else {
skillAllow = false
}
if security == .deny {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DISABLED: security=deny", reason: "security=deny"))
}
let requiresAsk: Bool = {
if ask == .always { return true }
if ask == .onMiss && security == .allowlist && allowlistMatch == nil && !skillAllow { return true }
return false
}()
var approvedByAsk = false
if requiresAsk {
let decision = ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: displayCommand,
cwd: request.cwd,
host: "node",
security: security.rawValue,
ask: ask.rawValue,
agentId: trimmedAgent,
resolvedPath: resolution?.resolvedPath))
switch decision {
case .deny:
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: user denied", reason: "user-denied"))
case .allowAlways:
approvedByAsk = true
if security == .allowlist {
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
if !pattern.isEmpty {
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
}
}
case .allowOnce:
approvedByAsk = true
}
}
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: allowlist miss", reason: "allowlist-miss"))
}
if let match = allowlistMatch {
ExecApprovalsStore.recordAllowlistUse(
agentId: trimmedAgent,
pattern: match.pattern,
command: displayCommand,
resolvedPath: resolution?.resolvedPath)
}
if request.needsScreenRecording == true {
let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false
if !authorized {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "UNAVAILABLE", message: "PERMISSION_MISSING: screenRecording", reason: "permission:screenRecording"))
}
}
let timeoutSec = request.timeoutMs.flatMap { Double($0) / 1000.0 }
let result = await Task.detached { () -> ShellExecutor.ShellResult in
await ShellExecutor.runDetailed(
command: command,
cwd: request.cwd,
env: env,
timeout: timeoutSec)
}.value
let payload = ExecHostRunResult(
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
stdout: result.stdout,
stderr: result.stderr,
error: result.errorMessage)
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: true,
payload: payload,
error: nil)
}
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? {
guard let overrides else { return nil }
var merged = ProcessInfo.processInfo.environment
for (rawKey, value) in overrides {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
let upper = key.uppercased()
if self.blockedEnvKeys.contains(upper) { continue }
if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
merged[key] = value
}
return merged
}
}
private final class ExecApprovalsSocketServer: @unchecked Sendable { private final class ExecApprovalsSocketServer: @unchecked Sendable {
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket") private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
private let socketPath: String private let socketPath: String
private let token: String private let token: String
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
private let onExec: @Sendable (ExecHostRequest) async -> ExecHostResponse
private var socketFD: Int32 = -1 private var socketFD: Int32 = -1
private var acceptTask: Task<Void, Never>? private var acceptTask: Task<Void, Never>?
private var isRunning = false private var isRunning = false
@@ -218,11 +436,13 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
init( init(
socketPath: String, socketPath: String,
token: String, token: String,
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision) onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision,
onExec: @escaping @Sendable (ExecHostRequest) async -> ExecHostResponse)
{ {
self.socketPath = socketPath self.socketPath = socketPath
self.token = token self.token = token
self.onPrompt = onPrompt self.onPrompt = onPrompt
self.onExec = onExec
} }
func start() { func start() {
@@ -317,26 +537,39 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
private func handleClient(fd: Int32) async { private func handleClient(fd: Int32) async {
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
do { do {
guard self.isAllowedPeer(fd: fd) else {
try self.sendApprovalResponse(handle: handle, id: UUID().uuidString, decision: .deny)
return
}
guard let line = try self.readLine(from: handle, maxBytes: 256_000), guard let line = try self.readLine(from: handle, maxBytes: 256_000),
let data = line.data(using: .utf8) let data = line.data(using: .utf8)
else { else {
return return
} }
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data) guard
guard request.type == "request", request.token == self.token else { let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: .deny) let type = envelope["type"] as? String
let data = try JSONEncoder().encode(response) else {
var payload = data return
payload.append(0x0A) }
try handle.write(contentsOf: payload)
if type == "request" {
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
guard request.token == self.token else {
try self.sendApprovalResponse(handle: handle, id: request.id, decision: .deny)
return
}
let decision = await self.onPrompt(request.request)
try self.sendApprovalResponse(handle: handle, id: request.id, decision: decision)
return
}
if type == "exec" {
let request = try JSONDecoder().decode(ExecHostSocketRequest.self, from: data)
let response = await self.handleExecRequest(request)
try self.sendExecResponse(handle: handle, response: response)
return return
} }
let decision = await self.onPrompt(request.request)
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: decision)
let responseData = try JSONEncoder().encode(response)
var payload = responseData
payload.append(0x0A)
try handle.write(contentsOf: payload)
} catch { } catch {
self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)") self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)")
} }
@@ -357,4 +590,77 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
let lineData = buffer.subdata(in: 0..<newlineIndex) let lineData = buffer.subdata(in: 0..<newlineIndex)
return String(data: lineData, encoding: .utf8) return String(data: lineData, encoding: .utf8)
} }
private func sendApprovalResponse(
handle: FileHandle,
id: String,
decision: ExecApprovalDecision) throws
{
let response = ExecApprovalSocketDecision(type: "decision", id: id, decision: decision)
let data = try JSONEncoder().encode(response)
var payload = data
payload.append(0x0A)
try handle.write(contentsOf: payload)
}
private func sendExecResponse(handle: FileHandle, response: ExecHostResponse) throws {
let data = try JSONEncoder().encode(response)
var payload = data
payload.append(0x0A)
try handle.write(contentsOf: payload)
}
private func isAllowedPeer(fd: Int32) -> Bool {
var uid = uid_t(0)
var gid = gid_t(0)
if getpeereid(fd, &uid, &gid) != 0 {
return false
}
return uid == geteuid()
}
private func handleExecRequest(_ request: ExecHostSocketRequest) async -> ExecHostResponse {
let nowMs = Int(Date().timeIntervalSince1970 * 1000)
if abs(nowMs - request.ts) > 10_000 {
return ExecHostResponse(
type: "exec-res",
id: request.id,
ok: false,
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "expired request", reason: "ttl"))
}
let expected = self.hmacHex(nonce: request.nonce, ts: request.ts, requestJson: request.requestJson)
if expected != request.hmac {
return ExecHostResponse(
type: "exec-res",
id: request.id,
ok: false,
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "invalid auth", reason: "hmac"))
}
guard let requestData = request.requestJson.data(using: .utf8),
let payload = try? JSONDecoder().decode(ExecHostRequest.self, from: requestData)
else {
return ExecHostResponse(
type: "exec-res",
id: request.id,
ok: false,
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "invalid payload", reason: "json"))
}
let response = await self.onExec(payload)
return ExecHostResponse(
type: "exec-res",
id: request.id,
ok: response.ok,
payload: response.payload,
error: response.error)
}
private func hmacHex(nonce: String, ts: Int, requestJson: String) -> String {
let key = SymmetricKey(data: Data(self.token.utf8))
let message = "\(nonce):\(ts):\(requestJson)"
let mac = HMAC<SHA256>.authenticationCode(for: Data(message.utf8), using: key)
return mac.map { String(format: "%02x", $0) }.joined()
}
} }
@@ -249,6 +249,13 @@ actor GatewayConnection {
return trimmed.isEmpty ? nil : trimmed return trimmed.isEmpty ? nil : trimmed
} }
func cachedGatewayVersion() -> String? {
guard let snapshot = self.lastSnapshot else { return nil }
let raw = snapshot.server["version"]?.value as? String
let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
func snapshotPaths() -> (configPath: String?, stateDir: String?) { func snapshotPaths() -> (configPath: String?, stateDir: String?) {
guard let snapshot = self.lastSnapshot else { return (nil, nil) } guard let snapshot = self.lastSnapshot else { return (nil, nil) }
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines) let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -14,6 +14,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
private weak var statusItem: NSStatusItem? private weak var statusItem: NSStatusItem?
private var loadTask: Task<Void, Never>? private var loadTask: Task<Void, Never>?
private var nodesLoadTask: Task<Void, Never>? private var nodesLoadTask: Task<Void, Never>?
private var previewTasks: [Task<Void, Never>] = []
private var isMenuOpen = false private var isMenuOpen = false
private var lastKnownMenuWidth: CGFloat? private var lastKnownMenuWidth: CGFloat?
private var menuOpenWidth: CGFloat? private var menuOpenWidth: CGFloat?
@@ -26,6 +27,10 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
private var cachedUsageErrorText: String? private var cachedUsageErrorText: String?
private var usageCacheUpdatedAt: Date? private var usageCacheUpdatedAt: Date?
private let usageRefreshIntervalSeconds: TimeInterval = 30 private let usageRefreshIntervalSeconds: TimeInterval = 30
private var cachedCostSummary: GatewayCostUsageSummary?
private var cachedCostErrorText: String?
private var costCacheUpdatedAt: Date?
private let costRefreshIntervalSeconds: TimeInterval = 45
private let nodesStore = NodesStore.shared private let nodesStore = NodesStore.shared
#if DEBUG #if DEBUG
private var testControlChannelConnected: Bool? private var testControlChannelConnected: Bool?
@@ -63,6 +68,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
guard let self else { return } guard let self else { return }
await self.refreshCache(force: forceRefresh) await self.refreshCache(force: forceRefresh)
await self.refreshUsageCache(force: forceRefresh) await self.refreshUsageCache(force: forceRefresh)
await self.refreshCostUsageCache(force: forceRefresh)
await MainActor.run { await MainActor.run {
guard self.isMenuOpen else { return } guard self.isMenuOpen else { return }
self.inject(into: menu) self.inject(into: menu)
@@ -87,6 +93,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
self.menuOpenWidth = nil self.menuOpenWidth = nil
self.loadTask?.cancel() self.loadTask?.cancel()
self.nodesLoadTask?.cancel() self.nodesLoadTask?.cancel()
self.cancelPreviewTasks()
} }
func menuNeedsUpdate(_ menu: NSMenu) { func menuNeedsUpdate(_ menu: NSMenu) {
@@ -107,6 +114,7 @@ extension MenuSessionsInjector {
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) {
self.cancelPreviewTasks()
// Remove any previous injected items. // Remove any previous injected items.
for item in menu.items where item.tag == self.tag { for item in menu.items where item.tag == self.tag {
menu.removeItem(item) menu.removeItem(item)
@@ -197,6 +205,7 @@ extension MenuSessionsInjector {
} }
cursor = self.insertUsageSection(into: menu, at: cursor, width: width) cursor = self.insertUsageSection(into: menu, at: cursor, width: width)
cursor = self.insertCostUsageSection(into: menu, at: cursor, width: width)
DispatchQueue.main.async { [weak self, weak headerView] in DispatchQueue.main.async { [weak self, weak headerView] in
guard let self, let headerView else { return } guard let self, let headerView else { return }
@@ -280,9 +289,7 @@ extension MenuSessionsInjector {
private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int { private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
let rows = self.usageRows let rows = self.usageRows
let errorText = self.cachedUsageErrorText if rows.isEmpty {
if rows.isEmpty, errorText == nil {
return cursor return cursor
} }
@@ -306,25 +313,6 @@ extension MenuSessionsInjector {
menu.insertItem(headerItem, at: cursor) menu.insertItem(headerItem, at: cursor)
cursor += 1 cursor += 1
if let errorText = errorText?.nonEmpty, !rows.isEmpty {
menu.insertItem(
self.makeMessageItem(
text: errorText,
symbolName: "exclamationmark.triangle",
width: width,
maxLines: 2),
at: cursor)
cursor += 1
}
if rows.isEmpty {
menu.insertItem(
self.makeMessageItem(text: errorText ?? "No usage available", symbolName: "minus", width: width),
at: cursor)
cursor += 1
return cursor
}
if let selectedProvider = self.selectedUsageProviderId, if let selectedProvider = self.selectedUsageProviderId,
let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }), let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }),
rows.count > 1 rows.count > 1
@@ -362,6 +350,28 @@ extension MenuSessionsInjector {
return cursor return cursor
} }
private func insertCostUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
guard self.isControlChannelConnected else { return cursor }
guard let submenu = self.buildCostUsageSubmenu(width: width) else { return cursor }
var cursor = cursor
if cursor > 0, !menu.items[cursor - 1].isSeparatorItem {
let separator = NSMenuItem.separator()
separator.tag = self.tag
menu.insertItem(separator, at: cursor)
cursor += 1
}
let item = NSMenuItem(title: "Usage cost (30 days)", action: nil, keyEquivalent: "")
item.tag = self.tag
item.isEnabled = true
item.image = NSImage(systemSymbolName: "chart.bar.xaxis", accessibilityDescription: nil)
item.submenu = submenu
menu.insertItem(item, at: cursor)
cursor += 1
return cursor
}
private var selectedUsageProviderId: String? { private var selectedUsageProviderId: String? {
guard let model = self.cachedSnapshot?.defaults.model.nonEmpty else { return nil } guard let model = self.cachedSnapshot?.defaults.model.nonEmpty else { return nil }
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -411,6 +421,36 @@ extension MenuSessionsInjector {
} }
} }
private func buildCostUsageSubmenu(width: CGFloat) -> NSMenu? {
if let error = self.cachedCostErrorText, !error.isEmpty, self.cachedCostSummary == nil {
let menu = NSMenu()
let item = NSMenuItem(title: error, action: nil, keyEquivalent: "")
item.isEnabled = false
menu.addItem(item)
return menu
}
guard let summary = self.cachedCostSummary else { return nil }
guard !summary.daily.isEmpty else { return nil }
let menu = NSMenu()
menu.delegate = self
let chartView = CostUsageHistoryMenuView(summary: summary, width: width)
let hosting = NSHostingView(rootView: AnyView(chartView))
let controller = NSHostingController(rootView: AnyView(chartView))
let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude))
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
let chartItem = NSMenuItem()
chartItem.view = hosting
chartItem.isEnabled = false
chartItem.representedObject = "costUsageChart"
menu.addItem(chartItem)
return menu
}
private func gatewayEntry() -> NodeInfo? { private func gatewayEntry() -> NodeInfo? {
let mode = AppStateStore.shared.connectionMode let mode = AppStateStore.shared.connectionMode
let isConnected = self.isControlChannelConnected let isConnected = self.isControlChannelConnected
@@ -440,6 +480,8 @@ extension MenuSessionsInjector {
displayName: "Gateway", displayName: "Gateway",
platform: platform, platform: platform,
version: nil, version: nil,
coreVersion: nil,
uiVersion: nil,
deviceFamily: nil, deviceFamily: nil,
modelIdentifier: nil, modelIdentifier: nil,
remoteIp: host, remoteIp: host,
@@ -473,15 +515,46 @@ 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,
width: width, width: width,
maxItems: 10,
maxLines: maxLines, maxLines: maxLines,
title: title)) title: title,
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false) items: [],
status: .loading))
let hosting = NSHostingView(rootView: view)
hosting.frame.size.width = max(1, width)
let size = hosting.fittingSize
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
item.view = hosting
let task = Task { [weak hosting] in
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: sessionKey, maxItems: 10)
guard !Task.isCancelled else { return }
await MainActor.run {
guard let hosting else { return }
let nextView = AnyView(SessionMenuPreviewView(
width: width,
maxLines: maxLines,
title: title,
items: snapshot.items,
status: snapshot.status))
hosting.rootView = nextView
hosting.invalidateIntrinsicContentSize()
hosting.frame.size.width = max(1, width)
let size = hosting.fittingSize
hosting.frame.size.height = size.height
}
}
self.previewTasks.append(task)
return item return item
} }
private func cancelPreviewTasks() {
for task in self.previewTasks {
task.cancel()
}
self.previewTasks.removeAll()
}
private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem { private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem {
let view = AnyView( let view = AnyView(
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 8) {
@@ -559,14 +632,36 @@ extension MenuSessionsInjector {
do { do {
self.cachedUsageSummary = try await UsageLoader.loadSummary() self.cachedUsageSummary = try await UsageLoader.loadSummary()
self.cachedUsageErrorText = nil
self.usageCacheUpdatedAt = Date()
} catch { } catch {
if self.cachedUsageSummary == nil { self.cachedUsageSummary = nil
self.cachedUsageErrorText = self.compactUsageError(error) self.cachedUsageErrorText = nil
}
self.usageCacheUpdatedAt = Date()
} }
self.usageCacheUpdatedAt = Date()
}
private func refreshCostUsageCache(force: Bool) async {
if !force,
let updated = self.costCacheUpdatedAt,
Date().timeIntervalSince(updated) < self.costRefreshIntervalSeconds
{
return
}
guard self.isControlChannelConnected else {
self.cachedCostSummary = nil
self.cachedCostErrorText = nil
self.costCacheUpdatedAt = Date()
return
}
do {
self.cachedCostSummary = try await CostUsageLoader.loadSummary()
self.cachedCostErrorText = nil
} catch {
self.cachedCostSummary = nil
self.cachedCostErrorText = self.compactUsageError(error)
}
self.costCacheUpdatedAt = Date()
} }
private func compactUsageError(_ error: Error) -> String { private func compactUsageError(_ error: Error) -> String {
@@ -747,8 +842,8 @@ extension MenuSessionsInjector {
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform)) menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
} }
if let version = entry.version?.nonEmpty { if let version = NodeMenuEntryFormatter.detailRightVersion(entry)?.nonEmpty {
menu.addItem(self.makeNodeCopyItem(label: "Version", value: self.formatVersionLabel(version))) menu.addItem(self.makeNodeCopyItem(label: "Version", value: version))
} }
menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No")) menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No"))
@@ -95,6 +95,8 @@ actor MacNodeBridgePairingClient {
displayName: hello.displayName, displayName: hello.displayName,
platform: hello.platform, platform: hello.platform,
version: hello.version, version: hello.version,
coreVersion: hello.coreVersion,
uiVersion: hello.uiVersion,
deviceFamily: hello.deviceFamily, deviceFamily: hello.deviceFamily,
modelIdentifier: hello.modelIdentifier, modelIdentifier: hello.modelIdentifier,
caps: hello.caps, caps: hello.caps,
@@ -114,12 +114,19 @@ final class MacNodeModeCoordinator {
let caps = self.currentCaps() let caps = self.currentCaps()
let commands = self.currentCommands(caps: caps) let commands = self.currentCommands(caps: caps)
let permissions = await self.currentPermissions() let permissions = await self.currentPermissions()
let uiVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
let liveGatewayVersion = await GatewayConnection.shared.cachedGatewayVersion()
let fallbackGatewayVersion = GatewayProcessManager.shared.environmentStatus.gatewayVersion
let coreVersion = (liveGatewayVersion ?? fallbackGatewayVersion)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return BridgeHello( return BridgeHello(
nodeId: Self.nodeId(), nodeId: Self.nodeId(),
displayName: InstanceIdentity.displayName, displayName: InstanceIdentity.displayName,
token: token, token: token,
platform: "macos", platform: "macos",
version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, version: uiVersion,
coreVersion: coreVersion?.isEmpty == false ? coreVersion : nil,
uiVersion: uiVersion,
deviceFamily: "Mac", deviceFamily: "Mac",
modelIdentifier: InstanceIdentity.modelIdentifier, modelIdentifier: InstanceIdentity.modelIdentifier,
caps: caps, caps: caps,
@@ -443,7 +443,6 @@ actor MacNodeRuntime {
let approvals = ExecApprovalsStore.resolve(agentId: agentId) let approvals = ExecApprovalsStore.resolve(agentId: agentId)
let security = approvals.agent.security let security = approvals.agent.security
let ask = approvals.agent.ask let ask = approvals.agent.ask
let askFallback = approvals.agent.askFallback
let autoAllowSkills = approvals.agent.autoAllowSkills let autoAllowSkills = approvals.agent.autoAllowSkills
let sessionKey = (params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) let sessionKey = (params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines) ? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -489,7 +488,7 @@ actor MacNodeRuntime {
var approvedByAsk = false var approvedByAsk = false
if requiresAsk { if requiresAsk {
let decision: ExecApprovalDecision? = await ExecApprovalsPromptPresenter.prompt( let decision = await ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest( ExecApprovalPromptRequest(
command: displayCommand, command: displayCommand,
cwd: params.cwd, cwd: params.cwd,
@@ -500,7 +499,7 @@ actor MacNodeRuntime {
resolvedPath: resolution?.resolvedPath)) resolvedPath: resolution?.resolvedPath))
switch decision { switch decision {
case .deny?: case .deny:
await self.emitExecEvent( await self.emitExecEvent(
"exec.denied", "exec.denied",
payload: ExecEventPayload( payload: ExecEventPayload(
@@ -513,41 +512,7 @@ actor MacNodeRuntime {
req, req,
code: .unavailable, code: .unavailable,
message: "SYSTEM_RUN_DENIED: user denied") message: "SYSTEM_RUN_DENIED: user denied")
case nil: case .allowAlways:
if askFallback == .full {
approvedByAsk = true
} else if askFallback == .allowlist {
if allowlistMatch != nil || skillAllow {
approvedByAsk = true
} else {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
reason: "approval-required"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: approval required")
}
} else {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
reason: "approval-required"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: approval required")
}
case .allowAlways?:
approvedByAsk = true approvedByAsk = true
if security == .allowlist { if security == .allowlist {
let pattern = resolution?.resolvedPath ?? let pattern = resolution?.resolvedPath ??
@@ -558,7 +523,7 @@ actor MacNodeRuntime {
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern) ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
} }
} }
case .allowOnce?: case .allowOnce:
approvedByAsk = true approvedByAsk = true
} }
} }
@@ -0,0 +1,150 @@
import Foundation
import OSLog
enum NodeServiceManager {
private static let logger = Logger(subsystem: "com.clawdbot", category: "node.service")
static func start() async -> String? {
let result = await self.runServiceCommandResult(
["node", "start"],
timeout: 20,
quiet: false)
if let error = self.errorMessage(from: result, treatNotLoadedAsError: true) {
self.logger.error("node service start failed: \(error, privacy: .public)")
return error
}
return nil
}
static func stop() async -> String? {
let result = await self.runServiceCommandResult(
["node", "stop"],
timeout: 15,
quiet: false)
if let error = self.errorMessage(from: result, treatNotLoadedAsError: false) {
self.logger.error("node service stop failed: \(error, privacy: .public)")
return error
}
return nil
}
}
extension NodeServiceManager {
private struct CommandResult {
let success: Bool
let payload: Data?
let message: String?
let parsed: ParsedServiceJson?
}
private struct ParsedServiceJson {
let text: String
let object: [String: Any]
let ok: Bool?
let result: String?
let message: String?
let error: String?
let hints: [String]
}
private static func runServiceCommandResult(
_ args: [String],
timeout: Double,
quiet: Bool) async -> CommandResult
{
let command = CommandResolver.clawdbotCommand(
subcommand: "service",
extraArgs: self.withJsonFlag(args),
// Service management must always run locally, even if remote mode is configured.
configRoot: ["gateway": ["mode": "local"]])
var env = ProcessInfo.processInfo.environment
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
let parsed = self.parseServiceJson(from: response.stdout) ?? self.parseServiceJson(from: response.stderr)
let ok = parsed?.ok
let message = parsed?.error ?? parsed?.message
let payload = parsed?.text.data(using: .utf8)
?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8)
let success = ok ?? response.success
if success {
return CommandResult(success: true, payload: payload, message: nil, parsed: parsed)
}
if quiet {
return CommandResult(success: false, payload: payload, message: message, parsed: parsed)
}
let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout)
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
let fullMessage = detail.map { "Node service command failed (\(exit)): \($0)" }
?? "Node service command failed (\(exit))"
self.logger.error("\(fullMessage, privacy: .public)")
return CommandResult(success: false, payload: payload, message: detail, parsed: parsed)
}
private static func errorMessage(from result: CommandResult, treatNotLoadedAsError: Bool) -> String? {
if !result.success {
return result.message ?? "Node service command failed"
}
guard let parsed = result.parsed else { return nil }
if parsed.ok == false {
return self.mergeHints(message: parsed.error ?? parsed.message, hints: parsed.hints)
}
if treatNotLoadedAsError, parsed.result == "not-loaded" {
let base = parsed.message ?? "Node service not loaded."
return self.mergeHints(message: base, hints: parsed.hints)
}
return nil
}
private static func withJsonFlag(_ args: [String]) -> [String] {
if args.contains("--json") { return args }
return args + ["--json"]
}
private static func parseServiceJson(from raw: String) -> ParsedServiceJson? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard let start = trimmed.firstIndex(of: "{"),
let end = trimmed.lastIndex(of: "}")
else {
return nil
}
let jsonText = String(trimmed[start...end])
guard let data = jsonText.data(using: .utf8) else { return nil }
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
let ok = object["ok"] as? Bool
let result = object["result"] as? String
let message = object["message"] as? String
let error = object["error"] as? String
let hints = (object["hints"] as? [String]) ?? []
return ParsedServiceJson(
text: jsonText,
object: object,
ok: ok,
result: result,
message: message,
error: error,
hints: hints)
}
private static func mergeHints(message: String?, hints: [String]) -> String? {
let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines)
let nonEmpty = trimmed?.isEmpty == false ? trimmed : nil
guard !hints.isEmpty else { return nonEmpty }
let hintText = hints.prefix(2).joined(separator: " · ")
if let nonEmpty {
return "\(nonEmpty) (\(hintText))"
}
return hintText
}
private static func summarize(_ text: String) -> String? {
let lines = text
.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard let last = lines.last else { return nil }
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
}
}
+39 -4
View File
@@ -35,8 +35,9 @@ struct NodeMenuEntryFormatter {
if let platform = self.platformText(entry) { if let platform = self.platformText(entry) {
parts.append("platform \(platform)") parts.append("platform \(platform)")
} }
if let version = entry.version?.nonEmpty { let versionLabels = self.versionLabels(entry)
parts.append("app \(self.compactVersion(version))") if !versionLabels.isEmpty {
parts.append(versionLabels.joined(separator: " · "))
} }
parts.append("status \(self.roleText(entry))") parts.append("status \(self.roleText(entry))")
return parts.joined(separator: " · ") return parts.joined(separator: " · ")
@@ -60,8 +61,9 @@ struct NodeMenuEntryFormatter {
} }
static func detailRightVersion(_ entry: NodeInfo) -> String? { static func detailRightVersion(_ entry: NodeInfo) -> String? {
guard let version = entry.version?.nonEmpty else { return nil } let labels = self.versionLabels(entry, compact: false)
return self.shortVersionLabel(version) if labels.isEmpty { return nil }
return labels.joined(separator: " · ")
} }
static func platformText(_ entry: NodeInfo) -> String? { static func platformText(_ entry: NodeInfo) -> String? {
@@ -127,6 +129,39 @@ struct NodeMenuEntryFormatter {
return compact return compact
} }
private static func versionLabels(_ entry: NodeInfo, compact: Bool = true) -> [String] {
let (core, ui) = self.resolveVersions(entry)
var labels: [String] = []
if let core {
let label = compact ? self.compactVersion(core) : self.shortVersionLabel(core)
labels.append("core \(label)")
}
if let ui {
let label = compact ? self.compactVersion(ui) : self.shortVersionLabel(ui)
labels.append("ui \(label)")
}
return labels
}
private static func resolveVersions(_ entry: NodeInfo) -> (core: String?, ui: String?) {
let core = entry.coreVersion?.nonEmpty
let ui = entry.uiVersion?.nonEmpty
if core != nil || ui != nil {
return (core, ui)
}
guard let legacy = entry.version?.nonEmpty else { return (nil, nil) }
if self.isHeadlessPlatform(entry) {
return (legacy, nil)
}
return (nil, legacy)
}
private static func isHeadlessPlatform(_ entry: NodeInfo) -> Bool {
let raw = entry.platform?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
if raw == "darwin" || raw == "linux" || raw == "win32" || raw == "windows" { return true }
return false
}
static func leadingSymbol(_ entry: NodeInfo) -> String { static func leadingSymbol(_ entry: NodeInfo) -> String {
if self.isGateway(entry) { if self.isGateway(entry) {
return self.safeSystemSymbol( return self.safeSystemSymbol(
@@ -7,6 +7,8 @@ struct NodeInfo: Identifiable, Codable {
let displayName: String? let displayName: String?
let platform: String? let platform: String?
let version: String? let version: String?
let coreVersion: String?
let uiVersion: String?
let deviceFamily: String? let deviceFamily: String?
let modelIdentifier: String? let modelIdentifier: String?
let remoteIp: String? let remoteIp: String?
@@ -210,6 +210,7 @@ extension OnboardingView {
title: String, title: String,
subtitle: String, subtitle: String,
systemImage: String, systemImage: String,
buttonTitle: String,
action: @escaping () -> Void) -> some View action: @escaping () -> Void) -> some View
{ {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
@@ -222,7 +223,7 @@ extension OnboardingView {
Text(subtitle) Text(subtitle)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Button("Open Settings → Skills", action: action) Button(buttonTitle, action: action)
.buttonStyle(.link) .buttonStyle(.link)
.padding(.top, 2) .padding(.top, 2)
} }
@@ -695,7 +695,8 @@ extension OnboardingView {
self.featureActionRow( self.featureActionRow(
title: "Connect WhatsApp or Telegram", title: "Connect WhatsApp or Telegram",
subtitle: "Open Settings → Channels to link channels and monitor status.", subtitle: "Open Settings → Channels to link channels and monitor status.",
systemImage: "link") systemImage: "link",
buttonTitle: "Open Settings → Channels")
{ {
self.openSettings(tab: .channels) self.openSettings(tab: .channels)
} }
@@ -711,7 +712,8 @@ extension OnboardingView {
self.featureActionRow( self.featureActionRow(
title: "Give your agent more powers", title: "Give your agent more powers",
subtitle: "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.", subtitle: "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.",
systemImage: "sparkles") systemImage: "sparkles",
buttonTitle: "Open Settings → Skills")
{ {
self.openSettings(tab: .skills) self.openSettings(tab: .skills)
} }
@@ -78,6 +78,7 @@ extension OnboardingView {
title: "Action", title: "Action",
subtitle: "Action subtitle", subtitle: "Action subtitle",
systemImage: "gearshape", systemImage: "gearshape",
buttonTitle: "Action",
action: {}) action: {})
_ = view.gatewaySubtitle(for: gateway) _ = view.gatewaySubtitle(for: gateway)
_ = view.isSelectedGateway(gateway) _ = view.isSelectedGateway(gateway)
@@ -3,13 +3,13 @@ import ClawdbotKit
import OSLog import OSLog
import SwiftUI import SwiftUI
private struct SessionPreviewItem: Identifiable, Sendable { struct SessionPreviewItem: Identifiable, Sendable {
let id: String let id: String
let role: PreviewRole let role: PreviewRole
let text: String let text: String
} }
private enum PreviewRole: String, Sendable { enum PreviewRole: String, Sendable {
case user case user
case assistant case assistant
case tool case tool
@@ -27,7 +27,7 @@ private enum PreviewRole: String, Sendable {
} }
} }
private actor SessionPreviewCache { actor SessionPreviewCache {
static let shared = SessionPreviewCache() static let shared = SessionPreviewCache()
private struct CacheEntry { private struct CacheEntry {
@@ -52,25 +52,33 @@ private actor SessionPreviewCache {
} }
} }
struct SessionMenuPreviewView: View { #if DEBUG
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview") extension SessionPreviewCache {
private static let previewTimeoutSeconds: Double = 4 func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt)
let sessionKey: String
let width: CGFloat
let maxItems: Int
let maxLines: Int
let title: String
@Environment(\.menuItemHighlighted) private var isHighlighted
@State private var items: [SessionPreviewItem] = []
@State private var status: LoadStatus = .loading
private struct PreviewTimeoutError: LocalizedError {
var errorDescription: String? { "preview timeout" }
} }
private enum LoadStatus: Equatable { func _testReset() {
self.entries = [:]
}
}
#endif
struct SessionMenuPreviewSnapshot: Sendable {
let items: [SessionPreviewItem]
let status: SessionMenuPreviewView.LoadStatus
}
struct SessionMenuPreviewView: View {
let width: CGFloat
let maxLines: Int
let title: String
let items: [SessionPreviewItem]
let status: LoadStatus
@Environment(\.menuItemHighlighted) private var isHighlighted
enum LoadStatus: Equatable {
case loading case loading
case ready case ready
case empty case empty
@@ -78,15 +86,17 @@ struct SessionMenuPreviewView: View {
} }
private var primaryColor: Color { private var primaryColor: Color {
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary if self.isHighlighted {
return Color(nsColor: .selectedMenuItemTextColor)
}
return Color(nsColor: .labelColor)
} }
private var secondaryColor: Color { private var secondaryColor: Color {
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary if self.isHighlighted {
} return Color(nsColor: .selectedMenuItemTextColor).opacity(0.85)
}
private var previewLimit: Int { return Color(nsColor: .secondaryLabelColor)
min(max(self.maxItems * 3, 20), 120)
} }
var body: some View { var body: some View {
@@ -100,21 +110,19 @@ struct SessionMenuPreviewView: View {
switch self.status { switch self.status {
case .loading: case .loading:
Text("Loading preview…") self.placeholder("Loading preview…")
.font(.caption)
.foregroundStyle(self.secondaryColor)
case .empty: case .empty:
Text("No recent messages") self.placeholder("No recent messages")
.font(.caption)
.foregroundStyle(self.secondaryColor)
case let .error(message): case let .error(message):
Text(message) self.placeholder(message)
.font(.caption)
.foregroundStyle(self.secondaryColor)
case .ready: case .ready:
VStack(alignment: .leading, spacing: 6) { if self.items.isEmpty {
ForEach(self.items) { item in self.placeholder("No recent messages")
self.previewRow(item) } else {
VStack(alignment: .leading, spacing: 6) {
ForEach(self.items) { item in
self.previewRow(item)
}
} }
} }
} }
@@ -123,9 +131,6 @@ struct SessionMenuPreviewView: View {
.padding(.leading, 16) .padding(.leading, 16)
.padding(.trailing, 11) .padding(.trailing, 11)
.frame(width: max(1, self.width), alignment: .leading) .frame(width: max(1, self.width), alignment: .leading)
.task(id: self.sessionKey) {
await self.loadPreview()
}
} }
@ViewBuilder @ViewBuilder
@@ -157,55 +162,66 @@ struct SessionMenuPreviewView: View {
} }
} }
private func loadPreview() async { @ViewBuilder
if let cached = await SessionPreviewCache.shared.cachedItems(for: self.sessionKey, maxAge: 12) { private func placeholder(_ text: String) -> some View {
await MainActor.run { Text(text)
self.items = cached .font(.caption)
self.status = cached.isEmpty ? .empty : .ready .foregroundStyle(self.primaryColor)
} }
return
}
await MainActor.run { }
self.status = .loading
enum SessionMenuPreviewLoader {
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
private static let previewTimeoutSeconds: Double = 4
private static let cacheMaxAgeSeconds: TimeInterval = 30
private struct PreviewTimeoutError: LocalizedError {
var errorDescription: String? { "preview timeout" }
}
static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot {
if let cached = await SessionPreviewCache.shared.cachedItems(for: sessionKey, maxAge: cacheMaxAgeSeconds) {
return Self.snapshot(from: cached)
} }
do { do {
let timeoutMs = Int(Self.previewTimeoutSeconds * 1000) let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
let payload = try await AsyncTimeout.withTimeout( let payload = try await AsyncTimeout.withTimeout(
seconds: Self.previewTimeoutSeconds, seconds: self.previewTimeoutSeconds,
onTimeout: { PreviewTimeoutError() }, onTimeout: { PreviewTimeoutError() },
operation: { operation: {
try await GatewayConnection.shared.chatHistory( try await GatewayConnection.shared.chatHistory(
sessionKey: self.sessionKey, sessionKey: sessionKey,
limit: self.previewLimit, limit: self.previewLimit(for: maxItems),
timeoutMs: timeoutMs) timeoutMs: timeoutMs)
}) })
let built = Self.previewItems(from: payload, maxItems: self.maxItems) let built = Self.previewItems(from: payload, maxItems: maxItems)
await SessionPreviewCache.shared.store(items: built, for: self.sessionKey) await SessionPreviewCache.shared.store(items: built, for: sessionKey)
await MainActor.run { return Self.snapshot(from: built)
self.items = built
self.status = built.isEmpty ? .empty : .ready
}
} catch is CancellationError { } catch is CancellationError {
return return SessionMenuPreviewSnapshot(items: [], status: .loading)
} catch { } catch {
let fallback = await SessionPreviewCache.shared.lastItems(for: self.sessionKey) let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey)
await MainActor.run { if let fallback {
if let fallback { return Self.snapshot(from: fallback)
self.items = fallback
self.status = fallback.isEmpty ? .empty : .ready
} else {
self.status = .error("Preview unavailable")
}
} }
let errorDescription = String(describing: error) let errorDescription = String(describing: error)
Self.logger.warning( Self.logger.warning(
"Session preview failed session=\(self.sessionKey, privacy: .public) " + "Session preview failed session=\(sessionKey, privacy: .public) " +
"error=\(errorDescription, privacy: .public)") "error=\(errorDescription, privacy: .public)")
return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable"))
} }
} }
private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot {
SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
}
private static func previewLimit(for maxItems: Int) -> Int {
min(max(maxItems * 3, 20), 120)
}
private static func previewItems( private static func previewItems(
from payload: ClawdbotChatHistoryPayload, from payload: ClawdbotChatHistoryPayload,
maxItems: Int) -> [SessionPreviewItem] maxItems: Int) -> [SessionPreviewItem]
@@ -0,0 +1,60 @@
import Foundation
struct GatewayCostUsageTotals: Codable {
let input: Int
let output: Int
let cacheRead: Int
let cacheWrite: Int
let totalTokens: Int
let totalCost: Double
let missingCostEntries: Int
}
struct GatewayCostUsageDay: Codable {
let date: String
let input: Int
let output: Int
let cacheRead: Int
let cacheWrite: Int
let totalTokens: Int
let totalCost: Double
let missingCostEntries: Int
}
struct GatewayCostUsageSummary: Codable {
let updatedAt: Double
let days: Int
let daily: [GatewayCostUsageDay]
let totals: GatewayCostUsageTotals
}
enum CostUsageFormatting {
static func formatUsd(_ value: Double?) -> String? {
guard let value, value.isFinite else { return nil }
if value >= 1 { return String(format: "$%.2f", value) }
if value >= 0.01 { return String(format: "$%.2f", value) }
return String(format: "$%.4f", value)
}
static func formatTokenCount(_ value: Int?) -> String? {
guard let value else { return nil }
let safe = max(0, value)
if safe >= 1_000_000 { return String(format: "%.1fm", Double(safe) / 1_000_000.0) }
if safe >= 1000 { return safe >= 10000
? String(format: "%.0fk", Double(safe) / 1000.0)
: String(format: "%.1fk", Double(safe) / 1000.0)
}
return String(safe)
}
}
@MainActor
enum CostUsageLoader {
static func loadSummary() async throws -> GatewayCostUsageSummary {
let data = try await ControlChannel.shared.request(
method: "usage.cost",
params: nil,
timeoutMs: 7000)
return try JSONDecoder().decode(GatewayCostUsageSummary.self, from: data)
}
}
+1 -13
View File
@@ -75,18 +75,6 @@ struct UsageRow: Identifiable {
extension GatewayUsageSummary { extension GatewayUsageSummary {
func primaryRows() -> [UsageRow] { func primaryRows() -> [UsageRow] {
self.providers.compactMap { provider in self.providers.compactMap { provider in
if let error = provider.error, provider.windows.isEmpty {
return UsageRow(
id: provider.provider,
providerId: provider.provider,
displayName: provider.displayName,
plan: provider.plan,
windowLabel: nil,
usedPercent: nil,
resetAt: nil,
error: error)
}
guard let window = provider.windows.max(by: { $0.usedPercent < $1.usedPercent }) else { guard let window = provider.windows.max(by: { $0.usedPercent < $1.usedPercent }) else {
return nil return nil
} }
@@ -99,7 +87,7 @@ extension GatewayUsageSummary {
windowLabel: window.label, windowLabel: window.label,
usedPercent: window.usedPercent, usedPercent: window.usedPercent,
resetAt: window.resetAt.map { Date(timeIntervalSince1970: $0 / 1000) }, resetAt: window.resetAt.map { Date(timeIntervalSince1970: $0 / 1000) },
error: provider.error) error: nil)
} }
} }
} }
@@ -37,14 +37,12 @@ struct UsageMenuLabelView: View {
Spacer(minLength: 4) Spacer(minLength: 4)
if !self.row.hasError { Text(self.row.detailText())
Text(self.row.detailText()) .font(.caption.monospacedDigit())
.font(.caption.monospacedDigit()) .foregroundStyle(self.secondaryTextColor)
.foregroundStyle(self.secondaryTextColor) .lineLimit(1)
.lineLimit(1) .truncationMode(.tail)
.truncationMode(.tail) .layoutPriority(2)
.layoutPriority(2)
}
if self.showsChevron { if self.showsChevron {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
@@ -54,15 +52,6 @@ struct UsageMenuLabelView: View {
} }
} }
if let error = self.row.error?.nonEmpty {
Text(error)
.font(.caption)
.foregroundStyle(self.secondaryTextColor)
.multilineTextAlignment(.leading)
.lineLimit(2)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: true)
}
} }
.padding(.vertical, 10) .padding(.vertical, 10)
.padding(.leading, self.paddingLeading) .padding(.leading, self.paddingLeading)
@@ -424,14 +424,18 @@ public struct PollParams: Codable, Sendable {
public struct AgentParams: Codable, Sendable { public struct AgentParams: Codable, Sendable {
public let message: String public let message: String
public let agentid: String?
public let to: String? public let to: String?
public let replyto: String?
public let sessionid: String? public let sessionid: String?
public let sessionkey: String? public let sessionkey: String?
public let thinking: String? public let thinking: String?
public let deliver: Bool? public let deliver: Bool?
public let attachments: [AnyCodable]? public let attachments: [AnyCodable]?
public let channel: String? public let channel: String?
public let replychannel: String?
public let accountid: String? public let accountid: String?
public let replyaccountid: String?
public let timeout: Int? public let timeout: Int?
public let lane: String? public let lane: String?
public let extrasystemprompt: String? public let extrasystemprompt: String?
@@ -441,14 +445,18 @@ public struct AgentParams: Codable, Sendable {
public init( public init(
message: String, message: String,
agentid: String?,
to: String?, to: String?,
replyto: String?,
sessionid: String?, sessionid: String?,
sessionkey: String?, sessionkey: String?,
thinking: String?, thinking: String?,
deliver: Bool?, deliver: Bool?,
attachments: [AnyCodable]?, attachments: [AnyCodable]?,
channel: String?, channel: String?,
replychannel: String?,
accountid: String?, accountid: String?,
replyaccountid: String?,
timeout: Int?, timeout: Int?,
lane: String?, lane: String?,
extrasystemprompt: String?, extrasystemprompt: String?,
@@ -457,14 +465,18 @@ public struct AgentParams: Codable, Sendable {
spawnedby: String? spawnedby: String?
) { ) {
self.message = message self.message = message
self.agentid = agentid
self.to = to self.to = to
self.replyto = replyto
self.sessionid = sessionid self.sessionid = sessionid
self.sessionkey = sessionkey self.sessionkey = sessionkey
self.thinking = thinking self.thinking = thinking
self.deliver = deliver self.deliver = deliver
self.attachments = attachments self.attachments = attachments
self.channel = channel self.channel = channel
self.replychannel = replychannel
self.accountid = accountid self.accountid = accountid
self.replyaccountid = replyaccountid
self.timeout = timeout self.timeout = timeout
self.lane = lane self.lane = lane
self.extrasystemprompt = extrasystemprompt self.extrasystemprompt = extrasystemprompt
@@ -474,14 +486,18 @@ public struct AgentParams: Codable, Sendable {
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case message case message
case agentid = "agentId"
case to case to
case replyto = "replyTo"
case sessionid = "sessionId" case sessionid = "sessionId"
case sessionkey = "sessionKey" case sessionkey = "sessionKey"
case thinking case thinking
case deliver case deliver
case attachments case attachments
case channel case channel
case replychannel = "replyChannel"
case accountid = "accountId" case accountid = "accountId"
case replyaccountid = "replyAccountId"
case timeout case timeout
case lane case lane
case extrasystemprompt = "extraSystemPrompt" case extrasystemprompt = "extraSystemPrompt"
@@ -530,6 +546,8 @@ public struct NodePairRequestParams: Codable, Sendable {
public let displayname: String? public let displayname: String?
public let platform: String? public let platform: String?
public let version: String? public let version: String?
public let coreversion: String?
public let uiversion: String?
public let devicefamily: String? public let devicefamily: String?
public let modelidentifier: String? public let modelidentifier: String?
public let caps: [String]? public let caps: [String]?
@@ -542,6 +560,8 @@ public struct NodePairRequestParams: Codable, Sendable {
displayname: String?, displayname: String?,
platform: String?, platform: String?,
version: String?, version: String?,
coreversion: String?,
uiversion: String?,
devicefamily: String?, devicefamily: String?,
modelidentifier: String?, modelidentifier: String?,
caps: [String]?, caps: [String]?,
@@ -553,6 +573,8 @@ public struct NodePairRequestParams: Codable, Sendable {
self.displayname = displayname self.displayname = displayname
self.platform = platform self.platform = platform
self.version = version self.version = version
self.coreversion = coreversion
self.uiversion = uiversion
self.devicefamily = devicefamily self.devicefamily = devicefamily
self.modelidentifier = modelidentifier self.modelidentifier = modelidentifier
self.caps = caps self.caps = caps
@@ -565,6 +587,8 @@ public struct NodePairRequestParams: Codable, Sendable {
case displayname = "displayName" case displayname = "displayName"
case platform case platform
case version case version
case coreversion = "coreVersion"
case uiversion = "uiVersion"
case devicefamily = "deviceFamily" case devicefamily = "deviceFamily"
case modelidentifier = "modelIdentifier" case modelidentifier = "modelIdentifier"
case caps case caps
@@ -1652,6 +1676,40 @@ public struct ExecApprovalsSetParams: Codable, Sendable {
} }
} }
public struct ExecApprovalsNodeGetParams: Codable, Sendable {
public let nodeid: String
public init(
nodeid: String
) {
self.nodeid = nodeid
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
}
}
public struct ExecApprovalsNodeSetParams: Codable, Sendable {
public let nodeid: String
public let file: [String: AnyCodable]
public let basehash: String?
public init(
nodeid: String,
file: [String: AnyCodable],
basehash: String?
) {
self.nodeid = nodeid
self.file = file
self.basehash = basehash
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case file
case basehash = "baseHash"
}
}
public struct ExecApprovalsSnapshot: Codable, Sendable { public struct ExecApprovalsSnapshot: Codable, Sendable {
public let path: String public let path: String
public let exists: Bool public let exists: Bool
@@ -0,0 +1,26 @@
import Foundation
import Testing
@testable import Clawdbot
@Suite(.serialized)
struct SessionMenuPreviewTests {
@Test func loaderReturnsCachedItems() async {
await SessionPreviewCache.shared._testReset()
let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")]
await SessionPreviewCache.shared._testSet(items: items, for: "main")
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
#expect(snapshot.status == .ready)
#expect(snapshot.items.count == 1)
#expect(snapshot.items.first?.text == "Hi")
}
@Test func loaderReturnsEmptyWhenCachedEmpty() async {
await SessionPreviewCache.shared._testReset()
await SessionPreviewCache.shared._testSet(items: [], for: "main")
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
#expect(snapshot.status == .empty)
#expect(snapshot.items.isEmpty)
}
}
@@ -63,6 +63,8 @@ public struct BridgeHello: Codable, Sendable {
public let token: String? public let token: String?
public let platform: String? public let platform: String?
public let version: String? public let version: String?
public let coreVersion: String?
public let uiVersion: String?
public let deviceFamily: String? public let deviceFamily: String?
public let modelIdentifier: String? public let modelIdentifier: String?
public let caps: [String]? public let caps: [String]?
@@ -76,6 +78,8 @@ public struct BridgeHello: Codable, Sendable {
token: String?, token: String?,
platform: String?, platform: String?,
version: String?, version: String?,
coreVersion: String? = nil,
uiVersion: String? = nil,
deviceFamily: String? = nil, deviceFamily: String? = nil,
modelIdentifier: String? = nil, modelIdentifier: String? = nil,
caps: [String]? = nil, caps: [String]? = nil,
@@ -88,6 +92,8 @@ public struct BridgeHello: Codable, Sendable {
self.token = token self.token = token
self.platform = platform self.platform = platform
self.version = version self.version = version
self.coreVersion = coreVersion
self.uiVersion = uiVersion
self.deviceFamily = deviceFamily self.deviceFamily = deviceFamily
self.modelIdentifier = modelIdentifier self.modelIdentifier = modelIdentifier
self.caps = caps self.caps = caps
@@ -121,6 +127,8 @@ public struct BridgePairRequest: Codable, Sendable {
public let displayName: String? public let displayName: String?
public let platform: String? public let platform: String?
public let version: String? public let version: String?
public let coreVersion: String?
public let uiVersion: String?
public let deviceFamily: String? public let deviceFamily: String?
public let modelIdentifier: String? public let modelIdentifier: String?
public let caps: [String]? public let caps: [String]?
@@ -135,6 +143,8 @@ public struct BridgePairRequest: Codable, Sendable {
displayName: String?, displayName: String?,
platform: String?, platform: String?,
version: String?, version: String?,
coreVersion: String? = nil,
uiVersion: String? = nil,
deviceFamily: String? = nil, deviceFamily: String? = nil,
modelIdentifier: String? = nil, modelIdentifier: String? = nil,
caps: [String]? = nil, caps: [String]? = nil,
@@ -148,6 +158,8 @@ public struct BridgePairRequest: Codable, Sendable {
self.displayName = displayName self.displayName = displayName
self.platform = platform self.platform = platform
self.version = version self.version = version
self.coreVersion = coreVersion
self.uiVersion = uiVersion
self.deviceFamily = deviceFamily self.deviceFamily = deviceFamily
self.modelIdentifier = modelIdentifier self.modelIdentifier = modelIdentifier
self.caps = caps self.caps = caps
+1 -1
View File
@@ -287,7 +287,7 @@ ack reaction after the bot replies.
- `dm.enabled`: set `false` to ignore all DMs (default `true`). - `dm.enabled`: set `false` to ignore all DMs (default `true`).
- `dm.policy`: DM access control (`pairing` recommended). `"open"` requires `dm.allowFrom=["*"]`. - `dm.policy`: DM access control (`pairing` recommended). `"open"` requires `dm.allowFrom=["*"]`.
- `dm.allowFrom`: DM allowlist (user ids or names). Used by `dm.policy="allowlist"` and for `dm.policy="open"` validation. - `dm.allowFrom`: DM allowlist (user ids or names). Used by `dm.policy="allowlist"` and for `dm.policy="open"` validation. The wizard accepts usernames and resolves them to ids when the bot can search members.
- `dm.groupEnabled`: enable group DMs (default `false`). - `dm.groupEnabled`: enable group DMs (default `false`).
- `dm.groupChannels`: optional allowlist for group DM channel ids or slugs. - `dm.groupChannels`: optional allowlist for group DM channel ids or slugs.
- `groupPolicy`: controls guild channel handling (`open|disabled|allowlist`); `allowlist` requires channel allowlists. - `groupPolicy`: controls guild channel handling (`open|disabled|allowlist`); `allowlist` requires channel allowlists.
+1 -1
View File
@@ -244,7 +244,7 @@ Provider options:
- `channels.imessage.service`: `imessage | sms | auto`. - `channels.imessage.service`: `imessage | sms | auto`.
- `channels.imessage.region`: SMS region. - `channels.imessage.region`: SMS region.
- `channels.imessage.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). - `channels.imessage.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.imessage.allowFrom`: DM allowlist (handles or `chat_id:*`). `open` requires `"*"`. - `channels.imessage.allowFrom`: DM allowlist (handles, emails, E.164 numbers, or `chat_id:*`). `open` requires `"*"`. iMessage has no usernames; use handles or chat targets.
- `channels.imessage.groupPolicy`: `open | allowlist | disabled` (default: allowlist). - `channels.imessage.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
- `channels.imessage.groupAllowFrom`: group sender allowlist. - `channels.imessage.groupAllowFrom`: group sender allowlist.
- `channels.imessage.historyLimit` / `channels.imessage.accounts.*.historyLimit`: max group messages to include as context (0 disables). - `channels.imessage.historyLimit` / `channels.imessage.accounts.*.historyLimit`: max group messages to include as context (0 disables).
+2 -2
View File
@@ -70,7 +70,7 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis
- `clawdbot pairing list matrix` - `clawdbot pairing list matrix`
- `clawdbot pairing approve matrix <CODE>` - `clawdbot pairing approve matrix <CODE>`
- Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`. - Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`.
- `channels.matrix.dm.allowFrom` accepts user IDs or display names (resolved at startup when directory search is available). - `channels.matrix.dm.allowFrom` accepts user IDs or display names. The wizard resolves display names to user IDs when directory search is available.
## Rooms (groups) ## Rooms (groups)
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset. - Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset.
@@ -121,7 +121,7 @@ Provider options:
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound). - `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars). - `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing). - `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.matrix.dm.allowFrom`: DM allowlist. `open` requires `"*"`. - `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible.
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist). - `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
- `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms. - `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms.
- `channels.matrix.rooms`: per-room settings and allowlist. - `channels.matrix.rooms`: per-room settings and allowlist.
+2 -2
View File
@@ -76,7 +76,7 @@ Disable with:
**DM access** **DM access**
- Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved. - Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved.
- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names (resolved at startup when Graph allows). - `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names. The wizard resolves names to IDs via Microsoft Graph when credentials allow.
**Group access** **Group access**
- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). Use `channels.defaults.groupPolicy` to override the default when unset. - Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). Use `channels.defaults.groupPolicy` to override the default when unset.
@@ -413,7 +413,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
- `channels.msteams.webhook.port` (default `3978`) - `channels.msteams.webhook.port` (default `3978`)
- `channels.msteams.webhook.path` (default `/api/messages`) - `channels.msteams.webhook.path` (default `/api/messages`)
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing) - `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs or UPNs). - `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available.
- `channels.msteams.textChunkLimit`: outbound text chunk size. - `channels.msteams.textChunkLimit`: outbound text chunk size.
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains). - `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
- `channels.msteams.requireMention`: require @mention in channels/groups (default true). - `channels.msteams.requireMention`: require @mention in channels/groups (default true).
+1 -1
View File
@@ -120,7 +120,7 @@ Provider options:
- `channels.signal.ignoreStories`: ignore stories from the daemon. - `channels.signal.ignoreStories`: ignore stories from the daemon.
- `channels.signal.sendReadReceipts`: forward read receipts. - `channels.signal.sendReadReceipts`: forward read receipts.
- `channels.signal.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). - `channels.signal.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.signal.allowFrom`: DM allowlist (E.164 or `uuid:<id>`). `open` requires `"*"`. - `channels.signal.allowFrom`: DM allowlist (E.164 or `uuid:<id>`). `open` requires `"*"`. Signal has no usernames; use phone/UUID ids.
- `channels.signal.groupPolicy`: `open | allowlist | disabled` (default: allowlist). - `channels.signal.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
- `channels.signal.groupAllowFrom`: group sender allowlist. - `channels.signal.groupAllowFrom`: group sender allowlist.
- `channels.signal.historyLimit`: max group messages to include as context (0 disables). - `channels.signal.historyLimit`: max group messages to include as context (0 disables).
+1 -1
View File
@@ -378,7 +378,7 @@ For fine-grained control, use these tags in agent responses:
- Default: `channels.slack.dm.policy="pairing"` — unknown DM senders get a pairing code (expires after 1 hour). - Default: `channels.slack.dm.policy="pairing"` — unknown DM senders get a pairing code (expires after 1 hour).
- Approve via: `clawdbot pairing approve slack <code>`. - Approve via: `clawdbot pairing approve slack <code>`.
- To allow anyone: set `channels.slack.dm.policy="open"` and `channels.slack.dm.allowFrom=["*"]`. - To allow anyone: set `channels.slack.dm.policy="open"` and `channels.slack.dm.allowFrom=["*"]`.
- `channels.slack.dm.allowFrom` accepts user IDs, @handles, or emails (resolved at startup when tokens allow). - `channels.slack.dm.allowFrom` accepts user IDs, @handles, or emails (resolved at startup when tokens allow). The wizard accepts usernames and resolves them to ids during setup when tokens allow.
## Group policy ## Group policy
- `channels.slack.groupPolicy` controls channel handling (`open|disabled|allowlist`). - `channels.slack.groupPolicy` controls channel handling (`open|disabled|allowlist`).
+1 -1
View File
@@ -305,7 +305,7 @@ Use the global setting when all Telegram bots/accounts should behave the same. U
- `clawdbot pairing list telegram` - `clawdbot pairing list telegram`
- `clawdbot pairing approve telegram <CODE>` - `clawdbot pairing approve telegram <CODE>`
- Pairing is the default token exchange used for Telegram DMs. Details: [Pairing](/start/pairing) - Pairing is the default token exchange used for Telegram DMs. Details: [Pairing](/start/pairing)
- `channels.telegram.allowFrom` accepts numeric user IDs (recommended) or `@username` entries. It is **not** the bot username; use the human sender’s ID. - `channels.telegram.allowFrom` accepts numeric user IDs (recommended) or `@username` entries. It is **not** the bot username; use the human sender’s ID. The wizard accepts `@username` and resolves it to the numeric ID when possible.
#### Finding your Telegram user ID #### Finding your Telegram user ID
Safer (no third-party bot): Safer (no third-party bot):
+1 -1
View File
@@ -308,7 +308,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
## Config quick map ## Config quick map
- `channels.whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled). - `channels.whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled).
- `channels.whatsapp.selfChatMode` (same-phone setup; bot uses your personal WhatsApp number). - `channels.whatsapp.selfChatMode` (same-phone setup; bot uses your personal WhatsApp number).
- `channels.whatsapp.allowFrom` (DM allowlist). - `channels.whatsapp.allowFrom` (DM allowlist). WhatsApp uses E.164 phone numbers (no usernames).
- `channels.whatsapp.mediaMaxMb` (inbound media save cap). - `channels.whatsapp.mediaMaxMb` (inbound media save cap).
- `channels.whatsapp.ackReaction` (auto-reaction on message receipt: `{emoji, direct, group}`). - `channels.whatsapp.ackReaction` (auto-reaction on message receipt: `{emoji, direct, group}`).
- `channels.whatsapp.accounts.<accountId>.*` (per-account settings + optional `authDir`). - `channels.whatsapp.accounts.<accountId>.*` (per-account settings + optional `authDir`).
+2 -2
View File
@@ -92,7 +92,7 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
- `clawdbot pairing list zalo` - `clawdbot pairing list zalo`
- `clawdbot pairing approve zalo <CODE>` - `clawdbot pairing approve zalo <CODE>`
- Pairing is the default token exchange. Details: [Pairing](/start/pairing) - Pairing is the default token exchange. Details: [Pairing](/start/pairing)
- `channels.zalo.allowFrom` accepts numeric user IDs. - `channels.zalo.allowFrom` accepts numeric user IDs (no username lookup available).
## Long-polling vs webhook ## Long-polling vs webhook
- Default: long-polling (no public URL required). - Default: long-polling (no public URL required).
@@ -147,7 +147,7 @@ Provider options:
- `channels.zalo.botToken`: bot token from Zalo Bot Platform. - `channels.zalo.botToken`: bot token from Zalo Bot Platform.
- `channels.zalo.tokenFile`: read token from file path. - `channels.zalo.tokenFile`: read token from file path.
- `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). - `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. - `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs.
- `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5). - `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5).
- `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required). - `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required).
- `channels.zalo.webhookSecret`: webhook secret (8-256 chars). - `channels.zalo.webhookSecret`: webhook secret (8-256 chars).
+1 -1
View File
@@ -66,7 +66,7 @@ clawdbot directory groups list --channel zalouser --query "work"
## Access control (DMs) ## Access control (DMs)
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`). `channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
`channels.zalouser.allowFrom` accepts user IDs or names (resolved at startup when available). `channels.zalouser.allowFrom` accepts user IDs or names. The wizard resolves names to IDs via `zca friend find` when available.
Approve via: Approve via:
- `clawdbot pairing list zalouser` - `clawdbot pairing list zalouser`
+3 -1
View File
@@ -7,6 +7,7 @@ read_when:
# `clawdbot agent` # `clawdbot agent`
Run an agent turn via the Gateway (use `--local` for embedded). Run an agent turn via the Gateway (use `--local` for embedded).
Use `--agent <id>` to target a configured agent directly.
Related: Related:
- Agent send tool: [Agent send](/tools/agent-send) - Agent send tool: [Agent send](/tools/agent-send)
@@ -15,6 +16,7 @@ Related:
```bash ```bash
clawdbot agent --to +15555550123 --message "status update" --deliver clawdbot agent --to +15555550123 --message "status update" --deliver
clawdbot agent --agent ops --message "Summarize logs"
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
``` ```
+1 -1
View File
@@ -28,7 +28,7 @@ clawdbot gateway
Notes: Notes:
- By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.clawdbot/clawdbot.json`. Use `--allow-unconfigured` for ad-hoc/dev runs. - By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.clawdbot/clawdbot.json`. Use `--allow-unconfigured` for ad-hoc/dev runs.
- Binding beyond loopback without auth is blocked (safety guardrail). - Binding beyond loopback without auth is blocked (safety guardrail).
- `SIGUSR1` triggers an in-process restart (useful without a supervisor). - `SIGUSR1` triggers an in-process restart when authorized (enable `commands.restart` or use the gateway tool/config apply/update).
- `SIGINT`/`SIGTERM` handlers stop the gateway process, but they don’t restore any custom terminal state. If you wrap the CLI with a TUI or raw-mode input, restore the terminal before exit. - `SIGINT`/`SIGTERM` handlers stop the gateway process, but they don’t restore any custom terminal state. If you wrap the CLI with a TUI or raw-mode input, restore the terminal before exit.
### Options ### Options
+14 -5
View File
@@ -7,8 +7,8 @@ read_when:
# `clawdbot memory` # `clawdbot memory`
Memory search tools (semantic memory status/index/search). Manage semantic memory indexing and search.
Provided by the active memory plugin (default: `memory-core`; use `plugins.slots.memory = "none"` to disable). Provided by the active memory plugin (default: `memory-core`; set `plugins.slots.memory = "none"` to disable).
Related: Related:
- Memory concept: [Memory](/concepts/memory) - Memory concept: [Memory](/concepts/memory)
@@ -22,11 +22,20 @@ clawdbot memory status --deep
clawdbot memory status --deep --index clawdbot memory status --deep --index
clawdbot memory status --deep --index --verbose clawdbot memory status --deep --index --verbose
clawdbot memory index clawdbot memory index
clawdbot memory index --verbose
clawdbot memory search "release checklist" clawdbot memory search "release checklist"
clawdbot memory status --agent main
clawdbot memory index --agent main --verbose
``` ```
## Options ## Options
- `--verbose`: emit debug logs during memory probes and indexing. Common:
- `--index-mode auto|batch|direct`: override batch usage when indexing (`direct` favors speed; `batch` favors OpenAI Batch pricing).
- `--progress auto|line|log|none`: progress output mode (`log` prints updates even without a TTY). - `--agent <id>`: scope to a single agent (default: all configured agents).
- `--verbose`: emit detailed logs during probes and indexing.
Notes:
- `memory status --deep` probes vector + embedding availability.
- `memory status --deep --index` runs a reindex if the store is dirty.
- `memory index --verbose` prints per-phase details (provider, model, sources, batch activity).
+9
View File
@@ -89,6 +89,15 @@ clawdbot sandbox recreate --all
clawdbot sandbox recreate --all clawdbot sandbox recreate --all
``` ```
### After changing setupCommand
```bash
clawdbot sandbox recreate --all
# or just one agent:
clawdbot sandbox recreate --agent family
```
### For a specific agent only ### For a specific agent only
```bash ```bash
+32 -18
View File
@@ -79,37 +79,46 @@ semantic queries can find related notes even when wording differs.
Defaults: Defaults:
- Enabled by default. - Enabled by default.
- Watches memory files for changes (debounced). - Watches memory files for changes (debounced).
- Uses remote embeddings (OpenAI) unless configured for local. - Uses remote embeddings by default. If `memorySearch.provider` is not set, Clawdbot auto-selects:
1. `local` if a `memorySearch.local.modelPath` is configured and the file exists.
2. `openai` if an OpenAI key can be resolved.
3. `gemini` if a Gemini key can be resolved.
4. Otherwise memory search stays disabled until configured.
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`. - Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite. - Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
Remote embeddings **require** an API key for the embedding provider. By default Remote embeddings **require** an API key for the embedding provider. Clawdbot
this is OpenAI (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Codex resolves keys from auth profiles, `models.providers.*.apiKey`, or environment
OAuth only covers chat/completions and does **not** satisfy embeddings for variables. Codex OAuth only covers chat/completions and does **not** satisfy
memory search. When using a custom OpenAI-compatible endpoint, set embeddings for memory search. For Gemini, use `GEMINI_API_KEY` or
`memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`). `models.providers.google.apiKey`. When using a custom OpenAI-compatible endpoint,
set `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
If you want to use **Gemini embeddings** directly, set the provider to `gemini`: ### Gemini embeddings (native)
Set the provider to `gemini` to use the Gemini embeddings API directly:
```json5 ```json5
agents: { agents: {
defaults: { defaults: {
memorySearch: { memorySearch: {
provider: "gemini", provider: "gemini",
model: "gemini-embedding-001", // default model: "gemini-embedding-001",
remote: { remote: {
apiKey: "${GEMINI_API_KEY}" apiKey: "YOUR_GEMINI_API_KEY"
} }
} }
} }
} }
``` ```
Gemini uses `GEMINI_API_KEY` (or `models.providers.google.apiKey`). Override Notes:
`memorySearch.remote.baseUrl` to point at a custom Gemini-compatible endpoint. - `remote.baseUrl` is optional (defaults to the Gemini API base URL).
- `remote.headers` lets you add extra headers if needed.
- Default model: `gemini-embedding-001`.
If you want to use a **custom OpenAI-compatible endpoint** (like OpenRouter or a proxy), If you want to use a **custom OpenAI-compatible endpoint** (OpenRouter, vLLM, or a proxy),
you can use the `remote` configuration: you can use the `remote` configuration with the OpenAI provider:
```json5 ```json5
agents: { agents: {
@@ -118,8 +127,8 @@ agents: {
provider: "openai", provider: "openai",
model: "text-embedding-3-small", model: "text-embedding-3-small",
remote: { remote: {
baseUrl: "https://proxy.example/v1", baseUrl: "https://api.example.com/v1/",
apiKey: "YOUR_PROXY_KEY", apiKey: "YOUR_OPENAI_COMPAT_API_KEY",
headers: { "X-Custom-Header": "value" } headers: { "X-Custom-Header": "value" }
} }
} }
@@ -130,11 +139,16 @@ agents: {
If you don't want to set an API key, use `memorySearch.provider = "local"` or set If you don't want to set an API key, use `memorySearch.provider = "local"` or set
`memorySearch.fallback = "none"`. `memorySearch.fallback = "none"`.
Batch indexing (OpenAI only): Fallbacks:
- Enabled by default for OpenAI embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable. - `memorySearch.fallback` can be `openai`, `gemini`, `local`, or `none`.
- The fallback provider is only used when the primary embedding provider fails.
Batch indexing (OpenAI + Gemini):
- Enabled by default for OpenAI and Gemini embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable.
- Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed. - Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed.
- Set `remote.batch.concurrency` to control how many batch jobs we submit in parallel (default: 2). - Set `remote.batch.concurrency` to control how many batch jobs we submit in parallel (default: 2).
- Batch mode currently applies only when `memorySearch.provider = "openai"` and uses your OpenAI API key. - Batch mode applies when `memorySearch.provider = "openai"` or `"gemini"` and uses the corresponding API key.
- Gemini batch jobs use the async embeddings batch endpoint and require Gemini Batch API availability.
Why OpenAI batch is fast + cheap: Why OpenAI batch is fast + cheap:
- For large backfills, OpenAI is typically the fastest option we support because we can submit many embedding requests in a single batch job and let OpenAI process them asynchronously. - For large backfills, OpenAI is typically the fastest option we support because we can submit many embedding requests in a single batch job and let OpenAI process them asynchronously.
+3
View File
@@ -293,6 +293,9 @@ Starting with v2026.1.6, each agent can have its own sandbox and tool restrictio
} }
``` ```
Note: `setupCommand` lives under `sandbox.docker` and runs once on container creation.
Per-agent `sandbox.docker.*` overrides are ignored when the resolved scope is `"shared"`.
**Benefits:** **Benefits:**
- **Security isolation**: Restrict tools for untrusted agents - **Security isolation**: Restrict tools for untrusted agents
- **Resource control**: Sandbox specific agents while keeping others on host - **Resource control**: Sandbox specific agents while keeping others on host
+41 -2
View File
@@ -9,7 +9,7 @@ read_when:
Clawdbot standardizes timestamps so the model sees a **single reference time**. Clawdbot standardizes timestamps so the model sees a **single reference time**.
## Message envelopes (UTC) ## Message envelopes (UTC by default)
Inbound messages are wrapped in an envelope like: Inbound messages are wrapped in an envelope like:
@@ -17,7 +17,46 @@ Inbound messages are wrapped in an envelope like:
[Provider ... 2026-01-05T21:26Z] message text [Provider ... 2026-01-05T21:26Z] message text
``` ```
The timestamp in the envelope is **always UTC**, with minutes precision. The timestamp in the envelope is **UTC by default**, with minutes precision.
You can override this with:
```json5
{
agents: {
defaults: {
envelopeTimezone: "user", // "utc" | "local" | "user" | IANA timezone
envelopeTimestamp: "on", // "on" | "off"
envelopeElapsed: "on" // "on" | "off"
}
}
}
```
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
- Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset.
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style).
### Examples
**UTC (default):**
```
[Signal Alice +1555 2026-01-18T05:19Z] hello
```
**Fixed timezone:**
```
[Signal Alice +1555 2026-01-18 06:19 GMT+1] hello
```
**Elapsed time:**
```
[Signal Alice +1555 +2m 2026-01-18T05:19Z] follow-up
```
## Tool payloads (raw provider data + normalized fields) ## Tool payloads (raw provider data + normalized fields)
+1
View File
@@ -13,6 +13,7 @@ read_when:
## Where it shows up ## Where it shows up
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available. - `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available.
- `/usage off|tokens|full` in chats: per-response usage footer (OAuth shows tokens only). - `/usage off|tokens|full` in chats: per-response usage footer (OAuth shows tokens only).
- `/usage cost` in chats: local cost summary aggregated from Clawdbot session logs.
- 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).
- macOS menu bar: “Usage” section under Context (only if available). - macOS menu bar: “Usage” section under Context (only if available).
+44 -4
View File
@@ -7,10 +7,10 @@ read_when:
# Date & Time # Date & Time
Clawdbot uses **UTC for transport timestamps** and **user-local time only in the system prompt**. Clawdbot defaults to **UTC for transport timestamps** and **user-local time only in the system prompt**.
We avoid rewriting provider timestamps so tools keep their native semantics. Provider timestamps are preserved so tools keep their native semantics.
## Message envelopes (UTC) ## Message envelopes (UTC by default)
Inbound messages are wrapped with a UTC timestamp (minute precision): Inbound messages are wrapped with a UTC timestamp (minute precision):
@@ -18,7 +18,47 @@ Inbound messages are wrapped with a UTC timestamp (minute precision):
[Provider ... 2026-01-05T21:26Z] message text [Provider ... 2026-01-05T21:26Z] message text
``` ```
This envelope timestamp is **always UTC**, regardless of the host timezone. This envelope timestamp is **UTC by default**, regardless of the host timezone.
You can override this behavior:
```json5
{
agents: {
defaults: {
envelopeTimezone: "utc", // "utc" | "local" | "user" | IANA timezone
envelopeTimestamp: "on", // "on" | "off"
envelopeElapsed: "on" // "on" | "off"
}
}
}
```
- `envelopeTimezone: "local"` uses the host timezone.
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone.
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style).
### Examples
**UTC (default):**
```
[WhatsApp +1555 2026-01-18T05:19Z] hello
```
**User timezone:**
```
[WhatsApp +1555 2026-01-18 00:19 CST] hello
```
**Elapsed time enabled:**
```
[WhatsApp +1555 +30s 2026-01-18T05:19Z] follow-up
```
## System prompt: Current Date & Time ## System prompt: Current Date & Time
+72
View File
@@ -0,0 +1,72 @@
---
summary: Node + tsx "__name is not a function" crash notes and workarounds
read_when:
- Debugging Node-only dev scripts or watch mode failures
- Investigating tsx/esbuild loader crashes in Clawdbot
---
# Node + tsx "__name is not a function" crash
## Summary
Running Clawdbot via Node with `tsx` fails at startup with:
```
[clawdbot] Failed to start CLI: TypeError: __name is not a function
at createSubsystemLogger (.../src/logging/subsystem.ts:203:25)
at .../src/agents/auth-profiles/constants.ts:25:20
```
This began after switching dev scripts from Bun to `tsx` (commit `2871657e`, 2026-01-06). The same runtime path worked with Bun.
## Environment
- Node: v25.x (observed on v25.3.0)
- tsx: 4.21.0
- OS: macOS (repro also likely on other platforms that run Node 25)
## Repro (Node-only)
```bash
# in repo root
node --version
pnpm install
node --import tsx src/entry.ts status
```
## Minimal repro in repo
```bash
node --import tsx scripts/repro/tsx-name-repro.ts
```
## Node version check
- Node 25.3.0: fails
- Node 22.22.0 (Homebrew `node@22`): fails
- Node 24: not installed here yet; needs verification
## Notes / hypothesis
- `tsx` uses esbuild to transform TS/ESM. esbuild’s `keepNames` emits a `__name` helper and wraps function definitions with `__name(...)`.
- The crash indicates `__name` exists but is not a function at runtime, which implies the helper is missing or overwritten for this module in the Node 25 loader path.
- Similar `__name` helper issues have been reported in other esbuild consumers when the helper is missing or rewritten.
## Regression history
- `2871657e` (2026-01-06): scripts changed from Bun to tsx to make Bun optional.
- Before that (Bun path), `pnpm clawdbot status` and `gateway:watch` worked.
## Workarounds
- Use Bun for dev scripts (current temporary revert).
- Use Node + tsc watch, then run compiled output:
```bash
pnpm exec tsc --watch --preserveWatchOutput
node --watch dist/entry.js status
```
- Confirmed locally: `pnpm exec tsc -p tsconfig.json` + `node dist/entry.js status` works on Node 25.
- Disable esbuild keepNames in the TS loader if possible (prevents `__name` helper insertion); tsx does not currently expose this.
- Test Node LTS (22/24) with `tsx` to see if the issue is Node 25–specific.
## References
- https://opennext.js.org/cloudflare/howtos/keep_names
- https://esbuild.github.io/api/#keep-names
- https://github.com/evanw/esbuild/issues/1031
## Next steps
- Repro on Node 22/24 to confirm Node 25 regression.
- Test `tsx` nightly or pin to earlier version if a known regression exists.
- If reproduces on Node LTS, file a minimal repro upstream with the `__name` stack trace.
+16
View File
@@ -17,6 +17,19 @@ If the file is missing, Clawdbot uses safe-ish defaults (embedded Pi agent + per
> **New to configuration?** Check out the [Configuration Examples](/gateway/configuration-examples) guide for complete examples with detailed explanations! > **New to configuration?** Check out the [Configuration Examples](/gateway/configuration-examples) guide for complete examples with detailed explanations!
## Strict config validation
Clawdbot only accepts configurations that fully match the schema.
Unknown keys, malformed types, or invalid values cause the Gateway to **refuse to start** for safety.
When validation fails:
- The Gateway does not boot.
- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot service`, `clawdbot help`).
- Run `clawdbot doctor` to see the exact issues.
- Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs.
Doctor never writes changes unless you explicitly opt into `--fix`/`--yes`.
## Schema + UI hints ## Schema + UI hints
The Gateway exposes a JSON Schema representation of the config via `config.schema` for UI editors. The Gateway exposes a JSON Schema representation of the config via `config.schema` for UI editors.
@@ -1990,6 +2003,9 @@ cross-session isolation. Use `scope: "session"` for per-session isolation.
Legacy: `perSession` is still supported (`true` → `scope: "session"`, Legacy: `perSession` is still supported (`true` → `scope: "session"`,
`false` → `scope: "shared"`). `false` → `scope: "shared"`).
`setupCommand` runs **once** after the container is created (inside the container via `sh -lc`).
For package installs, ensure network egress, a writable root FS, and a root user.
```json5 ```json5
{ {
agents: { agents: {
+1 -1
View File
@@ -34,7 +34,7 @@ pnpm gateway:watch
- Pass `--verbose` to mirror debug logging (handshakes, req/res, events) from the log file into stdio when troubleshooting. - Pass `--verbose` to mirror debug logging (handshakes, req/res, events) from the log file into stdio when troubleshooting.
- `--force` uses `lsof` to find listeners on the chosen port, sends SIGTERM, logs what it killed, then starts the gateway (fails fast if `lsof` is missing). - `--force` uses `lsof` to find listeners on the chosen port, sends SIGTERM, logs what it killed, then starts the gateway (fails fast if `lsof` is missing).
- If you run under a supervisor (launchd/systemd/mac app child-process mode), a stop/restart typically sends **SIGTERM**; older builds may surface this as `pnpm` `ELIFECYCLE` exit code **143** (SIGTERM), which is a normal shutdown, not a crash. - If you run under a supervisor (launchd/systemd/mac app child-process mode), a stop/restart typically sends **SIGTERM**; older builds may surface this as `pnpm` `ELIFECYCLE` exit code **143** (SIGTERM), which is a normal shutdown, not a crash.
- **SIGUSR1** triggers an in-process restart (no external supervisor required). This is what the `gateway` agent tool uses. - **SIGUSR1** triggers an in-process restart when authorized (gateway tool/config apply/update, or enable `commands.restart` for manual restarts).
- Gateway auth: set `gateway.auth.mode=token` + `gateway.auth.token` (or pass `--token <value>` / `CLAWDBOT_GATEWAY_TOKEN`) to require clients to send `connect.params.auth.token`. - Gateway auth: set `gateway.auth.mode=token` + `gateway.auth.token` (or pass `--token <value>` / `CLAWDBOT_GATEWAY_TOKEN`) to require clients to send `connect.params.auth.token`.
- The wizard now generates a token by default, even on loopback. - The wizard now generates a token by default, even on loopback.
- Port precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`. - Port precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.
+14
View File
@@ -116,6 +116,20 @@ Override with `agents.defaults.sandbox.docker.network`.
Docker installs and the containerized gateway live here: Docker installs and the containerized gateway live here:
[Docker](/install/docker) [Docker](/install/docker)
## setupCommand (one-time container setup)
`setupCommand` runs **once** after the sandbox container is created (not on every run).
It executes inside the container via `sh -lc`.
Paths:
- Global: `agents.defaults.sandbox.docker.setupCommand`
- Per-agent: `agents.list[].sandbox.docker.setupCommand`
Common pitfalls:
- Default `docker.network` is `"none"` (no egress), so package installs will fail.
- `readOnlyRoot: true` prevents writes; set `readOnlyRoot: false` or bake a custom image.
- `user` must be root for package installs (omit `user` or set `user: "0:0"`).
## Tool policy + escape hatches ## Tool policy + escape hatches
Tool allow/deny policies still apply before sandbox rules. If a tool is denied Tool allow/deny policies still apply before sandbox rules. If a tool is denied
globally or per-agent, sandboxing doesn’t bring it back. globally or per-agent, sandboxing doesn’t bring it back.
+7 -7
View File
@@ -5,13 +5,6 @@ read_when:
--- ---
# Security 🔒 # Security 🔒
Running an AI agent with shell access on your machine is... *spicy*. Here’s how to not get pwned.
Clawdbot is both a product and an experiment: you’re wiring frontier-model behavior into real messaging surfaces and real tools. **There is no “perfectly secure” setup.** The goal is to be deliberate about:
- who can talk to your bot
- where the bot is allowed to act
- what the bot can touch
## Quick check: `clawdbot security audit` ## Quick check: `clawdbot security audit`
Run this regularly (especially after changing config or exposing network surfaces): Run this regularly (especially after changing config or exposing network surfaces):
@@ -29,6 +22,13 @@ It flags common footguns (Gateway auth exposure, browser control exposure, eleva
- Turn `logging.redactSensitive="off"` back to `"tools"`. - Turn `logging.redactSensitive="off"` back to `"tools"`.
- Tighten local perms (`~/.clawdbot` → `700`, config file → `600`, plus common state files like `credentials/*.json`, `agents/*/agent/auth-profiles.json`, and `agents/*/sessions/sessions.json`). - Tighten local perms (`~/.clawdbot` → `700`, config file → `600`, plus common state files like `credentials/*.json`, `agents/*/agent/auth-profiles.json`, and `agents/*/sessions/sessions.json`).
Running an AI agent with shell access on your machine is... *spicy*. Here’s how to not get pwned.
Clawdbot is both a product and an experiment: you’re wiring frontier-model behavior into real messaging surfaces and real tools. **There is no “perfectly secure” setup.** The goal is to be deliberate about:
- who can talk to your bot
- where the bot is allowed to act
- what the bot can touch
### What the audit checks (high level) ### What the audit checks (high level)
- **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot? - **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot?
+16
View File
@@ -326,6 +326,22 @@ Clawdbot keeps conversation history in memory.
## Common troubleshooting ## Common troubleshooting
### “Gateway won’t start — configuration invalid”
Clawdbot now refuses to start when the config contains unknown keys, malformed values, or invalid types.
This is intentional for safety.
Fix it with Doctor:
```bash
clawdbot doctor
clawdbot doctor --fix
```
Notes:
- `clawdbot doctor` reports every invalid entry.
- `clawdbot doctor --fix` applies migrations/repairs and rewrites the config.
- Diagnostic commands like `clawdbot logs`, `clawdbot health`, `clawdbot status`, and `clawdbot service` still run even if the config is invalid.
### “All models failed” — what should I check first? ### “All models failed” — what should I check first?
- **Credentials** present for the provider(s) being tried (auth profiles + env vars). - **Credentials** present for the provider(s) being tried (auth profiles + env vars).
+8
View File
@@ -254,6 +254,14 @@ precedence, and troubleshooting.
### Enable sandboxing ### Enable sandboxing
If you plan to install packages in `setupCommand`, note:
- Default `docker.network` is `"none"` (no egress).
- `readOnlyRoot: true` blocks package installs.
- `user` must be root for `apt-get` (omit `user` or set `user: "0:0"`).
Clawdbot auto-recreates containers when `setupCommand` (or docker config) changes
unless the container was **recently used** (within ~5 minutes). Hot containers
log a warning with the exact `clawdbot sandbox recreate ...` command.
```json5 ```json5
{ {
agents: { agents: {
+41
View File
@@ -12,6 +12,7 @@ Clawdbot ships two installer scripts (served from `clawd.bot`):
- `https://clawd.bot/install.sh` — “recommended” installer (global npm install by default; can also install from a GitHub checkout) - `https://clawd.bot/install.sh` — “recommended” installer (global npm install by default; can also install from a GitHub checkout)
- `https://clawd.bot/install-cli.sh` — non-root-friendly CLI installer (installs into a prefix with its own Node) - `https://clawd.bot/install-cli.sh` — non-root-friendly CLI installer (installs into a prefix with its own Node)
- `https://clawd.bot/install.ps1` — Windows PowerShell installer (npm by default; optional git install)
To see the current flags/behavior, run: To see the current flags/behavior, run:
@@ -19,6 +20,12 @@ To see the current flags/behavior, run:
curl -fsSL https://clawd.bot/install.sh | bash -s -- --help curl -fsSL https://clawd.bot/install.sh | bash -s -- --help
``` ```
Windows (PowerShell) help:
```powershell
& ([scriptblock]::Create((iwr -useb https://clawd.bot/install.ps1))) -?
```
If the installer completes but `clawdbot` is not found in a new terminal, it’s usually a Node/npm PATH issue. See: [Install](/install#nodejs--npm-path-sanity). If the installer completes but `clawdbot` is not found in a new terminal, it’s usually a Node/npm PATH issue. See: [Install](/install#nodejs--npm-path-sanity).
## install.sh (recommended) ## install.sh (recommended)
@@ -73,3 +80,37 @@ Help:
```bash ```bash
curl -fsSL https://clawd.bot/install-cli.sh | bash -s -- --help curl -fsSL https://clawd.bot/install-cli.sh | bash -s -- --help
``` ```
## install.ps1 (Windows PowerShell)
What it does (high level):
- Ensure Node.js **22+** (winget/Chocolatey/Scoop or manual).
- Choose install method:
- `npm` (default): `npm install -g clawdbot@latest`
- `git`: clone/build a source checkout and install a wrapper script
- Runs `clawdbot doctor --non-interactive` on upgrades and git installs (best effort).
Examples:
```powershell
iwr -useb https://clawd.bot/install.ps1 | iex
```
```powershell
iwr -useb https://clawd.bot/install.ps1 | iex -InstallMethod git
```
```powershell
iwr -useb https://clawd.bot/install.ps1 | iex -InstallMethod git -GitDir "C:\\clawdbot"
```
Environment variables:
- `CLAWDBOT_INSTALL_METHOD=git|npm`
- `CLAWDBOT_GIT_DIR=...`
Git requirement:
If you choose `-InstallMethod git` and Git is missing, the installer will print the
Git for Windows link (`https://git-scm.com/download/win`) and exit.
+3
View File
@@ -18,6 +18,9 @@ This allows you to run multiple agents with different security profiles:
- Family/work agents with restricted tools - Family/work agents with restricted tools
- Public-facing agents in sandboxes - Public-facing agents in sandboxes
`setupCommand` belongs under `sandbox.docker` (global or per-agent) and runs once
when the container is created.
Auth is per-agent: each agent reads from its own `agentDir` auth store at: Auth is per-agent: each agent reads from its own `agentDir` auth store at:
``` ```
+5 -2
View File
@@ -9,8 +9,9 @@ read_when:
# Gateway on macOS (external launchd) # Gateway on macOS (external launchd)
Clawdbot.app no longer bundles Node/Bun or the Gateway runtime. The macOS app Clawdbot.app no longer bundles Node/Bun or the Gateway runtime. The macOS app
expects an **external** `clawdbot` CLI install and manages a per‑user launchd expects an **external** `clawdbot` CLI install, does not spawn the Gateway as a
service to keep the Gateway running. child process, and manages a per‑user launchd service to keep the Gateway
running (or attaches to an existing local Gateway if one is already running).
## Install the CLI (required for local mode) ## Install the CLI (required for local mode)
@@ -38,6 +39,8 @@ Manager:
Behavior: Behavior:
- “Clawdbot Active” enables/disables the LaunchAgent. - “Clawdbot Active” enables/disables the LaunchAgent.
- App quit does **not** stop the gateway (launchd keeps it alive). - App quit does **not** stop the gateway (launchd keeps it alive).
- If a Gateway is already running on the configured port, the app attaches to
it instead of starting a new one.
Logging: Logging:
- launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log` - launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log`
+5 -3
View File
@@ -5,9 +5,11 @@ read_when:
--- ---
# Gateway lifecycle on macOS # Gateway lifecycle on macOS
The macOS app **manages the Gateway via launchd** by default. The launchd job The macOS app **manages the Gateway via launchd** by default and does not spawn
uses the external `clawdbot` CLI (no embedded runtime). This gives you reliable the Gateway as a child process. It first tries to attach to an already‑running
auto‑start at login and restart on crashes. Gateway on the configured port; if none is reachable, it enables the launchd
service via the external `clawdbot` CLI (no embedded runtime). This gives you
reliable auto‑start at login and restart on crashes.
Child‑process mode (Gateway spawned directly by the app) is **not in use** today. Child‑process mode (Gateway spawned directly by the app) is **not in use** today.
If you need tighter coupling to the UI, run the Gateway manually in a terminal. If you need tighter coupling to the UI, run the Gateway manually in a terminal.
+3
View File
@@ -32,6 +32,9 @@ To build the macOS app and package it into `dist/Clawdbot.app`, run:
If you don't have an Apple Developer ID certificate, the script will automatically use **ad-hoc signing** (`-`). If you don't have an Apple Developer ID certificate, the script will automatically use **ad-hoc signing** (`-`).
For dev run modes, signing flags, and Team ID troubleshooting, see the macOS app README:
https://github.com/clawdbot/clawdbot/blob/main/apps/macos/README.md
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section. > **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
## 3. Install the CLI ## 3. Install the CLI
+2
View File
@@ -14,6 +14,7 @@ This app is usually built from [`scripts/package-mac-app.sh`](https://github.com
- inject build metadata into Info.plist: `ClawdbotBuildTimestamp` (UTC) and `ClawdbotGitCommit` (short hash) so the About pane can show build, git, and debug/release channel. - inject build metadata into Info.plist: `ClawdbotBuildTimestamp` (UTC) and `ClawdbotGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
- **Packaging requires Node 22+**: the script runs TS builds and the Control UI build. - **Packaging requires Node 22+**: the script runs TS builds and the Control UI build.
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing). - reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing).
- runs a Team ID audit after signing and fails if any Mach-O inside the app bundle is signed by a different Team ID. Set `SKIP_TEAM_ID_CHECK=1` to bypass.
## Usage ## Usage
@@ -23,6 +24,7 @@ scripts/package-mac-app.sh # auto-selects identity; errors if none
SIGN_IDENTITY="Developer ID Application: Your Name" scripts/package-mac-app.sh # real cert SIGN_IDENTITY="Developer ID Application: Your Name" scripts/package-mac-app.sh # real cert
ALLOW_ADHOC_SIGNING=1 scripts/package-mac-app.sh # ad-hoc (permissions will not stick) ALLOW_ADHOC_SIGNING=1 scripts/package-mac-app.sh # ad-hoc (permissions will not stick)
SIGN_IDENTITY="-" scripts/package-mac-app.sh # explicit ad-hoc (same caveat) SIGN_IDENTITY="-" scripts/package-mac-app.sh # explicit ad-hoc (same caveat)
DISABLE_LIBRARY_VALIDATION=1 scripts/package-mac-app.sh # dev-only Sparkle Team ID mismatch workaround
``` ```
### Ad-hoc Signing Note ### Ad-hoc Signing Note
+16 -5
View File
@@ -5,7 +5,7 @@ read_when:
--- ---
# Clawdbot macOS IPC architecture # Clawdbot macOS IPC architecture
**Current model:** there is **no local control socket** and no `clawdbot-mac` CLI. All agent actions go through the Gateway WebSocket and `node.invoke`. UI automation still uses PeekabooBridge. **Current model:** a local Unix socket connects the **node service** to the **macOS app** for exec approvals + `system.run`. There is no `clawdbot-mac` CLI; agent actions still flow through the Gateway WebSocket and `node.invoke`. UI automation uses PeekabooBridge.
## Goals ## Goals
- Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript). - Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript).
@@ -13,19 +13,29 @@ read_when:
- Predictable permissions: always the same signed bundle ID, launched by launchd, so TCC grants stick. - Predictable permissions: always the same signed bundle ID, launched by launchd, so TCC grants stick.
## How it works ## How it works
### Gateway + node bridge (current) ### Gateway + node bridge
- The app runs the Gateway (local mode) and connects to it as a node. - The app runs the Gateway (local mode) and connects to it as a node.
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`). - Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
### Node service + app IPC
- A headless node service connects to the Gateway bridge.
- `system.run` requests are forwarded to the macOS app over a local Unix socket.
- The app performs the exec in UI context, prompts if needed, and returns output.
Diagram (SCI):
```
Agent -> Gateway -> Bridge -> Node Service (TS)
| IPC (UDS + token + HMAC + TTL)
v
Mac App (UI + TCC + system.run)
```
### PeekabooBridge (UI automation) ### PeekabooBridge (UI automation)
- UI automation uses a separate UNIX socket named `bridge.sock` and the PeekabooBridge JSON protocol. - UI automation uses a separate UNIX socket named `bridge.sock` and the PeekabooBridge JSON protocol.
- Host preference order (client-side): Peekaboo.app → Claude.app → Clawdbot.app → local execution. - Host preference order (client-side): Peekaboo.app → Claude.app → Clawdbot.app → local execution.
- Security: bridge hosts require an allowed TeamID; DEBUG-only same-UID escape hatch is guarded by `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (Peekaboo convention). - Security: bridge hosts require an allowed TeamID; DEBUG-only same-UID escape hatch is guarded by `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (Peekaboo convention).
- See: [PeekabooBridge usage](/platforms/mac/peekaboo) for details. - See: [PeekabooBridge usage](/platforms/mac/peekaboo) for details.
### Mach/XPC
- Not required for automation; `node.invoke` + PeekabooBridge cover current needs.
## Operational flows ## Operational flows
- Restart/rebuild: `SIGN_IDENTITY="Apple Development: <Developer Name> (<TEAMID>)" scripts/restart-mac.sh` - Restart/rebuild: `SIGN_IDENTITY="Apple Development: <Developer Name> (<TEAMID>)" scripts/restart-mac.sh`
- Kills existing instances - Kills existing instances
@@ -38,3 +48,4 @@ read_when:
- PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development. - PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development.
- All communication remains local-only; no network sockets are exposed. - All communication remains local-only; no network sockets are exposed.
- TCC prompts originate only from the GUI app bundle; keep the signed bundle ID stable across rebuilds. - TCC prompts originate only from the GUI app bundle; keep the signed bundle ID stable across rebuilds.
- IPC hardening: socket mode `0600`, token, peer-UID checks, HMAC challenge/response, short TTL.
+19 -5
View File
@@ -7,8 +7,8 @@ read_when:
# Clawdbot macOS Companion (menu bar + gateway broker) # Clawdbot macOS Companion (menu bar + gateway broker)
The macOS app is the **menu‑bar companion** for Clawdbot. It owns permissions, The macOS app is the **menu‑bar companion** for Clawdbot. It owns permissions,
manages the Gateway locally, and exposes macOS capabilities to the agent as a manages/attaches to the Gateway locally (launchd or manual), and exposes macOS
node. capabilities to the agent as a node.
## What it does ## What it does
@@ -17,16 +17,18 @@ node.
Speech Recognition, Automation/AppleScript). Speech Recognition, Automation/AppleScript).
- Runs or connects to the Gateway (local or remote). - Runs or connects to the Gateway (local or remote).
- Exposes macOS‑only tools (Canvas, Camera, Screen Recording, `system.run`). - Exposes macOS‑only tools (Canvas, Camera, Screen Recording, `system.run`).
- Starts the local node host service in **remote** mode (launchd), and stops it in **local** mode.
- Optionally hosts **PeekabooBridge** for UI automation. - Optionally hosts **PeekabooBridge** for UI automation.
- Installs the global CLI (`clawdbot`) via npm/pnpm on request (bun not recommended for the Gateway runtime). - Installs the global CLI (`clawdbot`) via npm/pnpm on request (bun not recommended for the Gateway runtime).
## Local vs remote mode ## Local vs remote mode
- **Local** (default): the app ensures a local Gateway is running via launchd. - **Local** (default): the app attaches to a running local Gateway if present;
otherwise it enables the launchd service via `clawdbot daemon`.
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts - **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
a local process. a local process.
- **Attach‑only** (debug): the app connects to an already‑running local Gateway The app starts the local **node host service** so the remote Gateway can reach this Mac.
and never spawns its own. The app does not spawn the Gateway as a child process.
## Launchd control ## Launchd control
@@ -54,6 +56,18 @@ 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 service + app IPC:
- When the headless node service is running (remote mode), it connects to the Gateway bridge.
- `system.run` executes in the macOS app (UI/TCC context) over a local Unix socket; prompts + output stay in-app.
Diagram (SCI):
```
Gateway -> Bridge -> Node Service (TS)
| IPC (UDS + token + HMAC + TTL)
v
Mac App (UI + TCC + system.run)
```
## Exec approvals (system.run) ## Exec approvals (system.run)
`system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals). `system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals).
+2 -1
View File
@@ -37,6 +37,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams. - Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`) - Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`)
- Memory (LanceDB) — bundled long-term memory plugin (auto-recall/capture; set `plugins.slots.memory = "memory-lancedb"`)
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call` - [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser` - [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
- [Matrix](/channels/matrix) — `@clawdbot/matrix` - [Matrix](/channels/matrix) — `@clawdbot/matrix`
@@ -45,7 +46,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default) - Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default) - Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default) - Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
- Copilot Proxy (provider auth) — bundled as `copilot-proxy` (disabled by default) - Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default)
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:
+21
View File
@@ -6,6 +6,27 @@ read_when:
--- ---
# Github Copilot # Github Copilot
## What is GitHub Copilot?
GitHub Copilot is GitHub's AI coding assistant. It provides access to Copilot
models for your GitHub account and plan. Clawdbot can use Copilot as a model
provider in two different ways.
## Two ways to use Copilot in Clawdbot
### 1) Built-in GitHub Copilot provider (`github-copilot`)
Use the native device-login flow to obtain a GitHub token, then exchange it for
Copilot API tokens when Clawdbot runs. This is the **default** and simplest path
because it does not require VS Code.
### 2) Copilot Proxy plugin (`copilot-proxy`)
Use the **Copilot Proxy** VS Code extension as a local bridge. Clawdbot talks to
the proxy’s `/v1` endpoint and uses the model list you configure there. Choose
this when you already run Copilot Proxy in VS Code or need to route through it.
You must enable the plugin and keep the VS Code extension running.
Use GitHub Copilot as a model provider (`github-copilot`). The login command runs Use GitHub Copilot as a model provider (`github-copilot`). The login command runs
the GitHub device flow, saves an auth profile, and updates your config to use that the GitHub device flow, saves an auth profile, and updates your config to use that
profile. profile.
+21 -8
View File
@@ -30,6 +30,8 @@ read_when:
- **Node identity:** use existing `nodeId`. - **Node identity:** use existing `nodeId`.
- **Socket auth:** Unix socket + token (cross-platform); split later if needed. - **Socket auth:** Unix socket + token (cross-platform); split later if needed.
- **Node host state:** `~/.clawdbot/node.json` (node id + pairing token). - **Node host state:** `~/.clawdbot/node.json` (node id + pairing token).
- **macOS exec host:** run `system.run` inside the macOS app; node service forwards requests over local IPC.
- **No XPC helper:** stick to Unix socket + token + peer checks.
## Key concepts ## Key concepts
### Host ### Host
@@ -139,19 +141,30 @@ Notes:
## UI integration (macOS app) ## UI integration (macOS app)
### IPC ### IPC
- Unix socket at `~/.clawdbot/exec-approvals.sock`. - Unix socket at `~/.clawdbot/exec-approvals.sock` (0600).
- Runner connects and sends an approval request; UI responds with a decision. - Token stored in `exec-approvals.json` (0600).
- Token stored in `exec-approvals.json`. - Peer checks: same-UID only.
- Challenge/response: nonce + HMAC(token, request-hash) to prevent replay.
- Short TTL (e.g., 10s) + max payload + rate limit.
### Ask flow ### Ask flow (macOS app exec host)
1) Runner receives `system.run` from gateway. 1) Node service receives `system.run` from gateway.
2) If ask required, runner connects to the socket and sends a prompt request. 2) Node service connects to the local socket and sends the prompt/exec request.
3) UI shows dialog; returns decision. 3) App validates peer + token + HMAC + TTL, then shows dialog if needed.
4) Runner enforces decision and proceeds. 4) App executes the command in UI context and returns output.
5) Node service returns output to gateway.
If UI missing: If UI missing:
- Apply `askFallback` (`deny|allowlist|full`). - Apply `askFallback` (`deny|allowlist|full`).
### Diagram (SCI)
```
Agent -> Gateway -> Bridge -> Node Service (TS)
| IPC (UDS + token + HMAC + TTL)
v
Mac App (UI + TCC + system.run)
```
## Node identity + binding ## Node identity + binding
- Use existing `nodeId` from Bridge pairing. - Use existing `nodeId` from Bridge pairing.
- Binding model: - Binding model:
+78
View File
@@ -0,0 +1,78 @@
---
summary: "Strict config validation + doctor-only migrations"
read_when:
- Designing or implementing config validation behavior
- Working on config migrations or doctor workflows
- Handling plugin config schemas or plugin load gating
---
# Strict config validation (doctor-only migrations)
## Goals
- **Reject unknown config keys everywhere** (root + nested).
- **Reject plugin config without a schema**; don’t load that plugin.
- **Remove legacy auto-migration on load**; migrations run via doctor only.
- **Auto-run doctor (dry-run) on startup**; if invalid, block non-diagnostic commands.
## Non-goals
- Backward compatibility on load (legacy keys do not auto-migrate).
- Silent drops of unrecognized keys.
## Strict validation rules
- Config must match the schema exactly at every level.
- Unknown keys are validation errors (no passthrough at root or nested).
- `plugins.entries.<id>.config` must be validated by the plugin’s schema.
- If a plugin lacks a schema, **reject plugin load** and surface a clear error.
## Plugin schema enforcement
- Each plugin provides a strict schema for its config (no passthrough).
- Plugin load flow:
1) Resolve plugin schema by plugin id.
2) Validate config against the schema.
3) If missing schema or invalid config: block plugin load, record error.
- Error message includes:
- Plugin id
- Reason (missing schema / invalid config)
- Path(s) that failed validation
## Doctor flow
- Doctor runs **every time** config is loaded (dry-run by default).
- If config invalid:
- Print a summary + actionable errors.
- Instruct: `clawdbot doctor --fix`.
- `clawdbot doctor --fix`:
- Applies migrations.
- Removes unknown keys.
- Writes updated config.
## Command gating (when config is invalid)
Allowed (diagnostic-only):
- `clawdbot doctor`
- `clawdbot logs`
- `clawdbot health`
- `clawdbot help`
- `clawdbot status`
- `clawdbot service`
Everything else must hard-fail with: “Config invalid. Run `clawdbot doctor --fix`.”
## Error UX format
- Single summary header.
- Grouped sections:
- Unknown keys (full paths)
- Legacy keys / migrations needed
- Plugin load failures (plugin id + reason + path)
## Implementation touchpoints
- `src/config/zod-schema.ts`: remove root passthrough; strict objects everywhere.
- `src/config/zod-schema.providers.ts`: ensure strict channel schemas.
- `src/config/validation.ts`: fail on unknown keys; do not apply legacy migrations.
- `src/config/io.ts`: remove legacy auto-migrations; always run doctor dry-run.
- `src/config/legacy*.ts`: move usage to doctor only.
- `src/plugins/*`: add schema registry + gating.
- CLI command gating in `src/cli`.
## Tests
- Unknown key rejection (root + nested).
- Plugin missing schema → plugin load blocked with clear error.
- Invalid config → gateway startup blocked except diagnostic commands.
- Doctor dry-run auto; `doctor --fix` writes corrected config.
+5
View File
@@ -15,6 +15,11 @@ Use these when a task is clearly tied to a script; otherwise prefer the CLI.
- Prefer CLI surfaces when they exist (example: auth monitoring uses `clawdbot models status --check`). - Prefer CLI surfaces when they exist (example: auth monitoring uses `clawdbot models status --check`).
- Assume scripts are host‑specific; read them before running on a new machine. - Assume scripts are host‑specific; read them before running on a new machine.
## Git hooks
- `scripts/setup-git-hooks.js`: best-effort setup for `core.hooksPath` when inside a git repo.
- `scripts/format-staged.js`: pre-commit formatter for staged `src/` and `test/` files.
## Auth monitoring scripts ## Auth monitoring scripts
Auth monitoring scripts are documented here: Auth monitoring scripts are documented here:
+22 -36
View File
@@ -60,7 +60,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Remote gateways + nodes](#remote-gateways-nodes) - [Remote gateways + nodes](#remote-gateways-nodes)
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes) - [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon) - [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
- [Can I run a headless node host without the macOS app?](#can-i-run-a-headless-node-host-without-the-macos-app)
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config) - [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install) - [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
- [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac) - [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac)
@@ -406,7 +405,7 @@ You have three supported patterns:
Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS. Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS.
**Option B - use a macOS node (no SSH).** **Option B - use a macOS node (no SSH).**
Run the Gateway on Linux, pair a macOS node (menubar app), and configure **Exec approvals** (Settings → Exec approvals) to "Ask" or "Always Allow". Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Ask", selecting "Always Allow" in the prompt adds that command to the allowlist. Run the Gateway on Linux, pair a macOS node (menubar app), and set **Node Run Commands** to "Always Ask" or "Always Allow" on the Mac. Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Always Ask", approving "Always Allow" in the prompt adds that command to the allowlist.
**Option C - proxy macOS binaries over SSH (advanced).** **Option C - proxy macOS binaries over SSH (advanced).**
Keep the Gateway on Linux, but make the required CLI binaries resolve to SSH wrappers that run on a Mac. Then override the skill to allow Linux so it stays eligible. Keep the Gateway on Linux, but make the required CLI binaries resolve to SSH wrappers that run on a Mac. Then override the skill to allow Linux so it stays eligible.
@@ -502,14 +501,23 @@ is writable (read-only sandboxes skip it). See [Memory](/concepts/memory).
### Does semantic memory search require an OpenAI API key? ### Does semantic memory search require an OpenAI API key?
Only if you use **remote embeddings** (OpenAI). Codex OAuth covers Only if you use **OpenAI embeddings**. Codex OAuth covers chat/completions and
chat/completions and does **not** grant embeddings access, so **signing in with does **not** grant embeddings access, so **signing in with Codex (OAuth or the
Codex (OAuth or the Codex CLI login)** does not help for semantic memory search. Codex CLI login)** does not help for semantic memory search. OpenAI embeddings
Remote memory search still needs a real OpenAI API key (`OPENAI_API_KEY` or still need a real API key (`OPENAI_API_KEY` or `models.providers.openai.apiKey`).
`models.providers.openai.apiKey`). If you’d rather stay local, set
`memorySearch.provider = "local"` (and optionally `memorySearch.fallback = If you don’t set a provider explicitly, Clawdbot auto-selects a provider when it
"none"`). We support **remote or local embedding models** — see [Memory](/concepts/memory) can resolve an API key (auth profiles, `models.providers.*.apiKey`, or env vars).
for the setup details. It prefers OpenAI if an OpenAI key resolves, otherwise Gemini if a Gemini key
resolves. If neither key is available, memory search stays disabled until you
configure it. If you have a local model path configured and present, Clawdbot
prefers `local`.
If you’d rather stay local, set `memorySearch.provider = "local"` (and optionally
`memorySearch.fallback = "none"`). If you want Gemini embeddings, set
`memorySearch.provider = "gemini"` and provide `GEMINI_API_KEY` (or
`memorySearch.remote.apiKey`). We support **OpenAI, Gemini, or local** embedding
models — see [Memory](/concepts/memory) for the setup details.
## Where things live on disk ## Where things live on disk
@@ -743,23 +751,6 @@ to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes. A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
### Can I run a headless node host without the macOS app?
Yes. The headless node host is a **command-only** node that exposes `system.run` / `system.which`
without any UI. It has no screen/camera/notify support (use the macOS app for those).
Start it:
```bash
clawdbot node start --host <gateway-host> --port 18790
```
Notes:
- Pairing is still required (`clawdbot nodes pending` → `clawdbot nodes approve <requestId>`).
- Exec approvals still apply via `~/.clawdbot/exec-approvals.json`.
- If prompts are enabled but no companion UI is reachable, `askFallback` decides (default: deny).
Docs: [Node CLI](/cli/node), [Nodes](/nodes), [Exec approvals](/tools/exec-approvals).
### Is there an API / RPC way to apply config? ### Is there an API / RPC way to apply config?
Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation. Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation.
@@ -898,19 +889,14 @@ Send `/new` or `/reset` as a standalone message. See [Session management](/conce
### Do sessions reset automatically if I never send `/new`? ### Do sessions reset automatically if I never send `/new`?
Yes. By default sessions reset daily at **4:00 AM local time** on the gateway host. Yes. Sessions expire after `session.idleMinutes` (default **60**). The **next**
You can also add an idle window; when both daily and idle resets are configured, message starts a fresh session id for that chat key. This does not delete
whichever expires first starts a new session id on the next message. This does transcripts — it just starts a new session.
not delete transcripts — it just starts a new session.
```json5 ```json5
{ {
session: { session: {
reset: { idleMinutes: 240
mode: "daily",
atHour: 4,
idleMinutes: 240
}
} }
} }
``` ```
+1
View File
@@ -45,6 +45,7 @@ Use these in chat:
- `/usage off|tokens|full` → appends a **per-response usage footer** to every reply. - `/usage off|tokens|full` → appends a **per-response usage footer** to every reply.
- Persists per session (stored as `responseUsage`). - Persists per session (stored as `responseUsage`).
- OAuth auth **hides cost** (tokens only). - OAuth auth **hides cost** (tokens only).
- `/usage cost` → shows a local cost summary from Clawdbot session logs.
Other surfaces: Other surfaces:
+10 -3
View File
@@ -14,13 +14,15 @@ runtime on the current machine.
- Required: `--message <text>` - Required: `--message <text>`
- Session selection: - Session selection:
- `--to <dest>` derives the session key (group/channel targets preserve isolation; direct chats collapse to `main`), **or** - `--to <dest>` derives the session key (group/channel targets preserve isolation; direct chats collapse to `main`), **or**
- `--session-id <id>` reuses an existing session by id - `--session-id <id>` reuses an existing session by id, **or**
- `--agent <id>` targets a configured agent directly (uses that agent's `main` session key)
- Runs the same embedded agent runtime as normal inbound replies. - Runs the same embedded agent runtime as normal inbound replies.
- Thinking/verbose flags persist into the session store. - Thinking/verbose flags persist into the session store.
- Output: - Output:
- default: prints reply text (plus `MEDIA:<url>` lines) - default: prints reply text (plus `MEDIA:<url>` lines)
- `--json`: prints structured payload + metadata - `--json`: prints structured payload + metadata
- Optional delivery back to a channel with `--deliver` + `--channel` (target formats match `clawdbot message --target`). - Optional delivery back to a channel with `--deliver` + `--channel` (target formats match `clawdbot message --target`).
- Use `--reply-channel`/`--reply-to`/`--reply-account` to override delivery without changing the session.
If the Gateway is unreachable, the CLI **falls back** to the embedded local run. If the Gateway is unreachable, the CLI **falls back** to the embedded local run.
@@ -28,16 +30,21 @@ If the Gateway is unreachable, the CLI **falls back** to the embedded local run.
```bash ```bash
clawdbot agent --to +15555550123 --message "status update" clawdbot agent --to +15555550123 --message "status update"
clawdbot agent --agent ops --message "Summarize logs"
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
clawdbot agent --to +15555550123 --message "Trace logs" --verbose on --json clawdbot agent --to +15555550123 --message "Trace logs" --verbose on --json
clawdbot agent --to +15555550123 --message "Summon reply" --deliver clawdbot agent --to +15555550123 --message "Summon reply" --deliver
clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
``` ```
## Flags ## Flags
- `--local`: run locally (requires model provider API keys in your shell) - `--local`: run locally (requires model provider API keys in your shell)
- `--deliver`: send the reply to the chosen channel (requires `--to`) - `--deliver`: send the reply to the chosen channel
- `--channel`: `whatsapp|telegram|discord|slack|signal|imessage` (default: `whatsapp`) - `--channel`: delivery channel (`whatsapp|telegram|discord|slack|signal|imessage`, default: `whatsapp`)
- `--reply-to`: delivery target override
- `--reply-channel`: delivery channel override
- `--reply-account`: delivery account id override
- `--thinking <off|minimal|low|medium|high|xhigh>`: persist thinking level (GPT-5.2 + Codex models only) - `--thinking <off|minimal|low|medium|high|xhigh>`: persist thinking level (GPT-5.2 + Codex models only)
- `--verbose <on|full|off>`: persist verbose level - `--verbose <on|full|off>`: persist verbose level
- `--timeout <seconds>`: override agent timeout - `--timeout <seconds>`: override agent timeout
+17
View File
@@ -22,6 +22,10 @@ Exec approvals are enforced locally on the execution host:
- **gateway host** → `clawdbot` process on the gateway machine - **gateway host** → `clawdbot` process on the gateway machine
- **node host** → node runner (macOS companion app or headless node host) - **node host** → node runner (macOS companion app or headless node host)
Planned macOS split:
- **node service** forwards `system.run` to the **macOS app** over local IPC.
- **macOS app** enforces approvals + executes the command in UI context.
## Settings and storage ## Settings and storage
Approvals live in a local JSON file on the execution host: Approvals live in a local JSON file on the execution host:
@@ -128,6 +132,19 @@ Actions:
- **Always allow** → add to allowlist + run - **Always allow** → add to allowlist + run
- **Deny** → block - **Deny** → block
### macOS IPC flow (planned)
```
Gateway -> Bridge -> Node Service (TS)
| IPC (UDS + token + HMAC + TTL)
v
Mac App (UI + approvals + system.run)
```
Security notes:
- Unix socket mode `0600`, token stored in `exec-approvals.json`.
- Same-UID peer check.
- Challenge/response (nonce + HMAC token + request hash) + short TTL.
## System events ## System events
Exec lifecycle is surfaced as system messages: Exec lifecycle is surfaced as system messages:
+22
View File
@@ -40,6 +40,28 @@ Notes:
- `tools.exec.security` (default: `deny`) - `tools.exec.security` (default: `deny`)
- `tools.exec.ask` (default: `on-miss`) - `tools.exec.ask` (default: `on-miss`)
- `tools.exec.node` (default: unset) - `tools.exec.node` (default: unset)
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
Example:
```json5
{
tools: {
exec: {
pathPrepend: ["~/bin", "/opt/oss/bin"]
}
}
}
```
### PATH handling
- `host=gateway`: uses the Gateway process `PATH`. Daemons install a minimal `PATH`:
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
Clawdbot prepends `env.PATH` after profile sourcing; `tools.exec.pathPrepend` applies here too.
- `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies
if the exec call already sets `env.PATH`.
Per-agent node binding (use the agent list index in config): Per-agent node binding (use the agent list index in config):
+1 -1
View File
@@ -356,7 +356,7 @@ Notes:
Restart or apply updates to the running Gateway process (in-place). Restart or apply updates to the running Gateway process (in-place).
Core actions: Core actions:
- `restart` (sends `SIGUSR1` to the current process; `clawdbot gateway` restart in-place) - `restart` (authorizes + sends `SIGUSR1` for in-process restart; `clawdbot gateway` restart in-place)
- `config.get` / `config.schema` - `config.get` / `config.schema`
- `config.apply` (validate + write config + restart + wake) - `config.apply` (validate + write config + restart + wake)
- `update.run` (run update + restart + wake) - `update.run` (run update + restart + wake)
+2
View File
@@ -111,6 +111,8 @@ Note on sandboxing:
- `requires.bins` is checked on the **host** at skill load time. - `requires.bins` is checked on the **host** at skill load time.
- If an agent is sandboxed, the binary must also exist **inside the container**. - If an agent is sandboxed, the binary must also exist **inside the container**.
Install it via `agents.defaults.sandbox.docker.setupCommand` (or a custom image). Install it via `agents.defaults.sandbox.docker.setupCommand` (or a custom image).
`setupCommand` runs once after the container is created.
Package installs also require network egress, a writable root FS, and a root user in the sandbox.
Example: the `summarize` skill (`skills/summarize/SKILL.md`) needs the `summarize` CLI Example: the `summarize` skill (`skills/summarize/SKILL.md`) needs the `summarize` CLI
in the sandbox container to run there. in the sandbox container to run there.
+2 -2
View File
@@ -64,7 +64,7 @@ Text + native (when enabled):
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session) - `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`) - `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`) - `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
- `/usage off|tokens|full` (per-response usage footer) - `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
- `/stop` - `/stop`
- `/restart` - `/restart`
- `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram) - `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram)
@@ -91,7 +91,7 @@ Text-only:
Notes: Notes:
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`). - Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
- For full provider usage breakdown, use `clawdbot status --usage`. - For full provider usage breakdown, use `clawdbot status --usage`.
- `/usage` controls the per-response usage footer. It only shows dollar cost when the model uses an API key (OAuth hides cost). - `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs.
- `/restart` is disabled by default; set `commands.restart: true` to enable it. - `/restart` is disabled by default; set `commands.restart: true` to enable it.
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats. - `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
+2
View File
@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { bluebubblesPlugin } from "./src/channel.js"; import { bluebubblesPlugin } from "./src/channel.js";
import { handleBlueBubblesWebhookRequest } from "./src/monitor.js"; import { handleBlueBubblesWebhookRequest } from "./src/monitor.js";
@@ -8,6 +9,7 @@ const plugin = {
id: "bluebubbles", id: "bluebubbles",
name: "BlueBubbles", name: "BlueBubbles",
description: "BlueBubbles channel plugin (macOS app)", description: "BlueBubbles channel plugin (macOS app)",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
setBlueBubblesRuntime(api.runtime); setBlueBubblesRuntime(api.runtime);
api.registerChannel({ plugin: bluebubblesPlugin }); api.registerChannel({ plugin: bluebubblesPlugin });
+10
View File
@@ -836,10 +836,20 @@ async function processMessage(
const fromLabel = message.isGroup const fromLabel = message.isGroup
? `group:${peerId}` ? `group:${peerId}`
: message.senderName || `user:${message.senderId}`; : message.senderName || `user:${message.senderId}`;
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({ const body = core.channel.reply.formatAgentEnvelope({
channel: "BlueBubbles", channel: "BlueBubbles",
from: fromLabel, from: fromLabel,
timestamp: message.timestamp, timestamp: message.timestamp,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody, body: rawBody,
}); });
let chatGuidForActions = chatGuid; let chatGuidForActions = chatGuid;
+3
View File
@@ -1,3 +1,5 @@
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
const DEFAULT_BASE_URL = "http://localhost:3000/v1"; const DEFAULT_BASE_URL = "http://localhost:3000/v1";
const DEFAULT_API_KEY = "n/a"; const DEFAULT_API_KEY = "n/a";
const DEFAULT_CONTEXT_WINDOW = 128_000; const DEFAULT_CONTEXT_WINDOW = 128_000;
@@ -61,6 +63,7 @@ const copilotProxyPlugin = {
id: "copilot-proxy", id: "copilot-proxy",
name: "Copilot Proxy", name: "Copilot Proxy",
description: "Local Copilot Proxy (VS Code LM) provider plugin", description: "Local Copilot Proxy (VS Code LM) provider plugin",
configSchema: emptyPluginConfigSchema(),
register(api) { register(api) {
api.registerProvider({ api.registerProvider({
id: "copilot-proxy", id: "copilot-proxy",
+2
View File
@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { discordPlugin } from "./src/channel.js"; import { discordPlugin } from "./src/channel.js";
import { setDiscordRuntime } from "./src/runtime.js"; import { setDiscordRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "discord", id: "discord",
name: "Discord", name: "Discord",
description: "Discord channel plugin", description: "Discord channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
setDiscordRuntime(api.runtime); setDiscordRuntime(api.runtime);
api.registerChannel({ plugin: discordPlugin }); api.registerChannel({ plugin: discordPlugin });
@@ -1,6 +1,7 @@
import { createHash, randomBytes } from "node:crypto"; import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { createServer } from "node:http"; import { createServer } from "node:http";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync // OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync
const decode = (s: string) => Buffer.from(s, "base64").toString(); const decode = (s: string) => Buffer.from(s, "base64").toString();
@@ -360,6 +361,7 @@ const antigravityPlugin = {
id: "google-antigravity-auth", id: "google-antigravity-auth",
name: "Google Antigravity Auth", name: "Google Antigravity Auth",
description: "OAuth flow for Google Antigravity (Cloud Code Assist)", description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
configSchema: emptyPluginConfigSchema(),
register(api) { register(api) {
api.registerProvider({ api.registerProvider({
id: "google-antigravity", id: "google-antigravity",
@@ -1,3 +1,5 @@
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { loginGeminiCliOAuth } from "./oauth.js"; import { loginGeminiCliOAuth } from "./oauth.js";
const PROVIDER_ID = "google-gemini-cli"; const PROVIDER_ID = "google-gemini-cli";
@@ -14,6 +16,7 @@ const geminiCliPlugin = {
id: "google-gemini-cli-auth", id: "google-gemini-cli-auth",
name: "Google Gemini CLI Auth", name: "Google Gemini CLI Auth",
description: "OAuth flow for Gemini CLI (Google Code Assist)", description: "OAuth flow for Gemini CLI (Google Code Assist)",
configSchema: emptyPluginConfigSchema(),
register(api) { register(api) {
api.registerProvider({ api.registerProvider({
id: PROVIDER_ID, id: PROVIDER_ID,
+2
View File
@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { imessagePlugin } from "./src/channel.js"; import { imessagePlugin } from "./src/channel.js";
import { setIMessageRuntime } from "./src/runtime.js"; import { setIMessageRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "imessage", id: "imessage",
name: "iMessage", name: "iMessage",
description: "iMessage channel plugin", description: "iMessage channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
setIMessageRuntime(api.runtime); setIMessageRuntime(api.runtime);
api.registerChannel({ plugin: imessagePlugin }); api.registerChannel({ plugin: imessagePlugin });
+2
View File
@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { matrixPlugin } from "./src/channel.js"; import { matrixPlugin } from "./src/channel.js";
import { setMatrixRuntime } from "./src/runtime.js"; import { setMatrixRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "matrix", id: "matrix",
name: "Matrix", name: "Matrix",
description: "Matrix channel plugin (matrix-js-sdk)", description: "Matrix channel plugin (matrix-js-sdk)",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
setMatrixRuntime(api.runtime); setMatrixRuntime(api.runtime);
api.registerChannel({ plugin: matrixPlugin }); api.registerChannel({ plugin: matrixPlugin });
+23 -17
View File
@@ -466,25 +466,34 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
isThreadRoot: event.isThreadRoot, isThreadRoot: event.isThreadRoot,
}); });
const route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "matrix",
peer: {
kind: isDirectMessage ? "dm" : "channel",
id: isDirectMessage ? senderId : roomId,
},
});
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId); const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`; const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({ const body = core.channel.reply.formatAgentEnvelope({
channel: "Matrix", channel: "Matrix",
from: envelopeFrom, from: envelopeFrom,
timestamp: event.getTs() ?? undefined, timestamp: event.getTs() ?? undefined,
previousTimestamp,
envelope: envelopeOptions,
body: textWithId, body: textWithId,
}); });
const route = core.channel.routing.resolveAgentRoute({ const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
cfg,
channel: "matrix",
peer: {
kind: isDirectMessage ? "dm" : "channel",
id: isDirectMessage ? senderId : roomId,
},
});
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({ const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body, Body: body,
RawBody: bodyText, RawBody: bodyText,
@@ -517,13 +526,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
OriginatingTo: `room:${roomId}`, OriginatingTo: `room:${roomId}`,
}); });
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { void core.channel.session.recordSessionMetaFromInbound({
agentId: route.agentId, storePath,
}); sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
void core.channel.session.recordSessionMetaFromInbound({ ctx: ctxPayload,
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
}).catch((err) => { }).catch((err) => {
logger.warn( logger.warn(
{ error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey }, { error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },
+79 -31
View File
@@ -7,6 +7,7 @@ import {
type WizardPrompter, type WizardPrompter,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
import { listMatrixDirectoryPeersLive } from "./directory-live.js";
import { resolveMatrixAccount } from "./matrix/accounts.js"; import { resolveMatrixAccount } from "./matrix/accounts.js";
import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js"; import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
import type { CoreConfig, DmPolicy } from "./types.js"; import type { CoreConfig, DmPolicy } from "./types.js";
@@ -49,40 +50,86 @@ async function promptMatrixAllowFrom(params: {
}): Promise<CoreConfig> { }): Promise<CoreConfig> {
const { cfg, prompter } = params; const { cfg, prompter } = params;
const existingAllowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? []; const existingAllowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? [];
const entry = await prompter.text({ const account = resolveMatrixAccount({ cfg });
message: "Matrix allowFrom (user id)", const canResolve = Boolean(account.configured);
placeholder: "@user:server",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
if (!raw.startsWith("@")) return "Matrix user IDs should start with @";
if (!raw.includes(":")) return "Matrix user IDs should include a server (:@server)";
return undefined;
},
});
const normalized = String(entry).trim();
const merged = [
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
normalized,
];
const unique = [...new Set(merged)];
return { const parseInput = (raw: string) =>
...cfg, raw
channels: { .split(/[\n,;]+/g)
...cfg.channels, .map((entry) => entry.trim())
matrix: { .filter(Boolean);
...cfg.channels?.matrix,
enabled: true, const isFullUserId = (value: string) => value.startsWith("@") && value.includes(":");
dm: {
...cfg.channels?.matrix?.dm, while (true) {
policy: "allowlist", const entry = await prompter.text({
allowFrom: unique, message: "Matrix allowFrom (username or user id)",
placeholder: "@user:server",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = parseInput(String(entry));
const resolvedIds: string[] = [];
let unresolved: string[] = [];
for (const part of parts) {
if (isFullUserId(part)) {
resolvedIds.push(part);
continue;
}
if (!canResolve) {
unresolved.push(part);
continue;
}
const results = await listMatrixDirectoryPeersLive({
cfg,
query: part,
limit: 5,
}).catch(() => []);
const match = results.find((result) => result.id);
if (match?.id) {
resolvedIds.push(match.id);
if (results.length > 1) {
await prompter.note(
`Multiple matches for "${part}", using ${match.id}.`,
"Matrix allowlist",
);
}
} else {
unresolved.push(part);
}
}
if (unresolved.length > 0) {
await prompter.note(
`Could not resolve: ${unresolved.join(", ")}. Use full @user:server IDs.`,
"Matrix allowlist",
);
continue;
}
const unique = [
...new Set([
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
...resolvedIds,
]),
];
return {
...cfg,
channels: {
...cfg.channels,
matrix: {
...cfg.channels?.matrix,
enabled: true,
dm: {
...cfg.channels?.matrix?.dm,
policy: "allowlist",
allowFrom: unique,
},
}, },
}, },
}, };
}; }
} }
function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") { function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") {
@@ -121,6 +168,7 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
allowFromKey: "channels.matrix.dm.allowFrom", allowFromKey: "channels.matrix.dm.allowFrom",
getCurrent: (cfg) => (cfg as CoreConfig).channels?.matrix?.dm?.policy ?? "pairing", getCurrent: (cfg) => (cfg as CoreConfig).channels?.matrix?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) => setMatrixDmPolicy(cfg as CoreConfig, policy), setPolicy: (cfg, policy) => setMatrixDmPolicy(cfg as CoreConfig, policy),
promptAllowFrom: promptMatrixAllowFrom,
}; };
export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
+2
View File
@@ -1,10 +1,12 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
const memoryCorePlugin = { const memoryCorePlugin = {
id: "memory-core", id: "memory-core",
name: "Memory (Core)", name: "Memory (Core)",
description: "File-backed memory search tools and CLI", description: "File-backed memory search tools and CLI",
kind: "memory", kind: "memory",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
api.registerTool( api.registerTool(
(ctx) => { (ctx) => {
@@ -24,6 +24,16 @@ const EMBEDDING_DIMENSIONS: Record<string, number> = {
"text-embedding-3-large": 3072, "text-embedding-3-large": 3072,
}; };
function assertAllowedKeys(
value: Record<string, unknown>,
allowed: string[],
label: string,
) {
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
if (unknown.length === 0) return;
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
}
export function vectorDimsForModel(model: string): number { export function vectorDimsForModel(model: string): number {
const dims = EMBEDDING_DIMENSIONS[model]; const dims = EMBEDDING_DIMENSIONS[model];
if (!dims) { if (!dims) {
@@ -54,11 +64,13 @@ export const memoryConfigSchema = {
throw new Error("memory config required"); throw new Error("memory config required");
} }
const cfg = value as Record<string, unknown>; const cfg = value as Record<string, unknown>;
assertAllowedKeys(cfg, ["embedding", "dbPath", "autoCapture", "autoRecall"], "memory config");
const embedding = cfg.embedding as Record<string, unknown> | undefined; const embedding = cfg.embedding as Record<string, unknown> | undefined;
if (!embedding || typeof embedding.apiKey !== "string") { if (!embedding || typeof embedding.apiKey !== "string") {
throw new Error("embedding.apiKey is required"); throw new Error("embedding.apiKey is required");
} }
assertAllowedKeys(embedding, ["apiKey", "model"], "embedding config");
const model = resolveEmbeddingModel(embedding); const model = resolveEmbeddingModel(embedding);
@@ -37,8 +37,8 @@ describeWithKey("memory plugin e2e", () => {
// Dynamic import to avoid loading LanceDB when not testing // Dynamic import to avoid loading LanceDB when not testing
const { default: memoryPlugin } = await import("./index.js"); const { default: memoryPlugin } = await import("./index.js");
expect(memoryPlugin.id).toBe("memory"); expect(memoryPlugin.id).toBe("memory-lancedb");
expect(memoryPlugin.name).toBe("Memory (Vector)"); expect(memoryPlugin.name).toBe("Memory (LanceDB)");
expect(memoryPlugin.kind).toBe("memory"); expect(memoryPlugin.kind).toBe("memory");
expect(memoryPlugin.configSchema).toBeDefined(); expect(memoryPlugin.configSchema).toBeDefined();
expect(memoryPlugin.register).toBeInstanceOf(Function); expect(memoryPlugin.register).toBeInstanceOf(Function);
@@ -185,8 +185,8 @@ describeWithKey("memory plugin live tests", () => {
const logs: string[] = []; const logs: string[] = [];
const mockApi = { const mockApi = {
id: "memory", id: "memory-lancedb",
name: "Memory (Vector)", name: "Memory (LanceDB)",
source: "test", source: "test",
config: {}, config: {},
pluginConfig: { pluginConfig: {
@@ -1,5 +1,5 @@
/** /**
* Clawdbot Memory Plugin * Clawdbot Memory (LanceDB) Plugin
* *
* Long-term memory with vector search for AI conversations. * Long-term memory with vector search for AI conversations.
* Uses LanceDB for storage and OpenAI for embeddings. * Uses LanceDB for storage and OpenAI for embeddings.
@@ -214,9 +214,9 @@ function detectCategory(text: string): MemoryCategory {
// ============================================================================ // ============================================================================
const memoryPlugin = { const memoryPlugin = {
id: "memory", id: "memory-lancedb",
name: "Memory (Vector)", name: "Memory (LanceDB)",
description: "Long-term memory with vector search and seamless auto-recall/capture", description: "LanceDB-backed long-term memory with auto-recall/capture",
kind: "memory" as const, kind: "memory" as const,
configSchema: memoryConfigSchema, configSchema: memoryConfigSchema,
@@ -227,7 +227,9 @@ const memoryPlugin = {
const db = new MemoryDB(resolvedDbPath, vectorDim); const db = new MemoryDB(resolvedDbPath, vectorDim);
const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model!); const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model!);
api.logger.info(`memory: plugin registered (db: ${resolvedDbPath}, lazy init)`); api.logger.info(
`memory-lancedb: plugin registered (db: ${resolvedDbPath}, lazy init)`,
);
// ======================================================================== // ========================================================================
// Tools // Tools
@@ -417,7 +419,7 @@ const memoryPlugin = {
({ program }) => { ({ program }) => {
const memory = program const memory = program
.command("ltm") .command("ltm")
.description("Long-term memory plugin commands"); .description("LanceDB memory plugin commands");
memory memory
.command("list") .command("list")
@@ -477,14 +479,14 @@ const memoryPlugin = {
.join("\n"); .join("\n");
api.logger.info?.( api.logger.info?.(
`memory: injecting ${results.length} memories into context`, `memory-lancedb: injecting ${results.length} memories into context`,
); );
return { return {
prependContext: `<relevant-memories>\nThe following memories may be relevant to this conversation:\n${memoryContext}\n</relevant-memories>`, prependContext: `<relevant-memories>\nThe following memories may be relevant to this conversation:\n${memoryContext}\n</relevant-memories>`,
}; };
} catch (err) { } catch (err) {
api.logger.warn(`memory: recall failed: ${String(err)}`); api.logger.warn(`memory-lancedb: recall failed: ${String(err)}`);
} }
}); });
} }
@@ -559,10 +561,10 @@ const memoryPlugin = {
} }
if (stored > 0) { if (stored > 0) {
api.logger.info(`memory: auto-captured ${stored} memories`); api.logger.info(`memory-lancedb: auto-captured ${stored} memories`);
} }
} catch (err) { } catch (err) {
api.logger.warn(`memory: capture failed: ${String(err)}`); api.logger.warn(`memory-lancedb: capture failed: ${String(err)}`);
} }
}); });
} }
@@ -572,14 +574,14 @@ const memoryPlugin = {
// ======================================================================== // ========================================================================
api.registerService({ api.registerService({
id: "memory", id: "memory-lancedb",
start: () => { start: () => {
api.logger.info( api.logger.info(
`memory: initialized (db: ${resolvedDbPath}, model: ${cfg.embedding.model})`, `memory-lancedb: initialized (db: ${resolvedDbPath}, model: ${cfg.embedding.model})`,
); );
}, },
stop: () => { stop: () => {
api.logger.info("memory: stopped"); api.logger.info("memory-lancedb: stopped");
}, },
}); });
}, },
@@ -1,8 +1,8 @@
{ {
"name": "@clawdbot/memory", "name": "@clawdbot/memory-lancedb",
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
"description": "Clawdbot long-term memory plugin with vector search and seamless auto-recall/capture", "description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
"dependencies": { "dependencies": {
"@lancedb/lancedb": "^0.15.0", "@lancedb/lancedb": "^0.15.0",
"@sinclair/typebox": "0.34.47", "@sinclair/typebox": "0.34.47",
+2
View File
@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { msteamsPlugin } from "./src/channel.js"; import { msteamsPlugin } from "./src/channel.js";
import { setMSTeamsRuntime } from "./src/runtime.js"; import { setMSTeamsRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "msteams", id: "msteams",
name: "Microsoft Teams", name: "Microsoft Teams",
description: "Microsoft Teams channel plugin (Bot Framework)", description: "Microsoft Teams channel plugin (Bot Framework)",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
setMSTeamsRuntime(api.runtime); setMSTeamsRuntime(api.runtime);
api.registerChannel({ plugin: msteamsPlugin }); api.registerChannel({ plugin: msteamsPlugin });
@@ -406,10 +406,20 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const mediaPayload = buildMSTeamsMediaPayload(mediaList); const mediaPayload = buildMSTeamsMediaPayload(mediaList);
const envelopeFrom = isDirectMessage ? senderName : conversationType; const envelopeFrom = isDirectMessage ? senderName : conversationType;
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({ const body = core.channel.reply.formatAgentEnvelope({
channel: "Teams", channel: "Teams",
from: envelopeFrom, from: envelopeFrom,
timestamp, timestamp,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody, body: rawBody,
}); });
let combinedBody = body; let combinedBody = body;
@@ -421,15 +431,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
historyKey, historyKey,
limit: historyLimit, limit: historyLimit,
currentMessage: combinedBody, currentMessage: combinedBody,
formatEntry: (entry) => formatEntry: (entry) =>
core.channel.reply.formatAgentEnvelope({ core.channel.reply.formatAgentEnvelope({
channel: "Teams", channel: "Teams",
from: conversationType, from: conversationType,
timestamp: entry.timestamp, timestamp: entry.timestamp,
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`, body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
}), envelope: envelopeOptions,
}); }),
} });
}
const ctxPayload = core.channel.reply.finalizeInboundContext({ const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: combinedBody, Body: combinedBody,
@@ -455,11 +466,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
...mediaPayload, ...mediaPayload,
}); });
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { void core.channel.session.recordSessionMetaFromInbound({
agentId: route.agentId, storePath,
});
void core.channel.session.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey, sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload, ctx: ctxPayload,
}).catch((err) => { }).catch((err) => {
+93
View File
@@ -16,6 +16,7 @@ import { resolveMSTeamsCredentials } from "./token.js";
import { import {
parseMSTeamsTeamEntry, parseMSTeamsTeamEntry,
resolveMSTeamsChannelAllowlist, resolveMSTeamsChannelAllowlist,
resolveMSTeamsUserAllowlist,
} from "./resolve-allowlist.js"; } from "./resolve-allowlist.js";
const channel = "msteams" as const; const channel = "msteams" as const;
@@ -38,6 +39,97 @@ function setMSTeamsDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
}; };
} }
function setMSTeamsAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
return {
...cfg,
channels: {
...cfg.channels,
msteams: {
...cfg.channels?.msteams,
allowFrom,
},
},
};
}
function parseAllowFromInput(raw: string): string[] {
return raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
function looksLikeGuid(value: string): boolean {
return /^[0-9a-fA-F-]{16,}$/.test(value);
}
async function promptMSTeamsAllowFrom(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
}): Promise<ClawdbotConfig> {
const existing = params.cfg.channels?.msteams?.allowFrom ?? [];
await params.prompter.note(
[
"Allowlist MS Teams DMs by display name, UPN/email, or user id.",
"We resolve names to user IDs via Microsoft Graph when credentials allow.",
"Examples:",
"- alex@example.com",
"- Alex Johnson",
"- 00000000-0000-0000-0000-000000000000",
].join("\n"),
"MS Teams allowlist",
);
while (true) {
const entry = await params.prompter.text({
message: "MS Teams allowFrom (usernames or ids)",
placeholder: "alex@example.com, Alex Johnson",
initialValue: existing[0] ? String(existing[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = parseAllowFromInput(String(entry));
if (parts.length === 0) {
await params.prompter.note("Enter at least one user.", "MS Teams allowlist");
continue;
}
const resolved = await resolveMSTeamsUserAllowlist({
cfg: params.cfg,
entries: parts,
}).catch(() => null);
if (!resolved) {
const ids = parts.filter((part) => looksLikeGuid(part));
if (ids.length !== parts.length) {
await params.prompter.note(
"Graph lookup unavailable. Use user IDs only.",
"MS Teams allowlist",
);
continue;
}
const unique = [
...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids]),
];
return setMSTeamsAllowFrom(params.cfg, unique);
}
const unresolved = resolved.filter((item) => !item.resolved || !item.id);
if (unresolved.length > 0) {
await params.prompter.note(
`Could not resolve: ${unresolved.map((item) => item.input).join(", ")}`,
"MS Teams allowlist",
);
continue;
}
const ids = resolved.map((item) => item.id as string);
const unique = [
...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids]),
];
return setMSTeamsAllowFrom(params.cfg, unique);
}
}
async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise<void> { async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note( await prompter.note(
[ [
@@ -106,6 +198,7 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
allowFromKey: "channels.msteams.allowFrom", allowFromKey: "channels.msteams.allowFrom",
getCurrent: (cfg) => cfg.channels?.msteams?.dmPolicy ?? "pairing", getCurrent: (cfg) => cfg.channels?.msteams?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setMSTeamsDmPolicy(cfg, policy), setPolicy: (cfg, policy) => setMSTeamsDmPolicy(cfg, policy),
promptAllowFrom: promptMSTeamsAllowFrom,
}; };
export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = { export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = {
+3
View File
@@ -1,3 +1,5 @@
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { loginQwenPortalOAuth } from "./oauth.js"; import { loginQwenPortalOAuth } from "./oauth.js";
const PROVIDER_ID = "qwen-portal"; const PROVIDER_ID = "qwen-portal";
@@ -30,6 +32,7 @@ const qwenPortalPlugin = {
id: "qwen-portal-auth", id: "qwen-portal-auth",
name: "Qwen OAuth", name: "Qwen OAuth",
description: "OAuth flow for Qwen (free-tier) models", description: "OAuth flow for Qwen (free-tier) models",
configSchema: emptyPluginConfigSchema(),
register(api) { register(api) {
api.registerProvider({ api.registerProvider({
id: PROVIDER_ID, id: PROVIDER_ID,
+2
View File
@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { signalPlugin } from "./src/channel.js"; import { signalPlugin } from "./src/channel.js";
import { setSignalRuntime } from "./src/runtime.js"; import { setSignalRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "signal", id: "signal",
name: "Signal", name: "Signal",
description: "Signal channel plugin", description: "Signal channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
setSignalRuntime(api.runtime); setSignalRuntime(api.runtime);
api.registerChannel({ plugin: signalPlugin }); api.registerChannel({ plugin: signalPlugin });
+2
View File
@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { slackPlugin } from "./src/channel.js"; import { slackPlugin } from "./src/channel.js";
import { setSlackRuntime } from "./src/runtime.js"; import { setSlackRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "slack", id: "slack",
name: "Slack", name: "Slack",
description: "Slack channel plugin", description: "Slack channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
setSlackRuntime(api.runtime); setSlackRuntime(api.runtime);
api.registerChannel({ plugin: slackPlugin }); api.registerChannel({ plugin: slackPlugin });
+2
View File
@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { telegramPlugin } from "./src/channel.js"; import { telegramPlugin } from "./src/channel.js";
import { setTelegramRuntime } from "./src/runtime.js"; import { setTelegramRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "telegram", id: "telegram",
name: "Telegram", name: "Telegram",
description: "Telegram channel plugin", description: "Telegram channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
setTelegramRuntime(api.runtime); setTelegramRuntime(api.runtime);
api.registerChannel({ plugin: telegramPlugin }); api.registerChannel({ plugin: telegramPlugin });
+23 -8
View File
@@ -35,30 +35,36 @@ export type InboundPolicy = z.infer<typeof InboundPolicySchema>;
// Provider-Specific Configuration // Provider-Specific Configuration
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const TelnyxConfigSchema = z.object({ export const TelnyxConfigSchema = z
.object({
/** Telnyx API v2 key */ /** Telnyx API v2 key */
apiKey: z.string().min(1).optional(), apiKey: z.string().min(1).optional(),
/** Telnyx connection ID (from Call Control app) */ /** Telnyx connection ID (from Call Control app) */
connectionId: z.string().min(1).optional(), connectionId: z.string().min(1).optional(),
/** Public key for webhook signature verification */ /** Public key for webhook signature verification */
publicKey: z.string().min(1).optional(), publicKey: z.string().min(1).optional(),
}); })
.strict();
export type TelnyxConfig = z.infer<typeof TelnyxConfigSchema>; export type TelnyxConfig = z.infer<typeof TelnyxConfigSchema>;
export const TwilioConfigSchema = z.object({ export const TwilioConfigSchema = z
.object({
/** Twilio Account SID */ /** Twilio Account SID */
accountSid: z.string().min(1).optional(), accountSid: z.string().min(1).optional(),
/** Twilio Auth Token */ /** Twilio Auth Token */
authToken: z.string().min(1).optional(), authToken: z.string().min(1).optional(),
}); })
.strict();
export type TwilioConfig = z.infer<typeof TwilioConfigSchema>; export type TwilioConfig = z.infer<typeof TwilioConfigSchema>;
export const PlivoConfigSchema = z.object({ export const PlivoConfigSchema = z
.object({
/** Plivo Auth ID (starts with MA/SA) */ /** Plivo Auth ID (starts with MA/SA) */
authId: z.string().min(1).optional(), authId: z.string().min(1).optional(),
/** Plivo Auth Token */ /** Plivo Auth Token */
authToken: z.string().min(1).optional(), authToken: z.string().min(1).optional(),
}); })
.strict();
export type PlivoConfig = z.infer<typeof PlivoConfigSchema>; export type PlivoConfig = z.infer<typeof PlivoConfigSchema>;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -72,6 +78,7 @@ export const SttConfigSchema = z
/** Whisper model to use */ /** Whisper model to use */
model: z.string().min(1).default("whisper-1"), model: z.string().min(1).default("whisper-1"),
}) })
.strict()
.default({ provider: "openai", model: "whisper-1" }); .default({ provider: "openai", model: "whisper-1" });
export type SttConfig = z.infer<typeof SttConfigSchema>; export type SttConfig = z.infer<typeof SttConfigSchema>;
@@ -97,6 +104,7 @@ export const TtsConfigSchema = z
*/ */
instructions: z.string().optional(), instructions: z.string().optional(),
}) })
.strict()
.default({ provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" }); .default({ provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" });
export type TtsConfig = z.infer<typeof TtsConfigSchema>; export type TtsConfig = z.infer<typeof TtsConfigSchema>;
@@ -113,6 +121,7 @@ export const VoiceCallServeConfigSchema = z
/** Webhook path */ /** Webhook path */
path: z.string().min(1).default("/voice/webhook"), path: z.string().min(1).default("/voice/webhook"),
}) })
.strict()
.default({ port: 3334, bind: "127.0.0.1", path: "/voice/webhook" }); .default({ port: 3334, bind: "127.0.0.1", path: "/voice/webhook" });
export type VoiceCallServeConfig = z.infer<typeof VoiceCallServeConfigSchema>; export type VoiceCallServeConfig = z.infer<typeof VoiceCallServeConfigSchema>;
@@ -128,6 +137,7 @@ export const VoiceCallTailscaleConfigSchema = z
/** Path for Tailscale serve/funnel (should usually match serve.path) */ /** Path for Tailscale serve/funnel (should usually match serve.path) */
path: z.string().min(1).default("/voice/webhook"), path: z.string().min(1).default("/voice/webhook"),
}) })
.strict()
.default({ mode: "off", path: "/voice/webhook" }); .default({ mode: "off", path: "/voice/webhook" });
export type VoiceCallTailscaleConfig = z.infer< export type VoiceCallTailscaleConfig = z.infer<
typeof VoiceCallTailscaleConfigSchema typeof VoiceCallTailscaleConfigSchema
@@ -161,6 +171,7 @@ export const VoiceCallTunnelConfigSchema = z
*/ */
allowNgrokFreeTier: z.boolean().default(true), allowNgrokFreeTier: z.boolean().default(true),
}) })
.strict()
.default({ provider: "none", allowNgrokFreeTier: true }); .default({ provider: "none", allowNgrokFreeTier: true });
export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>; export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>;
@@ -183,6 +194,7 @@ export const OutboundConfigSchema = z
/** Seconds to wait after TTS before auto-hangup in notify mode */ /** Seconds to wait after TTS before auto-hangup in notify mode */
notifyHangupDelaySec: z.number().int().nonnegative().default(3), notifyHangupDelaySec: z.number().int().nonnegative().default(3),
}) })
.strict()
.default({ defaultMode: "notify", notifyHangupDelaySec: 3 }); .default({ defaultMode: "notify", notifyHangupDelaySec: 3 });
export type OutboundConfig = z.infer<typeof OutboundConfigSchema>; export type OutboundConfig = z.infer<typeof OutboundConfigSchema>;
@@ -207,6 +219,7 @@ export const VoiceCallStreamingConfigSchema = z
/** WebSocket path for media stream connections */ /** WebSocket path for media stream connections */
streamPath: z.string().min(1).default("/voice/stream"), streamPath: z.string().min(1).default("/voice/stream"),
}) })
.strict()
.default({ .default({
enabled: false, enabled: false,
sttProvider: "openai-realtime", sttProvider: "openai-realtime",
@@ -223,7 +236,8 @@ export type VoiceCallStreamingConfig = z.infer<
// Main Voice Call Configuration // Main Voice Call Configuration
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const VoiceCallConfigSchema = z.object({ export const VoiceCallConfigSchema = z
.object({
/** Enable voice call functionality */ /** Enable voice call functionality */
enabled: z.boolean().default(false), enabled: z.boolean().default(false),
@@ -307,7 +321,8 @@ export const VoiceCallConfigSchema = z.object({
/** Timeout for response generation in ms (default 30s) */ /** Timeout for response generation in ms (default 30s) */
responseTimeoutMs: z.number().int().positive().default(30000), responseTimeoutMs: z.number().int().positive().default(30000),
}); })
.strict();
export type VoiceCallConfig = z.infer<typeof VoiceCallConfigSchema>; export type VoiceCallConfig = z.infer<typeof VoiceCallConfigSchema>;
Loaded 100 of 481 files, more files were not shown because too many files have changed in this diff. Show more