Compare commits

..
Author SHA1 Message Date
Peter Steinberger c01bfcbc12 fix: load CLI plugin registry for channel-aware commands (#1338) (thanks @MaudeBot) 2026-01-21 00:48:36 +00:00
Maude Bot 47110e88c7 fix(cli): load plugin registry for message/channels commands
Fixes #1327 - 'clawdbot message --channel telegram' fails with
'Unknown channel: telegram' because plugins weren't loaded.

The Commander code path (non-route-first) calls ensureConfigReady() in
preAction but doesn't load the plugin registry. Channel plugins like
telegram are registered during plugin loading, so getChannelPlugin()
returns undefined without it.

This adds ensurePluginRegistryLoaded() call for commands that need
channel plugin access: message, channels, directory.
2026-01-20 23:59:43 +00:00
551 changed files with 7432 additions and 20680 deletions

No files matched your search

+1 -2
View File
@@ -29,7 +29,6 @@
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`. - Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`. - Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
- Node remains supported for running built output (`dist/*`) and production installs. - Node remains supported for running built output (`dist/*`) and production installs.
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. Release checklist: `docs/platforms/mac/release.md`.
- Type-check/build: `pnpm build` (tsc) - Type-check/build: `pnpm build` (tsc)
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt) - Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage` - Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
@@ -101,7 +100,7 @@
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars. - CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes. - Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.** - Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- macOS logs: use `./scripts/clawlog.sh` to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`. - macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance. - If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code. - SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync. - Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
+250 -205
View File
@@ -2,229 +2,274 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 2026.1.22
### Changes
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
- Signal: add typing indicators and DM read receipts via signal-cli.
### Fixes
- Doctor: warn when gateway.mode is unset with configure/config guidance.
- Lobster: fix plugin discovery and harden the tool runtime. (#1152) Thanks @vignesh07.
## 2026.1.21
### Changes
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
- CLI: exec approvals mutations render tables instead of raw JSON.
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing.
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
### Breaking
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http
### Fixes
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
- macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-.
- Gateway: prevent multiple gateways from sharing the same config/state at once (singleton lock).
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
- Typing: start instant typing indicators at run start so DMs and mentions show immediately.
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
- Model picker: list the full catalog when no model allowlist is configured.
- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo.
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204.
- Infra: preserve fetch helper methods when wrapping abort signals. (#1387)
- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc.
## 2026.1.20 ## 2026.1.20
### Changes ### Changes
- Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui - Deps: update workspace + memory-lancedb dependencies.
- Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui
- TUI: add syntax highlighting for code blocks. (#1200) https://docs.clawd.bot/tui
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) https://docs.clawd.bot/tui
- TUI: add a searchable model picker for quicker model selection. (#1198) https://docs.clawd.bot/tui
- TUI: add input history (up/down) for submitted messages. (#1348) https://docs.clawd.bot/tui
- ACP: add `clawdbot acp` for IDE integrations. https://docs.clawd.bot/cli/acp
- ACP: add `clawdbot acp client` interactive harness for debugging. https://docs.clawd.bot/cli/acp
- Skills: add download installs with OS-filtered options. https://docs.clawd.bot/tools/skills
- Skills: add the local sherpa-onnx-tts skill. https://docs.clawd.bot/tools/skills
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. https://docs.clawd.bot/concepts/memory
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates. https://docs.clawd.bot/concepts/memory
- Memory: add OpenAI batch indexing for embeddings when configured. https://docs.clawd.bot/concepts/memory
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings. https://docs.clawd.bot/concepts/memory
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). https://docs.clawd.bot/concepts/memory
- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default. https://docs.clawd.bot/concepts/memory
- Memory: add `--verbose` logging for memory status + batch indexing details. https://docs.clawd.bot/concepts/memory
- Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.clawd.bot/concepts/memory
- Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) https://docs.clawd.bot/tools/browser
- Nostr: add the Nostr channel plugin with profile management + onboarding defaults. (#1323) https://docs.clawd.bot/channels/nostr
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) https://docs.clawd.bot/channels/matrix
- Slack: add HTTP webhook mode via Bolt HTTP receiver. (#1143) https://docs.clawd.bot/channels/slack
- Telegram: enrich forwarded-message context with normalized origin details + legacy fallback. (#1090) https://docs.clawd.bot/channels/telegram
- Discord: fall back to `/skill` when native command limits are exceeded. (#1287)
- Discord: expose `/skill` globally. (#1287)
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) https://docs.clawd.bot/plugins/zalouser
- Plugins: require manifest-embedded config schemas with preflight validation warnings. (#1272) https://docs.clawd.bot/plugins/manifest
- Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest
- Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui
- Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools
- Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles
- Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.
- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime. https://docs.clawd.bot/channels/zalo
- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime. https://docs.clawd.bot/plugins/zalouser
- Plugins: allow optional agent tools with explicit allowlists and add the plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
- Plugins: 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`.
- Gateway/API: add `/v1/responses` (OpenResponses) with item-based input + semantic streaming events. (#1229)
- Gateway/API: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229)
- Usage: add `/usage cost` summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs
- Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.clawd.bot/cli/security
- Exec: add host/security/ask routing for gateway + node exec. https://docs.clawd.bot/tools/exec
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node). https://docs.clawd.bot/tools/exec
- Exec approvals: migrate approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists + skill auto-allow toggle, and add approvals UI + node exec lifecycle events. https://docs.clawd.bot/tools/exec-approvals
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`. https://docs.clawd.bot/cli/node
- Nodes: add node daemon service install/status/start/stop/restart. https://docs.clawd.bot/cli/node
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) https://docs.clawd.bot/concepts/session
- Sessions: allow `sessions_spawn` to override thinking level for sub-agent runs. https://docs.clawd.bot/tools/subagents
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers. https://docs.clawd.bot/concepts/groups
- Models: add Qwen Portal OAuth provider support. (#1120) https://docs.clawd.bot/providers/qwen
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. https://docs.clawd.bot/start/onboarding
- Docs: clarify allowlist input types and onboarding behavior for messaging channels. https://docs.clawd.bot/start/onboarding
- Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.clawd.bot/platforms/android
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.clawd.bot/bedrock
- Docs: clarify WhatsApp voice notes. https://docs.clawd.bot/channels/whatsapp
- Docs: clarify Windows WSL portproxy LAN access notes. https://docs.clawd.bot/platforms/windows
- Docs: refresh bird skill install metadata and usage notes. (#1302) https://docs.clawd.bot/tools/browser-login
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
- Agents: clarify node_modules read-only guidance in agent instructions.
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
- Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
- Android: bump okhttp + dnsjava to satisfy lint dependency checks.
- Build: update workspace + core/plugin deps.
- Build: use tsgo for dev/watch builds by default (opt out with `CLAWDBOT_TS_COMPILER=tsc`).
- 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.
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release. - Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
- macOS: stop syncing Peekaboo in postinstall. - Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
- Swabble: use the tagged Commander Swift package release. - 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.
### Breaking - Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety. Run `clawdbot doctor --fix` to repair, then update plugins (`clawdbot plugins update`) if you use any. - 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.
- 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.
- 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. - 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. - Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244) - Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
- Diagnostics: gate heartbeat/webhook logging. (#1244)
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
- Gateway: allow mobile node client ids for iOS + Android handshake validation. (#1354)
- Gateway: clarify connect/validation errors for gateway params. (#1347)
- Gateway: preserve restart wake routing + thread replies across restarts. (#1337)
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
- Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.
- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241)
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. (#1137)
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
- Sessions: fall back to session labels when listing display names. (#1124)
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226)
- Doctor: clarify plugin auto-enable hint text in the startup banner. - Doctor: clarify plugin auto-enable hint text in the startup banner.
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169) - Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
- Docs: make docs:list fail fast with a clear error if the docs directory is missing. - UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) - Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context. - TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- CLI: preserve cron delivery settings when editing message payloads. (#1322) - TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output.
- CLI: avoid duplicating --profile/--dev flags when formatting commands. - CLI: avoid duplicating --profile/--dev flags when formatting commands.
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) - CLI: load channel plugins for commands that need registry-backed lookups. (#1338) — thanks @MaudeBot.
- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195)
- CLI: skip runner rebuilds when dist is fresh. (#1231)
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301) - Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
- Status: show both usage windows with reset hints when usage data is available. (#1101) - Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315)
- UI: preserve ordered list numbering in chat markdown. (#1341)
- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342)
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283)
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212)
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202)
- TUI: align custom editor initialization with the latest pi-tui API. (#1298)
- TUI: show generic empty-state text for searchable pickers. (#1201)
- TUI: highlight model search matches and stabilize search ordering.
- Configure: hide OpenRouter auto routing model from the model picker. (#1182)
- Memory: show total file counts + scan issues in `clawdbot memory status`.
- Memory: fall back to non-batch embeddings after repeated batch failures.
- Memory: apply OpenAI batch defaults even without explicit remote config.
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
- Memory: parallelize embedding indexing with rate-limit retries.
- Memory: split overly long lines to keep embeddings under token limits.
- Memory: skip empty chunks to avoid invalid embedding inputs.
- Memory: split embedding batches to avoid OpenAI token limits during indexing.
- Memory: probe sqlite-vec availability in `clawdbot memory status`.
- Exec approvals: enforce allowlist when ask is off.
- Exec approvals: prefer raw command for node approvals/events.
- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.
- Tools: return a companion-app-required message when node exec is requested with no paired node.
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
- Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297)
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304) - Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) - 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: make resolve warnings avoid raw JSON payloads on rate limits.
- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295) - Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)
- Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners. - Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
- Discord: only emit slow listener warnings after 30s.
- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123) ## 2026.1.19-3
- Telegram: honor pairing allowlists for native slash commands.
- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118) ### Changes
- Slack: resolve Bolt import interop for Bun + Node. (#1191) - Android: remove legacy bridge transport code now that nodes use the gateway protocol.
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter). - Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
- Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346) - Gateway: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229) — thanks @RyanLisse.
- Browser: register AI snapshot refs for act commands. (#1282) - Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) — thanks @steipete.
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
- Anthropic: default API prompt caching to 1h with configurable TTL override. ### Fixes
- Anthropic: ignore TTL for OAuth. - Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
- Auth profiles: keep auto-pinned preference while allowing rotation on failover. (#1138) - UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
- Auth profiles: user pins stay locked. (#1138) - Config: allow Perplexity as a web_search provider in config validation. (#1230)
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) - Browser: register AI snapshot refs for act commands. (#1282) — thanks @John-Rood.
## 2026.1.19-2
### Changes
- Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.
- Android: bump okhttp + dnsjava to satisfy lint dependency checks.
- Docs: refresh Android node discovery docs for the Gateway WS service type.
### Fixes
- Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts. - Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.
- Tests: stabilize plugin SDK resolution and embedded agent timeouts. - CLI: skip runner rebuilds when dist is fresh. (#1231) — thanks @mukhtharcm, @thewilloftheshadow.
- Windows: install gateway scheduled task as the current user.
- Windows: show friendly guidance instead of failing on access denied. ## 2026.1.19-1
### Breaking
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
### Changes
- Gateway: add `/v1/responses` endpoint (OpenResponses API) for agentic workflows with item-based input and semantic streaming events. Enable via `gateway.http.endpoints.responses.enabled: true`.
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
- Agents: clarify node_modules read-only guidance in agent instructions.
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) — thanks @Whoaa512.
### Fixes
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
- Agents: add `clawdbot agents set-identity` helper and update bootstrap guidance for multi-agent setups. (#1222) — thanks @ThePickle31.
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214) — thanks @ameno-.
- Memory: show total file counts + scan issues in `clawdbot memory status`; fall back to non-batch embeddings after repeated batch failures.
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226) — thanks @VACInc. (fixes #1225)
## 2026.1.18-5
### Changes
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels.
- TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07.
- Docs: clarify allowlist input types and onboarding behavior for messaging channels.
### Fixes
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
- Docs: make docs:list fail fast with a clear error if the docs directory is missing.
- macOS: load menu session previews asynchronously so items populate while the menu is open. - macOS: load menu session previews asynchronously so items populate while the menu is open.
- macOS: use label colors for session preview text so previews render in menu subviews. - macOS: use label colors for session preview text so previews render in menu subviews.
- macOS: suppress usage error text in the menubar cost view. - macOS: suppress usage error text in the menubar cost view.
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166) - Telegram: honor pairing allowlists for native slash commands.
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105) - TUI: highlight model search matches and stabilize search ordering.
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006) - CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195) — thanks @gumadeiras.
- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214) - Slack: resolve Bolt import interop for Bun + Node. (#1191) — thanks @CoreyH.
- Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.
- Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x. ## 2026.1.18-4
### Changes
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.
- macOS: stop syncing Peekaboo in postinstall.
- Swabble: use the tagged Commander Swift package release.
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
- Memory: add native Gemini embeddings provider for memory search. (#1151)
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
### Fixes
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166) — thanks @AlexMikhalev.
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
## 2026.1.18-3
### Changes
- Exec: add host/security/ask routing for gateway + node exec.
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
- macOS: add approvals socket UI server + node exec lifecycle events.
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
- Nodes: add node daemon service install/status/start/stop/restart.
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
- Agents: auto-inject local image references for vision models and avoid reloading history images. (#1098) — thanks @tyler6204.
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
- ACP: add experimental ACP support for IDE integrations (`clawdbot acp`). Thanks @visionik.
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
- macOS: add exec-host IPC for node service `system.run` with HMAC + peer UID checks.
### Fixes
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
- Tools: return a companion-app-required message when node exec is requested with no paired node.
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
## 2026.1.18-2
### Fixes
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
## 2026.1.18-1
### Changes
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
### Fixes
- Memory: apply OpenAI batch defaults even without explicit remote config.
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
- Discord: only emit slow listener warnings after 30s.
## 2026.1.17-6
### Changes
- Plugins: add exclusive plugin slots with a dedicated memory slot selector.
- Memory: ship core memory tools + CLI as the bundled `memory-core` plugin.
- Docs: document plugin slots and memory plugin behavior.
- Plugins: add the bundled BlueBubbles channel plugin (disabled by default).
- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime.
- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime.
## 2026.1.17-5
### Changes
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback.
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates.
- CLI: surface FTS + embedding cache state in `clawdbot memory status`.
- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default.
- Plugins: allow optional agent tools with explicit allowlists and add plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
- Tools: centralize plugin tool policy helpers.
- Commands: add `/subagents info` and show sub-agent counts in `/status`.
- Docs: clarify plugin agent tool configuration. https://docs.clawd.bot/plugins/agent-tools
### Fixes
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
## 2026.1.18-1
### Changes
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
### Fixes
- Memory: apply OpenAI batch defaults even without explicit remote config.
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
- Discord: only emit slow listener warnings after 30s.
## 2026.1.17-3
### Changes
- Memory: add OpenAI Batch API indexing for embeddings when configured.
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings.
### Fixes
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
## 2026.1.17-2
### Changes
### Fixes
- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.
- Memory: parallelize embedding indexing with rate-limit retries.
- Memory: split overly long lines to keep embeddings under token limits.
- Memory: skip empty chunks to avoid invalid embedding inputs.
- Sessions: fall back to session labels when listing display names. (#1124) — thanks @abdaraxus.
- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123) — thanks @thewilloftheshadow.
## 2026.1.17-1
### Changes
- Telegram: enrich forwarded message context with normalized origin details + legacy fallback. (#1090) — thanks @sleontenko.
- macOS: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x.
- macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg.
- CLI: surface update availability in `clawdbot status`.
- CLI: add `clawdbot memory status --deep/--index` probes.
- CLI: add playful update completion quips.
### Fixes
- Doctor: avoid re-adding WhatsApp ack reaction config when only legacy auth files exist. (#1087) — thanks @YuriNachos.
- Hooks: parse multi-line/YAML frontmatter metadata blocks (JSON5-friendly). (#1114) — thanks @sebslight.
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
- Windows: install gateway scheduled task as the current user; show friendly guidance instead of failing on access denied.
- Status: show both usage windows with reset hints when usage data is available. (#1101) — thanks @rhjoh.
- Memory: probe sqlite-vec availability in `clawdbot memory status`.
- Memory: split embedding batches to avoid OpenAI token limits during indexing.
- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118) — thanks @sleontenko.
## 2026.1.16-2 ## 2026.1.16-2
+28 -23
View File
@@ -471,30 +471,35 @@ by Peter Steinberger and the community.
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
AI/vibe-coded PRs welcome! 🤖 AI/vibe-coded PRs welcome! 🤖
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
Core contributors:
- @cpojer — Telegram onboarding UX + docs
Thanks to all clawtributors: Thanks to all clawtributors:
<p align="left"> <p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/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/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></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/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/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/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/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/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/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/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/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/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/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/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/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/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/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/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/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/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/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/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/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/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/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/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/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/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/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/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/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/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/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/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/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/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/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/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/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/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/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/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/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/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/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></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/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/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/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/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/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/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/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></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/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/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=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/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/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/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=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/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/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/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/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/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/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/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=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/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/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=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=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p> </p>
+173 -191
View File
@@ -2,196 +2,6 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0"> <rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel> <channel>
<title>Clawdbot</title> <title>Clawdbot</title>
<item>
<title>2026.1.21</title>
<pubDate>Wed, 21 Jan 2026 08:18:22 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>7116</sparkle:version>
<sparkle:shortVersionString>2026.1.21</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.21</h2>
<h3>Changes</h3>
<ul>
<li>Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui</li>
<li>Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui</li>
<li>TUI: add syntax highlighting for code blocks. (#1200) https://docs.clawd.bot/tui</li>
<li>TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) https://docs.clawd.bot/tui</li>
<li>TUI: add a searchable model picker for quicker model selection. (#1198) https://docs.clawd.bot/tui</li>
<li>TUI: add input history (up/down) for submitted messages. (#1348) https://docs.clawd.bot/tui</li>
<li>ACP: add <code>clawdbot acp</code> for IDE integrations. https://docs.clawd.bot/cli/acp</li>
<li>ACP: add <code>clawdbot acp client</code> interactive harness for debugging. https://docs.clawd.bot/cli/acp</li>
<li>Skills: add download installs with OS-filtered options. https://docs.clawd.bot/tools/skills</li>
<li>Skills: add the local sherpa-onnx-tts skill. https://docs.clawd.bot/tools/skills</li>
<li>Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: add SQLite embedding cache to speed up reindexing and frequent updates. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: add OpenAI batch indexing for embeddings when configured. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: enable OpenAI batch indexing by default for OpenAI embeddings. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). https://docs.clawd.bot/concepts/memory</li>
<li>Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: add <code>--verbose</code> logging for memory status + batch indexing details. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.clawd.bot/concepts/memory</li>
<li>Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) https://docs.clawd.bot/tools/browser</li>
<li>Nostr: add the Nostr channel plugin with profile management + onboarding defaults. (#1323) https://docs.clawd.bot/channels/nostr</li>
<li>Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) https://docs.clawd.bot/channels/matrix</li>
<li>Slack: add HTTP webhook mode via Bolt HTTP receiver. (#1143) https://docs.clawd.bot/channels/slack</li>
<li>Telegram: enrich forwarded-message context with normalized origin details + legacy fallback. (#1090) https://docs.clawd.bot/channels/telegram</li>
<li>Discord: fall back to <code>/skill</code> when native command limits are exceeded. (#1287)</li>
<li>Discord: expose <code>/skill</code> globally. (#1287)</li>
<li>Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) https://docs.clawd.bot/plugins/zalouser</li>
<li>Plugins: require manifest-embedded config schemas with preflight validation warnings. (#1272) https://docs.clawd.bot/plugins/manifest</li>
<li>Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest</li>
<li>Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest</li>
<li>Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui</li>
<li>Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools</li>
<li>Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles</li>
<li>Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.</li>
<li>Plugins: migrate the Zalo plugin to the shared plugin SDK runtime. https://docs.clawd.bot/channels/zalo</li>
<li>Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime. https://docs.clawd.bot/plugins/zalouser</li>
<li>Plugins: allow optional agent tools with explicit allowlists and add the plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools</li>
<li>Plugins: auto-enable bundled channel/provider plugins when configuration is present.</li>
<li>Plugins: sync plugin sources on channel switches and update npm-installed plugins during <code>clawdbot update</code>.</li>
<li>Plugins: share npm plugin update logic between <code>clawdbot update</code> and <code>clawdbot plugins update</code>.</li>
<li>Gateway/API: add <code>/v1/responses</code> (OpenResponses) with item-based input + semantic streaming events. (#1229)</li>
<li>Gateway/API: expand <code>/v1/responses</code> to support file/image inputs, tool_choice, usage, and output limits. (#1229)</li>
<li>Usage: add <code>/usage cost</code> summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs</li>
<li>Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.clawd.bot/cli/security</li>
<li>Exec: add host/security/ask routing for gateway + node exec. https://docs.clawd.bot/tools/exec</li>
<li>Exec: add <code>/exec</code> directive for per-session exec defaults (host/security/ask/node). https://docs.clawd.bot/tools/exec</li>
<li>Exec approvals: migrate approvals to <code>~/.clawdbot/exec-approvals.json</code> with per-agent allowlists + skill auto-allow toggle, and add approvals UI + node exec lifecycle events. https://docs.clawd.bot/tools/exec-approvals</li>
<li>Nodes: add headless node host (<code>clawdbot node start</code>) for <code>system.run</code>/<code>system.which</code>. https://docs.clawd.bot/cli/node</li>
<li>Nodes: add node daemon service install/status/start/stop/restart. https://docs.clawd.bot/cli/node</li>
<li>Bridge: add <code>skills.bins</code> RPC to support node host auto-allow skill bins.</li>
<li>Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) https://docs.clawd.bot/concepts/session</li>
<li>Sessions: allow <code>sessions_spawn</code> to override thinking level for sub-agent runs. https://docs.clawd.bot/tools/subagents</li>
<li>Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers. https://docs.clawd.bot/concepts/groups</li>
<li>Models: add Qwen Portal OAuth provider support. (#1120) https://docs.clawd.bot/providers/qwen</li>
<li>Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. https://docs.clawd.bot/start/onboarding</li>
<li>Docs: clarify allowlist input types and onboarding behavior for messaging channels. https://docs.clawd.bot/start/onboarding</li>
<li>Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.clawd.bot/platforms/android</li>
<li>Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.clawd.bot/bedrock</li>
<li>Docs: clarify WhatsApp voice notes. https://docs.clawd.bot/channels/whatsapp</li>
<li>Docs: clarify Windows WSL portproxy LAN access notes. https://docs.clawd.bot/platforms/windows</li>
<li>Docs: refresh bird skill install metadata and usage notes. (#1302) https://docs.clawd.bot/tools/browser-login</li>
<li>Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.</li>
<li>Agents: clarify node_modules read-only guidance in agent instructions.</li>
<li>Config: stamp last-touched metadata on write and warn if the config is newer than the running build.</li>
<li>macOS: hide usage section when usage is unavailable instead of showing provider errors.</li>
<li>Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.</li>
<li>Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.</li>
<li>Android: remove legacy bridge transport code now that nodes use the gateway protocol.</li>
<li>Android: bump okhttp + dnsjava to satisfy lint dependency checks.</li>
<li>Build: update workspace + core/plugin deps.</li>
<li>Build: use tsgo for dev/watch builds by default (opt out with <code>CLAWDBOT_TS_COMPILER=tsc</code>).</li>
<li>Repo: remove the Peekaboo git submodule now that the SPM release is used.</li>
<li>macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.</li>
<li>macOS: stop syncing Peekaboo in postinstall.</li>
<li>Swabble: use the tagged Commander Swift package release.</li>
</ul>
<h3>Breaking</h3>
<ul>
<li><strong>BREAKING:</strong> Reject invalid/unknown config entries and refuse to start the gateway for safety. Run <code>clawdbot doctor --fix</code> to repair, then update plugins (<code>clawdbot plugins update</code>) if you use any.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Discovery: shorten Bonjour DNS-SD service type to <code>_clawdbot-gw._tcp</code> and update discovery clients/docs.</li>
<li>Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.</li>
<li>Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244)</li>
<li>Diagnostics: gate heartbeat/webhook logging. (#1244)</li>
<li>Gateway: strip inbound envelope headers from chat history messages to keep clients clean.</li>
<li>Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.</li>
<li>Gateway: allow mobile node client ids for iOS + Android handshake validation. (#1354)</li>
<li>Gateway: clarify connect/validation errors for gateway params. (#1347)</li>
<li>Gateway: preserve restart wake routing + thread replies across restarts. (#1337)</li>
<li>Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.</li>
<li>Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.</li>
<li>Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)</li>
<li>Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241)</li>
<li>Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)</li>
<li>Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. (#1137)</li>
<li>Agents: sanitize oversized image payloads before send and surface image-dimension errors.</li>
<li>Sessions: fall back to session labels when listing display names. (#1124)</li>
<li>Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)</li>
<li>Config: log invalid config issues once per run and keep invalid-config errors stackless.</li>
<li>Config: allow Perplexity as a web_search provider in config validation. (#1230)</li>
<li>Config: allow custom fields under <code>skills.entries.<name>.config</code> for skill credentials/config. (#1226)</li>
<li>Doctor: clarify plugin auto-enable hint text in the startup banner.</li>
<li>Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)</li>
<li>Docs: make docs:list fail fast with a clear error if the docs directory is missing.</li>
<li>Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297)</li>
<li>Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.</li>
<li>CLI: preserve cron delivery settings when editing message payloads. (#1322)</li>
<li>CLI: keep <code>clawdbot logs</code> output resilient to broken pipes while preserving progress output.</li>
<li>CLI: avoid duplicating --profile/--dev flags when formatting commands.</li>
<li>CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207)</li>
<li>CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195)</li>
<li>CLI: skip runner rebuilds when dist is fresh. (#1231)</li>
<li>CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.</li>
<li>Status: route native <code>/status</code> to the active agent so model selection reflects the correct profile. (#1301)</li>
<li>Status: show both usage windows with reset hints when usage data is available. (#1101)</li>
<li>UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315)</li>
<li>UI: preserve ordered list numbering in chat markdown. (#1341)</li>
<li>UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342)</li>
<li>UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283)</li>
<li>UI: enable shell mode for sync Windows spawns to avoid <code>pnpm ui:build</code> EINVAL. (#1212)</li>
<li>TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202)</li>
<li>TUI: align custom editor initialization with the latest pi-tui API. (#1298)</li>
<li>TUI: show generic empty-state text for searchable pickers. (#1201)</li>
<li>TUI: highlight model search matches and stabilize search ordering.</li>
<li>Configure: hide OpenRouter auto routing model from the model picker. (#1182)</li>
<li>Memory: show total file counts + scan issues in <code>clawdbot memory status</code>.</li>
<li>Memory: fall back to non-batch embeddings after repeated batch failures.</li>
<li>Memory: apply OpenAI batch defaults even without explicit remote config.</li>
<li>Memory: index atomically so failed reindex preserves the previous memory database. (#1151)</li>
<li>Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)</li>
<li>Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.</li>
<li>Memory: parallelize embedding indexing with rate-limit retries.</li>
<li>Memory: split overly long lines to keep embeddings under token limits.</li>
<li>Memory: skip empty chunks to avoid invalid embedding inputs.</li>
<li>Memory: split embedding batches to avoid OpenAI token limits during indexing.</li>
<li>Memory: probe sqlite-vec availability in <code>clawdbot memory status</code>.</li>
<li>Exec approvals: enforce allowlist when ask is off.</li>
<li>Exec approvals: prefer raw command for node approvals/events.</li>
<li>Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.</li>
<li>Tools: return a companion-app-required message when node exec is requested with no paired node.</li>
<li>Tools: return a companion-app-required message when <code>system.run</code> is requested without a supporting node.</li>
<li>Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).</li>
<li>Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297)</li>
<li>Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)</li>
<li>Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147)</li>
<li>Discord: make resolve warnings avoid raw JSON payloads on rate limits.</li>
<li>Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)</li>
<li>Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.</li>
<li>Discord: only emit slow listener warnings after 30s.</li>
<li>Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123)</li>
<li>Telegram: honor pairing allowlists for native slash commands.</li>
<li>Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118)</li>
<li>Slack: resolve Bolt import interop for Bun + Node. (#1191)</li>
<li>Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).</li>
<li>Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346)</li>
<li>Browser: register AI snapshot refs for act commands. (#1282)</li>
<li>Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)</li>
<li>Anthropic: default API prompt caching to 1h with configurable TTL override.</li>
<li>Anthropic: ignore TTL for OAuth.</li>
<li>Auth profiles: keep auto-pinned preference while allowing rotation on failover. (#1138)</li>
<li>Auth profiles: user pins stay locked. (#1138)</li>
<li>Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332)</li>
<li>Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.</li>
<li>Tests: stabilize plugin SDK resolution and embedded agent timeouts.</li>
<li>Windows: install gateway scheduled task as the current user.</li>
<li>Windows: show friendly guidance instead of failing on access denied.</li>
<li>macOS: load menu session previews asynchronously so items populate while the menu is open.</li>
<li>macOS: use label colors for session preview text so previews render in menu subviews.</li>
<li>macOS: suppress usage error text in the menubar cost view.</li>
<li>macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166)</li>
<li>macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)</li>
<li>macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)</li>
<li>Daemon: include HOME in service environments to avoid missing HOME errors. (#1214)</li>
</ul>
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
</item>
<item> <item>
<title>2026.1.16-2</title> <title>2026.1.16-2</title>
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate> <pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
@@ -289,5 +99,177 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
]]></description> ]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/> <enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
</item> </item>
<item>
<title>2026.1.14-1</title>
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5825</sparkle:version>
<sparkle:shortVersionString>2026.1.14-1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.14-1</h2>
<h3>Highlights</h3>
<ul>
<li>Web search: <code>web_search</code>/<code>web_fetch</code> tools (Brave API) + first-time setup in onboarding/configure.</li>
<li>Browser control: Chrome extension relay takeover mode + remote browser control via <code>clawdbot browser serve</code>.</li>
<li>Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.</li>
<li>Security: expanded <code>clawdbot security audit</code> (+ <code>--fix</code>), detect-secrets CI scan, and a <code>SECURITY.md</code> reporting policy.</li>
</ul>
<h3>Changes</h3>
<h4>Web Tools</h4>
<ul>
<li>Tools: add <code>web_search</code>/<code>web_fetch</code> (Brave API), including helpful setup hints when the key is missing.</li>
<li>Tools: enable <code>web_fetch</code> by default (unless explicitly disabled in config).</li>
<li>CLI/Docs: add <code>clawdbot configure --section web</code> for storing Brave API keys and update onboarding tips.</li>
</ul>
<h4>Browser / Control UI</h4>
<ul>
<li>Browser: add Chrome extension relay takeover mode (toolbar button) + <code>clawdbot browser serve</code> remote control + <code>browser.controlToken</code>.</li>
<li>Browser: ship a built-in <code>chrome</code> profile for extension relay and start the relay automatically when running locally.</li>
<li>Browser: default <code>browser.defaultProfile</code> to <code>chrome</code> (existing Chrome takeover mode).</li>
<li>Browser: add <code>clawdbot browser extension install/path</code> and copy extension path to clipboard.</li>
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
<li>Control UI: show raw any-map entries in config views; move Docs link into the left nav.</li>
</ul>
<h4>Plugins</h4>
<ul>
<li>Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.</li>
<li>Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.</li>
<li>Channels: add Matrix plugin (external) with docs + onboarding hooks.</li>
<li>Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.</li>
</ul>
<h4>Security</h4>
<ul>
<li>Security: expand <code>clawdbot security audit</code> checks and publish a <code>SECURITY.md</code> reporting policy.</li>
<li>Security: extend <code>clawdbot security audit --fix</code> to tighten more sensitive state paths.</li>
<li>Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.</li>
</ul>
<h4>Onboarding / Daemon</h4>
<ul>
<li>Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require <code>--accept-risk</code> for <code>--non-interactive</code>.</li>
<li>Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.</li>
</ul>
<h4>Auth / Usage / Config</h4>
<ul>
<li>Usage: add MiniMax coding plan usage tracking.</li>
<li>Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.</li>
<li>Agents: add optional auth-profile copy prompt on <code>agents add</code> and improve auth error messaging.</li>
<li>Auth: add dynamic template variables to <code>messages.responsePrefix</code>. (#928) — thanks @sebslight.</li>
<li>Config: add <code>channels.<provider>.configWrites</code> gating for channel-initiated config writes; migrate Slack channel IDs.</li>
</ul>
<h4>Channels</h4>
<ul>
<li>Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.</li>
<li>WhatsApp: add <code>channels.whatsapp.sendReadReceipts</code> to disable auto read receipts. (#882) — thanks @chrisrodz.</li>
</ul>
<h4>Docs</h4>
<ul>
<li>Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.</li>
<li>Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.</li>
<li>Docs: expand gateway security hardening guidance and incident response checklist.</li>
<li>Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.</li>
<li>Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)</li>
<li>Docs: add per-command CLI doc pages and link them from <code>clawdbot <command> --help</code>.</li>
<li>Docs: add multi-gateway guide (sidebar + nav).</li>
</ul>
<h3>Fixes</h3>
<h4>Gateway / Daemon / Sessions</h4>
<ul>
<li>Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.</li>
<li>Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare <code>main</code> alias).</li>
<li>Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.</li>
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
<li>Browser: persist role snapshot refs per CDP target so <code>snapshot</code> → <code>act</code> clicks work even if Playwright returns a different Page instance.</li>
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
<li>Daemon: clear persisted launchd disabled state before bootstrap (fixes <code>daemon install</code> after uninstall). (#849) — thanks @ndraiman.</li>
<li>Sessions: return deep clones (<code>structuredClone</code>) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.</li>
<li>Heartbeat: keep <code>updatedAt</code> monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.</li>
<li>Agent: clear run context after CLI runs (<code>clearAgentRunContext</code>) to avoid runaway contexts. (#934) — thanks @ronak-guliani.</li>
<li>Gateway/Dev: ensure <code>pnpm gateway:dev</code> always uses the dev profile config + state (<code>~/.clawdbot-dev</code>).</li>
</ul>
<h4>CLI / Onboarding</h4>
<ul>
<li>Onboarding: show web search setup at the end (not the beginning).</li>
<li>Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.</li>
<li>Health: colorize “not configured” provider lines for easier scanning.</li>
</ul>
<h4>Control UI / TUI</h4>
<ul>
<li>Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)</li>
<li>UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.</li>
<li>UI: always apply <code>?token=</code> from URL (fixes unauthorized after re-onboard).</li>
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
<li>TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.</li>
<li>TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.</li>
<li>TUI: show LLM error messages (rate limits, auth, etc.) instead of <code>(no output)</code>.</li>
</ul>
<h4>Agents / Auth / Tools / Sandbox</h4>
<ul>
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
<li>Logging: tolerate <code>EIO</code> from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.</li>
<li>Sandbox: restore <code>docker.binds</code> config validation and preserve configured PATH for <code>docker exec</code>. (#873) — thanks @akonyer.</li>
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
</ul>
<h4>macOS / Apps</h4>
<ul>
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
<li>macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.</li>
<li>macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)</li>
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
<li>macOS: fix cron preview/testing payload to use <code>channel</code> key. (#867) — thanks @wes-davis.</li>
<li>macOS: update cron testing channel arg. (#896) — thanks @ngutman.</li>
</ul>
<h4>Channels / Messaging</h4>
<ul>
<li>Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)</li>
<li>Slack: respect <code>channels.slack.requireMention</code> default when resolving channel mention gating. (#850) — thanks @evalexpr.</li>
<li>Slack: drop Socket Mode events with mismatched <code>api_app_id</code>/<code>team_id</code>. (#889) — thanks @roshanasingh4.</li>
<li>Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.</li>
<li>Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.</li>
<li>Telegram: honor <code>channels.telegram.timeoutSeconds</code> for grammY API requests. (#863) — thanks @Snaver.</li>
<li>Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).</li>
<li>Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.</li>
<li>Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.</li>
<li>Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.</li>
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
<li>Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.</li>
<li>iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)</li>
<li>WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.</li>
<li>WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.</li>
<li>WhatsApp: harden owner command auth.</li>
<li>Auto-reply: treat trailing <code>NO_REPLY</code> tokens as silent replies.</li>
</ul>
<h4>Config / Doctor / Packaging</h4>
<ul>
<li>Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).</li>
<li>Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).</li>
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
</item>
</channel> </channel>
</rss> </rss>
+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 = 202601210 versionCode = 202601200
versionName = "2026.1.21" versionName = "2026.1.20"
} }
buildTypes { buildTypes {
@@ -529,7 +529,7 @@ class NodeRuntime(context: Context) {
caps = buildCapabilities(), caps = buildCapabilities(),
commands = buildInvokeCommands(), commands = buildInvokeCommands(),
permissions = emptyMap(), permissions = emptyMap(),
client = buildClientInfo(clientId = "clawdbot-android", clientMode = "node"), client = buildClientInfo(clientId = "node-host", clientMode = "node"),
userAgent = buildUserAgent(), userAgent = buildUserAgent(),
) )
} }
+2 -2
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.21</string> <string>2026.1.20</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>20260121</string> <string>20260120</string>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoadsInWebContent</key> <key>NSAllowsArbitraryLoadsInWebContent</key>
@@ -132,12 +132,6 @@ final class TalkModeManager: NSObject {
} }
private func startRecognition() throws { private func startRecognition() throws {
#if targetEnvironment(simulator)
throw NSError(domain: "TalkMode", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
])
#endif
self.stopRecognition() self.stopRecognition()
self.speechRecognizer = SFSpeechRecognizer() self.speechRecognizer = SFSpeechRecognizer()
guard let recognizer = self.speechRecognizer else { guard let recognizer = self.speechRecognizer else {
@@ -152,11 +146,6 @@ final class TalkModeManager: NSObject {
let input = self.audioEngine.inputNode let input = self.audioEngine.inputNode
let format = input.outputFormat(forBus: 0) let format = input.outputFormat(forBus: 0)
guard format.sampleRate > 0, format.channelCount > 0 else {
throw NSError(domain: "TalkMode", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Invalid audio input format",
])
}
input.removeTap(onBus: 0) input.removeTap(onBus: 0)
let tapBlock = Self.makeAudioTapAppendCallback(request: request) let tapBlock = Self.makeAudioTapAppendCallback(request: request)
input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock) input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock)
+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.21</string> <string>2026.1.20</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>20260121</string> <string>20260120</string>
</dict> </dict>
</plist> </plist>
+4 -4
View File
@@ -81,8 +81,8 @@ targets:
properties: properties:
CFBundleDisplayName: Clawdbot CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.1.21" CFBundleShortVersionString: "2026.1.20"
CFBundleVersion: "20260121" CFBundleVersion: "20260120"
UILaunchScreen: {} UILaunchScreen: {}
UIApplicationSceneManifest: UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false UIApplicationSupportsMultipleScenes: false
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist path: Tests/Info.plist
properties: properties:
CFBundleDisplayName: ClawdbotTests CFBundleDisplayName: ClawdbotTests
CFBundleShortVersionString: "2026.1.21" CFBundleShortVersionString: "2026.1.20"
CFBundleVersion: "20260121" CFBundleVersion: "20260120"
@@ -284,16 +284,13 @@ enum CommandResolver {
var args: [String] = [ var args: [String] = [
"-o", "BatchMode=yes", "-o", "BatchMode=yes",
"-o", "IdentitiesOnly=yes",
"-o", "StrictHostKeyChecking=accept-new", "-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes", "-o", "UpdateHostKeys=yes",
] ]
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) } if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines) if !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if !identity.isEmpty { args.append(contentsOf: ["-i", settings.identity])
// Only use IdentitiesOnly when an explicit identity file is provided.
// This allows 1Password SSH agent and other SSH agents to provide keys.
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
args.append(contentsOf: ["-i", identity])
} }
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
args.append(userHost) args.append(userHost)
+24 -321
View File
@@ -6,19 +6,15 @@ struct ConfigSettings: View {
private let isNixMode = ProcessInfo.processInfo.isNixMode private let isNixMode = ProcessInfo.processInfo.isNixMode
@Bindable var store: ChannelsStore @Bindable var store: ChannelsStore
@State private var hasLoaded = false @State private var hasLoaded = false
@State private var activeSectionKey: String?
@State private var activeSubsection: SubsectionSelection?
init(store: ChannelsStore = .shared) { init(store: ChannelsStore = .shared) {
self.store = store self.store = store
} }
var body: some View { var body: some View {
HStack(spacing: 16) { ScrollView {
self.sidebar self.content
self.detail
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.task { .task {
guard !self.hasLoaded else { return } guard !self.hasLoaded else { return }
guard !self.isPreview else { return } guard !self.isPreview else { return }
@@ -26,125 +22,42 @@ struct ConfigSettings: View {
await self.store.loadConfigSchema() await self.store.loadConfigSchema()
await self.store.loadConfig() await self.store.loadConfig()
} }
.onAppear { self.ensureSelection() }
.onChange(of: self.store.configSchemaLoading) { _, loading in
if !loading { self.ensureSelection() }
}
} }
} }
extension ConfigSettings { extension ConfigSettings {
private enum SubsectionSelection: Hashable { private var content: some View {
case all VStack(alignment: .leading, spacing: 16) {
case key(String) self.header
} if let status = self.store.configStatus {
Text(status)
private struct ConfigSection: Identifiable { .font(.callout)
let key: String .foregroundStyle(.secondary)
let label: String }
let help: String? self.actionRow
let node: ConfigSchemaNode Group {
if self.store.configSchemaLoading {
var id: String { self.key } ProgressView().controlSize(.small)
} } else if let schema = self.store.configSchema {
ConfigSchemaForm(store: self.store, schema: schema, path: [])
private struct ConfigSubsection: Identifiable { .disabled(self.isNixMode)
let key: String } else {
let label: String Text("Schema unavailable.")
let help: String?
let node: ConfigSchemaNode
let path: ConfigPath
var id: String { self.key }
}
private var sections: [ConfigSection] {
guard let schema = self.store.configSchema else { return [] }
return self.resolveSections(schema)
}
private var activeSection: ConfigSection? {
self.sections.first { $0.key == self.activeSectionKey }
}
private var sidebar: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
if self.sections.isEmpty {
Text("No config sections available.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 4)
} else {
ForEach(self.sections) { section in
self.sidebarRow(section)
}
} }
} }
.padding(.vertical, 10) if self.store.configDirty, !self.isNixMode {
.padding(.horizontal, 10) Text("Unsaved changes")
}
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor)))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private var detail: some View {
VStack(alignment: .leading, spacing: 16) {
if self.store.configSchemaLoading {
ProgressView().controlSize(.small)
} else if let section = self.activeSection {
self.sectionDetail(section)
} else if self.store.configSchema != nil {
self.emptyDetail
} else {
Text("Schema unavailable.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer(minLength: 0)
} }
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .leading)
}
private var emptyDetail: some View {
VStack(alignment: .leading, spacing: 8) {
self.header
Text("Select a config section to view settings.")
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.vertical, 18) .padding(.vertical, 18)
} .groupBoxStyle(PlainSettingsGroupBoxStyle())
private func sectionDetail(_ section: ConfigSection) -> some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 16) {
self.header
if let status = self.store.configStatus {
Text(status)
.font(.callout)
.foregroundStyle(.secondary)
}
self.actionRow
self.sectionHeader(section)
self.subsectionNav(section)
self.sectionForm(section)
if self.store.configDirty, !self.isNixMode {
Text("Unsaved changes")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
} }
@ViewBuilder @ViewBuilder
@@ -158,18 +71,6 @@ extension ConfigSettings {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
private func sectionHeader(_ section: ConfigSection) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(section.label)
.font(.title3.weight(.semibold))
if let help = section.help {
Text(help)
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
private var actionRow: some View { private var actionRow: some View {
HStack(spacing: 10) { HStack(spacing: 10) {
Button("Reload") { Button("Reload") {
@@ -184,204 +85,6 @@ extension ConfigSettings {
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
private func sidebarRow(_ section: ConfigSection) -> some View {
let isSelected = self.activeSectionKey == section.key
return Button {
self.selectSection(section)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(section.label)
if let help = section.help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.background(Color.clear)
.contentShape(Rectangle())
}
.frame(maxWidth: .infinity, alignment: .leading)
.buttonStyle(.plain)
.contentShape(Rectangle())
}
@ViewBuilder
private func subsectionNav(_ section: ConfigSection) -> some View {
let subsections = self.resolveSubsections(for: section)
if subsections.isEmpty {
EmptyView()
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
self.subsectionButton(
title: "All",
isSelected: self.activeSubsection == .all)
{
self.activeSubsection = .all
}
ForEach(subsections) { subsection in
self.subsectionButton(
title: subsection.label,
isSelected: self.activeSubsection == .key(subsection.key))
{
self.activeSubsection = .key(subsection.key)
}
}
}
.padding(.vertical, 2)
}
}
}
private func subsectionButton(
title: String,
isSelected: Bool,
action: @escaping () -> Void) -> some View
{
Button(action: action) {
Text(title)
.font(.callout.weight(.semibold))
.foregroundStyle(isSelected ? Color.accentColor : .primary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color(nsColor: .controlBackgroundColor))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
private func sectionForm(_ section: ConfigSection) -> some View {
let subsection = self.activeSubsection
let defaultPath: ConfigPath = [.key(section.key)]
let subsections = self.resolveSubsections(for: section)
let resolved: (ConfigSchemaNode, ConfigPath) = {
if case let .key(key) = subsection,
let match = subsections.first(where: { $0.key == key })
{
return (match.node, match.path)
}
return (self.resolvedSchemaNode(section.node), defaultPath)
}()
return ConfigSchemaForm(store: self.store, schema: resolved.0, path: resolved.1)
.disabled(self.isNixMode)
}
private func ensureSelection() {
guard let schema = self.store.configSchema else { return }
let sections = self.resolveSections(schema)
guard !sections.isEmpty else { return }
let active = sections.first { $0.key == self.activeSectionKey } ?? sections[0]
if self.activeSectionKey != active.key {
self.activeSectionKey = active.key
}
self.ensureSubsection(for: active)
}
private func ensureSubsection(for section: ConfigSection) {
let subsections = self.resolveSubsections(for: section)
guard !subsections.isEmpty else {
self.activeSubsection = nil
return
}
switch self.activeSubsection {
case .all:
return
case let .key(key):
if subsections.contains(where: { $0.key == key }) { return }
case .none:
break
}
if let first = subsections.first {
self.activeSubsection = .key(first.key)
}
}
private func selectSection(_ section: ConfigSection) {
guard self.activeSectionKey != section.key else { return }
self.activeSectionKey = section.key
let subsections = self.resolveSubsections(for: section)
if let first = subsections.first {
self.activeSubsection = .key(first.key)
} else {
self.activeSubsection = nil
}
}
private func resolveSections(_ root: ConfigSchemaNode) -> [ConfigSection] {
let node = self.resolvedSchemaNode(root)
let hints = self.store.configUiHints
let keys = node.properties.keys.sorted { lhs, rhs in
let orderA = hintForPath([.key(lhs)], hints: hints)?.order ?? 0
let orderB = hintForPath([.key(rhs)], hints: hints)?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs < rhs
}
return keys.compactMap { key in
guard let child = node.properties[key] else { return nil }
let path: ConfigPath = [.key(key)]
let hint = hintForPath(path, hints: hints)
let label = hint?.label
?? child.title
?? self.humanize(key)
let help = hint?.help ?? child.description
return ConfigSection(key: key, label: label, help: help, node: child)
}
}
private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] {
let node = self.resolvedSchemaNode(section.node)
guard node.schemaType == "object" else { return [] }
let hints = self.store.configUiHints
let keys = node.properties.keys.sorted { lhs, rhs in
let orderA = hintForPath([.key(section.key), .key(lhs)], hints: hints)?.order ?? 0
let orderB = hintForPath([.key(section.key), .key(rhs)], hints: hints)?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs < rhs
}
return keys.compactMap { key in
guard let child = node.properties[key] else { return nil }
let path: ConfigPath = [.key(section.key), .key(key)]
let hint = hintForPath(path, hints: hints)
let label = hint?.label
?? child.title
?? self.humanize(key)
let help = hint?.help ?? child.description
return ConfigSubsection(
key: key,
label: label,
help: help,
node: child,
path: path)
}
}
private func resolvedSchemaNode(_ node: ConfigSchemaNode) -> ConfigSchemaNode {
let variants = node.anyOf.isEmpty ? node.oneOf : node.anyOf
if !variants.isEmpty {
let nonNull = variants.filter { !$0.isNullSchema }
if nonNull.count == 1, let only = nonNull.first { return only }
}
return node
}
private func humanize(_ key: String) -> String {
key.replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: "-", with: " ")
.capitalized
}
} }
struct ConfigSettings_Previews: PreviewProvider { struct ConfigSettings_Previews: PreviewProvider {
@@ -6,20 +6,15 @@ final class ConnectionModeCoordinator {
static let shared = ConnectionModeCoordinator() static let shared = ConnectionModeCoordinator()
private let logger = Logger(subsystem: "com.clawdbot", category: "connection") private let logger = Logger(subsystem: "com.clawdbot", category: "connection")
private var lastMode: AppState.ConnectionMode?
/// Apply the requested connection mode by starting/stopping local gateway, /// Apply the requested connection mode by starting/stopping local gateway,
/// managing the control-channel SSH tunnel, and cleaning up chat windows/panels. /// managing the control-channel SSH tunnel, and cleaning up chat windows/panels.
func apply(mode: AppState.ConnectionMode, paused: Bool) async { func apply(mode: AppState.ConnectionMode, paused: Bool) async {
if let lastMode = self.lastMode, lastMode != mode {
GatewayProcessManager.shared.clearLastFailure()
NodesStore.shared.lastError = nil
}
self.lastMode = mode
switch mode { switch mode {
case .unconfigured: case .unconfigured:
_ = await NodeServiceManager.stop() if let error = await NodeServiceManager.stop() {
NodesStore.shared.lastError = nil NodesStore.shared.lastError = "Node service stop failed: \(error)"
}
await RemoteTunnelManager.shared.stopAll() await RemoteTunnelManager.shared.stopAll()
WebChatManager.shared.resetTunnels() WebChatManager.shared.resetTunnels()
GatewayProcessManager.shared.stop() GatewayProcessManager.shared.stop()
@@ -28,8 +23,9 @@ final class ConnectionModeCoordinator {
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) } Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
case .local: case .local:
_ = await NodeServiceManager.stop() if let error = await NodeServiceManager.stop() {
NodesStore.shared.lastError = nil NodesStore.shared.lastError = "Node service stop failed: \(error)"
}
await RemoteTunnelManager.shared.stopAll() await RemoteTunnelManager.shared.stopAll()
WebChatManager.shared.resetTunnels() WebChatManager.shared.resetTunnels()
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused) let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
@@ -60,7 +56,6 @@ final class ConnectionModeCoordinator {
WebChatManager.shared.resetTunnels() WebChatManager.shared.resetTunnels()
do { do {
NodesStore.shared.lastError = nil
if let error = await NodeServiceManager.start() { if let error = await NodeServiceManager.start() {
NodesStore.shared.lastError = "Node service start failed: \(error)" NodesStore.shared.lastError = "Node service start failed: \(error)"
} }
@@ -87,7 +87,15 @@ final class ControlChannel {
func configure() async { func configure() async {
self.logger.info("control channel configure mode=local") self.logger.info("control channel configure mode=local")
await self.refreshEndpoint(reason: "configure") self.state = .connecting
do {
try await GatewayConnection.shared.refresh()
self.state = .connected
PresenceReporter.shared.sendImmediate(reason: "connect")
} catch {
let message = self.friendlyGatewayMessage(error)
self.state = .degraded(message)
}
} }
func configure(mode: Mode = .local) async throws { func configure(mode: Mode = .local) async throws {
@@ -103,7 +111,7 @@ final class ControlChannel {
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)") "target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
self.state = .connecting self.state = .connecting
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
await self.refreshEndpoint(reason: "configure") await self.configure()
} catch { } catch {
self.state = .degraded(error.localizedDescription) self.state = .degraded(error.localizedDescription)
throw error throw error
@@ -111,19 +119,6 @@ final class ControlChannel {
} }
} }
func refreshEndpoint(reason: String) async {
self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)")
self.state = .connecting
do {
try await self.establishGatewayConnection()
self.state = .connected
PresenceReporter.shared.sendImmediate(reason: "connect")
} catch {
let message = self.friendlyGatewayMessage(error)
self.state = .degraded(message)
}
}
func disconnect() async { func disconnect() async {
await GatewayConnection.shared.shutdown() await GatewayConnection.shared.shutdown()
self.state = .disconnected self.state = .disconnected
@@ -280,28 +275,18 @@ final class ControlChannel {
} }
} }
await self.refreshEndpoint(reason: "recovery:\(reasonText)") do {
if case .connected = self.state { try await GatewayConnection.shared.refresh()
self.logger.info("control channel recovery finished") self.logger.info("control channel recovery finished")
} else if case let .degraded(message) = self.state { } catch {
self.logger.error("control channel recovery failed \(message, privacy: .public)") self.logger.error(
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
} }
self.recoveryTask = nil self.recoveryTask = nil
} }
} }
private func establishGatewayConnection(timeoutMs: Int = 5000) async throws {
try await GatewayConnection.shared.refresh()
let ok = try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
if ok == false {
throw NSError(
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
}
}
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws { func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
var merged = params var merged = params
merged["text"] = AnyHashable(text) merged["text"] = AnyHashable(text)
@@ -484,22 +484,6 @@ struct DebugSettings: View {
} }
} }
VStack(alignment: .leading, spacing: 6) {
Text(
"Note: macOS may require restarting Clawdbot after enabling Accessibility or Screen Recording.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button {
LaunchdManager.startClawdbot()
} label: {
Label("Restart Clawdbot", systemImage: "arrow.counterclockwise")
}
.buttonStyle(.bordered)
.controlSize(.small)
}
HStack(spacing: 8) { HStack(spacing: 8) {
Button("Restart app") { DebugActions.restartApp() } Button("Restart app") { DebugActions.restartApp() }
Button("Restart onboarding") { DebugActions.restartOnboarding() } Button("Restart onboarding") { DebugActions.restartOnboarding() }
@@ -276,15 +276,12 @@ enum ExecApprovalsStore {
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines) ? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
: "default" : "default"
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent() let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
let resolvedAgent = ExecApprovalsResolvedDefaults( let resolvedAgent = ExecApprovalsResolvedDefaults(
security: agentEntry.security ?? wildcardEntry.security ?? resolvedDefaults.security, security: agentEntry.security ?? resolvedDefaults.security,
ask: agentEntry.ask ?? wildcardEntry.ask ?? resolvedDefaults.ask, ask: agentEntry.ask ?? resolvedDefaults.ask,
askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback askFallback: agentEntry.askFallback ?? resolvedDefaults.askFallback,
?? resolvedDefaults.askFallback, autoAllowSkills: agentEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills)
autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills let allowlist = (agentEntry.allowlist ?? [])
?? resolvedDefaults.autoAllowSkills)
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
.map { entry in .map { entry in
ExecAllowlistEntry( ExecAllowlistEntry(
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines), pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
@@ -556,30 +553,6 @@ enum ExecCommandFormatter {
} }
} }
enum ExecApprovalHelpers {
static func parseDecision(_ raw: String?) -> ExecApprovalDecision? {
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty else { return nil }
return ExecApprovalDecision(rawValue: trimmed)
}
static func requiresAsk(
ask: ExecAsk,
security: ExecSecurity,
allowlistMatch: ExecAllowlistEntry?,
skillAllow: Bool) -> Bool
{
if ask == .always { return true }
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
return false
}
static func allowlistPattern(command: [String], resolution: ExecCommandResolution?) -> String? {
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
return pattern.isEmpty ? nil : pattern
}
}
enum ExecAllowlistMatcher { enum ExecAllowlistMatcher {
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? { static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
guard let resolution, !entries.isEmpty else { return nil } guard let resolution, !entries.isEmpty else { return nil }
@@ -314,12 +314,12 @@ private enum ExecHostExecutor {
} }
var approvedByAsk = approvalDecision != nil var approvedByAsk = approvalDecision != nil
if ExecApprovalHelpers.requiresAsk( if self.requiresAsk(
ask: context.ask, ask: context.ask,
security: context.security, security: context.security,
allowlistMatch: context.allowlistMatch, allowlistMatch: context.allowlistMatch,
skillAllow: context.skillAllow), skillAllow: context.skillAllow),
approvalDecision == nil approvalDecision == nil
{ {
let decision = ExecApprovalsPromptPresenter.prompt( let decision = ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest( ExecApprovalPromptRequest(
@@ -417,20 +417,36 @@ private enum ExecHostExecutor {
skillAllow: skillAllow) skillAllow: skillAllow)
} }
private static func requiresAsk(
ask: ExecAsk,
security: ExecSecurity,
allowlistMatch: ExecAllowlistEntry?,
skillAllow: Bool) -> Bool
{
if ask == .always { return true }
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
return false
}
private static func persistAllowlistEntry( private static func persistAllowlistEntry(
decision: ExecApprovalDecision?, decision: ExecApprovalDecision?,
context: ExecApprovalContext) context: ExecApprovalContext)
{ {
guard decision == .allowAlways, context.security == .allowlist else { return } guard decision == .allowAlways, context.security == .allowlist else { return }
guard let pattern = ExecApprovalHelpers.allowlistPattern( guard let pattern = self.allowlistPattern(command: context.command, resolution: context.resolution) else {
command: context.command,
resolution: context.resolution)
else {
return return
} }
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern) ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
} }
private static func allowlistPattern(
command: [String],
resolution: ExecCommandResolution?) -> String?
{
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
return pattern.isEmpty ? nil : pattern
}
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? { private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
guard needsScreenRecording == true else { return nil } guard needsScreenRecording == true else { return nil }
let authorized = await PermissionManager let authorized = await PermissionManager
@@ -1,63 +0,0 @@
import Foundation
import Observation
import OSLog
@MainActor
@Observable
final class GatewayConnectivityCoordinator {
static let shared = GatewayConnectivityCoordinator()
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.connectivity")
private var endpointTask: Task<Void, Never>?
private var lastResolvedURL: URL?
private(set) var endpointState: GatewayEndpointState?
private(set) var resolvedURL: URL?
private(set) var resolvedMode: AppState.ConnectionMode?
private(set) var resolvedHostLabel: String?
private init() {
self.start()
}
func start() {
guard self.endpointTask == nil else { return }
self.endpointTask = Task { [weak self] in
guard let self else { return }
let stream = await GatewayEndpointStore.shared.subscribe()
for await state in stream {
await MainActor.run { self.handleEndpointState(state) }
}
}
}
var localEndpointHostLabel: String? {
guard self.resolvedMode == .local, let url = self.resolvedURL else { return nil }
return Self.hostLabel(for: url)
}
private func handleEndpointState(_ state: GatewayEndpointState) {
self.endpointState = state
switch state {
case let .ready(mode, url, _, _):
self.resolvedMode = mode
self.resolvedURL = url
self.resolvedHostLabel = Self.hostLabel(for: url)
let urlChanged = self.lastResolvedURL?.absoluteString != url.absoluteString
if urlChanged {
self.lastResolvedURL = url
Task { await ControlChannel.shared.refreshEndpoint(reason: "endpoint changed") }
}
case let .connecting(mode, _):
self.resolvedMode = mode
case let .unavailable(mode, _):
self.resolvedMode = mode
}
}
private static func hostLabel(for url: URL) -> String {
let host = url.host ?? url.absoluteString
if let port = url.port { return "\(host):\(port)" }
return host
}
}
@@ -68,7 +68,6 @@ actor GatewayEndpointStore {
env: ProcessInfo.processInfo.environment) env: ProcessInfo.processInfo.environment)
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root) let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP } let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
?? TailscaleService.fallbackTailnetIPv4()
return GatewayEndpointStore.resolveLocalGatewayHost( return GatewayEndpointStore.resolveLocalGatewayHost(
bindMode: bind, bindMode: bind,
customBindHost: customBindHost, customBindHost: customBindHost,
@@ -173,10 +172,6 @@ actor GatewayEndpointStore {
return configToken return configToken
} }
if isRemote {
return nil
}
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines), if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty !token.isEmpty
{ {
@@ -488,7 +483,6 @@ actor GatewayEndpointStore {
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil } guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP } let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
?? TailscaleService.fallbackTailnetIPv4()
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil } guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
let scheme = GatewayEndpointStore.resolveGatewayScheme( let scheme = GatewayEndpointStore.resolveGatewayScheme(
@@ -560,16 +554,13 @@ actor GatewayEndpointStore {
{ {
switch bindMode { switch bindMode {
case "tailnet": case "tailnet":
return tailscaleIP ?? "127.0.0.1" tailscaleIP ?? "127.0.0.1"
case "auto": case "auto":
if let tailscaleIP, !tailscaleIP.isEmpty { "127.0.0.1"
return tailscaleIP
}
return "127.0.0.1"
case "custom": case "custom":
return customBindHost ?? "127.0.0.1" customBindHost ?? "127.0.0.1"
default: default:
return "127.0.0.1" "127.0.0.1"
} }
} }
} }
@@ -640,12 +631,11 @@ extension GatewayEndpointStore {
static func _testResolveLocalGatewayHost( static func _testResolveLocalGatewayHost(
bindMode: String?, bindMode: String?,
tailscaleIP: String?, tailscaleIP: String?) -> String
customBindHost: String? = nil) -> String
{ {
self.resolveLocalGatewayHost( self.resolveLocalGatewayHost(
bindMode: bindMode, bindMode: bindMode,
customBindHost: customBindHost, customBindHost: nil,
tailscaleIP: tailscaleIP) tailscaleIP: tailscaleIP)
} }
} }
@@ -42,20 +42,10 @@ final class GatewayProcessManager {
private var environmentRefreshTask: Task<Void, Never>? private var environmentRefreshTask: Task<Void, Never>?
private var lastEnvironmentRefresh: Date? private var lastEnvironmentRefresh: Date?
private var logRefreshTask: Task<Void, Never>? private var logRefreshTask: Task<Void, Never>?
#if DEBUG
private var testingConnection: GatewayConnection?
#endif
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.process") private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.process")
private let logLimit = 20000 // characters to keep in-memory private let logLimit = 20000 // characters to keep in-memory
private let environmentRefreshMinInterval: TimeInterval = 30 private let environmentRefreshMinInterval: TimeInterval = 30
private var connection: GatewayConnection {
#if DEBUG
return self.testingConnection ?? .shared
#else
return .shared
#endif
}
func setActive(_ active: Bool) { func setActive(_ active: Bool) {
// Remote mode should never spawn a local gateway; treat as stopped. // Remote mode should never spawn a local gateway; treat as stopped.
@@ -136,10 +126,6 @@ final class GatewayProcessManager {
} }
} }
func clearLastFailure() {
self.lastFailureReason = nil
}
func refreshEnvironmentStatus(force: Bool = false) { func refreshEnvironmentStatus(force: Bool = false) {
let now = Date() let now = Date()
if !force { if !force {
@@ -192,7 +178,7 @@ final class GatewayProcessManager {
let hasListener = instance != nil let hasListener = instance != nil
let attemptAttach = { let attemptAttach = {
try await self.connection.requestRaw(method: .health, timeoutMs: 2000) try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 2000)
} }
for attempt in 0..<(hasListener ? 3 : 1) { for attempt in 0..<(hasListener ? 3 : 1) {
@@ -201,7 +187,6 @@ final class GatewayProcessManager {
let snap = decodeHealthSnapshot(from: data) let snap = decodeHealthSnapshot(from: data)
let details = self.describe(details: instanceText, port: port, snap: snap) let details = self.describe(details: instanceText, port: port, snap: snap)
self.existingGatewayDetails = details self.existingGatewayDetails = details
self.clearLastFailure()
self.status = .attachedExisting(details: details) self.status = .attachedExisting(details: details)
self.appendLog("[gateway] using existing instance: \(details)\n") self.appendLog("[gateway] using existing instance: \(details)\n")
self.logger.info("gateway using existing instance details=\(details)") self.logger.info("gateway using existing instance details=\(details)")
@@ -325,10 +310,9 @@ final class GatewayProcessManager {
while Date() < deadline { while Date() < deadline {
if !self.desiredActive { return } if !self.desiredActive { return }
do { do {
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500) _ = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 1500)
let instance = await PortGuardian.shared.describe(port: port) let instance = await PortGuardian.shared.describe(port: port)
let details = instance.map { "pid \($0.pid)" } let details = instance.map { "pid \($0.pid)" }
self.clearLastFailure()
self.status = .running(details: details) self.status = .running(details: details)
self.logger.info("gateway started details=\(details ?? "ok")") self.logger.info("gateway started details=\(details ?? "ok")")
self.refreshControlChannelIfNeeded(reason: "gateway started") self.refreshControlChannelIfNeeded(reason: "gateway started")
@@ -368,8 +352,7 @@ final class GatewayProcessManager {
while Date() < deadline { while Date() < deadline {
if !self.desiredActive { return false } if !self.desiredActive { return false }
do { do {
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500) _ = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 1500)
self.clearLastFailure()
return true return true
} catch { } catch {
try? await Task.sleep(nanoseconds: 300_000_000) try? await Task.sleep(nanoseconds: 300_000_000)
@@ -402,19 +385,3 @@ final class GatewayProcessManager {
return String(text.suffix(limit)) return String(text.suffix(limit))
} }
} }
#if DEBUG
extension GatewayProcessManager {
func setTestingConnection(_ connection: GatewayConnection?) {
self.testingConnection = connection
}
func setTestingDesiredActive(_ active: Bool) {
self.desiredActive = active
}
func setTestingLastFailureReason(_ reason: String?) {
self.lastFailureReason = reason
}
}
#endif
@@ -15,6 +15,10 @@ struct GeneralSettings: View {
private let gatewayManager = GatewayProcessManager.shared private let gatewayManager = GatewayProcessManager.shared
@State private var gatewayDiscovery = GatewayDiscoveryModel( @State private var gatewayDiscovery = GatewayDiscoveryModel(
localDisplayName: InstanceIdentity.displayName) localDisplayName: InstanceIdentity.displayName)
@State private var isInstallingCLI = false
@State private var cliStatus: String?
@State private var cliInstalled = false
@State private var cliInstallLocation: String?
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking @State private var gatewayStatus: GatewayEnvironmentStatus = .checking
@State private var remoteStatus: RemoteStatus = .idle @State private var remoteStatus: RemoteStatus = .idle
@State private var showRemoteAdvanced = false @State private var showRemoteAdvanced = false
@@ -25,6 +29,25 @@ struct GeneralSettings: View {
var body: some View { var body: some View {
ScrollView(.vertical) { ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
if !self.state.onboardingSeen {
Button {
DebugActions.restartOnboarding()
} label: {
HStack(spacing: 8) {
Label("Complete onboarding to finish setup", systemImage: "arrow.counterclockwise")
.font(.callout.weight(.semibold))
.foregroundStyle(Color.accentColor)
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.bottom, 2)
}
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
SettingsToggleRow( SettingsToggleRow(
title: "Clawdbot active", title: "Clawdbot active",
@@ -60,6 +83,8 @@ struct GeneralSettings: View {
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.", subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
binding: self.$cameraEnabled) binding: self.$cameraEnabled)
SystemRunSettingsView()
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("Location Access") Text("Location Access")
.font(.body) .font(.body)
@@ -105,6 +130,7 @@ struct GeneralSettings: View {
} }
.onAppear { .onAppear {
guard !self.isPreview else { return } guard !self.isPreview else { return }
self.refreshCLIStatus()
self.refreshGatewayStatus() self.refreshGatewayStatus()
self.lastLocationModeRaw = self.locationModeRaw self.lastLocationModeRaw = self.locationModeRaw
} }
@@ -161,14 +187,13 @@ struct GeneralSettings: View {
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
Picker("Mode", selection: self.$state.connectionMode) { Picker("", selection: self.$state.connectionMode) {
Text("Not configured").tag(AppState.ConnectionMode.unconfigured) Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
Text("Local (this Mac)").tag(AppState.ConnectionMode.local) Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
Text("Remote over SSH").tag(AppState.ConnectionMode.remote) Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
} }
.pickerStyle(.menu) .pickerStyle(.segmented)
.labelsHidden() .frame(width: 380, alignment: .leading)
.frame(width: 260, alignment: .leading)
if self.state.connectionMode == .unconfigured { if self.state.connectionMode == .unconfigured {
Text("Pick Local or Remote to start the Gateway.") Text("Pick Local or Remote to start the Gateway.")
@@ -191,6 +216,8 @@ struct GeneralSettings: View {
if self.state.connectionMode == .remote { if self.state.connectionMode == .remote {
self.remoteCard self.remoteCard
} }
self.cliInstaller
} }
} }
@@ -319,6 +346,59 @@ struct GeneralSettings: View {
return message == self.controlStatusLine return message == self.controlStatusLine
} }
private var cliInstaller: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Button {
Task { await self.installCLI() }
} label: {
let title = self.cliInstalled ? "Reinstall CLI" : "Install CLI"
ZStack {
Text(title)
.opacity(self.isInstallingCLI ? 0 : 1)
if self.isInstallingCLI {
ProgressView()
.controlSize(.mini)
}
}
.frame(minWidth: 150)
}
.disabled(self.isInstallingCLI)
if self.isInstallingCLI {
Text("Working...")
.font(.callout)
.foregroundStyle(.secondary)
} else if self.cliInstalled {
Label("Installed", systemImage: "checkmark.circle.fill")
.font(.callout)
.foregroundStyle(.secondary)
} else {
Text("Not installed")
.font(.callout)
.foregroundStyle(.secondary)
}
}
if let status = cliStatus {
Text(status)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
} else if let installLocation = self.cliInstallLocation {
Text("Found at \(installLocation)")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
} else {
Text("Installs a user-space Node 22+ runtime and the CLI (no Homebrew).")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
}
private var gatewayInstallerCard: some View { private var gatewayInstallerCard: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) { HStack(spacing: 10) {
@@ -374,6 +454,22 @@ struct GeneralSettings: View {
.cornerRadius(10) .cornerRadius(10)
} }
private func installCLI() async {
guard !self.isInstallingCLI else { return }
self.isInstallingCLI = true
defer { isInstallingCLI = false }
await CLIInstaller.install { status in
self.cliStatus = status
self.refreshCLIStatus()
}
}
private func refreshCLIStatus() {
let installLocation = CLIInstaller.installedLocation()
self.cliInstallLocation = installLocation
self.cliInstalled = installLocation != nil
}
private func refreshGatewayStatus() { private func refreshGatewayStatus() {
Task { Task {
let status = await Task.detached(priority: .utility) { let status = await Task.detached(priority: .utility) {
@@ -667,6 +763,9 @@ extension GeneralSettings {
message: "Gateway ready") message: "Gateway ready")
view.remoteStatus = .failed("SSH failed") view.remoteStatus = .failed("SSH failed")
view.showRemoteAdvanced = true view.showRemoteAdvanced = true
view.cliInstalled = true
view.cliInstallLocation = "/usr/local/bin/clawdbot"
view.cliStatus = "Installed"
_ = view.body _ = view.body
state.connectionMode = .unconfigured state.connectionMode = .unconfigured
@@ -235,8 +235,8 @@ final class HealthStore {
let lower = error.lowercased() let lower = error.lowercased()
if lower.contains("connection refused") { if lower.contains("connection refused") {
let port = GatewayEnvironment.gatewayPort() let port = GatewayEnvironment.gatewayPort()
let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)" return "The gateway control port (127.0.0.1:\(port)) isn’t listening — " +
return "The gateway control port (\(host)) isn’t listening — restart Clawdbot to bring it back." "restart Clawdbot to bring it back."
} }
if lower.contains("timeout") { if lower.contains("timeout") {
return "Timed out waiting for the control server; the gateway may be crashed or still starting." return "Timed out waiting for the control server; the gateway may be crashed or still starting."
@@ -13,7 +13,6 @@ struct ClawdbotApp: App {
private let gatewayManager = GatewayProcessManager.shared private let gatewayManager = GatewayProcessManager.shared
private let controlChannel = ControlChannel.shared private let controlChannel = ControlChannel.shared
private let activityStore = WorkActivityStore.shared private let activityStore = WorkActivityStore.shared
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
@State private var statusItem: NSStatusItem? @State private var statusItem: NSStatusItem?
@State private var isMenuPresented = false @State private var isMenuPresented = false
@State private var isPanelVisible = false @State private var isPanelVisible = false
@@ -469,7 +469,7 @@ extension MenuSessionsInjector {
} }
case .local: case .local:
platform = "local" platform = "local"
host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)" host = "127.0.0.1:\(port)"
case .unconfigured: case .unconfigured:
platform = nil platform = nil
host = nil host = nil
@@ -2,28 +2,14 @@ import Foundation
import JavaScriptCore import JavaScriptCore
enum ModelCatalogLoader { enum ModelCatalogLoader {
static var defaultPath: String { self.resolveDefaultPath() } static let defaultPath: String = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path
private static let logger = Logger(subsystem: "com.clawdbot", category: "models") private static let logger = Logger(subsystem: "com.clawdbot", category: "models")
private nonisolated static let appSupportDir: URL = {
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("Clawdbot", isDirectory: true)
}()
private static var cachePath: URL {
self.appSupportDir.appendingPathComponent("model-catalog/models.generated.js", isDirectory: false)
}
static func load(from path: String) async throws -> [ModelChoice] { static func load(from path: String) async throws -> [ModelChoice] {
let expanded = (path as NSString).expandingTildeInPath let expanded = (path as NSString).expandingTildeInPath
guard let resolved = self.resolvePath(preferred: expanded) else { self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: expanded).lastPathComponent)")
self.logger.error("model catalog load failed: file not found") let source = try String(contentsOfFile: expanded, encoding: .utf8)
throw NSError(
domain: "ModelCatalogLoader",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Model catalog file not found"])
}
self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: resolved.path).lastPathComponent)")
let source = try String(contentsOfFile: resolved.path, encoding: .utf8)
let sanitized = self.sanitize(source: source) let sanitized = self.sanitize(source: source)
let ctx = JSContext() let ctx = JSContext()
@@ -59,82 +45,9 @@ enum ModelCatalogLoader {
return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending
} }
self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)") self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)")
if resolved.shouldCache {
self.cacheCatalog(sourcePath: resolved.path)
}
return sorted return sorted
} }
private static func resolveDefaultPath() -> String {
let cache = self.cachePath.path
if FileManager().isReadableFile(atPath: cache) { return cache }
if let bundlePath = self.bundleCatalogPath() { return bundlePath }
if let nodePath = self.nodeModulesCatalogPath() { return nodePath }
return cache
}
private static func resolvePath(preferred: String) -> (path: String, shouldCache: Bool)? {
if FileManager().isReadableFile(atPath: preferred) {
return (preferred, preferred != self.cachePath.path)
}
if let bundlePath = self.bundleCatalogPath(), bundlePath != preferred {
self.logger.warning("model catalog path missing; falling back to bundled catalog")
return (bundlePath, true)
}
let cache = self.cachePath.path
if cache != preferred, FileManager().isReadableFile(atPath: cache) {
self.logger.warning("model catalog path missing; falling back to cached catalog")
return (cache, false)
}
if let nodePath = self.nodeModulesCatalogPath(), nodePath != preferred {
self.logger.warning("model catalog path missing; falling back to node_modules catalog")
return (nodePath, true)
}
return nil
}
private static func bundleCatalogPath() -> String? {
guard let url = Bundle.main.url(forResource: "models.generated", withExtension: "js") else {
return nil
}
return url.path
}
private static func nodeModulesCatalogPath() -> String? {
let roots = [
URL(fileURLWithPath: CommandResolver.projectRootPath()),
URL(fileURLWithPath: FileManager().currentDirectoryPath),
]
for root in roots {
let candidate = root
.appendingPathComponent("node_modules/@mariozechner/pi-ai/dist/models.generated.js")
if FileManager().isReadableFile(atPath: candidate.path) {
return candidate.path
}
}
return nil
}
private static func cacheCatalog(sourcePath: String) {
let destination = self.cachePath
do {
try FileManager().createDirectory(
at: destination.deletingLastPathComponent(),
withIntermediateDirectories: true)
if FileManager().fileExists(atPath: destination.path) {
try FileManager().removeItem(at: destination)
}
try FileManager().copyItem(atPath: sourcePath, toPath: destination.path)
self.logger.debug("model catalog cached file=\(destination.lastPathComponent)")
} catch {
self.logger.warning("model catalog cache failed: \(error.localizedDescription)")
}
}
private static func sanitize(source: String) -> String { private static func sanitize(source: String) -> String {
guard let exportRange = source.range(of: "export const MODELS"), guard let exportRange = source.range(of: "export const MODELS"),
let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"), let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"),
@@ -480,26 +480,26 @@ actor MacNodeRuntime {
message: "SYSTEM_RUN_DISABLED: security=deny") message: "SYSTEM_RUN_DISABLED: security=deny")
} }
let approval = await self.resolveSystemRunApproval( let requiresAsk: Bool = {
req: req, if ask == .always { return true }
params: params, if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
context: ExecRunContext( return false
displayCommand: displayCommand, }()
security: security,
ask: ask, let approvedByAsk = params.approved == true
agentId: agentId, if requiresAsk, !approvedByAsk {
resolution: resolution, await self.emitExecEvent(
allowlistMatch: allowlistMatch, "exec.denied",
skillAllow: skillAllow, payload: ExecEventPayload(
sessionKey: sessionKey, sessionKey: sessionKey,
runId: runId)) runId: runId,
if let response = approval.response { return response } host: "node",
let approvedByAsk = approval.approvedByAsk command: displayCommand,
let persistAllowlist = approval.persistAllowlist reason: "approval-required"))
if persistAllowlist, security == .allowlist, return Self.errorResponse(
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution) req,
{ code: .unavailable,
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern) message: "SYSTEM_RUN_DENIED: approval required")
} }
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk { if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
@@ -619,99 +619,6 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
} }
private struct ExecApprovalOutcome {
var approvedByAsk: Bool
var persistAllowlist: Bool
var response: BridgeInvokeResponse?
}
private struct ExecRunContext {
var displayCommand: String
var security: ExecSecurity
var ask: ExecAsk
var agentId: String?
var resolution: ExecCommandResolution?
var allowlistMatch: ExecAllowlistEntry?
var skillAllow: Bool
var sessionKey: String
var runId: String
}
private func resolveSystemRunApproval(
req: BridgeInvokeRequest,
params: ClawdbotSystemRunParams,
context: ExecRunContext) async -> ExecApprovalOutcome
{
let requiresAsk = ExecApprovalHelpers.requiresAsk(
ask: context.ask,
security: context.security,
allowlistMatch: context.allowlistMatch,
skillAllow: context.skillAllow)
let decisionFromParams = ExecApprovalHelpers.parseDecision(params.approvalDecision)
var approvedByAsk = params.approved == true || decisionFromParams != nil
var persistAllowlist = decisionFromParams == .allowAlways
if decisionFromParams == .deny {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: context.sessionKey,
runId: context.runId,
host: "node",
command: context.displayCommand,
reason: "user-denied"))
return ExecApprovalOutcome(
approvedByAsk: approvedByAsk,
persistAllowlist: persistAllowlist,
response: Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: user denied"))
}
if requiresAsk, !approvedByAsk {
let decision = await MainActor.run {
ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: context.displayCommand,
cwd: params.cwd,
host: "node",
security: context.security.rawValue,
ask: context.ask.rawValue,
agentId: context.agentId,
resolvedPath: context.resolution?.resolvedPath))
}
switch decision {
case .deny:
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: context.sessionKey,
runId: context.runId,
host: "node",
command: context.displayCommand,
reason: "user-denied"))
return ExecApprovalOutcome(
approvedByAsk: approvedByAsk,
persistAllowlist: persistAllowlist,
response: Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: user denied"))
case .allowAlways:
approvedByAsk = true
persistAllowlist = true
case .allowOnce:
approvedByAsk = true
}
}
return ExecApprovalOutcome(
approvedByAsk: approvedByAsk,
persistAllowlist: persistAllowlist,
response: nil)
}
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
_ = ExecApprovalsStore.ensureFile() _ = ExecApprovalsStore.ensureFile()
let snapshot = ExecApprovalsStore.readSnapshot() let snapshot = ExecApprovalsStore.readSnapshot()
@@ -8,8 +8,6 @@ struct PermissionsSettings: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
SystemRunSettingsView()
Text("Allow these so Clawdbot can notify and capture when needed.") Text("Allow these so Clawdbot can notify and capture when needed.")
.padding(.top, 4) .padding(.top, 4)
@@ -47,6 +45,25 @@ struct PermissionStatusList: View {
.font(.footnote) .font(.footnote)
.padding(.top, 2) .padding(.top, 2)
.help("Refresh status") .help("Refresh status")
if (self.status[.accessibility] ?? false) == false || (self.status[.screenRecording] ?? false) == false {
VStack(alignment: .leading, spacing: 8) {
Text(
"Note: macOS may require restarting Clawdbot after enabling Accessibility or Screen Recording.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button {
LaunchdManager.startClawdbot()
} label: {
Label("Restart Clawdbot", systemImage: "arrow.counterclockwise")
}
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.top, 4)
}
} }
} }
@@ -72,6 +72,7 @@ final class RemotePortTunnel {
} }
var args: [String] = [ var args: [String] = [
"-o", "BatchMode=yes", "-o", "BatchMode=yes",
"-o", "IdentitiesOnly=yes",
"-o", "ExitOnForwardFailure=yes", "-o", "ExitOnForwardFailure=yes",
"-o", "StrictHostKeyChecking=accept-new", "-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes", "-o", "UpdateHostKeys=yes",
@@ -83,12 +84,7 @@ final class RemotePortTunnel {
] ]
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) } if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines) let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
if !identity.isEmpty { if !identity.isEmpty { args.append(contentsOf: ["-i", identity]) }
// Only use IdentitiesOnly when an explicit identity file is provided.
// This allows 1Password SSH agent and other SSH agents to provide keys.
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
args.append(contentsOf: ["-i", identity])
}
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
args.append(userHost) args.append(userHost)
@@ -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.21</string> <string>2026.1.20</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>202601210</string> <string>202601200</string>
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>
<string>Clawdbot</string> <string>Clawdbot</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -2,9 +2,6 @@ import AppKit
import Foundation import Foundation
import Observation import Observation
import os import os
#if canImport(Darwin)
import Darwin
#endif
/// Manages Tailscale integration and status checking. /// Manages Tailscale integration and status checking.
@Observable @Observable
@@ -103,14 +100,16 @@ final class TailscaleService {
} }
func checkTailscaleStatus() async { func checkTailscaleStatus() async {
let previousIP = self.tailscaleIP
self.isInstalled = self.checkAppInstallation() self.isInstalled = self.checkAppInstallation()
if !self.isInstalled { guard self.isInstalled else {
self.isRunning = false self.isRunning = false
self.tailscaleHostname = nil self.tailscaleHostname = nil
self.tailscaleIP = nil self.tailscaleIP = nil
self.statusError = "Tailscale is not installed" self.statusError = "Tailscale is not installed"
} else if let apiResponse = await fetchTailscaleStatus() { return
}
if let apiResponse = await fetchTailscaleStatus() {
self.isRunning = apiResponse.status.lowercased() == "running" self.isRunning = apiResponse.status.lowercased() == "running"
if self.isRunning { if self.isRunning {
@@ -139,19 +138,6 @@ final class TailscaleService {
self.statusError = "Please start the Tailscale app" self.statusError = "Please start the Tailscale app"
self.logger.info("Tailscale API not responding; app likely not running") self.logger.info("Tailscale API not responding; app likely not running")
} }
if self.tailscaleIP == nil, let fallback = Self.detectTailnetIPv4() {
self.tailscaleIP = fallback
if !self.isRunning {
self.isRunning = true
}
self.statusError = nil
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
}
if previousIP != self.tailscaleIP {
await GatewayEndpointStore.shared.refresh()
}
} }
func openTailscaleApp() { func openTailscaleApp() {
@@ -177,50 +163,4 @@ final class TailscaleService {
NSWorkspace.shared.open(url) NSWorkspace.shared.open(url)
} }
} }
private nonisolated static func isTailnetIPv4(_ address: String) -> Bool {
let parts = address.split(separator: ".")
guard parts.count == 4 else { return false }
let octets = parts.compactMap { Int($0) }
guard octets.count == 4 else { return false }
let a = octets[0]
let b = octets[1]
return a == 100 && b >= 64 && b <= 127
}
private nonisolated static func detectTailnetIPv4() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
defer { freeifaddrs(addrList) }
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
let flags = Int32(ptr.pointee.ifa_flags)
let isUp = (flags & IFF_UP) != 0
let isLoopback = (flags & IFF_LOOPBACK) != 0
let family = ptr.pointee.ifa_addr.pointee.sa_family
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
var addr = ptr.pointee.ifa_addr.pointee
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = getnameinfo(
&addr,
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard result == 0 else { continue }
let len = buffer.prefix { $0 != 0 }
let bytes = len.map { UInt8(bitPattern: $0) }
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
if Self.isTailnetIPv4(ip) { return ip }
}
return nil
}
nonisolated static func fallbackTailnetIPv4() -> String? {
self.detectTailnetIPv4()
}
} }
@@ -1,16 +1,13 @@
import ClawdbotKit import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
#if canImport(Darwin)
import Darwin
#endif
struct ConnectOptions { struct ConnectOptions {
var url: String? var url: String?
var token: String? var token: String?
var password: String? var password: String?
var mode: String? var mode: String?
var timeoutMs: Int = 15000 var timeoutMs: Int = 15_000
var json: Bool = false var json: Bool = false
var probe: Bool = false var probe: Bool = false
var clientId: String = "clawdbot-macos" var clientId: String = "clawdbot-macos"
@@ -22,43 +19,53 @@ struct ConnectOptions {
static func parse(_ args: [String]) -> ConnectOptions { static func parse(_ args: [String]) -> ConnectOptions {
var opts = ConnectOptions() var opts = ConnectOptions()
let flagHandlers: [String: (inout ConnectOptions) -> Void] = [
"-h": { $0.help = true },
"--help": { $0.help = true },
"--json": { $0.json = true },
"--probe": { $0.probe = true },
]
let valueHandlers: [String: (inout ConnectOptions, String) -> Void] = [
"--url": { $0.url = $1 },
"--token": { $0.token = $1 },
"--password": { $0.password = $1 },
"--mode": { $0.mode = $1 },
"--timeout": { opts, raw in
if let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) {
opts.timeoutMs = max(250, parsed)
}
},
"--client-id": { $0.clientId = $1 },
"--client-mode": { $0.clientMode = $1 },
"--display-name": { $0.displayName = $1 },
"--role": { $0.role = $1 },
"--scopes": { opts, raw in
opts.scopes = raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
},
]
var i = 0 var i = 0
while i < args.count { while i < args.count {
let arg = args[i] let arg = args[i]
if let handler = flagHandlers[arg] { switch arg {
handler(&opts) case "-h", "--help":
i += 1 opts.help = true
continue case "--json":
} opts.json = true
if let handler = valueHandlers[arg], let value = self.nextValue(args, index: &i) { case "--probe":
handler(&opts, value) opts.probe = true
i += 1 case "--url":
continue 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 i += 1
} }
@@ -247,12 +254,8 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
if resolvedMode == "remote" { if resolvedMode == "remote" {
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines), guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty !raw.isEmpty else {
else { throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
} }
guard let url = URL(string: raw) else { guard let url = URL(string: raw) else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"]) throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
@@ -265,12 +268,9 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
} }
let port = config.port ?? 18789 let port = config.port ?? 18789
let host = resolveLocalHost(bind: config.bind) let host = "127.0.0.1"
guard let url = URL(string: "ws://\(host):\(port)") else { guard let url = URL(string: "ws://\(host):\(port)") else {
throw NSError( throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
} }
return GatewayEndpoint( return GatewayEndpoint(
url: url, url: url,
@@ -280,7 +280,7 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
} }
private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? { private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? {
try? resolveGatewayEndpoint(opts: opts, config: config) return try? resolveGatewayEndpoint(opts: opts, config: config)
} }
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? { private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
@@ -304,56 +304,3 @@ private func resolvedPassword(opts: ConnectOptions, mode: String, config: Gatewa
} }
return config.password return config.password
} }
private func resolveLocalHost(bind: String?) -> String {
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let tailnetIP = detectTailnetIPv4()
switch normalized {
case "tailnet", "auto":
return tailnetIP ?? "127.0.0.1"
default:
return "127.0.0.1"
}
}
private func detectTailnetIPv4() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
defer { freeifaddrs(addrList) }
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
let flags = Int32(ptr.pointee.ifa_flags)
let isUp = (flags & IFF_UP) != 0
let isLoopback = (flags & IFF_LOOPBACK) != 0
let family = ptr.pointee.ifa_addr.pointee.sa_family
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
var addr = ptr.pointee.ifa_addr.pointee
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = getnameinfo(
&addr,
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard result == 0 else { continue }
let len = buffer.prefix { $0 != 0 }
let bytes = len.map { UInt8(bitPattern: $0) }
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
if isTailnetIPv4(ip) { return ip }
}
return nil
}
private func isTailnetIPv4(_ address: String) -> Bool {
let parts = address.split(separator: ".")
guard parts.count == 4 else { return false }
let octets = parts.compactMap { Int($0) }
guard octets.count == 4 else { return false }
let a = octets[0]
let b = octets[1]
return a == 100 && b >= 64 && b <= 127
}
@@ -18,7 +18,6 @@ public struct ConnectParams: Codable, Sendable {
public let caps: [String]? public let caps: [String]?
public let commands: [String]? public let commands: [String]?
public let permissions: [String: AnyCodable]? public let permissions: [String: AnyCodable]?
public let pathenv: String?
public let role: String? public let role: String?
public let scopes: [String]? public let scopes: [String]?
public let device: [String: AnyCodable]? public let device: [String: AnyCodable]?
@@ -33,7 +32,6 @@ public struct ConnectParams: Codable, Sendable {
caps: [String]?, caps: [String]?,
commands: [String]?, commands: [String]?,
permissions: [String: AnyCodable]?, permissions: [String: AnyCodable]?,
pathenv: String?,
role: String?, role: String?,
scopes: [String]?, scopes: [String]?,
device: [String: AnyCodable]?, device: [String: AnyCodable]?,
@@ -47,7 +45,6 @@ public struct ConnectParams: Codable, Sendable {
self.caps = caps self.caps = caps
self.commands = commands self.commands = commands
self.permissions = permissions self.permissions = permissions
self.pathenv = pathenv
self.role = role self.role = role
self.scopes = scopes self.scopes = scopes
self.device = device self.device = device
@@ -62,7 +59,6 @@ public struct ConnectParams: Codable, Sendable {
case caps case caps
case commands case commands
case permissions case permissions
case pathenv = "pathEnv"
case role case role
case scopes case scopes
case device case device
@@ -477,7 +473,6 @@ public struct AgentParams: Codable, Sendable {
public let replychannel: String? public let replychannel: String?
public let accountid: String? public let accountid: String?
public let replyaccountid: String? public let replyaccountid: String?
public let threadid: String?
public let timeout: Int? public let timeout: Int?
public let lane: String? public let lane: String?
public let extrasystemprompt: String? public let extrasystemprompt: String?
@@ -499,7 +494,6 @@ public struct AgentParams: Codable, Sendable {
replychannel: String?, replychannel: String?,
accountid: String?, accountid: String?,
replyaccountid: String?, replyaccountid: String?,
threadid: String?,
timeout: Int?, timeout: Int?,
lane: String?, lane: String?,
extrasystemprompt: String?, extrasystemprompt: String?,
@@ -520,7 +514,6 @@ public struct AgentParams: Codable, Sendable {
self.replychannel = replychannel self.replychannel = replychannel
self.accountid = accountid self.accountid = accountid
self.replyaccountid = replyaccountid self.replyaccountid = replyaccountid
self.threadid = threadid
self.timeout = timeout self.timeout = timeout
self.lane = lane self.lane = lane
self.extrasystemprompt = extrasystemprompt self.extrasystemprompt = extrasystemprompt
@@ -542,7 +535,6 @@ public struct AgentParams: Codable, Sendable {
case replychannel = "replyChannel" case replychannel = "replyChannel"
case accountid = "accountId" case accountid = "accountId"
case replyaccountid = "replyAccountId" case replyaccountid = "replyAccountId"
case threadid = "threadId"
case timeout case timeout
case lane case lane
case extrasystemprompt = "extraSystemPrompt" case extrasystemprompt = "extraSystemPrompt"
@@ -843,47 +835,35 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int? public let activeminutes: Int?
public let includeglobal: Bool? public let includeglobal: Bool?
public let includeunknown: Bool? public let includeunknown: Bool?
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String? public let label: String?
public let spawnedby: String? public let spawnedby: String?
public let agentid: String? public let agentid: String?
public let search: String?
public init( public init(
limit: Int?, limit: Int?,
activeminutes: Int?, activeminutes: Int?,
includeglobal: Bool?, includeglobal: Bool?,
includeunknown: Bool?, includeunknown: Bool?,
includederivedtitles: Bool?,
includelastmessage: Bool?,
label: String?, label: String?,
spawnedby: String?, spawnedby: String?,
agentid: String?, agentid: String?
search: String?
) { ) {
self.limit = limit self.limit = limit
self.activeminutes = activeminutes self.activeminutes = activeminutes
self.includeglobal = includeglobal self.includeglobal = includeglobal
self.includeunknown = includeunknown self.includeunknown = includeunknown
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label self.label = label
self.spawnedby = spawnedby self.spawnedby = spawnedby
self.agentid = agentid self.agentid = agentid
self.search = search
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case limit case limit
case activeminutes = "activeMinutes" case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal" case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown" case includeunknown = "includeUnknown"
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label case label
case spawnedby = "spawnedBy" case spawnedby = "spawnedBy"
case agentid = "agentId" case agentid = "agentId"
case search
} }
} }
@@ -1908,7 +1888,6 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
} }
public struct ExecApprovalRequestParams: Codable, Sendable { public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String?
public let command: String public let command: String
public let cwd: String? public let cwd: String?
public let host: String? public let host: String?
@@ -1920,7 +1899,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public let timeoutms: Int? public let timeoutms: Int?
public init( public init(
id: String?,
command: String, command: String,
cwd: String?, cwd: String?,
host: String?, host: String?,
@@ -1931,7 +1909,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
sessionkey: String?, sessionkey: String?,
timeoutms: Int? timeoutms: Int?
) { ) {
self.id = id
self.command = command self.command = command
self.cwd = cwd self.cwd = cwd
self.host = host self.host = host
@@ -1943,7 +1920,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
self.timeoutms = timeoutms self.timeoutms = timeoutms
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id
case command case command
case cwd case cwd
case host case host
@@ -3,8 +3,6 @@ import SwiftUI
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
private typealias SnapshotAnyCodable = Clawdbot.AnyCodable
@Suite(.serialized) @Suite(.serialized)
@MainActor @MainActor
struct ChannelsSettingsSmokeTests { struct ChannelsSettingsSmokeTests {
@@ -19,11 +17,8 @@ struct ChannelsSettingsSmokeTests {
"signal": "Signal", "signal": "Signal",
"imessage": "iMessage", "imessage": "iMessage",
], ],
channelDetailLabels: nil,
channelSystemImages: nil,
channelMeta: nil,
channels: [ channels: [
"whatsapp": SnapshotAnyCodable([ "whatsapp": AnyCodable([
"configured": true, "configured": true,
"linked": true, "linked": true,
"authAgeMs": 86_400_000, "authAgeMs": 86_400_000,
@@ -42,7 +37,7 @@ struct ChannelsSettingsSmokeTests {
"lastEventAt": 1_700_000_060_000, "lastEventAt": 1_700_000_060_000,
"lastError": "needs login", "lastError": "needs login",
]), ]),
"telegram": SnapshotAnyCodable([ "telegram": AnyCodable([
"configured": true, "configured": true,
"tokenSource": "env", "tokenSource": "env",
"running": true, "running": true,
@@ -57,7 +52,7 @@ struct ChannelsSettingsSmokeTests {
], ],
"lastProbeAt": 1_700_000_050_000, "lastProbeAt": 1_700_000_050_000,
]), ]),
"signal": SnapshotAnyCodable([ "signal": AnyCodable([
"configured": true, "configured": true,
"baseUrl": "http://127.0.0.1:8080", "baseUrl": "http://127.0.0.1:8080",
"running": true, "running": true,
@@ -70,7 +65,7 @@ struct ChannelsSettingsSmokeTests {
], ],
"lastProbeAt": 1_700_000_050_000, "lastProbeAt": 1_700_000_050_000,
]), ]),
"imessage": SnapshotAnyCodable([ "imessage": AnyCodable([
"configured": false, "configured": false,
"running": false, "running": false,
"lastError": "not configured", "lastError": "not configured",
@@ -105,18 +100,15 @@ struct ChannelsSettingsSmokeTests {
"signal": "Signal", "signal": "Signal",
"imessage": "iMessage", "imessage": "iMessage",
], ],
channelDetailLabels: nil,
channelSystemImages: nil,
channelMeta: nil,
channels: [ channels: [
"whatsapp": SnapshotAnyCodable([ "whatsapp": AnyCodable([
"configured": false, "configured": false,
"linked": false, "linked": false,
"running": false, "running": false,
"connected": false, "connected": false,
"reconnectAttempts": 0, "reconnectAttempts": 0,
]), ]),
"telegram": SnapshotAnyCodable([ "telegram": AnyCodable([
"configured": false, "configured": false,
"running": false, "running": false,
"lastError": "bot missing", "lastError": "bot missing",
@@ -128,7 +120,7 @@ struct ChannelsSettingsSmokeTests {
], ],
"lastProbeAt": 1_700_000_100_000, "lastProbeAt": 1_700_000_100_000,
]), ]),
"signal": SnapshotAnyCodable([ "signal": AnyCodable([
"configured": false, "configured": false,
"baseUrl": "http://127.0.0.1:8080", "baseUrl": "http://127.0.0.1:8080",
"running": false, "running": false,
@@ -141,7 +133,7 @@ struct ChannelsSettingsSmokeTests {
], ],
"lastProbeAt": 1_700_000_200_000, "lastProbeAt": 1_700_000_200_000,
]), ]),
"imessage": SnapshotAnyCodable([ "imessage": AnyCodable([
"configured": false, "configured": false,
"running": false, "running": false,
"lastError": "not configured", "lastError": "not configured",
@@ -11,19 +11,16 @@ struct CronJobEditorSmokeTests {
} }
@Test func cronJobEditorBuildsBodyForNewJob() { @Test func cronJobEditorBuildsBodyForNewJob() {
let channelsStore = ChannelsStore(isPreview: true)
let view = CronJobEditor( let view = CronJobEditor(
job: nil, job: nil,
isSaving: .constant(false), isSaving: .constant(false),
error: .constant(nil), error: .constant(nil),
channelsStore: channelsStore,
onCancel: {}, onCancel: {},
onSave: { _ in }) onSave: { _ in })
_ = view.body _ = view.body
} }
@Test func cronJobEditorBuildsBodyForExistingJob() { @Test func cronJobEditorBuildsBodyForExistingJob() {
let channelsStore = ChannelsStore(isPreview: true)
let job = CronJob( let job = CronJob(
id: "job-1", id: "job-1",
agentId: "ops", agentId: "ops",
@@ -57,36 +54,31 @@ struct CronJobEditorSmokeTests {
job: job, job: job,
isSaving: .constant(false), isSaving: .constant(false),
error: .constant(nil), error: .constant(nil),
channelsStore: channelsStore,
onCancel: {}, onCancel: {},
onSave: { _ in }) onSave: { _ in })
_ = view.body _ = view.body
} }
@Test func cronJobEditorExercisesBuilders() { @Test func cronJobEditorExercisesBuilders() {
let channelsStore = ChannelsStore(isPreview: true)
var view = CronJobEditor( var view = CronJobEditor(
job: nil, job: nil,
isSaving: .constant(false), isSaving: .constant(false),
error: .constant(nil), error: .constant(nil),
channelsStore: channelsStore,
onCancel: {}, onCancel: {},
onSave: { _ in }) onSave: { _ in })
view.exerciseForTesting() view.exerciseForTesting()
} }
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws { @Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
let channelsStore = ChannelsStore(isPreview: true)
let view = CronJobEditor( let view = CronJobEditor(
job: nil, job: nil,
isSaving: .constant(false), isSaving: .constant(false),
error: .constant(nil), error: .constant(nil),
channelsStore: channelsStore,
onCancel: {}, onCancel: {},
onSave: { _ in }) onSave: { _ in })
var root: [String: Any] = [:] var root: [String: Any] = [:]
view.applyDeleteAfterRun(to: &root, scheduleKind: CronJobEditor.ScheduleKind.at, deleteAfterRun: true) view.applyDeleteAfterRun(to: &root, scheduleKind: .at, deleteAfterRun: true)
let raw = root["deleteAfterRun"] as? Bool let raw = root["deleteAfterRun"] as? Bool
#expect(raw == true) #expect(raw == true)
} }
@@ -1,60 +0,0 @@
import Foundation
import Testing
@testable import Clawdbot
@Suite struct ExecApprovalHelpersTests {
@Test func parseDecisionTrimsAndRejectsInvalid() {
#expect(ExecApprovalHelpers.parseDecision("allow-once") == .allowOnce)
#expect(ExecApprovalHelpers.parseDecision(" allow-always ") == .allowAlways)
#expect(ExecApprovalHelpers.parseDecision("deny") == .deny)
#expect(ExecApprovalHelpers.parseDecision("") == nil)
#expect(ExecApprovalHelpers.parseDecision("nope") == nil)
}
@Test func allowlistPatternPrefersResolution() {
let resolved = ExecCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
#expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: resolved) == resolved.resolvedPath)
let rawOnly = ExecCommandResolution(
rawExecutable: "rg",
resolvedPath: nil,
executableName: "rg",
cwd: nil)
#expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: rawOnly) == "rg")
#expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: nil) == "rg")
#expect(ExecApprovalHelpers.allowlistPattern(command: [], resolution: nil) == nil)
}
@Test func requiresAskMatchesPolicy() {
let entry = ExecAllowlistEntry(pattern: "/bin/ls", lastUsedAt: nil, lastUsedCommand: nil, lastResolvedPath: nil)
#expect(ExecApprovalHelpers.requiresAsk(
ask: .always,
security: .deny,
allowlistMatch: nil,
skillAllow: false))
#expect(ExecApprovalHelpers.requiresAsk(
ask: .onMiss,
security: .allowlist,
allowlistMatch: nil,
skillAllow: false))
#expect(!ExecApprovalHelpers.requiresAsk(
ask: .onMiss,
security: .allowlist,
allowlistMatch: entry,
skillAllow: false))
#expect(!ExecApprovalHelpers.requiresAsk(
ask: .onMiss,
security: .allowlist,
allowlistMatch: nil,
skillAllow: true))
#expect(!ExecApprovalHelpers.requiresAsk(
ask: .off,
security: .allowlist,
allowlistMatch: nil,
skillAllow: false))
}
}
@@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation import Foundation
import os import os
import Testing import Testing
@@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation import Foundation
import os import os
import Testing import Testing
@@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation import Foundation
import os import os
import Testing import Testing
@@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation import Foundation
import os import os
import Testing import Testing
@@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation import Foundation
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
@@ -139,40 +139,4 @@ import Testing
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults) let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
#expect(resolved.mode == .remote) #expect(resolved.mode == .remote)
} }
@Test func resolveLocalGatewayHostPrefersTailnetForAuto() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: "100.64.1.2")
#expect(host == "100.64.1.2")
}
@Test func resolveLocalGatewayHostFallsBackToLoopbackForAuto() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: nil)
#expect(host == "127.0.0.1")
}
@Test func resolveLocalGatewayHostPrefersTailnetForTailnetMode() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: "100.64.1.5")
#expect(host == "100.64.1.5")
}
@Test func resolveLocalGatewayHostFallsBackToLoopbackForTailnetMode() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: nil)
#expect(host == "127.0.0.1")
}
@Test func resolveLocalGatewayHostUsesCustomBindHost() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "custom",
tailscaleIP: "100.64.1.9",
customBindHost: "192.168.1.10")
#expect(host == "192.168.1.10")
}
} }
@@ -48,10 +48,7 @@ import Testing
@Test func expectedGatewayVersionFromStringUsesParser() { @Test func expectedGatewayVersionFromStringUsesParser() {
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2)) #expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver( #expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11))
major: 2026,
minor: 1,
patch: 11))
#expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil) #expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil)
} }
} }
@@ -1,146 +0,0 @@
import Foundation
import os
import Testing
@testable import Clawdbot
@Suite(.serialized)
@MainActor
struct GatewayProcessManagerTests {
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>)
-> Void)?>(initialState: nil)
private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
private let sendCount = OSAllocatedUnfairLock(initialState: 0)
var state: URLSessionTask.State = .suspended
func resume() {
self.state = .running
}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
self.state = .canceling
self.cancelCount.withLock { $0 += 1 }
let handler = self.pendingReceiveHandler.withLock { handler in
defer { handler = nil }
return handler
}
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.cancelled)))
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
let currentSendCount = self.sendCount.withLock { count in
defer { count += 1 }
return count
}
if currentSendCount == 0 {
guard case let .data(data) = message else { return }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["type"] as? String) == "req",
(obj["method"] as? String) == "connect",
let id = obj["id"] as? String
{
self.connectRequestID.withLock { $0 = id }
}
return
}
guard case let .data(data) = message else { return }
guard
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["type"] as? String) == "req",
let id = obj["id"] as? String
else {
return
}
let response = Self.responseData(id: id)
let handler = self.pendingReceiveHandler.withLock { $0 }
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(response)))
}
func receive() async throws -> URLSessionWebSocketTask.Message {
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(Self.connectOkData(id: id))
}
func receive(
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
{
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
private static func connectOkData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 2,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
}
private static func responseData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": { "ok": true }
}
"""
return Data(json.utf8)
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]())
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
_ = url
let task = FakeWebSocketTask()
self.tasks.withLock { $0.append(task) }
return WebSocketTaskBox(task: task)
}
}
@Test func clearsLastFailureWhenHealthSucceeds() async {
let session = FakeWebSocketSession()
let url = URL(string: "ws://example.invalid")!
let connection = GatewayConnection(
configProvider: { (url: url, token: nil, password: nil) },
sessionBox: WebSocketSessionBox(session: session))
let manager = GatewayProcessManager.shared
manager.setTestingConnection(connection)
manager.setTestingDesiredActive(true)
manager.setTestingLastFailureReason("health failed")
defer {
manager.setTestingConnection(nil)
manager.setTestingDesiredActive(false)
manager.setTestingLastFailureReason(nil)
}
let ready = await manager.waitForGatewayReady(timeout: 0.5)
#expect(ready)
#expect(manager.lastFailureReason == nil)
}
}
@@ -7,17 +7,15 @@ import Testing
@Suite(.serialized) @Suite(.serialized)
struct LowCoverageHelperTests { struct LowCoverageHelperTests {
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
@Test func anyCodableHelperAccessors() throws { @Test func anyCodableHelperAccessors() throws {
let payload: [String: ProtoAnyCodable] = [ let payload: [String: AnyCodable] = [
"title": ProtoAnyCodable("Hello"), "title": AnyCodable("Hello"),
"flag": ProtoAnyCodable(true), "flag": AnyCodable(true),
"count": ProtoAnyCodable(3), "count": AnyCodable(3),
"ratio": ProtoAnyCodable(1.25), "ratio": AnyCodable(1.25),
"list": ProtoAnyCodable([ProtoAnyCodable("a"), ProtoAnyCodable(2)]), "list": AnyCodable([AnyCodable("a"), AnyCodable(2)]),
] ]
let any = ProtoAnyCodable(payload) let any = AnyCodable(payload)
let dict = try #require(any.dictionaryValue) let dict = try #require(any.dictionaryValue)
#expect(dict["title"]?.stringValue == "Hello") #expect(dict["title"]?.stringValue == "Hello")
#expect(dict["flag"]?.boolValue == true) #expect(dict["flag"]?.boolValue == true)
@@ -78,27 +76,31 @@ struct LowCoverageHelperTests {
#expect(result.stderr.contains("stderr-1999")) #expect(result.stderr.contains("stderr-1999"))
} }
@Test func nodeInfoCodableRoundTrip() throws { @Test func pairedNodesStorePersists() async throws {
let info = NodeInfo( let dir = FileManager().temporaryDirectory
.appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent("nodes.json")
let store = PairedNodesStore(fileURL: url)
await store.load()
#expect(await store.all().isEmpty)
let node = PairedNode(
nodeId: "node-1", nodeId: "node-1",
displayName: "Node One", displayName: "Node One",
platform: "macOS", platform: "macOS",
version: "1.0", version: "1.0",
coreVersion: "1.0-core",
uiVersion: "1.0-ui",
deviceFamily: "Mac", deviceFamily: "Mac",
modelIdentifier: "MacBookPro", modelIdentifier: "MacBookPro",
remoteIp: "192.168.1.2", token: "token",
caps: ["chat"], createdAtMs: 1,
commands: ["send"], lastSeenAtMs: nil)
permissions: ["send": true], try await store.upsert(node)
paired: true, #expect(await store.find(nodeId: "node-1")?.displayName == "Node One")
connected: false)
let data = try JSONEncoder().encode(info) try await store.touchSeen(nodeId: "node-1")
let decoded = try JSONDecoder().decode(NodeInfo.self, from: data) let updated = await store.find(nodeId: "node-1")
#expect(decoded.nodeId == "node-1") #expect(updated?.lastSeenAtMs != nil)
#expect(decoded.isPaired == true)
#expect(decoded.isConnected == false)
} }
@Test @MainActor func presenceReporterHelpers() { @Test @MainActor func presenceReporterHelpers() {
@@ -21,7 +21,6 @@ import Testing
features: [:], features: [:],
snapshot: snapshot, snapshot: snapshot,
canvashosturl: nil, canvashosturl: nil,
auth: nil,
policy: [:]) policy: [:])
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello)) let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello))
@@ -3,15 +3,13 @@ import SwiftUI
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
@Suite(.serialized) @Suite(.serialized)
@MainActor @MainActor
struct OnboardingWizardStepViewTests { struct OnboardingWizardStepViewTests {
@Test func noteStepBuilds() { @Test func noteStepBuilds() {
let step = WizardStep( let step = WizardStep(
id: "step-1", id: "step-1",
type: ProtoAnyCodable("note"), type: AnyCodable("note"),
title: "Welcome", title: "Welcome",
message: "Hello", message: "Hello",
options: nil, options: nil,
@@ -24,17 +22,17 @@ struct OnboardingWizardStepViewTests {
} }
@Test func selectStepBuilds() { @Test func selectStepBuilds() {
let options: [[String: ProtoAnyCodable]] = [ let options: [[String: AnyCodable]] = [
["value": ProtoAnyCodable("local"), "label": ProtoAnyCodable("Local"), "hint": ProtoAnyCodable("This Mac")], ["value": AnyCodable("local"), "label": AnyCodable("Local"), "hint": AnyCodable("This Mac")],
["value": ProtoAnyCodable("remote"), "label": ProtoAnyCodable("Remote")], ["value": AnyCodable("remote"), "label": AnyCodable("Remote")],
] ]
let step = WizardStep( let step = WizardStep(
id: "step-2", id: "step-2",
type: ProtoAnyCodable("select"), type: AnyCodable("select"),
title: "Mode", title: "Mode",
message: "Choose a mode", message: "Choose a mode",
options: options, options: options,
initialvalue: ProtoAnyCodable("local"), initialvalue: AnyCodable("local"),
placeholder: nil, placeholder: nil,
sensitive: nil, sensitive: nil,
executor: nil) executor: nil)
@@ -15,9 +15,6 @@ extension URLSessionWebSocketTask: WebSocketTasking {}
public struct WebSocketTaskBox: @unchecked Sendable { public struct WebSocketTaskBox: @unchecked Sendable {
public let task: any WebSocketTasking public let task: any WebSocketTasking
public init(task: any WebSocketTasking) {
self.task = task
}
public var state: URLSessionTask.State { self.task.state } public var state: URLSessionTask.State { self.task.state }
@@ -571,14 +568,7 @@ public actor GatewayChannelActor {
id: id, id: id,
method: method, method: method,
params: paramsObject) params: paramsObject)
let data: Data let data = try self.encoder.encode(frame)
do {
data = try self.encoder.encode(frame)
} catch {
self.logger.error(
"gateway request encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
throw error
}
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
self.pending[id] = cont self.pending[id] = cont
Task { [weak self] in Task { [weak self] in
@@ -23,35 +23,6 @@ public actor GatewayNodeSession {
private var onConnected: (@Sendable () async -> Void)? private var onConnected: (@Sendable () async -> Void)?
private var onDisconnected: (@Sendable (String) async -> Void)? private var onDisconnected: (@Sendable (String) async -> Void)?
private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)? private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)?
static func invokeWithTimeout(
request: BridgeInvokeRequest,
timeoutMs: Int?,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
) async -> BridgeInvokeResponse {
let timeout = max(0, timeoutMs ?? 0)
guard timeout > 0 else {
return await onInvoke(request)
}
return await withTaskGroup(of: BridgeInvokeResponse.self) { group in
group.addTask { await onInvoke(request) }
group.addTask {
try? await Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000)
return BridgeInvokeResponse(
id: request.id,
ok: false,
error: ClawdbotNodeError(
code: .unavailable,
message: "node invoke timed out")
)
}
let first = await group.next()!
group.cancelAll()
return first
}
}
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:] private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
private var canvasHostUrl: String? private var canvasHostUrl: String?
@@ -196,11 +167,7 @@ public actor GatewayNodeSession {
let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data) let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
guard let onInvoke else { return } guard let onInvoke else { return }
let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON) let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON)
let response = await Self.invokeWithTimeout( let response = await onInvoke(req)
request: req,
timeoutMs: request.timeoutMs,
onInvoke: onInvoke
)
await self.sendInvokeResult(request: request, response: response) await self.sendInvokeResult(request: request, response: response)
} catch { } catch {
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)") self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
@@ -213,14 +180,12 @@ public actor GatewayNodeSession {
"id": AnyCodable(request.id), "id": AnyCodable(request.id),
"nodeId": AnyCodable(request.nodeId), "nodeId": AnyCodable(request.nodeId),
"ok": AnyCodable(response.ok), "ok": AnyCodable(response.ok),
"payloadJSON": AnyCodable(response.payloadJSON ?? NSNull()),
] ]
if let payloadJSON = response.payloadJSON {
params["payloadJSON"] = AnyCodable(payloadJSON)
}
if let error = response.error { if let error = response.error {
params["error"] = AnyCodable([ params["error"] = AnyCodable([
"code": error.code.rawValue, "code": AnyCodable(error.code.rawValue),
"message": error.message, "message": AnyCodable(error.message),
]) ])
} }
do { do {
@@ -30,7 +30,6 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
public var agentId: String? public var agentId: String?
public var sessionKey: String? public var sessionKey: String?
public var approved: Bool? public var approved: Bool?
public var approvalDecision: String?
public init( public init(
command: [String], command: [String],
@@ -41,8 +40,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
needsScreenRecording: Bool? = nil, needsScreenRecording: Bool? = nil,
agentId: String? = nil, agentId: String? = nil,
sessionKey: String? = nil, sessionKey: String? = nil,
approved: Bool? = nil, approved: Bool? = nil)
approvalDecision: String? = nil)
{ {
self.command = command self.command = command
self.rawCommand = rawCommand self.rawCommand = rawCommand
@@ -53,7 +51,6 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
self.agentId = agentId self.agentId = agentId
self.sessionKey = sessionKey self.sessionKey = sessionKey
self.approved = approved self.approved = approved
self.approvalDecision = approvalDecision
} }
} }
@@ -18,7 +18,6 @@ public struct ConnectParams: Codable, Sendable {
public let caps: [String]? public let caps: [String]?
public let commands: [String]? public let commands: [String]?
public let permissions: [String: AnyCodable]? public let permissions: [String: AnyCodable]?
public let pathenv: String?
public let role: String? public let role: String?
public let scopes: [String]? public let scopes: [String]?
public let device: [String: AnyCodable]? public let device: [String: AnyCodable]?
@@ -33,7 +32,6 @@ public struct ConnectParams: Codable, Sendable {
caps: [String]?, caps: [String]?,
commands: [String]?, commands: [String]?,
permissions: [String: AnyCodable]?, permissions: [String: AnyCodable]?,
pathenv: String?,
role: String?, role: String?,
scopes: [String]?, scopes: [String]?,
device: [String: AnyCodable]?, device: [String: AnyCodable]?,
@@ -47,7 +45,6 @@ public struct ConnectParams: Codable, Sendable {
self.caps = caps self.caps = caps
self.commands = commands self.commands = commands
self.permissions = permissions self.permissions = permissions
self.pathenv = pathenv
self.role = role self.role = role
self.scopes = scopes self.scopes = scopes
self.device = device self.device = device
@@ -62,7 +59,6 @@ public struct ConnectParams: Codable, Sendable {
case caps case caps
case commands case commands
case permissions case permissions
case pathenv = "pathEnv"
case role case role
case scopes case scopes
case device case device
@@ -477,7 +473,6 @@ public struct AgentParams: Codable, Sendable {
public let replychannel: String? public let replychannel: String?
public let accountid: String? public let accountid: String?
public let replyaccountid: String? public let replyaccountid: String?
public let threadid: String?
public let timeout: Int? public let timeout: Int?
public let lane: String? public let lane: String?
public let extrasystemprompt: String? public let extrasystemprompt: String?
@@ -499,7 +494,6 @@ public struct AgentParams: Codable, Sendable {
replychannel: String?, replychannel: String?,
accountid: String?, accountid: String?,
replyaccountid: String?, replyaccountid: String?,
threadid: String?,
timeout: Int?, timeout: Int?,
lane: String?, lane: String?,
extrasystemprompt: String?, extrasystemprompt: String?,
@@ -520,7 +514,6 @@ public struct AgentParams: Codable, Sendable {
self.replychannel = replychannel self.replychannel = replychannel
self.accountid = accountid self.accountid = accountid
self.replyaccountid = replyaccountid self.replyaccountid = replyaccountid
self.threadid = threadid
self.timeout = timeout self.timeout = timeout
self.lane = lane self.lane = lane
self.extrasystemprompt = extrasystemprompt self.extrasystemprompt = extrasystemprompt
@@ -542,7 +535,6 @@ public struct AgentParams: Codable, Sendable {
case replychannel = "replyChannel" case replychannel = "replyChannel"
case accountid = "accountId" case accountid = "accountId"
case replyaccountid = "replyAccountId" case replyaccountid = "replyAccountId"
case threadid = "threadId"
case timeout case timeout
case lane case lane
case extrasystemprompt = "extraSystemPrompt" case extrasystemprompt = "extraSystemPrompt"
@@ -843,47 +835,35 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int? public let activeminutes: Int?
public let includeglobal: Bool? public let includeglobal: Bool?
public let includeunknown: Bool? public let includeunknown: Bool?
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String? public let label: String?
public let spawnedby: String? public let spawnedby: String?
public let agentid: String? public let agentid: String?
public let search: String?
public init( public init(
limit: Int?, limit: Int?,
activeminutes: Int?, activeminutes: Int?,
includeglobal: Bool?, includeglobal: Bool?,
includeunknown: Bool?, includeunknown: Bool?,
includederivedtitles: Bool?,
includelastmessage: Bool?,
label: String?, label: String?,
spawnedby: String?, spawnedby: String?,
agentid: String?, agentid: String?
search: String?
) { ) {
self.limit = limit self.limit = limit
self.activeminutes = activeminutes self.activeminutes = activeminutes
self.includeglobal = includeglobal self.includeglobal = includeglobal
self.includeunknown = includeunknown self.includeunknown = includeunknown
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label self.label = label
self.spawnedby = spawnedby self.spawnedby = spawnedby
self.agentid = agentid self.agentid = agentid
self.search = search
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case limit case limit
case activeminutes = "activeMinutes" case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal" case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown" case includeunknown = "includeUnknown"
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label case label
case spawnedby = "spawnedBy" case spawnedby = "spawnedBy"
case agentid = "agentId" case agentid = "agentId"
case search
} }
} }
@@ -1344,9 +1324,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
public let ts: Int public let ts: Int
public let channelorder: [String] public let channelorder: [String]
public let channellabels: [String: AnyCodable] public let channellabels: [String: AnyCodable]
public let channeldetaillabels: [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]
@@ -1355,9 +1332,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
ts: Int, ts: Int,
channelorder: [String], channelorder: [String],
channellabels: [String: AnyCodable], channellabels: [String: AnyCodable],
channeldetaillabels: [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]
@@ -1365,9 +1339,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
self.ts = ts self.ts = ts
self.channelorder = channelorder self.channelorder = channelorder
self.channellabels = channellabels self.channellabels = channellabels
self.channeldetaillabels = channeldetaillabels
self.channelsystemimages = channelsystemimages
self.channelmeta = channelmeta
self.channels = channels self.channels = channels
self.channelaccounts = channelaccounts self.channelaccounts = channelaccounts
self.channeldefaultaccountid = channeldefaultaccountid self.channeldefaultaccountid = channeldefaultaccountid
@@ -1376,9 +1347,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
case ts case ts
case channelorder = "channelOrder" case channelorder = "channelOrder"
case channellabels = "channelLabels" case channellabels = "channelLabels"
case channeldetaillabels = "channelDetailLabels"
case channelsystemimages = "channelSystemImages"
case channelmeta = "channelMeta"
case channels case channels
case channelaccounts = "channelAccounts" case channelaccounts = "channelAccounts"
case channeldefaultaccountid = "channelDefaultAccountId" case channeldefaultaccountid = "channelDefaultAccountId"
@@ -1908,7 +1876,6 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
} }
public struct ExecApprovalRequestParams: Codable, Sendable { public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String?
public let command: String public let command: String
public let cwd: String? public let cwd: String?
public let host: String? public let host: String?
@@ -1920,7 +1887,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public let timeoutms: Int? public let timeoutms: Int?
public init( public init(
id: String?,
command: String, command: String,
cwd: String?, cwd: String?,
host: String?, host: String?,
@@ -1931,7 +1897,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
sessionkey: String?, sessionkey: String?,
timeoutms: Int? timeoutms: Int?
) { ) {
self.id = id
self.command = command self.command = command
self.cwd = cwd self.cwd = cwd
self.host = host self.host = host
@@ -1943,7 +1908,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
self.timeoutms = timeoutms self.timeoutms = timeoutms
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id
case command case command
case cwd case cwd
case host case host
@@ -1,56 +0,0 @@
import Foundation
import Testing
@testable import ClawdbotKit
import ClawdbotProtocol
struct GatewayNodeSessionTests {
@Test
func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
timeoutMs: 50,
onInvoke: { req in
#expect(req.id == "1")
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: "{}", error: nil)
}
)
#expect(response.ok == true)
#expect(response.error == nil)
#expect(response.payloadJSON == "{}")
}
@Test
func invokeWithTimeoutReturnsTimeoutError() async {
let request = BridgeInvokeRequest(id: "abc", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
timeoutMs: 10,
onInvoke: { _ in
try? await Task.sleep(nanoseconds: 200_000_000) // 200ms
return BridgeInvokeResponse(id: "abc", ok: true, payloadJSON: "{}", error: nil)
}
)
#expect(response.ok == false)
#expect(response.error?.code == .unavailable)
#expect(response.error?.message.contains("timed out") == true)
}
@Test
func invokeWithTimeoutZeroDisablesTimeout() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
timeoutMs: 0,
onInvoke: { req in
try? await Task.sleep(nanoseconds: 5_000_000)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
}
)
#expect(response.ok == true)
#expect(response.error == nil)
}
}
-13
View File
@@ -149,19 +149,6 @@ Available actions:
- **leaveGroup**: Leave a group chat (`chatGuid`) - **leaveGroup**: Leave a group chat (`chatGuid`)
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`) - **sendAttachment**: Send media/files (`to`, `buffer`, `filename`)
### Message IDs (short vs full)
Clawdbot may surface *short* message IDs (e.g., `1`, `2`) to save tokens.
- `MessageSid` / `ReplyToId` can be short IDs.
- `MessageSidFull` / `ReplyToIdFull` contain the provider full IDs.
- Short IDs are in-memory; they can expire on restart or cache eviction.
- Actions accept short or full `messageId`, but short IDs will error if no longer available.
Use full IDs for durable automations and storage:
- Templates: `{{MessageSidFull}}`, `{{ReplyToIdFull}}`
- Context: `MessageSidFull` / `ReplyToIdFull` in inbound payloads
See [Configuration](/gateway/configuration) for template variables.
## Block streaming ## Block streaming
Control whether responses are sent as a single message or streamed in blocks: Control whether responses are sent as a single message or streamed in blocks:
```json5 ```json5
-1
View File
@@ -175,7 +175,6 @@ Notes:
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages. - `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages.
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`. - Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
- If `channels` is present, any channel not listed is denied by default. - If `channels` is present, any channel not listed is denied by default.
- Use a `"*"` channel entry to apply defaults across all channels; explicit channel entries override the wildcard.
- Threads inherit parent channel config (allowlist, `requireMention`, skills, prompts, etc.) unless you add the thread channel id explicitly. - Threads inherit parent channel config (allowlist, `requireMention`, skills, prompts, etc.) unless you add the thread channel id explicitly.
- Bot-authored messages are ignored by default; set `channels.discord.allowBots=true` to allow them (own messages remain filtered). - Bot-authored messages are ignored by default; set `channels.discord.allowBots=true` to allow them (own messages remain filtered).
- Warning: If you allow replies to other bots (`channels.discord.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.discord.guilds.*.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`. - Warning: If you allow replies to other bots (`channels.discord.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.discord.guilds.*.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
-5
View File
@@ -100,11 +100,6 @@ Groups:
- Use `channels.signal.ignoreAttachments` to skip downloading media. - Use `channels.signal.ignoreAttachments` to skip downloading media.
- Group history context uses `channels.signal.historyLimit` (or `channels.signal.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). - Group history context uses `channels.signal.historyLimit` (or `channels.signal.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
## Typing + read receipts
- **Typing indicators**: Clawdbot sends typing signals via `signal-cli sendTyping` and refreshes them while a reply is running.
- **Read receipts**: when `channels.signal.sendReadReceipts` is true, Clawdbot forwards read receipts for allowed DMs.
- Signal-cli does not expose read receipts for groups.
## Delivery targets (CLI/cron) ## Delivery targets (CLI/cron)
- DMs: `signal:+15551234567` (or plain E.164). - DMs: `signal:+15551234567` (or plain E.164).
- Groups: `signal:group:<groupId>`. - Groups: `signal:group:<groupId>`.
-4
View File
@@ -484,10 +484,6 @@ The agent sees reactions as **system notifications** in the conversation history
- Make sure your Telegram user ID is authorized (via pairing or `channels.telegram.allowFrom`) - Make sure your Telegram user ID is authorized (via pairing or `channels.telegram.allowFrom`)
- Commands require authorization even in groups with `groupPolicy: "open"` - Commands require authorization even in groups with `groupPolicy: "open"`
**Long-polling aborts immediately on Node 22+ (often with proxies/custom fetch):**
- Node 22+ is stricter about `AbortSignal` instances; foreign signals can abort `fetch` calls right away.
- Upgrade to a Clawdbot build that normalizes abort signals, or run the gateway on Node 20 until you can upgrade.
**Bot starts, then silently stops responding (or logs `HttpError: Network request ... failed`):** **Bot starts, then silently stops responding (or logs `HttpError: Network request ... failed`):**
- Some hosts resolve `api.telegram.org` to IPv6 first. If your server does not have working IPv6 egress, grammY can get stuck on IPv6-only requests. - Some hosts resolve `api.telegram.org` to IPv6 first. If your server does not have working IPv6 egress, grammY can get stuck on IPv6-only requests.
- Fix by enabling IPv6 egress **or** forcing IPv4 resolution for `api.telegram.org` (for example, add an `/etc/hosts` entry using the IPv4 A record, or prefer IPv4 in your OS DNS stack), then restart the gateway. - Fix by enabling IPv6 egress **or** forcing IPv4 resolution for `api.telegram.org` (for example, add an `/etc/hosts` entry using the IPv4 A record, or prefer IPv4 in your OS DNS stack), then restart the gateway.
-6
View File
@@ -286,11 +286,6 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
- CLI: `clawdbot message send --media <mp4> --gif-playback` - CLI: `clawdbot message send --media <mp4> --gif-playback`
- Gateway: `send` params include `gifPlayback: true` - Gateway: `send` params include `gifPlayback: true`
## Voice notes (PTT audio)
WhatsApp sends audio as **voice notes** (PTT bubble).
- Best results: OGG/Opus. Clawdbot rewrites `audio/ogg` to `audio/ogg; codecs=opus`.
- `[[audio_as_voice]]` is ignored for WhatsApp (audio already ships as voice note).
## Media limits + optimization ## Media limits + optimization
- Default outbound cap: 5 MB (per media item). - Default outbound cap: 5 MB (per media item).
- Override: `agents.defaults.mediaMaxMb`. - Override: `agents.defaults.mediaMaxMb`.
@@ -334,7 +329,6 @@ WhatsApp sends audio as **voice notes** (PTT bubble).
- `agents.defaults.heartbeat.model` (optional override) - `agents.defaults.heartbeat.model` (optional override)
- `agents.defaults.heartbeat.target` - `agents.defaults.heartbeat.target`
- `agents.defaults.heartbeat.to` - `agents.defaults.heartbeat.to`
- `agents.defaults.heartbeat.session`
- `agents.list[].heartbeat.*` (per-agent overrides) - `agents.list[].heartbeat.*` (per-agent overrides)
- `session.*` (scope, idle, store, mainKey) - `session.*` (scope, idle, store, mainKey)
- `web.enabled` (disable channel startup when false) - `web.enabled` (disable channel startup when false)
+2 -6
View File
@@ -7,8 +7,8 @@ read_when:
# `clawdbot approvals` # `clawdbot approvals`
Manage exec approvals for the **local host**, **gateway host**, or a **node host**. Manage exec approvals for the **gateway host** or a **node host**.
By default, commands target the local approvals file on disk. Use `--gateway` to target the gateway, or `--node` to target a specific node. By default, commands target the gateway. Use `--node` to edit a node’s approvals.
Related: Related:
- Exec approvals: [Exec approvals](/tools/exec-approvals) - Exec approvals: [Exec approvals](/tools/exec-approvals)
@@ -19,7 +19,6 @@ Related:
```bash ```bash
clawdbot approvals get clawdbot approvals get
clawdbot approvals get --node <id|name|ip> clawdbot approvals get --node <id|name|ip>
clawdbot approvals get --gateway
``` ```
## Replace approvals from a file ## Replace approvals from a file
@@ -27,7 +26,6 @@ clawdbot approvals get --gateway
```bash ```bash
clawdbot approvals set --file ./exec-approvals.json clawdbot approvals set --file ./exec-approvals.json
clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json
clawdbot approvals set --gateway --file ./exec-approvals.json
``` ```
## Allowlist helpers ## Allowlist helpers
@@ -35,7 +33,6 @@ clawdbot approvals set --gateway --file ./exec-approvals.json
```bash ```bash
clawdbot approvals allowlist add "~/Projects/**/bin/rg" clawdbot approvals allowlist add "~/Projects/**/bin/rg"
clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime" clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"
clawdbot approvals allowlist add --agent "*" "/usr/bin/uname"
clawdbot approvals allowlist remove "~/Projects/**/bin/rg" clawdbot approvals allowlist remove "~/Projects/**/bin/rg"
``` ```
@@ -43,6 +40,5 @@ clawdbot approvals allowlist remove "~/Projects/**/bin/rg"
## Notes ## Notes
- `--node` uses the same resolver as `clawdbot nodes` (id, name, ip, or id prefix). - `--node` uses the same resolver as `clawdbot nodes` (id, name, ip, or id prefix).
- `--agent` defaults to `"*"`, which applies to all agents.
- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host). - The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host).
- Approvals files are stored per host at `~/.clawdbot/exec-approvals.json`. - Approvals files are stored per host at `~/.clawdbot/exec-approvals.json`.
-3
View File
@@ -8,9 +8,6 @@ read_when:
Interactive prompt to set up credentials, devices, and agent defaults. Interactive prompt to set up credentials, devices, and agent defaults.
Note: The **Model** section now includes a multi-select for the
`agents.defaults.models` allowlist (what shows up in `/model` and the model picker).
Tip: `clawdbot config` without a subcommand opens the same wizard. Use Tip: `clawdbot config` without a subcommand opens the same wizard. Use
`clawdbot config get|set|unset` for non-interactive edits. `clawdbot config get|set|unset` for non-interactive edits.
-13
View File
@@ -14,16 +14,3 @@ Related:
Tip: run `clawdbot cron --help` for the full command surface. Tip: run `clawdbot cron --help` for the full command surface.
## Common edits
Update delivery settings without changing the message:
```bash
clawdbot cron edit <job-id> --deliver --channel telegram --to "123456789"
```
Disable delivery for an isolated job:
```bash
clawdbot cron edit <job-id> --no-deliver
```
+23
View File
@@ -0,0 +1,23 @@
---
summary: "CLI reference for `clawdbot daemon` (install/uninstall/status for the Gateway service)"
read_when:
- You want to run the Gateway as a background service
- You’re debugging daemon install, status, or logs
---
# `clawdbot daemon`
Manage the Gateway daemon (background service).
Note: `clawdbot service gateway …` is the preferred surface; `daemon` remains
as a legacy alias for compatibility.
Related:
- Gateway CLI: [Gateway](/cli/gateway)
- macOS platform notes: [macOS](/platforms/macos)
Tip: run `clawdbot daemon --help` for platform-specific flags.
Notes:
- `daemon status` supports `--json` for scripting.
- `daemon install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly).
+5 -42
View File
@@ -25,12 +25,6 @@ Run a local Gateway process:
clawdbot gateway clawdbot gateway
``` ```
Foreground alias:
```bash
clawdbot gateway run
```
Notes: Notes:
- By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.clawdbot/clawdbot.json`. Use `--allow-unconfigured` for ad-hoc/dev runs. - By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.clawdbot/clawdbot.json`. Use `--allow-unconfigured` for ad-hoc/dev runs.
- Binding beyond loopback without auth is blocked (safety guardrail). - Binding beyond loopback without auth is blocked (safety guardrail).
@@ -40,7 +34,7 @@ Notes:
### Options ### Options
- `--port <port>`: WebSocket port (default comes from config/env; usually `18789`). - `--port <port>`: WebSocket port (default comes from config/env; usually `18789`).
- `--bind <loopback|lan|tailnet|auto|custom>`: listener bind mode. - `--bind <loopback|lan|tailnet|auto>`: listener bind mode.
- `--auth <token|password>`: auth mode override. - `--auth <token|password>`: auth mode override.
- `--token <token>`: token override (also sets `CLAWDBOT_GATEWAY_TOKEN` for the process). - `--token <token>`: token override (also sets `CLAWDBOT_GATEWAY_TOKEN` for the process).
- `--password <password>`: password override (also sets `CLAWDBOT_GATEWAY_PASSWORD` for the process). - `--password <password>`: password override (also sets `CLAWDBOT_GATEWAY_PASSWORD` for the process).
@@ -81,32 +75,15 @@ clawdbot gateway health --url ws://127.0.0.1:18789
### `gateway status` ### `gateway status`
`gateway status` shows the Gateway service (launchd/systemd/schtasks) plus an optional RPC probe. `gateway status` is the “debug everything” command. It always probes:
```bash
clawdbot gateway status
clawdbot gateway status --json
```
Options:
- `--url <url>`: override the probe URL.
- `--token <token>`: token auth for the probe.
- `--password <password>`: password auth for the probe.
- `--timeout <ms>`: probe timeout (default `10000`).
- `--no-probe`: skip the RPC probe (service-only view).
- `--deep`: scan system-level services too.
### `gateway probe`
`gateway probe` is the “debug everything” command. It always probes:
- your configured remote gateway (if set), and - your configured remote gateway (if set), and
- localhost (loopback) **even if remote is configured**. - localhost (loopback) **even if remote is configured**.
If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use isolated profiles/ports (e.g., a rescue bot), but most installs still run a single gateway. If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use isolated profiles/ports (e.g., a rescue bot), but most installs still run a single gateway.
```bash ```bash
clawdbot gateway probe clawdbot gateway status
clawdbot gateway probe --json clawdbot gateway status --json
``` ```
#### Remote over SSH (Mac app parity) #### Remote over SSH (Mac app parity)
@@ -116,7 +93,7 @@ The macOS app “Remote over SSH” mode uses a local port-forward so the remote
CLI equivalent: CLI equivalent:
```bash ```bash
clawdbot gateway probe --ssh user@gateway-host clawdbot gateway status --ssh user@gateway-host
``` ```
Options: Options:
@@ -137,20 +114,6 @@ clawdbot gateway call status
clawdbot gateway call logs.tail --params '{"sinceMs": 60000}' clawdbot gateway call logs.tail --params '{"sinceMs": 60000}'
``` ```
## Manage the Gateway service
```bash
clawdbot gateway install
clawdbot gateway start
clawdbot gateway stop
clawdbot gateway restart
clawdbot gateway uninstall
```
Notes:
- `gateway install` supports `--port`, `--runtime`, `--token`, `--force`, `--json`.
- Lifecycle commands accept `--json` for scripting.
## Discover gateways (Bonjour) ## Discover gateways (Bonjour)
`gateway discover` scans for Gateway beacons (`_clawdbot-gw._tcp`). `gateway discover` scans for Gateway beacons (`_clawdbot-gw._tcp`).
+56 -38
View File
@@ -28,6 +28,8 @@ This page describes the current CLI behavior. If commands change, update this do
- [`health`](/cli/health) - [`health`](/cli/health)
- [`sessions`](/cli/sessions) - [`sessions`](/cli/sessions)
- [`gateway`](/cli/gateway) - [`gateway`](/cli/gateway)
- [`daemon`](/cli/daemon)
- [`service`](/cli/service)
- [`logs`](/cli/logs) - [`logs`](/cli/logs)
- [`models`](/cli/models) - [`models`](/cli/models)
- [`memory`](/cli/memory) - [`memory`](/cli/memory)
@@ -136,14 +138,29 @@ clawdbot [--dev] [--profile <name>] <command>
call call
health health
status status
probe
discover discover
daemon
status
install install
uninstall uninstall
start start
stop stop
restart restart
run service
gateway
status
install
uninstall
start
stop
restart
node
status
install
uninstall
start
stop
restart
logs logs
models models
list list
@@ -174,13 +191,14 @@ clawdbot [--dev] [--profile <name>] <command>
nodes nodes
devices devices
node node
run
status
install
uninstall
start start
stop daemon
restart status
install
uninstall
start
stop
restart
approvals approvals
get get
set set
@@ -310,7 +328,7 @@ Options:
- `--minimax-api-key <key>` - `--minimax-api-key <key>`
- `--opencode-zen-api-key <key>` - `--opencode-zen-api-key <key>`
- `--gateway-port <port>` - `--gateway-port <port>`
- `--gateway-bind <loopback|lan|tailnet|auto|custom>` - `--gateway-bind <loopback|lan|tailnet|auto>`
- `--gateway-auth <off|token|password>` - `--gateway-auth <off|token|password>`
- `--gateway-token <token>` - `--gateway-token <token>`
- `--gateway-password <password>` - `--gateway-password <password>`
@@ -526,7 +544,7 @@ Options:
- `--debug` (alias for `--verbose`) - `--debug` (alias for `--verbose`)
Notes: Notes:
- Overview includes Gateway + node host service status when available. - Overview includes Gateway + Node service status when available.
### Usage tracking ### Usage tracking
Clawdbot can surface provider usage/quota when OAuth/API creds are available. Clawdbot can surface provider usage/quota when OAuth/API creds are available.
@@ -596,7 +614,7 @@ Run the WebSocket Gateway.
Options: Options:
- `--port <port>` - `--port <port>`
- `--bind <loopback|tailnet|lan|auto|custom>` - `--bind <loopback|tailnet|lan|auto>`
- `--token <token>` - `--token <token>`
- `--auth <token|password>` - `--auth <token|password>`
- `--password <password>` - `--password <password>`
@@ -613,25 +631,25 @@ Options:
- `--raw-stream` - `--raw-stream`
- `--raw-stream-path <path>` - `--raw-stream-path <path>`
### `gateway service` ### `daemon`
Manage the Gateway service (launchd/systemd/schtasks). Manage the Gateway service (launchd/systemd/schtasks).
Subcommands: Subcommands:
- `gateway status` (probes the Gateway RPC by default) - `daemon status` (probes the Gateway RPC by default)
- `gateway install` (service install) - `daemon install` (service install)
- `gateway uninstall` - `daemon uninstall`
- `gateway start` - `daemon start`
- `gateway stop` - `daemon stop`
- `gateway restart` - `daemon restart`
Notes: Notes:
- `gateway status` probes the Gateway RPC by default using the service’s resolved port/config (override with `--url/--token/--password`). - `daemon status` probes the Gateway RPC by default using the daemon’s resolved port/config (override with `--url/--token/--password`).
- `gateway status` supports `--no-probe`, `--deep`, and `--json` for scripting. - `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting.
- `gateway status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named Clawdbot services are treated as first-class and aren't flagged as "extra". - `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named Clawdbot services are treated as first-class and aren't flagged as "extra".
- `gateway status` prints which config path the CLI uses vs which config the service likely uses (service env), plus the resolved probe target URL. - `daemon status` prints which config path the CLI uses vs which config the daemon likely uses (service env), plus the resolved probe target URL.
- `gateway install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly). - `daemon install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly).
- `gateway install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs). - `daemon install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs).
- `gateway install` options: `--port`, `--runtime`, `--token`, `--force`, `--json`. - `daemon install` options: `--port`, `--runtime`, `--token`, `--force`, `--json`.
### `logs` ### `logs`
Tail Gateway file logs via RPC. Tail Gateway file logs via RPC.
@@ -650,16 +668,13 @@ clawdbot logs --no-color
``` ```
### `gateway <subcommand>` ### `gateway <subcommand>`
Gateway CLI helpers (use `--url`, `--token`, `--password`, `--timeout`, `--expect-final` for RPC subcommands). Gateway RPC helpers (use `--url`, `--token`, `--password`, `--timeout`, `--expect-final` for each).
Subcommands: Subcommands:
- `gateway call <method> [--params <json>]` - `gateway call <method> [--params <json>]`
- `gateway health` - `gateway health`
- `gateway status` - `gateway status`
- `gateway probe`
- `gateway discover` - `gateway discover`
- `gateway install|uninstall|start|stop|restart`
- `gateway run`
Common RPCs: Common RPCs:
- `config.apply` (validate + write config + restart + wake) - `config.apply` (validate + write config + restart + wake)
@@ -791,13 +806,16 @@ All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
[`clawdbot node`](/cli/node). [`clawdbot node`](/cli/node).
Subcommands: Subcommands:
- `node run --host <gateway-host> --port 18790` - `node start --host <gateway-host> --port 18790`
- `node status` - `node service status`
- `node install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]` - `node service install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
- `node uninstall` - `node service uninstall`
- `node run` - `node service start`
- `node stop` - `node service stop`
- `node restart` - `node service restart`
Legacy alias:
- `node daemon …` (same as `node service …`)
## Nodes ## Nodes
@@ -807,9 +825,9 @@ Common options:
- `--url`, `--token`, `--timeout`, `--json` - `--url`, `--token`, `--timeout`, `--json`
Subcommands: Subcommands:
- `nodes status [--connected] [--last-connected <duration>]` - `nodes status`
- `nodes describe --node <id|name|ip>` - `nodes describe --node <id|name|ip>`
- `nodes list [--connected] [--last-connected <duration>]` - `nodes list`
- `nodes pending` - `nodes pending`
- `nodes approve <requestId>` - `nodes approve <requestId>`
- `nodes reject <requestId>` - `nodes reject <requestId>`
+17 -7
View File
@@ -23,10 +23,10 @@ Common use cases:
Execution is still guarded by **exec approvals** and per‑agent allowlists on the Execution is still guarded by **exec approvals** and per‑agent allowlists on the
node host, so you can keep command access scoped and explicit. node host, so you can keep command access scoped and explicit.
## Run (foreground) ## Start (foreground)
```bash ```bash
clawdbot node run --host <gateway-host> --port 18790 clawdbot node start --host <gateway-host> --port 18790
``` ```
Options: Options:
@@ -42,7 +42,9 @@ Options:
Install a headless node host as a user service. Install a headless node host as a user service.
```bash ```bash
clawdbot node install --host <gateway-host> --port 18790 clawdbot node service install --host <gateway-host> --port 18790
# or
clawdbot service node install --host <gateway-host> --port 18790
``` ```
Options: Options:
@@ -59,10 +61,18 @@ Manage the service:
```bash ```bash
clawdbot node status clawdbot node status
clawdbot node run clawdbot service node status
clawdbot node stop clawdbot node service status
clawdbot node restart clawdbot node service start
clawdbot node uninstall clawdbot node service stop
clawdbot node service restart
clawdbot node service uninstall
```
Legacy alias:
```bash
clawdbot node daemon status
``` ```
## Pairing ## Pairing
-22
View File
@@ -18,37 +18,15 @@ Related:
```bash ```bash
clawdbot nodes list clawdbot nodes list
clawdbot nodes list --connected
clawdbot nodes list --last-connected 24h
clawdbot nodes pending clawdbot nodes pending
clawdbot nodes approve <requestId> clawdbot nodes approve <requestId>
clawdbot nodes status clawdbot nodes status
clawdbot nodes status --connected
clawdbot nodes status --last-connected 24h
``` ```
`nodes list` prints pending/paired tables. Paired rows include the most recent connect age (Last Connect).
Use `--connected` to only show currently-connected nodes. Use `--last-connected <duration>` to
filter to nodes that connected within a duration (e.g. `24h`, `7d`).
## Invoke / run ## Invoke / run
```bash ```bash
clawdbot nodes invoke --node <id|name|ip> --command <command> --params <json> clawdbot nodes invoke --node <id|name|ip> --command <command> --params <json>
clawdbot nodes run --node <id|name|ip> <command...> clawdbot nodes run --node <id|name|ip> <command...>
clawdbot nodes run --raw "git status"
clawdbot nodes run --agent main --node <id|name|ip> --raw "git status"
``` ```
### Exec-style defaults
`nodes run` mirrors the model’s exec behavior (defaults + approvals):
- Reads `tools.exec.*` (plus `agents.list[].tools.exec.*` overrides).
- Uses exec approvals (`exec.approval.request`) before invoking `system.run`.
- `--node` can be omitted when `tools.exec.node` is set.
Flags:
- `--raw <command>`: run a shell string (`/bin/sh -lc` or `cmd.exe /c`).
- `--agent <id>`: agent-scoped approvals/allowlists (defaults to configured agent).
- `--ask <off|on-miss|always>`, `--security <deny|allowlist|full>`: overrides.
+1 -1
View File
@@ -21,4 +21,4 @@ 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. It also warns when small models (<=300B) are used without sandboxing and with web/browser tools enabled.
+51
View File
@@ -0,0 +1,51 @@
---
summary: "CLI reference for `clawdbot service` (manage gateway + node services)"
read_when:
- You want to manage Gateway or node services cross-platform
- You want a single surface for start/stop/install/uninstall
---
# `clawdbot service`
Manage the **Gateway** service and **node host** services.
Related:
- Gateway daemon (legacy alias): [Daemon](/cli/daemon)
- Node host: [Node](/cli/node)
## Gateway service
```bash
clawdbot service gateway status
clawdbot service gateway install --port 18789
clawdbot service gateway start
clawdbot service gateway stop
clawdbot service gateway restart
clawdbot service gateway uninstall
```
Notes:
- `service gateway status` supports `--json` and `--deep` for system checks.
- `service gateway install` supports `--runtime node|bun` and `--token`.
## Node host service
```bash
clawdbot service node status
clawdbot service node install --host <gateway-host> --port 18790
clawdbot service node start
clawdbot service node stop
clawdbot service node restart
clawdbot service node uninstall
```
Notes:
- `service node install` supports `--runtime node|bun`, `--node-id`, `--display-name`,
and TLS options (`--tls`, `--tls-fingerprint`).
## Aliases
- `clawdbot daemon …` → `clawdbot service gateway …`
- `clawdbot node service …` → `clawdbot service node …`
- `clawdbot node status` → `clawdbot service node status`
- `clawdbot node daemon …` → `clawdbot service node …` (legacy)
+1 -1
View File
@@ -19,6 +19,6 @@ clawdbot status --usage
Notes: 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 host 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). - 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 -14
View File
@@ -1,5 +1,5 @@
--- ---
summary: "CLI reference for `clawdbot update` (safe-ish source update + optional gateway restart)" summary: "CLI reference for `clawdbot update` (safe-ish source update + optional daemon restart)"
read_when: read_when:
- You want to update a source checkout safely - You want to update a source checkout safely
- You need to understand `--update` shorthand behavior - You need to understand `--update` shorthand behavior
@@ -7,9 +7,9 @@ read_when:
# `clawdbot update` # `clawdbot update`
Safely update Clawdbot and switch between stable/beta/dev channels. Safely update a **source checkout** (git install) of Clawdbot.
If you installed via **npm/pnpm** (global install, no git metadata), updates happen via the package manager flow in [Updating](/install/updating). If you installed via **npm/pnpm** (global install, no git metadata), use the package manager flow in [Updating](/install/updating).
## Usage ## Usage
@@ -26,7 +26,7 @@ clawdbot --update
## Options ## Options
- `--restart`: restart the Gateway service 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|dev>`: set the update channel (git + npm; 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.
@@ -48,16 +48,7 @@ Options:
- `--json`: print machine-readable status JSON. - `--json`: print machine-readable status JSON.
- `--timeout <seconds>`: timeout for checks (default is 3s). - `--timeout <seconds>`: timeout for checks (default is 3s).
## What it does ## What it does (git checkout)
When you switch channels explicitly (`--channel ...`), Clawdbot also keeps the
install method aligned:
- `dev` → ensures a git checkout (default: `~/clawdbot`, override with `CLAWDBOT_GIT_DIR`),
updates it, and installs the global CLI from that checkout.
- `stable`/`beta` → installs from npm using the matching dist-tag.
## Git checkout flow
Channels: Channels:
-15
View File
@@ -77,21 +77,6 @@ Client Gateway
safely retry; the server keeps a short‑lived dedupe cache. safely retry; the server keeps a short‑lived dedupe cache.
- Nodes must include `role: "node"` plus caps/commands/permissions in `connect`. - Nodes must include `role: "node"` plus caps/commands/permissions in `connect`.
## Pairing + local trust
- All WS clients (operators + nodes) include a **device identity** on `connect`.
- New device IDs require pairing approval; the Gateway issues a **device token**
for subsequent connects.
- **Local** connects (loopback or the gateway host’s own tailnet address) can be
auto‑approved to keep same‑host UX smooth.
- **Non‑local** connects must sign the `connect.challenge` nonce and require
explicit approval.
- Gateway auth (`gateway.auth.*`) still applies to **all** connections, local or
remote.
Details: [Gateway protocol](/gateway/protocol), [Pairing](/start/pairing),
[Security](/gateway/security).
## Protocol typing and codegen ## Protocol typing and codegen
- TypeBox schemas define the protocol. - TypeBox schemas define the protocol.
+2 -20
View File
@@ -194,7 +194,7 @@ Local mode:
- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`). - File type: Markdown only (`MEMORY.md`, `memory/**/*.md`).
- Index storage: per-agent SQLite at `~/.clawdbot/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token). - Index storage: per-agent SQLite at `~/.clawdbot/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync is scheduled on session start, on search, or on an interval and runs asynchronously. Session transcripts use delta thresholds to trigger background sync. - Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync runs on session start, on first search when dirty, and optionally on an interval.
- Reindex triggers: the index stores the embedding **provider/model + endpoint fingerprint + chunking params**. If any of those change, Clawdbot automatically resets and reindexes the entire store. - Reindex triggers: the index stores the embedding **provider/model + endpoint fingerprint + chunking params**. If any of those change, Clawdbot automatically resets and reindexes the entire store.
### Hybrid search (BM25 + vector) ### Hybrid search (BM25 + vector)
@@ -299,29 +299,11 @@ agents: {
Notes: Notes:
- Session indexing is **opt-in** (off by default). - Session indexing is **opt-in** (off by default).
- Session updates are debounced and **indexed asynchronously** once they cross delta thresholds (best-effort). - Session updates are debounced and indexed lazily on the next `memory_search` (or manual `clawdbot memory index`).
- `memory_search` never blocks on indexing; results can be slightly stale until background sync finishes.
- Results still include snippets only; `memory_get` remains limited to memory files. - Results still include snippets only; `memory_get` remains limited to memory files.
- Session indexing is isolated per agent (only that agent’s session logs are indexed). - Session indexing is isolated per agent (only that agent’s session logs are indexed).
- Session logs live on disk (`~/.clawdbot/agents/<agentId>/sessions/*.jsonl`). Any process/user with filesystem access can read them, so treat disk access as the trust boundary. For stricter isolation, run agents under separate OS users or hosts. - Session logs live on disk (`~/.clawdbot/agents/<agentId>/sessions/*.jsonl`). Any process/user with filesystem access can read them, so treat disk access as the trust boundary. For stricter isolation, run agents under separate OS users or hosts.
Delta thresholds (defaults shown):
```json5
agents: {
defaults: {
memorySearch: {
sync: {
sessions: {
deltaBytes: 100000, // ~100 KB
deltaMessages: 50 // JSONL lines
}
}
}
}
}
```
### SQLite vector acceleration (sqlite-vec) ### SQLite vector acceleration (sqlite-vec)
When the sqlite-vec extension is available, Clawdbot stores embeddings in a When the sqlite-vec extension is available, Clawdbot stores embeddings in a
+1 -1
View File
@@ -38,7 +38,7 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
- Provider: `anthropic` - Provider: `anthropic`
- Auth: `ANTHROPIC_API_KEY` or `claude setup-token` - Auth: `ANTHROPIC_API_KEY` or `claude setup-token`
- Example model: `anthropic/claude-opus-4-5` - Example model: `anthropic/claude-opus-4-5`
- CLI: `clawdbot onboard --auth-choice token` (paste setup-token) or `clawdbot models auth paste-token --provider anthropic` - CLI: `clawdbot onboard --auth-choice setup-token`
```json5 ```json5
{ {
+22 -24
View File
@@ -9,22 +9,8 @@ read_when:
Session pruning trims **old tool results** from the in-memory context right before each LLM call. It does **not** rewrite the on-disk session history (`*.jsonl`). Session pruning trims **old tool results** from the in-memory context right before each LLM call. It does **not** rewrite the on-disk session history (`*.jsonl`).
## When it runs ## When it runs
- When `mode: "cache-ttl"` is enabled and the last Anthropic call for the session is older than `ttl`. - Before each LLM request (context hook).
- Only affects the messages sent to the model for that request. - Only affects the messages sent to the model for that request.
- Only active for Anthropic API calls (and OpenRouter Anthropic models).
- For best results, match `ttl` to your model `cacheControlTtl`.
- After a prune, the TTL window resets so subsequent requests keep cache until `ttl` expires again.
## Smart defaults (Anthropic)
- **OAuth or setup-token** profiles: enable `cache-ttl` pruning and set heartbeat to `1h`.
- **API key** profiles: enable `cache-ttl` pruning, set heartbeat to `30m`, and default `cacheControlTtl` to `1h` on Anthropic models.
- If you set any of these values explicitly, Clawdbot does **not** override them.
## What this improves (cost + cache behavior)
- **Why prune:** Anthropic prompt caching only applies within the TTL. If a session goes idle past the TTL, the next request re-caches the full prompt unless you trim it first.
- **What gets cheaper:** pruning reduces the **cacheWrite** size for that first request after the TTL expires.
- **Why the TTL reset matters:** once pruning runs, the cache window resets, so follow‑up requests can reuse the freshly cached prompt instead of re-caching the full history again.
- **What it does not do:** pruning doesn’t add tokens or “double” costs; it only changes what gets cached on that first post‑TTL request.
## What can be pruned ## What can be pruned
- Only `toolResult` messages. - Only `toolResult` messages.
@@ -40,10 +26,14 @@ Pruning uses an estimated context window (chars ≈ tokens × 4). The window siz
3) `agents.defaults.contextTokens`. 3) `agents.defaults.contextTokens`.
4) Default `200000` tokens. 4) Default `200000` tokens.
## Mode ## Modes
### cache-ttl ### adaptive
- Pruning only runs if the last Anthropic call is older than `ttl` (default `5m`). - If estimated context ratio ≥ `softTrimRatio`: soft-trim oversized tool results.
- When it runs: same soft-trim + hard-clear behavior as before. - If still ≥ `hardClearRatio` **and** prunable tool text ≥ `minPrunableToolChars`: hard-clear oldest eligible tool results.
### aggressive
- Always hard-clears eligible tool results before the cutoff.
- Ignores `hardClear.enabled` (always clears when eligible).
## Soft vs hard pruning ## Soft vs hard pruning
- **Soft-trim**: only for oversized tool results. - **Soft-trim**: only for oversized tool results.
@@ -62,7 +52,6 @@ Pruning uses an estimated context window (chars ≈ tokens × 4). The window siz
- Compaction is separate: compaction summarizes and persists, pruning is transient per request. See [/concepts/compaction](/concepts/compaction). - Compaction is separate: compaction summarizes and persists, pruning is transient per request. See [/concepts/compaction](/concepts/compaction).
## Defaults (when enabled) ## Defaults (when enabled)
- `ttl`: `"5m"`
- `keepLastAssistants`: `3` - `keepLastAssistants`: `3`
- `softTrimRatio`: `0.3` - `softTrimRatio`: `0.3`
- `hardClearRatio`: `0.5` - `hardClearRatio`: `0.5`
@@ -71,7 +60,16 @@ Pruning uses an estimated context window (chars ≈ tokens × 4). The window siz
- `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }` - `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }`
## Examples ## Examples
Default (off): Default (adaptive):
```json5
{
agent: {
contextPruning: { mode: "adaptive" }
}
}
```
To disable:
```json5 ```json5
{ {
agent: { agent: {
@@ -80,11 +78,11 @@ Default (off):
} }
``` ```
Enable TTL-aware pruning: Aggressive:
```json5 ```json5
{ {
agent: { agent: {
contextPruning: { mode: "cache-ttl", ttl: "5m" } contextPruning: { mode: "aggressive" }
} }
} }
``` ```
@@ -94,7 +92,7 @@ Restrict pruning to specific tools:
{ {
agent: { agent: {
contextPruning: { contextPruning: {
mode: "cache-ttl", mode: "adaptive",
tools: { allow: ["exec", "read"], deny: ["*image*"] } tools: { allow: ["exec", "read"], deny: ["*image*"] }
} }
} }
-4
View File
@@ -60,7 +60,6 @@ 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).
- Per-channel overrides (optional): `resetByChannel` overrides the reset policy for a channel (applies to all session types for that channel and takes precedence over `reset`/`resetByType`).
- 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. `/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.
- 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).
@@ -110,9 +109,6 @@ Send these as standalone messages so they register.
dm: { mode: "idle", idleMinutes: 240 }, dm: { mode: "idle", idleMinutes: 240 },
group: { mode: "idle", idleMinutes: 120 } group: { mode: "idle", idleMinutes: 120 }
}, },
resetByChannel: {
discord: { mode: "idle", idleMinutes: 10080 }
},
resetTriggers: ["/new", "/reset"], resetTriggers: ["/new", "/reset"],
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json", store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
mainKey: "main", mainKey: "main",
+1 -1
View File
@@ -100,7 +100,7 @@ CLAWDBOT_PROFILE=dev clawdbot gateway --dev --reset
Tip: if a non‑dev gateway is already running (launchd/systemd), stop it first: Tip: if a non‑dev gateway is already running (launchd/systemd), stop it first:
```bash ```bash
clawdbot gateway stop clawdbot daemon stop
``` ```
## Raw stream logging (Clawdbot) ## Raw stream logging (Clawdbot)
+2 -1
View File
@@ -829,6 +829,8 @@
"cli/nodes", "cli/nodes",
"cli/approvals", "cli/approvals",
"cli/gateway", "cli/gateway",
"cli/daemon",
"cli/service",
"cli/tui", "cli/tui",
"cli/voicecall", "cli/voicecall",
"cli/wake", "cli/wake",
@@ -977,7 +979,6 @@
"plugin", "plugin",
"plugins/voice-call", "plugins/voice-call",
"plugins/zalouser", "plugins/zalouser",
"tools/lobster",
"tools/exec", "tools/exec",
"tools/web", "tools/web",
"tools/apply-patch", "tools/apply-patch",
+2 -2
View File
@@ -58,8 +58,8 @@ Exact allowlist is enforced in `src/gateway/server-bridge.ts`.
## Exec lifecycle events ## Exec lifecycle events
Nodes can emit `exec.finished` or `exec.denied` events to surface system.run activity. Nodes can emit `exec.started`, `exec.finished`, or `exec.denied` events to surface
These are mapped to system events in the gateway. (Legacy nodes may still emit `exec.started`.) system.run activity. These are mapped to system events in the gateway.
Payload fields (all optional unless noted): Payload fields (all optional unless noted):
- `sessionKey` (required): agent session to receive the system event. - `sessionKey` (required): agent session to receive the system event.
+1 -3
View File
@@ -151,9 +151,7 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
atHour: 4, atHour: 4,
idleMinutes: 60 idleMinutes: 60
}, },
resetByChannel: { heartbeatIdleMinutes: 120,
discord: { mode: "idle", idleMinutes: 10080 }
},
resetTriggers: ["/new", "/reset"], resetTriggers: ["/new", "/reset"],
store: "~/.clawdbot/agents/default/sessions/sessions.json", store: "~/.clawdbot/agents/default/sessions/sessions.json",
typingIntervalSeconds: 5, typingIntervalSeconds: 5,
+36 -10
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`. 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. `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.
Example: Example:
@@ -1742,9 +1742,8 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
`30m`. Set `0m` to disable. `30m`. Set `0m` to disable.
- `model`: optional override model for heartbeat runs (`provider/model`). - `model`: optional override model for heartbeat runs (`provider/model`).
- `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`. - `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`.
- `session`: optional session key to control which session the heartbeat runs in. Default: `main`. - `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram). - `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `msteams`, `signal`, `imessage`, `none`). Default: `last`.
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md` line if you still want the file read. - `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md` line if you still want the file read.
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 300). - `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 300).
@@ -1841,7 +1840,7 @@ Example:
`agents.defaults.subagents` configures sub-agent defaults: `agents.defaults.subagents` configures sub-agent defaults:
- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call. - `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call.
- `maxConcurrent`: max concurrent sub-agent runs (default 1) - `maxConcurrent`: max concurrent sub-agent runs (default 8)
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable) - `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins) - Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins)
@@ -1975,7 +1974,7 @@ Notes:
`agents.defaults.maxConcurrent` sets the maximum number of embedded agent runs that can `agents.defaults.maxConcurrent` sets the maximum number of embedded agent runs that can
execute in parallel across sessions. Each session is still serialized (one run execute in parallel across sessions. Each session is still serialized (one run
per session key at a time). Default: 1. per session key at a time). Default: 4.
### `agents.defaults.sandbox` ### `agents.defaults.sandbox`
@@ -2659,8 +2658,6 @@ Control UI base path:
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served. - `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`. - Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
- Default: root (`/`) (unchanged). - Default: root (`/`) (unchanged).
- `gateway.controlUi.allowInsecureAuth` allows token-only auth over **HTTP** (no device identity).
Default: `false`. Prefer HTTPS (Tailscale Serve) or `127.0.0.1`.
Related docs: Related docs:
- [Control UI](/web/control-ui) - [Control UI](/web/control-ui)
@@ -2672,6 +2669,7 @@ Notes:
- `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag). - `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI). - `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
- OpenAI Chat Completions endpoint: **disabled by default**; enable with `gateway.http.endpoints.chatCompletions.enabled: true`. - OpenAI Chat Completions endpoint: **disabled by default**; enable with `gateway.http.endpoints.chatCompletions.enabled: true`.
- OpenResponses endpoint: **disabled by default**; enable with `gateway.http.endpoints.responses.enabled: true`.
- Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`. - Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.
- Non-loopback binds (`lan`/`tailnet`/`auto`) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`). - Non-loopback binds (`lan`/`tailnet`/`auto`) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
- The onboarding wizard generates a gateway token by default (even on loopback). - The onboarding wizard generates a gateway token by default (even on loopback).
@@ -2679,7 +2677,7 @@ Notes:
Auth and Tailscale: Auth and Tailscale:
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`). - `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine). - `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine and as the bootstrap credential for device pairing).
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers). - When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended). - `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers - `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
@@ -2688,6 +2686,9 @@ Auth and Tailscale:
`true`, Serve requests do not need a token/password; set `false` to require `true`, Serve requests do not need a token/password; set `false` to require
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
auth mode is not `password`. auth mode is not `password`.
- After pairing, the Gateway issues **device tokens** scoped to the device role + scopes.
These are returned in `hello-ok.auth.deviceToken`; clients should persist and reuse them
instead of the shared token. Rotate/revoke via `device.token.rotate`/`device.token.revoke`.
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind). - `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth. - `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown. - `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
@@ -2696,6 +2697,7 @@ Remote client defaults (CLI):
- `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`. - `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`.
- `gateway.remote.token` supplies the token for remote calls (leave unset for no auth). - `gateway.remote.token` supplies the token for remote calls (leave unset for no auth).
- `gateway.remote.password` supplies the password for remote calls (leave unset for no auth). - `gateway.remote.password` supplies the password for remote calls (leave unset for no auth).
- `gateway.remote.tlsFingerprint` pins the gateway TLS cert fingerprint (sha256).
macOS app behavior: macOS app behavior:
- Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes. - Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
@@ -2709,12 +2711,36 @@ macOS app behavior:
remote: { remote: {
url: "ws://gateway.tailnet:18789", url: "ws://gateway.tailnet:18789",
token: "your-token", token: "your-token",
password: "your-password" password: "your-password",
tlsFingerprint: "sha256:ab12cd34..."
} }
} }
} }
``` ```
### `gateway.nodes` (Node command allowlist)
The Gateway enforces a per-platform command allowlist for `node.invoke`. Nodes must both
**declare** a command and have it **allowed** by the Gateway to run it.
Use this section to extend or deny commands:
```json5
{
gateway: {
nodes: {
allowCommands: ["custom.vendor.command"], // extra commands beyond defaults
denyCommands: ["sms.send"] // block a command even if declared
}
}
}
```
Notes:
- `allowCommands` extends the built-in per-platform defaults.
- `denyCommands` always wins (even if the node claims the command).
- `node.invoke` rejects commands that are not declared by the node.
### `gateway.reload` (Config hot reload) ### `gateway.reload` (Config hot reload)
The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically. The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically.
@@ -2962,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-bridge._tcp` under `~/.clawdbot/dns/` using the standard discovery domain `clawdbot.internal.` When enabled, the Gateway writes a unicast DNS-SD zone for `_clawdbot-gw._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)
+3 -3
View File
@@ -225,10 +225,10 @@ Notes:
- `clawdbot doctor --yes` accepts the default repair prompts. - `clawdbot doctor --yes` accepts the default repair prompts.
- `clawdbot doctor --repair` applies recommended fixes without prompts. - `clawdbot doctor --repair` applies recommended fixes without prompts.
- `clawdbot doctor --repair --force` overwrites custom supervisor configs. - `clawdbot doctor --repair --force` overwrites custom supervisor configs.
- You can always force a full rewrite via `clawdbot gateway install --force`. - You can always force a full rewrite via `clawdbot daemon install --force`.
### 16) Gateway runtime + port diagnostics ### 16) Gateway runtime + port diagnostics
Doctor inspects the service runtime (PID, last exit status) and warns when the Doctor inspects the daemon runtime (PID, last exit status) and warns when the
service is installed but not actually running. It also checks for port collisions service is installed but not actually running. It also checks for port collisions
on the gateway port (default `18789`) and reports likely causes (gateway already on the gateway port (default `18789`) and reports likely causes (gateway already
running, SSH tunnel). running, SSH tunnel).
@@ -236,7 +236,7 @@ running, SSH tunnel).
### 17) Gateway runtime best practices ### 17) Gateway runtime best practices
Doctor warns when the gateway service runs on Bun or a version-managed Node path Doctor warns when the gateway service runs on Bun or a version-managed Node path
(`nvm`, `fnm`, `volta`, `asdf`, etc.). WhatsApp + Telegram channels require Node, (`nvm`, `fnm`, `volta`, `asdf`, etc.). WhatsApp + Telegram channels require Node,
and version-manager paths can break after upgrades because the service does not and version-manager paths can break after upgrades because the daemon does not
load your shell init. Doctor offers to migrate to a system Node install when load your shell init. Doctor offers to migrate to a system Node install when
available (Homebrew/apt/choco). available (Homebrew/apt/choco).
+6 -18
View File
@@ -10,11 +10,10 @@ surface anything that needs attention without spamming you.
## Quick start (beginner) ## Quick start (beginner)
1. Leave heartbeats enabled (default is `30m`, or `1h` for Anthropic OAuth/setup-token) or set your own cadence. 1. Leave heartbeats enabled (default is `30m`) or set your own cadence.
2. Create a tiny `HEARTBEAT.md` checklist in the agent workspace (optional but recommended). 2. Create a tiny `HEARTBEAT.md` checklist in the agent workspace (optional but recommended).
3. Decide where heartbeat messages should go (`target: "last"` is the default). 3. Decide where heartbeat messages should go (`target: "last"` is the default).
4. Optional: enable heartbeat reasoning delivery for transparency. 4. Optional: enable heartbeat reasoning delivery for transparency.
5. Optional: restrict heartbeats to active hours (local time).
Example config: Example config:
@@ -25,7 +24,6 @@ Example config:
heartbeat: { heartbeat: {
every: "30m", every: "30m",
target: "last", target: "last",
// activeHours: { start: "08:00", end: "24:00" },
// includeReasoning: true, // optional: send separate `Reasoning:` message too // includeReasoning: true, // optional: send separate `Reasoning:` message too
} }
} }
@@ -35,13 +33,11 @@ Example config:
## Defaults ## Defaults
- Interval: `30m` (or `1h` when Anthropic OAuth/setup-token is the detected auth mode). Set `agents.defaults.heartbeat.every` or per-agent `agents.list[].heartbeat.every`; use `0m` to disable. - Interval: `30m` (set `agents.defaults.heartbeat.every` or per-agent `agents.list[].heartbeat.every`; use `0m` to disable).
- Prompt body (configurable via `agents.defaults.heartbeat.prompt`): - Prompt body (configurable via `agents.defaults.heartbeat.prompt`):
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.` `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
- The heartbeat prompt is sent **verbatim** as the user message. The system - The heartbeat prompt is sent **verbatim** as the user message. The system
prompt includes a “Heartbeat” section and the run is flagged internally. prompt includes a “Heartbeat” section and the run is flagged internally.
- Active hours (`heartbeat.activeHours`) are checked in the configured timezone.
Outside the window, heartbeats are skipped until the next tick inside the window.
## What the heartbeat prompt is for ## What the heartbeat prompt is for
@@ -127,26 +123,18 @@ Example: two agents, only the second agent runs heartbeats.
- `every`: heartbeat interval (duration string; default unit = minutes). - `every`: heartbeat interval (duration string; default unit = minutes).
- `model`: optional model override for heartbeat runs (`provider/model`). - `model`: optional model override for heartbeat runs (`provider/model`).
- `includeReasoning`: when enabled, also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). - `includeReasoning`: when enabled, also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`).
- `session`: optional session key for heartbeat runs.
- `main` (default): agent main session.
- Explicit session key (copy from `clawdbot sessions --json` or the [sessions CLI](/cli/sessions)).
- Session key formats: see [Sessions](/concepts/session) and [Groups](/concepts/groups).
- `target`: - `target`:
- `last` (default): deliver to the last used external channel. - `last` (default): deliver to the last used external channel.
- explicit channel: `whatsapp` / `telegram` / `discord` / `slack` / `msteams` / `signal` / `imessage`. - explicit channel: `whatsapp` / `telegram` / `discord` / `slack` / `signal` / `imessage`.
- `none`: run the heartbeat but **do not deliver** externally. - `none`: run the heartbeat but **do not deliver** externally.
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id). - `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram, etc.).
- `prompt`: overrides the default prompt body (not merged). - `prompt`: overrides the default prompt body (not merged).
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery. - `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery.
## Delivery behavior ## Delivery behavior
- Heartbeats run in the agent’s main session by default (`agent:<id>:<mainKey>`), - Heartbeats run in each agent’s **main session** (`agent:<id>:<mainKey>`), or `global`
or `global` when `session.scope = "global"`. Set `session` to override to a when `session.scope = "global"`.
specific channel session (Discord/WhatsApp/etc.).
- `session` only affects the run context; delivery is controlled by `target` and `to`.
- To deliver to a specific channel/recipient, set `target` + `to`. With
`target: "last"`, delivery uses the last external channel for that session.
- If the main queue is busy, the heartbeat is skipped and retried later. - If the main queue is busy, the heartbeat is skipped and retried later.
- If `target` resolves to no external destination, the run still happens but no - If `target` resolves to no external destination, the run still happens but no
outbound message is sent. outbound message is sent.
+27 -27
View File
@@ -1,9 +1,9 @@
--- ---
summary: "Runbook for the Gateway service, lifecycle, and operations" summary: "Runbook for the Gateway daemon, lifecycle, and operations"
read_when: read_when:
- Running or debugging the gateway process - Running or debugging the gateway process
--- ---
# Gateway service runbook # Gateway (daemon) runbook
Last updated: 2025-12-09 Last updated: 2025-12-09
@@ -101,10 +101,10 @@ Checklist per instance:
- unique `agents.defaults.workspace` - unique `agents.defaults.workspace`
- separate WhatsApp numbers (if using WA) - separate WhatsApp numbers (if using WA)
Service install per profile: Daemon install per profile:
```bash ```bash
clawdbot --profile main gateway install clawdbot --profile main daemon install
clawdbot --profile rescue gateway install clawdbot --profile rescue daemon install
``` ```
Example: Example:
@@ -175,49 +175,49 @@ See also: [Presence](/concepts/presence) for how presence is produced/deduped an
- Events are not replayed. Clients detect seq gaps and should refresh (`health` + `system-presence`) before continuing. WebChat and macOS clients now auto-refresh on gap. - Events are not replayed. Clients detect seq gaps and should refresh (`health` + `system-presence`) before continuing. WebChat and macOS clients now auto-refresh on gap.
## Supervision (macOS example) ## Supervision (macOS example)
- Use launchd to keep the service alive: - Use launchd to keep the daemon alive:
- Program: path to `clawdbot` - Program: path to `clawdbot`
- Arguments: `gateway` - Arguments: `gateway`
- KeepAlive: true - KeepAlive: true
- StandardOut/Err: file paths or `syslog` - StandardOut/Err: file paths or `syslog`
- On failure, launchd restarts; fatal misconfig should keep exiting so the operator notices. - On failure, launchd restarts; fatal misconfig should keep exiting so the operator notices.
- LaunchAgents are per-user and require a logged-in session; for headless setups use a custom LaunchDaemon (not shipped). - LaunchAgents are per-user and require a logged-in session; for headless setups use a custom LaunchDaemon (not shipped).
- `clawdbot gateway install` writes `~/Library/LaunchAgents/com.clawdbot.gateway.plist` - `clawdbot daemon install` writes `~/Library/LaunchAgents/com.clawdbot.gateway.plist`
(or `com.clawdbot.<profile>.plist`). (or `com.clawdbot.<profile>.plist`).
- `clawdbot doctor` audits the LaunchAgent config and can update it to current defaults. - `clawdbot doctor` audits the LaunchAgent config and can update it to current defaults.
## Gateway service management (CLI) ## Daemon management (CLI)
Use the Gateway CLI for install/start/stop/restart/status: Use the CLI daemon manager for install/start/stop/restart/status:
```bash ```bash
clawdbot gateway status clawdbot daemon status
clawdbot gateway install clawdbot daemon install
clawdbot gateway stop clawdbot daemon stop
clawdbot gateway restart clawdbot daemon restart
clawdbot logs --follow clawdbot logs --follow
``` ```
Notes: Notes:
- `gateway status` probes the Gateway RPC by default using the service’s resolved port/config (override with `--url`). - `daemon status` probes the Gateway RPC by default using the daemon’s resolved port/config (override with `--url`).
- `gateway status --deep` adds system-level scans (LaunchDaemons/system units). - `daemon status --deep` adds system-level scans (LaunchDaemons/system units).
- `gateway status --no-probe` skips the RPC probe (useful when networking is down). - `daemon status --no-probe` skips the RPC probe (useful when networking is down).
- `gateway status --json` is stable for scripts. - `daemon status --json` is stable for scripts.
- `gateway status` reports **supervisor runtime** (launchd/systemd running) separately from **RPC reachability** (WS connect + status RPC). - `daemon status` reports **supervisor runtime** (launchd/systemd running) separately from **RPC reachability** (WS connect + status RPC).
- `gateway status` prints config path + probe target to avoid “localhost vs LAN bind” confusion and profile mismatches. - `daemon status` prints config path + probe target to avoid “localhost vs LAN bind” confusion and profile mismatches.
- `gateway status` includes the last gateway error line when the service looks running but the port is closed. - `daemon status` includes the last gateway error line when the service looks running but the port is closed.
- `logs` tails the Gateway file log via RPC (no manual `tail`/`grep` needed). - `logs` tails the Gateway file log via RPC (no manual `tail`/`grep` needed).
- If other gateway-like services are detected, the CLI warns unless they are Clawdbot profile services. - If other gateway-like services are detected, the CLI warns unless they are Clawdbot profile services.
We still recommend **one gateway per machine** for most setups; use isolated profiles/ports for redundancy or a rescue bot. See [Multiple gateways](/gateway/multiple-gateways). We still recommend **one gateway per machine** for most setups; use isolated profiles/ports for redundancy or a rescue bot. See [Multiple gateways](/gateway/multiple-gateways).
- Cleanup: `clawdbot gateway uninstall` (current service) and `clawdbot doctor` (legacy migrations). - Cleanup: `clawdbot daemon uninstall` (current service) and `clawdbot doctor` (legacy migrations).
- `gateway install` is a no-op when already installed; use `clawdbot gateway install --force` to reinstall (profile/env/path changes). - `daemon install` is a no-op when already installed; use `clawdbot daemon install --force` to reinstall (profile/env/path changes).
Bundled mac app: Bundled mac app:
- Clawdbot.app can bundle a Node-based gateway relay and install a per-user LaunchAgent labeled - Clawdbot.app can bundle a Node-based gateway relay and install a per-user LaunchAgent labeled
`com.clawdbot.gateway` (or `com.clawdbot.<profile>`). `com.clawdbot.gateway` (or `com.clawdbot.<profile>`).
- To stop it cleanly, use `clawdbot gateway stop` (or `launchctl bootout gui/$UID/com.clawdbot.gateway`). - To stop it cleanly, use `clawdbot daemon stop` (or `launchctl bootout gui/$UID/com.clawdbot.gateway`).
- To restart, use `clawdbot gateway restart` (or `launchctl kickstart -k gui/$UID/com.clawdbot.gateway`). - To restart, use `clawdbot daemon restart` (or `launchctl kickstart -k gui/$UID/com.clawdbot.gateway`).
- `launchctl` only works if the LaunchAgent is installed; otherwise use `clawdbot gateway install` first. - `launchctl` only works if the LaunchAgent is installed; otherwise use `clawdbot daemon install` first.
- Replace the label with `com.clawdbot.<profile>` when running a named profile. - Replace the label with `com.clawdbot.<profile>` when running a named profile.
## Supervision (systemd user unit) ## Supervision (systemd user unit)
@@ -226,7 +226,7 @@ recommend user services for single-user machines (simpler env, per-user config).
Use a **system service** for multi-user or always-on servers (no lingering Use a **system service** for multi-user or always-on servers (no lingering
required, shared supervision). required, shared supervision).
`clawdbot gateway install` writes the user unit. `clawdbot doctor` audits the `clawdbot daemon install` writes the user unit. `clawdbot doctor` audits the
unit and can update it to match the current recommended defaults. unit and can update it to match the current recommended defaults.
Create `~/.config/systemd/user/clawdbot-gateway[-<profile>].service`: Create `~/.config/systemd/user/clawdbot-gateway[-<profile>].service`:
@@ -285,7 +285,7 @@ Windows installs should use **WSL2** and follow the Linux systemd section above.
- `clawdbot message send --target <num> --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp). - `clawdbot message send --target <num> --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp).
- `clawdbot agent --message "hi" --to <num>` — run an agent turn (waits for final by default). - `clawdbot agent --message "hi" --to <num>` — run an agent turn (waits for final by default).
- `clawdbot gateway call <method> --params '{"k":"v"}'` — raw method invoker for debugging. - `clawdbot gateway call <method> --params '{"k":"v"}'` — raw method invoker for debugging.
- `clawdbot gateway stop|restart` — stop/restart the supervised gateway service (launchd/systemd). - `clawdbot daemon stop|restart` — stop/restart the supervised gateway service (launchd/systemd).
- Gateway helper subcommands assume a running gateway on `--url`; they no longer auto-spawn one. - Gateway helper subcommands assume a running gateway on `--url`; they no longer auto-spawn one.
## Migration guidance ## Migration guidance
+6 -6
View File
@@ -31,10 +31,10 @@ clawdbot --profile rescue setup
clawdbot --profile rescue gateway --port 19001 clawdbot --profile rescue gateway --port 19001
``` ```
Per-profile services: Per-profile daemons:
```bash ```bash
clawdbot --profile main gateway install clawdbot --profile main daemon install
clawdbot --profile rescue gateway install clawdbot --profile rescue daemon install
``` ```
## Rescue-bot guide ## Rescue-bot guide
@@ -55,7 +55,7 @@ Port spacing: leave at least 20 ports between base ports so the derived bridge/b
# Main bot (existing or fresh, without --profile param) # Main bot (existing or fresh, without --profile param)
# Runs on port 18789 + Chrome CDC/Canvas/... Ports # Runs on port 18789 + Chrome CDC/Canvas/... Ports
clawdbot onboard clawdbot onboard
clawdbot gateway install clawdbot daemon install
# Rescue bot (isolated profile + ports) # Rescue bot (isolated profile + ports)
clawdbot --profile rescue onboard clawdbot --profile rescue onboard
@@ -65,8 +65,8 @@ clawdbot --profile rescue onboard
# better choose completely different base port, like 19789, # better choose completely different base port, like 19789,
# - rest of the onboarding is the same as normal # - rest of the onboarding is the same as normal
# To install the service (if not happened automatically during onboarding) # To install the daemon (if not happened automatically during onboarding)
clawdbot --profile rescue gateway install clawdbot --profile rescue daemon install
``` ```
## Port mapping (derived) ## Port mapping (derived)
-3
View File
@@ -195,10 +195,7 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- Gateways issue tokens per device + role. - Gateways issue tokens per device + role.
- Pairing approvals are required for new device IDs unless local auto-approval - Pairing approvals are required for new device IDs unless local auto-approval
is enabled. is enabled.
- **Local** connects include loopback and the gateway host’s own tailnet address
(so same‑host tailnet binds can still auto‑approve).
- All WS clients must include `device` identity during `connect` (operator + node). - All WS clients must include `device` identity during `connect` (operator + node).
Control UI can omit it **only** when `gateway.controlUi.allowInsecureAuth` is enabled.
- Non-local connections must sign the server-provided `connect.challenge` nonce. - Non-local connections must sign the server-provided `connect.challenge` nonce.
## TLS + pinning ## TLS + pinning
+3 -3
View File
@@ -50,7 +50,7 @@ Guide: [Tailscale](/gateway/tailscale) and [Web overview](/web).
## Command flow (what runs where) ## Command flow (what runs where)
One gateway service owns state + channels. Nodes are peripherals. One gateway daemon owns state + channels. Nodes are peripherals.
Flow example (Telegram → node): Flow example (Telegram → node):
- Telegram message arrives at the **Gateway**. - Telegram message arrives at the **Gateway**.
@@ -59,7 +59,7 @@ Flow example (Telegram → node):
- Node returns the result; Gateway replies back out to Telegram. - Node returns the result; Gateway replies back out to Telegram.
Notes: Notes:
- **Nodes do not run the gateway service.** Only one gateway should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). - **Nodes do not run the gateway daemon.** Only one gateway should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)).
- macOS app “node mode” is just a node client over the Bridge. - macOS app “node mode” is just a node client over the Bridge.
## SSH tunnel (CLI + tools) ## SSH tunnel (CLI + tools)
@@ -112,7 +112,7 @@ Runbook: [macOS remote access](/platforms/mac/remote).
Short version: **keep the Gateway loopback-only** unless you’re sure you need a bind. Short version: **keep the Gateway loopback-only** unless you’re sure you need a bind.
- **Loopback + SSH/Tailscale Serve** is the safest default (no public exposure). - **Loopback + SSH/Tailscale Serve** is the safest default (no public exposure).
- **Non-loopback binds** (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) must use auth tokens/passwords. - **Non-loopback binds** (`lan`/`tailnet`/`auto`) must use auth tokens/passwords.
- `gateway.remote.token` is **only** for remote CLI calls — it does **not** enable local auth. - `gateway.remote.token` is **only** for remote CLI calls — it does **not** enable local auth.
- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`. - `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`.
- **Tailscale Serve** can authenticate via identity headers when `gateway.auth.allowTailscale: true`. - **Tailscale Serve** can authenticate via identity headers when `gateway.auth.allowTailscale: true`.
+1 -16
View File
@@ -52,15 +52,6 @@ When the audit prints findings, treat this as a priority order:
5. **Plugins/extensions**: only load what you explicitly trust. 5. **Plugins/extensions**: only load what you explicitly trust.
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools. 6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
## Control UI over HTTP
The Control UI needs a **secure context** (HTTPS or localhost) to generate device
identity. If you enable `gateway.controlUi.allowInsecureAuth`, the UI falls back
to **token-only auth** on plain HTTP and skips device pairing. This is a security
downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
`clawdbot security audit` warns when this setting is enabled.
## Local session logs live on disk ## Local session logs live on disk
Clawdbot stores session transcripts on disk under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl`. Clawdbot stores session transcripts on disk under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl`.
@@ -246,7 +237,7 @@ The Gateway multiplexes **WebSocket + HTTP** on a single port:
Bind mode controls where the Gateway listens: Bind mode controls where the Gateway listens:
- `gateway.bind: "loopback"` (default): only local clients can connect. - `gateway.bind: "loopback"` (default): only local clients can connect.
- Non-loopback binds (`"lan"`, `"tailnet"`, `"custom"`) expand the attack surface. Only use them with `gateway.auth` enabled and a real firewall. - Non-loopback binds (`"lan"`, `"tailnet"`, `"auto"`) expand the attack surface. Only use them with `gateway.auth` enabled and a real firewall.
Rules of thumb: Rules of thumb:
- Prefer Tailscale Serve over LAN binds (Serve keeps the Gateway on loopback, and Tailscale handles access). - Prefer Tailscale Serve over LAN binds (Serve keeps the Gateway on loopback, and Tailscale handles access).
@@ -279,12 +270,6 @@ Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
protect local WS access. protect local WS access.
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`. Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
Local device pairing:
- Device pairing is auto‑approved for **local** connects (loopback or the
gateway host’s own tailnet address) to keep same‑host clients smooth.
- Other tailnet peers are **not** treated as local; they still need pairing
approval.
Auth modes: Auth modes:
- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups). - `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups).
- `gateway.auth.mode: "password"`: password auth (prefer setting via env: `CLAWDBOT_GATEWAY_PASSWORD`). - `gateway.auth.mode: "password"`: password auth (prefer setting via env: `CLAWDBOT_GATEWAY_PASSWORD`).
-21
View File
@@ -46,25 +46,6 @@ force `gateway.auth.mode: "password"`.
Open: `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`) Open: `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
### Tailnet-only (bind to Tailnet IP)
Use this when you want the Gateway to listen directly on the Tailnet IP (no Serve/Funnel).
```json5
{
gateway: {
bind: "tailnet",
auth: { mode: "token", token: "your-token" }
}
}
```
Connect from another Tailnet device:
- Control UI: `http://<tailscale-ip>:18789/`
- WebSocket: `ws://<tailscale-ip>:18789`
Note: loopback (`http://127.0.0.1:18789`) will **not** work in this mode.
### Public internet (Funnel + shared password) ### Public internet (Funnel + shared password)
```json5 ```json5
@@ -92,8 +73,6 @@ clawdbot gateway --tailscale funnel --auth password
- `tailscale.mode: "funnel"` refuses to start unless auth mode is `password` to avoid public exposure. - `tailscale.mode: "funnel"` refuses to start unless auth mode is `password` to avoid public exposure.
- Set `gateway.tailscale.resetOnExit` if you want Clawdbot to undo `tailscale serve` - Set `gateway.tailscale.resetOnExit` if you want Clawdbot to undo `tailscale serve`
or `tailscale funnel` configuration on shutdown. or `tailscale funnel` configuration on shutdown.
- `gateway.bind: "tailnet"` is a direct Tailnet bind (no HTTPS, no Serve/Funnel).
- `gateway.bind: "auto"` prefers loopback; use `tailnet` if you want Tailnet-only.
- Serve/Funnel only expose the **Gateway control UI + WS**. Node **bridge** traffic - Serve/Funnel only expose the **Gateway control UI + WS**. Node **bridge** traffic
uses the separate bridge port (default `18790`) and is **not** proxied by Serve. uses the separate bridge port (default `18790`) and is **not** proxied by Serve.
+33 -74
View File
@@ -17,12 +17,12 @@ Quick triage commands (in order):
| Command | What it tells you | When to use it | | Command | What it tells you | When to use it |
|---|---|---| |---|---|---|
| `clawdbot status` | Local summary: OS + update, gateway reachability/mode, service, agents/sessions, provider config state | First check, quick overview | | `clawdbot status` | Local summary: OS + update, gateway reachability/mode, daemon, agents/sessions, provider config state | First check, quick overview |
| `clawdbot status --all` | Full local diagnosis (read-only, pasteable, safe-ish) incl. log tail | When you need to share a debug report | | `clawdbot status --all` | Full local diagnosis (read-only, pasteable, safe-ish) incl. log tail | When you need to share a debug report |
| `clawdbot status --deep` | Runs gateway health checks (incl. provider probes; requires reachable gateway) | When “configured” doesn’t mean “working” | | `clawdbot status --deep` | Runs gateway health checks (incl. provider probes; requires reachable gateway) | When “configured” doesn’t mean “working” |
| `clawdbot gateway probe` | Gateway discovery + reachability (local + remote targets) | When you suspect you’re probing the wrong gateway | | `clawdbot gateway status` | Gateway discovery + reachability (local + remote targets) | When you suspect you’re probing the wrong gateway |
| `clawdbot channels status --probe` | Asks the running gateway for channel status (and optionally probes) | When gateway is reachable but channels misbehave | | `clawdbot channels status --probe` | Asks the running gateway for channel status (and optionally probes) | When gateway is reachable but channels misbehave |
| `clawdbot gateway status` | Supervisor state (launchd/systemd/schtasks), runtime PID/exit, last gateway error | When the service “looks loaded” but nothing runs | | `clawdbot daemon status` | Supervisor state (launchd/systemd/schtasks), runtime PID/exit, last gateway error | When the daemon “looks loaded” but nothing runs |
| `clawdbot logs --follow` | Live logs (best signal for runtime issues) | When you need the actual failure reason | | `clawdbot logs --follow` | Live logs (best signal for runtime issues) | When you need the actual failure reason |
**Sharing output:** prefer `clawdbot status --all` (it redacts tokens). If you paste `clawdbot status`, consider setting `CLAWDBOT_SHOW_SECRETS=0` first (token previews). **Sharing output:** prefer `clawdbot status --all` (it redacts tokens). If you paste `clawdbot status`, consider setting `CLAWDBOT_SHOW_SECRETS=0` first (token previews).
@@ -31,19 +31,6 @@ See also: [Health checks](/gateway/health) and [Logging](/logging).
## Common Issues ## Common Issues
### Control UI fails on HTTP ("device identity required" / "connect failed")
If you open the dashboard over plain HTTP (e.g. `http://<lan-ip>:18789/` or
`http://<tailscale-ip>:18789/`), the browser runs in a **non-secure context** and
blocks WebCrypto, so device identity can’t be generated.
**Fix:**
- Prefer HTTPS via [Tailscale Serve](/gateway/tailscale).
- Or open locally on the gateway host: `http://127.0.0.1:18789/`.
- If you must stay on HTTP, enable `gateway.controlUi.allowInsecureAuth: true` and
use a gateway token (token-only; no device identity/pairing). See
[Control UI](/web/control-ui#insecure-http).
### CI Secrets Scan Failed ### CI Secrets Scan Failed
This means `detect-secrets` found new candidates not yet in the baseline. This means `detect-secrets` found new candidates not yet in the baseline.
@@ -51,16 +38,16 @@ Follow [Secret scanning](/gateway/security#secret-scanning-detect-secrets).
### Service Installed but Nothing is Running ### Service Installed but Nothing is Running
If the gateway service is installed but the process exits immediately, the service If the gateway service is installed but the process exits immediately, the daemon
can appear “loaded” while nothing is running. can appear “loaded” while nothing is running.
**Check:** **Check:**
```bash ```bash
clawdbot gateway status clawdbot daemon status
clawdbot doctor clawdbot doctor
``` ```
Doctor/service will show runtime state (PID/last exit) and log hints. Doctor/daemon will show runtime state (PID/last exit) and log hints.
**Logs:** **Logs:**
- Preferred: `clawdbot logs --follow` - Preferred: `clawdbot logs --follow`
@@ -82,42 +69,14 @@ Doctor/service will show runtime state (PID/last exit) and log hints.
See [/logging](/logging) for a full overview of formats, config, and access. See [/logging](/logging) for a full overview of formats, config, and access.
### "Gateway start blocked: set gateway.mode=local"
This means the config exists but `gateway.mode` is unset (or not `local`), so the
Gateway refuses to start.
**Fix (recommended):**
- Run the wizard and set the Gateway run mode to **Local**:
```bash
clawdbot configure
```
- Or set it directly:
```bash
clawdbot config set gateway.mode local
```
**If you meant to run a remote Gateway instead:**
- Set a remote URL and keep `gateway.mode=remote`:
```bash
clawdbot config set gateway.mode remote
clawdbot config set gateway.remote.url "wss://gateway.example.com"
```
**Ad-hoc/dev only:** pass `--allow-unconfigured` to start the gateway without
`gateway.mode=local`.
**No config file yet?** Run `clawdbot setup` to create a starter config, then rerun
the gateway.
### Service Environment (PATH + runtime) ### Service Environment (PATH + runtime)
The gateway service runs with a **minimal PATH** to avoid shell/manager cruft: The gateway daemon runs with a **minimal PATH** to avoid shell/manager cruft:
- 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`
This intentionally excludes version managers (nvm/fnm/volta/asdf) and package This intentionally excludes version managers (nvm/fnm/volta/asdf) and package
managers (pnpm/npm) because the service 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, Exec runs on `host=gateway` merge your login-shell `PATH` into the exec environment,
@@ -147,31 +106,31 @@ the Gateway likely refused to bind.
**What "running" means here** **What "running" means here**
- `Runtime: running` means your supervisor (launchd/systemd/schtasks) thinks the process is alive. - `Runtime: running` means your supervisor (launchd/systemd/schtasks) thinks the process is alive.
- `RPC probe` means the CLI could actually connect to the gateway WebSocket and call `status`. - `RPC probe` means the CLI could actually connect to the gateway WebSocket and call `status`.
- Always trust `Probe target:` + `Config (service):` as the “what did we actually try?” lines. - Always trust `Probe target:` + `Config (daemon):` as the “what did we actually try?” lines.
**Check:** **Check:**
- `gateway.mode` must be `local` for `clawdbot gateway` and the service. - `gateway.mode` must be `local` for `clawdbot gateway` and the daemon.
- If you set `gateway.mode=remote`, the **CLI defaults** to a remote URL. The service can still be running locally, but your CLI may be probing the wrong place. Use `clawdbot gateway status` to see the service’s resolved port + probe target (or pass `--url`). - If you set `gateway.mode=remote`, the **CLI defaults** to a remote URL. The daemon can still be running locally, but your CLI may be probing the wrong place. Use `clawdbot daemon status` to see the daemon’s resolved port + probe target (or pass `--url`).
- `clawdbot gateway status` and `clawdbot doctor` surface the **last gateway error** from logs when the service looks running but the port is closed. - `clawdbot daemon status` and `clawdbot doctor` surface the **last gateway error** from logs when the service looks running but the port is closed.
- Non-loopback binds (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) require auth: - Non-loopback binds (`lan`/`tailnet`/`auto`) require auth:
`gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`). `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
- `gateway.remote.token` is for remote CLI calls only; it does **not** enable local auth. - `gateway.remote.token` is for remote CLI calls only; it does **not** enable local auth.
- `gateway.token` is ignored; use `gateway.auth.token`. - `gateway.token` is ignored; use `gateway.auth.token`.
**If `clawdbot gateway status` shows a config mismatch** **If `clawdbot daemon status` shows a config mismatch**
- `Config (cli): ...` and `Config (service): ...` should normally match. - `Config (cli): ...` and `Config (daemon): ...` should normally match.
- If they don’t, you’re almost certainly editing one config while the service is running another. - If they don’t, you’re almost certainly editing one config while the daemon is running another.
- Fix: rerun `clawdbot gateway install --force` from the same `--profile` / `CLAWDBOT_STATE_DIR` you want the service to use. - Fix: rerun `clawdbot daemon install --force` from the same `--profile` / `CLAWDBOT_STATE_DIR` you want the daemon to use.
**If `clawdbot gateway status` reports service config issues** **If `clawdbot daemon status` reports service config issues**
- The supervisor config (launchd/systemd/schtasks) is missing current defaults. - The supervisor config (launchd/systemd/schtasks) is missing current defaults.
- Fix: run `clawdbot doctor` to update it (or `clawdbot gateway install --force` for a full rewrite). - Fix: run `clawdbot doctor` to update it (or `clawdbot daemon install --force` for a full rewrite).
**If `Last gateway error:` mentions “refusing to bind … without auth”** **If `Last gateway error:` mentions “refusing to bind … without auth”**
- You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) but left auth off. - You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`auto`) but left auth off.
- Fix: set `gateway.auth.mode` + `gateway.auth.token` (or export `CLAWDBOT_GATEWAY_TOKEN`) and restart the service. - Fix: set `gateway.auth.mode` + `gateway.auth.token` (or export `CLAWDBOT_GATEWAY_TOKEN`) and restart the daemon.
**If `clawdbot gateway status` says `bind=tailnet` but no tailnet interface was found** **If `clawdbot daemon status` says `bind=tailnet` but no tailnet interface was found**
- The gateway tried to bind to a Tailscale IP (100.64.0.0/10) but none were detected on the host. - The gateway tried to bind to a Tailscale IP (100.64.0.0/10) but none were detected on the host.
- Fix: bring up Tailscale on that machine (or change `gateway.bind` to `loopback`/`lan`). - Fix: bring up Tailscale on that machine (or change `gateway.bind` to `loopback`/`lan`).
@@ -185,7 +144,7 @@ This means something is already listening on the gateway port.
**Check:** **Check:**
```bash ```bash
clawdbot gateway status clawdbot daemon status
``` ```
It will show the listener(s) and likely causes (gateway already running, SSH tunnel). It will show the listener(s) and likely causes (gateway already running, SSH tunnel).
@@ -395,7 +354,7 @@ clawdbot doctor --fix
Notes: Notes:
- `clawdbot doctor` reports every invalid entry. - `clawdbot doctor` reports every invalid entry.
- `clawdbot doctor --fix` applies migrations/repairs and rewrites the config. - `clawdbot doctor --fix` applies migrations/repairs and rewrites the config.
- Diagnostic commands like `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot gateway status`, and `clawdbot gateway probe` still run even if the config is invalid. - Diagnostic commands like `clawdbot logs`, `clawdbot health`, `clawdbot status`, and `clawdbot service` still run even if the config is invalid.
### “All models failed” — what should I check first? ### “All models failed” — what should I check first?
@@ -448,7 +407,7 @@ git status # ensure you’re in the repo root
pnpm install pnpm install
pnpm build pnpm build
clawdbot doctor clawdbot doctor
clawdbot gateway restart clawdbot daemon restart
``` ```
Why: pnpm is the configured package manager for this repo. Why: pnpm is the configured package manager for this repo.
@@ -473,7 +432,7 @@ Notes:
- After switching, run: - After switching, run:
```bash ```bash
clawdbot doctor clawdbot doctor
clawdbot gateway restart clawdbot daemon restart
``` ```
### Telegram block streaming isn’t splitting text between tool calls. Why? ### Telegram block streaming isn’t splitting text between tool calls. Why?
@@ -548,8 +507,8 @@ The app connects to a local gateway on port `18789`. If it stays stuck:
**Fix 1: Stop the supervisor (preferred)** **Fix 1: Stop the supervisor (preferred)**
If the gateway is supervised by launchd, killing the PID will just respawn it. Stop the supervisor first: If the gateway is supervised by launchd, killing the PID will just respawn it. Stop the supervisor first:
```bash ```bash
clawdbot gateway status clawdbot daemon status
clawdbot gateway stop clawdbot daemon stop
# Or: launchctl bootout gui/$UID/com.clawdbot.gateway (replace with com.clawdbot.<profile> if needed) # Or: launchctl bootout gui/$UID/com.clawdbot.gateway (replace with com.clawdbot.<profile> if needed)
``` ```
@@ -599,9 +558,9 @@ clawdbot channels login --verbose
```bash ```bash
# Supervisor + probe target + config paths # Supervisor + probe target + config paths
clawdbot gateway status clawdbot daemon status
# Include system-level scans (legacy/extra services, port listeners) # Include system-level scans (legacy/extra services, port listeners)
clawdbot gateway status --deep clawdbot daemon status --deep
# Is the gateway reachable? # Is the gateway reachable?
clawdbot health --json clawdbot health --json
@@ -622,13 +581,13 @@ tail -20 /tmp/clawdbot/clawdbot-*.log
Nuclear option: Nuclear option:
```bash ```bash
clawdbot gateway stop clawdbot daemon stop
# If you installed a service and want a clean install: # If you installed a service and want a clean install:
# clawdbot gateway uninstall # clawdbot daemon uninstall
trash "${CLAWDBOT_STATE_DIR:-$HOME/.clawdbot}" trash "${CLAWDBOT_STATE_DIR:-$HOME/.clawdbot}"
clawdbot channels login # re-pair WhatsApp clawdbot channels login # re-pair WhatsApp
clawdbot gateway restart # or: clawdbot gateway clawdbot daemon restart # or: clawdbot gateway
``` ```
⚠️ This loses all sessions and requires re-pairing WhatsApp. ⚠️ This loses all sessions and requires re-pairing WhatsApp.
+3 -8
View File
@@ -14,7 +14,7 @@ Run these in order:
```bash ```bash
clawdbot status clawdbot status
clawdbot status --all clawdbot status --all
clawdbot gateway probe clawdbot daemon status
clawdbot logs --follow clawdbot logs --follow
clawdbot doctor clawdbot doctor
``` ```
@@ -38,15 +38,10 @@ Almost always a Node/npm PATH issue. Start here:
- [Gateway troubleshooting](/gateway/troubleshooting) - [Gateway troubleshooting](/gateway/troubleshooting)
- [Gateway authentication](/gateway/authentication) - [Gateway authentication](/gateway/authentication)
### Control UI fails on HTTP (device identity required) ### Daemon says running, but RPC probe fails
- [Gateway troubleshooting](/gateway/troubleshooting) - [Gateway troubleshooting](/gateway/troubleshooting)
- [Control UI](/web/control-ui#insecure-http) - [Background process / daemon](/gateway/background-process)
### Service says running, but RPC probe fails
- [Gateway troubleshooting](/gateway/troubleshooting)
- [Background process / service](/gateway/background-process)
### Model/auth failures (rate limit, billing, “all models failed”) ### Model/auth failures (rate limit, billing, “all models failed”)
+2 -2
View File
@@ -103,13 +103,13 @@ Runtime requirement: **Node ≥ 22**.
npm install -g clawdbot@latest npm install -g clawdbot@latest
# or: pnpm add -g clawdbot@latest # or: pnpm add -g clawdbot@latest
# Onboard + install the service (launchd/systemd user service) # Onboard + install the daemon (launchd/systemd user service)
clawdbot onboard --install-daemon clawdbot onboard --install-daemon
# Pair WhatsApp Web (shows QR) # Pair WhatsApp Web (shows QR)
clawdbot channels login clawdbot channels login
# Gateway runs via the service after onboarding; manual run is still possible: # Gateway runs via daemon after onboarding; manual run is still possible:
clawdbot gateway --port 18789 clawdbot gateway --port 18789
``` ```
+1 -8
View File
@@ -7,7 +7,7 @@ read_when:
# Development channels # Development channels
Last updated: 2026-01-21 Last updated: 2026-01-20
Clawdbot ships three update channels: Clawdbot ships three update channels:
@@ -38,13 +38,6 @@ clawdbot update --channel dev
This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`). This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`).
When you **explicitly** switch channels with `--channel`, Clawdbot also aligns
the install method:
- `dev` ensures a git checkout (default `~/clawdbot`, override with `CLAWDBOT_GIT_DIR`),
updates it, and installs the global CLI from that checkout.
- `stable`/`beta` installs from npm using the matching dist-tag.
Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one. Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one.
## Plugins and channels ## Plugins and channels
+2 -2
View File
@@ -31,13 +31,13 @@ Manual steps (same result):
1) Stop the gateway service: 1) Stop the gateway service:
```bash ```bash
clawdbot gateway stop clawdbot daemon stop
``` ```
2) Uninstall the gateway service (launchd/systemd/schtasks): 2) Uninstall the gateway service (launchd/systemd/schtasks):
```bash ```bash
clawdbot gateway uninstall clawdbot daemon uninstall
``` ```
3) Delete state + config: 3) Delete state + config:
+8 -8
View File
@@ -68,12 +68,12 @@ Then:
```bash ```bash
clawdbot doctor clawdbot doctor
clawdbot gateway restart clawdbot daemon restart
clawdbot health clawdbot health
``` ```
Notes: Notes:
- If your Gateway runs as a service, `clawdbot gateway restart` is preferred over killing PIDs. - If your Gateway runs as a service, `clawdbot daemon restart` is preferred over killing PIDs.
- If you’re pinned to a specific version, see “Rollback / pinning” below. - If you’re pinned to a specific version, see “Rollback / pinning” below.
## Update (`clawdbot update`) ## Update (`clawdbot update`)
@@ -148,9 +148,9 @@ Details: [Doctor](/gateway/doctor)
CLI (works regardless of OS): CLI (works regardless of OS):
```bash ```bash
clawdbot gateway status clawdbot daemon status
clawdbot gateway stop clawdbot daemon stop
clawdbot gateway restart clawdbot daemon restart
clawdbot gateway --port 18789 clawdbot gateway --port 18789
clawdbot logs --follow clawdbot logs --follow
``` ```
@@ -159,7 +159,7 @@ If you’re supervised:
- macOS launchd (app-bundled LaunchAgent): `launchctl kickstart -k gui/$UID/com.clawdbot.gateway` (use `com.clawdbot.<profile>` if set) - macOS launchd (app-bundled LaunchAgent): `launchctl kickstart -k gui/$UID/com.clawdbot.gateway` (use `com.clawdbot.<profile>` if set)
- Linux systemd user service: `systemctl --user restart clawdbot-gateway[-<profile>].service` - Linux systemd user service: `systemctl --user restart clawdbot-gateway[-<profile>].service`
- Windows (WSL2): `systemctl --user restart clawdbot-gateway[-<profile>].service` - Windows (WSL2): `systemctl --user restart clawdbot-gateway[-<profile>].service`
- `launchctl`/`systemctl` only work if the service is installed; otherwise run `clawdbot gateway install`. - `launchctl`/`systemctl` only work if the service is installed; otherwise run `clawdbot daemon install`.
Runbook + exact service labels: [Gateway runbook](/gateway) Runbook + exact service labels: [Gateway runbook](/gateway)
@@ -183,7 +183,7 @@ Then restart + re-run doctor:
```bash ```bash
clawdbot doctor clawdbot doctor
clawdbot gateway restart clawdbot daemon restart
``` ```
### Pin (source) by date ### Pin (source) by date
@@ -200,7 +200,7 @@ Then reinstall deps + restart:
```bash ```bash
pnpm install pnpm install
pnpm build pnpm build
clawdbot gateway restart clawdbot daemon restart
``` ```
If you want to go back to latest later: If you want to go back to latest later:
-51
View File
@@ -1,51 +0,0 @@
---
summary: "Network hub: gateway surfaces, pairing, discovery, and security"
read_when:
- You need the network architecture + security overview
- You are debugging local vs tailnet access or pairing
- You want the canonical list of networking docs
---
# Network hub
This hub links the core docs for how Clawdbot connects, pairs, and secures
devices across localhost, LAN, and tailnet.
## Core model
- [Gateway architecture](/concepts/architecture)
- [Gateway protocol](/gateway/protocol)
- [Gateway runbook](/gateway)
- [Web surfaces + bind modes](/web)
## Pairing + identity
- [Pairing overview (DM + nodes)](/start/pairing)
- [Gateway-owned node pairing](/gateway/pairing)
- [Devices CLI (pairing + token rotation)](/cli/devices)
- [Pairing CLI (DM approvals)](/cli/pairing)
Local trust:
- Local connections (loopback or the gateway host’s own tailnet address) can be
auto‑approved for pairing to keep same‑host UX smooth.
- Non‑local tailnet/LAN clients still require explicit pairing approval.
## Discovery + transports
- [Discovery & transports](/gateway/discovery)
- [Bonjour / mDNS](/gateway/bonjour)
- [Remote access (SSH)](/gateway/remote)
- [Tailscale](/gateway/tailscale)
## Nodes + bridge
- [Nodes overview](/nodes)
- [Bridge protocol (legacy nodes)](/gateway/bridge-protocol)
- [Node runbook: iOS](/platforms/ios)
- [Node runbook: Android](/platforms/android)
## Security
- [Security overview](/gateway/security)
- [Gateway config reference](/gateway/configuration)
- [Troubleshooting](/gateway/troubleshooting)
- [Doctor](/gateway/doctor)
+2 -80
View File
@@ -13,7 +13,7 @@ A **node** is a companion device (iOS/Android today) that connects to the Gatewa
macOS can also run in **node mode**: the menubar app connects to the Gateway’s bridge and exposes its local canvas/camera commands as a node (so `clawdbot nodes …` works against this Mac). macOS can also run in **node mode**: the menubar app connects to the Gateway’s bridge and exposes its local canvas/camera commands as a node (so `clawdbot nodes …` works against this Mac).
Notes: Notes:
- Nodes are **peripherals**, not gateways. They don’t run the gateway service. - Nodes are **peripherals**, not gateways. They don’t run the gateway daemon.
- Telegram/WhatsApp/etc. messages land on the **gateway**, not on nodes. - Telegram/WhatsApp/etc. messages land on the **gateway**, not on nodes.
## Pairing + status ## Pairing + status
@@ -34,81 +34,6 @@ clawdbot nodes rename --node <idOrNameOrIp> --name "Kitchen iPad"
Notes: Notes:
- `nodes rename` stores a display name override in the gateway pairing store. - `nodes rename` stores a display name override in the gateway pairing store.
## Remote node host (system.run)
Use a **node host** when your Gateway runs on one machine and you want commands
to execute on another. The model still talks to the **gateway**; the gateway
forwards `exec` calls to the **node host** when `host=node` is selected.
### What runs where
- **Gateway host**: receives messages, runs the model, routes tool calls.
- **Node host**: executes `system.run`/`system.which` on the node machine.
- **Approvals**: enforced on the node host via `~/.clawdbot/exec-approvals.json`.
### Start a node host (foreground)
On the node machine:
```bash
clawdbot node run --host <gateway-host> --port 18789 --display-name "Build Node"
```
### Start a node host (service)
```bash
clawdbot node install --host <gateway-host> --port 18789 --display-name "Build Node"
clawdbot node start
```
### Pair + name
On the gateway host:
```bash
clawdbot nodes pending
clawdbot nodes approve <requestId>
clawdbot nodes list
```
Naming options:
- `--display-name` on `clawdbot node run` / `clawdbot node install` (persists in `~/.clawdbot/node.json` on the node).
- `clawdbot nodes rename --node <id|name|ip> --name "Build Node"` (gateway override).
### Allowlist the commands
Exec approvals are **per node host**. Add allowlist entries from the gateway:
```bash
clawdbot approvals allowlist add --node <id|name|ip> "/usr/bin/uname"
clawdbot approvals allowlist add --node <id|name|ip> "/usr/bin/sw_vers"
```
Approvals live on the node host at `~/.clawdbot/exec-approvals.json`.
### Point exec at the node
Configure defaults (gateway config):
```bash
clawdbot config set tools.exec.host node
clawdbot config set tools.exec.security allowlist
clawdbot config set tools.exec.node "<id-or-name>"
```
Or per session:
```
/exec host=node security=allowlist node=<id-or-name>
```
Once set, any `exec` call with `host=node` runs on the node host (subject to the
node allowlist/approvals).
Related:
- [Node host CLI](/cli/node)
- [Exec tool](/tools/exec)
- [Exec approvals](/tools/exec-approvals)
## Invoking commands ## Invoking commands
Low-level (raw RPC): Low-level (raw RPC):
@@ -281,7 +206,7 @@ or for running a minimal node alongside a server.
Start it: Start it:
```bash ```bash
clawdbot node run --host <gateway-host> --port 18790 clawdbot node start --host <gateway-host> --port 18790
``` ```
Notes: Notes:
@@ -289,9 +214,6 @@ Notes:
- The node host stores its node id + pairing token in `~/.clawdbot/node.json`. - The node host stores its node id + pairing token in `~/.clawdbot/node.json`.
- Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json` - Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json`
(see [Exec approvals](/tools/exec-approvals)). (see [Exec approvals](/tools/exec-approvals)).
- On macOS, the headless node host prefers the companion app exec host when reachable and falls
back to local execution if the app is unavailable. Set `CLAWDBOT_NODE_EXEC_HOST=app` to require
the app, or `CLAWDBOT_NODE_EXEC_FALLBACK=0` to disable fallback.
- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS. - Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
## Mac node mode ## Mac node mode
+4 -4
View File
@@ -97,7 +97,7 @@ It can set up:
- `~/.clawdbot/clawdbot.json` config - `~/.clawdbot/clawdbot.json` config
- model auth profiles - model auth profiles
- model provider config/login - model provider config/login
- Linux systemd **user** service (service) - Linux systemd **user** service (daemon)
If you’re doing OAuth on a headless VM: do OAuth on a normal machine first, then copy the auth profile to the VM (see [Help](/help)). If you’re doing OAuth on a headless VM: do OAuth on a normal machine first, then copy the auth profile to the VM (see [Help](/help)).
@@ -125,7 +125,7 @@ export CLAWDBOT_GATEWAY_TOKEN="$(openssl rand -hex 32)"
clawdbot gateway --bind lan --port 8080 --token "$CLAWDBOT_GATEWAY_TOKEN" clawdbot gateway --bind lan --port 8080 --token "$CLAWDBOT_GATEWAY_TOKEN"
``` ```
For service runs, persist it in `~/.clawdbot/clawdbot.json`: For daemon runs, persist it in `~/.clawdbot/clawdbot.json`:
```json5 ```json5
{ {
@@ -159,7 +159,7 @@ Notes:
Control UI details: [Control UI](/web/control-ui) Control UI details: [Control UI](/web/control-ui)
## 6) Keep it running (service) ## 6) Keep it running (daemon)
On Linux, Clawdbot uses a systemd **user** service. After `--install-daemon`, verify: On Linux, Clawdbot uses a systemd **user** service. After `--install-daemon`, verify:
@@ -180,7 +180,7 @@ More: [Linux](/platforms/linux)
```bash ```bash
npm i -g clawdbot@latest npm i -g clawdbot@latest
clawdbot doctor clawdbot doctor
clawdbot gateway restart clawdbot daemon restart
clawdbot health clawdbot health
``` ```
+3 -3
View File
@@ -31,15 +31,15 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
- Install guide: [Getting Started](/start/getting-started) - Install guide: [Getting Started](/start/getting-started)
- Gateway runbook: [Gateway](/gateway) - Gateway runbook: [Gateway](/gateway)
- Gateway configuration: [Configuration](/gateway/configuration) - Gateway configuration: [Configuration](/gateway/configuration)
- Service status: `clawdbot gateway status` - Service status: `clawdbot daemon status`
## Gateway service install (CLI) ## Gateway service install (CLI)
Use one of these (all supported): Use one of these (all supported):
- Wizard (recommended): `clawdbot onboard --install-daemon` - Wizard (recommended): `clawdbot onboard --install-daemon`
- Direct: `clawdbot gateway install` - Direct: `clawdbot daemon install`
- Configure flow: `clawdbot configure` → select **Gateway service** - Configure flow: `clawdbot configure` → select **Gateway daemon**
- Repair/migrate: `clawdbot doctor` (offers to install or fix the service) - Repair/migrate: `clawdbot doctor` (offers to install or fix the service)
The service target depends on OS: The service target depends on OS:
+2 -2
View File
@@ -41,7 +41,7 @@ clawdbot onboard --install-daemon
Or: Or:
``` ```
clawdbot gateway install clawdbot daemon install
``` ```
Or: Or:
@@ -50,7 +50,7 @@ Or:
clawdbot configure clawdbot configure
``` ```
Select **Gateway service** when prompted. Select **Gateway daemon** when prompted.
Repair/migrate: Repair/migrate:
+1 -1
View File
@@ -34,7 +34,7 @@ Plist location (per‑user):
Manager: Manager:
- The macOS app owns LaunchAgent install/update in Local mode. - The macOS app owns LaunchAgent install/update in Local mode.
- The CLI can also install it: `clawdbot gateway install`. - The CLI can also install it: `clawdbot daemon install`.
Behavior: Behavior:
- “Clawdbot Active” enables/disables the LaunchAgent. - “Clawdbot Active” enables/disables the LaunchAgent.
+2 -2
View File
@@ -82,8 +82,8 @@ If the app crashes when you try to allow **Speech Recognition** or **Microphone*
If the gateway status stays on "Starting...", check if a zombie process is holding the port: If the gateway status stays on "Starting...", check if a zombie process is holding the port:
```bash ```bash
clawdbot gateway status clawdbot daemon status
clawdbot gateway stop clawdbot daemon stop
# If you’re not using a LaunchAgent (dev mode / manual runs), find the listener: # If you’re not using a LaunchAgent (dev mode / manual runs), find the listener:
lsof -nP -iTCP:18789 -sTCP:LISTEN lsof -nP -iTCP:18789 -sTCP:LISTEN
+7 -8
View File
@@ -24,23 +24,22 @@ This app now ships Sparkle auto-updates. Release builds must be Developer ID–s
Notes: Notes:
- `APP_BUILD` maps to `CFBundleVersion`/`sparkle:version`; keep it numeric + monotonic (no `-beta`), or Sparkle compares it as equal. - `APP_BUILD` maps to `CFBundleVersion`/`sparkle:version`; keep it numeric + monotonic (no `-beta`), or Sparkle compares it as equal.
- Defaults to the current architecture (`$(uname -m)`). For release/universal builds, set `BUILD_ARCHS="arm64 x86_64"` (or `BUILD_ARCHS=all`). - Defaults to the current architecture (`$(uname -m)`). For release/universal builds, set `BUILD_ARCHS="arm64 x86_64"` (or `BUILD_ARCHS=all`).
- Use `scripts/package-mac-dist.sh` for release artifacts (zip + DMG + notarization). Use `scripts/package-mac-app.sh` for local/dev packaging.
```bash ```bash
# 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.21 \ APP_VERSION=2026.1.20 \
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.21.zip ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.20.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.21.dmg scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.20.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:
@@ -48,26 +47,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.21.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.21 \ APP_VERSION=2026.1.20 \
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.21.dSYM.zip ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.20.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.21.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.20.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.21.zip` (and `Clawdbot-2026.1.21.dSYM.zip`) to the GitHub release for tag `v2026.1.21`. - Upload `Clawdbot-2026.1.20.zip` (and `Clawdbot-2026.1.20.dSYM.zip`) to the GitHub release for tag `v2026.1.20`.
- 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.
Loaded 100 of 551 files, more files were not shown because too many files have changed in this diff. Show more