Compare commits

..
Author SHA1 Message Date
Peter Steinberger d0a79c19f9 fix: use gpt-5-nano in tests (#444) (thanks @grp06) 2026-01-13 07:05:23 +00:00
75 changed files with 460 additions and 1875 deletions

No files matched your search

+119 -46
View File
@@ -1,56 +1,129 @@
# Changelog # Changelog
## 2025.1.12 (Unreleased) ## 2026.1.13-2
### Highlights ### Changes
- Memory: add vector search for agent memories (Markdown-only) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback.
- Plugins: restore full voice-call plugin parity (Telnyx/Twilio, streaming, inbound policies, tools/CLI).
- Models: add Synthetic provider plus Moonshot Kimi K2 0905 + turbo/thinking variants (with docs).
- Cron: one-shot schedules accept ISO timestamps (UTC) with optional delete-after-run; cron jobs can target a specific agent (CLI + macOS/Control UI).
- Agents: add compaction mode config with optional safeguard summarization and per-agent model fallbacks.
### New & Improved
- Memory: add custom OpenAI-compatible embedding endpoints; support OpenAI/local `node-llama-cpp` embeddings with per-agent overrides and provider metadata in tools/CLI.
- Memory: new `clawdbot memory` CLI plus `memory_search`/`memory_get` tools with snippets + line ranges; index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default.
- Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config.
- Tools/Sandbox: add tool profiles + group shorthands; support tool-policy groups in `tools.sandbox.tools`; drop legacy `memory` shorthand; allow Docker bind mounts via `docker.binds`.
- Tools: add browser `scrollintoview` action; allow Claude/Gemini tool param aliases; allow thinking `xhigh` for GPT-5.2/Codex with safe downgrades.
- Gateway/CLI: add Tailscale binary discovery, custom bind mode, and probe auth retry; add `clawdbot dashboard` auto-open flow; default native slash commands to `"auto"` with per-provider overrides.
- Auth/Onboarding: add Chutes OAuth (PKCE + refresh + onboarding choice); normalize API key inputs; default TUI onboarding to `deliver: false`.
- Providers: add `discord.allowBots`; trim legacy MiniMax M2 from default catalogs; route MiniMax vision to the Coding Plan VLM endpoint (also accepts `@/path/to/file.png` inputs).
- Gateway: allow Tailscale Serve identity headers to satisfy token auth; rebuild Control UI assets when protocol schema is newer.
- Heartbeat: default `ackMaxChars` to 300 so short `HEARTBEAT_OK` replies stay internal.
### Installer
- Install: run `clawdbot doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected.
### Fixes
- Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds.
- Models/Providers: treat credential validation failures as auth errors to trigger fallback; normalize `${ENV_VAR}` apiKey values and auto-fill missing provider keys; preserve explicit GitHub Copilot provider config + agent-dir auth profiles.
- Auth: drop invalid auth profiles from ordering so environment keys can still be used for providers like MiniMax.
- Gemini: normalize Gemini 3 ids to preview variants; strip Gemini CLI tool call/response ids; downgrade missing `thought_signature`; strip Claude `msg_*` thought_signature fields to avoid base64 decode errors.
- Agents: auto-recover from compaction context overflow by resetting the session and retrying; propagate overflow details from embedded runs so callers can recover.
- MiniMax: strip malformed tool invocation XML; include `MiniMax-VL-01` in implicit provider for image pairing.
- Onboarding/Auth: honor `CLAWDBOT_AGENT_DIR` / `PI_CODING_AGENT_DIR` when writing auth profiles (MiniMax). (#829) — thanks @roshanasingh4.
- Anthropic: handle `overloaded_error` with a friendly message and failover classification. (#832) — thanks @danielz1z.
- Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid incorrect role errors.
- Messaging: enforce context isolation for message tool sends; keep typing indicators alive during tool execution.
- Auto-reply: `/status` allowlist behavior, reasoning-tag enforcement on fallback, and system-event enqueueing for elevated/reasoning toggles.
- Auto-reply: resolve ambiguous `/model` matches; fix streaming block reply media handling; keep >300 char heartbeat replies instead of dropping.
- Discord/Slack: centralize reply-thread planning; fix autoThread routing + add per-channel autoThread; avoid duplicate listeners; keep reasoning italics intact; allow clearing channel parents via message tool.
- Telegram: preserve forum topic thread ids, persist polling offsets, respect account bindings in webhook mode, and show typing indicator in General topics.
- Slack: accept slash commands with or without leading `/` for custom command configs.
- Cron: persist disabled jobs correctly; accept `jobId` aliases for update/run/remove params.
- Gateway/CLI: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides; `agents.list` respects explicit config; reduce noisy loopback WS logs during tests; run `clawdbot doctor --non-interactive` during updates.
- Onboarding/Control UI: refuse invalid configs (run doctor first); quote Windows browser URLs for OAuth; keep chat scroll position unless the user is near the bottom.
- Tools/UI: harden tool input schemas for strict providers; drop null-only union variants for Gemini schema cleanup; treat `maxChars: 0` as unlimited; keep TUI last streamed response instead of "(no output)".
- Connections UI: polish multi-account account cards.
### Maintenance
- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai. - Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai.
- Testing: update Vitest + browser-playwright to 4.0.17. - Testing: update Vitest + browser-playwright to 4.0.17.
- Docs: add Amazon Bedrock provider notes and link from models/FAQ. - Docs: add Amazon Bedrock provider notes and link from models/FAQ.
## 2026.1.13-1
### Changes
- Cron: accept ISO timestamps for one-shot schedules (UTC) and allow optional delete-after-run; wired into CLI + macOS editor.
- Gateway: add Tailscale binary discovery, custom bind mode, and probe auth retry for password changes. (#740 — thanks @jeffersonwarrior)
- Agents: add compaction mode config with optional safeguard summarization for long histories. (#700 — thanks @thewilloftheshadow)
- Agents: support per-agent model fallbacks via `agents.list[].model`. (#583 — thanks @mitschabaude-bot)
- Tools: add tool profiles plus group shorthands for tool policy allow/deny (global, per-agent, sandbox).
- Thinking: allow xhigh for GPT-5.2 + Codex models and downgrade on unsupported switches. (#444 — thanks @grp06)
### Fixes
- Gateway: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides when checking or restarting the daemon.
- Tools: harden tool input schemas for strict providers (no unions; flattened actions; enum helpers + tests).
- Discord: allow clearing channel parents via message tool without nullable schema inputs.
## 2026.1.12-4
### Changes
- Models/Moonshot: add Kimi K2 0905 + turbo/thinking variants to the preset + docs. (#818 — thanks @mickahouan)
- Memory: allow custom OpenAI-compatible embedding endpoints for memory search (remote baseUrl/apiKey/headers). (#819 — thanks @mukhtharcm)
- Auth: add Chutes OAuth (PKCE + refresh + onboarding choice). (#726 — thanks @FrieSei)
- Agents: make workspace bootstrap truncation configurable (default 20k) and warn when files are truncated.
### Fixes
- Typing: keep typing indicators alive during tool execution. (#450, #447 — thanks @thewilloftheshadow)
- Cron: coerce enabled patches so disabling jobs persists correctly. (#205 — thanks @thewilloftheshadow)
- Control UI: keep chat scroll position unless user is near the bottom. (#217 — thanks @thewilloftheshadow)
- Fallback: treat credential validation failures ("no credentials found", "no API key found") as auth errors that trigger model fallback. (#822 — thanks @sebslight)
- Telegram: preserve forum topic thread ids, including General topic replies. (#727 — thanks @thewilloftheshadow)
- Telegram: persist polling update offsets across restarts to avoid duplicate updates. (#739 — thanks @thewilloftheshadow)
- Discord: avoid duplicate message/reaction listeners on monitor reloads. (#744 — thanks @thewilloftheshadow)
- System events: include local timestamps when events are injected into prompts. (#245 — thanks @thewilloftheshadow)
- Cron: accept `jobId` aliases for cron update/run/remove params in gateway validation. (#252 — thanks @thewilloftheshadow)
- Models/Google: normalize Gemini 3 model ids to preview variants before runtime selection. (#795 — thanks @thewilloftheshadow)
- Models/Google: strip Gemini CLI tool call/response ids in patched provider handling. (#783 — thanks @ananth-vardhan-cn)
- TUI: keep the last streamed response instead of replacing it with "(no output)". (#747 — thanks @thewilloftheshadow)
- Slack: accept slash commands with or without leading `/` for custom command configs. (#798 — thanks @thewilloftheshadow)
- Onboarding/Configure: refuse to proceed with invalid configs; run `clawdbot doctor` first to avoid wiping custom fields. (#764 — thanks @mukhtharcm)
- Onboarding: quote Windows browser URLs when launching via `cmd start` to preserve OAuth query params. (#794 — thanks @roshanasingh4)
- Gateway/Auth: allow Tailscale Serve identity headers to satisfy token auth when `allowTailscale` is enabled. (#823 — thanks @roshanasingh4)
- Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid “Incorrect role information” errors. (#804 — thanks @ThomsenDrake)
- Discord/Slack: centralize reply-thread planning so auto-thread replies stay in the created thread without parent reply refs.
- Telegram: respect account-scoped bindings when webhook mode is enabled. (#821 — thanks @gumadeiras)
- Update: run `clawdbot doctor --non-interactive` during updates to avoid TTY hangs. (#781 — thanks @ronyrus)
- Browser tools: treat explicit `maxChars: 0` as unlimited while keeping the default limit only when omitted. (#796 — thanks @gabriel-trigo)
- Tools: allow Claude/Gemini tool param aliases (`file_path`, `old_string`, `new_string`) while enforcing required params at runtime. (#793 — thanks @hsrvc)
- Gemini: downgrade tool-call history missing `thought_signature` to avoid INVALID_ARGUMENT errors. (#793 — thanks @hsrvc)
- Messaging: enforce context isolation for message tool sends across providers (normalized targets + tests). (#793 — thanks @hsrvc)
- Auto-reply: re-evaluate reasoning tag enforcement on fallback providers to prevent leaked reasoning. (#810 — thanks @mcinteerj)
- Tools/Gemini: drop null-only union variants while cleaning tool schemas to avoid Cloud Code Assist schema errors. (#782 — thanks @AbhisekBasu1)
- Connections UI: polish multi-account account cards in the Connections view. (#816 — thanks @steipete)
- Gemini: strip Claude `msg_*` thought_signature fields from session history to avoid base64 decode errors. (#805 — thanks @marcmarg)
## 2026.1.12-3
### Changes
- Sandbox: drop legacy `memory` tool-policy shorthand; require explicit `group:memory`.
### Fixes
- Telegram: tolerate mocked bots missing native-command APIs (`setMyCommands`, `command`) during tests.
- Auto-reply: fix streaming block reply media handling (no redeclared/use-before-declare vars).
## 2026.1.12-2
### Changes
- Subagents: add config to set default sub-agent model (`agents.defaults.subagents.model` + per-agent override); still overridden by `sessions_spawn.model`.
- Plugins: restore full voice-call plugin parity (Telnyx/Twilio, streaming, inbound policies, tools/CLI).
- Sandbox: support tool-policy groups in `tools.sandbox.tools` (e.g. `group:memory`, `group:fs`) to reduce config churn.
### Fixes
- Models/MiniMax: strip malformed tool invocation XML (`<invoke>...</invoke>` and `</minimax:tool_call>`) from assistant text to prevent tool call leaks into user messages. (#809 — thanks @latitudeki5223)
- Tools/Models: MiniMax vision now uses the Coding Plan VLM endpoint (`/v1/coding_plan/vlm`) so the `image` tool works with MiniMax keys (also accepts `@/path/to/file.png`-style inputs).
- Gateway/macOS: reduce noisy loopback WS "closed before connect" logs during tests.
- Auto-reply: resolve ambiguous `/model` fuzzy matches by picking the best candidate instead of erroring.
## 2026.1.12-1
### Changes
- Heartbeat: raise default `ackMaxChars` to 300 so any `HEARTBEAT_OK` replies with short padding stay internal (fewer noisy heartbeat posts on providers).
- Onboarding: normalize API key inputs (strip `export KEY=...` wrappers) so shell-style entries paste cleanly.
## 2026.1.12 (Unreleased)
### Highlights
- Memory: add vector search for agent memories (Markdown-only scope) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback.
### Changes
- Agents: strengthen memory recall guidance (memory_search mandatory for past work/preferences; system prompt injects conditional recall section; memory_get now described as safe snippet fetch).
- Browser: add `scrollintoview` action to scroll refs into view before click/type.
- Memory: embedding providers support OpenAI or local `node-llama-cpp`; config adds defaults + per-agent overrides, provider/fallback metadata surfaced in tools/CLI.
- CLI/Tools: new `clawdbot memory` commands plus `memory_search`/`memory_get` tools returning snippets + line ranges and provider info.
- Runtime: memory index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default; inline status replies now stay auth-gated while inline prompts continue to the agent.
- Doctor: rebuild Control UI assets when protocol schema is newer to avoid stale UI connect errors. (#786) — thanks @meaningfool.
- Cron: allow jobs to target a specific agent and expose agent selection in the macOS app + Control UI.
- Discord: add `discord.allowBots` to permit bot-authored messages (still ignores its own messages) with docs warning about bot loops. (#802) — thanks @zknicker.
- CLI/Onboarding: `clawdbot dashboard` prints/copies the tokenized Control UI link and opens it; onboarding now auto-opens the dashboard with your token and keeps the link in the summary.
- Commands: native slash commands now default to `"auto"` (on for Discord/Telegram, off for Slack) with per-provider overrides (`discord/telegram/slack.commands.native`) and docs updated.
- Sandbox: allow Docker bind mounts via `docker.binds`; merges global + per-agent binds (per-agent ignored under shared scope) for custom host paths. (#790 — thanks @akonyer)
- Models: add Synthetic provider (Anthropic-compatible) and trim legacy MiniMax M2 from default catalogs. (#811 — thanks @siraht)
### Fixes
- Auto-reply: inline `/status` now honors allowlists (authorized stripped + replied inline; unauthorized leaves text for the agent) to match command gating tests.
- Models: normalize `${ENV_VAR}` apiKey config values and auto-fill missing provider `apiKey` from env/auth when custom provider models are configured (fixes MiniMax “Unknown model” on fresh installs).
- Models/Tools: include `MiniMax-VL-01` in implicit MiniMax provider so image pairing uses a real vision model.
- Telegram: show typing indicator in General forum topics. (#779) — thanks @azade-c.
- Discord: keep reasoning italics intact when messages are chunked, so reasoning stays italic across multi-part sends.
- Models: keep explicit GitHub Copilot provider config and honor agent-dir auth profiles for auto-injection. (#705) — thanks @TAGOOZ.
- Auto-reply: restore 300-char heartbeat ack limit and keep >300 char replies instead of dropping them; adjust long heartbeat test content accordingly.
- Gateway: `agents.list` now honors explicit `agents.list` config without pulling stray agents from disk; GitHub Copilot CLI auth path uses the updated provider build.
- Google: apply patched pi-ai `google-gemini-cli` function call handling (strips ids) after upgrading to pi-ai 0.43.0.
- Tools: allow Gemini/Claude-style aliases (`file_path`, `old_string`, `new_string`) in read/write/edit schemas while still enforcing required params at runtime to avoid validation loops.
- Auto-reply: elevated/reasoning toggles now enqueue system events so the model sees the mode change immediately.
- Tools: keep `image` available in sandbox and fail over when image models return empty output (fixes “(no text returned)”).
- Discord: add per-channel `autoThread` to auto-create threads for top-level messages. (#800) — thanks @davidguttman.
- Discord: fix autoThread routing so replies stay in the created thread and avoid reply references. (#807) — thanks @davidguttman.
- Onboarding: TUI defaults to `deliver: false` to avoid cross-provider auto-delivery leaks; onboarding spawns the TUI with explicit `deliver: false`. (#791 — thanks @roshanasingh4)
## 2026.1.11 ## 2026.1.11
### Highlights ### Highlights
+15 -15
View File
@@ -467,21 +467,21 @@ Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
Thanks to all clawtributors: Thanks to all clawtributors:
<p align="left"> <p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/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/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a>
<a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/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/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/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/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/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/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/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/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/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/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
<a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="Sebastian Barrios" title="Sebastian Barrios"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/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/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="Sebastian Barrios" title="Sebastian Barrios"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/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/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/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/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/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/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/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a>
<a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a>
<a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/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/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/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/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/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/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/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/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/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/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/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/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/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/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a>
<a href="https://github.com/search?q=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/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/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/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/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/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/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/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/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/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/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a>
<a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/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/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/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/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=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
<a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/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/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/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/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/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/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/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/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a>
<a href="https://github.com/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/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a>
<a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/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/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a>
<a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/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/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/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/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p> </p>
+3 -3
View File
@@ -257,12 +257,12 @@ Options:
- `--tailscale-reset-on-exit` - `--tailscale-reset-on-exit`
- `--install-daemon` - `--install-daemon`
- `--no-install-daemon` (alias: `--skip-daemon`) - `--no-install-daemon` (alias: `--skip-daemon`)
- `--daemon-runtime <node|bun>` (bun not recommended for WhatsApp/Telegram) - `--daemon-runtime <node|bun>`
- `--skip-providers` - `--skip-providers`
- `--skip-skills` - `--skip-skills`
- `--skip-health` - `--skip-health`
- `--skip-ui` - `--skip-ui`
- `--node-manager <npm|pnpm|bun>` (pnpm recommended; bun not recommended for Gateway runtime) - `--node-manager <npm|pnpm|bun>`
- `--json` - `--json`
### `configure` / `config` ### `configure` / `config`
@@ -549,7 +549,7 @@ Notes:
- `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting. - `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting.
- `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". - `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".
- `daemon status` prints which config path the CLI uses vs which config the daemon 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.
- `daemon install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs). - `daemon install` defaults to Node runtime; use `--runtime bun` only when WhatsApp is disabled.
- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`. - `daemon install` options: `--port`, `--runtime`, `--token`, `--force`.
### `logs` ### `logs`
+2 -2
View File
@@ -9,7 +9,7 @@ read_when:
Safely update a **source checkout** (git install) of Clawdbot. Safely update a **source checkout** (git install) of Clawdbot.
If you installed via **npm/pnpm** (global install, no git metadata), use the package manager flow in [Updating](/install/updating). If you installed via **npm/pnpm/bun** (global install, no git metadata), use the package manager flow in [Updating](/install/updating).
## Usage ## Usage
@@ -32,7 +32,7 @@ High-level:
1. Requires a clean worktree (no uncommitted changes). 1. Requires a clean worktree (no uncommitted changes).
2. Fetches and rebases against `@{upstream}`. 2. Fetches and rebases against `@{upstream}`.
3. Installs deps (pnpm preferred; npm fallback). 3. Installs deps (pnpm/bun/npm depending on the checkout).
4. Builds + builds the Control UI. 4. Builds + builds the Control UI.
5. Runs `clawdbot doctor` as the final “safe update” check. 5. Runs `clawdbot doctor` as the final “safe update” check.
-3
View File
@@ -92,9 +92,6 @@ This maps to:
`*.blockStreaming` is explicitly set to `true`. Telegram can stream drafts `*.blockStreaming` is explicitly set to `true`. Telegram can stream drafts
(`telegram.streamMode`) without block replies. (`telegram.streamMode`) without block replies.
Config location reminder: the `blockStreaming*` defaults live under
`agents.defaults`, not the root config.
## Telegram draft streaming (token-ish) ## Telegram draft streaming (token-ish)
Telegram is the only provider with draft streaming: Telegram is the only provider with draft streaming:
+1 -26
View File
@@ -746,10 +746,8 @@ Controls how chat commands are enabled across connectors.
```json5 ```json5
{ {
commands: { commands: {
native: "auto", // register native commands when supported (auto) native: false, // register native commands when supported
text: true, // parse slash commands in chat messages text: true, // parse slash commands in chat messages
bash: false, // allow ! (alias: /bash) (host-only; requires tools.elevated allowlists)
bashForegroundMs: 2000, // bash foreground window (0 backgrounds immediately)
config: false, // allow /config (writes to disk) config: false, // allow /config (writes to disk)
debug: false, // allow /debug (runtime-only overrides) debug: false, // allow /debug (runtime-only overrides)
restart: false, // allow /restart + gateway restart tool restart: false, // allow /restart + gateway restart tool
@@ -763,8 +761,6 @@ Notes:
- `commands.text: false` disables parsing chat messages for commands. - `commands.text: false` disables parsing chat messages for commands.
- `commands.native: "auto"` (default) turns on native commands for Discord/Telegram and leaves Slack off; unsupported providers stay text-only. - `commands.native: "auto"` (default) turns on native commands for Discord/Telegram and leaves Slack off; unsupported providers stay text-only.
- Set `commands.native: true|false` to force all, or override per provider with `discord.commands.native`, `telegram.commands.native`, `slack.commands.native` (bool or `"auto"`). `false` clears previously registered commands on Discord/Telegram at startup; Slack commands are managed in the Slack app. - Set `commands.native: true|false` to force all, or override per provider with `discord.commands.native`, `telegram.commands.native`, `slack.commands.native` (bool or `"auto"`). `false` clears previously registered commands on Discord/Telegram at startup; Slack commands are managed in the Slack app.
- `commands.bash: true` enables `! <cmd>` to run host shell commands (`/bash <cmd>` also works as an alias). Requires `tools.elevated.enabled` and allowlisting the sender in `tools.elevated.allowFrom.<provider>`.
- `commands.bashForegroundMs` controls how long bash waits before backgrounding. While a bash job is running, new `! <cmd>` requests are rejected (one at a time).
- `commands.config: true` enables `/config` (reads/writes `clawdbot.json`). - `commands.config: true` enables `/config` (reads/writes `clawdbot.json`).
- `commands.debug: true` enables `/debug` (runtime-only overrides). - `commands.debug: true` enables `/debug` (runtime-only overrides).
- `commands.restart: true` enables `/restart` and the gateway tool restart action. - `commands.restart: true` enables `/restart` and the gateway tool restart action.
@@ -1217,27 +1213,6 @@ is already present in `agents.defaults.models`:
If you configure the same alias name (case-insensitive) yourself, your value wins (defaults never override). If you configure the same alias name (case-insensitive) yourself, your value wins (defaults never override).
Example: Opus 4.5 primary with MiniMax M2.1 fallback (hosted MiniMax):
```json5
{
agents: {
defaults: {
models: {
"anthropic/claude-opus-4-5": { alias: "opus" },
"minimax/MiniMax-M2.1": { alias: "minimax" }
},
model: {
primary: "anthropic/claude-opus-4-5",
fallbacks: ["minimax/MiniMax-M2.1"]
}
}
}
}
```
MiniMax auth: set `MINIMAX_API_KEY` (env) or configure `models.providers.minimax`.
#### `agents.defaults.cliBackends` (CLI fallback) #### `agents.defaults.cliBackends` (CLI fallback)
Optional CLI backends for text-only fallback runs (no tool calls). These are useful as a Optional CLI backends for text-only fallback runs (no tool calls). These are useful as a
-1
View File
@@ -76,7 +76,6 @@ cat ~/.clawdbot/clawdbot.json
- Security warnings for open DM policies. - Security warnings for open DM policies.
- Gateway auth warnings when no `gateway.auth.token` is set (local mode; offers token generation). - Gateway auth warnings when no `gateway.auth.token` is set (local mode; offers token generation).
- systemd linger check on Linux. - systemd linger check on Linux.
- Source install checks (pnpm workspace mismatch, missing UI assets, missing tsx binary).
- Writes updated config + wizard metadata. - Writes updated config + wizard metadata.
## Detailed behavior and rationale ## Detailed behavior and rationale
-2
View File
@@ -73,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.
- 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.
## Tailscale prerequisites + limits ## Tailscale prerequisites + limits
+3 -6
View File
@@ -1,16 +1,13 @@
--- ---
summary: "Bun workflow (experimental): installs, patches, and gotchas vs pnpm" summary: "Bun workflow (preferred): installs, patches, and gotchas vs pnpm"
read_when: read_when:
- You want the fastest local dev loop (bun + watch) - You want the fastest local dev loop (bun + watch)
- You hit Bun install/patch/lifecycle script issues - You hit Bun install/patch/lifecycle script issues
--- ---
# Bun (experimental) # Bun
Goal: run this repo with **Bun** (optional, not recommended for WhatsApp/Telegram) Goal: run this repo with **Bun** (optional) without losing pnpm patch behavior.
without losing pnpm patch behavior.
⚠️ **Not recommended for Gateway runtime** (WhatsApp/Telegram bugs). Use Node for production.
## Status ## Status
-1
View File
@@ -30,7 +30,6 @@ What it does (high level):
- `git`: clone/build a source checkout and install a wrapper script - `git`: clone/build a source checkout and install a wrapper script
- On Linux: avoid global npm permission errors by switching npm’s prefix to `~/.npm-global` when needed. - On Linux: avoid global npm permission errors by switching npm’s prefix to `~/.npm-global` when needed.
- If upgrading an existing install: runs `clawdbot doctor --non-interactive` (best effort). - If upgrading an existing install: runs `clawdbot doctor --non-interactive` (best effort).
- For git installs: runs `clawdbot doctor --non-interactive` after install/update (best effort).
- Mitigates `sharp` native install gotchas by defaulting `SHARP_IGNORE_GLOBAL_LIBVIPS=1` (avoids building against system libvips). - Mitigates `sharp` native install gotchas by defaulting `SHARP_IGNORE_GLOBAL_LIBVIPS=1` (avoids building against system libvips).
If you *want* `sharp` to link against a globally-installed libvips (or you’re debugging), set: If you *want* `sharp` to link against a globally-installed libvips (or you’re debugging), set:
+11 -23
View File
@@ -9,28 +9,9 @@ read_when:
Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart → verify. Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart → verify.
## Recommended: re-run the website installer (upgrade in place)
The **preferred** update path is to re-run the installer from the website. It
detects existing installs, upgrades in place, and runs `clawdbot doctor` when
needed.
```bash
curl -fsSL https://clawd.bot/install.sh | bash
```
Notes:
- Add `--no-onboard` if you don’t want the onboarding wizard to run again.
- For **source installs**, use:
```bash
curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git --no-onboard
```
The installer will `git pull --rebase` **only** if the repo is clean.
- For **global installs**, the script uses `npm install -g clawdbot@latest` under the hood.
## Before you update ## Before you update
- Know how you installed: **global** (npm/pnpm) vs **from source** (git clone). - Know how you installed: **global** (npm/pnpm/bun) vs **from source** (git clone).
- Know how your Gateway is running: **foreground terminal** vs **supervised service** (launchd/systemd). - Know how your Gateway is running: **foreground terminal** vs **supervised service** (launchd/systemd).
- Snapshot your tailoring: - Snapshot your tailoring:
- Config: `~/.clawdbot/clawdbot.json` - Config: `~/.clawdbot/clawdbot.json`
@@ -48,7 +29,10 @@ npm i -g clawdbot@latest
```bash ```bash
pnpm add -g clawdbot@latest pnpm add -g clawdbot@latest
``` ```
We do **not** recommend Bun for the Gateway runtime (WhatsApp/Telegram bugs).
```bash
bun add -g clawdbot@latest
```
Then: Then:
@@ -75,7 +59,7 @@ It runs a safe-ish update flow:
- Fetches + rebases against the configured upstream. - Fetches + rebases against the configured upstream.
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`. - Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will skip. Use “Update (global install)” instead. If you installed via **npm/pnpm/bun** (no git metadata), `clawdbot update` will skip. Use “Update (global install)” instead.
## Update (Control UI / RPC) ## Update (Control UI / RPC)
@@ -109,7 +93,7 @@ pnpm clawdbot health
Notes: Notes:
- `pnpm build` matters when you run the packaged `clawdbot` binary ([`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js)) or use Node to run `dist/`. - `pnpm build` matters when you run the packaged `clawdbot` binary ([`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js)) or use Node to run `dist/`.
- If you run directly from TypeScript (`pnpm clawdbot ...`), a rebuild is usually unnecessary, but **config migrations still apply** → run doctor. - If you run directly from TypeScript (`pnpm clawdbot ...` / `bun run clawdbot ...`), a rebuild is usually unnecessary, but **config migrations still apply** → run doctor.
- Switching between global and git installs is easy: install the other flavor, then run `clawdbot doctor` so the gateway service entrypoint is rewritten to the current install. - Switching between global and git installs is easy: install the other flavor, then run `clawdbot doctor` so the gateway service entrypoint is rewritten to the current install.
## Always run: `clawdbot doctor` ## Always run: `clawdbot doctor`
@@ -161,6 +145,10 @@ npm i -g clawdbot@<version>
pnpm add -g clawdbot@<version> pnpm add -g clawdbot@<version>
``` ```
```bash
bun add -g clawdbot@<version>
```
Tip: to see the current published version, run `npm view clawdbot version`. Tip: to see the current published version, run `npm view clawdbot version`.
Then restart + re-run doctor: Then restart + re-run doctor:
+1 -2
View File
@@ -6,8 +6,7 @@ read_when:
--- ---
# Platforms # Platforms
Clawdbot core is written in TypeScript. **Node is the recommended runtime**. Clawdbot core is written in TypeScript, so the CLI + Gateway run anywhere Node or Bun runs.
Bun is not recommended for the Gateway (WhatsApp/Telegram bugs).
Companion apps exist for macOS (menu bar app) and mobile nodes (iOS/Android). Windows and Companion apps exist for macOS (menu bar app) and mobile nodes (iOS/Android). Windows and
Linux companion apps are planned, but the Gateway is fully supported today. Linux companion apps are planned, but the Gateway is fully supported today.
+2 -3
View File
@@ -6,8 +6,7 @@ read_when:
--- ---
# Linux App # Linux App
The Gateway is fully supported on Linux. **Node is the recommended runtime**. The Gateway is fully supported on Linux. The core is written in TypeScript, so it runs anywhere Node or Bun runs.
Bun is not recommended for the Gateway (WhatsApp/Telegram bugs).
Native Linux companion apps are planned. Contributions are welcome if you want to help build one. Native Linux companion apps are planned. Contributions are welcome if you want to help build one.
@@ -24,7 +23,7 @@ Step-by-step VPS guide: [exe.dev](/platforms/exe-dev)
## Install ## Install
- [Getting Started](/start/getting-started) - [Getting Started](/start/getting-started)
- [Install & updates](/install/updating) - [Install & updates](/install/updating)
- Optional flows: [Bun (experimental)](/install/bun), [Nix](/install/nix), [Docker](/install/docker) - Optional flows: [Bun](/install/bun), [Nix](/install/nix), [Docker](/install/docker)
## Gateway ## Gateway
- [Gateway runbook](/gateway) - [Gateway runbook](/gateway)
+1 -1
View File
@@ -20,7 +20,7 @@ You need Node 22+ on the Mac, then install `clawdbot` globally:
npm install -g clawdbot@<version> npm install -g clawdbot@<version>
``` ```
The macOS app’s **Install CLI** button runs the same flow via npm/pnpm (bun not recommended for Gateway runtime). The macOS app’s **Install CLI** button runs the same flow via npm/pnpm/bun.
## Launchd (Gateway as LaunchAgent) ## Launchd (Gateway as LaunchAgent)
+1 -1
View File
@@ -18,7 +18,7 @@ node.
- Runs or connects to the Gateway (local or remote). - Runs or connects to the Gateway (local or remote).
- Exposes macOS‑only tools (Canvas, Camera, Screen Recording, `system.run`). - Exposes macOS‑only tools (Canvas, Camera, Screen Recording, `system.run`).
- Optionally hosts **PeekabooBridge** for UI automation. - Optionally hosts **PeekabooBridge** for UI automation.
- Installs the global CLI (`clawdbot`) via npm/pnpm on request (bun not recommended for the Gateway runtime). - Installs the global CLI (`clawdbot`) via npm/pnpm/bun on request.
## Local vs remote mode ## Local vs remote mode
-3
View File
@@ -174,9 +174,6 @@ Notes:
- The bot lacks channel permissions (View/Send/Read History), or - The bot lacks channel permissions (View/Send/Read History), or
- Your config requires mentions and you didn’t mention it, or - Your config requires mentions and you didn’t mention it, or
- Your guild/channel allowlist denies the channel/user. - Your guild/channel allowlist denies the channel/user.
- **`requireMention: false` but still no replies**:
- `discord.groupPolicy` defaults to **allowlist**; you must either set it to `"open"` or explicitly list the channel under `discord.guilds.<id>.channels`.
- `requireMention` must live under `discord.guilds` (or a specific channel). `discord.requireMention` at the top level is ignored.
- **Permission audits** (`providers status --probe`) only check numeric channel IDs. If you use slugs/names as `discord.guilds.*.channels` keys, the audit can’t verify permissions. - **Permission audits** (`providers status --probe`) only check numeric channel IDs. If you use slugs/names as `discord.guilds.*.channels` keys, the audit can’t verify permissions.
- **DMs don’t work**: `discord.dm.enabled=false`, `discord.dm.policy="disabled"`, or you haven’t been approved yet (`discord.dm.policy="pairing"`). - **DMs don’t work**: `discord.dm.enabled=false`, `discord.dm.policy="disabled"`, or you haven’t been approved yet (`discord.dm.policy="pairing"`).
-44
View File
@@ -72,28 +72,6 @@ Configure via CLI:
} }
``` ```
### MiniMax M2.1 as fallback (Opus primary)
**Best for:** keep Opus 4.5 as primary, fail over to MiniMax M2.1.
```json5
{
env: { MINIMAX_API_KEY: "sk-..." },
agents: {
defaults: {
models: {
"anthropic/claude-opus-4-5": { alias: "opus" },
"minimax/MiniMax-M2.1": { alias: "minimax" }
},
model: {
primary: "anthropic/claude-opus-4-5",
fallbacks: ["minimax/MiniMax-M2.1"]
}
}
}
}
```
### Optional: Local via LM Studio (manual) ### Optional: Local via LM Studio (manual)
**Best for:** local inference with LM Studio. **Best for:** local inference with LM Studio.
@@ -156,27 +134,5 @@ Use the interactive config wizard to set MiniMax without editing JSON:
- Model refs are `minimax/<model>`. - Model refs are `minimax/<model>`.
- Update pricing values in `models.json` if you need exact cost tracking. - Update pricing values in `models.json` if you need exact cost tracking.
- Referral link for MiniMax Coding Plan (10% off): https://platform.minimax.io/subscribe/coding-plan?code=DbXJTRClnb&source=link
- See [/concepts/model-providers](/concepts/model-providers) for provider rules. - See [/concepts/model-providers](/concepts/model-providers) for provider rules.
- Use `clawdbot models list` and `clawdbot models set minimax/MiniMax-M2.1` to switch. - Use `clawdbot models list` and `clawdbot models set minimax/MiniMax-M2.1` to switch.
## Troubleshooting
### “Unknown model: minimax/MiniMax-M2.1”
This usually means the **MiniMax provider isn’t configured** (no provider entry
and no MiniMax auth profile/env key found). A fix for this detection is in
**2026.1.12** (unreleased at the time of writing). Fix by:
- Upgrading to **2026.1.12** (or run from source `main`), then restarting the gateway.
- Running `clawdbot configure` and selecting **MiniMax M2.1**, or
- Adding the `models.providers.minimax` block manually, or
- Setting `MINIMAX_API_KEY` (or a MiniMax auth profile) so the provider can be injected.
Make sure the model id is **case‑sensitive**:
- `minimax/MiniMax-M2.1`
- `minimax/MiniMax-M2.1-lightning`
Then recheck with:
```bash
clawdbot models list
```
+1 -2
View File
@@ -310,5 +310,4 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
- Fix: `clawdbot doctor` (or restart the gateway). If it persists, relink via `providers login` and inspect `clawdbot logs --follow`. - Fix: `clawdbot doctor` (or restart the gateway). If it persists, relink via `providers login` and inspect `clawdbot logs --follow`.
**Bun runtime** **Bun runtime**
- Bun is **not recommended**. WhatsApp (Baileys) and Telegram are unreliable on Bun. - WhatsApp uses Baileys; run the gateway with **Node** for WhatsApp. (See Getting Started runtime note.)
Run the gateway with **Node**. (See Getting Started runtime note.)
+87 -464
View File
@@ -7,120 +7,87 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
## Table of contents ## Table of contents
- [What is Clawdbot?](#what-is-clawdbot) - [What is Clawdbot, in one paragraph?](#what-is-clawdbot-in-one-paragraph)
- [What is Clawdbot, in one paragraph?](#what-is-clawdbot-in-one-paragraph) - [What’s the recommended way to install and set up Clawdbot?](#whats-the-recommended-way-to-install-and-set-up-clawdbot)
- [Quick start and first-run setup](#quick-start-and-first-run-setup) - [How do I open the dashboard after onboarding?](#how-do-i-open-the-dashboard-after-onboarding)
- [What’s the recommended way to install and set up Clawdbot?](#whats-the-recommended-way-to-install-and-set-up-clawdbot) - [How do I authenticate the dashboard (token) on localhost vs remote?](#how-do-i-authenticate-the-dashboard-token-on-localhost-vs-remote)
- [How do I open the dashboard after onboarding?](#how-do-i-open-the-dashboard-after-onboarding) - [What runtime do I need?](#what-runtime-do-i-need)
- [How do I authenticate the dashboard (token) on localhost vs remote?](#how-do-i-authenticate-the-dashboard-token-on-localhost-vs-remote) - [What does the onboarding wizard actually do?](#what-does-the-onboarding-wizard-actually-do)
- [What runtime do I need?](#what-runtime-do-i-need) - [How does Anthropic "setup-token" auth work?](#how-does-anthropic-setup-token-auth-work)
- [What does the onboarding wizard actually do?](#what-does-the-onboarding-wizard-actually-do) - [Do you support Claude subscription auth (Claude Code OAuth)?](#do-you-support-claude-subscription-auth-claude-code-oauth)
- [How does Anthropic "setup-token" auth work?](#how-does-anthropic-setup-token-auth-work) - [Is AWS Bedrock supported?](#is-aws-bedrock-supported)
- [Do you support Claude subscription auth (Claude Code OAuth)?](#do-you-support-claude-subscription-auth-claude-code-oauth) - [How does Codex auth work?](#how-does-codex-auth-work)
- [Is AWS Bedrock supported?](#is-aws-bedrock-supported) - [Is a local model OK for casual chats?](#is-a-local-model-ok-for-casual-chats)
- [How does Codex auth work?](#how-does-codex-auth-work) - [How do I keep hosted model traffic in a specific region?](#how-do-i-keep-hosted-model-traffic-in-a-specific-region)
- [Is a local model OK for casual chats?](#is-a-local-model-ok-for-casual-chats) - [Can I use Bun?](#can-i-use-bun)
- [How do I keep hosted model traffic in a specific region?](#how-do-i-keep-hosted-model-traffic-in-a-specific-region) - [Telegram: what goes in `allowFrom`?](#telegram-what-goes-in-allowfrom)
- [Can I use Bun?](#can-i-use-bun) - [Can multiple people use one WhatsApp number with different Clawdbots?](#can-multiple-people-use-one-whatsapp-number-with-different-clawdbots)
- [Telegram: what goes in `allowFrom`?](#telegram-what-goes-in-allowfrom) - [Can I run a "fast chat" agent and an "Opus for coding" agent?](#can-i-run-a-fast-chat-agent-and-an-opus-for-coding-agent)
- [Can multiple people use one WhatsApp number with different Clawdbots?](#can-multiple-people-use-one-whatsapp-number-with-different-clawdbots) - [Does Homebrew work on Linux?](#does-homebrew-work-on-linux)
- [Can I run a "fast chat" agent and an "Opus for coding" agent?](#can-i-run-a-fast-chat-agent-and-an-opus-for-coding-agent) - [Can I switch between npm and git installs later?](#can-i-switch-between-npm-and-git-installs-later)
- [Does Homebrew work on Linux?](#does-homebrew-work-on-linux) - [How do I customize skills without keeping the repo dirty?](#how-do-i-customize-skills-without-keeping-the-repo-dirty)
- [Can I switch between npm and git installs later?](#can-i-switch-between-npm-and-git-installs-later) - [Can I load skills from a custom folder?](#can-i-load-skills-from-a-custom-folder)
- [Should I run the Gateway on my laptop or a VPS?](#should-i-run-the-gateway-on-my-laptop-or-a-vps) - [How can I use different models for different tasks?](#how-can-i-use-different-models-for-different-tasks)
- [Skills and automation](#skills-and-automation) - [How do I install skills on Linux?](#how-do-i-install-skills-on-linux)
- [How do I customize skills without keeping the repo dirty?](#how-do-i-customize-skills-without-keeping-the-repo-dirty) - [Is there a dedicated sandboxing doc?](#is-there-a-dedicated-sandboxing-doc)
- [Can I load skills from a custom folder?](#can-i-load-skills-from-a-custom-folder) - [How do I bind a host folder into the sandbox?](#how-do-i-bind-a-host-folder-into-the-sandbox)
- [How can I use different models for different tasks?](#how-can-i-use-different-models-for-different-tasks) - [How does memory work?](#how-does-memory-work)
- [How do I install skills on Linux?](#how-do-i-install-skills-on-linux) - [Does semantic memory search require an OpenAI API key?](#does-semantic-memory-search-require-an-openai-api-key)
- [Do you have a Notion or HeyGen integration?](#do-you-have-a-notion-or-heygen-integration) - [Where does Clawdbot store its data?](#where-does-clawdbot-store-its-data)
- [Sandboxing and memory](#sandboxing-and-memory) - [How do I completely uninstall Clawdbot?](#how-do-i-completely-uninstall-clawdbot)
- [Is there a dedicated sandboxing doc?](#is-there-a-dedicated-sandboxing-doc) - [Can agents work outside the workspace?](#can-agents-work-outside-the-workspace)
- [How do I bind a host folder into the sandbox?](#how-do-i-bind-a-host-folder-into-the-sandbox) - [I’m in remote mode — where is the session store?](#im-in-remote-mode-where-is-the-session-store)
- [How does memory work?](#how-does-memory-work) - [What format is the config? Where is it?](#what-format-is-the-config-where-is-it)
- [Does semantic memory search require an OpenAI API key?](#does-semantic-memory-search-require-an-openai-api-key) - [I set `gateway.bind: "lan"` (or `"tailnet"`) and now nothing listens / the UI says unauthorized](#i-set-gatewaybind-lan-or-tailnet-and-now-nothing-listens-the-ui-says-unauthorized)
- [Where things live on disk](#where-things-live-on-disk) - [Why do I need a token on localhost now?](#why-do-i-need-a-token-on-localhost-now)
- [Where does Clawdbot store its data?](#where-does-clawdbot-store-its-data) - [Do I have to restart after changing config?](#do-i-have-to-restart-after-changing-config)
- [Where should AGENTS.md / SOUL.md / USER.md / MEMORY.md live?](#where-should-agentsmd--soulmd--usermd--memorymd-live) - [How do I run a central Gateway with specialized workers across devices?](#how-do-i-run-a-central-gateway-with-specialized-workers-across-devices)
- [How do I completely uninstall Clawdbot?](#how-do-i-completely-uninstall-clawdbot) - [Can the Clawdbot browser run headless?](#can-the-clawdbot-browser-run-headless)
- [Can agents work outside the workspace?](#can-agents-work-outside-the-workspace) - [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
- [I’m in remote mode — where is the session store?](#im-in-remote-mode-where-is-the-session-store) - [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
- [Config basics](#config-basics) - [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
- [What format is the config? Where is it?](#what-format-is-the-config-where-is-it) - [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
- [I set `gateway.bind: "lan"` (or `"tailnet"`) and now nothing listens / the UI says unauthorized](#i-set-gatewaybind-lan-or-tailnet-and-now-nothing-listens-the-ui-says-unauthorized) - [How does Clawdbot load environment variables?](#how-does-clawdbot-load-environment-variables)
- [Why do I need a token on localhost now?](#why-do-i-need-a-token-on-localhost-now) - [“I started the Gateway via a daemon and my env vars disappeared.” What now?](#i-started-the-gateway-via-a-daemon-and-my-env-vars-disappeared-what-now)
- [Do I have to restart after changing config?](#do-i-have-to-restart-after-changing-config) - [How do I start a fresh conversation?](#how-do-i-start-a-fresh-conversation)
- [How do I run a central Gateway with specialized workers across devices?](#how-do-i-run-a-central-gateway-with-specialized-workers-across-devices) - [How do I completely reset Clawdbot (but keep it installed)?](#how-do-i-completely-reset-clawdbot-but-keep-it-installed)
- [Can the Clawdbot browser run headless?](#can-the-clawdbot-browser-run-headless) - [Do I need to add a “bot account” to a WhatsApp group?](#do-i-need-to-add-a-bot-account-to-a-whatsapp-group)
- [Remote gateways + nodes](#remote-gateways-nodes) - [Why doesn’t Clawdbot reply in a group?](#why-doesnt-clawdbot-reply-in-a-group)
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes) - [Do groups/threads share context with DMs?](#do-groupsthreads-share-context-with-dms)
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon) - [What is the “default model”?](#what-is-the-default-model)
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config) - [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install) - [Why do I see “Model … is not allowed” and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply)
- [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac) - [Are opus / sonnet / gpt built‑in shortcuts?](#are-opus-sonnet-gpt-builtin-shortcuts)
- [How do I connect a Mac node to a remote Gateway (Tailscale Serve)?](#how-do-i-connect-a-mac-node-to-a-remote-gateway-tailscale-serve) - [How do I define/override model shortcuts (aliases)?](#how-do-i-defineoverride-model-shortcuts-aliases)
- [Env vars and .env loading](#env-vars-and-env-loading) - [How do I add models from other providers like OpenRouter or Z.AI?](#how-do-i-add-models-from-other-providers-like-openrouter-or-zai)
- [How does Clawdbot load environment variables?](#how-does-clawdbot-load-environment-variables) - [How does failover work?](#how-does-failover-work)
- [“I started the Gateway via a daemon and my env vars disappeared.” What now?](#i-started-the-gateway-via-a-daemon-and-my-env-vars-disappeared-what-now) - [What does this error mean?](#what-does-this-error-mean)
- [I set `COPILOT_GITHUB_TOKEN`, but models status shows “Shell env: off.” Why?](#i-set-copilot_github_token-but-models-status-shows-shell-env-off-why) - [Fix checklist for `No credentials found for profile "anthropic:default"`](#fix-checklist-for-no-credentials-found-for-profile-anthropicdefault)
- [Sessions & multiple chats](#sessions-multiple-chats) - [Why did it also try Google Gemini and fail?](#why-did-it-also-try-google-gemini-and-fail)
- [How do I start a fresh conversation?](#how-do-i-start-a-fresh-conversation) - [What is an auth profile?](#what-is-an-auth-profile)
- [How do I completely reset Clawdbot (but keep it installed)?](#how-do-i-completely-reset-clawdbot-but-keep-it-installed) - [What are typical profile IDs?](#what-are-typical-profile-ids)
- [I’m getting “context too large” errors — how do I reset or compact?](#im-getting-context-too-large-errors-how-do-i-reset-or-compact) - [Can I control which auth profile is tried first?](#can-i-control-which-auth-profile-is-tried-first)
- [Do I need to add a “bot account” to a WhatsApp group?](#do-i-need-to-add-a-bot-account-to-a-whatsapp-group) - [OAuth vs API key: what’s the difference?](#oauth-vs-api-key-whats-the-difference)
- [Why doesn’t Clawdbot reply in a group?](#why-doesnt-clawdbot-reply-in-a-group) - [What port does the Gateway use?](#what-port-does-the-gateway-use)
- [Do groups/threads share context with DMs?](#do-groupsthreads-share-context-with-dms) - [Why does `clawdbot daemon status` say `Runtime: running` but `RPC probe: failed`?](#why-does-clawdbot-daemon-status-say-runtime-running-but-rpc-probe-failed)
- [Models: defaults, selection, aliases, switching](#models-defaults-selection-aliases-switching) - [Why does `clawdbot daemon status` show `Config (cli)` and `Config (daemon)` different?](#why-does-clawdbot-daemon-status-show-config-cli-and-config-daemon-different)
- [What is the “default model”?](#what-is-the-default-model) - [What does “another gateway instance is already listening” mean?](#what-does-another-gateway-instance-is-already-listening-mean)
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting) - [How do I run Clawdbot in remote mode (client connects to a Gateway elsewhere)?](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere)
- [Why do I see “Model … is not allowed” and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply) - [The Control UI says “unauthorized” (or keeps reconnecting). What now?](#the-control-ui-says-unauthorized-or-keeps-reconnecting-what-now)
- [Why do I see “Unknown model: minimax/MiniMax-M2.1”?](#why-do-i-see-unknown-model-minimaxminimax-m21) - [I set `gateway.bind: "tailnet"` but it can’t bind / nothing listens](#i-set-gatewaybind-tailnet-but-it-cant-bind-nothing-listens)
- [Can I use MiniMax as my default and OpenAI for complex tasks?](#can-i-use-minimax-as-my-default-and-openai-for-complex-tasks) - [Can I run multiple Gateways on the same host?](#can-i-run-multiple-gateways-on-the-same-host)
- [Are opus / sonnet / gpt built‑in shortcuts?](#are-opus-sonnet-gpt-builtin-shortcuts) - [Where are logs?](#where-are-logs)
- [How do I define/override model shortcuts (aliases)?](#how-do-i-defineoverride-model-shortcuts-aliases) - [How do I start/stop/restart the Gateway daemon?](#how-do-i-startstoprestart-the-gateway-daemon)
- [How do I add models from other providers like OpenRouter or Z.AI?](#how-do-i-add-models-from-other-providers-like-openrouter-or-zai) - [What’s the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails)
- [Model failover and “All models failed”](#model-failover-and-all-models-failed) - [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
- [How does failover work?](#how-does-failover-work) - [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms)
- [What does this error mean?](#what-does-this-error-mean) - [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work)
- [Fix checklist for `No credentials found for profile "anthropic:default"`](#fix-checklist-for-no-credentials-found-for-profile-anthropicdefault) - [How do I stop/cancel a running task?](#how-do-i-stopcancel-a-running-task)
- [Why did it also try Google Gemini and fail?](#why-did-it-also-try-google-gemini-and-fail) - [Why does it feel like the bot “ignores” rapid‑fire messages?](#why-does-it-feel-like-the-bot-ignores-rapidfire-messages)
- [Auth profiles: what they are and how to manage them](#auth-profiles-what-they-are-and-how-to-manage-them) - [“All models failed” — what should I check first?](#all-models-failed-what-should-i-check-first)
- [What is an auth profile?](#what-is-an-auth-profile) - [I’m running on my personal WhatsApp number — why is self-chat weird?](#im-running-on-my-personal-whatsapp-number-why-is-self-chat-weird)
- [What are typical profile IDs?](#what-are-typical-profile-ids) - [WhatsApp logged me out. How do I re‑auth?](#whatsapp-logged-me-out-how-do-i-reauth)
- [Can I control which auth profile is tried first?](#can-i-control-which-auth-profile-is-tried-first) - [Build errors on `main` — what’s the standard fix path?](#build-errors-on-main-whats-the-standard-fix-path)
- [OAuth vs API key: what’s the difference?](#oauth-vs-api-key-whats-the-difference)
- [Gateway: ports, “already running”, and remote mode](#gateway-ports-already-running-and-remote-mode)
- [What port does the Gateway use?](#what-port-does-the-gateway-use)
- [Why does `clawdbot daemon status` say `Runtime: running` but `RPC probe: failed`?](#why-does-clawdbot-daemon-status-say-runtime-running-but-rpc-probe-failed)
- [Why does `clawdbot daemon status` show `Config (cli)` and `Config (daemon)` different?](#why-does-clawdbot-daemon-status-show-config-cli-and-config-daemon-different)
- [What does “another gateway instance is already listening” mean?](#what-does-another-gateway-instance-is-already-listening-mean)
- [How do I run Clawdbot in remote mode (client connects to a Gateway elsewhere)?](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere)
- [The Control UI says “unauthorized” (or keeps reconnecting). What now?](#the-control-ui-says-unauthorized-or-keeps-reconnecting-what-now)
- [I set `gateway.bind: "tailnet"` but it can’t bind / nothing listens](#i-set-gatewaybind-tailnet-but-it-cant-bind-nothing-listens)
- [Can I run multiple Gateways on the same host?](#can-i-run-multiple-gateways-on-the-same-host)
- [What does “invalid handshake” / code 1008 mean?](#what-does-invalid-handshake--code-1008-mean)
- [Logging and debugging](#logging-and-debugging)
- [Where are logs?](#where-are-logs)
- [How do I start/stop/restart the Gateway daemon?](#how-do-i-startstoprestart-the-gateway-daemon)
- [What’s the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails)
- [Media & attachments](#media-attachments)
- [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
- [Security and access control](#security-and-access-control)
- [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms)
- [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work)
- [Chat commands, aborting tasks, and “it won’t stop”](#chat-commands-aborting-tasks-and-it-wont-stop)
- [How do I stop/cancel a running task?](#how-do-i-stopcancel-a-running-task)
- [Why does it feel like the bot “ignores” rapid‑fire messages?](#why-does-it-feel-like-the-bot-ignores-rapidfire-messages)
- [Common troubleshooting](#common-troubleshooting)
- [“All models failed” — what should I check first?](#all-models-failed-what-should-i-check-first)
- [I’m running on my personal WhatsApp number — why is self-chat weird?](#im-running-on-my-personal-whatsapp-number-why-is-self-chat-weird)
- [WhatsApp logged me out. How do I re‑auth?](#whatsapp-logged-me-out-how-do-i-reauth)
- [Build errors on `main` — what’s the standard fix path?](#build-errors-on-main-whats-the-standard-fix-path)
- [npm install fails (allow-build-scripts / missing tar or yargs). What now?](#npm-install-fails-allow-build-scripts--missing-tar-or-yargs-what-now)
- [How do I switch between git installs and npm installs?](#how-do-i-switch-between-git-installs-and-npm-installs)
- [Telegram block streaming isn’t splitting text between tool calls. Why?](#telegram-block-streaming-isnt-splitting-text-between-tool-calls-why)
- [Discord doesn’t reply in my server even with `requireMention: false`. Why?](#discord-doesnt-reply-in-my-server-even-with-requiremention-false-why)
- [Cloud Code Assist API error: invalid tool schema (400). What now?](#cloud-code-assist-api-error-invalid-tool-schema-400-what-now)
## First 60 seconds if something's broken ## First 60 seconds if something's broken
@@ -177,7 +144,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always‑on control plane; the assistant is the product. Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always‑on control plane; the assistant is the product.
## Quick start and first-run setup ## Quick start and first‑run setup
### What’s the recommended way to install and set up Clawdbot? ### What’s the recommended way to install and set up Clawdbot?
@@ -220,7 +187,7 @@ See [Dashboard](/web/dashboard) and [Web surfaces](/web) for bind modes and auth
### What runtime do I need? ### What runtime do I need?
Node **>= 22** is required. `pnpm` is recommended. Bun is **not recommended** for the Gateway. Node **>= 22** is required. `pnpm` is recommended; `bun` is optional.
### What does the onboarding wizard actually do? ### What does the onboarding wizard actually do?
@@ -243,8 +210,6 @@ The wizard can run `claude setup-token` on the gateway host (or you run it yours
Yes. Clawdbot can **reuse Claude Code CLI credentials** (OAuth) and also supports **setup-token**. If you have a Claude subscription, we recommend **setup-token** on the gateway host for the most reliable long‑running setup (requires Claude Pro/Max + the `claude` CLI). OAuth reuse is supported, but avoid logging in separately via Clawdbot and Claude Code to prevent token conflicts. See [Anthropic](/providers/anthropic) and [OAuth](/concepts/oauth). Yes. Clawdbot can **reuse Claude Code CLI credentials** (OAuth) and also supports **setup-token**. If you have a Claude subscription, we recommend **setup-token** on the gateway host for the most reliable long‑running setup (requires Claude Pro/Max + the `claude` CLI). OAuth reuse is supported, but avoid logging in separately via Clawdbot and Claude Code to prevent token conflicts. See [Anthropic](/providers/anthropic) and [OAuth](/concepts/oauth).
Note: Claude subscription access is governed by Anthropic’s terms. For production or multi‑user workloads, API keys are usually the safer choice.
### Is AWS Bedrock supported? ### Is AWS Bedrock supported?
Yes — via pi‑ai’s **Amazon Bedrock (Converse)** provider with **manual config**. You must supply AWS credentials/region on the gateway host and add a Bedrock provider entry in your models config. See [Amazon Bedrock](/bedrock) and [Model providers](/providers/models). If you prefer a managed key flow, an OpenAI‑compatible proxy in front of Bedrock is still a valid option. Yes — via pi‑ai’s **Amazon Bedrock (Converse)** provider with **manual config**. You must supply AWS credentials/region on the gateway host and add a Bedrock provider entry in your models config. See [Amazon Bedrock](/bedrock) and [Model providers](/providers/models). If you prefer a managed key flow, an OpenAI‑compatible proxy in front of Bedrock is still a valid option.
@@ -263,11 +228,7 @@ Pick region-pinned endpoints. OpenRouter exposes US-hosted options for MiniMax,
### Can I use Bun? ### Can I use Bun?
Bun is **not recommended**. We see runtime bugs, especially with WhatsApp and Telegram. Bun is supported for faster TypeScript execution, but **WhatsApp requires Node** in this ecosystem. The wizard lets you pick the runtime; choose **Node** if you use WhatsApp.
Use **Node** for stable gateways.
If you still want to experiment with Bun, do it on a non‑production gateway
without WhatsApp/Telegram.
### Telegram: what goes in `allowFrom`? ### Telegram: what goes in `allowFrom`?
@@ -319,25 +280,6 @@ clawdbot daemon restart
Doctor detects a gateway service entrypoint mismatch and offers to rewrite the service config to match the current install (use `--repair` in automation). Doctor detects a gateway service entrypoint mismatch and offers to rewrite the service config to match the current install (use `--repair` in automation).
### Should I run the Gateway on my laptop or a VPS?
Short answer: **if you want 24/7 reliability, use a VPS**. If you want the
lowest friction and you’re okay with sleep/restarts, run it locally.
**Laptop (local Gateway)**
- **Pros:** no server cost, direct access to local files, live browser window.
- **Cons:** sleep/network drops = disconnects, OS updates/reboots interrupt, must stay awake.
**VPS / cloud**
- **Pros:** always‑on, stable network, no laptop sleep issues, easier to keep running.
- **Cons:** often run headless (use screenshots), remote file access only, you must SSH for updates.
**Clawdbot‑specific note:** WhatsApp/Telegram/Slack/Discord all work fine from a VPS. The only real trade‑off is **headless browser** vs a visible window. See [Browser](/tools/browser).
**Recommended default:** VPS if you had gateway disconnects before. Local is great when you’re actively using the Mac and want local file access or UI automation with a visible browser.
## Skills and automation
### How do I customize skills without keeping the repo dirty? ### How do I customize skills without keeping the repo dirty?
Use managed overrides instead of editing the repo copy. Put your changes in `~/.clawdbot/skills/<name>/SKILL.md` (or add a folder via `skills.load.extraDirs` in `~/.clawdbot/clawdbot.json`). Precedence is `<workspace>/skills` > `~/.clawdbot/skills` > bundled, so managed overrides win without touching git. Only upstream-worthy edits should live in the repo and go out as PRs. Use managed overrides instead of editing the repo copy. Put your changes in `~/.clawdbot/skills/<name>/SKILL.md` (or add a folder via `skills.load.extraDirs` in `~/.clawdbot/clawdbot.json`). Precedence is `<workspace>/skills` > `~/.clawdbot/skills` > bundled, so managed overrides win without touching git. Only upstream-worthy edits should live in the repo and go out as PRs.
@@ -358,7 +300,6 @@ See [Cron jobs](/automation/cron-jobs), [Multi-Agent Routing](/concepts/multi-ag
### How do I install skills on Linux? ### How do I install skills on Linux?
Use **ClawdHub** (CLI) or drop skills into your workspace. The macOS Skills UI isn’t available on Linux. Use **ClawdHub** (CLI) or drop skills into your workspace. The macOS Skills UI isn’t available on Linux.
Browse skills at https://clawdhub.com.
Install the ClawdHub CLI (pick one package manager): Install the ClawdHub CLI (pick one package manager):
@@ -370,20 +311,9 @@ npm i -g clawdhub
pnpm add -g clawdhub pnpm add -g clawdhub
``` ```
### Do you have a Notion or HeyGen integration? ```bash
bun add -g clawdhub
Not built‑in today. ```
Options:
- **Custom skill / plugin:** best for reliable API access (Notion/HeyGen both have APIs).
- **Browser automation:** works without code but is slower and more fragile.
If you want to keep context per client (agency workflows), a simple pattern is:
- One Notion page per client (context + preferences + active work).
- Ask the agent to fetch that page at the start of a session.
If you want a native integration, open a feature request or build a skill
targeting those APIs.
Install skills: Install skills:
@@ -394,8 +324,6 @@ clawdhub update --all
ClawdHub installs into `./skills` under your current directory (or falls back to your configured Clawdbot workspace); Clawdbot treats that as `<workspace>/skills` on the next session. For shared skills across agents, place them in `~/.clawdbot/skills/<name>/SKILL.md`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills) and [ClawdHub](/tools/clawdhub). ClawdHub installs into `./skills` under your current directory (or falls back to your configured Clawdbot workspace); Clawdbot treats that as `<workspace>/skills` on the next session. For shared skills across agents, place them in `~/.clawdbot/skills/<name>/SKILL.md`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills) and [ClawdHub](/tools/clawdhub).
## Sandboxing and memory
### Is there a dedicated sandboxing doc? ### Is there a dedicated sandboxing doc?
Yes. See [Sandboxing](/gateway/sandboxing). For Docker-specific setup (full gateway in Docker or sandbox images), see [Docker](/install/docker). Yes. See [Sandboxing](/gateway/sandboxing). For Docker-specific setup (full gateway in Docker or sandbox images), see [Docker](/install/docker).
@@ -446,29 +374,6 @@ Legacy single‑agent path: `~/.clawdbot/agent/*` (migrated by `clawdbot doctor`
Your **workspace** (AGENTS.md, memory files, skills, etc.) is separate and configured via `agents.defaults.workspace` (default: `~/clawd`). Your **workspace** (AGENTS.md, memory files, skills, etc.) is separate and configured via `agents.defaults.workspace` (default: `~/clawd`).
### Where should AGENTS.md / SOUL.md / USER.md / MEMORY.md live?
These files live in the **agent workspace**, not `~/.clawdbot`.
- **Workspace (per agent)**: `AGENTS.md`, `SOUL.md`, `IDENTITY.md`, `USER.md`,
`MEMORY.md` (or `memory.md`), `memory/YYYY-MM-DD.md`, optional `HEARTBEAT.md`.
- **State dir (`~/.clawdbot`)**: config, credentials, auth profiles, sessions, logs,
and shared skills (`~/.clawdbot/skills`).
Default workspace is `~/clawd`, configurable via:
```json5
{
agents: { defaults: { workspace: "~/clawd" } }
}
```
If the bot “forgets” after a restart, confirm the Gateway is using the same
workspace on every launch (and remember: remote mode uses the **gateway host’s**
workspace, not your local laptop).
See [Agent workspace](/concepts/agent-workspace) and [Memory](/concepts/memory).
### How do I completely uninstall Clawdbot? ### How do I completely uninstall Clawdbot?
See the dedicated guide: [Uninstall](/install/uninstall). See the dedicated guide: [Uninstall](/install/uninstall).
@@ -573,11 +478,6 @@ Yes. It’s a config option:
Default is `false` (headful). Headless is more likely to trigger anti‑bot checks on some sites. See [Browser](/tools/browser). Default is `false` (headful). Headless is more likely to trigger anti‑bot checks on some sites. See [Browser](/tools/browser).
Headless uses the **same Chromium engine** and works for most automation (forms, clicks, scraping, logins). The main differences:
- No visible browser window (use screenshots if you need visuals).
- Some sites are stricter about automation in headless mode (CAPTCHAs, anti‑bot).
For example, X/Twitter often blocks headless sessions.
## Remote gateways + nodes ## Remote gateways + nodes
### How do commands propagate between Telegram, the gateway, and nodes? ### How do commands propagate between Telegram, the gateway, and nodes?
@@ -611,52 +511,6 @@ Yes. `config.apply` validates + writes the full config and restarts the Gateway
This sets your workspace and restricts who can trigger the bot. This sets your workspace and restricts who can trigger the bot.
### How do I set up Tailscale on a VPS and connect from my Mac?
Minimal steps:
1) **Install + login on the VPS**
```bash
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
```
2) **Install + login on your Mac**
- Use the Tailscale app and sign in to the same tailnet.
3) **Enable MagicDNS (recommended)**
- In the Tailscale admin console, enable MagicDNS so the VPS has a stable name.
4) **Use the tailnet hostname**
- SSH: `ssh user@your-vps.tailnet-xxxx.ts.net`
- Gateway WS: `ws://your-vps.tailnet-xxxx.ts.net:18789`
If you want the Control UI without SSH, use Tailscale Serve on the VPS:
```bash
clawdbot gateway --tailscale serve
```
This keeps the gateway bound to loopback and exposes HTTPS via Tailscale. See [Tailscale](/gateway/tailscale).
### How do I connect a Mac node to a remote Gateway (Tailscale Serve)?
Serve only exposes the **Gateway Control UI**. Nodes use the **bridge port**.
Recommended setup:
1) **Enable the bridge on the gateway host**:
```json5
{
bridge: { enabled: true, bind: "auto" }
}
```
`auto` prefers a tailnet IP when Tailscale is present.
2) **Make sure the VPS + Mac are on the same tailnet**.
3) **Use the macOS app in Remote mode** (SSH target can be the tailnet hostname).
The app will tunnel the bridge port and connect as a node.
4) **Approve the node** on the gateway:
```bash
clawdbot nodes pending
clawdbot nodes approve <requestId>
```
Docs: [Bridge protocol](/gateway/bridge-protocol), [Discovery](/gateway/discovery), [macOS remote mode](/platforms/mac/remote).
## Env vars and .env loading ## Env vars and .env loading
### How does Clawdbot load environment variables? ### How does Clawdbot load environment variables?
@@ -702,30 +556,6 @@ Two common fixes:
This runs your login shell and imports only missing expected keys (never overrides). Env var equivalents: This runs your login shell and imports only missing expected keys (never overrides). Env var equivalents:
`CLAWDBOT_LOAD_SHELL_ENV=1`, `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=15000`. `CLAWDBOT_LOAD_SHELL_ENV=1`, `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=15000`.
### I set `COPILOT_GITHUB_TOKEN`, but models status shows “Shell env: off.” Why?
`clawdbot models status` reports whether **shell env import** is enabled. “Shell env: off”
does **not** mean your env vars are missing — it just means Clawdbot won’t load
your login shell automatically.
If the Gateway runs as a daemon (launchd/systemd), it won’t inherit your shell
environment. Fix by doing one of these:
1) Put the token in `~/.clawdbot/.env`:
```
COPILOT_GITHUB_TOKEN=...
```
2) Or enable shell import (`env.shellEnv.enabled: true`).
3) Or add it to your config `env` block (applies only if missing).
Then restart the gateway and recheck:
```bash
clawdbot models status
```
Copilot tokens are read from `COPILOT_GITHUB_TOKEN` (also `GH_TOKEN` / `GITHUB_TOKEN`).
See [/concepts/model-providers](/concepts/model-providers) and [/environment](/environment).
## Sessions & multiple chats ## Sessions & multiple chats
### How do I start a fresh conversation? ### How do I start a fresh conversation?
@@ -757,28 +587,6 @@ Notes:
- If you used profiles (`--profile` / `CLAWDBOT_PROFILE`), reset each state dir (defaults are `~/.clawdbot-<profile>`). - If you used profiles (`--profile` / `CLAWDBOT_PROFILE`), reset each state dir (defaults are `~/.clawdbot-<profile>`).
- Dev reset: `clawdbot gateway --dev --reset` (dev-only; wipes dev config + credentials + sessions + workspace). - Dev reset: `clawdbot gateway --dev --reset` (dev-only; wipes dev config + credentials + sessions + workspace).
### I’m getting “context too large” errors — how do I reset or compact?
Use one of these:
- **Compact** (keeps the conversation but summarizes older turns):
```
/compact
```
or `/compact <instructions>` to guide the summary.
- **Reset** (fresh session ID for the same chat key):
```
/new
/reset
```
If it keeps happening:
- Enable or tune **session pruning** (`agents.defaults.contextPruning`) to trim old tool output.
- Use a model with a larger context window.
Docs: [Compaction](/concepts/compaction), [Session pruning](/concepts/session-pruning), [Session management](/concepts/session).
### Do I need to add a “bot account” to a WhatsApp group? ### Do I need to add a “bot account” to a WhatsApp group?
No. Clawdbot runs on **your own account**, so if you’re in the group, Clawdbot can see it. No. Clawdbot runs on **your own account**, so if you’re in the group, Clawdbot can see it.
@@ -863,59 +671,6 @@ Model "provider/model" is not allowed. Use /model to list available models.
That error is returned **instead of** a normal reply. Fix: add the model to That error is returned **instead of** a normal reply. Fix: add the model to
`agents.defaults.models`, remove the allowlist, or pick a model from `/model list`. `agents.defaults.models`, remove the allowlist, or pick a model from `/model list`.
### Why do I see “Unknown model: minimax/MiniMax-M2.1”?
This means the **provider isn’t configured** (no MiniMax provider config or auth
profile was found), so the model can’t be resolved. A fix for this detection is
in **2026.1.12** (unreleased at the time of writing).
Fix checklist:
1) Upgrade to **2026.1.12** (or run from source `main`), then restart the gateway.
2) Make sure MiniMax is configured (wizard or JSON), or that a MiniMax API key
exists in env/auth profiles so the provider can be injected.
3) Use the exact model id (case‑sensitive): `minimax/MiniMax-M2.1` or
`minimax/MiniMax-M2.1-lightning`.
4) Run:
```bash
clawdbot models list
```
and pick from the list (or `/model list` in chat).
See [MiniMax](/providers/minimax) and [Models](/concepts/models).
### Can I use MiniMax as my default and OpenAI for complex tasks?
Yes. Use **MiniMax as the default** and switch models **per session** when needed.
Fallbacks are for **errors**, not “hard tasks,” so use `/model` or a separate agent.
**Option A: switch per session**
```json5
{
env: { MINIMAX_API_KEY: "sk-...", OPENAI_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "minimax/MiniMax-M2.1" },
models: {
"minimax/MiniMax-M2.1": { alias: "minimax" },
"openai/gpt-5.2": { alias: "gpt" }
}
}
}
}
```
Then:
```
/model gpt
```
**Option B: separate agents**
- Agent A default: MiniMax
- Agent B default: OpenAI
- Route by agent or use `/agent` to switch
Docs: [Models](/concepts/models), [Multi-Agent Routing](/concepts/multi-agent), [MiniMax](/providers/minimax), [OpenAI](/providers/openai).
### Are opus / sonnet / gpt built‑in shortcuts? ### Are opus / sonnet / gpt built‑in shortcuts?
Yes. Clawdbot ships a few default shorthands (only applied when the model exists in `agents.defaults.models`): Yes. Clawdbot ships a few default shorthands (only applied when the model exists in `agents.defaults.models`):
@@ -1013,23 +768,6 @@ It means the system attempted to use the auth profile ID `anthropic:default`, bu
- **Sanity‑check model/auth status** - **Sanity‑check model/auth status**
- Use `clawdbot models status` to see configured models and whether providers are authenticated. - Use `clawdbot models status` to see configured models and whether providers are authenticated.
### Fix checklist for `No credentials found for profile "anthropic:claude-cli"`
This means the run is pinned to the **Claude CLI** profile, but the Gateway
can’t find that profile in its auth store.
- **Sync the Claude CLI token on the gateway host**
- Run `clawdbot models status` (it loads + syncs Claude CLI credentials).
- If it still says missing: run `claude setup-token` (or `clawdbot models auth setup-token --provider anthropic`) and retry.
- **If you want to use an API key instead**
- Put `ANTHROPIC_API_KEY` in `~/.clawdbot/.env` on the **gateway host**.
- Clear any pinned order that forces `anthropic:claude-cli`:
```bash
clawdbot models auth order clear --provider anthropic
```
- **Confirm you’re running commands on the gateway host**
- In remote mode, auth profiles live on the gateway machine, not your laptop.
### Why did it also try Google Gemini and fail? ### Why did it also try Google Gemini and fail?
If your model config includes Google Gemini as a fallback (or you switched to a Gemini shorthand), Clawdbot will try it during model fallback. If you haven’t configured Google credentials, you’ll see `No API key found for provider "google"`. If your model config includes Google Gemini as a fallback (or you switched to a Gemini shorthand), Clawdbot will try it during model fallback. If you haven’t configured Google credentials, you’ll see `No API key found for provider "google"`.
@@ -1172,8 +910,6 @@ Fix:
Fix: Fix:
- Start Tailscale on that host (so it has a 100.x address), or - Start Tailscale on that host (so it has a 100.x address), or
- Switch to `gateway.bind: "loopback"` / `"lan"`. - Switch to `gateway.bind: "loopback"` / `"lan"`.
Note: `tailnet` is legacy and is migrated to `auto` by Doctor. Prefer `gateway.bind: "auto"` when using Tailscale.
### Can I run multiple Gateways on the same host? ### Can I run multiple Gateways on the same host?
@@ -1193,29 +929,6 @@ Quick setup (recommended):
Profiles also suffix service names (`com.clawdbot.<profile>`, `clawdbot-gateway-<profile>.service`, `Clawdbot Gateway (<profile>)`). Profiles also suffix service names (`com.clawdbot.<profile>`, `clawdbot-gateway-<profile>.service`, `Clawdbot Gateway (<profile>)`).
### What does “invalid handshake” / code 1008 mean?
The Gateway is a **WebSocket server**, and it expects the very first message to
be a `connect` frame. If it receives anything else, it closes the connection
with **code 1008** (policy violation).
Common causes:
- You opened the **HTTP** URL in a browser (`http://...`) instead of a WS client.
- You used the wrong port or path.
- A proxy or tunnel stripped auth headers or sent a non‑Gateway request.
Quick fixes:
1) Use the WS URL: `ws://<host>:18789` (or `wss://...` if HTTPS).
2) Don’t open the WS port in a normal browser tab.
3) If auth is on, include the token/password in the `connect` frame.
If you’re using the CLI or TUI, the URL should look like:
```
clawdbot tui --url ws://<host>:18789 --token <token>
```
Protocol details: [Gateway protocol](/gateway/protocol).
## Logging and debugging ## Logging and debugging
### Where are logs? ### Where are logs?
@@ -1380,96 +1093,6 @@ clawdbot providers login
3) Check GitHub issues or Discord 3) Check GitHub issues or Discord
4) Temporary workaround: check out an older commit 4) Temporary workaround: check out an older commit
### npm install fails (allow-build-scripts / missing tar or yargs). What now?
If you’re running from source, use the repo’s package manager: **pnpm** (preferred).
The repo declares `packageManager: "pnpm@…"`, and pnpm patches are tracked in `pnpm.patchedDependencies`.
Typical recovery:
```bash
git status # ensure you’re in the repo root
pnpm install
pnpm build
pnpm clawdbot doctor
clawdbot daemon restart
```
Why: pnpm is the configured package manager for this repo, and the dependency
patching workflow relies on it.
### How do I switch between git installs and npm installs?
Use the **website installer** and select the install method with a flag. It
upgrades in place and rewrites the gateway service to point at the new install.
Switch **to git install**:
```bash
curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git --no-onboard
```
Switch **to npm global**:
```bash
curl -fsSL https://clawd.bot/install.sh | bash
```
Notes:
- The git flow only rebases if the repo is clean. Commit or stash changes first.
- After switching, run:
```bash
clawdbot doctor
clawdbot daemon restart
```
### Telegram block streaming isn’t splitting text between tool calls. Why?
Block streaming only sends **completed text blocks**. Common reasons you see a single message:
- `agents.defaults.blockStreamingDefault` is still `"off"`.
- `telegram.blockStreaming` is set to `false`.
- `telegram.streamMode` is `partial` or `block` **and draft streaming is active**
(private chat + topics). Draft streaming disables block streaming in that case.
- Your `minChars` / coalesce settings are too high, so chunks get merged.
- The model emits one large text block (no mid‑reply flush points).
Fix checklist:
1) Put block streaming settings under `agents.defaults`, not the root.
2) Set `telegram.streamMode: "off"` if you want real multi‑message block replies.
3) Use smaller chunk/coalesce thresholds while debugging.
See [Streaming](/concepts/streaming).
### Discord doesn’t reply in my server even with `requireMention: false`. Why?
`requireMention` only controls mention‑gating **after** the channel passes allowlists.
By default `discord.groupPolicy` is **allowlist**, so guild channels must be explicitly enabled.
Fix checklist:
1) Set `discord.groupPolicy: "open"` **or** add the guild/channel allowlist.
2) Use **numeric channel IDs** in `discord.guilds.<guildId>.channels`.
3) Put `requireMention: false` **under** `discord.guilds` (global or per‑channel).
Top‑level `discord.requireMention` is not a supported key.
4) Ensure the bot has **Message Content Intent** and channel permissions.
5) Run `clawdbot providers status --probe` for audit hints.
Docs: [Discord](/providers/discord), [Providers troubleshooting](/providers/troubleshooting).
### Cloud Code Assist API error: invalid tool schema (400). What now?
This is almost always a **tool schema compatibility** issue. The Cloud Code Assist
endpoint accepts a strict subset of JSON Schema. Clawdbot scrubs/normalizes tool
schemas in current `main`, but the fix is not in the last release yet (as of
January 13, 2026).
Fix checklist:
1) **Update Clawdbot**:
- If you can run from source, pull `main` and restart the gateway.
- Otherwise, wait for the next release that includes the schema scrubber.
2) Avoid unsupported keywords like `anyOf/oneOf/allOf`, `patternProperties`,
`additionalProperties`, `minLength`, `maxLength`, `format`, etc.
3) If you define custom tools, keep the top‑level schema as `type: "object"` with
`properties` and simple enums.
See [Tools](/tools) and [TypeBox schemas](/concepts/typebox).
## Answer the exact question from the screenshot/chat log ## Answer the exact question from the screenshot/chat log
**Q: “What’s the default model for Anthropic with an API key?”** **Q: “What’s the default model for Anthropic with an API key?”**
+6 -3
View File
@@ -79,7 +79,7 @@ What you’ll choose:
- **Auth**: OpenAI Code (Codex) subscription (OAuth) or API keys. For Anthropic we recommend an API key; `claude setup-token` is also supported. - **Auth**: OpenAI Code (Codex) subscription (OAuth) or API keys. For Anthropic we recommend an API key; `claude setup-token` is also supported.
- **Providers**: WhatsApp QR login, Telegram/Discord bot tokens, etc. - **Providers**: WhatsApp QR login, Telegram/Discord bot tokens, etc.
- **Daemon**: background install (launchd/systemd; WSL2 uses systemd) - **Daemon**: background install (launchd/systemd; WSL2 uses systemd)
- **Runtime**: Node (recommended; required for WhatsApp/Telegram). Bun is **not recommended**. - **Runtime**: Node (recommended; required for WhatsApp) or Bun (faster, but incompatible with WhatsApp)
- **Gateway token**: the wizard generates one by default (even on loopback) and stores it in `gateway.auth.token`. - **Gateway token**: the wizard generates one by default (even on loopback) and stores it in `gateway.auth.token`.
Wizard doc: [Wizard](/start/wizard) Wizard doc: [Wizard](/start/wizard)
@@ -110,8 +110,11 @@ clawdbot gateway --port 18789 --verbose
Dashboard (local loopback): `http://127.0.0.1:18789/` Dashboard (local loopback): `http://127.0.0.1:18789/`
If a token is configured, paste it into the Control UI settings (stored as `connect.params.auth.token`). If a token is configured, paste it into the Control UI settings (stored as `connect.params.auth.token`).
⚠️ **Bun warning (WhatsApp + Telegram):** Bun has known issues with these ⚠️ **WhatsApp + Bun warning:** Baileys (WhatsApp Web library) uses a WebSocket
providers. If you use WhatsApp or Telegram, run the Gateway with **Node**. path that is currently incompatible with Bun and can cause memory corruption on
reconnect. If you use WhatsApp, run the Gateway with **Node** until this is
resolved. Baileys: https://github.com/WhiskeySockets/Baileys · Bun issue:
https://github.com/oven-sh/bun/issues/5951
## 4) Pair + connect your first chat surface ## 4) Pair + connect your first chat surface
+1 -1
View File
@@ -27,7 +27,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
- [Docker](/install/docker) - [Docker](/install/docker)
- [Nix](/install/nix) - [Nix](/install/nix)
- [Updating / rollback](/install/updating) - [Updating / rollback](/install/updating)
- [Bun workflow (experimental)](/install/bun) - [Bun workflow](/install/bun)
## Core concepts ## Core concepts
+1 -1
View File
@@ -64,7 +64,7 @@ Onboarding requests TCC permissions needed for:
## 5) CLI (optional) ## 5) CLI (optional)
The app can install the global `clawdbot` CLI via npm/pnpm so terminal The app can install the global `clawdbot` CLI via npm/pnpm/bun so terminal
workflows and launchd tasks work out of the box. workflows and launchd tasks work out of the box.
## 6) Onboarding chat (dedicated session) ## 6) Onboarding chat (dedicated session)
+2 -2
View File
@@ -9,7 +9,7 @@ summary: "Community-built projects and integrations powered by Clawdbot"
Real projects from the community. See what people are building with Clawdbot. Real projects from the community. See what people are building with Clawdbot.
<Info> <Info>
**Want to be featured?** Share your project in [#showcase on Discord](https://discord.gg/clawd) or [tag @clawdbot on X](https://x.com/clawdbot). **Want to be featured?** Share your project in [#showcase on Discord](https://discord.gg/clawdbot) or [tag @clawdbot on X](https://x.com/clawdbot).
</Info> </Info>
## 🎥 Clawdbot in Action ## 🎥 Clawdbot in Action
@@ -366,7 +366,7 @@ Have something to share? We'd love to feature it!
<Steps> <Steps>
<Step title="Share It"> <Step title="Share It">
Post in [#showcase on Discord](https://discord.gg/clawd) or [tweet @clawdbot](https://x.com/clawdbot) Post in [#showcase on Discord](https://discord.gg/clawdbot) or [tweet @clawdbot](https://x.com/clawdbot)
</Step> </Step>
<Step title="Include Details"> <Step title="Include Details">
Tell us what it does, link to the repo/demo, share a screenshot if you have one Tell us what it does, link to the repo/demo, share a screenshot if you have one
+2 -2
View File
@@ -119,7 +119,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
- Linux (and Windows via WSL2): systemd user unit - Linux (and Windows via WSL2): systemd user unit
- Wizard attempts to enable lingering via `loginctl enable-linger <user>` so the Gateway stays up after logout. - Wizard attempts to enable lingering via `loginctl enable-linger <user>` so the Gateway stays up after logout.
- May prompt for sudo (writes `/var/lib/systemd/linger`); it tries without sudo first. - May prompt for sudo (writes `/var/lib/systemd/linger`); it tries without sudo first.
- **Runtime selection:** Node (recommended; required for WhatsApp/Telegram). Bun is **not recommended**. - **Runtime selection:** Node (recommended; required for WhatsApp) or Bun (faster, but incompatible with WhatsApp).
7) **Health check** 7) **Health check**
- Starts the Gateway (if needed) and runs `clawdbot health`. - Starts the Gateway (if needed) and runs `clawdbot health`.
@@ -127,7 +127,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
8) **Skills (recommended)** 8) **Skills (recommended)**
- Reads the available skills and checks requirements. - Reads the available skills and checks requirements.
- Lets you choose a node manager: **npm / pnpm** (bun not recommended). - Lets you choose a node manager: **npm / pnpm / bun**.
- Installs optional dependencies (some use Homebrew on macOS). - Installs optional dependencies (some use Homebrew on macOS).
9) **Finish** 9) **Finish**
+4
View File
@@ -42,6 +42,10 @@ npm i -g clawdhub
pnpm add -g clawdhub pnpm add -g clawdhub
``` ```
```bash
bun add -g clawdhub
```
## How it fits into Clawdbot ## How it fits into Clawdbot
By default, the CLI installs skills into `./skills` under your current working directory. If a Clawdbot workspace is configured, `clawdhub` falls back to that workspace unless you override `--workdir` (or `CLAWDHUB_WORKDIR`). Clawdbot loads workspace skills from `<workspace>/skills` and will pick them up in the **next** session. If you already use `~/.clawdbot/skills` or bundled skills, workspace skills take precedence. By default, the CLI installs skills into `./skills` under your current working directory. If a Clawdbot workspace is configured, `clawdhub` falls back to that workspace unless you override `--workdir` (or `CLAWDHUB_WORKDIR`). Clawdbot loads workspace skills from `<workspace>/skills` and will pick them up in the **next** session. If you already use `~/.clawdbot/skills` or bundled skills, workspace skills take precedence.
-1
View File
@@ -7,7 +7,6 @@ read_when:
## What it does ## What it does
- Elevated mode allows the exec tool to run with elevated privileges when the feature is available and the sender is approved. - Elevated mode allows the exec tool to run with elevated privileges when the feature is available and the sender is approved.
- The bash chat command (`!`; `/bash` alias) uses the same `tools.elevated` allowlists because it always runs on the host.
- **Optional for sandboxed agents**: elevated only changes behavior when the agent is running in a sandbox. If the agent already runs unsandboxed, elevated is effectively a no-op. - **Optional for sandboxed agents**: elevated only changes behavior when the agent is running in a sandbox. If the agent already runs unsandboxed, elevated is effectively a no-op.
- Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`. - Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`.
- Only `on|off` are accepted; anything else returns a hint and does not change state. - Only `on|off` are accepted; anything else returns a hint and does not change state.
+1 -3
View File
@@ -20,7 +20,7 @@ All skills-related configuration lives under `skills` in `~/.clawdbot/clawdbot.j
}, },
install: { install: {
preferBrew: true, preferBrew: true,
nodeManager: "npm" // npm | pnpm | yarn | bun (Gateway runtime still Node; bun not recommended) nodeManager: "npm" // npm | pnpm | yarn | bun
}, },
entries: { entries: {
"nano-banana-pro": { "nano-banana-pro": {
@@ -44,8 +44,6 @@ All skills-related configuration lives under `skills` in `~/.clawdbot/clawdbot.j
- `load.extraDirs`: additional skill directories to scan (lowest precedence). - `load.extraDirs`: additional skill directories to scan (lowest precedence).
- `install.preferBrew`: prefer brew installers when available (default: true). - `install.preferBrew`: prefer brew installers when available (default: true).
- `install.nodeManager`: node installer preference (`npm` | `pnpm` | `yarn` | `bun`, default: npm). - `install.nodeManager`: node installer preference (`npm` | `pnpm` | `yarn` | `bun`, default: npm).
This only affects **skill installs**; the Gateway runtime should still be Node
(Bun not recommended for WhatsApp/Telegram).
- `entries.<skillKey>`: per-skill overrides. - `entries.<skillKey>`: per-skill overrides.
Per-skill fields: Per-skill fields:
+3 -6
View File
@@ -45,9 +45,8 @@ surface those skills teach.
## ClawdHub (install + sync) ## ClawdHub (install + sync)
ClawdHub is the public skills registry for Clawdbot. Browse at ClawdHub is the public skills registry for Clawdbot. Use it to discover,
https://clawdhub.com. Use it to discover, install, update, and back up skills. install, update, and back up skills. Full guide: [ClawdHub](/tools/clawdhub).
Full guide: [ClawdHub](/tools/clawdhub).
Common flows: Common flows:
@@ -118,8 +117,6 @@ metadata: {"clawdbot":{"emoji":"♊️","requires":{"bins":["gemini"]},"install"
Notes: Notes:
- If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node). - If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node).
- Node installs honor `skills.install.nodeManager` in `clawdbot.json` (default: npm; options: npm/pnpm/yarn/bun). - Node installs honor `skills.install.nodeManager` in `clawdbot.json` (default: npm; options: npm/pnpm/yarn/bun).
This only affects **skill installs**; the Gateway runtime should still be Node
(Bun is not recommended for WhatsApp/Telegram).
- Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible. - Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible.
If no `metadata.clawdbot` is present, the skill is always eligible (unless If no `metadata.clawdbot` is present, the skill is always eligible (unless
@@ -204,6 +201,6 @@ See [Skills config](/tools/skills-config) for the full configuration schema.
## Looking for more skills? ## Looking for more skills?
Browse https://clawdhub.com. Browse [ClawdHub](/tools/clawdhub).
--- ---
-9
View File
@@ -7,7 +7,6 @@ read_when:
# Slash commands # Slash commands
Commands are handled by the Gateway. Most commands must be sent as a **standalone** message that starts with `/`. Commands are handled by the Gateway. Most commands must be sent as a **standalone** message that starts with `/`.
The host-only bash chat command uses `! <cmd>` (with `/bash <cmd>` as an alias).
There are two related systems: There are two related systems:
@@ -27,8 +26,6 @@ They run immediately, are stripped before the model sees the message, and the re
commands: { commands: {
native: "auto", native: "auto",
text: true, text: true,
bash: false,
bashForegroundMs: 2000,
config: false, config: false,
debug: false, debug: false,
restart: false, restart: false,
@@ -43,8 +40,6 @@ They run immediately, are stripped before the model sees the message, and the re
- Auto: on for Discord/Telegram; off for Slack (until you add slash commands); ignored for providers without native support. - Auto: on for Discord/Telegram; off for Slack (until you add slash commands); ignored for providers without native support.
- Set `discord.commands.native`, `telegram.commands.native`, or `slack.commands.native` to override per provider (bool or `"auto"`). - Set `discord.commands.native`, `telegram.commands.native`, or `slack.commands.native` to override per provider (bool or `"auto"`).
- `false` clears previously registered commands on Discord/Telegram at startup. Slack commands are managed in the Slack app and are not removed automatically. - `false` clears previously registered commands on Discord/Telegram at startup. Slack commands are managed in the Slack app and are not removed automatically.
- `commands.bash` (default `false`) enables `! <cmd>` to run host shell commands (`/bash <cmd>` is an alias; requires `tools.elevated` allowlists).
- `commands.bashForegroundMs` (default `2000`) controls how long bash waits before switching to background mode (`0` backgrounds immediately).
- `commands.config` (default `false`) enables `/config` (reads/writes `clawdbot.json`). - `commands.config` (default `false`) enables `/config` (reads/writes `clawdbot.json`).
- `commands.debug` (default `false`) enables `/debug` (runtime-only overrides). - `commands.debug` (default `false`) enables `/debug` (runtime-only overrides).
- `commands.useAccessGroups` (default `true`) enforces allowlists/policies for commands. - `commands.useAccessGroups` (default `true`) enforces allowlists/policies for commands.
@@ -71,13 +66,9 @@ Text + native (when enabled):
- `/elevated on|off` (alias: `/elev`) - `/elevated on|off` (alias: `/elev`)
- `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`) - `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`)
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings) - `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings)
- `/bash <command>` (host-only; alias for `! <command>`; requires `commands.bash: true` + `tools.elevated` allowlists)
Text-only: Text-only:
- `/compact [instructions]` (see [/concepts/compaction](/concepts/compaction)) - `/compact [instructions]` (see [/concepts/compaction](/concepts/compaction))
- `! <command>` (host-only; one at a time; use `!poll` + `!stop` for long-running jobs)
- `!poll` (check output / status; accepts optional `sessionId`; `/bash poll` also works)
- `!stop` (stop the running bash job; accepts optional `sessionId`; `/bash stop` also works)
Notes: Notes:
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`). - Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
-3
View File
@@ -67,6 +67,3 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
## Notes ## Notes
- The TUI shows Gateway chat deltas (`event: chat`) and agent tool events. - The TUI shows Gateway chat deltas (`event: chat`) and agent tool events.
- It registers as a Gateway client with `mode: "tui"` for presence and debugging. - It registers as a Gateway client with `mode: "tui"` for presence and debugging.
- The TUI uses the **Gateway’s** model/auth config and environment. If a model
token works in your shell but not in TUI, put it in `~/.clawdbot/.env` or
enable `env.shellEnv.enabled`, then restart the Gateway.
-2
View File
@@ -4,8 +4,6 @@ RUN corepack enable
WORKDIR /app WORKDIR /app
ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning"
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json vitest.config.ts ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json vitest.config.ts ./
COPY patches ./patches COPY patches ./patches
COPY src ./src COPY src ./src
@@ -20,7 +20,6 @@ docker run --rm -t \
--entrypoint bash \ --entrypoint bash \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e HOME=/home/node \ -e HOME=/home/node \
-e NODE_OPTIONS=--disable-warning=ExperimentalWarning \
-e CLAWDBOT_LIVE_TEST=1 \ -e CLAWDBOT_LIVE_TEST=1 \
-e CLAWDBOT_LIVE_GATEWAY_MODELS="${CLAWDBOT_LIVE_GATEWAY_MODELS:-all}" \ -e CLAWDBOT_LIVE_GATEWAY_MODELS="${CLAWDBOT_LIVE_GATEWAY_MODELS:-all}" \
-e CLAWDBOT_LIVE_GATEWAY_PROVIDERS="${CLAWDBOT_LIVE_GATEWAY_PROVIDERS:-}" \ -e CLAWDBOT_LIVE_GATEWAY_PROVIDERS="${CLAWDBOT_LIVE_GATEWAY_PROVIDERS:-}" \
-1
View File
@@ -20,7 +20,6 @@ docker run --rm -t \
--entrypoint bash \ --entrypoint bash \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e HOME=/home/node \ -e HOME=/home/node \
-e NODE_OPTIONS=--disable-warning=ExperimentalWarning \
-e CLAWDBOT_LIVE_TEST=1 \ -e CLAWDBOT_LIVE_TEST=1 \
-e CLAWDBOT_LIVE_MODELS="${CLAWDBOT_LIVE_MODELS:-all}" \ -e CLAWDBOT_LIVE_MODELS="${CLAWDBOT_LIVE_MODELS:-all}" \
-e CLAWDBOT_LIVE_PROVIDERS="${CLAWDBOT_LIVE_PROVIDERS:-}" \ -e CLAWDBOT_LIVE_PROVIDERS="${CLAWDBOT_LIVE_PROVIDERS:-}" \
+1 -1
View File
@@ -57,7 +57,7 @@ describe("resolveAgentConfig", () => {
defaults: { defaults: {
model: { model: {
primary: "anthropic/claude-sonnet-4", primary: "anthropic/claude-sonnet-4",
fallbacks: ["openai/gpt-4.1"], fallbacks: ["openai/gpt-5-nano"],
}, },
}, },
list: [ list: [
+9
View File
@@ -155,3 +155,12 @@ export function resolveAgentDir(cfg: ClawdbotConfig, agentId: string) {
const root = resolveStateDir(process.env, os.homedir); const root = resolveStateDir(process.env, os.homedir);
return path.join(root, "agents", id, "agent"); return path.join(root, "agents", id, "agent");
} }
/**
* Resolve the agent directory for the default agent without requiring config.
* Used by onboarding when writing auth profiles before config is fully set up.
*/
export function resolveDefaultAgentDir(): string {
const root = resolveStateDir(process.env, os.homedir);
return path.join(root, "agents", DEFAULT_AGENT_ID, "agent");
}
-128
View File
@@ -59,134 +59,6 @@ describe("resolveAuthProfileOrder", () => {
expect(order).toContain("anthropic:default"); expect(order).toContain("anthropic:default");
}); });
it("drops explicit order entries that are missing from the store", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: {
minimax: ["minimax:default", "minimax:prod"],
},
},
},
store: {
version: 1,
profiles: {
"minimax:prod": {
type: "api_key",
provider: "minimax",
key: "sk-prod",
},
},
},
provider: "minimax",
});
expect(order).toEqual(["minimax:prod"]);
});
it("drops explicit order entries that belong to another provider", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: {
minimax: ["openai:default", "minimax:prod"],
},
},
},
store: {
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-openai",
},
"minimax:prod": {
type: "api_key",
provider: "minimax",
key: "sk-mini",
},
},
},
provider: "minimax",
});
expect(order).toEqual(["minimax:prod"]);
});
it("drops token profiles with empty credentials", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: {
minimax: ["minimax:default"],
},
},
},
store: {
version: 1,
profiles: {
"minimax:default": {
type: "token",
provider: "minimax",
token: " ",
},
},
},
provider: "minimax",
});
expect(order).toEqual([]);
});
it("drops token profiles that are already expired", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: {
minimax: ["minimax:default"],
},
},
},
store: {
version: 1,
profiles: {
"minimax:default": {
type: "token",
provider: "minimax",
token: "sk-minimax",
expires: Date.now() - 1000,
},
},
},
provider: "minimax",
});
expect(order).toEqual([]);
});
it("keeps oauth profiles that can refresh", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: {
anthropic: ["anthropic:oauth"],
},
},
},
store: {
version: 1,
profiles: {
"anthropic:oauth": {
type: "oauth",
provider: "anthropic",
access: "",
refresh: "refresh-token",
expires: Date.now() - 1000,
},
},
},
provider: "anthropic",
});
expect(order).toEqual(["anthropic:oauth"]);
});
it("does not prioritize lastGood over round-robin ordering", () => { it("does not prioritize lastGood over round-robin ordering", () => {
const order = resolveAuthProfileOrder({ const order = resolveAuthProfileOrder({
cfg, cfg,
+2 -31
View File
@@ -1042,7 +1042,6 @@ export function resolveAuthProfileOrder(params: {
}): string[] { }): string[] {
const { cfg, store, provider, preferredProfile } = params; const { cfg, store, provider, preferredProfile } = params;
const providerKey = normalizeProviderId(provider); const providerKey = normalizeProviderId(provider);
const now = Date.now();
const storedOrder = (() => { const storedOrder = (() => {
const order = store.order; const order = store.order;
if (!order) return undefined; if (!order) return undefined;
@@ -1077,36 +1076,7 @@ export function resolveAuthProfileOrder(params: {
const filtered = baseOrder.filter((profileId) => { const filtered = baseOrder.filter((profileId) => {
const cred = store.profiles[profileId]; const cred = store.profiles[profileId];
if (!cred) return false; return cred ? normalizeProviderId(cred.provider) === providerKey : true;
if (normalizeProviderId(cred.provider) !== providerKey) return false;
const profileConfig = cfg?.auth?.profiles?.[profileId];
if (profileConfig) {
if (normalizeProviderId(profileConfig.provider) !== providerKey) {
return false;
}
if (profileConfig.mode !== cred.type) {
const oauthCompatible =
profileConfig.mode === "oauth" && cred.type === "token";
if (!oauthCompatible) return false;
}
}
if (cred.type === "api_key") return Boolean(cred.key?.trim());
if (cred.type === "token") {
if (!cred.token?.trim()) return false;
if (
typeof cred.expires === "number" &&
Number.isFinite(cred.expires) &&
cred.expires > 0 &&
now >= cred.expires
) {
return false;
}
return true;
}
if (cred.type === "oauth") {
return Boolean(cred.access?.trim() || cred.refresh?.trim());
}
return false;
}); });
const deduped: string[] = []; const deduped: string[] = [];
for (const entry of filtered) { for (const entry of filtered) {
@@ -1119,6 +1089,7 @@ export function resolveAuthProfileOrder(params: {
if (explicitOrder && explicitOrder.length > 0) { if (explicitOrder && explicitOrder.length > 0) {
// ...but still respect cooldown tracking to avoid repeatedly selecting a // ...but still respect cooldown tracking to avoid repeatedly selecting a
// known-bad/rate-limited key as the first candidate. // known-bad/rate-limited key as the first candidate.
const now = Date.now();
const available: string[] = []; const available: string[] = [];
const inCooldown: Array<{ profileId: string; cooldownUntil: number }> = []; const inCooldown: Array<{ profileId: string; cooldownUntil: number }> = [];
+13 -13
View File
@@ -8,7 +8,7 @@ function makeCfg(overrides: Partial<ClawdbotConfig> = {}): ClawdbotConfig {
agents: { agents: {
defaults: { defaults: {
model: { model: {
primary: "openai/gpt-4.1-mini", primary: "openai/gpt-5-nano",
fallbacks: ["anthropic/claude-haiku-3-5"], fallbacks: ["anthropic/claude-haiku-3-5"],
}, },
}, },
@@ -29,7 +29,7 @@ describe("runWithModelFallback", () => {
runWithModelFallback({ runWithModelFallback({
cfg, cfg,
provider: "openai", provider: "openai",
model: "gpt-4.1-mini", model: "gpt-5-nano",
run, run,
}), }),
).rejects.toThrow("bad request"); ).rejects.toThrow("bad request");
@@ -46,7 +46,7 @@ describe("runWithModelFallback", () => {
const result = await runWithModelFallback({ const result = await runWithModelFallback({
cfg, cfg,
provider: "openai", provider: "openai",
model: "gpt-4.1-mini", model: "gpt-5-nano",
run, run,
}); });
@@ -68,7 +68,7 @@ describe("runWithModelFallback", () => {
const result = await runWithModelFallback({ const result = await runWithModelFallback({
cfg, cfg,
provider: "openai", provider: "openai",
model: "gpt-4.1-mini", model: "gpt-5-nano",
run, run,
}); });
@@ -92,7 +92,7 @@ describe("runWithModelFallback", () => {
const result = await runWithModelFallback({ const result = await runWithModelFallback({
cfg, cfg,
provider: "openai", provider: "openai",
model: "gpt-4.1-mini", model: "gpt-5-nano",
run, run,
}); });
@@ -129,7 +129,7 @@ describe("runWithModelFallback", () => {
agents: { agents: {
defaults: { defaults: {
model: { model: {
primary: "openai/gpt-4.1-mini", primary: "openai/gpt-5-nano",
}, },
}, },
}, },
@@ -173,13 +173,13 @@ describe("runWithModelFallback", () => {
cfg, cfg,
provider: "anthropic", provider: "anthropic",
model: "claude-opus-4-5", model: "claude-opus-4-5",
fallbacksOverride: ["openai/gpt-4.1"], fallbacksOverride: ["openai/gpt-5-nano"],
run: async (provider, model) => { run: async (provider, model) => {
calls.push({ provider, model }); calls.push({ provider, model });
if (provider === "anthropic") { if (provider === "anthropic") {
throw Object.assign(new Error("nope"), { status: 401 }); throw Object.assign(new Error("nope"), { status: 401 });
} }
if (provider === "openai" && model === "gpt-4.1") { if (provider === "openai" && model === "gpt-5-nano") {
return "ok"; return "ok";
} }
throw new Error(`unexpected candidate: ${provider}/${model}`); throw new Error(`unexpected candidate: ${provider}/${model}`);
@@ -189,7 +189,7 @@ describe("runWithModelFallback", () => {
expect(res.result).toBe("ok"); expect(res.result).toBe("ok");
expect(calls).toEqual([ expect(calls).toEqual([
{ provider: "anthropic", model: "claude-opus-4-5" }, { provider: "anthropic", model: "claude-opus-4-5" },
{ provider: "openai", model: "gpt-4.1" }, { provider: "openai", model: "gpt-5-nano" },
]); ]);
}); });
@@ -234,7 +234,7 @@ describe("runWithModelFallback", () => {
const result = await runWithModelFallback({ const result = await runWithModelFallback({
cfg, cfg,
provider: "openai", provider: "openai",
model: "gpt-4.1-mini", model: "gpt-5-nano",
run, run,
}); });
@@ -254,7 +254,7 @@ describe("runWithModelFallback", () => {
const result = await runWithModelFallback({ const result = await runWithModelFallback({
cfg, cfg,
provider: "openai", provider: "openai",
model: "gpt-4.1-mini", model: "gpt-5-nano",
run, run,
}); });
@@ -269,7 +269,7 @@ describe("runWithModelFallback", () => {
agents: { agents: {
defaults: { defaults: {
model: { model: {
primary: "openai/gpt-4.1-mini", primary: "openai/gpt-5-nano",
fallbacks: [], fallbacks: [],
}, },
}, },
@@ -292,6 +292,6 @@ describe("runWithModelFallback", () => {
expect(result.result).toBe("ok"); expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2); expect(run).toHaveBeenCalledTimes(2);
expect(result.provider).toBe("openai"); expect(result.provider).toBe("openai");
expect(result.model).toBe("gpt-4.1-mini"); expect(result.model).toBe("gpt-5-nano");
}); });
}); });
+12 -8
View File
@@ -13,8 +13,8 @@ import {
const catalog = [ const catalog = [
{ {
provider: "openai", provider: "openai",
id: "gpt-4", id: "gpt-5-nano",
name: "GPT-4", name: "GPT-5 Nano",
}, },
]; ];
@@ -24,7 +24,7 @@ describe("buildAllowedModelSet", () => {
agents: { agents: {
defaults: { defaults: {
models: { models: {
"openai/gpt-4": { alias: "gpt4" }, "openai/gpt-5-nano": { alias: "gpt5nano" },
}, },
}, },
}, },
@@ -38,7 +38,9 @@ describe("buildAllowedModelSet", () => {
}); });
expect(allowed.allowAny).toBe(false); expect(allowed.allowAny).toBe(false);
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true); expect(allowed.allowedKeys.has(modelKey("openai", "gpt-5-nano"))).toBe(
true,
);
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe( expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(
true, true,
); );
@@ -57,7 +59,9 @@ describe("buildAllowedModelSet", () => {
}); });
expect(allowed.allowAny).toBe(true); expect(allowed.allowAny).toBe(true);
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true); expect(allowed.allowedKeys.has(modelKey("openai", "gpt-5-nano"))).toBe(
true,
);
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe( expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(
true, true,
); );
@@ -236,7 +240,7 @@ describe("resolveAllowedModelRef", () => {
agents: { agents: {
defaults: { defaults: {
models: { models: {
"openai/gpt-4": { alias: "GPT4" }, "openai/gpt-5-nano": { alias: "GPT5NANO" },
}, },
}, },
}, },
@@ -244,12 +248,12 @@ describe("resolveAllowedModelRef", () => {
const resolved = resolveAllowedModelRef({ const resolved = resolveAllowedModelRef({
cfg, cfg,
catalog: [ catalog: [
{ provider: "openai", id: "gpt-4", name: "GPT-4" }, { provider: "openai", id: "gpt-5-nano", name: "GPT-5 Nano" },
{ provider: "anthropic", id: "claude-sonnet-4-1", name: "Sonnet" }, { provider: "anthropic", id: "claude-sonnet-4-1", name: "Sonnet" },
], ],
raw: "anthropic/claude-sonnet-4-1", raw: "anthropic/claude-sonnet-4-1",
defaultProvider: "openai", defaultProvider: "openai",
defaultModel: "gpt-4", defaultModel: "gpt-5-nano",
}); });
expect(resolved).toEqual({ expect(resolved).toEqual({
error: "model not allowed: anthropic/claude-sonnet-4-1", error: "model not allowed: anthropic/claude-sonnet-4-1",
+20 -55
View File
@@ -253,17 +253,19 @@ describeLive("live models (profile keys)", () => {
parameters: Type.Object({}, { additionalProperties: false }), parameters: Type.Object({}, { additionalProperties: false }),
}; };
let firstUserContent = const first = await completeSimpleWithTimeout(
"Call the tool `noop` with {}. Do not write any other text.";
let firstUser = {
role: "user" as const,
content: firstUserContent,
timestamp: Date.now(),
};
let first = await completeSimpleWithTimeout(
model, model,
{ messages: [firstUser], tools: [noopTool] }, {
messages: [
{
role: "user",
content:
"Call the tool `noop` with {}. Do not write any other text.",
timestamp: Date.now(),
},
],
tools: [noopTool],
},
{ {
apiKey, apiKey,
reasoning: resolveTestReasoning(model), reasoning: resolveTestReasoning(model),
@@ -272,49 +274,8 @@ describeLive("live models (profile keys)", () => {
perModelTimeoutMs, perModelTimeoutMs,
); );
let toolCall = first.content.find((b) => b.type === "toolCall"); const toolCall = first.content.find((b) => b.type === "toolCall");
let firstText = first.content
.filter((b) => b.type === "text")
.map((b) => b.text.trim())
.join(" ")
.trim();
// Occasional flake: model answers in text instead of tool call (or adds text).
// Retry a couple times with a stronger instruction so we still exercise the tool-only replay path.
for (
let i = 0;
i < 2 && (!toolCall || firstText.length > 0);
i += 1
) {
firstUserContent =
"Call the tool `noop` with {}. IMPORTANT: respond ONLY with the tool call; no other text.";
firstUser = {
role: "user" as const,
content: firstUserContent,
timestamp: Date.now(),
};
first = await completeSimpleWithTimeout(
model,
{ messages: [firstUser], tools: [noopTool] },
{
apiKey,
reasoning: resolveTestReasoning(model),
maxTokens: 128,
},
perModelTimeoutMs,
);
toolCall = first.content.find((b) => b.type === "toolCall");
firstText = first.content
.filter((b) => b.type === "text")
.map((b) => b.text.trim())
.join(" ")
.trim();
}
expect(toolCall).toBeTruthy(); expect(toolCall).toBeTruthy();
expect(firstText.length).toBe(0);
if (!toolCall || toolCall.type !== "toolCall") { if (!toolCall || toolCall.type !== "toolCall") {
throw new Error("expected tool call"); throw new Error("expected tool call");
} }
@@ -323,7 +284,12 @@ describeLive("live models (profile keys)", () => {
model, model,
{ {
messages: [ messages: [
firstUser, {
role: "user",
content:
"Call the tool `noop` with {}. Do not write any other text.",
timestamp: Date.now(),
},
first, first,
{ {
role: "toolResult", role: "toolResult",
@@ -343,8 +309,7 @@ describeLive("live models (profile keys)", () => {
{ {
apiKey, apiKey,
reasoning: resolveTestReasoning(model), reasoning: resolveTestReasoning(model),
// Headroom: reasoning summary can consume most of the output budget. maxTokens: 64,
maxTokens: 256,
}, },
perModelTimeoutMs, perModelTimeoutMs,
); );
-14
View File
@@ -209,11 +209,6 @@ describe("classifyFailoverReason", () => {
expect(classifyFailoverReason("resource has been exhausted")).toBe( expect(classifyFailoverReason("resource has been exhausted")).toBe(
"rate_limit", "rate_limit",
); );
expect(
classifyFailoverReason(
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}',
),
).toBe("rate_limit");
expect(classifyFailoverReason("invalid request format")).toBe("format"); expect(classifyFailoverReason("invalid request format")).toBe("format");
expect(classifyFailoverReason("credit balance too low")).toBe("billing"); expect(classifyFailoverReason("credit balance too low")).toBe("billing");
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout"); expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
@@ -270,15 +265,6 @@ describe("formatAssistantErrorText", () => {
"Message ordering conflict", "Message ordering conflict",
); );
}); });
it("returns a friendly message for Anthropic overload errors", () => {
const msg = makeAssistantError(
'{"type":"error","error":{"details":null,"type":"overloaded_error","message":"Overloaded"},"request_id":"req_123"}',
);
expect(formatAssistantErrorText(msg)).toBe(
"The AI service is temporarily overloaded. Please try again in a moment.",
);
});
}); });
describe("sanitizeToolCallId", () => { describe("sanitizeToolCallId", () => {
+2 -16
View File
@@ -370,8 +370,8 @@ export function formatAssistantErrorText(
// Check for context overflow (413) errors // Check for context overflow (413) errors
if (isContextOverflowError(raw)) { if (isContextOverflowError(raw)) {
return ( return (
"Context overflow: prompt too large for the model. " + "Context overflow: the conversation history is too large. " +
"Try again with less input or a larger-context model." "Use /new or /reset to start a fresh session."
); );
} }
@@ -392,11 +392,6 @@ export function formatAssistantErrorText(
return `LLM request rejected: ${invalidRequest[1]}`; return `LLM request rejected: ${invalidRequest[1]}`;
} }
// Check for overloaded errors (Anthropic API capacity)
if (isOverloadedErrorMessage(raw)) {
return "The AI service is temporarily overloaded. Please try again in a moment.";
}
// Keep it short for WhatsApp. // Keep it short for WhatsApp.
return raw.length > 600 ? `${raw.slice(0, 600)}…` : raw; return raw.length > 600 ? `${raw.slice(0, 600)}…` : raw;
} }
@@ -419,10 +414,6 @@ const ERROR_PATTERNS = {
"resource_exhausted", "resource_exhausted",
"usage limit", "usage limit",
], ],
overloaded: [
/overloaded_error|"type"\s*:\s*"overloaded_error"/i,
"overloaded",
],
timeout: [ timeout: [
"timeout", "timeout",
"timed out", "timed out",
@@ -505,10 +496,6 @@ export function isAuthErrorMessage(raw: string): boolean {
return matchesErrorPatterns(raw, ERROR_PATTERNS.auth); return matchesErrorPatterns(raw, ERROR_PATTERNS.auth);
} }
export function isOverloadedErrorMessage(raw: string): boolean {
return matchesErrorPatterns(raw, ERROR_PATTERNS.overloaded);
}
export function isCloudCodeAssistFormatError(raw: string): boolean { export function isCloudCodeAssistFormatError(raw: string): boolean {
return matchesErrorPatterns(raw, ERROR_PATTERNS.format); return matchesErrorPatterns(raw, ERROR_PATTERNS.format);
} }
@@ -530,7 +517,6 @@ export type FailoverReason =
export function classifyFailoverReason(raw: string): FailoverReason | null { export function classifyFailoverReason(raw: string): FailoverReason | null {
if (isRateLimitErrorMessage(raw)) return "rate_limit"; if (isRateLimitErrorMessage(raw)) return "rate_limit";
if (isOverloadedErrorMessage(raw)) return "rate_limit"; // Treat overloaded as rate limit for failover
if (isCloudCodeAssistFormatError(raw)) return "format"; if (isCloudCodeAssistFormatError(raw)) return "format";
if (isBillingErrorMessage(raw)) return "billing"; if (isBillingErrorMessage(raw)) return "billing";
if (isTimeoutErrorMessage(raw)) return "timeout"; if (isTimeoutErrorMessage(raw)) return "timeout";
@@ -207,11 +207,11 @@ describe("resolveExtraParams", () => {
expect(result).toBeUndefined(); expect(result).toBeUndefined();
}); });
it("returns undefined for openai/gpt-4 with no config", () => { it("returns undefined for openai/gpt-5-nano with no config", () => {
const result = resolveExtraParams({ const result = resolveExtraParams({
cfg: undefined, cfg: undefined,
provider: "openai", provider: "openai",
modelId: "gpt-4", modelId: "gpt-5-nano",
}); });
expect(result).toBeUndefined(); expect(result).toBeUndefined();
@@ -223,7 +223,7 @@ describe("resolveExtraParams", () => {
agents: { agents: {
defaults: { defaults: {
models: { models: {
"openai/gpt-4": { "openai/gpt-5-nano": {
params: { params: {
logprobs: true, logprobs: true,
top_logprobs: 5, top_logprobs: 5,
@@ -234,7 +234,7 @@ describe("resolveExtraParams", () => {
}, },
}, },
provider: "openai", provider: "openai",
modelId: "gpt-4", modelId: "gpt-5-nano",
}); });
expect(result).toEqual({ expect(result).toEqual({
+2 -10
View File
@@ -362,10 +362,6 @@ export type EmbeddedPiRunMeta = {
durationMs: number; durationMs: number;
agentMeta?: EmbeddedPiAgentMeta; agentMeta?: EmbeddedPiAgentMeta;
aborted?: boolean; aborted?: boolean;
error?: {
kind: "context_overflow" | "compaction_failure";
message: string;
};
}; };
function buildModelAliasLines(cfg?: ClawdbotConfig) { function buildModelAliasLines(cfg?: ClawdbotConfig) {
@@ -1980,15 +1976,12 @@ export async function runEmbeddedPiAgent(params: {
if (promptError && !aborted) { if (promptError && !aborted) {
const errorText = describeUnknownError(promptError); const errorText = describeUnknownError(promptError);
if (isContextOverflowError(errorText)) { if (isContextOverflowError(errorText)) {
const kind = isCompactionFailureError(errorText)
? "compaction_failure"
: "context_overflow";
return { return {
payloads: [ payloads: [
{ {
text: text:
"Context overflow: prompt too large for the model. " + "Context overflow: the conversation history is too large for the model. " +
"Try again with less input or a larger-context model.", "Use /new or /reset to start a fresh session, or try a model with a larger context window.",
isError: true, isError: true,
}, },
], ],
@@ -1999,7 +1992,6 @@ export async function runEmbeddedPiAgent(params: {
provider, provider,
model: model.id, model: model.id,
}, },
error: { kind, message: errorText },
}, },
}; };
} }
-8
View File
@@ -251,13 +251,6 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
textAlias: "/queue", textAlias: "/queue",
acceptsArgs: true, acceptsArgs: true,
}), }),
defineChatCommand({
key: "bash",
description: "Run host shell commands (host-only).",
textAlias: "/bash",
scope: "text",
acceptsArgs: true,
}),
]; ];
registerAlias(commands, "status", "/usage"); registerAlias(commands, "status", "/usage");
@@ -321,7 +314,6 @@ export function isCommandEnabled(
): boolean { ): boolean {
if (commandKey === "config") return cfg.commands?.config === true; if (commandKey === "config") return cfg.commands?.config === true;
if (commandKey === "debug") return cfg.commands?.debug === true; if (commandKey === "debug") return cfg.commands?.debug === true;
if (commandKey === "bash") return cfg.commands?.bash === true;
return true; return true;
} }
+1 -1
View File
@@ -42,7 +42,7 @@ describe("block streaming", () => {
piEmbeddedMock.runEmbeddedPiAgent.mockReset(); piEmbeddedMock.runEmbeddedPiAgent.mockReset();
vi.mocked(loadModelCatalog).mockResolvedValue([ vi.mocked(loadModelCatalog).mockResolvedValue([
{ id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" }, { id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" },
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, { id: "gpt-5-nano", name: "GPT-5 Nano", provider: "openai" },
]); ]);
}); });
+26 -26
View File
@@ -61,7 +61,7 @@ describe("directive behavior", () => {
vi.mocked(loadModelCatalog).mockResolvedValue([ vi.mocked(loadModelCatalog).mockResolvedValue([
{ id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" }, { id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" },
{ id: "claude-sonnet-4-1", name: "Sonnet 4.1", provider: "anthropic" }, { id: "claude-sonnet-4-1", name: "Sonnet 4.1", provider: "anthropic" },
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, { id: "gpt-5-nano", name: "GPT-5 Nano", provider: "openai" },
]); ]);
}); });
@@ -143,7 +143,7 @@ describe("directive behavior", () => {
{ {
agents: { agents: {
defaults: { defaults: {
model: "openai/gpt-4.1-mini", model: "openai/gpt-5-nano",
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
}, },
}, },
@@ -1655,7 +1655,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
models: { models: {
"anthropic/claude-opus-4-5": {}, "anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {}, "openai/gpt-5-nano": {},
}, },
}, },
}, },
@@ -1666,7 +1666,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("anthropic/claude-opus-4-5"); expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("Pick: /model <#> or /model <provider/model>"); expect(text).toContain("Pick: /model <#> or /model <provider/model>");
expect(text).toContain("gpt-4.1-mini — openai"); expect(text).toContain("gpt-5-nano — openai");
expect(text).not.toContain("claude-sonnet-4-1"); expect(text).not.toContain("claude-sonnet-4-1");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
@@ -1687,7 +1687,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
models: { models: {
"anthropic/claude-opus-4-5": {}, "anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {}, "openai/gpt-5-nano": {},
}, },
}, },
}, },
@@ -1697,7 +1697,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("anthropic/claude-opus-4-5"); expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini"); expect(text).toContain("openai/gpt-5-nano");
expect(text).not.toContain("claude-sonnet-4-1"); expect(text).not.toContain("claude-sonnet-4-1");
expect(text).toContain("auth:"); expect(text).toContain("auth:");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
@@ -1719,7 +1719,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
models: { models: {
"anthropic/claude-opus-4-5": {}, "anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {}, "openai/gpt-5-nano": {},
}, },
}, },
}, },
@@ -1730,7 +1730,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Pick: /model <#> or /model <provider/model>"); expect(text).toContain("Pick: /model <#> or /model <provider/model>");
expect(text).toContain("claude-opus-4-5 — anthropic"); expect(text).toContain("claude-opus-4-5 — anthropic");
expect(text).toContain("gpt-4.1-mini — openai"); expect(text).toContain("gpt-5-nano — openai");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });
@@ -1751,7 +1751,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
models: { models: {
"anthropic/claude-opus-4-5": {}, "anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {}, "openai/gpt-5-nano": {},
}, },
}, },
}, },
@@ -1762,7 +1762,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Pick: /model <#> or /model <provider/model>"); expect(text).toContain("Pick: /model <#> or /model <provider/model>");
expect(text).toContain("claude-opus-4-5 — anthropic"); expect(text).toContain("claude-opus-4-5 — anthropic");
expect(text).toContain("gpt-4.1-mini — openai"); expect(text).toContain("gpt-5-nano — openai");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });
@@ -1778,7 +1778,7 @@ describe("directive behavior", () => {
id: "claude-opus-4-5", id: "claude-opus-4-5",
name: "Claude Opus 4.5", name: "Claude Opus 4.5",
}, },
{ provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" }, { provider: "openai", id: "gpt-5-nano", name: "GPT-5 Nano" },
]); ]);
const storePath = path.join(home, "sessions.json"); const storePath = path.join(home, "sessions.json");
@@ -1792,7 +1792,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
models: { models: {
"anthropic/claude-opus-4-5": {}, "anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {}, "openai/gpt-5-nano": {},
"minimax/MiniMax-M2.1": { alias: "minimax" }, "minimax/MiniMax-M2.1": { alias: "minimax" },
}, },
}, },
@@ -1813,7 +1813,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("claude-opus-4-5 — anthropic"); expect(text).toContain("claude-opus-4-5 — anthropic");
expect(text).toContain("gpt-4.1-mini — openai"); expect(text).toContain("gpt-5-nano — openai");
expect(text).toContain("MiniMax-M2.1 — minimax"); expect(text).toContain("MiniMax-M2.1 — minimax");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
@@ -1853,7 +1853,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json"); const storePath = path.join(home, "sessions.json");
await getReplyFromConfig( await getReplyFromConfig(
{ Body: "/model openai/gpt-4.1-mini", From: "+1222", To: "+1222" }, { Body: "/model openai/gpt-5-nano", From: "+1222", To: "+1222" },
{}, {},
{ {
agents: { agents: {
@@ -1862,7 +1862,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
models: { models: {
"anthropic/claude-opus-4-5": {}, "anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {}, "openai/gpt-5-nano": {},
}, },
}, },
}, },
@@ -1871,7 +1871,7 @@ describe("directive behavior", () => {
); );
assertModelSelection(storePath, { assertModelSelection(storePath, {
model: "gpt-4.1-mini", model: "gpt-5-nano",
provider: "openai", provider: "openai",
}); });
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
@@ -1889,10 +1889,10 @@ describe("directive behavior", () => {
{ {
agents: { agents: {
defaults: { defaults: {
model: { primary: "openai/gpt-4.1-mini" }, model: { primary: "openai/gpt-5-nano" },
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
models: { models: {
"openai/gpt-4.1-mini": {}, "openai/gpt-5-nano": {},
"anthropic/claude-opus-4-5": { alias: "Opus" }, "anthropic/claude-opus-4-5": { alias: "Opus" },
}, },
}, },
@@ -2209,10 +2209,10 @@ describe("directive behavior", () => {
{ {
agents: { agents: {
defaults: { defaults: {
model: { primary: "openai/gpt-4.1-mini" }, model: { primary: "openai/gpt-5-nano" },
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
models: { models: {
"openai/gpt-4.1-mini": {}, "openai/gpt-5-nano": {},
"anthropic/claude-opus-4-5": { alias: "Opus" }, "anthropic/claude-opus-4-5": { alias: "Opus" },
}, },
}, },
@@ -2242,10 +2242,10 @@ describe("directive behavior", () => {
{ {
agents: { agents: {
defaults: { defaults: {
model: { primary: "openai/gpt-4.1-mini" }, model: { primary: "openai/gpt-5-nano" },
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
models: { models: {
"openai/gpt-4.1-mini": {}, "openai/gpt-5-nano": {},
"anthropic/claude-opus-4-5": { alias: "Opus" }, "anthropic/claude-opus-4-5": { alias: "Opus" },
}, },
}, },
@@ -2278,7 +2278,7 @@ describe("directive behavior", () => {
{ {
agents: { agents: {
defaults: { defaults: {
model: { primary: "openai/gpt-4.1-mini" }, model: { primary: "openai/gpt-5-nano" },
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
}, },
}, },
@@ -2309,7 +2309,7 @@ describe("directive behavior", () => {
{ {
agents: { agents: {
defaults: { defaults: {
model: { primary: "openai/gpt-4.1-mini" }, model: { primary: "openai/gpt-5-nano" },
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
}, },
}, },
@@ -2336,7 +2336,7 @@ describe("directive behavior", () => {
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
{ {
Body: "please sync /model openai/gpt-4.1-mini now", Body: "please sync /model openai/gpt-5-nano now",
From: "+1004", From: "+1004",
To: "+2000", To: "+2000",
}, },
@@ -2348,7 +2348,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
models: { models: {
"anthropic/claude-opus-4-5": {}, "anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {}, "openai/gpt-5-nano": {},
}, },
}, },
}, },
+6 -6
View File
@@ -41,7 +41,7 @@ const modelCatalogMocks = vi.hoisted(() => ({
name: "Claude Opus 4.5 (OpenRouter)", name: "Claude Opus 4.5 (OpenRouter)",
contextWindow: 200000, contextWindow: 200000,
}, },
{ provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" }, { provider: "openai", id: "gpt-5-nano", name: "GPT-5 Nano" },
{ provider: "openai", id: "gpt-5.2", name: "GPT-5.2" }, { provider: "openai", id: "gpt-5.2", name: "GPT-5.2" },
{ provider: "openai-codex", id: "gpt-5.2", name: "GPT-5.2 (Codex)" }, { provider: "openai-codex", id: "gpt-5.2", name: "GPT-5.2 (Codex)" },
{ provider: "minimax", id: "MiniMax-M2.1", name: "MiniMax M2.1" }, { provider: "minimax", id: "MiniMax-M2.1", name: "MiniMax M2.1" },
@@ -320,7 +320,7 @@ describe("trigger handling", () => {
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
{ {
Body: "/model openai/gpt-4.1-mini", Body: "/model openai/gpt-5-nano",
From: "telegram:111", From: "telegram:111",
To: "telegram:111", To: "telegram:111",
ChatType: "direct", ChatType: "direct",
@@ -336,11 +336,11 @@ describe("trigger handling", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Model set to openai/gpt-4.1-mini"); expect(text).toContain("Model set to openai/gpt-5-nano");
const store = loadSessionStore(cfg.session.store); const store = loadSessionStore(cfg.session.store);
expect(store[targetSessionKey]?.providerOverride).toBe("openai"); expect(store[targetSessionKey]?.providerOverride).toBe("openai");
expect(store[targetSessionKey]?.modelOverride).toBe("gpt-4.1-mini"); expect(store[targetSessionKey]?.modelOverride).toBe("gpt-5-nano");
expect(store[slashSessionKey]).toBeUndefined(); expect(store[slashSessionKey]).toBeUndefined();
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
@@ -368,7 +368,7 @@ describe("trigger handling", () => {
expect(vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]).toEqual( expect(vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]).toEqual(
expect.objectContaining({ expect.objectContaining({
provider: "openai", provider: "openai",
model: "gpt-4.1-mini", model: "gpt-5-nano",
}), }),
); );
}); });
@@ -1456,7 +1456,7 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe( expect(text).toBe(
"⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model.", "⚠️ Context overflow - conversation too long. Starting fresh might help!",
); );
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
}); });
-10
View File
@@ -981,11 +981,6 @@ export async function getReplyFromConfig(
command: inlineCommandContext, command: inlineCommandContext,
agentId, agentId,
directives, directives,
elevated: {
enabled: elevatedEnabled,
allowed: elevatedAllowed,
failures: elevatedFailures,
},
sessionEntry, sessionEntry,
sessionStore, sessionStore,
sessionKey, sessionKey,
@@ -1043,11 +1038,6 @@ export async function getReplyFromConfig(
command, command,
agentId, agentId,
directives, directives,
elevated: {
enabled: elevatedEnabled,
allowed: elevatedAllowed,
failures: elevatedFailures,
},
sessionEntry, sessionEntry,
sessionStore, sessionStore,
sessionKey, sessionKey,
@@ -525,65 +525,6 @@ describe("runReplyAgent typing (heartbeat)", () => {
} }
}); });
it("retries after context overflow payload by resetting the session", async () => {
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
const stateDir = await fs.mkdtemp(
path.join(tmpdir(), "clawdbot-session-overflow-reset-"),
);
process.env.CLAWDBOT_STATE_DIR = stateDir;
try {
const sessionId = "session";
const storePath = path.join(stateDir, "sessions", "sessions.json");
const sessionEntry = { sessionId, updatedAt: Date.now() };
const sessionStore = { main: sessionEntry };
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
runEmbeddedPiAgentMock
.mockImplementationOnce(async () => ({
payloads: [
{ text: "Context overflow: prompt too large", isError: true },
],
meta: {
durationMs: 1,
error: {
kind: "context_overflow",
message:
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
},
},
}))
.mockImplementationOnce(async () => ({
payloads: [{ text: "ok" }],
meta: { durationMs: 1 },
}));
const callsBefore = runEmbeddedPiAgentMock.mock.calls.length;
const { run } = createMinimalRun({
sessionEntry,
sessionStore,
sessionKey: "main",
storePath,
});
const res = await run();
expect(runEmbeddedPiAgentMock.mock.calls.length - callsBefore).toBe(2);
const payload = Array.isArray(res) ? res[0] : res;
expect(payload).toMatchObject({ text: "ok" });
expect(sessionStore.main.sessionId).not.toBe(sessionId);
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId);
} finally {
if (prevStateDir) {
process.env.CLAWDBOT_STATE_DIR = prevStateDir;
} else {
delete process.env.CLAWDBOT_STATE_DIR;
}
}
});
it("still replies even if session reset fails to persist", async () => { it("still replies even if session reset fails to persist", async () => {
const prevStateDir = process.env.CLAWDBOT_STATE_DIR; const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
const stateDir = await fs.mkdtemp( const stateDir = await fs.mkdtemp(
+1 -15
View File
@@ -834,20 +834,6 @@ export async function runReplyAgent(params: {
runResult = fallbackResult.result; runResult = fallbackResult.result;
fallbackProvider = fallbackResult.provider; fallbackProvider = fallbackResult.provider;
fallbackModel = fallbackResult.model; fallbackModel = fallbackResult.model;
// Some embedded runs surface context overflow as an error payload instead of throwing.
// Treat those as a session-level failure and auto-recover by starting a fresh session.
const embeddedError = runResult.meta?.error;
if (
embeddedError &&
isContextOverflowError(embeddedError.message) &&
!didResetAfterCompactionFailure &&
(await resetSessionAfterCompactionFailure(embeddedError.message))
) {
didResetAfterCompactionFailure = true;
continue;
}
break; break;
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
@@ -908,7 +894,7 @@ export async function runReplyAgent(params: {
defaultRuntime.error(`Embedded agent failed before reply: ${message}`); defaultRuntime.error(`Embedded agent failed before reply: ${message}`);
return finalizeWithFollowup({ return finalizeWithFollowup({
text: isContextOverflow text: isContextOverflow
? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model." ? "⚠️ Context overflow - conversation too long. Starting fresh might help!"
: `⚠️ Agent failed before reply: ${message}. Check gateway logs for details.`, : `⚠️ Agent failed before reply: ${message}. Check gateway logs for details.`,
}); });
} }
-416
View File
@@ -1,416 +0,0 @@
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import {
getFinishedSession,
getSession,
markExited,
} from "../../agents/bash-process-registry.js";
import { createExecTool } from "../../agents/bash-tools.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import { killProcessTree } from "../../agents/shell-utils.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { logVerbose } from "../../globals.js";
import type { MsgContext } from "../templating.js";
import type { ReplyPayload } from "../types.js";
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
const CHAT_BASH_SCOPE_KEY = "chat:bash";
const DEFAULT_FOREGROUND_MS = 2000;
const MAX_FOREGROUND_MS = 30_000;
type BashRequest =
| { action: "help" }
| { action: "run"; command: string }
| { action: "poll"; sessionId?: string }
| { action: "stop"; sessionId?: string };
type ActiveBashJob =
| { state: "starting"; startedAt: number; command: string }
| {
state: "running";
sessionId: string;
startedAt: number;
command: string;
watcherAttached: boolean;
};
let activeJob: ActiveBashJob | null = null;
function clampNumber(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function resolveForegroundMs(cfg: ClawdbotConfig): number {
const raw = cfg.commands?.bashForegroundMs;
if (typeof raw !== "number" || Number.isNaN(raw))
return DEFAULT_FOREGROUND_MS;
return clampNumber(Math.floor(raw), 0, MAX_FOREGROUND_MS);
}
function formatSessionSnippet(sessionId: string) {
const trimmed = sessionId.trim();
if (trimmed.length <= 12) return trimmed;
return `${trimmed.slice(0, 8)}…`;
}
function formatOutputBlock(text: string) {
const trimmed = text.trim();
if (!trimmed) return "(no output)";
return `\`\`\`txt\n${trimmed}\n\`\`\``;
}
function parseBashRequest(raw: string): BashRequest | null {
const trimmed = raw.trimStart();
let restSource = "";
if (trimmed.toLowerCase().startsWith("/bash")) {
const match = trimmed.match(/^\/bash(?:\s*:\s*|\s+|$)([\s\S]*)$/i);
if (!match) return null;
restSource = match[1] ?? "";
} else if (trimmed.startsWith("!")) {
restSource = trimmed.slice(1);
if (restSource.trimStart().startsWith(":")) {
restSource = restSource.trimStart().slice(1);
}
} else {
return null;
}
const rest = restSource.trimStart();
if (!rest) return { action: "help" };
const tokenMatch = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/);
const token = tokenMatch?.[1]?.trim() ?? "";
const remainder = tokenMatch?.[2]?.trim() ?? "";
const lowered = token.toLowerCase();
if (lowered === "poll") {
return { action: "poll", sessionId: remainder || undefined };
}
if (lowered === "stop") {
return { action: "stop", sessionId: remainder || undefined };
}
if (lowered === "help") {
return { action: "help" };
}
return { action: "run", command: rest };
}
function resolveRawCommandBody(params: {
ctx: MsgContext;
cfg: ClawdbotConfig;
agentId?: string;
isGroup: boolean;
}) {
const source =
params.ctx.CommandBody ?? params.ctx.RawBody ?? params.ctx.Body ?? "";
const stripped = stripStructuralPrefixes(source);
return params.isGroup
? stripMentions(stripped, params.ctx, params.cfg, params.agentId)
: stripped;
}
function getScopedSession(sessionId: string) {
const running = getSession(sessionId);
if (running && running.scopeKey === CHAT_BASH_SCOPE_KEY) return { running };
const finished = getFinishedSession(sessionId);
if (finished && finished.scopeKey === CHAT_BASH_SCOPE_KEY)
return { finished };
return {};
}
function ensureActiveJobState() {
if (!activeJob) return null;
if (activeJob.state === "starting") return activeJob;
const { running, finished } = getScopedSession(activeJob.sessionId);
if (running) return activeJob;
if (finished) {
activeJob = null;
return null;
}
activeJob = null;
return null;
}
function attachActiveWatcher(sessionId: string) {
if (!activeJob || activeJob.state !== "running") return;
if (activeJob.sessionId !== sessionId) return;
if (activeJob.watcherAttached) return;
const { running } = getScopedSession(sessionId);
const child = running?.child;
if (!child) return;
activeJob.watcherAttached = true;
child.once("close", () => {
if (activeJob?.state === "running" && activeJob.sessionId === sessionId) {
activeJob = null;
}
});
}
function buildUsageReply(): ReplyPayload {
return {
text: [
"⚙️ Usage:",
"- ! <command>",
"- !poll | ! poll",
"- !stop | ! stop",
"- /bash ... (alias; same subcommands as !)",
].join("\n"),
};
}
function formatElevatedUnavailableMessage(params: {
runtimeSandboxed: boolean;
failures: Array<{ gate: string; key: string }>;
sessionKey?: string;
}): string {
const lines: string[] = [];
lines.push(
`elevated is not available right now (runtime=${params.runtimeSandboxed ? "sandboxed" : "direct"}).`,
);
if (params.failures.length > 0) {
lines.push(
`Failing gates: ${params.failures
.map((f) => `${f.gate} (${f.key})`)
.join(", ")}`,
);
} else {
lines.push(
"Failing gates: enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled), allowFrom (tools.elevated.allowFrom.<provider>).",
);
}
lines.push("Fix-it keys:");
lines.push("- tools.elevated.enabled");
lines.push("- tools.elevated.allowFrom.<provider>");
lines.push("- agents.list[].tools.elevated.enabled");
lines.push("- agents.list[].tools.elevated.allowFrom.<provider>");
if (params.sessionKey) {
lines.push(`See: clawdbot sandbox explain --session ${params.sessionKey}`);
}
return lines.join("\n");
}
export async function handleBashChatCommand(params: {
ctx: MsgContext;
cfg: ClawdbotConfig;
agentId?: string;
sessionKey: string;
isGroup: boolean;
elevated: {
enabled: boolean;
allowed: boolean;
failures: Array<{ gate: string; key: string }>;
};
}): Promise<ReplyPayload> {
if (params.cfg.commands?.bash !== true) {
return {
text: "⚠️ bash is disabled. Set commands.bash=true to enable.",
};
}
const agentId =
params.agentId ??
resolveSessionAgentId({
sessionKey: params.sessionKey,
config: params.cfg,
});
if (!params.elevated.enabled || !params.elevated.allowed) {
const runtimeSandboxed = resolveSandboxRuntimeStatus({
cfg: params.cfg,
sessionKey: params.ctx.SessionKey,
}).sandboxed;
return {
text: formatElevatedUnavailableMessage({
runtimeSandboxed,
failures: params.elevated.failures,
sessionKey: params.ctx.SessionKey,
}),
};
}
const rawBody = resolveRawCommandBody({
ctx: params.ctx,
cfg: params.cfg,
agentId,
isGroup: params.isGroup,
}).trim();
const request = parseBashRequest(rawBody);
if (!request) {
return { text: "⚠️ Unrecognized bash request." };
}
const liveJob = ensureActiveJobState();
if (request.action === "help") {
return buildUsageReply();
}
if (request.action === "poll") {
const sessionId =
request.sessionId?.trim() ||
(liveJob?.state === "running" ? liveJob.sessionId : "");
if (!sessionId) {
return { text: "⚙️ No active bash job." };
}
const { running, finished } = getScopedSession(sessionId);
if (running) {
attachActiveWatcher(sessionId);
const runtimeSec = Math.max(
0,
Math.floor((Date.now() - running.startedAt) / 1000),
);
const tail = running.tail || "(no output yet)";
return {
text: [
`⚙️ bash still running (session ${formatSessionSnippet(sessionId)}, ${runtimeSec}s).`,
formatOutputBlock(tail),
"Hint: !stop (or /bash stop)",
].join("\n"),
};
}
if (finished) {
if (activeJob?.state === "running" && activeJob.sessionId === sessionId) {
activeJob = null;
}
const exitLabel = finished.exitSignal
? `signal ${String(finished.exitSignal)}`
: `code ${String(finished.exitCode ?? 0)}`;
const prefix = finished.status === "completed" ? "⚙️" : "⚠️";
return {
text: [
`${prefix} bash finished (session ${formatSessionSnippet(sessionId)}).`,
`Exit: ${exitLabel}`,
formatOutputBlock(finished.aggregated || finished.tail),
].join("\n"),
};
}
if (activeJob?.state === "running" && activeJob.sessionId === sessionId) {
activeJob = null;
}
return {
text: `⚙️ No bash session found for ${formatSessionSnippet(sessionId)}.`,
};
}
if (request.action === "stop") {
const sessionId =
request.sessionId?.trim() ||
(liveJob?.state === "running" ? liveJob.sessionId : "");
if (!sessionId) {
return { text: "⚙️ No active bash job." };
}
const { running } = getScopedSession(sessionId);
if (!running) {
if (activeJob?.state === "running" && activeJob.sessionId === sessionId) {
activeJob = null;
}
return {
text: `⚙️ No running bash job found for ${formatSessionSnippet(sessionId)}.`,
};
}
if (!running.backgrounded) {
return {
text: `⚠️ Session ${formatSessionSnippet(sessionId)} is not backgrounded.`,
};
}
const pid = running.pid ?? running.child?.pid;
if (pid) {
killProcessTree(pid);
}
markExited(running, null, "SIGKILL", "failed");
if (activeJob?.state === "running" && activeJob.sessionId === sessionId) {
activeJob = null;
}
return {
text: `⚙️ bash stopped (session ${formatSessionSnippet(sessionId)}).`,
};
}
// request.action === "run"
if (liveJob) {
const label =
liveJob.state === "running"
? formatSessionSnippet(liveJob.sessionId)
: "starting";
return {
text: `⚠️ A bash job is already running (${label}). Use !poll / !stop (or /bash poll / /bash stop).`,
};
}
const commandText = request.command.trim();
if (!commandText) return buildUsageReply();
activeJob = {
state: "starting",
startedAt: Date.now(),
command: commandText,
};
try {
const foregroundMs = resolveForegroundMs(params.cfg);
const shouldBackgroundImmediately = foregroundMs <= 0;
const timeoutSec =
params.cfg.tools?.exec?.timeoutSec ?? params.cfg.tools?.bash?.timeoutSec;
const execTool = createExecTool({
scopeKey: CHAT_BASH_SCOPE_KEY,
allowBackground: true,
timeoutSec,
elevated: {
enabled: params.elevated.enabled,
allowed: params.elevated.allowed,
defaultLevel: "on",
},
});
const result = await execTool.execute("chat-bash", {
command: commandText,
background: shouldBackgroundImmediately,
yieldMs: shouldBackgroundImmediately ? undefined : foregroundMs,
timeout: timeoutSec,
elevated: true,
});
if (result.details?.status === "running") {
const sessionId = result.details.sessionId;
activeJob = {
state: "running",
sessionId,
startedAt: result.details.startedAt,
command: commandText,
watcherAttached: false,
};
attachActiveWatcher(sessionId);
const snippet = formatSessionSnippet(sessionId);
logVerbose(`Started bash session ${snippet}: ${commandText}`);
return {
text: `⚙️ bash started (session ${sessionId}). Still running; use !poll / !stop (or /bash poll / /bash stop).`,
};
}
// Completed in foreground.
activeJob = null;
const exitCode =
result.details?.status === "completed" ? result.details.exitCode : 0;
const output =
result.details?.status === "completed"
? result.details.aggregated
: result.content
.map((chunk) => (chunk.type === "text" ? chunk.text : ""))
.join("\n");
return {
text: [
`⚙️ bash: ${commandText}`,
`Exit: ${exitCode}`,
formatOutputBlock(output || "(no output)"),
].join("\n"),
};
} catch (err) {
activeJob = null;
const message = err instanceof Error ? err.message : String(err);
return {
text: [`⚠️ bash failed: ${commandText}`, formatOutputBlock(message)].join(
"\n",
),
};
}
}
export function resetBashChatCommandForTests() {
activeJob = null;
}
-59
View File
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js"; import type { MsgContext } from "../templating.js";
import { resetBashChatCommandForTests } from "./bash-command.js";
import { buildCommandContext, handleCommands } from "./commands.js"; import { buildCommandContext, handleCommands } from "./commands.js";
import { parseInlineDirectives } from "./directive-handling.js"; import { parseInlineDirectives } from "./directive-handling.js";
@@ -34,7 +33,6 @@ function buildParams(
cfg, cfg,
command, command,
directives: parseInlineDirectives(commandBody), directives: parseInlineDirectives(commandBody),
elevated: { enabled: true, allowed: true, failures: [] },
sessionKey: "agent:main:main", sessionKey: "agent:main:main",
workspaceDir: "/tmp", workspaceDir: "/tmp",
defaultGroupActivation: () => "mention", defaultGroupActivation: () => "mention",
@@ -49,37 +47,6 @@ function buildParams(
} }
describe("handleCommands gating", () => { describe("handleCommands gating", () => {
it("blocks /bash when disabled", async () => {
resetBashChatCommandForTests();
const cfg = {
commands: { bash: false, text: true },
whatsapp: { allowFrom: ["*"] },
} as ClawdbotConfig;
const params = buildParams("/bash echo hi", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("bash is disabled");
});
it("blocks /bash when elevated is not allowlisted", async () => {
resetBashChatCommandForTests();
const cfg = {
commands: { bash: true, text: true },
whatsapp: { allowFrom: ["*"] },
} as ClawdbotConfig;
const params = buildParams("/bash echo hi", cfg);
params.elevated = {
enabled: true,
allowed: false,
failures: [
{ gate: "allowFrom", key: "tools.elevated.allowFrom.whatsapp" },
],
};
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("elevated is not available");
});
it("blocks /config when disabled", async () => { it("blocks /config when disabled", async () => {
const cfg = { const cfg = {
commands: { config: false, debug: false, text: true }, commands: { config: false, debug: false, text: true },
@@ -103,32 +70,6 @@ describe("handleCommands gating", () => {
}); });
}); });
describe("handleCommands bash alias", () => {
it("routes !poll through the /bash handler", async () => {
resetBashChatCommandForTests();
const cfg = {
commands: { bash: true, text: true },
whatsapp: { allowFrom: ["*"] },
} as ClawdbotConfig;
const params = buildParams("!poll", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("No active bash job");
});
it("routes !stop through the /bash handler", async () => {
resetBashChatCommandForTests();
const cfg = {
commands: { bash: true, text: true },
whatsapp: { allowFrom: ["*"] },
} as ClawdbotConfig;
const params = buildParams("!stop", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("No active bash job");
});
});
describe("handleCommands identity", () => { describe("handleCommands identity", () => {
it("returns sender details for /whoami", async () => { it("returns sender details for /whoami", async () => {
const cfg = { const cfg = {
-31
View File
@@ -83,7 +83,6 @@ import type {
} from "../thinking.js"; } from "../thinking.js";
import type { ReplyPayload } from "../types.js"; import type { ReplyPayload } from "../types.js";
import { isAbortTrigger, setAbortMemory } from "./abort.js"; import { isAbortTrigger, setAbortMemory } from "./abort.js";
import { handleBashChatCommand } from "./bash-command.js";
import { parseConfigCommand } from "./config-commands.js"; import { parseConfigCommand } from "./config-commands.js";
import { parseDebugCommand } from "./debug-commands.js"; import { parseDebugCommand } from "./debug-commands.js";
import type { InlineDirectives } from "./directive-handling.js"; import type { InlineDirectives } from "./directive-handling.js";
@@ -401,11 +400,6 @@ export async function handleCommands(params: {
command: CommandContext; command: CommandContext;
agentId?: string; agentId?: string;
directives: InlineDirectives; directives: InlineDirectives;
elevated: {
enabled: boolean;
allowed: boolean;
failures: Array<{ gate: string; key: string }>;
};
sessionEntry?: SessionEntry; sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>; sessionStore?: Record<string, SessionEntry>;
sessionKey: string; sessionKey: string;
@@ -431,7 +425,6 @@ export async function handleCommands(params: {
cfg, cfg,
command, command,
directives, directives,
elevated,
sessionEntry, sessionEntry,
sessionStore, sessionStore,
sessionKey, sessionKey,
@@ -472,30 +465,6 @@ export async function handleCommands(params: {
commandSource: ctx.CommandSource, commandSource: ctx.CommandSource,
}); });
const bashSlashRequested =
allowTextCommands &&
(command.commandBodyNormalized === "/bash" ||
command.commandBodyNormalized.startsWith("/bash "));
const bashBangRequested =
allowTextCommands && command.commandBodyNormalized.startsWith("!");
if (bashSlashRequested || (bashBangRequested && command.isAuthorizedSender)) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /bash from unauthorized sender: ${command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
const reply = await handleBashChatCommand({
ctx,
cfg,
agentId: params.agentId,
sessionKey,
isGroup,
elevated,
});
return { shouldContinue: false, reply };
}
if (allowTextCommands && activationCommand.hasCommand) { if (allowTextCommands && activationCommand.hasCommand) {
if (!isGroup) { if (!isGroup) {
return { return {
+2 -2
View File
@@ -122,7 +122,7 @@ describe("buildStatusMessage", () => {
sessionId: "override-1", sessionId: "override-1",
updatedAt: 0, updatedAt: 0,
providerOverride: "openai", providerOverride: "openai",
modelOverride: "gpt-4.1-mini", modelOverride: "gpt-5-nano",
modelProvider: "anthropic", modelProvider: "anthropic",
model: "claude-haiku-4-5", model: "claude-haiku-4-5",
contextTokens: 32_000, contextTokens: 32_000,
@@ -133,7 +133,7 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key", modelAuth: "api-key",
}); });
expect(normalizeTestText(text)).toContain("Model: openai/gpt-4.1-mini"); expect(normalizeTestText(text)).toContain("Model: openai/gpt-5-nano");
}); });
it("keeps provider prefix from configured model", () => { it("keeps provider prefix from configured model", () => {
+1 -3
View File
@@ -25,9 +25,7 @@ describe("listThinkingLevels", () => {
}); });
it("excludes xhigh for non-codex models", () => { it("excludes xhigh for non-codex models", () => {
expect(listThinkingLevels(undefined, "gpt-4.1-mini")).not.toContain( expect(listThinkingLevels(undefined, "gpt-5-nano")).not.toContain("xhigh");
"xhigh",
);
}); });
}); });
+3 -3
View File
@@ -152,10 +152,10 @@ describe("agentCommand", () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const store = path.join(home, "sessions.json"); const store = path.join(home, "sessions.json");
mockConfig(home, store, { mockConfig(home, store, {
model: { primary: "openai/gpt-4.1-mini" }, model: { primary: "openai/gpt-5-nano" },
models: { models: {
"anthropic/claude-opus-4-5": {}, "anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {}, "openai/gpt-5-nano": {},
}, },
}); });
@@ -163,7 +163,7 @@ describe("agentCommand", () => {
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.provider).toBe("openai"); expect(callArgs?.provider).toBe("openai");
expect(callArgs?.model).toBe("gpt-4.1-mini"); expect(callArgs?.model).toBe("gpt-5-nano");
}); });
}); });
+1 -1
View File
@@ -27,7 +27,7 @@ describe("agents helpers", () => {
name: "Work", name: "Work",
workspace: "/work-ws", workspace: "/work-ws",
agentDir: "/state/agents/work/agent", agentDir: "/state/agents/work/agent",
model: "openai/gpt-4.1", model: "openai/gpt-5-nano",
}, },
], ],
}, },
+28 -11
View File
@@ -18,13 +18,6 @@ vi.mock("../providers/github-copilot-auth.js", () => ({
const noopAsync = async () => {}; const noopAsync = async () => {};
const noop = () => {}; const noop = () => {};
const authProfilePathFor = (agentDir: string) =>
path.join(agentDir, "auth-profiles.json");
const requireAgentDir = () => {
const agentDir = process.env.CLAWDBOT_AGENT_DIR;
if (!agentDir) throw new Error("CLAWDBOT_AGENT_DIR not set");
return agentDir;
};
describe("applyAuthChoice", () => { describe("applyAuthChoice", () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR; const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
@@ -118,7 +111,13 @@ describe("applyAuthChoice", () => {
mode: "api_key", mode: "api_key",
}); });
const authProfilePath = authProfilePathFor(requireAgentDir()); const authProfilePath = path.join(
tempStateDir,
"agents",
"main",
"agent",
"auth-profiles.json",
);
const raw = await fs.readFile(authProfilePath, "utf8"); const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as { const parsed = JSON.parse(raw) as {
profiles?: Record<string, { key?: string }>; profiles?: Record<string, { key?: string }>;
@@ -171,7 +170,13 @@ describe("applyAuthChoice", () => {
mode: "api_key", mode: "api_key",
}); });
const authProfilePath = authProfilePathFor(requireAgentDir()); const authProfilePath = path.join(
tempStateDir,
"agents",
"main",
"agent",
"auth-profiles.json",
);
const raw = await fs.readFile(authProfilePath, "utf8"); const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as { const parsed = JSON.parse(raw) as {
profiles?: Record<string, { key?: string }>; profiles?: Record<string, { key?: string }>;
@@ -332,7 +337,13 @@ describe("applyAuthChoice", () => {
"openrouter/auto", "openrouter/auto",
); );
const authProfilePath = authProfilePathFor(requireAgentDir()); const authProfilePath = path.join(
tempStateDir,
"agents",
"main",
"agent",
"auth-profiles.json",
);
const raw = await fs.readFile(authProfilePath, "utf8"); const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as { const parsed = JSON.parse(raw) as {
profiles?: Record<string, { key?: string }>; profiles?: Record<string, { key?: string }>;
@@ -415,7 +426,13 @@ describe("applyAuthChoice", () => {
mode: "oauth", mode: "oauth",
}); });
const authProfilePath = authProfilePathFor(requireAgentDir()); const authProfilePath = path.join(
tempStateDir,
"agents",
"main",
"agent",
"auth-profiles.json",
);
const raw = await fs.readFile(authProfilePath, "utf8"); const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as { const parsed = JSON.parse(raw) as {
profiles?: Record< profiles?: Record<
-37
View File
@@ -1,37 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { note } from "../terminal/note.js";
export function noteSourceInstallIssues(root: string | null) {
if (!root) return;
const workspaceMarker = path.join(root, "pnpm-workspace.yaml");
if (!fs.existsSync(workspaceMarker)) return;
const warnings: string[] = [];
const nodeModules = path.join(root, "node_modules");
const pnpmStore = path.join(nodeModules, ".pnpm");
const tsxBin = path.join(nodeModules, ".bin", "tsx");
const srcEntry = path.join(root, "src", "entry.ts");
if (fs.existsSync(nodeModules) && !fs.existsSync(pnpmStore)) {
warnings.push(
"- node_modules was not installed by pnpm (missing node_modules/.pnpm). Run: pnpm install",
);
}
if (fs.existsSync(path.join(root, "package-lock.json"))) {
warnings.push(
"- package-lock.json present in a pnpm workspace. If you ran npm install, remove it and reinstall with pnpm.",
);
}
if (fs.existsSync(srcEntry) && !fs.existsSync(tsxBin)) {
warnings.push("- tsx binary is missing for source runs. Run: pnpm install");
}
if (warnings.length > 0) {
note(warnings.join("\n"), "Install");
}
}
+2 -41
View File
@@ -23,49 +23,10 @@ export async function maybeRepairUiProtocolFreshness(
try { try {
const [schemaStats, uiStats] = await Promise.all([ const [schemaStats, uiStats] = await Promise.all([
fs.stat(schemaPath).catch(() => null), fs.stat(schemaPath),
fs.stat(uiIndexPath).catch(() => null), fs.stat(uiIndexPath),
]); ]);
if (schemaStats && !uiStats) {
note(
["- Control UI assets are missing.", "- Run: pnpm ui:build"].join("\n"),
"UI",
);
const shouldRepair = await prompter.confirmRepair({
message: "Build Control UI assets now?",
initialValue: true,
});
if (shouldRepair) {
note("Building Control UI assets... (this may take a moment)", "UI");
const uiScriptPath = path.join(root, "scripts/ui.js");
const buildResult = await runCommandWithTimeout(
[process.execPath, uiScriptPath, "build"],
{
cwd: root,
timeoutMs: 120_000,
env: { ...process.env, FORCE_COLOR: "1" },
},
);
if (buildResult.code === 0) {
note("UI build complete.", "UI");
} else {
const details = [
`UI build failed (exit ${buildResult.code ?? "unknown"}).`,
buildResult.stderr.trim() ? buildResult.stderr.trim() : null,
]
.filter(Boolean)
.join("\n");
note(details, "UI");
}
}
return;
}
if (!schemaStats || !uiStats) return;
if (schemaStats.mtime > uiStats.mtime) { if (schemaStats.mtime > uiStats.mtime) {
const uiMtimeIso = uiStats.mtime.toISOString(); const uiMtimeIso = uiStats.mtime.toISOString();
// Find changes since the UI build // Find changes since the UI build
+5 -8
View File
@@ -62,7 +62,6 @@ import {
maybeRepairGatewayServiceConfig, maybeRepairGatewayServiceConfig,
maybeScanExtraGatewayServices, maybeScanExtraGatewayServices,
} from "./doctor-gateway-services.js"; } from "./doctor-gateway-services.js";
import { noteSourceInstallIssues } from "./doctor-install.js";
import { import {
maybeMigrateLegacyConfigFile, maybeMigrateLegacyConfigFile,
normalizeLegacyConfigValues, normalizeLegacyConfigValues,
@@ -188,12 +187,6 @@ export async function doctorCommand(
printWizardHeader(runtime); printWizardHeader(runtime);
intro("Clawdbot doctor"); intro("Clawdbot doctor");
const root = await resolveClawdbotPackageRoot({
moduleUrl: import.meta.url,
argv1: process.argv[1],
cwd: process.cwd(),
});
const updateInProgress = process.env.CLAWDBOT_UPDATE_IN_PROGRESS === "1"; const updateInProgress = process.env.CLAWDBOT_UPDATE_IN_PROGRESS === "1";
const canOfferUpdate = const canOfferUpdate =
!updateInProgress && !updateInProgress &&
@@ -202,6 +195,11 @@ export async function doctorCommand(
options.repair !== true && options.repair !== true &&
Boolean(process.stdin.isTTY); Boolean(process.stdin.isTTY);
if (canOfferUpdate) { if (canOfferUpdate) {
const root = await resolveClawdbotPackageRoot({
moduleUrl: import.meta.url,
argv1: process.argv[1],
cwd: process.cwd(),
});
if (root) { if (root) {
const git = await detectClawdbotGitCheckout(root); const git = await detectClawdbotGitCheckout(root);
if (git === "git") { if (git === "git") {
@@ -252,7 +250,6 @@ export async function doctorCommand(
} }
await maybeRepairUiProtocolFreshness(runtime, prompter); await maybeRepairUiProtocolFreshness(runtime, prompter);
noteSourceInstallIssues(root);
await maybeMigrateLegacyConfigFile(runtime); await maybeMigrateLegacyConfigFile(runtime);
+6 -6
View File
@@ -157,8 +157,8 @@ describe("models list/status", () => {
}, },
{ {
provider: "openai", provider: "openai",
id: "gpt-4.1-mini", id: "gpt-5-nano",
name: "GPT-4.1 mini", name: "GPT-5 Nano",
input: ["text"], input: ["text"],
baseUrl: "https://api.openai.com/v1", baseUrl: "https://api.openai.com/v1",
contextWindow: 128000, contextWindow: 128000,
@@ -199,8 +199,8 @@ describe("models list/status", () => {
}, },
{ {
provider: "openai", provider: "openai",
id: "gpt-4.1-mini", id: "gpt-5-nano",
name: "GPT-4.1 mini", name: "GPT-5 Nano",
input: ["text"], input: ["text"],
baseUrl: "https://api.openai.com/v1", baseUrl: "https://api.openai.com/v1",
contextWindow: 128000, contextWindow: 128000,
@@ -241,8 +241,8 @@ describe("models list/status", () => {
}, },
{ {
provider: "openai", provider: "openai",
id: "gpt-4.1-mini", id: "gpt-5-nano",
name: "GPT-4.1 mini", name: "GPT-5 Nano",
input: ["text"], input: ["text"],
baseUrl: "https://api.openai.com/v1", baseUrl: "https://api.openai.com/v1",
contextWindow: 128000, contextWindow: 128000,
+11 -85
View File
@@ -18,18 +18,9 @@ import {
OPENROUTER_DEFAULT_MODEL_REF, OPENROUTER_DEFAULT_MODEL_REF,
SYNTHETIC_DEFAULT_MODEL_ID, SYNTHETIC_DEFAULT_MODEL_ID,
SYNTHETIC_DEFAULT_MODEL_REF, SYNTHETIC_DEFAULT_MODEL_REF,
setMinimaxApiKey,
writeOAuthCredentials, writeOAuthCredentials,
} from "./onboard-auth.js"; } from "./onboard-auth.js";
const authProfilePathFor = (agentDir: string) =>
path.join(agentDir, "auth-profiles.json");
const requireAgentDir = () => {
const agentDir = process.env.CLAWDBOT_AGENT_DIR;
if (!agentDir) throw new Error("CLAWDBOT_AGENT_DIR not set");
return agentDir;
};
describe("writeOAuthCredentials", () => { describe("writeOAuthCredentials", () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR; const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR; const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
@@ -59,9 +50,10 @@ describe("writeOAuthCredentials", () => {
delete process.env.CLAWDBOT_OAUTH_DIR; delete process.env.CLAWDBOT_OAUTH_DIR;
}); });
it("writes auth-profiles.json under CLAWDBOT_AGENT_DIR when set", async () => { it("writes auth-profiles.json under CLAWDBOT_STATE_DIR/agents/main/agent", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-oauth-")); tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-oauth-"));
process.env.CLAWDBOT_STATE_DIR = tempStateDir; process.env.CLAWDBOT_STATE_DIR = tempStateDir;
// Even if legacy env vars are set, onboarding should write to the multi-agent path.
process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent");
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
@@ -73,7 +65,14 @@ describe("writeOAuthCredentials", () => {
await writeOAuthCredentials("openai-codex", creds); await writeOAuthCredentials("openai-codex", creds);
const authProfilePath = authProfilePathFor(requireAgentDir()); // Now writes to the multi-agent path: agents/main/agent
const authProfilePath = path.join(
tempStateDir,
"agents",
"main",
"agent",
"auth-profiles.json",
);
const raw = await fs.readFile(authProfilePath, "utf8"); const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as { const parsed = JSON.parse(raw) as {
profiles?: Record<string, OAuthCredentials & { type?: string }>; profiles?: Record<string, OAuthCredentials & { type?: string }>;
@@ -86,80 +85,7 @@ describe("writeOAuthCredentials", () => {
await expect( await expect(
fs.readFile( fs.readFile(
path.join( path.join(tempStateDir, "agent", "auth-profiles.json"),
tempStateDir,
"agents",
"main",
"agent",
"auth-profiles.json",
),
"utf8",
),
).rejects.toThrow();
});
});
describe("setMinimaxApiKey", () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
let tempStateDir: string | null = null;
afterEach(async () => {
if (tempStateDir) {
await fs.rm(tempStateDir, { recursive: true, force: true });
tempStateDir = null;
}
if (previousStateDir === undefined) {
delete process.env.CLAWDBOT_STATE_DIR;
} else {
process.env.CLAWDBOT_STATE_DIR = previousStateDir;
}
if (previousAgentDir === undefined) {
delete process.env.CLAWDBOT_AGENT_DIR;
} else {
process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
}
if (previousPiAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
}
});
it("writes to CLAWDBOT_AGENT_DIR when set", async () => {
tempStateDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-minimax-"),
);
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "custom-agent");
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
await setMinimaxApiKey("sk-minimax-test");
const customAuthPath = authProfilePathFor(requireAgentDir());
const raw = await fs.readFile(customAuthPath, "utf8");
const parsed = JSON.parse(raw) as {
profiles?: Record<
string,
{ type?: string; provider?: string; key?: string }
>;
};
expect(parsed.profiles?.["minimax:default"]).toMatchObject({
type: "api_key",
provider: "minimax",
key: "sk-minimax-test",
});
await expect(
fs.readFile(
path.join(
tempStateDir,
"agents",
"main",
"agent",
"auth-profiles.json",
),
"utf8", "utf8",
), ),
).rejects.toThrow(); ).rejects.toThrow();
+17 -20
View File
@@ -1,5 +1,5 @@
import type { OAuthCredentials } from "@mariozechner/pi-ai"; import type { OAuthCredentials } from "@mariozechner/pi-ai";
import { resolveClawdbotAgentDir } from "../agents/agent-paths.js"; import { resolveDefaultAgentDir } from "../agents/agent-scope.js";
import { upsertAuthProfile } from "../agents/auth-profiles.js"; import { upsertAuthProfile } from "../agents/auth-profiles.js";
import { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js"; import { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js";
import { import {
@@ -24,9 +24,6 @@ const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000;
const MOONSHOT_DEFAULT_MAX_TOKENS = 8192; const MOONSHOT_DEFAULT_MAX_TOKENS = 8192;
export const MOONSHOT_DEFAULT_MODEL_REF = `moonshot/${MOONSHOT_DEFAULT_MODEL_ID}`; export const MOONSHOT_DEFAULT_MODEL_REF = `moonshot/${MOONSHOT_DEFAULT_MODEL_ID}`;
export { SYNTHETIC_DEFAULT_MODEL_ID, SYNTHETIC_DEFAULT_MODEL_REF }; export { SYNTHETIC_DEFAULT_MODEL_ID, SYNTHETIC_DEFAULT_MODEL_REF };
const resolveAuthAgentDir = (agentDir?: string) =>
agentDir ?? resolveClawdbotAgentDir();
// Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs. // Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs.
const MINIMAX_API_COST = { const MINIMAX_API_COST = {
input: 15, input: 15,
@@ -110,7 +107,7 @@ export async function writeOAuthCredentials(
creds: OAuthCredentials, creds: OAuthCredentials,
agentDir?: string, agentDir?: string,
): Promise<void> { ): Promise<void> {
// Write to resolved agent dir so gateway finds credentials on startup. // Write to the multi-agent path so gateway finds credentials on startup
upsertAuthProfile({ upsertAuthProfile({
profileId: `${provider}:${creds.email ?? "default"}`, profileId: `${provider}:${creds.email ?? "default"}`,
credential: { credential: {
@@ -118,12 +115,12 @@ export async function writeOAuthCredentials(
provider, provider,
...creds, ...creds,
}, },
agentDir: resolveAuthAgentDir(agentDir), agentDir: agentDir ?? resolveDefaultAgentDir(),
}); });
} }
export async function setAnthropicApiKey(key: string, agentDir?: string) { export async function setAnthropicApiKey(key: string, agentDir?: string) {
// Write to resolved agent dir so gateway finds credentials on startup. // Write to the multi-agent path so gateway finds credentials on startup
upsertAuthProfile({ upsertAuthProfile({
profileId: "anthropic:default", profileId: "anthropic:default",
credential: { credential: {
@@ -131,12 +128,12 @@ export async function setAnthropicApiKey(key: string, agentDir?: string) {
provider: "anthropic", provider: "anthropic",
key, key,
}, },
agentDir: resolveAuthAgentDir(agentDir), agentDir: agentDir ?? resolveDefaultAgentDir(),
}); });
} }
export async function setGeminiApiKey(key: string, agentDir?: string) { export async function setGeminiApiKey(key: string, agentDir?: string) {
// Write to resolved agent dir so gateway finds credentials on startup. // Write to the multi-agent path so gateway finds credentials on startup
upsertAuthProfile({ upsertAuthProfile({
profileId: "google:default", profileId: "google:default",
credential: { credential: {
@@ -144,12 +141,12 @@ export async function setGeminiApiKey(key: string, agentDir?: string) {
provider: "google", provider: "google",
key, key,
}, },
agentDir: resolveAuthAgentDir(agentDir), agentDir: agentDir ?? resolveDefaultAgentDir(),
}); });
} }
export async function setMinimaxApiKey(key: string, agentDir?: string) { export async function setMinimaxApiKey(key: string, agentDir?: string) {
// Write to resolved agent dir so gateway finds credentials on startup. // Write to the multi-agent path so gateway finds credentials on startup
upsertAuthProfile({ upsertAuthProfile({
profileId: "minimax:default", profileId: "minimax:default",
credential: { credential: {
@@ -157,12 +154,12 @@ export async function setMinimaxApiKey(key: string, agentDir?: string) {
provider: "minimax", provider: "minimax",
key, key,
}, },
agentDir: resolveAuthAgentDir(agentDir), agentDir: agentDir ?? resolveDefaultAgentDir(),
}); });
} }
export async function setMoonshotApiKey(key: string, agentDir?: string) { export async function setMoonshotApiKey(key: string, agentDir?: string) {
// Write to resolved agent dir so gateway finds credentials on startup. // Write to the multi-agent path so gateway finds credentials on startup
upsertAuthProfile({ upsertAuthProfile({
profileId: "moonshot:default", profileId: "moonshot:default",
credential: { credential: {
@@ -170,12 +167,12 @@ export async function setMoonshotApiKey(key: string, agentDir?: string) {
provider: "moonshot", provider: "moonshot",
key, key,
}, },
agentDir: resolveAuthAgentDir(agentDir), agentDir: agentDir ?? resolveDefaultAgentDir(),
}); });
} }
export async function setSyntheticApiKey(key: string, agentDir?: string) { export async function setSyntheticApiKey(key: string, agentDir?: string) {
// Write to resolved agent dir so gateway finds credentials on startup. // Write to the multi-agent path so gateway finds credentials on startup
upsertAuthProfile({ upsertAuthProfile({
profileId: "synthetic:default", profileId: "synthetic:default",
credential: { credential: {
@@ -183,7 +180,7 @@ export async function setSyntheticApiKey(key: string, agentDir?: string) {
provider: "synthetic", provider: "synthetic",
key, key,
}, },
agentDir: resolveAuthAgentDir(agentDir), agentDir: agentDir ?? resolveDefaultAgentDir(),
}); });
} }
@@ -191,7 +188,7 @@ export const ZAI_DEFAULT_MODEL_REF = "zai/glm-4.7";
export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto"; export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto";
export async function setZaiApiKey(key: string, agentDir?: string) { export async function setZaiApiKey(key: string, agentDir?: string) {
// Write to resolved agent dir so gateway finds credentials on startup. // Write to the multi-agent path so gateway finds credentials on startup
upsertAuthProfile({ upsertAuthProfile({
profileId: "zai:default", profileId: "zai:default",
credential: { credential: {
@@ -199,7 +196,7 @@ export async function setZaiApiKey(key: string, agentDir?: string) {
provider: "zai", provider: "zai",
key, key,
}, },
agentDir: resolveAuthAgentDir(agentDir), agentDir: agentDir ?? resolveDefaultAgentDir(),
}); });
} }
@@ -211,7 +208,7 @@ export async function setOpenrouterApiKey(key: string, agentDir?: string) {
provider: "openrouter", provider: "openrouter",
key, key,
}, },
agentDir: resolveAuthAgentDir(agentDir), agentDir: agentDir ?? resolveDefaultAgentDir(),
}); });
} }
@@ -717,7 +714,7 @@ export async function setOpencodeZenApiKey(key: string, agentDir?: string) {
provider: "opencode", provider: "opencode",
key, key,
}, },
agentDir: resolveAuthAgentDir(agentDir), agentDir: agentDir ?? resolveDefaultAgentDir(),
}); });
} }
+6 -6
View File
@@ -1603,10 +1603,10 @@ describe("legacy config detection", () => {
const res = migrateLegacyConfig({ const res = migrateLegacyConfig({
agent: { agent: {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-5",
modelFallbacks: ["openai/gpt-4.1-mini"], modelFallbacks: ["openai/gpt-5-nano"],
imageModel: "openai/gpt-4.1-mini", imageModel: "openai/gpt-5-nano",
imageModelFallbacks: ["anthropic/claude-opus-4-5"], imageModelFallbacks: ["anthropic/claude-opus-4-5"],
allowedModels: ["anthropic/claude-opus-4-5", "openai/gpt-4.1-mini"], allowedModels: ["anthropic/claude-opus-4-5", "openai/gpt-5-nano"],
modelAliases: { Opus: "anthropic/claude-opus-4-5" }, modelAliases: { Opus: "anthropic/claude-opus-4-5" },
}, },
}); });
@@ -1615,10 +1615,10 @@ describe("legacy config detection", () => {
"anthropic/claude-opus-4-5", "anthropic/claude-opus-4-5",
); );
expect(res.config?.agents?.defaults?.model?.fallbacks).toEqual([ expect(res.config?.agents?.defaults?.model?.fallbacks).toEqual([
"openai/gpt-4.1-mini", "openai/gpt-5-nano",
]); ]);
expect(res.config?.agents?.defaults?.imageModel?.primary).toBe( expect(res.config?.agents?.defaults?.imageModel?.primary).toBe(
"openai/gpt-4.1-mini", "openai/gpt-5-nano",
); );
expect(res.config?.agents?.defaults?.imageModel?.fallbacks).toEqual([ expect(res.config?.agents?.defaults?.imageModel?.fallbacks).toEqual([
"anthropic/claude-opus-4-5", "anthropic/claude-opus-4-5",
@@ -1627,7 +1627,7 @@ describe("legacy config detection", () => {
res.config?.agents?.defaults?.models?.["anthropic/claude-opus-4-5"], res.config?.agents?.defaults?.models?.["anthropic/claude-opus-4-5"],
).toMatchObject({ alias: "Opus" }); ).toMatchObject({ alias: "Opus" });
expect( expect(
res.config?.agents?.defaults?.models?.["openai/gpt-4.1-mini"], res.config?.agents?.defaults?.models?.["openai/gpt-5-nano"],
).toBeTruthy(); ).toBeTruthy();
expect(res.config?.agent).toBeUndefined(); expect(res.config?.agent).toBeUndefined();
}); });
-6
View File
@@ -155,8 +155,6 @@ const FIELD_LABELS: Record<string, string> = {
"agents.defaults.cliBackends": "CLI Backends", "agents.defaults.cliBackends": "CLI Backends",
"commands.native": "Native Commands", "commands.native": "Native Commands",
"commands.text": "Text Commands", "commands.text": "Text Commands",
"commands.bash": "Allow Bash Chat Command",
"commands.bashForegroundMs": "Bash Foreground Window (ms)",
"commands.config": "Allow /config", "commands.config": "Allow /config",
"commands.debug": "Allow /debug", "commands.debug": "Allow /debug",
"commands.restart": "Allow Restart", "commands.restart": "Allow Restart",
@@ -291,10 +289,6 @@ const FIELD_HELP: Record<string, string> = {
"commands.native": "commands.native":
"Register native commands with connectors that support it (Discord/Slack/Telegram).", "Register native commands with connectors that support it (Discord/Slack/Telegram).",
"commands.text": "Allow text command parsing (slash commands only).", "commands.text": "Allow text command parsing (slash commands only).",
"commands.bash":
"Allow bash chat command (`!`; `/bash` alias) to run host shell commands (default: false; requires tools.elevated).",
"commands.bashForegroundMs":
"How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately).",
"commands.config": "commands.config":
"Allow /config chat command to read/write config on disk (default: false).", "Allow /config chat command to read/write config on disk (default: false).",
"commands.debug": "commands.debug":
-4
View File
@@ -1244,10 +1244,6 @@ export type CommandsConfig = {
native?: NativeCommandsSetting; native?: NativeCommandsSetting;
/** Enable text command parsing (default: true). */ /** Enable text command parsing (default: true). */
text?: boolean; text?: boolean;
/** Allow bash chat command (`!`; `/bash` alias) (default: false). */
bash?: boolean;
/** How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately). */
bashForegroundMs?: number;
/** Allow /config command (default: false). */ /** Allow /config command (default: false). */
config?: boolean; config?: boolean;
/** Allow /debug command (default: false). */ /** Allow /debug command (default: false). */
-2
View File
@@ -724,8 +724,6 @@ const CommandsSchema = z
.object({ .object({
native: NativeCommandsSettingSchema.optional().default("auto"), native: NativeCommandsSettingSchema.optional().default("auto"),
text: z.boolean().optional(), text: z.boolean().optional(),
bash: z.boolean().optional(),
bashForegroundMs: z.number().int().min(0).max(30_000).optional(),
config: z.boolean().optional(), config: z.boolean().optional(),
debug: z.boolean().optional(), debug: z.boolean().optional(),
restart: z.boolean().optional(), restart: z.boolean().optional(),
+2 -2
View File
@@ -213,7 +213,7 @@ describe("runCronIsolatedAgentTurn", () => {
job: makeJob({ job: makeJob({
kind: "agentTurn", kind: "agentTurn",
message: "do it", message: "do it",
model: "openai/gpt-4.1-mini", model: "openai/gpt-5-nano",
}), }),
message: "do it", message: "do it",
sessionKey: "cron:job-1", sessionKey: "cron:job-1",
@@ -226,7 +226,7 @@ describe("runCronIsolatedAgentTurn", () => {
model?: string; model?: string;
}; };
expect(call?.provider).toBe("openai"); expect(call?.provider).toBe("openai");
expect(call?.model).toBe("gpt-4.1-mini"); expect(call?.model).toBe("gpt-5-nano");
}); });
}); });
-28
View File
@@ -1,5 +1,4 @@
#!/usr/bin/env node #!/usr/bin/env node
import { spawnSync } from "node:child_process";
import process from "node:process"; import process from "node:process";
import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js"; import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js";
@@ -9,33 +8,6 @@ if (process.argv.includes("--no-color")) {
process.env.FORCE_COLOR = "0"; process.env.FORCE_COLOR = "0";
} }
const EXPERIMENTAL_WARNING_FLAG = "--disable-warning=ExperimentalWarning";
function hasExperimentalWarningSuppressed(nodeOptions: string): boolean {
if (!nodeOptions) return false;
return (
nodeOptions.includes(EXPERIMENTAL_WARNING_FLAG) ||
nodeOptions.includes("--no-warnings")
);
}
function ensureExperimentalWarningSuppressed(): void {
if (process.env.CLAWDBOT_NODE_OPTIONS_READY === "1") return;
const nodeOptions = process.env.NODE_OPTIONS ?? "";
if (hasExperimentalWarningSuppressed(nodeOptions)) return;
process.env.CLAWDBOT_NODE_OPTIONS_READY = "1";
process.env.NODE_OPTIONS =
`${nodeOptions} ${EXPERIMENTAL_WARNING_FLAG}`.trim();
const result = spawnSync(process.execPath, process.argv.slice(1), {
stdio: "inherit",
env: process.env,
});
if (result.signal) process.exit(1);
process.exit(result.status ?? 1);
}
ensureExperimentalWarningSuppressed();
const parsed = parseCliProfileArgs(process.argv); const parsed = parseCliProfileArgs(process.argv);
if (!parsed.ok) { if (!parsed.ok) {
// Keep it simple; Commander will handle rich help/errors after we strip flags. // Keep it simple; Commander will handle rich help/errors after we strip flags.
+2 -2
View File
@@ -46,7 +46,7 @@ describe("hooks mapping", () => {
match: { path: "gmail" }, match: { path: "gmail" },
action: "agent", action: "agent",
messageTemplate: "Subject: {{messages[0].subject}}", messageTemplate: "Subject: {{messages[0].subject}}",
model: "openai/gpt-4.1-mini", model: "openai/gpt-5-nano",
}, },
], ],
}); });
@@ -58,7 +58,7 @@ describe("hooks mapping", () => {
}); });
expect(result?.ok).toBe(true); expect(result?.ok).toBe(true);
if (result?.ok && result.action.kind === "agent") { if (result?.ok && result.action.kind === "agent") {
expect(result.action.model).toBe("openai/gpt-4.1-mini"); expect(result.action.model).toBe("openai/gpt-5-nano");
} }
}); });
+2 -2
View File
@@ -88,7 +88,7 @@ describe("gateway server hooks", () => {
body: JSON.stringify({ body: JSON.stringify({
message: "Do it", message: "Do it",
name: "Email", name: "Email",
model: "openai/gpt-4.1-mini", model: "openai/gpt-5-nano",
}), }),
}); });
expect(res.status).toBe(202); expect(res.status).toBe(202);
@@ -96,7 +96,7 @@ describe("gateway server hooks", () => {
const call = cronIsolatedRun.mock.calls[0]?.[0] as { const call = cronIsolatedRun.mock.calls[0]?.[0] as {
job?: { payload?: { model?: string } }; job?: { payload?: { model?: string } };
}; };
expect(call?.job?.payload?.model).toBe("openai/gpt-4.1-mini"); expect(call?.job?.payload?.model).toBe("openai/gpt-5-nano");
drainSystemEvents(resolveMainKey()); drainSystemEvents(resolveMainKey());
await server.close(); await server.close();
}); });
+3 -9
View File
@@ -786,15 +786,9 @@ export async function runOnboardingWizard(
} }
} }
const controlUiEnabled = const controlUiAssets = await ensureControlUiAssetsBuilt(runtime);
nextConfig.gateway?.controlUi?.enabled ?? if (!controlUiAssets.ok && controlUiAssets.message) {
baseConfig.gateway?.controlUi?.enabled ?? runtime.error(controlUiAssets.message);
true;
if (!opts.skipUi && controlUiEnabled) {
const controlUiAssets = await ensureControlUiAssetsBuilt(runtime);
if (!controlUiAssets.ok && controlUiAssets.message) {
runtime.error(controlUiAssets.message);
}
} }
await prompter.note( await prompter.note(
+3 -3
View File
@@ -122,9 +122,9 @@ const spawnGatewayInstance = async (name: string): Promise<GatewayInstance> => {
try { try {
child = spawn( child = spawn(
"node", "bun",
[ [
"dist/index.js", "src/index.ts",
"gateway", "gateway",
"--port", "--port",
String(port), String(port),
@@ -222,7 +222,7 @@ const runCliJson = async (
): Promise<unknown> => { ): Promise<unknown> => {
const stdout: string[] = []; const stdout: string[] = [];
const stderr: string[] = []; const stderr: string[] = [];
const child = spawn("node", ["dist/index.js", ...args], { const child = spawn("bun", ["src/index.ts", ...args], {
cwd: process.cwd(), cwd: process.cwd(),
env: { ...process.env, ...env }, env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],