Compare commits

..
347 changed files with 4723 additions and 22859 deletions

No files matched your search

-3
View File
@@ -66,6 +66,3 @@ apps/ios/*.mobileprovision
IDENTITY.md IDENTITY.md
USER.md USER.md
.tgz .tgz
# local tooling
.serena/
+1
View File
@@ -0,0 +1 @@
/cache
Binary file not shown.
Binary file not shown.
+87
View File
@@ -0,0 +1,87 @@
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp csharp_omnisharp
# dart elixir elm erlang fortran fsharp
# go groovy haskell java julia kotlin
# lua markdown nix pascal perl php
# powershell python python_jedi r rego ruby
# ruby_solargraph rust scala swift terraform toml
# typescript typescript_vts yaml zig
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal / Lazarus, use pascal
# Special requirements:
# - csharp: Requires the presence of a .sln file in the project folder.
# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus.
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- typescript
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "clawdbot"
included_optional_tools: []
-5
View File
@@ -41,11 +41,6 @@
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability. - Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
- Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` for CLI command, package/binary, paths, and config keys. - Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` for CLI command, package/binary, paths, and config keys.
## Release Channels (Naming)
- stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`.
- beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app).
- dev: moving head on `main` (no tag; git checkout main).
## Testing Guidelines ## Testing Guidelines
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements). - Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`. - Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
+2 -28
View File
@@ -2,44 +2,22 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 2026.1.20 ## 2026.1.20-1
### Changes ### Changes
- Deps: update workspace + memory-lancedb dependencies.
- Repo: remove the Peekaboo git submodule now that the SPM release is used. - Repo: remove the Peekaboo git submodule now that the SPM release is used.
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
- Channels: add the Nostr plugin channel with profile management + onboarding install defaults. (#1323) — thanks @joelklabo.
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow. - Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel. - Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
- Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow.
- Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0. - Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0.
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl. - Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete. - Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
- Security: warn when <=300B models run without sandboxing and with web tools enabled.
### Fixes ### Fixes
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
- Doctor: clarify plugin auto-enable hint text in the startup banner.
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter). - Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- Config: allow Perplexity as a web_search provider in config validation. (#1230, #1247) — thanks @sebslight.
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs. - TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
- CLI: avoid duplicating --profile/--dev flags when formatting commands. - CLI: avoid duplicating --profile/--dev flags when formatting commands.
- CLI: load channel plugins for commands that need registry-backed lookups. (#1338) — thanks @MaudeBot.
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander. - Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander. - Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
- Anthropic: default API prompt caching to 1h with configurable TTL override; ignore TTL for OAuth.
- Discord: make resolve warnings avoid raw JSON payloads on rate limits.
- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)
- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
## 2026.1.19-3 ## 2026.1.19-3
@@ -52,8 +30,6 @@ Docs: https://docs.clawd.bot
### Fixes ### Fixes
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean. - Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest. - UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
- Browser: register AI snapshot refs for act commands. (#1282) — thanks @John-Rood.
## 2026.1.19-2 ## 2026.1.19-2
@@ -76,7 +52,6 @@ Docs: https://docs.clawd.bot
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting. - Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
- Agents: clarify node_modules read-only guidance in agent instructions. - Agents: clarify node_modules read-only guidance in agent instructions.
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07. - TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) — thanks @Whoaa512.
### Fixes ### Fixes
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba. - UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
@@ -89,7 +64,6 @@ Docs: https://docs.clawd.bot
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07. - 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) - 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. - CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226) — thanks @VACInc. (fixes #1225)
## 2026.1.18-5 ## 2026.1.18-5
+20 -29
View File
@@ -71,15 +71,6 @@ clawdbot agent --message "Ship checklist" --thinking high
Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`). Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`).
## Development channels
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`), npm dist-tag `latest`.
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing).
- **dev**: moving head of `main`, npm dist-tag `dev` (when published).
Switch channels (git + npm): `clawdbot update --channel stable|beta|dev`.
Details: [Development channels](https://docs.clawd.bot/install/development-channels).
## From source (development) ## From source (development)
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly. Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
@@ -482,24 +473,24 @@ Thanks to all clawtributors:
<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/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/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/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/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/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/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/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/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/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/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/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></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/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/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/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/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/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/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/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/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/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/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/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></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/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/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/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/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/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/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/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/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></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/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/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/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/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></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/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></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/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></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/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/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></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/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></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/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></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/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/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/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/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/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/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/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/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/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/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></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/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></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/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/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></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/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></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/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/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></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/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/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/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/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/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/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/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/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></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/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/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></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/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></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/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/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></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/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></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/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></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/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/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></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/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/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/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/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/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/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/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/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></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/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></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/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/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/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/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=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></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=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></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/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/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/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/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/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/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/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></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/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/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/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=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/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=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/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=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=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/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></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=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/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></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=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> <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>
+5 -5
View File
@@ -1,13 +1,13 @@
{ {
"originHash" : "c0677e232394b5f6b0191b6dbb5bae553d55264f65ae725cd03a8ffdfda9cdd3", "originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748",
"pins" : [ "pins" : [
{ {
"identity" : "commander", "identity" : "elevenlabskit",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander.git", "location" : "https://github.com/steipete/ElevenLabsKit",
"state" : { "state" : {
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce", "revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
"version" : "0.2.1" "version" : "0.1.0"
} }
}, },
{ {
+1 -1
View File
@@ -1,6 +1,6 @@
## Clawdbot Node (Android) (internal) ## Clawdbot Node (Android) (internal)
Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gw._tcp`) and exposes **Canvas + Chat + Camera**. Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gateway._tcp`) and exposes **Canvas + Chat + Camera**.
Notes: Notes:
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action). - The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "com.clawdbot.android" applicationId = "com.clawdbot.android"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 202601200 versionCode = 202601114
versionName = "2026.1.20" versionName = "2026.1.11-4"
} }
buildTypes { buildTypes {
@@ -12,7 +12,6 @@ import com.clawdbot.android.chat.ChatMessage
import com.clawdbot.android.chat.ChatPendingToolCall import com.clawdbot.android.chat.ChatPendingToolCall
import com.clawdbot.android.chat.ChatSessionEntry import com.clawdbot.android.chat.ChatSessionEntry
import com.clawdbot.android.chat.OutgoingAttachment import com.clawdbot.android.chat.OutgoingAttachment
import com.clawdbot.android.gateway.DeviceAuthStore
import com.clawdbot.android.gateway.DeviceIdentityStore import com.clawdbot.android.gateway.DeviceIdentityStore
import com.clawdbot.android.gateway.GatewayClientInfo import com.clawdbot.android.gateway.GatewayClientInfo
import com.clawdbot.android.gateway.GatewayConnectOptions import com.clawdbot.android.gateway.GatewayConnectOptions
@@ -63,7 +62,6 @@ class NodeRuntime(context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val prefs = SecurePrefs(appContext) val prefs = SecurePrefs(appContext)
private val deviceAuthStore = DeviceAuthStore(prefs)
val canvas = CanvasController() val canvas = CanvasController()
val camera = CameraCaptureManager(appContext) val camera = CameraCaptureManager(appContext)
val location = LocationCaptureManager(appContext) val location = LocationCaptureManager(appContext)
@@ -155,7 +153,6 @@ class NodeRuntime(context: Context) {
GatewaySession( GatewaySession(
scope = scope, scope = scope,
identityStore = identityStore, identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { name, remote, mainSessionKey -> onConnected = { name, remote, mainSessionKey ->
operatorConnected = true operatorConnected = true
operatorStatusText = "Connected" operatorStatusText = "Connected"
@@ -191,7 +188,6 @@ class NodeRuntime(context: Context) {
GatewaySession( GatewaySession(
scope = scope, scope = scope,
identityStore = identityStore, identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ -> onConnected = { _, _, _ ->
nodeConnected = true nodeConnected = true
nodeStatusText = "Connected" nodeStatusText = "Connected"
@@ -189,18 +189,6 @@ class SecurePrefs(context: Context) {
prefs.edit { putString(key, fingerprint.trim()) } prefs.edit { putString(key, fingerprint.trim()) }
} }
fun getString(key: String): String? {
return prefs.getString(key, null)
}
fun putString(key: String, value: String) {
prefs.edit { putString(key, value) }
}
fun remove(key: String) {
prefs.edit { remove(key) }
}
private fun loadOrCreateInstanceId(): String { private fun loadOrCreateInstanceId(): String {
val existing = prefs.getString("node.instanceId", null)?.trim() val existing = prefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing if (!existing.isNullOrBlank()) return existing
@@ -1,26 +0,0 @@
package com.clawdbot.android.gateway
import com.clawdbot.android.SecurePrefs
class DeviceAuthStore(private val prefs: SecurePrefs) {
fun loadToken(deviceId: String, role: String): String? {
val key = tokenKey(deviceId, role)
return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() }
}
fun saveToken(deviceId: String, role: String, token: String) {
val key = tokenKey(deviceId, role)
prefs.putString(key, token.trim())
}
fun clearToken(deviceId: String, role: String) {
val key = tokenKey(deviceId, role)
prefs.remove(key)
}
private fun tokenKey(deviceId: String, role: String): String {
val normalizedDevice = deviceId.trim().lowercase()
val normalizedRole = role.trim().lowercase()
return "gateway.deviceToken.$normalizedDevice.$normalizedRole"
}
}
@@ -51,7 +51,7 @@ class GatewayDiscovery(
private val nsd = context.getSystemService(NsdManager::class.java) private val nsd = context.getSystemService(NsdManager::class.java)
private val connectivity = context.getSystemService(ConnectivityManager::class.java) private val connectivity = context.getSystemService(ConnectivityManager::class.java)
private val dns = DnsResolver.getInstance() private val dns = DnsResolver.getInstance()
private val serviceType = "_clawdbot-gw._tcp." private val serviceType = "_clawdbot-gateway._tcp."
private val wideAreaDomain = "clawdbot.internal." private val wideAreaDomain = "clawdbot.internal."
private val logTag = "Clawdbot/GatewayDiscovery" private val logTag = "Clawdbot/GatewayDiscovery"
@@ -55,7 +55,6 @@ data class GatewayConnectOptions(
class GatewaySession( class GatewaySession(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val identityStore: DeviceIdentityStore, private val identityStore: DeviceIdentityStore,
private val deviceAuthStore: DeviceAuthStore,
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit, private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
private val onDisconnected: (message: String) -> Unit, private val onDisconnected: (message: String) -> Unit,
private val onEvent: (event: String, payloadJson: String?) -> Unit, private val onEvent: (event: String, payloadJson: String?) -> Unit,
@@ -178,7 +177,6 @@ class GatewaySession(
private val connectDeferred = CompletableDeferred<Unit>() private val connectDeferred = CompletableDeferred<Unit>()
private val closedDeferred = CompletableDeferred<Unit>() private val closedDeferred = CompletableDeferred<Unit>()
private val isClosed = AtomicBoolean(false) private val isClosed = AtomicBoolean(false)
private val connectNonceDeferred = CompletableDeferred<String?>()
private val client: OkHttpClient = buildClient() private val client: OkHttpClient = buildClient()
private var socket: WebSocket? = null private var socket: WebSocket? = null
private val loggerTag = "ClawdbotGateway" private val loggerTag = "ClawdbotGateway"
@@ -255,8 +253,7 @@ class GatewaySession(
override fun onOpen(webSocket: WebSocket, response: Response) { override fun onOpen(webSocket: WebSocket, response: Response) {
scope.launch { scope.launch {
try { try {
val nonce = awaitConnectNonce() sendConnect()
sendConnect(nonce)
} catch (err: Throwable) { } catch (err: Throwable) {
connectDeferred.completeExceptionally(err) connectDeferred.completeExceptionally(err)
closeQuietly() closeQuietly()
@@ -291,30 +288,16 @@ class GatewaySession(
} }
} }
private suspend fun sendConnect(connectNonce: String?) { private suspend fun sendConnect() {
val identity = identityStore.loadOrCreate() val payload = buildConnectParams()
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
val trimmedToken = token?.trim().orEmpty()
val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken
val canFallbackToShared = !storedToken.isNullOrBlank() && trimmedToken.isNotBlank()
val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim())
val res = request("connect", payload, timeoutMs = 8_000) val res = request("connect", payload, timeoutMs = 8_000)
if (!res.ok) { if (!res.ok) {
val msg = res.error?.message ?: "connect failed" val msg = res.error?.message ?: "connect failed"
if (canFallbackToShared) {
deviceAuthStore.clearToken(identity.deviceId, options.role)
}
throw IllegalStateException(msg) throw IllegalStateException(msg)
} }
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload") val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed") val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull() val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
val authObj = obj["auth"].asObjectOrNull()
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
if (!deviceToken.isNullOrBlank()) {
deviceAuthStore.saveToken(identity.deviceId, authRole, deviceToken)
}
val rawCanvas = obj["canvasHostUrl"].asStringOrNull() val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint) canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint)
val sessionDefaults = val sessionDefaults =
@@ -325,12 +308,7 @@ class GatewaySession(
connectDeferred.complete(Unit) connectDeferred.complete(Unit)
} }
private fun buildConnectParams( private fun buildConnectParams(): JsonObject {
identity: DeviceIdentity,
connectNonce: String?,
authToken: String,
authPassword: String?,
): JsonObject {
val client = options.client val client = options.client
val locale = Locale.getDefault().toLanguageTag() val locale = Locale.getDefault().toLanguageTag()
val clientObj = val clientObj =
@@ -345,20 +323,22 @@ class GatewaySession(
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) } client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
} }
val password = authPassword?.trim().orEmpty() val authToken = token?.trim().orEmpty()
val authPassword = password?.trim().orEmpty()
val authJson = val authJson =
when { when {
authToken.isNotEmpty() -> authToken.isNotEmpty() ->
buildJsonObject { buildJsonObject {
put("token", JsonPrimitive(authToken)) put("token", JsonPrimitive(authToken))
} }
password.isNotEmpty() -> authPassword.isNotEmpty() ->
buildJsonObject { buildJsonObject {
put("password", JsonPrimitive(password)) put("password", JsonPrimitive(authPassword))
} }
else -> null else -> null
} }
val identity = identityStore.loadOrCreate()
val signedAtMs = System.currentTimeMillis() val signedAtMs = System.currentTimeMillis()
val payload = val payload =
buildDeviceAuthPayload( buildDeviceAuthPayload(
@@ -369,7 +349,6 @@ class GatewaySession(
scopes = options.scopes, scopes = options.scopes,
signedAtMs = signedAtMs, signedAtMs = signedAtMs,
token = if (authToken.isNotEmpty()) authToken else null, token = if (authToken.isNotEmpty()) authToken else null,
nonce = connectNonce,
) )
val signature = identityStore.signPayload(payload, identity) val signature = identityStore.signPayload(payload, identity)
val publicKey = identityStore.publicKeyBase64Url(identity) val publicKey = identityStore.publicKeyBase64Url(identity)
@@ -380,9 +359,6 @@ class GatewaySession(
put("publicKey", JsonPrimitive(publicKey)) put("publicKey", JsonPrimitive(publicKey))
put("signature", JsonPrimitive(signature)) put("signature", JsonPrimitive(signature))
put("signedAt", JsonPrimitive(signedAtMs)) put("signedAt", JsonPrimitive(signedAtMs))
if (!connectNonce.isNullOrBlank()) {
put("nonce", JsonPrimitive(connectNonce))
}
} }
} else { } else {
null null
@@ -440,13 +416,6 @@ class GatewaySession(
val event = frame["event"].asStringOrNull() ?: return val event = frame["event"].asStringOrNull() ?: return
val payloadJson = val payloadJson =
frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull() frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull()
if (event == "connect.challenge") {
val nonce = extractConnectNonce(payloadJson)
if (!connectNonceDeferred.isCompleted) {
connectNonceDeferred.complete(nonce)
}
return
}
if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) { if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) {
handleInvokeEvent(payloadJson) handleInvokeEvent(payloadJson)
return return
@@ -454,21 +423,6 @@ class GatewaySession(
onEvent(event, payloadJson) onEvent(event, payloadJson)
} }
private suspend fun awaitConnectNonce(): String? {
if (isLoopbackHost(endpoint.host)) return null
return try {
withTimeout(2_000) { connectNonceDeferred.await() }
} catch (_: Throwable) {
null
}
}
private fun extractConnectNonce(payloadJson: String?): String? {
if (payloadJson.isNullOrBlank()) return null
val obj = parseJsonOrNull(payloadJson)?.asObjectOrNull() ?: return null
return obj["nonce"].asStringOrNull()
}
private fun handleInvokeEvent(payloadJson: String) { private fun handleInvokeEvent(payloadJson: String) {
val payload = val payload =
try { try {
@@ -590,26 +544,19 @@ class GatewaySession(
scopes: List<String>, scopes: List<String>,
signedAtMs: Long, signedAtMs: Long,
token: String?, token: String?,
nonce: String?,
): String { ): String {
val scopeString = scopes.joinToString(",") val scopeString = scopes.joinToString(",")
val authToken = token.orEmpty() val authToken = token.orEmpty()
val version = if (nonce.isNullOrBlank()) "v1" else "v2" return listOf(
val parts = "v1",
mutableListOf( deviceId,
version, clientId,
deviceId, clientMode,
clientId, role,
clientMode, scopeString,
role, signedAtMs.toString(),
scopeString, authToken,
signedAtMs.toString(), ).joinToString("|")
authToken,
)
if (!nonce.isNullOrBlank()) {
parts.add(nonce)
}
return parts.joinToString("|")
} }
private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? { private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? {
@@ -84,7 +84,5 @@ private fun sha256Hex(data: ByteArray): String {
} }
private fun normalizeFingerprint(raw: String): String { private fun normalizeFingerprint(raw: String): String {
val stripped = raw.trim() return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
.replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "")
return stripped.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
} }
+3 -3
View File
@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.20</string> <string>2026.1.11-4</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>20260120</string> <string>202601113</string>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoadsInWebContent</key> <key>NSAllowsArbitraryLoadsInWebContent</key>
@@ -29,7 +29,7 @@
</dict> </dict>
<key>NSBonjourServices</key> <key>NSBonjourServices</key>
<array> <array>
<string>_clawdbot-gw._tcp</string> <string>_clawdbot-gateway._tcp</string>
</array> </array>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>Clawdbot can capture photos or short video clips when requested via the gateway.</string> <string>Clawdbot can capture photos or short video clips when requested via the gateway.</string>
+9 -8
View File
@@ -1,13 +1,15 @@
Sources/Gateway/GatewayConnectionController.swift Sources/Bridge/BridgeClient.swift
Sources/Gateway/GatewayDiscoveryDebugLogView.swift Sources/Bridge/BridgeConnectionController.swift
Sources/Gateway/GatewayDiscoveryModel.swift Sources/Bridge/BridgeDiscoveryDebugLogView.swift
Sources/Gateway/GatewaySettingsStore.swift Sources/Bridge/BridgeDiscoveryModel.swift
Sources/Gateway/KeychainStore.swift Sources/Bridge/BridgeEndpointID.swift
Sources/Bridge/BridgeSession.swift
Sources/Bridge/BridgeSettingsStore.swift
Sources/Bridge/KeychainStore.swift
Sources/Camera/CameraController.swift Sources/Camera/CameraController.swift
Sources/Chat/ChatSheet.swift Sources/Chat/ChatSheet.swift
Sources/Chat/IOSGatewayChatTransport.swift Sources/Chat/IOSBridgeChatTransport.swift
Sources/ClawdbotApp.swift Sources/ClawdbotApp.swift
Sources/Location/LocationService.swift
Sources/Model/NodeAppModel.swift Sources/Model/NodeAppModel.swift
Sources/RootCanvas.swift Sources/RootCanvas.swift
Sources/RootTabs.swift Sources/RootTabs.swift
@@ -15,7 +17,6 @@ Sources/Screen/ScreenController.swift
Sources/Screen/ScreenRecordService.swift Sources/Screen/ScreenRecordService.swift
Sources/Screen/ScreenTab.swift Sources/Screen/ScreenTab.swift
Sources/Screen/ScreenWebView.swift Sources/Screen/ScreenWebView.swift
Sources/SessionKey.swift
Sources/Settings/SettingsNetworkingHelpers.swift Sources/Settings/SettingsNetworkingHelpers.swift
Sources/Settings/SettingsTab.swift Sources/Settings/SettingsTab.swift
Sources/Settings/VoiceWakeWordsSettingsView.swift Sources/Settings/VoiceWakeWordsSettingsView.swift
+3 -3
View File
@@ -7,11 +7,11 @@ import Testing
@Test func stableIDForServiceDecodesAndNormalizesName() { @Test func stableIDForServiceDecodesAndNormalizesName() {
let endpoint = NWEndpoint.service( let endpoint = NWEndpoint.service(
name: "Clawdbot\\032Gateway \\032 Node\n", name: "Clawdbot\\032Gateway \\032 Node\n",
type: "_clawdbot-gw._tcp", type: "_clawdbot-gateway._tcp",
domain: "local.", domain: "local.",
interface: nil) interface: nil)
#expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gw._tcp|local.|Clawdbot Gateway Node") #expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gateway._tcp|local.|Clawdbot Gateway Node")
} }
@Test func stableIDForNonServiceUsesEndpointDescription() { @Test func stableIDForNonServiceUsesEndpointDescription() {
@@ -22,7 +22,7 @@ import Testing
@Test func prettyDescriptionDecodesBonjourEscapes() { @Test func prettyDescriptionDecodesBonjourEscapes() {
let endpoint = NWEndpoint.service( let endpoint = NWEndpoint.service(
name: "Clawdbot\\032Gateway", name: "Clawdbot\\032Gateway",
type: "_clawdbot-gw._tcp", type: "_clawdbot-gateway._tcp",
domain: "local.", domain: "local.",
interface: nil) interface: nil)
+2 -2
View File
@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>BNDL</string> <string>BNDL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.20</string> <string>2026.1.11-4</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>20260120</string> <string>202601113</string>
</dict> </dict>
</plist> </plist>
+5 -5
View File
@@ -81,8 +81,8 @@ targets:
properties: properties:
CFBundleDisplayName: Clawdbot CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.1.20" CFBundleShortVersionString: "2026.1.9"
CFBundleVersion: "20260120" CFBundleVersion: "20260109"
UILaunchScreen: {} UILaunchScreen: {}
UIApplicationSceneManifest: UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false UIApplicationSupportsMultipleScenes: false
@@ -92,7 +92,7 @@ targets:
NSAppTransportSecurity: NSAppTransportSecurity:
NSAllowsArbitraryLoadsInWebContent: true NSAllowsArbitraryLoadsInWebContent: true
NSBonjourServices: NSBonjourServices:
- _clawdbot-gw._tcp - _clawdbot-gateway._tcp
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the gateway. NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the gateway.
NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing. NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing.
NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always. NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always.
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist path: Tests/Info.plist
properties: properties:
CFBundleDisplayName: ClawdbotTests CFBundleDisplayName: ClawdbotTests
CFBundleShortVersionString: "2026.1.20" CFBundleShortVersionString: "2026.1.9"
CFBundleVersion: "20260120" CFBundleVersion: "20260109"
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"originHash" : "550d4ea41d4bb2546b99a7bfa1c5cba7e28a13862bc226727ea7426c61555a33", "originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438",
"pins" : [ "pins" : [
{ {
"identity" : "axorcist", "identity" : "axorcist",
+12 -3
View File
@@ -12,7 +12,8 @@ let package = Package(
.library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]), .library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]),
.library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]), .library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]),
.executable(name: "Clawdbot", targets: ["Clawdbot"]), .executable(name: "Clawdbot", targets: ["Clawdbot"]),
.executable(name: "clawdbot-mac", targets: ["ClawdbotMacCLI"]), .executable(name: "clawdbot-mac-discovery", targets: ["ClawdbotDiscoveryCLI"]),
.executable(name: "clawdbot-mac-wizard", targets: ["ClawdbotWizardCLI"]),
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"), .package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
@@ -66,13 +67,21 @@ let package = Package(
.enableUpcomingFeature("StrictConcurrency"), .enableUpcomingFeature("StrictConcurrency"),
]), ]),
.executableTarget( .executableTarget(
name: "ClawdbotMacCLI", name: "ClawdbotDiscoveryCLI",
dependencies: [ dependencies: [
"ClawdbotDiscovery", "ClawdbotDiscovery",
],
path: "Sources/ClawdbotDiscoveryCLI",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "ClawdbotWizardCLI",
dependencies: [
.product(name: "ClawdbotKit", package: "ClawdbotKit"), .product(name: "ClawdbotKit", package: "ClawdbotKit"),
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"), .product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
], ],
path: "Sources/ClawdbotMacCLI", path: "Sources/ClawdbotWizardCLI",
swiftSettings: [ swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"), .enableUpcomingFeature("StrictConcurrency"),
]), ]),
@@ -81,7 +81,7 @@ private struct EventRow: View {
return f.string(from: date) return f.string(from: date)
} }
private func prettyJSON(_ dict: [String: ClawdbotProtocol.AnyCodable]) -> String? { private func prettyJSON(_ dict: [String: AnyCodable]) -> String? {
let normalized = dict.mapValues { $0.value } let normalized = dict.mapValues { $0.value }
guard JSONSerialization.isValidJSONObject(normalized), guard JSONSerialization.isValidJSONObject(normalized),
let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.prettyPrinted]), let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.prettyPrinted]),
@@ -98,10 +98,7 @@ struct AgentEventsWindow_Previews: PreviewProvider {
seq: 1, seq: 1,
stream: "tool", stream: "tool",
ts: Date().timeIntervalSince1970 * 1000, ts: Date().timeIntervalSince1970 * 1000,
data: [ data: ["phase": AnyCodable("start"), "name": AnyCodable("bash")],
"phase": ClawdbotProtocol.AnyCodable("start"),
"name": ClawdbotProtocol.AnyCodable("bash"),
],
summary: nil) summary: nil)
AgentEventStore.shared.append(sample) AgentEventStore.shared.append(sample)
return AgentEventsWindow() return AgentEventsWindow()
@@ -1,11 +1,6 @@
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
// Prefer the ClawdbotKit wrapper to keep gateway request payloads consistent.
typealias AnyCodable = ClawdbotKit.AnyCodable
typealias InstanceIdentity = ClawdbotKit.InstanceIdentity
extension AnyCodable { extension AnyCodable {
var stringValue: String? { self.value as? String } var stringValue: String? { self.value as? String }
var boolValue: Bool? { self.value as? Bool } var boolValue: Bool? { self.value as? Bool }
@@ -25,23 +20,3 @@ extension AnyCodable {
} }
} }
} }
extension ClawdbotProtocol.AnyCodable {
var stringValue: String? { self.value as? String }
var boolValue: Bool? { self.value as? Bool }
var intValue: Int? { self.value as? Int }
var doubleValue: Double? { self.value as? Double }
var dictionaryValue: [String: ClawdbotProtocol.AnyCodable]? { self.value as? [String: ClawdbotProtocol.AnyCodable] }
var arrayValue: [ClawdbotProtocol.AnyCodable]? { self.value as? [ClawdbotProtocol.AnyCodable] }
var foundationValue: Any {
switch self.value {
case let dict as [String: ClawdbotProtocol.AnyCodable]:
dict.mapValues { $0.foundationValue }
case let array as [ClawdbotProtocol.AnyCodable]:
array.map(\.foundationValue)
default:
self.value
}
}
}
@@ -426,17 +426,24 @@ extension ChannelsSettings {
} }
private func resolveChannelTitle(_ id: String) -> String { private func resolveChannelTitle(_ id: String) -> String {
let label = self.store.resolveChannelLabel(id) if let label = self.store.snapshot?.channelLabels[id], !label.isEmpty {
if label != id { return label } return label
}
return id.prefix(1).uppercased() + id.dropFirst() return id.prefix(1).uppercased() + id.dropFirst()
} }
private func resolveChannelDetailTitle(_ id: String) -> String { private func resolveChannelDetailTitle(_ id: String) -> String {
self.store.resolveChannelDetailLabel(id) if let detail = self.store.snapshot?.channelDetailLabels?[id], !detail.isEmpty {
return detail
}
return self.resolveChannelTitle(id)
} }
private func resolveChannelSystemImage(_ id: String) -> String { private func resolveChannelSystemImage(_ id: String) -> String {
self.store.resolveChannelSystemImage(id) if let symbol = self.store.snapshot?.channelSystemImages?[id], !symbol.isEmpty {
return symbol
}
return "message"
} }
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? { private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
@@ -153,19 +153,11 @@ struct ChannelsStatusSnapshot: Codable {
let application: AnyCodable? let application: AnyCodable?
} }
struct ChannelUiMetaEntry: Codable {
let id: String
let label: String
let detailLabel: String
let systemImage: String?
}
let ts: Double let ts: Double
let channelOrder: [String] let channelOrder: [String]
let channelLabels: [String: String] let channelLabels: [String: String]
let channelDetailLabels: [String: String]? let channelDetailLabels: [String: String]? = nil
let channelSystemImages: [String: String]? let channelSystemImages: [String: String]? = nil
let channelMeta: [ChannelUiMetaEntry]?
let channels: [String: AnyCodable] let channels: [String: AnyCodable]
let channelAccounts: [String: [ChannelAccountSnapshot]] let channelAccounts: [String: [ChannelAccountSnapshot]]
let channelDefaultAccountId: [String: String] let channelDefaultAccountId: [String: String]
@@ -227,47 +219,6 @@ final class ChannelsStore {
var configRoot: [String: Any] = [:] var configRoot: [String: Any] = [:]
var configLoaded = false var configLoaded = false
func channelMetaEntry(_ id: String) -> ChannelsStatusSnapshot.ChannelUiMetaEntry? {
self.snapshot?.channelMeta?.first(where: { $0.id == id })
}
func resolveChannelLabel(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), !meta.label.isEmpty {
return meta.label
}
if let label = self.snapshot?.channelLabels[id], !label.isEmpty {
return label
}
return id
}
func resolveChannelDetailLabel(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), !meta.detailLabel.isEmpty {
return meta.detailLabel
}
if let detail = self.snapshot?.channelDetailLabels?[id], !detail.isEmpty {
return detail
}
return self.resolveChannelLabel(id)
}
func resolveChannelSystemImage(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), let symbol = meta.systemImage, !symbol.isEmpty {
return symbol
}
if let symbol = self.snapshot?.channelSystemImages?[id], !symbol.isEmpty {
return symbol
}
return "message"
}
func orderedChannelIds() -> [String] {
if let meta = self.snapshot?.channelMeta, !meta.isEmpty {
return meta.map(\.id)
}
return self.snapshot?.channelOrder ?? []
}
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) { init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview self.isPreview = isPreview
} }
@@ -20,7 +20,7 @@ struct ControlAgentEvent: Codable, Sendable, Identifiable {
let seq: Int let seq: Int
let stream: String let stream: String
let ts: Double let ts: Double
let data: [String: ClawdbotProtocol.AnyCodable] let data: [String: AnyCodable]
let summary: String? let summary: String?
} }
@@ -156,8 +156,8 @@ final class ControlChannel {
timeoutMs: Double? = nil) async throws -> Data timeoutMs: Double? = nil) async throws -> Data
{ {
do { do {
let rawParams = params?.reduce(into: [String: ClawdbotKit.AnyCodable]()) { let rawParams = params?.reduce(into: [String: AnyCodable]()) {
$0[$1.key] = ClawdbotKit.AnyCodable($1.value.base) $0[$1.key] = AnyCodable($1.value.base)
} }
let data = try await GatewayConnection.shared.request( let data = try await GatewayConnection.shared.request(
method: method, method: method,
@@ -346,7 +346,7 @@ final class ControlChannel {
let phase = event.data["phase"]?.value as? String ?? "" let phase = event.data["phase"]?.value as? String ?? ""
let name = event.data["name"]?.value as? String let name = event.data["name"]?.value as? String
let meta = event.data["meta"]?.value as? String let meta = event.data["meta"]?.value as? String
let args = Self.bridgeToProtocolArgs(event.data["args"]) let args = event.data["args"]?.value as? [String: AnyCodable]
WorkActivityStore.shared.handleTool( WorkActivityStore.shared.handleTool(
sessionKey: sessionKey, sessionKey: sessionKey,
phase: phase, phase: phase,
@@ -357,27 +357,6 @@ final class ControlChannel {
break break
} }
} }
private static func bridgeToProtocolArgs(
_ value: ClawdbotProtocol.AnyCodable?) -> [String: ClawdbotProtocol.AnyCodable]?
{
guard let value else { return nil }
if let dict = value.value as? [String: ClawdbotProtocol.AnyCodable] {
return dict
}
if let dict = value.value as? [String: ClawdbotKit.AnyCodable],
let data = try? JSONEncoder().encode(dict),
let decoded = try? JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
{
return decoded
}
if let data = try? JSONEncoder().encode(value),
let decoded = try? JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
{
return decoded
}
return nil
}
} }
extension Notification.Name { extension Notification.Name {
@@ -55,7 +55,8 @@ struct CronJobEditor: View {
@State var postPrefix: String = "Cron" @State var postPrefix: String = "Cron"
var channelOptions: [String] { var channelOptions: [String] {
let ordered = self.channelsStore.orderedChannelIds() let snapshot = self.channelsStore.snapshot
let ordered = snapshot?.channelOrder ?? []
var options = ["last"] + ordered var options = ["last"] + ordered
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty, !options.contains(trimmed) { if !trimmed.isEmpty, !options.contains(trimmed) {
@@ -67,7 +68,7 @@ struct CronJobEditor: View {
func channelLabel(for id: String) -> String { func channelLabel(for id: String) -> String {
if id == "last" { return "last" } if id == "last" { return "last" }
return self.channelsStore.resolveChannelLabel(id) return self.channelsStore.snapshot?.channelLabels[id] ?? id
} }
var body: some View { var body: some View {
@@ -259,20 +259,6 @@ enum ExecApprovalsPromptPresenter {
@MainActor @MainActor
private enum ExecHostExecutor { private enum ExecHostExecutor {
private struct ExecApprovalContext {
let command: [String]
let displayCommand: String
let trimmedAgent: String?
let approvals: ExecApprovalsResolved
let security: ExecSecurity
let ask: ExecAsk
let autoAllowSkills: Bool
let env: [String: String]?
let resolution: ExecCommandResolution?
let allowlistMatch: ExecAllowlistEntry?
let skillAllow: Bool
}
private static let blockedEnvKeys: Set<String> = [ private static let blockedEnvKeys: Set<String> = [
"PATH", "PATH",
"NODE_OPTIONS", "NODE_OPTIONS",
@@ -291,93 +277,14 @@ private enum ExecHostExecutor {
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse { static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard !command.isEmpty else { guard !command.isEmpty else {
return self.errorResponse( return ExecHostResponse(
code: "INVALID_REQUEST", type: "exec-res",
message: "command required", id: UUID().uuidString,
reason: "invalid") ok: false,
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "command required", reason: "invalid"))
} }
let context = await self.buildContext(request: request, command: command)
if context.security == .deny {
return self.errorResponse(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DISABLED: security=deny",
reason: "security=deny")
}
let approvalDecision = request.approvalDecision
if approvalDecision == .deny {
return self.errorResponse(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: user denied",
reason: "user-denied")
}
var approvedByAsk = approvalDecision != nil
if self.requiresAsk(
ask: context.ask,
security: context.security,
allowlistMatch: context.allowlistMatch,
skillAllow: context.skillAllow),
approvalDecision == nil
{
let decision = ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: context.displayCommand,
cwd: request.cwd,
host: "node",
security: context.security.rawValue,
ask: context.ask.rawValue,
agentId: context.trimmedAgent,
resolvedPath: context.resolution?.resolvedPath))
switch decision {
case .deny:
return self.errorResponse(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: user denied",
reason: "user-denied")
case .allowAlways:
approvedByAsk = true
self.persistAllowlistEntry(decision: decision, context: context)
case .allowOnce:
approvedByAsk = true
}
}
self.persistAllowlistEntry(decision: approvalDecision, context: context)
if context.security == .allowlist,
context.allowlistMatch == nil,
!context.skillAllow,
!approvedByAsk
{
return self.errorResponse(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: allowlist miss",
reason: "allowlist-miss")
}
if let match = context.allowlistMatch {
ExecApprovalsStore.recordAllowlistUse(
agentId: context.trimmedAgent,
pattern: match.pattern,
command: context.displayCommand,
resolvedPath: context.resolution?.resolvedPath)
}
if let errorResponse = await self.ensureScreenRecordingAccess(request.needsScreenRecording) {
return errorResponse
}
return await self.runCommand(
command: command,
cwd: request.cwd,
env: context.env,
timeoutMs: request.timeoutMs)
}
private static func buildContext(request: ExecHostRequest, command: [String]) async -> ExecApprovalContext {
let displayCommand = ExecCommandFormatter.displayString( let displayCommand = ExecCommandFormatter.displayString(
for: command, for: command,
rawCommand: request.rawCommand) rawCommand: request.rawCommand)
@@ -403,72 +310,122 @@ private enum ExecHostExecutor {
} else { } else {
skillAllow = false skillAllow = false
} }
return ExecApprovalContext(
command: command,
displayCommand: displayCommand,
trimmedAgent: trimmedAgent,
approvals: approvals,
security: security,
ask: ask,
autoAllowSkills: autoAllowSkills,
env: env,
resolution: resolution,
allowlistMatch: allowlistMatch,
skillAllow: skillAllow)
}
private static func requiresAsk( if security == .deny {
ask: ExecAsk, return ExecHostResponse(
security: ExecSecurity, type: "exec-res",
allowlistMatch: ExecAllowlistEntry?, id: UUID().uuidString,
skillAllow: Bool) -> Bool ok: false,
{ payload: nil,
if ask == .always { return true } error: ExecHostError(
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true } code: "UNAVAILABLE",
return false message: "SYSTEM_RUN_DISABLED: security=deny",
} reason: "security=deny"))
private static func persistAllowlistEntry(
decision: ExecApprovalDecision?,
context: ExecApprovalContext)
{
guard decision == .allowAlways, context.security == .allowlist else { return }
guard let pattern = self.allowlistPattern(command: context.command, resolution: context.resolution) else {
return
} }
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
}
private static func allowlistPattern( let requiresAsk: Bool = {
command: [String], if ask == .always { return true }
resolution: ExecCommandResolution?) -> String? if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
{ return false
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? "" }()
return pattern.isEmpty ? nil : pattern
}
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? { let approvalDecision = request.approvalDecision
guard needsScreenRecording == true else { return nil } if approvalDecision == .deny {
let authorized = await PermissionManager return ExecHostResponse(
.status([.screenRecording])[.screenRecording] ?? false type: "exec-res",
if authorized { return nil } id: UUID().uuidString,
return self.errorResponse( ok: false,
code: "UNAVAILABLE", payload: nil,
message: "PERMISSION_MISSING: screenRecording", error: ExecHostError(
reason: "permission:screenRecording") code: "UNAVAILABLE",
} message: "SYSTEM_RUN_DENIED: user denied",
reason: "user-denied"))
}
private static func runCommand( var approvedByAsk = approvalDecision != nil
command: [String], if requiresAsk, approvalDecision == nil {
cwd: String?, let decision = ExecApprovalsPromptPresenter.prompt(
env: [String: String]?, ExecApprovalPromptRequest(
timeoutMs: Int?) async -> ExecHostResponse command: displayCommand,
{ cwd: request.cwd,
let timeoutSec = timeoutMs.flatMap { Double($0) / 1000.0 } 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 approvalDecision == .allowAlways, security == .allowlist {
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
if !pattern.isEmpty {
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
}
}
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 let result = await Task.detached { () -> ShellExecutor.ShellResult in
await ShellExecutor.runDetailed( await ShellExecutor.runDetailed(
command: command, command: command,
cwd: cwd, cwd: request.cwd,
env: env, env: env,
timeout: timeoutSec) timeout: timeoutSec)
}.value }.value
@@ -479,24 +436,7 @@ private enum ExecHostExecutor {
stdout: result.stdout, stdout: result.stdout,
stderr: result.stderr, stderr: result.stderr,
error: result.errorMessage) error: result.errorMessage)
return self.successResponse(payload) return ExecHostResponse(
}
private static func errorResponse(
code: String,
message: String,
reason: String?) -> ExecHostResponse
{
ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: code, message: message, reason: reason))
}
private static func successResponse(_ payload: ExecHostRunResult) -> ExecHostResponse {
ExecHostResponse(
type: "exec-res", type: "exec-res",
id: UUID().uuidString, id: UUID().uuidString,
ok: true, ok: true,
@@ -148,27 +148,6 @@ actor GatewayConnection {
} }
} }
let nsError = lastError as NSError
if nsError.domain == URLError.errorDomain,
let fallback = await GatewayEndpointStore.shared.maybeFallbackToTailnet(from: cfg.url)
{
await self.configure(url: fallback.url, token: fallback.token, password: fallback.password)
for delayMs in [150, 400, 900] {
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
do {
guard let client = self.client else {
throw NSError(
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
}
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
lastError = error
}
}
}
throw lastError throw lastError
case .remote: case .remote:
let nsError = error as NSError let nsError = error as NSError
@@ -265,9 +244,9 @@ actor GatewayConnection {
return trimmed.isEmpty ? nil : trimmed return trimmed.isEmpty ? nil : trimmed
} }
private func sessionDefaultString(_ defaults: [String: ClawdbotProtocol.AnyCodable]?, key: String) -> String { private func sessionDefaultString(_ defaults: [String: AnyCodable]?, key: String) -> String {
let raw = defaults?[key]?.value as? String (defaults?[key]?.stringValue ?? "")
return (raw ?? "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
} }
func cachedMainSessionKey() -> String? { func cachedMainSessionKey() -> String? {
@@ -165,7 +165,7 @@ actor GatewayEndpointStore {
} }
return trimmed return trimmed
} }
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root), if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
!configToken.isEmpty !configToken.isEmpty
{ {
@@ -177,7 +177,7 @@ actor GatewayEndpointStore {
{ {
return token return token
} }
return nil return nil
} }
@@ -469,35 +469,6 @@ actor GatewayEndpointStore {
} }
} }
func maybeFallbackToTailnet(from currentURL: URL) async -> GatewayConnection.Config? {
let mode = await self.deps.mode()
guard mode == .local else { return nil }
let root = ClawdbotConfigFile.loadDict()
let bind = GatewayEndpointStore.resolveGatewayBindMode(
root: root,
env: ProcessInfo.processInfo.environment)
guard bind == "auto" else { return nil }
let currentHost = currentURL.host?.lowercased() ?? ""
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
let scheme = GatewayEndpointStore.resolveGatewayScheme(
root: root,
env: ProcessInfo.processInfo.environment)
let port = self.deps.localPort()
let token = self.deps.token()
let password = self.deps.password()
let url = URL(string: "\(scheme)://\(tailscaleIP):\(port)")!
self.logger.info("auto bind fallback to tailnet host=\(tailscaleIP, privacy: .public)")
self.setState(.ready(mode: .local, url: url, token: token, password: password))
return (url, token, password)
}
private static func resolveGatewayBindMode( private static func resolveGatewayBindMode(
root: [String: Any], root: [String: Any],
env: [String: String]) -> String? env: [String: String]) -> String?
@@ -553,10 +524,8 @@ actor GatewayEndpointStore {
tailscaleIP: String?) -> String tailscaleIP: String?) -> String
{ {
switch bindMode { switch bindMode {
case "tailnet": case "tailnet", "auto":
tailscaleIP ?? "127.0.0.1" tailscaleIP ?? "127.0.0.1"
case "auto":
"127.0.0.1"
case "custom": case "custom":
customBindHost ?? "127.0.0.1" customBindHost ?? "127.0.0.1"
default: default:
@@ -217,7 +217,7 @@ final class OnboardingWizardModel {
struct OnboardingWizardStepView: View { struct OnboardingWizardStepView: View {
let step: WizardStep let step: WizardStep
let isSubmitting: Bool let isSubmitting: Bool
let onStepSubmit: (AnyCodable?) -> Void let onSubmit: (AnyCodable?) -> Void
@State private var textValue: String @State private var textValue: String
@State private var confirmValue: Bool @State private var confirmValue: Bool
@@ -229,7 +229,7 @@ struct OnboardingWizardStepView: View {
init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) { init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) {
self.step = step self.step = step
self.isSubmitting = isSubmitting self.isSubmitting = isSubmitting
self.onStepSubmit = onSubmit self.onSubmit = onSubmit
let options = parseWizardOptions(step.options).enumerated().map { index, option in let options = parseWizardOptions(step.options).enumerated().map { index, option in
WizardOptionItem(index: index, option: option) WizardOptionItem(index: index, option: option)
} }
@@ -379,27 +379,27 @@ struct OnboardingWizardStepView: View {
private func submit() { private func submit() {
switch wizardStepType(self.step) { switch wizardStepType(self.step) {
case "note", "progress": case "note", "progress":
self.onStepSubmit(nil) self.onSubmit(nil)
case "text": case "text":
self.onStepSubmit(AnyCodable(self.textValue)) self.onSubmit(AnyCodable(self.textValue))
case "confirm": case "confirm":
self.onStepSubmit(AnyCodable(self.confirmValue)) self.onSubmit(AnyCodable(self.confirmValue))
case "select": case "select":
guard self.optionItems.indices.contains(self.selectedIndex) else { guard self.optionItems.indices.contains(self.selectedIndex) else {
self.onStepSubmit(nil) self.onSubmit(nil)
return return
} }
let option = self.optionItems[self.selectedIndex].option let option = self.optionItems[self.selectedIndex].option
self.onStepSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label)) self.onSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
case "multiselect": case "multiselect":
let values = self.optionItems let values = self.optionItems
.filter { self.selectedIndices.contains($0.index) } .filter { self.selectedIndices.contains($0.index) }
.map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) } .map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) }
self.onStepSubmit(AnyCodable(values)) self.onSubmit(AnyCodable(values))
case "action": case "action":
self.onStepSubmit(AnyCodable(true)) self.onSubmit(AnyCodable(true))
default: default:
self.onStepSubmit(nil) self.onSubmit(nil)
} }
} }
} }
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.20</string> <string>2026.1.11-4</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>202601200</string> <string>202601113</string>
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>
<string>Clawdbot</string> <string>Clawdbot</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -52,7 +52,7 @@ enum WideAreaGatewayDiscovery {
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_clawdbot-gw._tcp.\(domainTrimmed)" let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
guard let ptrLines = context.dig( guard let ptrLines = context.dig(
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"], ["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline), min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
@@ -66,7 +66,7 @@ enum WideAreaGatewayDiscovery {
let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines) let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if ptr.isEmpty { continue } if ptr.isEmpty { continue }
let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr
let suffix = "._clawdbot-gw._tcp.\(domainTrimmed)" let suffix = "._clawdbot-gateway._tcp.\(domainTrimmed)"
let rawInstanceName = ptrName.hasSuffix(suffix) let rawInstanceName = ptrName.hasSuffix(suffix)
? String(ptrName.dropLast(suffix.count)) ? String(ptrName.dropLast(suffix.count))
: ptrName : ptrName
@@ -156,7 +156,7 @@ enum WideAreaGatewayDiscovery {
{ {
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_clawdbot-gw._tcp.\(domainTrimmed)" let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
let ips = candidates let ips = candidates
candidates.removeAll(keepingCapacity: true) candidates.removeAll(keepingCapacity: true)
@@ -0,0 +1,150 @@
import ClawdbotDiscovery
import Foundation
struct DiscoveryOptions {
var timeoutMs: Int = 2000
var json: Bool = false
var includeLocal: Bool = false
var help: Bool = false
static func parse(_ args: [String]) -> DiscoveryOptions {
var opts = DiscoveryOptions()
var i = 0
while i < args.count {
let arg = args[i]
switch arg {
case "-h", "--help":
opts.help = true
case "--json":
opts.json = true
case "--include-local":
opts.includeLocal = true
case "--timeout":
let next = (i + 1 < args.count) ? args[i + 1] : nil
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
opts.timeoutMs = max(100, parsed)
i += 1
}
default:
break
}
i += 1
}
return opts
}
}
struct DiscoveryOutput: Encodable {
struct Gateway: Encodable {
var displayName: String
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var gatewayPort: Int?
var cliPath: String?
var stableID: String
var debugID: String
var isLocal: Bool
}
var status: String
var timeoutMs: Int
var includeLocal: Bool
var count: Int
var gateways: [Gateway]
}
@main
struct ClawdbotDiscoveryCLI {
static func main() async {
let opts = DiscoveryOptions.parse(Array(CommandLine.arguments.dropFirst()))
if opts.help {
print("""
clawdbot-mac-discovery
Usage:
clawdbot-mac-discovery [--timeout <ms>] [--json] [--include-local]
Options:
--timeout <ms> Discovery window in milliseconds (default: 2000)
--json Emit JSON
--include-local Include gateways considered local
-h, --help Show help
""")
return
}
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
let model = GatewayDiscoveryModel(
localDisplayName: displayName,
filterLocalGateways: !opts.includeLocal)
await MainActor.run {
model.start()
}
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
try? await Task.sleep(nanoseconds: nanos)
let gateways = await MainActor.run { model.gateways }
let status = await MainActor.run { model.statusText }
await MainActor.run {
model.stop()
}
if opts.json {
let payload = DiscoveryOutput(
status: status,
timeoutMs: opts.timeoutMs,
includeLocal: opts.includeLocal,
count: gateways.count,
gateways: gateways.map {
DiscoveryOutput.Gateway(
displayName: $0.displayName,
lanHost: $0.lanHost,
tailnetDns: $0.tailnetDns,
sshPort: $0.sshPort,
gatewayPort: $0.gatewayPort,
cliPath: $0.cliPath,
stableID: $0.stableID,
debugID: $0.debugID,
isLocal: $0.isLocal)
})
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(payload),
let json = String(data: data, encoding: .utf8)
{
print(json)
} else {
print("{\"error\":\"failed to encode JSON\"}")
}
return
}
print("Gateway Discovery (macOS NWBrowser)")
print("Status: \(status)")
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
if gateways.isEmpty { return }
for gateway in gateways {
let hosts = [gateway.tailnetDns, gateway.lanHost]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: ", ")
print("- \(gateway.displayName)")
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
print(" ssh: \(gateway.sshPort)")
if let port = gateway.gatewayPort {
print(" gatewayPort: \(port)")
}
if let cliPath = gateway.cliPath {
print(" cliPath: \(cliPath)")
}
print(" isLocal: \(gateway.isLocal)")
print(" stableID: \(gateway.stableID)")
print(" debugID: \(gateway.debugID)")
}
}
}
+5 -4
View File
@@ -408,7 +408,8 @@ extension Request: Codable {
} }
// Shared transport settings // Shared transport settings
public let controlSocketPath = FileManager() public let controlSocketPath =
.homeDirectoryForCurrentUser FileManager()
.appendingPathComponent("Library/Application Support/clawdbot/control.sock") .homeDirectoryForCurrentUser
.path .appendingPathComponent("Library/Application Support/clawdbot/control.sock")
.path
@@ -1,306 +0,0 @@
import ClawdbotKit
import ClawdbotProtocol
import Foundation
struct ConnectOptions {
var url: String?
var token: String?
var password: String?
var mode: String?
var timeoutMs: Int = 15_000
var json: Bool = false
var probe: Bool = false
var clientId: String = "clawdbot-macos"
var clientMode: String = "ui"
var displayName: String?
var role: String = "operator"
var scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"]
var help: Bool = false
static func parse(_ args: [String]) -> ConnectOptions {
var opts = ConnectOptions()
var i = 0
while i < args.count {
let arg = args[i]
switch arg {
case "-h", "--help":
opts.help = true
case "--json":
opts.json = true
case "--probe":
opts.probe = true
case "--url":
opts.url = self.nextValue(args, index: &i)
case "--token":
opts.token = self.nextValue(args, index: &i)
case "--password":
opts.password = self.nextValue(args, index: &i)
case "--mode":
if let value = self.nextValue(args, index: &i) {
opts.mode = value
}
case "--timeout":
if let raw = self.nextValue(args, index: &i),
let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
{
opts.timeoutMs = max(250, parsed)
}
case "--client-id":
if let value = self.nextValue(args, index: &i) {
opts.clientId = value
}
case "--client-mode":
if let value = self.nextValue(args, index: &i) {
opts.clientMode = value
}
case "--display-name":
opts.displayName = self.nextValue(args, index: &i)
case "--role":
if let value = self.nextValue(args, index: &i) {
opts.role = value
}
case "--scopes":
if let value = self.nextValue(args, index: &i) {
opts.scopes = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
default:
break
}
i += 1
}
return opts
}
private static func nextValue(_ args: [String], index: inout Int) -> String? {
guard index + 1 < args.count else { return nil }
index += 1
return args[index].trimmingCharacters(in: .whitespacesAndNewlines)
}
}
struct ConnectOutput: Encodable {
var status: String
var url: String
var mode: String
var role: String
var clientId: String
var clientMode: String
var scopes: [String]
var snapshot: HelloOk?
var health: ProtoAnyCodable?
var error: String?
}
actor SnapshotStore {
private var value: HelloOk?
func set(_ snapshot: HelloOk) {
self.value = snapshot
}
func get() -> HelloOk? {
self.value
}
}
func runConnect(_ args: [String]) async {
let opts = ConnectOptions.parse(args)
if opts.help {
print("""
clawdbot-mac connect
Usage:
clawdbot-mac connect [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--timeout <ms>] [--probe] [--json]
[--client-id <id>] [--client-mode <mode>] [--display-name <name>]
[--role <role>] [--scopes <a,b,c>]
Options:
--url <url> Gateway WebSocket URL (overrides config)
--token <token> Gateway token (if required)
--password <pw> Gateway password (if required)
--mode <mode> Resolve from config: local|remote (default: config or local)
--timeout <ms> Request timeout (default: 15000)
--probe Force a fresh health probe
--json Emit JSON
--client-id <id> Override client id (default: clawdbot-macos)
--client-mode <m> Override client mode (default: ui)
--display-name <n> Override display name
--role <role> Override role (default: operator)
--scopes <a,b,c> Override scopes list
-h, --help Show help
""")
return
}
let config = loadGatewayConfig()
do {
let endpoint = try resolveGatewayEndpoint(opts: opts, config: config)
let displayName = opts.displayName ?? Host.current().localizedName ?? "Clawdbot macOS Debug CLI"
let connectOptions = GatewayConnectOptions(
role: opts.role,
scopes: opts.scopes,
caps: [],
commands: [],
permissions: [:],
clientId: opts.clientId,
clientMode: opts.clientMode,
clientDisplayName: displayName)
let snapshotStore = SnapshotStore()
let channel = GatewayChannelActor(
url: endpoint.url,
token: endpoint.token,
password: endpoint.password,
pushHandler: { push in
if case let .snapshot(ok) = push {
await snapshotStore.set(ok)
}
},
connectOptions: connectOptions)
let params: [String: KitAnyCodable]? = opts.probe ? ["probe": KitAnyCodable(true)] : nil
let data = try await channel.request(
method: "health",
params: params,
timeoutMs: Double(opts.timeoutMs))
let health = try? JSONDecoder().decode(ProtoAnyCodable.self, from: data)
let snapshot = await snapshotStore.get()
await channel.shutdown()
let output = ConnectOutput(
status: "ok",
url: endpoint.url.absoluteString,
mode: endpoint.mode,
role: opts.role,
clientId: opts.clientId,
clientMode: opts.clientMode,
scopes: opts.scopes,
snapshot: snapshot,
health: health,
error: nil)
printConnectOutput(output, json: opts.json)
} catch {
let endpoint = bestEffortEndpoint(opts: opts, config: config)
let fallbackMode = (opts.mode ?? config.mode ?? "local").lowercased()
let output = ConnectOutput(
status: "error",
url: endpoint?.url.absoluteString ?? "unknown",
mode: endpoint?.mode ?? fallbackMode,
role: opts.role,
clientId: opts.clientId,
clientMode: opts.clientMode,
scopes: opts.scopes,
snapshot: nil,
health: nil,
error: error.localizedDescription)
printConnectOutput(output, json: opts.json)
exit(1)
}
}
private func printConnectOutput(_ output: ConnectOutput, json: Bool) {
if json {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(output),
let text = String(data: data, encoding: .utf8)
{
print(text)
} else {
print("{\"error\":\"failed to encode JSON\"}")
}
return
}
print("Clawdbot macOS Gateway Connect")
print("Status: \(output.status)")
print("URL: \(output.url)")
print("Mode: \(output.mode)")
print("Client: \(output.clientId) (\(output.clientMode))")
print("Role: \(output.role)")
print("Scopes: \(output.scopes.joined(separator: ", "))")
if let snapshot = output.snapshot {
print("Protocol: \(snapshot._protocol)")
if let version = snapshot.server["version"]?.value as? String {
print("Server: \(version)")
}
}
if let health = output.health,
let ok = (health.value as? [String: ProtoAnyCodable])?["ok"]?.value as? Bool
{
print("Health: \(ok ? "ok" : "error")")
} else if output.health != nil {
print("Health: received")
}
if let error = output.error {
print("Error: \(error)")
}
}
private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) throws -> GatewayEndpoint {
let resolvedMode = (opts.mode ?? config.mode ?? "local").lowercased()
if let raw = opts.url, !raw.isEmpty {
guard let url = URL(string: raw) else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
}
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
mode: resolvedMode)
}
if resolvedMode == "remote" {
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
}
guard let url = URL(string: raw) else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
}
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
mode: resolvedMode)
}
let port = config.port ?? 18789
let host = "127.0.0.1"
guard let url = URL(string: "ws://\(host):\(port)") else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
}
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
mode: resolvedMode)
}
private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? {
return try? resolveGatewayEndpoint(opts: opts, config: config)
}
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
if let token = opts.token, !token.isEmpty { return token }
if let token = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"], !token.isEmpty {
return token
}
if mode == "remote" {
return config.remoteToken
}
return config.token
}
private func resolvedPassword(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
if let password = opts.password, !password.isEmpty { return password }
if let password = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"], !password.isEmpty {
return password
}
if mode == "remote" {
return config.remotePassword
}
return config.password
}
@@ -1,149 +0,0 @@
import ClawdbotDiscovery
import Foundation
struct DiscoveryOptions {
var timeoutMs: Int = 2000
var json: Bool = false
var includeLocal: Bool = false
var help: Bool = false
static func parse(_ args: [String]) -> DiscoveryOptions {
var opts = DiscoveryOptions()
var i = 0
while i < args.count {
let arg = args[i]
switch arg {
case "-h", "--help":
opts.help = true
case "--json":
opts.json = true
case "--include-local":
opts.includeLocal = true
case "--timeout":
let next = (i + 1 < args.count) ? args[i + 1] : nil
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
opts.timeoutMs = max(100, parsed)
i += 1
}
default:
break
}
i += 1
}
return opts
}
}
struct DiscoveryOutput: Encodable {
struct Gateway: Encodable {
var displayName: String
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var gatewayPort: Int?
var cliPath: String?
var stableID: String
var debugID: String
var isLocal: Bool
}
var status: String
var timeoutMs: Int
var includeLocal: Bool
var count: Int
var gateways: [Gateway]
}
func runDiscover(_ args: [String]) async {
let opts = DiscoveryOptions.parse(args)
if opts.help {
print("""
clawdbot-mac discover
Usage:
clawdbot-mac discover [--timeout <ms>] [--json] [--include-local]
Options:
--timeout <ms> Discovery window in milliseconds (default: 2000)
--json Emit JSON
--include-local Include gateways considered local
-h, --help Show help
""")
return
}
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
let model = await MainActor.run {
GatewayDiscoveryModel(
localDisplayName: displayName,
filterLocalGateways: !opts.includeLocal)
}
await MainActor.run {
model.start()
}
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
try? await Task.sleep(nanoseconds: nanos)
let gateways = await MainActor.run { model.gateways }
let status = await MainActor.run { model.statusText }
await MainActor.run {
model.stop()
}
if opts.json {
let payload = DiscoveryOutput(
status: status,
timeoutMs: opts.timeoutMs,
includeLocal: opts.includeLocal,
count: gateways.count,
gateways: gateways.map {
DiscoveryOutput.Gateway(
displayName: $0.displayName,
lanHost: $0.lanHost,
tailnetDns: $0.tailnetDns,
sshPort: $0.sshPort,
gatewayPort: $0.gatewayPort,
cliPath: $0.cliPath,
stableID: $0.stableID,
debugID: $0.debugID,
isLocal: $0.isLocal)
})
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(payload),
let json = String(data: data, encoding: .utf8)
{
print(json)
} else {
print("{\"error\":\"failed to encode JSON\"}")
}
return
}
print("Gateway Discovery (macOS NWBrowser)")
print("Status: \(status)")
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
if gateways.isEmpty { return }
for gateway in gateways {
let hosts = [gateway.tailnetDns, gateway.lanHost]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: ", ")
print("- \(gateway.displayName)")
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
print(" ssh: \(gateway.sshPort)")
if let port = gateway.gatewayPort {
print(" gatewayPort: \(port)")
}
if let cliPath = gateway.cliPath {
print(" cliPath: \(cliPath)")
}
print(" isLocal: \(gateway.isLocal)")
print(" stableID: \(gateway.stableID)")
print(" debugID: \(gateway.debugID)")
}
}
@@ -1,56 +0,0 @@
import Foundation
private struct RootCommand {
var name: String
var args: [String]
}
@main
struct ClawdbotMacCLI {
static func main() async {
let args = Array(CommandLine.arguments.dropFirst())
let command = parseRootCommand(args)
switch command?.name {
case nil:
printUsage()
case "-h", "--help", "help":
printUsage()
case "connect":
await runConnect(command?.args ?? [])
case "discover":
await runDiscover(command?.args ?? [])
case "wizard":
await runWizardCommand(command?.args ?? [])
default:
fputs("clawdbot-mac: unknown command\n", stderr)
printUsage()
exit(1)
}
}
}
private func parseRootCommand(_ args: [String]) -> RootCommand? {
guard let first = args.first else { return nil }
return RootCommand(name: first, args: Array(args.dropFirst()))
}
private func printUsage() {
print("""
clawdbot-mac
Usage:
clawdbot-mac connect [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--timeout <ms>] [--probe] [--json]
[--client-id <id>] [--client-mode <mode>] [--display-name <name>]
[--role <role>] [--scopes <a,b,c>]
clawdbot-mac discover [--timeout <ms>] [--json] [--include-local]
clawdbot-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--workspace <path>] [--json]
Examples:
clawdbot-mac connect
clawdbot-mac connect --url ws://127.0.0.1:18789 --json
clawdbot-mac discover --timeout 3000 --json
clawdbot-mac wizard --mode local
""")
}
@@ -1,60 +0,0 @@
import Foundation
struct GatewayConfig {
var mode: String?
var bind: String?
var port: Int?
var remoteUrl: String?
var token: String?
var password: String?
var remoteToken: String?
var remotePassword: String?
}
struct GatewayEndpoint {
let url: URL
let token: String?
let password: String?
let mode: String
}
func loadGatewayConfig() -> GatewayConfig {
let url = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot")
.appendingPathComponent("clawdbot.json")
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return GatewayConfig()
}
var cfg = GatewayConfig()
if let gateway = json["gateway"] as? [String: Any] {
cfg.mode = gateway["mode"] as? String
cfg.bind = gateway["bind"] as? String
cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"])
if let auth = gateway["auth"] as? [String: Any] {
cfg.token = auth["token"] as? String
cfg.password = auth["password"] as? String
}
if let remote = gateway["remote"] as? [String: Any] {
cfg.remoteUrl = remote["url"] as? String
cfg.remoteToken = remote["token"] as? String
cfg.remotePassword = remote["password"] as? String
}
}
return cfg
}
func parseInt(_ value: Any?) -> Int? {
switch value {
case let number as Int:
number
case let number as Double:
Int(number)
case let raw as String:
Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
default:
nil
}
}
@@ -1,5 +0,0 @@
import ClawdbotKit
import ClawdbotProtocol
typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
typealias KitAnyCodable = ClawdbotKit.AnyCodable
@@ -1326,7 +1326,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
public let channellabels: [String: AnyCodable] public let channellabels: [String: AnyCodable]
public let channeldetaillabels: [String: AnyCodable]? public let channeldetaillabels: [String: AnyCodable]?
public let channelsystemimages: [String: AnyCodable]? public let channelsystemimages: [String: AnyCodable]?
public let channelmeta: [[String: AnyCodable]]?
public let channels: [String: AnyCodable] public let channels: [String: AnyCodable]
public let channelaccounts: [String: AnyCodable] public let channelaccounts: [String: AnyCodable]
public let channeldefaultaccountid: [String: AnyCodable] public let channeldefaultaccountid: [String: AnyCodable]
@@ -1337,7 +1336,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
channellabels: [String: AnyCodable], channellabels: [String: AnyCodable],
channeldetaillabels: [String: AnyCodable]?, channeldetaillabels: [String: AnyCodable]?,
channelsystemimages: [String: AnyCodable]?, channelsystemimages: [String: AnyCodable]?,
channelmeta: [[String: AnyCodable]]?,
channels: [String: AnyCodable], channels: [String: AnyCodable],
channelaccounts: [String: AnyCodable], channelaccounts: [String: AnyCodable],
channeldefaultaccountid: [String: AnyCodable] channeldefaultaccountid: [String: AnyCodable]
@@ -1347,7 +1345,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
self.channellabels = channellabels self.channellabels = channellabels
self.channeldetaillabels = channeldetaillabels self.channeldetaillabels = channeldetaillabels
self.channelsystemimages = channelsystemimages self.channelsystemimages = channelsystemimages
self.channelmeta = channelmeta
self.channels = channels self.channels = channels
self.channelaccounts = channelaccounts self.channelaccounts = channelaccounts
self.channeldefaultaccountid = channeldefaultaccountid self.channeldefaultaccountid = channeldefaultaccountid
@@ -1358,7 +1355,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
case channellabels = "channelLabels" case channellabels = "channelLabels"
case channeldetaillabels = "channelDetailLabels" case channeldetaillabels = "channelDetailLabels"
case channelsystemimages = "channelSystemImages" case channelsystemimages = "channelSystemImages"
case channelmeta = "channelMeta"
case channels case channels
case channelaccounts = "channelAccounts" case channelaccounts = "channelAccounts"
case channeldefaultaccountid = "channelDefaultAccountId" case channeldefaultaccountid = "channelDefaultAccountId"
@@ -3,6 +3,8 @@ import ClawdbotProtocol
import Darwin import Darwin
import Foundation import Foundation
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
struct WizardCliOptions { struct WizardCliOptions {
var url: String? var url: String?
var token: String? var token: String?
@@ -49,6 +51,17 @@ struct WizardCliOptions {
} }
} }
struct GatewayConfig {
var mode: String?
var bind: String?
var port: Int?
var remoteUrl: String?
var token: String?
var password: String?
var remoteToken: String?
var remotePassword: String?
}
enum WizardCliError: Error, CustomStringConvertible { enum WizardCliError: Error, CustomStringConvertible {
case invalidUrl(String) case invalidUrl(String)
case missingRemoteUrl case missingRemoteUrl
@@ -67,56 +80,68 @@ enum WizardCliError: Error, CustomStringConvertible {
} }
} }
func runWizardCommand(_ args: [String]) async { @main
let opts = WizardCliOptions.parse(args) struct ClawdbotWizardCLI {
if opts.help { static func main() async {
print(""" let opts = WizardCliOptions.parse(Array(CommandLine.arguments.dropFirst()))
clawdbot-mac wizard if opts.help {
printUsage()
return
}
Usage: let config = loadGatewayConfig()
clawdbot-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>] do {
[--mode <local|remote>] [--workspace <path>] [--json] guard isatty(STDIN_FILENO) != 0 else {
throw WizardCliError.gatewayError("Wizard requires an interactive TTY.")
Options: }
--url <url> Gateway WebSocket URL (overrides config) let endpoint = try resolveGatewayEndpoint(opts: opts, config: config)
--token <token> Gateway token (if required) let client = GatewayWizardClient(
--password <pw> Gateway password (if required) url: endpoint.url,
--mode <mode> Wizard mode (local|remote). Default: local token: endpoint.token,
--workspace <path> Wizard workspace override password: endpoint.password,
--json Print raw wizard responses json: opts.json)
-h, --help Show help try await client.connect()
""") defer { Task { await client.close() } }
return try await runWizard(client: client, opts: opts)
} } catch {
fputs("wizard: \(error)\n", stderr)
let config = loadGatewayConfig() exit(1)
do {
guard isatty(STDIN_FILENO) != 0 else {
throw WizardCliError.gatewayError("Wizard requires an interactive TTY.")
} }
let endpoint = try resolveWizardGatewayEndpoint(opts: opts, config: config)
let client = GatewayWizardClient(
url: endpoint.url,
token: endpoint.token,
password: endpoint.password,
json: opts.json)
try await client.connect()
defer { Task { await client.close() } }
try await runWizard(client: client, opts: opts)
} catch {
fputs("wizard: \(error)\n", stderr)
exit(1)
} }
} }
private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint { private struct GatewayEndpoint {
let url: URL
let token: String?
let password: String?
}
private func printUsage() {
print("""
clawdbot-mac-wizard
Usage:
clawdbot-mac-wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--workspace <path>] [--json]
Options:
--url <url> Gateway WebSocket URL (overrides config)
--token <token> Gateway token (if required)
--password <pw> Gateway password (if required)
--mode <mode> Wizard mode (local|remote). Default: local
--workspace <path> Wizard workspace override
--json Print raw wizard responses
-h, --help Show help
""")
}
private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint {
if let raw = opts.url, !raw.isEmpty { if let raw = opts.url, !raw.isEmpty {
guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) } guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) }
return GatewayEndpoint( return GatewayEndpoint(
url: url, url: url,
token: resolvedToken(opts: opts, config: config), token: resolvedToken(opts: opts, config: config),
password: resolvedPassword(opts: opts, config: config), password: resolvedPassword(opts: opts, config: config))
mode: (config.mode ?? "local").lowercased())
} }
let mode = (config.mode ?? "local").lowercased() let mode = (config.mode ?? "local").lowercased()
@@ -128,8 +153,7 @@ private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: Gatewa
return GatewayEndpoint( return GatewayEndpoint(
url: url, url: url,
token: resolvedToken(opts: opts, config: config), token: resolvedToken(opts: opts, config: config),
password: resolvedPassword(opts: opts, config: config), password: resolvedPassword(opts: opts, config: config))
mode: mode)
} }
let port = config.port ?? 18789 let port = config.port ?? 18789
@@ -140,8 +164,7 @@ private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: Gatewa
return GatewayEndpoint( return GatewayEndpoint(
url: url, url: url,
token: resolvedToken(opts: opts, config: config), token: resolvedToken(opts: opts, config: config),
password: resolvedPassword(opts: opts, config: config), password: resolvedPassword(opts: opts, config: config))
mode: mode)
} }
private func resolvedToken(opts: WizardCliOptions, config: GatewayConfig) -> String? { private func resolvedToken(opts: WizardCliOptions, config: GatewayConfig) -> String? {
@@ -166,6 +189,47 @@ private func resolvedPassword(opts: WizardCliOptions, config: GatewayConfig) ->
return config.password return config.password
} }
private func loadGatewayConfig() -> GatewayConfig {
let url = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot")
.appendingPathComponent("clawdbot.json")
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return GatewayConfig()
}
var cfg = GatewayConfig()
if let gateway = json["gateway"] as? [String: Any] {
cfg.mode = gateway["mode"] as? String
cfg.bind = gateway["bind"] as? String
cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"])
if let auth = gateway["auth"] as? [String: Any] {
cfg.token = auth["token"] as? String
cfg.password = auth["password"] as? String
}
if let remote = gateway["remote"] as? [String: Any] {
cfg.remoteUrl = remote["url"] as? String
cfg.remoteToken = remote["token"] as? String
cfg.remotePassword = remote["password"] as? String
}
}
return cfg
}
private func parseInt(_ value: Any?) -> Int? {
switch value {
case let number as Int:
number
case let number as Double:
Int(number)
case let raw as String:
Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
default:
nil
}
}
actor GatewayWizardClient { actor GatewayWizardClient {
private enum ConnectChallengeError: Error { private enum ConnectChallengeError: Error {
case timeout case timeout
@@ -301,8 +365,7 @@ actor GatewayWizardClient {
} }
let payload = payloadParts.joined(separator: "|") let payload = payloadParts.joined(separator: "|")
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity), if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
{
var device: [String: ProtoAnyCodable] = [ var device: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(identity.deviceId), "id": ProtoAnyCodable(identity.deviceId),
"publicKey": ProtoAnyCodable(publicKey), "publicKey": ProtoAnyCodable(publicKey),
@@ -347,11 +410,10 @@ actor GatewayWizardClient {
operation: { operation: {
while true { while true {
let message = try await task.receive() let message = try await task.receive()
let frame = try await self.decodeFrame(message) let frame = try decodeFrame(message)
if case let .event(evt) = frame, evt.event == "connect.challenge" { if case let .event(evt) = frame, evt.event == "connect.challenge" {
if let payload = evt.payload?.value as? [String: ProtoAnyCodable], if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = payload["nonce"]?.value as? String let nonce = payload["nonce"]?.value as? String {
{
return nonce return nonce
} }
} }
@@ -20,7 +20,7 @@ struct WideAreaGatewayDiscoveryTests {
let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? "" let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? ""
if recordType == "PTR" { if recordType == "PTR" {
if nameserver == "@100.123.224.76" { if nameserver == "@100.123.224.76" {
return "steipetacstudio-gateway._clawdbot-gw._tcp.clawdbot.internal.\n" return "steipetacstudio-gateway._clawdbot-gateway._tcp.clawdbot.internal.\n"
} }
return "" return ""
} }
@@ -2,7 +2,7 @@ import Foundation
public enum ClawdbotBonjour { public enum ClawdbotBonjour {
// v0: internal-only, subject to rename. // v0: internal-only, subject to rename.
public static let gatewayServiceType = "_clawdbot-gw._tcp" public static let gatewayServiceType = "_clawdbot-gateway._tcp"
public static let gatewayServiceDomain = "local." public static let gatewayServiceDomain = "local."
public static let wideAreaGatewayServiceDomain = "clawdbot.internal." public static let wideAreaGatewayServiceDomain = "clawdbot.internal."
@@ -498,7 +498,7 @@ public actor GatewayChannelActor {
} }
} }
private nonisolated func decodeMessageData(_ msg: URLSessionWebSocketTask.Message) -> Data? { private func decodeMessageData(_ msg: URLSessionWebSocketTask.Message) -> Data? {
let data: Data? = switch msg { let data: Data? = switch msg {
case let .data(data): data case let .data(data): data
case let .string(text): text.data(using: .utf8) case let .string(text): text.data(using: .utf8)
@@ -29,10 +29,5 @@ public struct GatewayDecodingError: LocalizedError, Sendable {
public let method: String public let method: String
public let message: String public let message: String
public init(method: String, message: String) {
self.method = method
self.message = message
}
public var errorDescription: String? { "\(self.method): \(self.message)" } public var errorDescription: String? { "\(self.method): \(self.message)" }
} }
@@ -36,7 +36,7 @@ public enum GatewayTLSStore {
} }
} }
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, @unchecked Sendable { public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate {
private let params: GatewayTLSParams private let params: GatewayTLSParams
private lazy var session: URLSession = { private lazy var session: URLSession = {
let config = URLSessionConfiguration.default let config = URLSessionConfiguration.default
@@ -96,12 +96,10 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
} }
private func certificateFingerprint(_ trust: SecTrust) -> String? { private func certificateFingerprint(_ trust: SecTrust) -> String? {
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], let count = SecTrustGetCertificateCount(trust)
let cert = chain.first guard count > 0, let cert = SecTrustGetCertificateAtIndex(trust, 0) else { return nil }
else { let data = SecCertificateCopyData(cert) as Data
return nil return sha256Hex(data)
}
return sha256Hex(SecCertificateCopyData(cert) as Data)
} }
private func sha256Hex(_ data: Data) -> String { private func sha256Hex(_ data: Data) -> String {
@@ -110,9 +108,5 @@ private func sha256Hex(_ data: Data) -> String {
} }
private func normalizeFingerprint(_ raw: String) -> String { private func normalizeFingerprint(_ raw: String) -> String {
let stripped = raw.replacingOccurrences( raw.lowercased().filter(\.isHexDigit)
of: #"(?i)^sha-?256\s*:?\s*"#,
with: "",
options: .regularExpression)
return stripped.lowercased().filter(\.isHexDigit)
} }
-5
View File
@@ -127,11 +127,6 @@ Isolated jobs can deliver output to a channel. The job payload can specify:
If `channel` or `to` is omitted, cron can fall back to the main session’s “last route” If `channel` or `to` is omitted, cron can fall back to the main session’s “last route”
(the last place the agent replied). (the last place the agent replied).
Delivery notes:
- If `to` is set, cron auto-delivers the agent’s final output even if `deliver` is omitted.
- Use `deliver: true` when you want last-route delivery without an explicit `to`.
- Use `deliver: false` to keep output internal even if a `to` is present.
Target format reminders: Target format reminders:
- Slack/Discord targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity. - Slack/Discord targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
- Telegram topics should use the `:topic:` form (see below). - Telegram topics should use the `:topic:` form (see below).
-1
View File
@@ -21,7 +21,6 @@ Text is supported everywhere; media and reactions vary by channel.
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately). - [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately). - [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately). - [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately).
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately). - [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately). - [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket. - [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
-235
View File
@@ -1,235 +0,0 @@
---
summary: "Nostr DM channel via NIP-04 encrypted messages"
read_when:
- You want Clawdbot to receive DMs via Nostr
- You're setting up decentralized messaging
---
# Nostr
**Status:** Optional plugin (disabled by default).
Nostr is a decentralized protocol for social networking. This channel enables Clawdbot to receive and respond to encrypted direct messages (DMs) via NIP-04.
## Install (on demand)
### Onboarding (recommended)
- The onboarding wizard (`clawdbot onboard`) and `clawdbot channels add` list optional channel plugins.
- Selecting Nostr prompts you to install the plugin on demand.
Install defaults:
- **Dev channel + git checkout available:** uses the local plugin path.
- **Stable/Beta:** downloads from npm.
You can always override the choice in the prompt.
### Manual install
```bash
clawdbot plugins install @clawdbot/nostr
```
Use a local checkout (dev workflows):
```bash
clawdbot plugins install --link <path-to-clawdbot>/extensions/nostr
```
Restart the Gateway after installing or enabling plugins.
## Quick setup
1) Generate a Nostr keypair (if needed):
```bash
# Using nak
nak key generate
```
2) Add to config:
```json
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}"
}
}
}
```
3) Export the key:
```bash
export NOSTR_PRIVATE_KEY="nsec1..."
```
4) Restart the Gateway.
## Configuration reference
| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `privateKey` | string | required | Private key in `nsec` or hex format |
| `relays` | string[] | `['wss://relay.damus.io', 'wss://nos.lol']` | Relay URLs (WebSocket) |
| `dmPolicy` | string | `pairing` | DM access policy |
| `allowFrom` | string[] | `[]` | Allowed sender pubkeys |
| `enabled` | boolean | `true` | Enable/disable channel |
| `name` | string | - | Display name |
| `profile` | object | - | NIP-01 profile metadata |
## Profile metadata
Profile data is published as a NIP-01 `kind:0` event. You can manage it from the Control UI (Channels -> Nostr -> Profile) or set it directly in config.
Example:
```json
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"profile": {
"name": "clawdbot",
"displayName": "Clawdbot",
"about": "Personal assistant DM bot",
"picture": "https://example.com/avatar.png",
"banner": "https://example.com/banner.png",
"website": "https://example.com",
"nip05": "clawdbot@example.com",
"lud16": "clawdbot@example.com"
}
}
}
}
```
Notes:
- Profile URLs must use `https://`.
- Importing from relays merges fields and preserves local overrides.
## Access control
### DM policies
- **pairing** (default): unknown senders get a pairing code.
- **allowlist**: only pubkeys in `allowFrom` can DM.
- **open**: public inbound DMs (requires `allowFrom: ["*"]`).
- **disabled**: ignore inbound DMs.
### Allowlist example
```json
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"dmPolicy": "allowlist",
"allowFrom": ["npub1abc...", "npub1xyz..."]
}
}
}
```
## Key formats
Accepted formats:
- **Private key:** `nsec...` or 64-char hex
- **Pubkeys (`allowFrom`):** `npub...` or hex
## Relays
Defaults: `relay.damus.io` and `nos.lol`.
```json
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"relays": [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nostr.wine"
]
}
}
}
```
Tips:
- Use 2-3 relays for redundancy.
- Avoid too many relays (latency, duplication).
- Paid relays can improve reliability.
- Local relays are fine for testing (`ws://localhost:7777`).
## Protocol support
| NIP | Status | Description |
| --- | --- | --- |
| NIP-01 | Supported | Basic event format + profile metadata |
| NIP-04 | Supported | Encrypted DMs (`kind:4`) |
| NIP-17 | Planned | Gift-wrapped DMs |
| NIP-44 | Planned | Versioned encryption |
## Testing
### Local relay
```bash
# Start strfry
docker run -p 7777:7777 ghcr.io/hoytech/strfry
```
```json
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"relays": ["ws://localhost:7777"]
}
}
}
```
### Manual test
1) Note the bot pubkey (npub) from logs.
2) Open a Nostr client (Damus, Amethyst, etc.).
3) DM the bot pubkey.
4) Verify the response.
## Troubleshooting
### Not receiving messages
- Verify the private key is valid.
- Ensure relay URLs are reachable and use `wss://` (or `ws://` for local).
- Confirm `enabled` is not `false`.
- Check Gateway logs for relay connection errors.
### Not sending responses
- Check relay accepts writes.
- Verify outbound connectivity.
- Watch for relay rate limits.
### Duplicate responses
- Expected when using multiple relays.
- Messages are deduplicated by event ID; only the first delivery triggers a response.
## Security
- Never commit private keys.
- Use environment variables for keys.
- Consider `allowlist` for production bots.
## Limitations (MVP)
- Direct messages only (no group chats).
- No media attachments.
- NIP-04 only (NIP-17 gift-wrap planned).
+1 -1
View File
@@ -116,7 +116,7 @@ clawdbot gateway call logs.tail --params '{"sinceMs": 60000}'
## Discover gateways (Bonjour) ## Discover gateways (Bonjour)
`gateway discover` scans for Gateway beacons (`_clawdbot-gw._tcp`). `gateway discover` scans for Gateway beacons (`_clawdbot-gateway._tcp`).
- Multicast DNS-SD: `local.` - Multicast DNS-SD: `local.`
- Unicast DNS-SD (Wide-Area Bonjour): `clawdbot.internal.` (requires split DNS + DNS server; see [/gateway/bonjour](/gateway/bonjour)) - Unicast DNS-SD (Wide-Area Bonjour): `clawdbot.internal.` (requires split DNS + DNS server; see [/gateway/bonjour](/gateway/bonjour))
-3
View File
@@ -114,9 +114,6 @@ clawdbot sandbox recreate --agent alfred
**Solution:** Use `clawdbot sandbox recreate` to force removal of old containers. They'll be recreated automatically with current settings when next needed. **Solution:** Use `clawdbot sandbox recreate` to force removal of old containers. They'll be recreated automatically with current settings when next needed.
Tip: prefer `clawdbot sandbox recreate` over manual `docker rm`. It uses the
Gateway’s container naming and avoids mismatches when scope/session keys change.
## Configuration ## Configuration
Sandbox settings live in `~/.clawdbot/clawdbot.json` under `agents.defaults.sandbox` (per-agent overrides go in `agents.list[].sandbox`): Sandbox settings live in `~/.clawdbot/clawdbot.json` under `agents.defaults.sandbox` (per-agent overrides go in `agents.list[].sandbox`):
-1
View File
@@ -21,4 +21,3 @@ clawdbot security audit --fix
``` ```
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes. The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
It also warns when small models (<=300B) are used without sandboxing and with web/browser tools enabled.
-1
View File
@@ -20,5 +20,4 @@ Notes:
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal). - `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
- Output includes per-agent session stores when multiple agents are configured. - Output includes per-agent session stores when multiple agents are configured.
- Overview includes Gateway + Node service install/runtime status when available. - Overview includes Gateway + Node service install/runtime status when available.
- Overview includes update channel + git SHA (for source checkouts).
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)). - Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
+5 -30
View File
@@ -15,9 +15,7 @@ If you installed via **npm/pnpm** (global install, no git metadata), use the pac
```bash ```bash
clawdbot update clawdbot update
clawdbot update status
clawdbot update --channel beta clawdbot update --channel beta
clawdbot update --channel dev
clawdbot update --tag beta clawdbot update --tag beta
clawdbot update --restart clawdbot update --restart
clawdbot update --json clawdbot update --json
@@ -27,44 +25,22 @@ clawdbot --update
## Options ## Options
- `--restart`: restart the Gateway daemon after a successful update. - `--restart`: restart the Gateway daemon after a successful update.
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config). - `--channel <stable|beta>`: set the update channel for npm installs (persisted in config).
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only. - `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
- `--json`: print machine-readable `UpdateRunResult` JSON. - `--json`: print machine-readable `UpdateRunResult` JSON.
- `--timeout <seconds>`: per-step timeout (default is 1200s). - `--timeout <seconds>`: per-step timeout (default is 1200s).
Note: downgrades require confirmation because older versions can break configuration. Note: downgrades require confirmation because older versions can break configuration.
## `update status`
Show the active update channel + git tag/branch/SHA (for source checkouts), plus update availability.
```bash
clawdbot update status
clawdbot update status --json
clawdbot update status --timeout 10
```
Options:
- `--json`: print machine-readable status JSON.
- `--timeout <seconds>`: timeout for checks (default is 3s).
## What it does (git checkout) ## What it does (git checkout)
Channels:
- `stable`: checkout the latest non-beta tag, then build + doctor.
- `beta`: checkout the latest `-beta` tag, then build + doctor.
- `dev`: checkout `main`, then fetch + rebase.
High-level: High-level:
1. Requires a clean worktree (no uncommitted changes). 1. Requires a clean worktree (no uncommitted changes).
2. Switches to the selected channel (tag or branch). 2. Fetches and rebases against `@{upstream}`.
3. Fetches and rebases against `@{upstream}` (dev only). 3. Installs deps (pnpm preferred; npm fallback).
4. Installs deps (pnpm preferred; npm fallback). 4. Builds + builds the Control UI.
5. Builds + builds the Control UI. 5. Runs `clawdbot doctor` as the final “safe update” check.
6. Runs `clawdbot doctor` as the final “safe update” check.
7. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins.
## `--update` shorthand ## `--update` shorthand
@@ -73,6 +49,5 @@ High-level:
## See also ## See also
- `clawdbot doctor` (offers to run update first on git checkouts) - `clawdbot doctor` (offers to run update first on git checkouts)
- [Development channels](/install/development-channels)
- [Updating](/install/updating) - [Updating](/install/updating)
- [CLI reference](/cli) - [CLI reference](/cli)
-46
View File
@@ -256,52 +256,6 @@ Keep WhatsApp on the fast agent, but route one DM to Opus:
Peer bindings always win, so keep them above the channel-wide rule. Peer bindings always win, so keep them above the channel-wide rule.
## Family agent bound to a WhatsApp group
Bind a dedicated family agent to a single WhatsApp group, with mention gating
and a tighter tool policy:
```json5
{
agents: {
list: [
{
id: "family",
name: "Family",
workspace: "~/clawd-family",
identity: { name: "Family Bot" },
groupChat: {
mentionPatterns: ["@family", "@familybot", "@Family Bot"]
},
sandbox: {
mode: "all",
scope: "agent"
},
tools: {
allow: ["exec", "read", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "session_status"],
deny: ["write", "edit", "apply_patch", "browser", "canvas", "nodes", "cron"]
}
}
]
},
bindings: [
{
agentId: "family",
match: {
channel: "whatsapp",
peer: { kind: "group", id: "120363999999999999@g.us" }
}
}
]
}
```
Notes:
- Tool allow/deny lists are **tools**, not skills. If a skill needs to run a
binary, ensure `exec` is allowed and the binary exists in the sandbox.
- For stricter gating, set `agents.list[].groupChat.mentionPatterns` and keep
group allowlists enabled for the channel.
## Per-Agent Sandbox and Tool Configuration ## Per-Agent Sandbox and Tool Configuration
Starting with v2026.1.6, each agent can have its own sandbox and tool restrictions: Starting with v2026.1.6, each agent can have its own sandbox and tool restrictions:
+1 -1
View File
@@ -60,7 +60,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
- Idle reset (optional): `idleMinutes` adds a sliding idle window. When both daily and idle resets are configured, **whichever expires first** forces a new session. - Idle reset (optional): `idleMinutes` adds a sliding idle window. When both daily and idle resets are configured, **whichever expires first** forces a new session.
- Legacy idle-only: if you set `session.idleMinutes` without any `session.reset`/`resetByType` config, Clawdbot stays in idle-only mode for backward compatibility. - Legacy idle-only: if you set `session.idleMinutes` without any `session.reset`/`resetByType` config, Clawdbot stays in idle-only mode for backward compatibility.
- Per-type overrides (optional): `resetByType` lets you override the policy for `dm`, `group`, and `thread` sessions (thread = Slack/Discord threads, Telegram topics, Matrix threads when provided by the connector). - Per-type overrides (optional): `resetByType` lets you override the policy for `dm`, `group`, and `thread` sessions (thread = Slack/Discord threads, Telegram topics, Matrix threads when provided by the connector).
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. `/new <model>` accepts a model alias, `provider/model`, or provider name (fuzzy match) to set the new session model. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset. - Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them. - Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse). - Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
-1
View File
@@ -793,7 +793,6 @@
"install/index", "install/index",
"install/installer", "install/installer",
"install/updating", "install/updating",
"install/development-channels",
"install/uninstall", "install/uninstall",
"install/ansible", "install/ansible",
"install/nix", "install/nix",
+9 -9
View File
@@ -19,7 +19,7 @@ boundary. You can keep the same discovery UX by switching to **unicast DNS‑SD*
High‑level steps: High‑level steps:
1) Run a DNS server on the gateway host (reachable over Tailnet). 1) Run a DNS server on the gateway host (reachable over Tailnet).
2) Publish DNS‑SD records for `_clawdbot-gw._tcp` under a dedicated zone 2) Publish DNS‑SD records for `_clawdbot-bridge._tcp` under a dedicated zone
(example: `clawdbot.internal.`). (example: `clawdbot.internal.`).
3) Configure Tailscale **split DNS** so `clawdbot.internal` resolves via that 3) Configure Tailscale **split DNS** so `clawdbot.internal` resolves via that
DNS server for clients (including iOS). DNS server for clients (including iOS).
@@ -49,8 +49,8 @@ This installs CoreDNS and configures it to:
Validate from a tailnet‑connected machine: Validate from a tailnet‑connected machine:
```bash ```bash
dns-sd -B _clawdbot-gw._tcp clawdbot.internal. dns-sd -B _clawdbot-bridge._tcp clawdbot.internal.
dig @<TAILNET_IPV4> -p 53 _clawdbot-gw._tcp.clawdbot.internal PTR +short dig @<TAILNET_IPV4> -p 53 _clawdbot-bridge._tcp.clawdbot.internal PTR +short
``` ```
### Tailscale DNS settings ### Tailscale DNS settings
@@ -61,7 +61,7 @@ In the Tailscale admin console:
- Add split DNS so the domain `clawdbot.internal` uses that nameserver. - Add split DNS so the domain `clawdbot.internal` uses that nameserver.
Once clients accept tailnet DNS, iOS nodes can browse Once clients accept tailnet DNS, iOS nodes can browse
`_clawdbot-gw._tcp` in `clawdbot.internal.` without multicast. `_clawdbot-bridge._tcp` in `clawdbot.internal.` without multicast.
### Bridge listener security (recommended) ### Bridge listener security (recommended)
@@ -74,11 +74,11 @@ For tailnet‑only setups:
## What advertises ## What advertises
Only the Gateway advertises `_clawdbot-gw._tcp`. Only the Gateway (when the **bridge is enabled**) advertises `_clawdbot-bridge._tcp`.
## Service types ## Service types
- `_clawdbot-gw._tcp` — gateway transport beacon (used by macOS/iOS/Android nodes). - `_clawdbot-bridge._tcp` — bridge transport beacon (used by macOS/iOS/Android nodes).
## TXT keys (non‑secret hints) ## TXT keys (non‑secret hints)
@@ -101,11 +101,11 @@ Useful built‑in tools:
- Browse instances: - Browse instances:
```bash ```bash
dns-sd -B _clawdbot-gw._tcp local. dns-sd -B _clawdbot-bridge._tcp local.
``` ```
- Resolve one instance (replace `<instance>`): - Resolve one instance (replace `<instance>`):
```bash ```bash
dns-sd -L "<instance>" _clawdbot-gw._tcp local. dns-sd -L "<instance>" _clawdbot-bridge._tcp local.
``` ```
If browsing works but resolving fails, you’re usually hitting a LAN policy or If browsing works but resolving fails, you’re usually hitting a LAN policy or
@@ -122,7 +122,7 @@ The Gateway writes a rolling log file (printed on startup as
## Debugging on iOS node ## Debugging on iOS node
The iOS node uses `NWBrowser` to discover `_clawdbot-gw._tcp`. The iOS node uses `NWBrowser` to discover `_clawdbot-bridge._tcp`.
To capture logs: To capture logs:
- Settings → Bridge → Advanced → **Discovery Debug Logs** - Settings → Bridge → Advanced → **Discovery Debug Logs**
+2 -2
View File
@@ -1414,7 +1414,7 @@ Each `agents.defaults.models` entry can include:
- `alias` (optional model shortcut, e.g. `/opus`). - `alias` (optional model shortcut, e.g. `/opus`).
- `params` (optional provider-specific API params passed through to the model request). - `params` (optional provider-specific API params passed through to the model request).
`params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`, `cacheControlTtl` (`"5m"` or `"1h"`, Anthropic API + OpenRouter Anthropic models only; ignored for Anthropic OAuth/Claude Code tokens). These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the model’s defaults and need a change. Anthropic API defaults to `"1h"` unless you override (`cacheControlTtl: "5m"`). Clawdbot includes the `extended-cache-ttl-2025-04-11` beta flag for Anthropic API; keep it if you override provider headers. `params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`. These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the model’s defaults and need a change.
Example: Example:
@@ -2988,7 +2988,7 @@ Auto-generated certs require `openssl` on PATH; if generation fails, the bridge
### `discovery.wideArea` (Wide-Area Bonjour / unicast DNS‑SD) ### `discovery.wideArea` (Wide-Area Bonjour / unicast DNS‑SD)
When enabled, the Gateway writes a unicast DNS-SD zone for `_clawdbot-gw._tcp` under `~/.clawdbot/dns/` using the standard discovery domain `clawdbot.internal.` When enabled, the Gateway writes a unicast DNS-SD zone for `_clawdbot-bridge._tcp` under `~/.clawdbot/dns/` using the standard discovery domain `clawdbot.internal.`
To make iOS/Android discover across networks (Vienna ⇄ London), pair this with: To make iOS/Android discover across networks (Vienna ⇄ London), pair this with:
- a DNS server on the gateway host serving `clawdbot.internal.` (CoreDNS is recommended) - a DNS server on the gateway host serving `clawdbot.internal.` (CoreDNS is recommended)
+1 -1
View File
@@ -51,7 +51,7 @@ Troubleshooting and beacon details: [Bonjour](/gateway/bonjour).
#### Service beacon details #### Service beacon details
- Service types: - Service types:
- `_clawdbot-gw._tcp` (gateway transport beacon) - `_clawdbot-bridge._tcp` (bridge transport beacon)
- TXT keys (non-secret): - TXT keys (non-secret):
- `role=gateway` - `role=gateway`
- `lanHost=<hostname>.local` - `lanHost=<hostname>.local`
-7
View File
@@ -105,11 +105,6 @@ Build it once:
scripts/sandbox-setup.sh scripts/sandbox-setup.sh
``` ```
Note: the default image does **not** include Node. If a skill needs Node (or
other runtimes), either bake a custom image or install via
`sandbox.docker.setupCommand` (requires network egress + writable root +
root user).
Sandboxed browser image: Sandboxed browser image:
```bash ```bash
scripts/sandbox-browser-setup.sh scripts/sandbox-browser-setup.sh
@@ -134,8 +129,6 @@ Common pitfalls:
- Default `docker.network` is `"none"` (no egress), so package installs will fail. - Default `docker.network` is `"none"` (no egress), so package installs will fail.
- `readOnlyRoot: true` prevents writes; set `readOnlyRoot: false` or bake a custom image. - `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"`). - `user` must be root for package installs (omit `user` or set `user: "0:0"`).
- Sandbox exec does **not** inherit host `process.env`. Use
`agents.defaults.sandbox.docker.env` (or a custom image) for skill API keys.
## 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
-1
View File
@@ -177,7 +177,6 @@ Recommendations:
- **Use the latest generation, best-tier model** for any bot that can run tools or touch files/networks. - **Use the latest generation, best-tier model** for any bot that can run tools or touch files/networks.
- **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes. - **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes.
- If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists). - If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists).
- When running small models, **enable sandboxing for all sessions** and **disable web_search/web_fetch/browser** unless inputs are tightly controlled.
## Reasoning & verbose output in groups ## Reasoning & verbose output in groups
-14
View File
@@ -79,25 +79,11 @@ This intentionally excludes version managers (nvm/fnm/volta/asdf) and package
managers (pnpm/npm) because the daemon does not load your shell init. Runtime managers (pnpm/npm) because the daemon does not load your shell init. Runtime
variables like `DISPLAY` should live in `~/.clawdbot/.env` (loaded early by the variables like `DISPLAY` should live in `~/.clawdbot/.env` (loaded early by the
gateway). gateway).
Exec runs on `host=gateway` merge your login-shell `PATH` into the exec environment,
so missing tools usually mean your shell init isn’t exporting them (or set
`tools.exec.pathPrepend`). See [/tools/exec](/tools/exec).
WhatsApp + Telegram channels require **Node**; Bun is unsupported. If your WhatsApp + Telegram channels require **Node**; Bun is unsupported. If your
service was installed with Bun or a version-managed Node path, run `clawdbot doctor` service was installed with Bun or a version-managed Node path, run `clawdbot doctor`
to migrate to a system Node install. to migrate to a system Node install.
### Skill missing API key in sandbox
**Symptom:** Skill works on host but fails in sandbox with missing API key.
**Why:** sandboxed exec runs inside Docker and does **not** inherit host `process.env`.
**Fix:**
- set `agents.defaults.sandbox.docker.env` (or per-agent `agents.list[].sandbox.docker.env`)
- or bake the key into your custom sandbox image
- then run `clawdbot sandbox recreate --agent <id>` (or `--all`)
### Service Running but Port Not Listening ### Service Running but Port Not Listening
If the service reports **running** but nothing is listening on the gateway port, If the service reports **running** but nothing is listening on the gateway port,
-65
View File
@@ -1,65 +0,0 @@
---
summary: "Stable, beta, and dev channels: semantics, switching, and tagging"
read_when:
- You want to switch between stable/beta/dev
- You are tagging or publishing prereleases
---
# Development channels
Last updated: 2026-01-20
Clawdbot ships three update channels:
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`). npm dist-tag: `latest`.
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`). npm dist-tag: `beta`.
- **dev**: moving head of `main` (git). npm dist-tag: `dev` (when published).
## Switching channels
Git checkout:
```bash
clawdbot update --channel stable
clawdbot update --channel beta
clawdbot update --channel dev
```
- `stable`/`beta` check out the latest matching tag.
- `dev` switches to `main` and rebases on the upstream.
npm/pnpm global install:
```bash
clawdbot update --channel stable
clawdbot update --channel beta
clawdbot update --channel dev
```
This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`).
Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one.
## Plugins and channels
When you switch channels with `clawdbot update`, Clawdbot also syncs plugin sources:
- `dev` prefers bundled plugins from the git checkout.
- `stable` and `beta` restore npm-installed plugin packages.
## Tagging best practices
- Stable: tag each release (`vYYYY.M.D` or `vYYYY.M.D-<patch>`).
- Beta: use `vYYYY.M.D-beta.N` (increment `N`).
- Keep tags immutable: never move or reuse a tag.
- Publish dist-tags alongside git tags:
- `latest` → stable
- `beta` → prerelease
- `dev` → main snapshot (optional)
## macOS app availability
Beta and dev builds may **not** include a macOS app release. That’s OK:
- The git tag and npm dist-tag can still be published.
- Call out “no macOS build for this beta” in release notes or changelog.
+7 -6
View File
@@ -50,18 +50,20 @@ pnpm add -g clawdbot@latest
``` ```
We do **not** recommend Bun for the Gateway runtime (WhatsApp/Telegram bugs). We do **not** recommend Bun for the Gateway runtime (WhatsApp/Telegram bugs).
To switch update channels (git + npm installs): To stay on the beta channel for CLI updates:
```bash ```bash
clawdbot update --channel beta clawdbot update --channel beta
clawdbot update --channel dev ```
Switch back to stable later:
```bash
clawdbot update --channel stable clawdbot update --channel stable
``` ```
Use `--tag <dist-tag|version>` for a one-off install tag/version. Use `--tag <dist-tag|version>` for a one-off install tag/version.
See [Development channels](/install/development-channels) for channel semantics and release notes.
Note: on npm installs, the gateway logs an update hint on startup (checks the current channel tag). Disable via `update.checkOnStart: false`. Note: on npm installs, the gateway logs an update hint on startup (checks the current channel tag). Disable via `update.checkOnStart: false`.
Then: Then:
@@ -86,8 +88,7 @@ clawdbot update --restart
It runs a safe-ish update flow: It runs a safe-ish update flow:
- Requires a clean worktree. - Requires a clean worktree.
- Switches to the selected channel (tag or branch). - Fetches + rebases against the configured upstream.
- Fetches + rebases against the configured upstream (dev channel).
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`. - Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead. If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead.
-173
View File
@@ -136,179 +136,6 @@ Tool summaries can redact sensitive tokens before they hit the console:
Redaction affects **console output only** and does not alter file logs. Redaction affects **console output only** and does not alter file logs.
## Diagnostics + OpenTelemetry
Diagnostics are structured, machine-readable events for model runs **and**
message-flow telemetry (webhooks, queueing, session state). They do **not**
replace logs; they exist to feed metrics, traces, and other exporters.
Diagnostics events are emitted in-process, but exporters only attach when
diagnostics + the exporter plugin are enabled.
### OpenTelemetry vs OTLP
- **OpenTelemetry (OTel)**: the data model + SDKs for traces, metrics, and logs.
- **OTLP**: the wire protocol used to export OTel data to a collector/backend.
- Clawdbot exports via **OTLP/HTTP (protobuf)** today.
### Signals exported
- **Metrics**: counters + histograms (token usage, message flow, queueing).
- **Traces**: spans for model usage + webhook/message processing.
- **Logs**: exported over OTLP when `diagnostics.otel.logs` is enabled. Log
volume can be high; keep `logging.level` and exporter filters in mind.
### Diagnostic event catalog
Model usage:
- `model.usage`: tokens, cost, duration, context, provider/model/channel, session ids.
Message flow:
- `webhook.received`: webhook ingress per channel.
- `webhook.processed`: webhook handled + duration.
- `webhook.error`: webhook handler errors.
- `message.queued`: message enqueued for processing.
- `message.processed`: outcome + duration + optional error.
Queue + session:
- `queue.lane.enqueue`: command queue lane enqueue + depth.
- `queue.lane.dequeue`: command queue lane dequeue + wait time.
- `session.state`: session state transition + reason.
- `session.stuck`: session stuck warning + age.
- `run.attempt`: run retry/attempt metadata.
- `diagnostic.heartbeat`: aggregate counters (webhooks/queue/session).
### Enable diagnostics (no exporter)
Use this if you want diagnostics events available to plugins or custom sinks:
```json
{
"diagnostics": {
"enabled": true
}
}
```
### Export to OpenTelemetry
Diagnostics can be exported via the `diagnostics-otel` plugin (OTLP/HTTP). This
works with any OpenTelemetry collector/backend that accepts OTLP/HTTP.
```json
{
"plugins": {
"allow": ["diagnostics-otel"],
"entries": {
"diagnostics-otel": {
"enabled": true
}
}
},
"diagnostics": {
"enabled": true,
"otel": {
"enabled": true,
"endpoint": "http://otel-collector:4318",
"protocol": "http/protobuf",
"serviceName": "clawdbot-gateway",
"traces": true,
"metrics": true,
"logs": true,
"sampleRate": 0.2,
"flushIntervalMs": 60000
}
}
}
```
Notes:
- You can also enable the plugin with `clawdbot plugins enable diagnostics-otel`.
- `protocol` currently supports `http/protobuf` only. `grpc` is ignored.
- Metrics include token usage, cost, context size, run duration, and message-flow
counters/histograms (webhooks, queueing, session state, queue depth/wait).
- Traces/metrics can be toggled with `traces` / `metrics` (default: on). Traces
include model usage spans plus webhook/message processing spans when enabled.
- Set `headers` when your collector requires auth.
- Environment variables supported: `OTEL_EXPORTER_OTLP_ENDPOINT`,
`OTEL_SERVICE_NAME`, `OTEL_EXPORTER_OTLP_PROTOCOL`.
### Exported metrics (names + types)
Model usage:
- `clawdbot.tokens` (counter, attrs: `clawdbot.token`, `clawdbot.channel`,
`clawdbot.provider`, `clawdbot.model`)
- `clawdbot.cost.usd` (counter, attrs: `clawdbot.channel`, `clawdbot.provider`,
`clawdbot.model`)
- `clawdbot.run.duration_ms` (histogram, attrs: `clawdbot.channel`,
`clawdbot.provider`, `clawdbot.model`)
- `clawdbot.context.tokens` (histogram, attrs: `clawdbot.context`,
`clawdbot.channel`, `clawdbot.provider`, `clawdbot.model`)
Message flow:
- `clawdbot.webhook.received` (counter, attrs: `clawdbot.channel`,
`clawdbot.webhook`)
- `clawdbot.webhook.error` (counter, attrs: `clawdbot.channel`,
`clawdbot.webhook`)
- `clawdbot.webhook.duration_ms` (histogram, attrs: `clawdbot.channel`,
`clawdbot.webhook`)
- `clawdbot.message.queued` (counter, attrs: `clawdbot.channel`,
`clawdbot.source`)
- `clawdbot.message.processed` (counter, attrs: `clawdbot.channel`,
`clawdbot.outcome`)
- `clawdbot.message.duration_ms` (histogram, attrs: `clawdbot.channel`,
`clawdbot.outcome`)
Queues + sessions:
- `clawdbot.queue.lane.enqueue` (counter, attrs: `clawdbot.lane`)
- `clawdbot.queue.lane.dequeue` (counter, attrs: `clawdbot.lane`)
- `clawdbot.queue.depth` (histogram, attrs: `clawdbot.lane` or
`clawdbot.channel=heartbeat`)
- `clawdbot.queue.wait_ms` (histogram, attrs: `clawdbot.lane`)
- `clawdbot.session.state` (counter, attrs: `clawdbot.state`, `clawdbot.reason`)
- `clawdbot.session.stuck` (counter, attrs: `clawdbot.state`)
- `clawdbot.session.stuck_age_ms` (histogram, attrs: `clawdbot.state`)
- `clawdbot.run.attempt` (counter, attrs: `clawdbot.attempt`)
### Exported spans (names + key attributes)
- `clawdbot.model.usage`
- `clawdbot.channel`, `clawdbot.provider`, `clawdbot.model`
- `clawdbot.sessionKey`, `clawdbot.sessionId`
- `clawdbot.tokens.*` (input/output/cache_read/cache_write/total)
- `clawdbot.webhook.processed`
- `clawdbot.channel`, `clawdbot.webhook`, `clawdbot.chatId`
- `clawdbot.webhook.error`
- `clawdbot.channel`, `clawdbot.webhook`, `clawdbot.chatId`,
`clawdbot.error`
- `clawdbot.message.processed`
- `clawdbot.channel`, `clawdbot.outcome`, `clawdbot.chatId`,
`clawdbot.messageId`, `clawdbot.sessionKey`, `clawdbot.sessionId`,
`clawdbot.reason`
- `clawdbot.session.stuck`
- `clawdbot.state`, `clawdbot.ageMs`, `clawdbot.queueDepth`,
`clawdbot.sessionKey`, `clawdbot.sessionId`
### Sampling + flushing
- Trace sampling: `diagnostics.otel.sampleRate` (0.0–1.0, root spans only).
- Metric export interval: `diagnostics.otel.flushIntervalMs` (min 1000ms).
### Protocol notes
- OTLP/HTTP endpoints can be set via `diagnostics.otel.endpoint` or
`OTEL_EXPORTER_OTLP_ENDPOINT`.
- If the endpoint already contains `/v1/traces` or `/v1/metrics`, it is used as-is.
- If the endpoint already contains `/v1/logs`, it is used as-is for logs.
- `diagnostics.otel.logs` enables OTLP log export for the main logger output.
### Log export behavior
- OTLP logs use the same structured records written to `logging.file`.
- Respect `logging.level` (file log level). Console redaction does **not** apply
to OTLP logs.
- High-volume installs should prefer OTLP collector sampling/filtering.
## Troubleshooting tips ## Troubleshooting tips
- **Gateway not reachable?** Run `clawdbot doctor` first. - **Gateway not reachable?** Run `clawdbot doctor` first.
+2 -2
View File
@@ -52,7 +52,7 @@ For tailnet-only setups (recommended for Vienna ⇄ London), bind the gateway to
From the gateway machine: From the gateway machine:
```bash ```bash
dns-sd -B _clawdbot-gw._tcp local. dns-sd -B _clawdbot-gateway._tcp local.
``` ```
More debugging notes: [Bonjour](/gateway/bonjour). More debugging notes: [Bonjour](/gateway/bonjour).
@@ -61,7 +61,7 @@ More debugging notes: [Bonjour](/gateway/bonjour).
Android NSD/mDNS discovery won’t cross networks. If your Android node and the gateway are on different networks but connected via Tailscale, use Wide-Area Bonjour / unicast DNS-SD instead: Android NSD/mDNS discovery won’t cross networks. If your Android node and the gateway are on different networks but connected via Tailscale, use Wide-Area Bonjour / unicast DNS-SD instead:
1) Set up a DNS-SD zone (example `clawdbot.internal.`) on the gateway host and publish `_clawdbot-gw._tcp` records. 1) Set up a DNS-SD zone (example `clawdbot.internal.`) on the gateway host and publish `_clawdbot-gateway._tcp` records.
2) Configure Tailscale split DNS for `clawdbot.internal` pointing at that DNS server. 2) Configure Tailscale split DNS for `clawdbot.internal` pointing at that DNS server.
Details and example CoreDNS config: [Bonjour](/gateway/bonjour). Details and example CoreDNS config: [Bonjour](/gateway/bonjour).
+7 -7
View File
@@ -29,17 +29,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled. # From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare. # APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=com.clawdbot.mac \ BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.20 \ APP_VERSION=2026.1.13 \
APP_BUILD="$(git rev-list --count HEAD)" \ APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \ BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \ SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-app.sh scripts/package-mac-app.sh
# Zip for distribution (includes resource forks for Sparkle delta support) # Zip for distribution (includes resource forks for Sparkle delta support)
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.20.zip ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.13.zip
# Optional: also build a styled DMG for humans (drag to /Applications) # Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.20.dmg scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.13.dmg
# Recommended: build + notarize/staple zip + DMG # Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once: # First, create a keychain profile once:
@@ -47,26 +47,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.20.dmg
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>" # --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \ NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
BUNDLE_ID=com.clawdbot.mac \ BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.20 \ APP_VERSION=2026.1.13 \
APP_BUILD="$(git rev-list --count HEAD)" \ APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \ BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \ SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release # Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.20.dSYM.zip ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.13.dSYM.zip
``` ```
## Appcast entry ## Appcast entry
Use the release note generator so Sparkle renders formatted HTML notes: Use the release note generator so Sparkle renders formatted HTML notes:
```bash ```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.20.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.13.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
``` ```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing. Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
## Publish & verify ## Publish & verify
- Upload `Clawdbot-2026.1.20.zip` (and `Clawdbot-2026.1.20.dSYM.zip`) to the GitHub release for tag `v2026.1.20`. - Upload `Clawdbot-2026.1.13.zip` (and `Clawdbot-2026.1.13.dSYM.zip`) to the GitHub release for tag `v2026.1.13`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`. - Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
- Sanity checks: - Sanity checks:
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200. - `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
+1 -1
View File
@@ -5,7 +5,7 @@ read_when:
--- ---
# Clawdbot macOS IPC architecture # Clawdbot macOS IPC architecture
**Current model:** a local Unix socket connects the **node service** to the **macOS app** for exec approvals + `system.run`. A `clawdbot-mac` debug CLI exists for discovery/connect checks; agent actions still flow through the Gateway WebSocket and `node.invoke`. UI automation 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).
+6 -14
View File
@@ -140,27 +140,19 @@ Safety:
- `swift run Clawdbot` (or Xcode) - `swift run Clawdbot` (or Xcode)
- Package app: `scripts/package-mac-app.sh` - Package app: `scripts/package-mac-app.sh`
## Debug gateway connectivity (macOS CLI) ## Debug gateway discovery (macOS CLI)
Use the debug CLI to exercise the same Gateway WebSocket handshake and discovery Use the debug CLI to exercise the same Bonjour + wide‑area discovery code that the
logic that the macOS app uses, without launching the app. macOS app uses, without launching the app.
```bash ```bash
cd apps/macos cd apps/macos
swift run clawdbot-mac connect --json swift run clawdbot-mac-discovery --timeout 3000 --json
swift run clawdbot-mac discover --timeout 3000 --json
``` ```
Connect options: Options:
- `--url <ws://host:port>`: override config
- `--mode <local|remote>`: resolve from config (default: config or local)
- `--probe`: force a fresh health probe
- `--timeout <ms>`: request timeout (default: `15000`)
- `--json`: structured output for diffing
Discovery options:
- `--include-local`: include gateways that would be filtered as “local” - `--include-local`: include gateways that would be filtered as “local”
- `--timeout <ms>`: overall discovery window (default: `2000`) - `--timeout <ms>`: overall discovery window (default `2000`)
- `--json`: structured output for diffing - `--json`: structured output for diffing
Tip: compare against `clawdbot gateway discover --json` to see whether the Tip: compare against `clawdbot gateway discover --json` to see whether the
-1
View File
@@ -41,7 +41,6 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- [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`
- [Nostr](/channels/nostr) — `@clawdbot/nostr`
- [Zalo](/channels/zalo) — `@clawdbot/zalo` - [Zalo](/channels/zalo) — `@clawdbot/zalo`
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams` - [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
- 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)
-24
View File
@@ -34,30 +34,6 @@ clawdbot onboard --anthropic-api-key "$ANTHROPIC_API_KEY"
} }
``` ```
## Prompt caching (Anthropic API)
Clawdbot enables **1-hour prompt caching by default** for Anthropic API keys.
This is **API-only**; Claude Code CLI OAuth ignores TTL settings.
To override the TTL per model, set `cacheControlTtl` in the model `params`:
```json5
{
agents: {
defaults: {
models: {
"anthropic/claude-opus-4-5": {
params: { cacheControlTtl: "5m" } // or "1h"
}
}
}
}
}
```
Clawdbot includes the `extended-cache-ttl-2025-04-11` beta flag for Anthropic API
requests; keep it if you override provider headers (see [/gateway/configuration](/gateway/configuration)).
## Option B: Claude Code CLI (setup-token or OAuth) ## Option B: Claude Code CLI (setup-token or OAuth)
**Best for:** using your Claude subscription or existing Claude Code CLI login. **Best for:** using your Claude subscription or existing Claude Code CLI login.
+1 -2
View File
@@ -57,8 +57,7 @@ Example:
### PATH handling ### PATH handling
- `host=gateway`: merges your login-shell `PATH` into the exec environment (unless the exec call - `host=gateway`: uses the Gateway process `PATH`. Daemons install a minimal `PATH`:
already sets `env.PATH`). The daemon itself still runs with a minimal `PATH`:
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin` - macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
- Linux: `/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`. - `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
-11
View File
@@ -62,14 +62,3 @@ Per-skill fields:
- Keys under `entries` map to the skill name by default. If a skill defines - Keys under `entries` map to the skill name by default. If a skill defines
`metadata.clawdbot.skillKey`, use that key instead. `metadata.clawdbot.skillKey`, use that key instead.
- Changes to skills are picked up on the next agent turn when the watcher is enabled. - Changes to skills are picked up on the next agent turn when the watcher is enabled.
### Sandboxed skills + env vars
When a session is **sandboxed**, skill processes run inside Docker. The sandbox
does **not** inherit the host `process.env`.
Use one of:
- `agents.defaults.sandbox.docker.env` (or per-agent `agents.list[].sandbox.docker.env`)
- bake the env into your custom sandbox image
Global `env` and `skills.entries.<skill>.env/apiKey` apply to **host** runs only.
-5
View File
@@ -155,10 +155,6 @@ Bundled/managed skills can be toggled and supplied with env values:
apiKey: "GEMINI_KEY_HERE", apiKey: "GEMINI_KEY_HERE",
env: { env: {
GEMINI_API_KEY: "GEMINI_KEY_HERE" GEMINI_API_KEY: "GEMINI_KEY_HERE"
},
config: {
endpoint: "https://example.invalid",
model: "nano-pro"
} }
}, },
peekaboo: { enabled: true }, peekaboo: { enabled: true },
@@ -177,7 +173,6 @@ Rules:
- `enabled: false` disables the skill even if it’s bundled/installed. - `enabled: false` disables the skill even if it’s bundled/installed.
- `env`: injected **only if** the variable isn’t already set in the process. - `env`: injected **only if** the variable isn’t already set in the process.
- `apiKey`: convenience for skills that declare `metadata.clawdbot.primaryEnv`. - `apiKey`: convenience for skills that declare `metadata.clawdbot.primaryEnv`.
- `config`: optional bag for custom per-skill fields; custom keys must live here.
- `allowBundled`: optional allowlist for **bundled** skills only. If set, only - `allowBundled`: optional allowlist for **bundled** skills only. If set, only
bundled skills in the list are eligible (managed/workspace skills unaffected). bundled skills in the list are eligible (managed/workspace skills unaffected).
+1 -4
View File
@@ -58,7 +58,6 @@ They run immediately, are stripped before the model sees the message, and the re
Text + native (when enabled): Text + native (when enabled):
- `/help` - `/help`
- `/commands` - `/commands`
- `/skill <name> [input]` (run a skill by name)
- `/status` (show current status; includes provider usage/quota for the current model provider when available) - `/status` (show current status; includes provider usage/quota for the current model provider when available)
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size) - `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/whoami` (show your sender id; alias: `/id`) - `/whoami` (show your sender id; alias: `/id`)
@@ -73,7 +72,7 @@ Text + native (when enabled):
- `/dock-slack` (alias: `/dock_slack`) (switch replies to Slack) - `/dock-slack` (alias: `/dock_slack`) (switch replies to Slack)
- `/activation mention|always` (groups only) - `/activation mention|always` (groups only)
- `/send on|off|inherit` (owner-only) - `/send on|off|inherit` (owner-only)
- `/reset` or `/new [model]` (optional model hint; remainder is passed through) - `/reset` or `/new`
- `/think <off|minimal|low|medium|high|xhigh>` (dynamic choices by model/provider; aliases: `/thinking`, `/t`) - `/think <off|minimal|low|medium|high|xhigh>` (dynamic choices by model/provider; aliases: `/thinking`, `/t`)
- `/verbose on|full|off` (alias: `/v`) - `/verbose on|full|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only) - `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
@@ -91,7 +90,6 @@ 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:`).
- `/new <model>` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body.
- 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; `/usage cost` prints a local cost summary from Clawdbot session logs. - `/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.
@@ -104,7 +102,6 @@ Notes:
- Currently: `/help`, `/commands`, `/status`, `/whoami` (`/id`). - Currently: `/help`, `/commands`, `/status`, `/whoami` (`/id`).
- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text. - Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text.
- **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`). - **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`).
- `/skill <name> [input]` runs a skill by name (useful when native command limits prevent per-skill commands).
- By default, skill commands are forwarded to the model as a normal request. - By default, skill commands are forwarded to the model as a normal request.
- Skills may optionally declare `command-dispatch: tool` to route the command directly to a tool (deterministic, no model). - Skills may optionally declare `command-dispatch: tool` to route the command directly to a tool (deterministic, no model).
- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg. - **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg.
+1 -2
View File
@@ -69,8 +69,7 @@ Note: the merge is additive, so main profiles are always available as fallbacks.
Sub-agents report back via an announce step: Sub-agents report back via an announce step:
- The announce step runs inside the sub-agent session (not the requester session). - The announce step runs inside the sub-agent session (not the requester session).
- If the sub-agent replies exactly `ANNOUNCE_SKIP`, nothing is posted. - If the sub-agent replies exactly `ANNOUNCE_SKIP`, nothing is posted.
- Otherwise the announce reply is posted to the requester chat channel via a follow-up `agent` call (`deliver=true`). - Otherwise the announce reply is posted to the requester chat channel via the gateway `send` method.
- Announce replies preserve thread/topic routing when available (Slack threads, Telegram topics, Matrix threads).
- Announce messages are normalized to a stable template: - Announce messages are normalized to a stable template:
- `Status:` derived from the run outcome (`success`, `error`, `timeout`, or `unknown`). - `Status:` derived from the run outcome (`success`, `error`, `timeout`, or `unknown`).
- `Result:` the summary content from the announce step (or `(not available)` if missing). - `Result:` the summary content from the announce step (or `(not available)` if missing).
+25 -9
View File
@@ -1,6 +1,4 @@
import { import {
BLUEBUBBLES_ACTION_NAMES,
BLUEBUBBLES_ACTIONS,
createActionGate, createActionGate,
jsonResult, jsonResult,
readBooleanParam, readBooleanParam,
@@ -51,7 +49,19 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
} }
/** Supported action names for BlueBubbles */ /** Supported action names for BlueBubbles */
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES); const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>([
"react",
"edit",
"unsend",
"reply",
"sendWithEffect",
"renameGroup",
"setGroupIcon",
"addParticipant",
"removeParticipant",
"leaveGroup",
"sendAttachment",
]);
export const bluebubblesMessageActions: ChannelMessageActionAdapter = { export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => { listActions: ({ cfg }) => {
@@ -59,13 +69,19 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
if (!account.enabled || !account.configured) return []; if (!account.enabled || !account.configured) return [];
const gate = createActionGate((cfg as ClawdbotConfig).channels?.bluebubbles?.actions); const gate = createActionGate((cfg as ClawdbotConfig).channels?.bluebubbles?.actions);
const actions = new Set<ChannelMessageActionName>(); const actions = new Set<ChannelMessageActionName>();
// Check if running on macOS 26+ (edit not supported)
const macOS26 = isMacOS26OrHigher(account.accountId); const macOS26 = isMacOS26OrHigher(account.accountId);
for (const action of BLUEBUBBLES_ACTION_NAMES) { if (gate("reactions")) actions.add("react");
const spec = BLUEBUBBLES_ACTIONS[action]; if (gate("edit") && !macOS26) actions.add("edit");
if (!spec?.gate) continue; if (gate("unsend")) actions.add("unsend");
if (spec.unsupportedOnMacOS26 && macOS26) continue; if (gate("reply")) actions.add("reply");
if (gate(spec.gate)) actions.add(action); if (gate("sendWithEffect")) actions.add("sendWithEffect");
} if (gate("renameGroup")) actions.add("renameGroup");
if (gate("setGroupIcon")) actions.add("setGroupIcon");
if (gate("addParticipant")) actions.add("addParticipant");
if (gate("removeParticipant")) actions.add("removeParticipant");
if (gate("leaveGroup")) actions.add("leaveGroup");
if (gate("sendAttachment")) actions.add("sendAttachment");
return Array.from(actions); return Array.from(actions);
}, },
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action), supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
@@ -1,8 +0,0 @@
{
"id": "diagnostics-otel",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
-16
View File
@@ -1,16 +0,0 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { createDiagnosticsOtelService } from "./src/service.js";
const plugin = {
id: "diagnostics-otel",
name: "Diagnostics OpenTelemetry",
description: "Export diagnostics events to OpenTelemetry",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
api.registerService(createDiagnosticsOtelService());
},
};
export default plugin;
-22
View File
@@ -1,22 +0,0 @@
{
"name": "@clawdbot/diagnostics-otel",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot diagnostics OpenTelemetry exporter",
"clawdbot": {
"extensions": ["./index.ts"]
},
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.210.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.210.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.210.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.210.0",
"@opentelemetry/resources": "^2.4.0",
"@opentelemetry/sdk-logs": "^0.210.0",
"@opentelemetry/sdk-metrics": "^2.4.0",
"@opentelemetry/sdk-node": "^0.210.0",
"@opentelemetry/sdk-trace-base": "^2.4.0",
"@opentelemetry/semantic-conventions": "^1.39.0"
}
}
@@ -1,220 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const registerLogTransportMock = vi.hoisted(() => vi.fn());
const telemetryState = vi.hoisted(() => {
const counters = new Map<string, { add: ReturnType<typeof vi.fn> }>();
const histograms = new Map<string, { record: ReturnType<typeof vi.fn> }>();
const tracer = {
startSpan: vi.fn((_name: string, _opts?: unknown) => ({
end: vi.fn(),
setStatus: vi.fn(),
})),
};
const meter = {
createCounter: vi.fn((name: string) => {
const counter = { add: vi.fn() };
counters.set(name, counter);
return counter;
}),
createHistogram: vi.fn((name: string) => {
const histogram = { record: vi.fn() };
histograms.set(name, histogram);
return histogram;
}),
};
return { counters, histograms, tracer, meter };
});
const sdkStart = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const sdkShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const logEmit = vi.hoisted(() => vi.fn());
const logShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock("@opentelemetry/api", () => ({
metrics: {
getMeter: () => telemetryState.meter,
},
trace: {
getTracer: () => telemetryState.tracer,
},
SpanStatusCode: {
ERROR: 2,
},
}));
vi.mock("@opentelemetry/sdk-node", () => ({
NodeSDK: class {
start = sdkStart;
shutdown = sdkShutdown;
},
}));
vi.mock("@opentelemetry/exporter-metrics-otlp-http", () => ({
OTLPMetricExporter: class {},
}));
vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({
OTLPTraceExporter: class {},
}));
vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({
OTLPLogExporter: class {},
}));
vi.mock("@opentelemetry/sdk-logs", () => ({
BatchLogRecordProcessor: class {},
LoggerProvider: class {
addLogRecordProcessor = vi.fn();
getLogger = vi.fn(() => ({
emit: logEmit,
}));
shutdown = logShutdown;
},
}));
vi.mock("@opentelemetry/sdk-metrics", () => ({
PeriodicExportingMetricReader: class {},
}));
vi.mock("@opentelemetry/sdk-trace-base", () => ({
ParentBasedSampler: class {},
TraceIdRatioBasedSampler: class {},
}));
vi.mock("@opentelemetry/resources", () => ({
Resource: class {
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
constructor(_value?: unknown) {}
},
}));
vi.mock("@opentelemetry/semantic-conventions", () => ({
SemanticResourceAttributes: {
SERVICE_NAME: "service.name",
},
}));
vi.mock("clawdbot/plugin-sdk", async () => {
const actual = await vi.importActual<typeof import("clawdbot/plugin-sdk")>("clawdbot/plugin-sdk");
return {
...actual,
registerLogTransport: registerLogTransportMock,
};
});
import { createDiagnosticsOtelService } from "./service.js";
import { emitDiagnosticEvent } from "clawdbot/plugin-sdk";
describe("diagnostics-otel service", () => {
beforeEach(() => {
telemetryState.counters.clear();
telemetryState.histograms.clear();
telemetryState.tracer.startSpan.mockClear();
telemetryState.meter.createCounter.mockClear();
telemetryState.meter.createHistogram.mockClear();
sdkStart.mockClear();
sdkShutdown.mockClear();
logEmit.mockClear();
logShutdown.mockClear();
registerLogTransportMock.mockReset();
});
test("records message-flow metrics and spans", async () => {
const registeredTransports: Array<(logObj: Record<string, unknown>) => void> = [];
const stopTransport = vi.fn();
registerLogTransportMock.mockImplementation((transport) => {
registeredTransports.push(transport);
return stopTransport;
});
const service = createDiagnosticsOtelService();
await service.start({
config: {
diagnostics: {
enabled: true,
otel: {
enabled: true,
endpoint: "http://otel-collector:4318",
protocol: "http/protobuf",
traces: true,
metrics: true,
logs: true,
},
},
},
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
});
emitDiagnosticEvent({
type: "webhook.received",
channel: "telegram",
updateType: "telegram-post",
});
emitDiagnosticEvent({
type: "webhook.processed",
channel: "telegram",
updateType: "telegram-post",
durationMs: 120,
});
emitDiagnosticEvent({
type: "message.queued",
channel: "telegram",
source: "telegram",
queueDepth: 2,
});
emitDiagnosticEvent({
type: "message.processed",
channel: "telegram",
outcome: "completed",
durationMs: 55,
});
emitDiagnosticEvent({
type: "queue.lane.dequeue",
lane: "main",
queueSize: 3,
waitMs: 10,
});
emitDiagnosticEvent({
type: "session.stuck",
state: "processing",
ageMs: 125_000,
});
emitDiagnosticEvent({
type: "run.attempt",
runId: "run-1",
attempt: 2,
});
expect(telemetryState.counters.get("clawdbot.webhook.received")?.add).toHaveBeenCalled();
expect(telemetryState.histograms.get("clawdbot.webhook.duration_ms")?.record).toHaveBeenCalled();
expect(telemetryState.counters.get("clawdbot.message.queued")?.add).toHaveBeenCalled();
expect(telemetryState.counters.get("clawdbot.message.processed")?.add).toHaveBeenCalled();
expect(telemetryState.histograms.get("clawdbot.message.duration_ms")?.record).toHaveBeenCalled();
expect(telemetryState.histograms.get("clawdbot.queue.wait_ms")?.record).toHaveBeenCalled();
expect(telemetryState.counters.get("clawdbot.session.stuck")?.add).toHaveBeenCalled();
expect(telemetryState.histograms.get("clawdbot.session.stuck_age_ms")?.record).toHaveBeenCalled();
expect(telemetryState.counters.get("clawdbot.run.attempt")?.add).toHaveBeenCalled();
const spanNames = telemetryState.tracer.startSpan.mock.calls.map((call) => call[0]);
expect(spanNames).toContain("clawdbot.webhook.processed");
expect(spanNames).toContain("clawdbot.message.processed");
expect(spanNames).toContain("clawdbot.session.stuck");
expect(registerLogTransportMock).toHaveBeenCalledTimes(1);
expect(registeredTransports).toHaveLength(1);
registeredTransports[0]?.({
0: "{\"subsystem\":\"diagnostic\"}",
1: "hello",
_meta: { logLevelName: "INFO", date: new Date() },
});
expect(logEmit).toHaveBeenCalled();
await service.stop?.();
});
});
-566
View File
@@ -1,566 +0,0 @@
import { metrics, trace, SpanStatusCode } from "@opentelemetry/api";
import type { SeverityNumber } from "@opentelemetry/api-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { Resource } from "@opentelemetry/resources";
import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
import type { ClawdbotPluginService, DiagnosticEventPayload } from "clawdbot/plugin-sdk";
import { onDiagnosticEvent, registerLogTransport } from "clawdbot/plugin-sdk";
const DEFAULT_SERVICE_NAME = "clawdbot";
function normalizeEndpoint(endpoint?: string): string | undefined {
const trimmed = endpoint?.trim();
return trimmed ? trimmed.replace(/\/+$/, "") : undefined;
}
function resolveOtelUrl(endpoint: string | undefined, path: string): string | undefined {
if (!endpoint) return undefined;
if (endpoint.includes("/v1/")) return endpoint;
return `${endpoint}/${path}`;
}
function resolveSampleRate(value: number | undefined): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
if (value < 0 || value > 1) return undefined;
return value;
}
export function createDiagnosticsOtelService(): ClawdbotPluginService {
let sdk: NodeSDK | null = null;
let logProvider: LoggerProvider | null = null;
let stopLogTransport: (() => void) | null = null;
let unsubscribe: (() => void) | null = null;
return {
id: "diagnostics-otel",
async start(ctx) {
const cfg = ctx.config.diagnostics;
const otel = cfg?.otel;
if (!cfg?.enabled || !otel?.enabled) return;
const protocol = otel.protocol ?? process.env.OTEL_EXPORTER_OTLP_PROTOCOL ?? "http/protobuf";
if (protocol !== "http/protobuf") {
ctx.logger.warn(`diagnostics-otel: unsupported protocol ${protocol}`);
return;
}
const endpoint = normalizeEndpoint(otel.endpoint ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT);
const headers = otel.headers ?? undefined;
const serviceName =
otel.serviceName?.trim() || process.env.OTEL_SERVICE_NAME || DEFAULT_SERVICE_NAME;
const sampleRate = resolveSampleRate(otel.sampleRate);
const tracesEnabled = otel.traces !== false;
const metricsEnabled = otel.metrics !== false;
const logsEnabled = otel.logs === true;
if (!tracesEnabled && !metricsEnabled && !logsEnabled) return;
const resource = new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: serviceName,
});
const traceUrl = resolveOtelUrl(endpoint, "v1/traces");
const metricUrl = resolveOtelUrl(endpoint, "v1/metrics");
const logUrl = resolveOtelUrl(endpoint, "v1/logs");
const traceExporter = tracesEnabled
? new OTLPTraceExporter({
...(traceUrl ? { url: traceUrl } : {}),
...(headers ? { headers } : {}),
})
: undefined;
const metricExporter = metricsEnabled
? new OTLPMetricExporter({
...(metricUrl ? { url: metricUrl } : {}),
...(headers ? { headers } : {}),
})
: undefined;
const metricReader = metricExporter
? new PeriodicExportingMetricReader({
exporter: metricExporter,
...(typeof otel.flushIntervalMs === "number"
? { exportIntervalMillis: Math.max(1000, otel.flushIntervalMs) }
: {}),
})
: undefined;
if (tracesEnabled || metricsEnabled) {
sdk = new NodeSDK({
resource,
...(traceExporter ? { traceExporter } : {}),
...(metricReader ? { metricReader } : {}),
...(sampleRate !== undefined
? {
sampler: new ParentBasedSampler({
root: new TraceIdRatioBasedSampler(sampleRate),
}),
}
: {}),
});
await sdk.start();
}
const logSeverityMap: Record<string, SeverityNumber> = {
TRACE: 1 as SeverityNumber,
DEBUG: 5 as SeverityNumber,
INFO: 9 as SeverityNumber,
WARN: 13 as SeverityNumber,
ERROR: 17 as SeverityNumber,
FATAL: 21 as SeverityNumber,
};
const meter = metrics.getMeter("clawdbot");
const tracer = trace.getTracer("clawdbot");
const tokensCounter = meter.createCounter("clawdbot.tokens", {
unit: "1",
description: "Token usage by type",
});
const costCounter = meter.createCounter("clawdbot.cost.usd", {
unit: "1",
description: "Estimated model cost (USD)",
});
const durationHistogram = meter.createHistogram("clawdbot.run.duration_ms", {
unit: "ms",
description: "Agent run duration",
});
const contextHistogram = meter.createHistogram("clawdbot.context.tokens", {
unit: "1",
description: "Context window size and usage",
});
const webhookReceivedCounter = meter.createCounter("clawdbot.webhook.received", {
unit: "1",
description: "Webhook requests received",
});
const webhookErrorCounter = meter.createCounter("clawdbot.webhook.error", {
unit: "1",
description: "Webhook processing errors",
});
const webhookDurationHistogram = meter.createHistogram("clawdbot.webhook.duration_ms", {
unit: "ms",
description: "Webhook processing duration",
});
const messageQueuedCounter = meter.createCounter("clawdbot.message.queued", {
unit: "1",
description: "Messages queued for processing",
});
const messageProcessedCounter = meter.createCounter("clawdbot.message.processed", {
unit: "1",
description: "Messages processed by outcome",
});
const messageDurationHistogram = meter.createHistogram("clawdbot.message.duration_ms", {
unit: "ms",
description: "Message processing duration",
});
const queueDepthHistogram = meter.createHistogram("clawdbot.queue.depth", {
unit: "1",
description: "Queue depth on enqueue/dequeue",
});
const queueWaitHistogram = meter.createHistogram("clawdbot.queue.wait_ms", {
unit: "ms",
description: "Queue wait time before execution",
});
const laneEnqueueCounter = meter.createCounter("clawdbot.queue.lane.enqueue", {
unit: "1",
description: "Command queue lane enqueue events",
});
const laneDequeueCounter = meter.createCounter("clawdbot.queue.lane.dequeue", {
unit: "1",
description: "Command queue lane dequeue events",
});
const sessionStateCounter = meter.createCounter("clawdbot.session.state", {
unit: "1",
description: "Session state transitions",
});
const sessionStuckCounter = meter.createCounter("clawdbot.session.stuck", {
unit: "1",
description: "Sessions stuck in processing",
});
const sessionStuckAgeHistogram = meter.createHistogram("clawdbot.session.stuck_age_ms", {
unit: "ms",
description: "Age of stuck sessions",
});
const runAttemptCounter = meter.createCounter("clawdbot.run.attempt", {
unit: "1",
description: "Run attempts",
});
if (logsEnabled) {
const logExporter = new OTLPLogExporter({
...(logUrl ? { url: logUrl } : {}),
...(headers ? { headers } : {}),
});
logProvider = new LoggerProvider({ resource });
logProvider.addLogRecordProcessor(
new BatchLogRecordProcessor(logExporter, {
...(typeof otel.flushIntervalMs === "number"
? { scheduledDelayMillis: Math.max(1000, otel.flushIntervalMs) }
: {}),
}),
);
const otelLogger = logProvider.getLogger("clawdbot");
stopLogTransport = registerLogTransport((logObj) => {
const safeStringify = (value: unknown) => {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
};
const meta = (logObj as Record<string, unknown>)._meta as
| {
logLevelName?: string;
date?: Date;
name?: string;
parentNames?: string[];
path?: {
filePath?: string;
fileLine?: string;
fileColumn?: string;
filePathWithLine?: string;
method?: string;
};
}
| undefined;
const logLevelName = meta?.logLevelName ?? "INFO";
const severityNumber = logSeverityMap[logLevelName] ?? (9 as SeverityNumber);
const numericArgs = Object.entries(logObj)
.filter(([key]) => /^\d+$/.test(key))
.sort((a, b) => Number(a[0]) - Number(b[0]))
.map(([, value]) => value);
let bindings: Record<string, unknown> | undefined;
if (typeof numericArgs[0] === "string" && numericArgs[0].trim().startsWith("{")) {
try {
const parsed = JSON.parse(numericArgs[0]);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
bindings = parsed as Record<string, unknown>;
numericArgs.shift();
}
} catch {
// ignore malformed json bindings
}
}
let message = "";
if (numericArgs.length > 0 && typeof numericArgs[numericArgs.length - 1] === "string") {
message = String(numericArgs.pop());
} else if (numericArgs.length === 1) {
message = safeStringify(numericArgs[0]);
numericArgs.length = 0;
}
if (!message) {
message = "log";
}
const attributes: Record<string, string | number | boolean> = {
"clawdbot.log.level": logLevelName,
};
if (meta?.name) attributes["clawdbot.logger"] = meta.name;
if (meta?.parentNames?.length) {
attributes["clawdbot.logger.parents"] = meta.parentNames.join(".");
}
if (bindings) {
for (const [key, value] of Object.entries(bindings)) {
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
attributes[`clawdbot.${key}`] = value;
} else if (value != null) {
attributes[`clawdbot.${key}`] = safeStringify(value);
}
}
}
if (numericArgs.length > 0) {
attributes["clawdbot.log.args"] = safeStringify(numericArgs);
}
if (meta?.path?.filePath) attributes["code.filepath"] = meta.path.filePath;
if (meta?.path?.fileLine) attributes["code.lineno"] = Number(meta.path.fileLine);
if (meta?.path?.method) attributes["code.function"] = meta.path.method;
if (meta?.path?.filePathWithLine) {
attributes["clawdbot.code.location"] = meta.path.filePathWithLine;
}
otelLogger.emit({
body: message,
severityText: logLevelName,
severityNumber,
attributes,
timestamp: meta?.date ?? new Date(),
});
});
}
const spanWithDuration = (
name: string,
attributes: Record<string, string | number>,
durationMs?: number,
) => {
const startTime =
typeof durationMs === "number" ? Date.now() - Math.max(0, durationMs) : undefined;
const span = tracer.startSpan(name, {
attributes,
...(startTime ? { startTime } : {}),
});
return span;
};
const recordModelUsage = (evt: Extract<DiagnosticEventPayload, { type: "model.usage" }>) => {
const attrs = {
"clawdbot.channel": evt.channel ?? "unknown",
"clawdbot.provider": evt.provider ?? "unknown",
"clawdbot.model": evt.model ?? "unknown",
};
const usage = evt.usage;
if (usage.input) tokensCounter.add(usage.input, { ...attrs, "clawdbot.token": "input" });
if (usage.output) tokensCounter.add(usage.output, { ...attrs, "clawdbot.token": "output" });
if (usage.cacheRead)
tokensCounter.add(usage.cacheRead, { ...attrs, "clawdbot.token": "cache_read" });
if (usage.cacheWrite)
tokensCounter.add(usage.cacheWrite, { ...attrs, "clawdbot.token": "cache_write" });
if (usage.promptTokens)
tokensCounter.add(usage.promptTokens, { ...attrs, "clawdbot.token": "prompt" });
if (usage.total) tokensCounter.add(usage.total, { ...attrs, "clawdbot.token": "total" });
if (evt.costUsd) costCounter.add(evt.costUsd, attrs);
if (evt.durationMs) durationHistogram.record(evt.durationMs, attrs);
if (evt.context?.limit)
contextHistogram.record(evt.context.limit, {
...attrs,
"clawdbot.context": "limit",
});
if (evt.context?.used)
contextHistogram.record(evt.context.used, {
...attrs,
"clawdbot.context": "used",
});
if (!tracesEnabled) return;
const spanAttrs: Record<string, string | number> = {
...attrs,
"clawdbot.sessionKey": evt.sessionKey ?? "",
"clawdbot.sessionId": evt.sessionId ?? "",
"clawdbot.tokens.input": usage.input ?? 0,
"clawdbot.tokens.output": usage.output ?? 0,
"clawdbot.tokens.cache_read": usage.cacheRead ?? 0,
"clawdbot.tokens.cache_write": usage.cacheWrite ?? 0,
"clawdbot.tokens.total": usage.total ?? 0,
};
const span = spanWithDuration("clawdbot.model.usage", spanAttrs, evt.durationMs);
span.end();
};
const recordWebhookReceived = (
evt: Extract<DiagnosticEventPayload, { type: "webhook.received" }>,
) => {
const attrs = {
"clawdbot.channel": evt.channel ?? "unknown",
"clawdbot.webhook": evt.updateType ?? "unknown",
};
webhookReceivedCounter.add(1, attrs);
};
const recordWebhookProcessed = (
evt: Extract<DiagnosticEventPayload, { type: "webhook.processed" }>,
) => {
const attrs = {
"clawdbot.channel": evt.channel ?? "unknown",
"clawdbot.webhook": evt.updateType ?? "unknown",
};
if (typeof evt.durationMs === "number") {
webhookDurationHistogram.record(evt.durationMs, attrs);
}
if (!tracesEnabled) return;
const spanAttrs: Record<string, string | number> = { ...attrs };
if (evt.chatId !== undefined) spanAttrs["clawdbot.chatId"] = String(evt.chatId);
const span = spanWithDuration("clawdbot.webhook.processed", spanAttrs, evt.durationMs);
span.end();
};
const recordWebhookError = (
evt: Extract<DiagnosticEventPayload, { type: "webhook.error" }>,
) => {
const attrs = {
"clawdbot.channel": evt.channel ?? "unknown",
"clawdbot.webhook": evt.updateType ?? "unknown",
};
webhookErrorCounter.add(1, attrs);
if (!tracesEnabled) return;
const spanAttrs: Record<string, string | number> = {
...attrs,
"clawdbot.error": evt.error,
};
if (evt.chatId !== undefined) spanAttrs["clawdbot.chatId"] = String(evt.chatId);
const span = tracer.startSpan("clawdbot.webhook.error", {
attributes: spanAttrs,
});
span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error });
span.end();
};
const recordMessageQueued = (
evt: Extract<DiagnosticEventPayload, { type: "message.queued" }>,
) => {
const attrs = {
"clawdbot.channel": evt.channel ?? "unknown",
"clawdbot.source": evt.source ?? "unknown",
};
messageQueuedCounter.add(1, attrs);
if (typeof evt.queueDepth === "number") {
queueDepthHistogram.record(evt.queueDepth, attrs);
}
};
const recordMessageProcessed = (
evt: Extract<DiagnosticEventPayload, { type: "message.processed" }>,
) => {
const attrs = {
"clawdbot.channel": evt.channel ?? "unknown",
"clawdbot.outcome": evt.outcome ?? "unknown",
};
messageProcessedCounter.add(1, attrs);
if (typeof evt.durationMs === "number") {
messageDurationHistogram.record(evt.durationMs, attrs);
}
if (!tracesEnabled) return;
const spanAttrs: Record<string, string | number> = { ...attrs };
if (evt.sessionKey) spanAttrs["clawdbot.sessionKey"] = evt.sessionKey;
if (evt.sessionId) spanAttrs["clawdbot.sessionId"] = evt.sessionId;
if (evt.chatId !== undefined) spanAttrs["clawdbot.chatId"] = String(evt.chatId);
if (evt.messageId !== undefined) spanAttrs["clawdbot.messageId"] = String(evt.messageId);
if (evt.reason) spanAttrs["clawdbot.reason"] = evt.reason;
const span = spanWithDuration("clawdbot.message.processed", spanAttrs, evt.durationMs);
if (evt.outcome === "error") {
span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error });
}
span.end();
};
const recordLaneEnqueue = (
evt: Extract<DiagnosticEventPayload, { type: "queue.lane.enqueue" }>,
) => {
const attrs = { "clawdbot.lane": evt.lane };
laneEnqueueCounter.add(1, attrs);
queueDepthHistogram.record(evt.queueSize, attrs);
};
const recordLaneDequeue = (
evt: Extract<DiagnosticEventPayload, { type: "queue.lane.dequeue" }>,
) => {
const attrs = { "clawdbot.lane": evt.lane };
laneDequeueCounter.add(1, attrs);
queueDepthHistogram.record(evt.queueSize, attrs);
if (typeof evt.waitMs === "number") {
queueWaitHistogram.record(evt.waitMs, attrs);
}
};
const recordSessionState = (
evt: Extract<DiagnosticEventPayload, { type: "session.state" }>,
) => {
const attrs: Record<string, string> = { "clawdbot.state": evt.state };
if (evt.reason) attrs["clawdbot.reason"] = evt.reason;
sessionStateCounter.add(1, attrs);
};
const recordSessionStuck = (
evt: Extract<DiagnosticEventPayload, { type: "session.stuck" }>,
) => {
const attrs: Record<string, string> = { "clawdbot.state": evt.state };
sessionStuckCounter.add(1, attrs);
if (typeof evt.ageMs === "number") {
sessionStuckAgeHistogram.record(evt.ageMs, attrs);
}
if (!tracesEnabled) return;
const spanAttrs: Record<string, string | number> = { ...attrs };
if (evt.sessionKey) spanAttrs["clawdbot.sessionKey"] = evt.sessionKey;
if (evt.sessionId) spanAttrs["clawdbot.sessionId"] = evt.sessionId;
spanAttrs["clawdbot.queueDepth"] = evt.queueDepth ?? 0;
spanAttrs["clawdbot.ageMs"] = evt.ageMs;
const span = tracer.startSpan("clawdbot.session.stuck", { attributes: spanAttrs });
span.setStatus({ code: SpanStatusCode.ERROR, message: "session stuck" });
span.end();
};
const recordRunAttempt = (evt: Extract<DiagnosticEventPayload, { type: "run.attempt" }>) => {
runAttemptCounter.add(1, { "clawdbot.attempt": evt.attempt });
};
const recordHeartbeat = (
evt: Extract<DiagnosticEventPayload, { type: "diagnostic.heartbeat" }>,
) => {
queueDepthHistogram.record(evt.queued, { "clawdbot.channel": "heartbeat" });
};
unsubscribe = onDiagnosticEvent((evt: DiagnosticEventPayload) => {
switch (evt.type) {
case "model.usage":
recordModelUsage(evt);
return;
case "webhook.received":
recordWebhookReceived(evt);
return;
case "webhook.processed":
recordWebhookProcessed(evt);
return;
case "webhook.error":
recordWebhookError(evt);
return;
case "message.queued":
recordMessageQueued(evt);
return;
case "message.processed":
recordMessageProcessed(evt);
return;
case "queue.lane.enqueue":
recordLaneEnqueue(evt);
return;
case "queue.lane.dequeue":
recordLaneDequeue(evt);
return;
case "session.state":
recordSessionState(evt);
return;
case "session.stuck":
recordSessionStuck(evt);
return;
case "run.attempt":
recordRunAttempt(evt);
return;
case "diagnostic.heartbeat":
recordHeartbeat(evt);
return;
}
});
if (logsEnabled) {
ctx.logger.info("diagnostics-otel: logs exporter enabled (OTLP/HTTP)");
}
},
async stop() {
unsubscribe?.();
unsubscribe = null;
stopLogTransport?.();
stopLogTransport = null;
if (logProvider) {
await logProvider.shutdown().catch(() => undefined);
logProvider = null;
}
if (sdk) {
await sdk.shutdown().catch(() => undefined);
sdk = null;
}
},
} satisfies ClawdbotPluginService;
}
+518 -15
View File
@@ -1,15 +1,518 @@
export type { import type { MatrixClient } from "matrix-bot-sdk";
MatrixActionClientOpts,
MatrixMessageSummary, import { getMatrixRuntime } from "../runtime.js";
MatrixReactionSummary, import type { CoreConfig } from "../types.js";
} from "./actions/types.js"; import { getActiveMatrixClient } from "./active-client.js";
export { import {
sendMatrixMessage, createMatrixClient,
editMatrixMessage, isBunRuntime,
deleteMatrixMessage, resolveMatrixAuth,
readMatrixMessages, resolveSharedMatrixClient,
} from "./actions/messages.js"; } from "./client.js";
export { listMatrixReactions, removeMatrixReactions } from "./actions/reactions.js"; import {
export { pinMatrixMessage, unpinMatrixMessage, listMatrixPins } from "./actions/pins.js"; reactMatrixMessage,
export { getMatrixMemberInfo, getMatrixRoomInfo } from "./actions/room.js"; resolveMatrixRoomId,
export { reactMatrixMessage } from "./send.js"; sendMessageMatrix,
} from "./send.js";
// Constants that were previously from matrix-js-sdk
const MsgType = {
Text: "m.text",
} as const;
const RelationType = {
Replace: "m.replace",
Annotation: "m.annotation",
} as const;
const EventType = {
RoomMessage: "m.room.message",
RoomPinnedEvents: "m.room.pinned_events",
RoomTopic: "m.room.topic",
Reaction: "m.reaction",
} as const;
// Type definitions for matrix-bot-sdk event content
type RoomMessageEventContent = {
msgtype: string;
body: string;
"m.new_content"?: RoomMessageEventContent;
"m.relates_to"?: {
rel_type?: string;
event_id?: string;
"m.in_reply_to"?: { event_id?: string };
};
};
type ReactionEventContent = {
"m.relates_to": {
rel_type: string;
event_id: string;
key: string;
};
};
type RoomPinnedEventsEventContent = {
pinned: string[];
};
type RoomTopicEventContent = {
topic?: string;
};
type MatrixRawEvent = {
event_id: string;
sender: string;
type: string;
origin_server_ts: number;
content: Record<string, unknown>;
unsigned?: {
redacted_because?: unknown;
};
};
export type MatrixActionClientOpts = {
client?: MatrixClient;
timeoutMs?: number;
};
export type MatrixMessageSummary = {
eventId?: string;
sender?: string;
body?: string;
msgtype?: string;
timestamp?: number;
relatesTo?: {
relType?: string;
eventId?: string;
key?: string;
};
};
export type MatrixReactionSummary = {
key: string;
count: number;
users: string[];
};
type MatrixActionClient = {
client: MatrixClient;
stopOnDone: boolean;
};
function ensureNodeRuntime() {
if (isBunRuntime()) {
throw new Error("Matrix support requires Node (bun runtime not supported)");
}
}
async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise<MatrixActionClient> {
ensureNodeRuntime();
if (opts.client) return { client: opts.client, stopOnDone: false };
const active = getActiveMatrixClient();
if (active) return { client: active, stopOnDone: false };
const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT);
if (shouldShareClient) {
const client = await resolveSharedMatrixClient({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
timeoutMs: opts.timeoutMs,
});
return { client, stopOnDone: false };
}
const auth = await resolveMatrixAuth({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
});
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
});
if (auth.encryption && client.crypto) {
try {
const joinedRooms = await client.getJoinedRooms();
await client.crypto.prepare(joinedRooms);
} catch {
// Ignore crypto prep failures for one-off actions.
}
}
await client.start();
return { client, stopOnDone: true };
}
function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSummary {
const content = event.content as RoomMessageEventContent;
const relates = content["m.relates_to"];
let relType: string | undefined;
let eventId: string | undefined;
if (relates) {
if ("rel_type" in relates) {
relType = relates.rel_type;
eventId = relates.event_id;
} else if ("m.in_reply_to" in relates) {
eventId = relates["m.in_reply_to"]?.event_id;
}
}
const relatesTo =
relType || eventId
? {
relType,
eventId,
}
: undefined;
return {
eventId: event.event_id,
sender: event.sender,
body: content.body,
msgtype: content.msgtype,
timestamp: event.origin_server_ts,
relatesTo,
};
}
async function readPinnedEvents(client: MatrixClient, roomId: string): Promise<string[]> {
try {
const content = (await client.getRoomStateEvent(
roomId,
EventType.RoomPinnedEvents,
"",
)) as RoomPinnedEventsEventContent;
const pinned = content.pinned;
return pinned.filter((id) => id.trim().length > 0);
} catch (err: unknown) {
const errObj = err as { statusCode?: number; body?: { errcode?: string } };
const httpStatus = errObj.statusCode;
const errcode = errObj.body?.errcode;
if (httpStatus === 404 || errcode === "M_NOT_FOUND") {
return [];
}
throw err;
}
}
async function fetchEventSummary(
client: MatrixClient,
roomId: string,
eventId: string,
): Promise<MatrixMessageSummary | null> {
try {
const raw = await client.getEvent(roomId, eventId) as MatrixRawEvent;
if (raw.unsigned?.redacted_because) return null;
return summarizeMatrixRawEvent(raw);
} catch (err) {
// Event not found, redacted, or inaccessible - return null
return null;
}
}
export async function sendMatrixMessage(
to: string,
content: string,
opts: MatrixActionClientOpts & {
mediaUrl?: string;
replyToId?: string;
threadId?: string;
} = {},
) {
return await sendMessageMatrix(to, content, {
mediaUrl: opts.mediaUrl,
replyToId: opts.replyToId,
threadId: opts.threadId,
client: opts.client,
timeoutMs: opts.timeoutMs,
});
}
export async function editMatrixMessage(
roomId: string,
messageId: string,
content: string,
opts: MatrixActionClientOpts = {},
) {
const trimmed = content.trim();
if (!trimmed) throw new Error("Matrix edit requires content");
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const newContent = {
msgtype: MsgType.Text,
body: trimmed,
} satisfies RoomMessageEventContent;
const payload: RoomMessageEventContent = {
msgtype: MsgType.Text,
body: `* ${trimmed}`,
"m.new_content": newContent,
"m.relates_to": {
rel_type: RelationType.Replace,
event_id: messageId,
},
};
const eventId = await client.sendMessage(resolvedRoom, payload);
return { eventId: eventId ?? null };
} finally {
if (stopOnDone) client.stop();
}
}
export async function deleteMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { reason?: string } = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
await client.redactEvent(resolvedRoom, messageId, opts.reason);
} finally {
if (stopOnDone) client.stop();
}
}
export async function readMatrixMessages(
roomId: string,
opts: MatrixActionClientOpts & {
limit?: number;
before?: string;
after?: string;
} = {},
): Promise<{
messages: MatrixMessageSummary[];
nextBatch?: string | null;
prevBatch?: string | null;
}> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const limit =
typeof opts.limit === "number" && Number.isFinite(opts.limit)
? Math.max(1, Math.floor(opts.limit))
: 20;
const token = opts.before?.trim() || opts.after?.trim() || undefined;
const dir = opts.after ? "f" : "b";
// matrix-bot-sdk uses doRequest for room messages
const res = await client.doRequest("GET", `/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`, {
dir,
limit,
from: token,
}) as { chunk: MatrixRawEvent[]; start?: string; end?: string };
const messages = res.chunk
.filter((event) => event.type === EventType.RoomMessage)
.filter((event) => !event.unsigned?.redacted_because)
.map(summarizeMatrixRawEvent);
return {
messages,
nextBatch: res.end ?? null,
prevBatch: res.start ?? null,
};
} finally {
if (stopOnDone) client.stop();
}
}
export async function listMatrixReactions(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { limit?: number } = {},
): Promise<MatrixReactionSummary[]> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const limit =
typeof opts.limit === "number" && Number.isFinite(opts.limit)
? Math.max(1, Math.floor(opts.limit))
: 100;
// matrix-bot-sdk uses doRequest for relations
const res = await client.doRequest(
"GET",
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
{ dir: "b", limit },
) as { chunk: MatrixRawEvent[] };
const summaries = new Map<string, MatrixReactionSummary>();
for (const event of res.chunk) {
const content = event.content as ReactionEventContent;
const key = content["m.relates_to"]?.key;
if (!key) continue;
const sender = event.sender ?? "";
const entry: MatrixReactionSummary = summaries.get(key) ?? {
key,
count: 0,
users: [],
};
entry.count += 1;
if (sender && !entry.users.includes(sender)) {
entry.users.push(sender);
}
summaries.set(key, entry);
}
return Array.from(summaries.values());
} finally {
if (stopOnDone) client.stop();
}
}
export async function removeMatrixReactions(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { emoji?: string } = {},
): Promise<{ removed: number }> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const res = await client.doRequest(
"GET",
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
{ dir: "b", limit: 200 },
) as { chunk: MatrixRawEvent[] };
const userId = await client.getUserId();
if (!userId) return { removed: 0 };
const targetEmoji = opts.emoji?.trim();
const toRemove = res.chunk
.filter((event) => event.sender === userId)
.filter((event) => {
if (!targetEmoji) return true;
const content = event.content as ReactionEventContent;
return content["m.relates_to"]?.key === targetEmoji;
})
.map((event) => event.event_id)
.filter((id): id is string => Boolean(id));
if (toRemove.length === 0) return { removed: 0 };
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
return { removed: toRemove.length };
} finally {
if (stopOnDone) client.stop();
}
}
export async function pinMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[] }> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const current = await readPinnedEvents(client, resolvedRoom);
const next = current.includes(messageId) ? current : [...current, messageId];
const payload: RoomPinnedEventsEventContent = { pinned: next };
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
return { pinned: next };
} finally {
if (stopOnDone) client.stop();
}
}
export async function unpinMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[] }> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const current = await readPinnedEvents(client, resolvedRoom);
const next = current.filter((id) => id !== messageId);
const payload: RoomPinnedEventsEventContent = { pinned: next };
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
return { pinned: next };
} finally {
if (stopOnDone) client.stop();
}
}
export async function listMatrixPins(
roomId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const pinned = await readPinnedEvents(client, resolvedRoom);
const events = (
await Promise.all(
pinned.map(async (eventId) => {
try {
return await fetchEventSummary(client, resolvedRoom, eventId);
} catch {
return null;
}
}),
)
).filter((event): event is MatrixMessageSummary => Boolean(event));
return { pinned, events };
} finally {
if (stopOnDone) client.stop();
}
}
export async function getMatrixMemberInfo(
userId: string,
opts: MatrixActionClientOpts & { roomId?: string } = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
// matrix-bot-sdk uses getUserProfile
const profile = await client.getUserProfile(userId);
// Note: matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk
// We'd need to fetch room state separately if needed
return {
userId,
profile: {
displayName: profile?.displayname ?? null,
avatarUrl: profile?.avatar_url ?? null,
},
membership: null, // Would need separate room state query
powerLevel: null, // Would need separate power levels state query
displayName: profile?.displayname ?? null,
roomId: roomId ?? null,
};
} finally {
if (stopOnDone) client.stop();
}
}
export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
// matrix-bot-sdk uses getRoomState for state events
let name: string | null = null;
let topic: string | null = null;
let canonicalAlias: string | null = null;
let memberCount: number | null = null;
try {
const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", "");
name = nameState?.name ?? null;
} catch { /* ignore */ }
try {
const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, "");
topic = topicState?.topic ?? null;
} catch { /* ignore */ }
try {
const aliasState = await client.getRoomStateEvent(resolvedRoom, "m.room.canonical_alias", "");
canonicalAlias = aliasState?.alias ?? null;
} catch { /* ignore */ }
try {
const members = await client.getJoinedRoomMembers(resolvedRoom);
memberCount = members.length;
} catch { /* ignore */ }
return {
roomId: resolvedRoom,
name,
topic,
canonicalAlias,
altAliases: [], // Would need separate query
memberCount,
};
} finally {
if (stopOnDone) client.stop();
}
}
export { reactMatrixMessage };
@@ -1,53 +0,0 @@
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../types.js";
import { getActiveMatrixClient } from "../active-client.js";
import {
createMatrixClient,
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
} from "../client.js";
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
export function ensureNodeRuntime() {
if (isBunRuntime()) {
throw new Error("Matrix support requires Node (bun runtime not supported)");
}
}
export async function resolveActionClient(
opts: MatrixActionClientOpts = {},
): Promise<MatrixActionClient> {
ensureNodeRuntime();
if (opts.client) return { client: opts.client, stopOnDone: false };
const active = getActiveMatrixClient();
if (active) return { client: active, stopOnDone: false };
const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT);
if (shouldShareClient) {
const client = await resolveSharedMatrixClient({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
timeoutMs: opts.timeoutMs,
});
return { client, stopOnDone: false };
}
const auth = await resolveMatrixAuth({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
});
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
});
if (auth.encryption && client.crypto) {
try {
const joinedRooms = await client.getJoinedRooms();
await client.crypto.prepare(joinedRooms);
} catch {
// Ignore crypto prep failures for one-off actions.
}
}
await client.start();
return { client, stopOnDone: true };
}
@@ -1,120 +0,0 @@
import {
EventType,
MsgType,
RelationType,
type MatrixActionClientOpts,
type MatrixMessageSummary,
type MatrixRawEvent,
type RoomMessageEventContent,
} from "./types.js";
import { resolveActionClient } from "./client.js";
import { summarizeMatrixRawEvent } from "./summary.js";
import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js";
export async function sendMatrixMessage(
to: string,
content: string,
opts: MatrixActionClientOpts & {
mediaUrl?: string;
replyToId?: string;
threadId?: string;
} = {},
) {
return await sendMessageMatrix(to, content, {
mediaUrl: opts.mediaUrl,
replyToId: opts.replyToId,
threadId: opts.threadId,
client: opts.client,
timeoutMs: opts.timeoutMs,
});
}
export async function editMatrixMessage(
roomId: string,
messageId: string,
content: string,
opts: MatrixActionClientOpts = {},
) {
const trimmed = content.trim();
if (!trimmed) throw new Error("Matrix edit requires content");
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const newContent = {
msgtype: MsgType.Text,
body: trimmed,
} satisfies RoomMessageEventContent;
const payload: RoomMessageEventContent = {
msgtype: MsgType.Text,
body: `* ${trimmed}`,
"m.new_content": newContent,
"m.relates_to": {
rel_type: RelationType.Replace,
event_id: messageId,
},
};
const eventId = await client.sendMessage(resolvedRoom, payload);
return { eventId: eventId ?? null };
} finally {
if (stopOnDone) client.stop();
}
}
export async function deleteMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { reason?: string } = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
await client.redactEvent(resolvedRoom, messageId, opts.reason);
} finally {
if (stopOnDone) client.stop();
}
}
export async function readMatrixMessages(
roomId: string,
opts: MatrixActionClientOpts & {
limit?: number;
before?: string;
after?: string;
} = {},
): Promise<{
messages: MatrixMessageSummary[];
nextBatch?: string | null;
prevBatch?: string | null;
}> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const limit =
typeof opts.limit === "number" && Number.isFinite(opts.limit)
? Math.max(1, Math.floor(opts.limit))
: 20;
const token = opts.before?.trim() || opts.after?.trim() || undefined;
const dir = opts.after ? "f" : "b";
// matrix-bot-sdk uses doRequest for room messages
const res = await client.doRequest(
"GET",
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,
{
dir,
limit,
from: token,
},
) as { chunk: MatrixRawEvent[]; start?: string; end?: string };
const messages = res.chunk
.filter((event) => event.type === EventType.RoomMessage)
.filter((event) => !event.unsigned?.redacted_because)
.map(summarizeMatrixRawEvent);
return {
messages,
nextBatch: res.end ?? null,
prevBatch: res.start ?? null,
};
} finally {
if (stopOnDone) client.stop();
}
}
@@ -1,70 +0,0 @@
import {
EventType,
type MatrixActionClientOpts,
type MatrixMessageSummary,
type RoomPinnedEventsEventContent,
} from "./types.js";
import { resolveActionClient } from "./client.js";
import { fetchEventSummary, readPinnedEvents } from "./summary.js";
import { resolveMatrixRoomId } from "../send.js";
export async function pinMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[] }> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const current = await readPinnedEvents(client, resolvedRoom);
const next = current.includes(messageId) ? current : [...current, messageId];
const payload: RoomPinnedEventsEventContent = { pinned: next };
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
return { pinned: next };
} finally {
if (stopOnDone) client.stop();
}
}
export async function unpinMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[] }> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const current = await readPinnedEvents(client, resolvedRoom);
const next = current.filter((id) => id !== messageId);
const payload: RoomPinnedEventsEventContent = { pinned: next };
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
return { pinned: next };
} finally {
if (stopOnDone) client.stop();
}
}
export async function listMatrixPins(
roomId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const pinned = await readPinnedEvents(client, resolvedRoom);
const events = (
await Promise.all(
pinned.map(async (eventId) => {
try {
return await fetchEventSummary(client, resolvedRoom, eventId);
} catch {
return null;
}
}),
)
).filter((event): event is MatrixMessageSummary => Boolean(event));
return { pinned, events };
} finally {
if (stopOnDone) client.stop();
}
}
@@ -1,84 +0,0 @@
import {
EventType,
RelationType,
type MatrixActionClientOpts,
type MatrixRawEvent,
type MatrixReactionSummary,
type ReactionEventContent,
} from "./types.js";
import { resolveActionClient } from "./client.js";
import { resolveMatrixRoomId } from "../send.js";
export async function listMatrixReactions(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { limit?: number } = {},
): Promise<MatrixReactionSummary[]> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const limit =
typeof opts.limit === "number" && Number.isFinite(opts.limit)
? Math.max(1, Math.floor(opts.limit))
: 100;
// matrix-bot-sdk uses doRequest for relations
const res = await client.doRequest(
"GET",
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
{ dir: "b", limit },
) as { chunk: MatrixRawEvent[] };
const summaries = new Map<string, MatrixReactionSummary>();
for (const event of res.chunk) {
const content = event.content as ReactionEventContent;
const key = content["m.relates_to"]?.key;
if (!key) continue;
const sender = event.sender ?? "";
const entry: MatrixReactionSummary = summaries.get(key) ?? {
key,
count: 0,
users: [],
};
entry.count += 1;
if (sender && !entry.users.includes(sender)) {
entry.users.push(sender);
}
summaries.set(key, entry);
}
return Array.from(summaries.values());
} finally {
if (stopOnDone) client.stop();
}
}
export async function removeMatrixReactions(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { emoji?: string } = {},
): Promise<{ removed: number }> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const res = await client.doRequest(
"GET",
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
{ dir: "b", limit: 200 },
) as { chunk: MatrixRawEvent[] };
const userId = await client.getUserId();
if (!userId) return { removed: 0 };
const targetEmoji = opts.emoji?.trim();
const toRemove = res.chunk
.filter((event) => event.sender === userId)
.filter((event) => {
if (!targetEmoji) return true;
const content = event.content as ReactionEventContent;
return content["m.relates_to"]?.key === targetEmoji;
})
.map((event) => event.event_id)
.filter((id): id is string => Boolean(id));
if (toRemove.length === 0) return { removed: 0 };
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
return { removed: toRemove.length };
} finally {
if (stopOnDone) client.stop();
}
}
@@ -1,88 +0,0 @@
import { EventType, type MatrixActionClientOpts } from "./types.js";
import { resolveActionClient } from "./client.js";
import { resolveMatrixRoomId } from "../send.js";
export async function getMatrixMemberInfo(
userId: string,
opts: MatrixActionClientOpts & { roomId?: string } = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
// matrix-bot-sdk uses getUserProfile
const profile = await client.getUserProfile(userId);
// Note: matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk
// We'd need to fetch room state separately if needed
return {
userId,
profile: {
displayName: profile?.displayname ?? null,
avatarUrl: profile?.avatar_url ?? null,
},
membership: null, // Would need separate room state query
powerLevel: null, // Would need separate power levels state query
displayName: profile?.displayname ?? null,
roomId: roomId ?? null,
};
} finally {
if (stopOnDone) client.stop();
}
}
export async function getMatrixRoomInfo(
roomId: string,
opts: MatrixActionClientOpts = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
// matrix-bot-sdk uses getRoomState for state events
let name: string | null = null;
let topic: string | null = null;
let canonicalAlias: string | null = null;
let memberCount: number | null = null;
try {
const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", "");
name = nameState?.name ?? null;
} catch {
// ignore
}
try {
const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, "");
topic = topicState?.topic ?? null;
} catch {
// ignore
}
try {
const aliasState = await client.getRoomStateEvent(
resolvedRoom,
"m.room.canonical_alias",
"",
);
canonicalAlias = aliasState?.alias ?? null;
} catch {
// ignore
}
try {
const members = await client.getJoinedRoomMembers(resolvedRoom);
memberCount = members.length;
} catch {
// ignore
}
return {
roomId: resolvedRoom,
name,
topic,
canonicalAlias,
altAliases: [], // Would need separate query
memberCount,
};
} finally {
if (stopOnDone) client.stop();
}
}
@@ -1,77 +0,0 @@
import type { MatrixClient } from "matrix-bot-sdk";
import {
EventType,
type MatrixMessageSummary,
type MatrixRawEvent,
type RoomMessageEventContent,
type RoomPinnedEventsEventContent,
} from "./types.js";
export function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSummary {
const content = event.content as RoomMessageEventContent;
const relates = content["m.relates_to"];
let relType: string | undefined;
let eventId: string | undefined;
if (relates) {
if ("rel_type" in relates) {
relType = relates.rel_type;
eventId = relates.event_id;
} else if ("m.in_reply_to" in relates) {
eventId = relates["m.in_reply_to"]?.event_id;
}
}
const relatesTo =
relType || eventId
? {
relType,
eventId,
}
: undefined;
return {
eventId: event.event_id,
sender: event.sender,
body: content.body,
msgtype: content.msgtype,
timestamp: event.origin_server_ts,
relatesTo,
};
}
export async function readPinnedEvents(
client: MatrixClient,
roomId: string,
): Promise<string[]> {
try {
const content = (await client.getRoomStateEvent(
roomId,
EventType.RoomPinnedEvents,
"",
)) as RoomPinnedEventsEventContent;
const pinned = content.pinned;
return pinned.filter((id) => id.trim().length > 0);
} catch (err: unknown) {
const errObj = err as { statusCode?: number; body?: { errcode?: string } };
const httpStatus = errObj.statusCode;
const errcode = errObj.body?.errcode;
if (httpStatus === 404 || errcode === "M_NOT_FOUND") {
return [];
}
throw err;
}
}
export async function fetchEventSummary(
client: MatrixClient,
roomId: string,
eventId: string,
): Promise<MatrixMessageSummary | null> {
try {
const raw = (await client.getEvent(roomId, eventId)) as MatrixRawEvent;
if (raw.unsigned?.redacted_because) return null;
return summarizeMatrixRawEvent(raw);
} catch {
// Event not found, redacted, or inaccessible - return null
return null;
}
}
@@ -1,84 +0,0 @@
import type { MatrixClient } from "matrix-bot-sdk";
export const MsgType = {
Text: "m.text",
} as const;
export const RelationType = {
Replace: "m.replace",
Annotation: "m.annotation",
} as const;
export const EventType = {
RoomMessage: "m.room.message",
RoomPinnedEvents: "m.room.pinned_events",
RoomTopic: "m.room.topic",
Reaction: "m.reaction",
} as const;
export type RoomMessageEventContent = {
msgtype: string;
body: string;
"m.new_content"?: RoomMessageEventContent;
"m.relates_to"?: {
rel_type?: string;
event_id?: string;
"m.in_reply_to"?: { event_id?: string };
};
};
export type ReactionEventContent = {
"m.relates_to": {
rel_type: string;
event_id: string;
key: string;
};
};
export type RoomPinnedEventsEventContent = {
pinned: string[];
};
export type RoomTopicEventContent = {
topic?: string;
};
export type MatrixRawEvent = {
event_id: string;
sender: string;
type: string;
origin_server_ts: number;
content: Record<string, unknown>;
unsigned?: {
redacted_because?: unknown;
};
};
export type MatrixActionClientOpts = {
client?: MatrixClient;
timeoutMs?: number;
};
export type MatrixMessageSummary = {
eventId?: string;
sender?: string;
body?: string;
msgtype?: string;
timestamp?: number;
relatesTo?: {
relType?: string;
eventId?: string;
key?: string;
};
};
export type MatrixReactionSummary = {
key: string;
count: number;
users: string[];
};
export type MatrixActionClient = {
client: MatrixClient;
stopOnDone: boolean;
};
+645 -9
View File
@@ -1,9 +1,645 @@
export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js"; import crypto from "node:crypto";
export { isBunRuntime } from "./client/runtime.js"; import fs from "node:fs";
export { resolveMatrixConfig, resolveMatrixAuth } from "./client/config.js"; import os from "node:os";
export { createMatrixClient } from "./client/create-client.js"; import path from "node:path";
export {
resolveSharedMatrixClient, import {
waitForMatrixSync, ConsoleLogger,
stopSharedClient, LogService,
} from "./client/shared.js"; MatrixClient,
SimpleFsStorageProvider,
RustSdkCryptoStorageProvider,
} from "matrix-bot-sdk";
import type { IStorageProvider, ICryptoStorageProvider } from "matrix-bot-sdk";
import type { CoreConfig } from "../types.js";
import { getMatrixRuntime } from "../runtime.js";
export type MatrixResolvedConfig = {
homeserver: string;
userId: string;
accessToken?: string;
password?: string;
deviceName?: string;
initialSyncLimit?: number;
encryption?: boolean;
};
/**
* Authenticated Matrix configuration.
* Note: deviceId is NOT included here because it's implicit in the accessToken.
* The crypto storage assumes the device ID (and thus access token) does not change
* between restarts. If the access token becomes invalid or crypto storage is lost,
* both will need to be recreated together.
*/
export type MatrixAuth = {
homeserver: string;
userId: string;
accessToken: string;
deviceName?: string;
initialSyncLimit?: number;
encryption?: boolean;
};
type SharedMatrixClientState = {
client: MatrixClient;
key: string;
started: boolean;
cryptoReady: boolean;
};
let sharedClientState: SharedMatrixClientState | null = null;
let sharedClientPromise: Promise<SharedMatrixClientState> | null = null;
let sharedClientStartPromise: Promise<void> | null = null;
export function isBunRuntime(): boolean {
const versions = process.versions as { bun?: string };
return typeof versions.bun === "string";
}
let matrixSdkLoggingConfigured = false;
const matrixSdkBaseLogger = new ConsoleLogger();
function shouldSuppressMatrixHttpNotFound(
module: string,
messageOrObject: unknown[],
): boolean {
if (module !== "MatrixHttpClient") return false;
return messageOrObject.some((entry) => {
if (!entry || typeof entry !== "object") return false;
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
});
}
function ensureMatrixSdkLoggingConfigured(): void {
if (matrixSdkLoggingConfigured) return;
matrixSdkLoggingConfigured = true;
LogService.setLogger({
trace: (module, ...messageOrObject) =>
matrixSdkBaseLogger.trace(module, ...messageOrObject),
debug: (module, ...messageOrObject) =>
matrixSdkBaseLogger.debug(module, ...messageOrObject),
info: (module, ...messageOrObject) =>
matrixSdkBaseLogger.info(module, ...messageOrObject),
warn: (module, ...messageOrObject) =>
matrixSdkBaseLogger.warn(module, ...messageOrObject),
error: (module, ...messageOrObject) => {
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) return;
matrixSdkBaseLogger.error(module, ...messageOrObject);
},
});
}
function clean(value?: string): string {
return value?.trim() ?? "";
}
const DEFAULT_ACCOUNT_KEY = "default";
const STORAGE_META_FILENAME = "storage-meta.json";
type MatrixStoragePaths = {
rootDir: string;
storagePath: string;
cryptoPath: string;
metaPath: string;
accountKey: string;
tokenHash: string;
};
function sanitizePathSegment(value: string): string {
const cleaned = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "_")
.replace(/^_+|_+$/g, "");
return cleaned || "unknown";
}
function resolveHomeserverKey(homeserver: string): string {
try {
const url = new URL(homeserver);
if (url.host) return sanitizePathSegment(url.host);
} catch {
// fall through
}
return sanitizePathSegment(homeserver);
}
function hashAccessToken(accessToken: string): string {
return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16);
}
function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): {
storagePath: string;
cryptoPath: string;
} {
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
return {
storagePath: path.join(stateDir, "matrix", "bot-storage.json"),
cryptoPath: path.join(stateDir, "matrix", "crypto"),
};
}
function resolveMatrixStoragePaths(params: {
homeserver: string;
userId: string;
accessToken: string;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
}): MatrixStoragePaths {
const env = params.env ?? process.env;
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
const accountKey = sanitizePathSegment(params.accountId ?? DEFAULT_ACCOUNT_KEY);
const userKey = sanitizePathSegment(params.userId);
const serverKey = resolveHomeserverKey(params.homeserver);
const tokenHash = hashAccessToken(params.accessToken);
const rootDir = path.join(
stateDir,
"matrix",
"accounts",
accountKey,
`${serverKey}__${userKey}`,
tokenHash,
);
return {
rootDir,
storagePath: path.join(rootDir, "bot-storage.json"),
cryptoPath: path.join(rootDir, "crypto"),
metaPath: path.join(rootDir, STORAGE_META_FILENAME),
accountKey,
tokenHash,
};
}
function maybeMigrateLegacyStorage(params: {
storagePaths: MatrixStoragePaths;
env?: NodeJS.ProcessEnv;
}): void {
const legacy = resolveLegacyStoragePaths(params.env);
const hasLegacyStorage = fs.existsSync(legacy.storagePath);
const hasLegacyCrypto = fs.existsSync(legacy.cryptoPath);
const hasNewStorage =
fs.existsSync(params.storagePaths.storagePath) ||
fs.existsSync(params.storagePaths.cryptoPath);
if (!hasLegacyStorage && !hasLegacyCrypto) return;
if (hasNewStorage) return;
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
if (hasLegacyStorage) {
try {
fs.renameSync(legacy.storagePath, params.storagePaths.storagePath);
} catch {
// Ignore migration failures; new store will be created.
}
}
if (hasLegacyCrypto) {
try {
fs.renameSync(legacy.cryptoPath, params.storagePaths.cryptoPath);
} catch {
// Ignore migration failures; new store will be created.
}
}
}
function writeStorageMeta(params: {
storagePaths: MatrixStoragePaths;
homeserver: string;
userId: string;
accountId?: string | null;
}): void {
try {
const payload = {
homeserver: params.homeserver,
userId: params.userId,
accountId: params.accountId ?? DEFAULT_ACCOUNT_KEY,
accessTokenHash: params.storagePaths.tokenHash,
createdAt: new Date().toISOString(),
};
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
fs.writeFileSync(
params.storagePaths.metaPath,
JSON.stringify(payload, null, 2),
"utf-8",
);
} catch {
// ignore meta write failures
}
}
function sanitizeUserIdList(input: unknown, label: string): string[] {
if (input == null) return [];
if (!Array.isArray(input)) {
LogService.warn(
"MatrixClientLite",
`Expected ${label} list to be an array, got ${typeof input}`,
);
return [];
}
const filtered = input.filter(
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
);
if (filtered.length !== input.length) {
LogService.warn(
"MatrixClientLite",
`Dropping ${input.length - filtered.length} invalid ${label} entries from sync payload`,
);
}
return filtered;
}
export function resolveMatrixConfig(
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig {
const matrix = cfg.channels?.matrix ?? {};
const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER);
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
const accessToken =
clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
const deviceName =
clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined;
const initialSyncLimit =
typeof matrix.initialSyncLimit === "number"
? Math.max(0, Math.floor(matrix.initialSyncLimit))
: undefined;
const encryption = matrix.encryption ?? false;
return {
homeserver,
userId,
accessToken,
password,
deviceName,
initialSyncLimit,
encryption,
};
}
export async function resolveMatrixAuth(params?: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
}): Promise<MatrixAuth> {
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const env = params?.env ?? process.env;
const resolved = resolveMatrixConfig(cfg, env);
if (!resolved.homeserver) {
throw new Error("Matrix homeserver is required (matrix.homeserver)");
}
const {
loadMatrixCredentials,
saveMatrixCredentials,
credentialsMatchConfig,
touchMatrixCredentials,
} = await import("./credentials.js");
const cached = loadMatrixCredentials(env);
const cachedCredentials =
cached &&
credentialsMatchConfig(cached, {
homeserver: resolved.homeserver,
userId: resolved.userId || "",
})
? cached
: null;
// If we have an access token, we can fetch userId via whoami if not provided
if (resolved.accessToken) {
let userId = resolved.userId;
if (!userId) {
// Fetch userId from access token via whoami
ensureMatrixSdkLoggingConfigured();
const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
const whoami = await tempClient.getUserId();
userId = whoami;
// Save the credentials with the fetched userId
saveMatrixCredentials({
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
});
} else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
touchMatrixCredentials(env);
}
return {
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
}
if (cachedCredentials) {
touchMatrixCredentials(env);
return {
homeserver: cachedCredentials.homeserver,
userId: cachedCredentials.userId,
accessToken: cachedCredentials.accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
}
if (!resolved.userId) {
throw new Error(
"Matrix userId is required when no access token is configured (matrix.userId)",
);
}
if (!resolved.password) {
throw new Error(
"Matrix password is required when no access token is configured (matrix.password)",
);
}
// Login with password using HTTP API
const loginResponse = await fetch(`${resolved.homeserver}/_matrix/client/v3/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
initial_device_display_name: resolved.deviceName ?? "Clawdbot Gateway",
}),
});
if (!loginResponse.ok) {
const errorText = await loginResponse.text();
throw new Error(`Matrix login failed: ${errorText}`);
}
const login = (await loginResponse.json()) as {
access_token?: string;
user_id?: string;
device_id?: string;
};
const accessToken = login.access_token?.trim();
if (!accessToken) {
throw new Error("Matrix login did not return an access token");
}
const auth: MatrixAuth = {
homeserver: resolved.homeserver,
userId: login.user_id ?? resolved.userId,
accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
saveMatrixCredentials({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
deviceId: login.device_id,
});
return auth;
}
export async function createMatrixClient(params: {
homeserver: string;
userId: string;
accessToken: string;
encryption?: boolean;
localTimeoutMs?: number;
accountId?: string | null;
}): Promise<MatrixClient> {
ensureMatrixSdkLoggingConfigured();
const env = process.env;
// Create storage provider
const storagePaths = resolveMatrixStoragePaths({
homeserver: params.homeserver,
userId: params.userId,
accessToken: params.accessToken,
accountId: params.accountId,
env,
});
maybeMigrateLegacyStorage({ storagePaths, env });
fs.mkdirSync(storagePaths.rootDir, { recursive: true });
const storage: IStorageProvider = new SimpleFsStorageProvider(storagePaths.storagePath);
// Create crypto storage if encryption is enabled
let cryptoStorage: ICryptoStorageProvider | undefined;
if (params.encryption) {
fs.mkdirSync(storagePaths.cryptoPath, { recursive: true });
try {
const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs");
cryptoStorage = new RustSdkCryptoStorageProvider(
storagePaths.cryptoPath,
StoreType.Sqlite,
);
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to initialize crypto storage, E2EE disabled:", err);
}
}
writeStorageMeta({
storagePaths,
homeserver: params.homeserver,
userId: params.userId,
accountId: params.accountId,
});
const client = new MatrixClient(
params.homeserver,
params.accessToken,
storage,
cryptoStorage,
);
if (client.crypto) {
const originalUpdateSyncData = client.crypto.updateSyncData.bind(client.crypto);
client.crypto.updateSyncData = async (
toDeviceMessages,
otkCounts,
unusedFallbackKeyAlgs,
changedDeviceLists,
leftDeviceLists,
) => {
const safeChanged = sanitizeUserIdList(changedDeviceLists, "changed device list");
const safeLeft = sanitizeUserIdList(leftDeviceLists, "left device list");
try {
return await originalUpdateSyncData(
toDeviceMessages,
otkCounts,
unusedFallbackKeyAlgs,
safeChanged,
safeLeft,
);
} catch (err) {
const message = typeof err === "string" ? err : err instanceof Error ? err.message : "";
if (message.includes("Expect value to be String")) {
LogService.warn(
"MatrixClientLite",
"Ignoring malformed device list entries during crypto sync",
message,
);
return;
}
throw err;
}
};
}
return client;
}
function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string {
return [
auth.homeserver,
auth.userId,
auth.accessToken,
auth.encryption ? "e2ee" : "plain",
accountId ?? DEFAULT_ACCOUNT_KEY,
].join("|");
}
async function createSharedMatrixClient(params: {
auth: MatrixAuth;
timeoutMs?: number;
accountId?: string | null;
}): Promise<SharedMatrixClientState> {
const client = await createMatrixClient({
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
encryption: params.auth.encryption,
localTimeoutMs: params.timeoutMs,
accountId: params.accountId,
});
return {
client,
key: buildSharedClientKey(params.auth, params.accountId),
started: false,
cryptoReady: false,
};
}
async function ensureSharedClientStarted(params: {
state: SharedMatrixClientState;
timeoutMs?: number;
initialSyncLimit?: number;
encryption?: boolean;
}): Promise<void> {
if (params.state.started) return;
if (sharedClientStartPromise) {
await sharedClientStartPromise;
return;
}
sharedClientStartPromise = (async () => {
const client = params.state.client;
// Initialize crypto if enabled
if (params.encryption && !params.state.cryptoReady) {
try {
const joinedRooms = await client.getJoinedRooms();
if (client.crypto) {
await client.crypto.prepare(joinedRooms);
params.state.cryptoReady = true;
}
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err);
}
}
await client.start();
params.state.started = true;
})();
try {
await sharedClientStartPromise;
} finally {
sharedClientStartPromise = null;
}
}
export async function resolveSharedMatrixClient(
params: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
auth?: MatrixAuth;
startClient?: boolean;
accountId?: string | null;
} = {},
): Promise<MatrixClient> {
const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env }));
const key = buildSharedClientKey(auth, params.accountId);
const shouldStart = params.startClient !== false;
if (sharedClientState?.key === key) {
if (shouldStart) {
await ensureSharedClientStarted({
state: sharedClientState,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return sharedClientState.client;
}
if (sharedClientPromise) {
const pending = await sharedClientPromise;
if (pending.key === key) {
if (shouldStart) {
await ensureSharedClientStarted({
state: pending,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return pending.client;
}
pending.client.stop();
sharedClientState = null;
sharedClientPromise = null;
}
sharedClientPromise = createSharedMatrixClient({
auth,
timeoutMs: params.timeoutMs,
accountId: params.accountId,
});
try {
const created = await sharedClientPromise;
sharedClientState = created;
if (shouldStart) {
await ensureSharedClientStarted({
state: created,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return created.client;
} finally {
sharedClientPromise = null;
}
}
export async function waitForMatrixSync(_params: {
client: MatrixClient;
timeoutMs?: number;
abortSignal?: AbortSignal;
}): Promise<void> {
// matrix-bot-sdk handles sync internally in start()
// This is kept for API compatibility but is essentially a no-op now
}
export function stopSharedClient(): void {
if (sharedClientState) {
sharedClientState.client.stop();
sharedClientState = null;
}
}
@@ -1,165 +0,0 @@
import { MatrixClient } from "matrix-bot-sdk";
import type { CoreConfig } from "../types.js";
import { getMatrixRuntime } from "../../runtime.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
function clean(value?: string): string {
return value?.trim() ?? "";
}
export function resolveMatrixConfig(
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig {
const matrix = cfg.channels?.matrix ?? {};
const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER);
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
const accessToken =
clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
const deviceName =
clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined;
const initialSyncLimit =
typeof matrix.initialSyncLimit === "number"
? Math.max(0, Math.floor(matrix.initialSyncLimit))
: undefined;
const encryption = matrix.encryption ?? false;
return {
homeserver,
userId,
accessToken,
password,
deviceName,
initialSyncLimit,
encryption,
};
}
export async function resolveMatrixAuth(params?: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
}): Promise<MatrixAuth> {
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const env = params?.env ?? process.env;
const resolved = resolveMatrixConfig(cfg, env);
if (!resolved.homeserver) {
throw new Error("Matrix homeserver is required (matrix.homeserver)");
}
const {
loadMatrixCredentials,
saveMatrixCredentials,
credentialsMatchConfig,
touchMatrixCredentials,
} = await import("./credentials.js");
const cached = loadMatrixCredentials(env);
const cachedCredentials =
cached &&
credentialsMatchConfig(cached, {
homeserver: resolved.homeserver,
userId: resolved.userId || "",
})
? cached
: null;
// If we have an access token, we can fetch userId via whoami if not provided
if (resolved.accessToken) {
let userId = resolved.userId;
if (!userId) {
// Fetch userId from access token via whoami
ensureMatrixSdkLoggingConfigured();
const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
const whoami = await tempClient.getUserId();
userId = whoami;
// Save the credentials with the fetched userId
saveMatrixCredentials({
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
});
} else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
touchMatrixCredentials(env);
}
return {
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
}
if (cachedCredentials) {
touchMatrixCredentials(env);
return {
homeserver: cachedCredentials.homeserver,
userId: cachedCredentials.userId,
accessToken: cachedCredentials.accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
}
if (!resolved.userId) {
throw new Error(
"Matrix userId is required when no access token is configured (matrix.userId)",
);
}
if (!resolved.password) {
throw new Error(
"Matrix password is required when no access token is configured (matrix.password)",
);
}
// Login with password using HTTP API
const loginResponse = await fetch(`${resolved.homeserver}/_matrix/client/v3/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
initial_device_display_name: resolved.deviceName ?? "Clawdbot Gateway",
}),
});
if (!loginResponse.ok) {
const errorText = await loginResponse.text();
throw new Error(`Matrix login failed: ${errorText}`);
}
const login = (await loginResponse.json()) as {
access_token?: string;
user_id?: string;
device_id?: string;
};
const accessToken = login.access_token?.trim();
if (!accessToken) {
throw new Error("Matrix login did not return an access token");
}
const auth: MatrixAuth = {
homeserver: resolved.homeserver,
userId: login.user_id ?? resolved.userId,
accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
saveMatrixCredentials({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
deviceId: login.device_id,
});
return auth;
}
@@ -1,127 +0,0 @@
import fs from "node:fs";
import {
LogService,
MatrixClient,
SimpleFsStorageProvider,
RustSdkCryptoStorageProvider,
} from "matrix-bot-sdk";
import type { IStorageProvider, ICryptoStorageProvider } from "matrix-bot-sdk";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import {
maybeMigrateLegacyStorage,
resolveMatrixStoragePaths,
writeStorageMeta,
} from "./storage.js";
function sanitizeUserIdList(input: unknown, label: string): string[] {
if (input == null) return [];
if (!Array.isArray(input)) {
LogService.warn(
"MatrixClientLite",
`Expected ${label} list to be an array, got ${typeof input}`,
);
return [];
}
const filtered = input.filter(
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
);
if (filtered.length !== input.length) {
LogService.warn(
"MatrixClientLite",
`Dropping ${input.length - filtered.length} invalid ${label} entries from sync payload`,
);
}
return filtered;
}
export async function createMatrixClient(params: {
homeserver: string;
userId: string;
accessToken: string;
encryption?: boolean;
localTimeoutMs?: number;
accountId?: string | null;
}): Promise<MatrixClient> {
ensureMatrixSdkLoggingConfigured();
const env = process.env;
// Create storage provider
const storagePaths = resolveMatrixStoragePaths({
homeserver: params.homeserver,
userId: params.userId,
accessToken: params.accessToken,
accountId: params.accountId,
env,
});
maybeMigrateLegacyStorage({ storagePaths, env });
fs.mkdirSync(storagePaths.rootDir, { recursive: true });
const storage: IStorageProvider = new SimpleFsStorageProvider(storagePaths.storagePath);
// Create crypto storage if encryption is enabled
let cryptoStorage: ICryptoStorageProvider | undefined;
if (params.encryption) {
fs.mkdirSync(storagePaths.cryptoPath, { recursive: true });
try {
const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs");
cryptoStorage = new RustSdkCryptoStorageProvider(
storagePaths.cryptoPath,
StoreType.Sqlite,
);
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to initialize crypto storage, E2EE disabled:", err);
}
}
writeStorageMeta({
storagePaths,
homeserver: params.homeserver,
userId: params.userId,
accountId: params.accountId,
});
const client = new MatrixClient(
params.homeserver,
params.accessToken,
storage,
cryptoStorage,
);
if (client.crypto) {
const originalUpdateSyncData = client.crypto.updateSyncData.bind(client.crypto);
client.crypto.updateSyncData = async (
toDeviceMessages,
otkCounts,
unusedFallbackKeyAlgs,
changedDeviceLists,
leftDeviceLists,
) => {
const safeChanged = sanitizeUserIdList(changedDeviceLists, "changed device list");
const safeLeft = sanitizeUserIdList(leftDeviceLists, "left device list");
try {
return await originalUpdateSyncData(
toDeviceMessages,
otkCounts,
unusedFallbackKeyAlgs,
safeChanged,
safeLeft,
);
} catch (err) {
const message = typeof err === "string" ? err : err instanceof Error ? err.message : "";
if (message.includes("Expect value to be String")) {
LogService.warn(
"MatrixClientLite",
"Ignoring malformed device list entries during crypto sync",
message,
);
return;
}
throw err;
}
};
}
return client;
}
@@ -1,35 +0,0 @@
import { ConsoleLogger, LogService } from "matrix-bot-sdk";
let matrixSdkLoggingConfigured = false;
const matrixSdkBaseLogger = new ConsoleLogger();
function shouldSuppressMatrixHttpNotFound(
module: string,
messageOrObject: unknown[],
): boolean {
if (module !== "MatrixHttpClient") return false;
return messageOrObject.some((entry) => {
if (!entry || typeof entry !== "object") return false;
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
});
}
export function ensureMatrixSdkLoggingConfigured(): void {
if (matrixSdkLoggingConfigured) return;
matrixSdkLoggingConfigured = true;
LogService.setLogger({
trace: (module, ...messageOrObject) =>
matrixSdkBaseLogger.trace(module, ...messageOrObject),
debug: (module, ...messageOrObject) =>
matrixSdkBaseLogger.debug(module, ...messageOrObject),
info: (module, ...messageOrObject) =>
matrixSdkBaseLogger.info(module, ...messageOrObject),
warn: (module, ...messageOrObject) =>
matrixSdkBaseLogger.warn(module, ...messageOrObject),
error: (module, ...messageOrObject) => {
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) return;
matrixSdkBaseLogger.error(module, ...messageOrObject);
},
});
}
@@ -1,4 +0,0 @@
export function isBunRuntime(): boolean {
const versions = process.versions as { bun?: string };
return typeof versions.bun === "string";
}
Loaded 100 of 347 files, more files were not shown because too many files have changed in this diff. Show more