Compare commits

..
Author SHA1 Message Date
Peter Steinberger 07cc816969 fix: explain /bash enablement when disabled (#722) (thanks @vrknetha) 2026-01-13 07:18:15 +00:00
Peter Steinberger b0d0914ca0 docs: group FAQ sections 2026-01-13 07:17:28 +00:00
Peter Steinberger b33334620a Tests: run e2e gateway with node 2026-01-13 07:17:28 +00:00
Peter Steinberger ebc4fe57d7 Docs: reorder 2025.1.12 changelog 2026-01-13 07:17:28 +00:00
Peter Steinberger 5909cf36b9 Deps: update Pi + Vitest and add Bedrock docs 2026-01-13 07:17:28 +00:00
Peter Steinberger e3a97b96d9 docs: add FAQ table of contents 2026-01-13 07:17:28 +00:00
Peter Steinberger 4942f3af0f fix: per-agent model fallbacks (#583) (thanks @mitschabaude-bot) 2026-01-13 07:17:28 +00:00
Gregor's Bot ad44e32910 Docs: document agents.list model fallbacks 2026-01-13 07:17:28 +00:00
Gregor's Bot b0b77aae88 Agents: test per-agent model fallbacks override 2026-01-13 07:17:28 +00:00
Gregor's Bot 007f8c9222 Config: support per-agent model fallbacks 2026-01-13 07:17:28 +00:00
Peter Steinberger dbd5a76cb0 fix: gate xhigh by model (#444) (thanks @grp06) 2026-01-13 07:17:28 +00:00
George Pickett 630b256aee Thinking: gate xhigh by model 2026-01-13 07:17:28 +00:00
Peter Steinberger e170bc397d test(tools): cover tool policy helpers 2026-01-13 07:17:28 +00:00
Peter Steinberger d2f033aee5 docs: update changelog for tool schema and profiles 2026-01-13 07:17:28 +00:00
Peter Steinberger 8f12b47b6d feat(tools): add tool profiles and group shorthands 2026-01-13 07:17:28 +00:00
Peter Steinberger e8b382e8ed fix(tools): harden tool schemas for strict providers 2026-01-13 07:17:28 +00:00
vrknetha 721a6bf0f9 Docs: document bash chat command 2026-01-13 11:56:51 +05:30
vrknetha 25a5f1cb96 Auto-reply: add host-only /bash + ! bash command 2026-01-13 11:54:34 +05:30
40 changed files with 2624 additions and 634 deletions

No files matched your search

+38 -110
View File
@@ -1,120 +1,48 @@
# Changelog # Changelog
## 2026.1.13-1 ## 2025.1.12 (Unreleased)
### 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).
### 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 ### Highlights
- Memory: add vector search for agent memories (Markdown-only scope) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback. - 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.
### Changes ### New & Improved
- 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). - Memory: add custom OpenAI-compatible embedding endpoints; support OpenAI/local `node-llama-cpp` embeddings with per-agent overrides and provider metadata in tools/CLI.
- Browser: add `scrollintoview` action to scroll refs into view before click/type. - 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.
- Memory: embedding providers support OpenAI or local `node-llama-cpp`; config adds defaults + per-agent overrides, provider/fallback metadata surfaced in tools/CLI. - Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config.
- CLI/Tools: new `clawdbot memory` commands plus `memory_search`/`memory_get` tools returning snippets + line ranges and provider info. - 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`.
- 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. - Tools: add browser `scrollintoview` action; allow Claude/Gemini tool param aliases; allow thinking `xhigh` for GPT-5.2/Codex with safe downgrades.
- Doctor: rebuild Control UI assets when protocol schema is newer to avoid stale UI connect errors. (#786) — thanks @meaningfool. - 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.
- Cron: allow jobs to target a specific agent and expose agent selection in the macOS app + Control UI. - Auth/Onboarding: add Chutes OAuth (PKCE + refresh + onboarding choice); normalize API key inputs; default TUI onboarding to `deliver: false`.
- Discord: add `discord.allowBots` to permit bot-authored messages (still ignores its own messages) with docs warning about bot loops. (#802) — thanks @zknicker. - 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).
- 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. - Gateway: allow Tailscale Serve identity headers to satisfy token auth; rebuild Control UI assets when protocol schema is newer.
- 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. - Heartbeat: default `ackMaxChars` to 300 so short `HEARTBEAT_OK` replies stay internal.
- 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 ### Fixes
- Auto-reply: inline `/status` now honors allowlists (authorized stripped + replied inline; unauthorized leaves text for the agent) to match command gating tests. - 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.
- 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). - 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.
- Models/Tools: include `MiniMax-VL-01` in implicit MiniMax provider so image pairing uses a real vision model. - MiniMax: strip malformed tool invocation XML; include `MiniMax-VL-01` in implicit provider for image pairing.
- Telegram: show typing indicator in General forum topics. (#779) — thanks @azade-c. - Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid incorrect role errors.
- Discord: keep reasoning italics intact when messages are chunked, so reasoning stays italic across multi-part sends. - Messaging: enforce context isolation for message tool sends; keep typing indicators alive during tool execution.
- Models: keep explicit GitHub Copilot provider config and honor agent-dir auth profiles for auto-injection. (#705) — thanks @TAGOOZ. - Auto-reply: `/status` allowlist behavior, reasoning-tag enforcement on fallback, and system-event enqueueing for elevated/reasoning toggles.
- Auto-reply: restore 300-char heartbeat ack limit and keep >300 char replies instead of dropping them; adjust long heartbeat test content accordingly. - Auto-reply: explain how to enable `/bash` when it’s disabled; add security notes + FAQ. (#722) — thanks @vrknetha.
- 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. - Auto-reply: resolve ambiguous `/model` matches; fix streaming block reply media handling; keep >300 char heartbeat replies instead of dropping.
- Google: apply patched pi-ai `google-gemini-cli` function call handling (strips ids) after upgrading to pi-ai 0.43.0. - 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.
- 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. - Telegram: preserve forum topic thread ids, persist polling offsets, respect account bindings in webhook mode, and show typing indicator in General topics.
- Auto-reply: elevated/reasoning toggles now enqueue system events so the model sees the mode change immediately. - Slack: accept slash commands with or without leading `/` for custom command configs.
- Tools: keep `image` available in sandbox and fail over when image models return empty output (fixes “(no text returned)”). - Cron: persist disabled jobs correctly; accept `jobId` aliases for update/run/remove params.
- Discord: add per-channel `autoThread` to auto-create threads for top-level messages. (#800) — thanks @davidguttman. - 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.
- Discord: fix autoThread routing so replies stay in the created thread and avoid reply references. (#807) — thanks @davidguttman. - 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.
- Onboarding: TUI defaults to `deliver: false` to avoid cross-provider auto-delivery leaks; onboarding spawns the TUI with explicit `deliver: false`. (#791 — thanks @roshanasingh4) - 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.
- Testing: update Vitest + browser-playwright to 4.0.17.
- Docs: add Amazon Bedrock provider notes and link from models/FAQ.
## 2026.1.11 ## 2026.1.11
+1 -1
View File
@@ -247,7 +247,7 @@ Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands ar
- `/status` — compact session status (model + tokens, cost when available) - `/status` — compact session status (model + tokens, cost when available)
- `/new` or `/reset` — reset the session - `/new` or `/reset` — reset the session
- `/compact` — compact session context (summary) - `/compact` — compact session context (summary)
- `/think <level>` — off|minimal|low|medium|high - `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
- `/verbose on|off` - `/verbose on|off`
- `/cost on|off` — append per-response token/cost usage lines - `/cost on|off` — append per-response token/cost usage lines
- `/restart` — restart the gateway (owner-only in groups) - `/restart` — restart the gateway (owner-only in groups)
+1 -1
View File
@@ -105,7 +105,7 @@ Isolation options (only for `session=isolated`):
### Model and thinking overrides ### Model and thinking overrides
Isolated jobs (`agentTurn`) can override the model and thinking level: Isolated jobs (`agentTurn`) can override the model and thinking level:
- `model`: Provider/model string (e.g., `anthropic/claude-sonnet-4-20250514`) or alias (e.g., `opus`) - `model`: Provider/model string (e.g., `anthropic/claude-sonnet-4-20250514`) or alias (e.g., `opus`)
- `thinking`: Thinking level (`off`, `minimal`, `low`, `medium`, `high`) - `thinking`: Thinking level (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`; GPT-5.2 + Codex models only)
Note: You can set `model` on main-session jobs too, but it changes the shared main Note: You can set `model` on main-session jobs too, but it changes the shared main
session model. We recommend model overrides only for isolated jobs to avoid session model. We recommend model overrides only for isolated jobs to avoid
+71
View File
@@ -0,0 +1,71 @@
---
summary: "Use Amazon Bedrock (Converse API) models with Clawdbot"
read_when:
- You want to use Amazon Bedrock models with Clawdbot
- You need AWS credential/region setup for model calls
---
# Amazon Bedrock
Clawdbot can use **Amazon Bedrock** models via pi‑ai’s **Bedrock Converse**
streaming provider. Bedrock auth uses the **AWS SDK default credential chain**,
not an API key.
## What pi‑ai supports
- Provider: `amazon-bedrock`
- API: `bedrock-converse-stream`
- Auth: AWS credentials (env vars, shared config, or instance role)
- Region: `AWS_REGION` or `AWS_DEFAULT_REGION` (default: `us-east-1`)
## Setup (manual)
1) Ensure AWS credentials are available on the **gateway host**:
```bash
export AWS_ACCESS_KEY_ID="AKIA..."
export AWS_SECRET_ACCESS_KEY="..."
export AWS_REGION="us-east-1"
# Optional:
export AWS_SESSION_TOKEN="..."
export AWS_PROFILE="your-profile"
```
2) Add a Bedrock provider and model to your config:
```json5
{
models: {
providers: {
"amazon-bedrock": {
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
api: "bedrock-converse-stream",
models: [
{
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
name: "Claude 3.7 Sonnet (Bedrock)",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192
}
]
}
}
},
agents: {
defaults: {
model: { primary: "amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0" }
}
}
}
```
## Notes
- Bedrock requires **model access** enabled in your AWS account/region.
- If you use profiles, set `AWS_PROFILE` on the gateway host.
- Reasoning support depends on the model; check the Bedrock model card for
current capabilities.
- If you prefer a managed key flow, you can also place an OpenAI‑compatible
proxy in front of Bedrock and configure it as an OpenAI provider instead.
+1 -1
View File
@@ -398,7 +398,7 @@ Required:
Options: Options:
- `--to <dest>` (for session key and optional delivery) - `--to <dest>` (for session key and optional delivery)
- `--session-id <id>` - `--session-id <id>`
- `--thinking <off|minimal|low|medium|high>` - `--thinking <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only)
- `--verbose <on|off>` - `--verbose <on|off>`
- `--provider <whatsapp|telegram|discord|slack|signal|imessage>` - `--provider <whatsapp|telegram|discord|slack|signal|imessage>`
- `--local` - `--local`
@@ -39,7 +39,7 @@ Add `hooks.gmail.model` config option to specify an optional cheaper model for G
| Field | Type | Default | Description | | Field | Type | Default | Description |
|-------|------|---------|-------------| |-------|------|---------|-------------|
| `hooks.gmail.model` | `string` | (none) | Model to use for Gmail hook processing. Accepts `provider/model` refs or aliases from `agents.defaults.models`. | | `hooks.gmail.model` | `string` | (none) | Model to use for Gmail hook processing. Accepts `provider/model` refs or aliases from `agents.defaults.models`. |
| `hooks.gmail.thinking` | `string` | (inherited) | Thinking level override (`off`, `minimal`, `low`, `medium`, `high`). If unset, inherits from `agents.defaults.thinkingDefault` or model's default. | | `hooks.gmail.thinking` | `string` | (inherited) | Thinking level override (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`; GPT-5.2 + Codex models only). If unset, inherits from `agents.defaults.thinkingDefault` or model's default. |
### Alias Support ### Alias Support
+5 -1
View File
@@ -746,8 +746,10 @@ Controls how chat commands are enabled across connectors.
```json5 ```json5
{ {
commands: { commands: {
native: false, // register native commands when supported native: "auto", // register native commands when supported (auto)
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
@@ -761,6 +763,8 @@ 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.
+1
View File
@@ -31,6 +31,7 @@ model as `provider/model`.
- [Z.AI](/providers/zai) - [Z.AI](/providers/zai)
- [GLM models](/providers/glm) - [GLM models](/providers/glm)
- [MiniMax](/providers/minimax) - [MiniMax](/providers/minimax)
- [Amazon Bedrock](/bedrock)
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration, For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
see [Model providers](/concepts/model-providers). see [Model providers](/concepts/model-providers).
+129 -2
View File
@@ -5,6 +5,109 @@ summary: "Frequently asked questions about Clawdbot setup, configuration, and us
Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, multi-agent, OAuth/API keys, model failover). For runtime diagnostics, see [Troubleshooting](/gateway/troubleshooting). For the full config reference, see [Configuration](/gateway/configuration). Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, multi-agent, OAuth/API keys, model failover). For runtime diagnostics, see [Troubleshooting](/gateway/troubleshooting). For the full config reference, see [Configuration](/gateway/configuration).
## Table of contents
- [What is Clawdbot?](#what-is-clawdbot)
- [What is Clawdbot, in one paragraph?](#what-is-clawdbot-in-one-paragraph)
- [Quick start and first-run setup](#quick-start-and-first-run-setup)
- [What’s the recommended way to install and set up Clawdbot?](#whats-the-recommended-way-to-install-and-set-up-clawdbot)
- [How do I open the dashboard after onboarding?](#how-do-i-open-the-dashboard-after-onboarding)
- [How do I authenticate the dashboard (token) on localhost vs remote?](#how-do-i-authenticate-the-dashboard-token-on-localhost-vs-remote)
- [What runtime do I need?](#what-runtime-do-i-need)
- [What does the onboarding wizard actually do?](#what-does-the-onboarding-wizard-actually-do)
- [How does Anthropic "setup-token" auth work?](#how-does-anthropic-setup-token-auth-work)
- [Do you support Claude subscription auth (Claude Code OAuth)?](#do-you-support-claude-subscription-auth-claude-code-oauth)
- [Is AWS Bedrock supported?](#is-aws-bedrock-supported)
- [How does Codex auth work?](#how-does-codex-auth-work)
- [Is a local model OK for casual chats?](#is-a-local-model-ok-for-casual-chats)
- [How do I keep hosted model traffic in a specific region?](#how-do-i-keep-hosted-model-traffic-in-a-specific-region)
- [Can I use Bun?](#can-i-use-bun)
- [Telegram: what goes in `allowFrom`?](#telegram-what-goes-in-allowfrom)
- [Can multiple people use one WhatsApp number with different Clawdbots?](#can-multiple-people-use-one-whatsapp-number-with-different-clawdbots)
- [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)
- [Does Homebrew work on Linux?](#does-homebrew-work-on-linux)
- [Can I switch between npm and git installs later?](#can-i-switch-between-npm-and-git-installs-later)
- [Skills and automation](#skills-and-automation)
- [How do I customize skills without keeping the repo dirty?](#how-do-i-customize-skills-without-keeping-the-repo-dirty)
- [Can I load skills from a custom folder?](#can-i-load-skills-from-a-custom-folder)
- [How can I use different models for different tasks?](#how-can-i-use-different-models-for-different-tasks)
- [How do I install skills on Linux?](#how-do-i-install-skills-on-linux)
- [Sandboxing and memory](#sandboxing-and-memory)
- [Is there a dedicated sandboxing doc?](#is-there-a-dedicated-sandboxing-doc)
- [How do I bind a host folder into the sandbox?](#how-do-i-bind-a-host-folder-into-the-sandbox)
- [How does memory work?](#how-does-memory-work)
- [Does semantic memory search require an OpenAI API key?](#does-semantic-memory-search-require-an-openai-api-key)
- [Where things live on disk](#where-things-live-on-disk)
- [Where does Clawdbot store its data?](#where-does-clawdbot-store-its-data)
- [How do I completely uninstall Clawdbot?](#how-do-i-completely-uninstall-clawdbot)
- [Can agents work outside the workspace?](#can-agents-work-outside-the-workspace)
- [I’m in remote mode — where is the session store?](#im-in-remote-mode-where-is-the-session-store)
- [Config basics](#config-basics)
- [What format is the config? Where is it?](#what-format-is-the-config-where-is-it)
- [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)
- [Why do I need a token on localhost now?](#why-do-i-need-a-token-on-localhost-now)
- [Do I have to restart after changing config?](#do-i-have-to-restart-after-changing-config)
- [How do I run a central Gateway with specialized workers across devices?](#how-do-i-run-a-central-gateway-with-specialized-workers-across-devices)
- [Can the Clawdbot browser run headless?](#can-the-clawdbot-browser-run-headless)
- [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)
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
- [Env vars and .env loading](#env-vars-and-env-loading)
- [How does Clawdbot load environment variables?](#how-does-clawdbot-load-environment-variables)
- [“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)
- [Sessions & multiple chats](#sessions-multiple-chats)
- [How do I start a fresh conversation?](#how-do-i-start-a-fresh-conversation)
- [How do I completely reset Clawdbot (but keep it installed)?](#how-do-i-completely-reset-clawdbot-but-keep-it-installed)
- [Do I need to add a “bot account” to a WhatsApp group?](#do-i-need-to-add-a-bot-account-to-a-whatsapp-group)
- [Why doesn’t Clawdbot reply in a group?](#why-doesnt-clawdbot-reply-in-a-group)
- [Do groups/threads share context with DMs?](#do-groupsthreads-share-context-with-dms)
- [Models: defaults, selection, aliases, switching](#models-defaults-selection-aliases-switching)
- [What is the “default model”?](#what-is-the-default-model)
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
- [Why do I see “Model … is not allowed” and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply)
- [Are opus / sonnet / gpt built‑in shortcuts?](#are-opus-sonnet-gpt-builtin-shortcuts)
- [How do I define/override model shortcuts (aliases)?](#how-do-i-defineoverride-model-shortcuts-aliases)
- [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)
- [Model failover and “All models failed”](#model-failover-and-all-models-failed)
- [How does failover work?](#how-does-failover-work)
- [What does this error mean?](#what-does-this-error-mean)
- [Fix checklist for `No credentials found for profile "anthropic:default"`](#fix-checklist-for-no-credentials-found-for-profile-anthropicdefault)
- [Why did it also try Google Gemini and fail?](#why-did-it-also-try-google-gemini-and-fail)
- [Auth profiles: what they are and how to manage them](#auth-profiles-what-they-are-and-how-to-manage-them)
- [What is an auth profile?](#what-is-an-auth-profile)
- [What are typical profile IDs?](#what-are-typical-profile-ids)
- [Can I control which auth profile is tried first?](#can-i-control-which-auth-profile-is-tried-first)
- [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)
- [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 `/bash` say it’s disabled?](#why-does-bash-say-its-disabled)
- [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)
## First 60 seconds if something's broken ## First 60 seconds if something's broken
1) **Quick status (first check)** 1) **Quick status (first check)**
@@ -60,7 +163,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?
@@ -128,7 +231,7 @@ Yes. Clawdbot can **reuse Claude Code CLI credentials** (OAuth) and also support
### Is AWS Bedrock supported? ### Is AWS Bedrock supported?
Not currently. Clawdbot doesn’t ship a Bedrock provider today. If you must use Bedrock, the common workaround is an OpenAI‑compatible proxy in front of Bedrock, then point Clawdbot at that endpoint. See [Model providers](/providers/models) and [Model providers (full list)](/concepts/model-providers). 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.
### How does Codex auth work? ### How does Codex auth work?
@@ -196,6 +299,8 @@ 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).
## 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.
@@ -240,6 +345,8 @@ 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).
@@ -957,6 +1064,26 @@ Slash commands overview: see [Slash commands](/tools/slash-commands).
Most commands must be sent as a **standalone** message that starts with `/`, but a few shortcuts (like `/status`) also work inline for allowlisted senders. Most commands must be sent as a **standalone** message that starts with `/`, but a few shortcuts (like `/status`) also work inline for allowlisted senders.
### Why does `/bash` say it’s disabled?
The host shell command (`! <cmd>` or `/bash <cmd>`) is **disabled by default** because it runs directly on the gateway host. To enable it, update your config and restart the Gateway:
```json5
{
commands: { bash: true },
tools: {
elevated: {
enabled: true,
allowFrom: {
"<provider>": ["<sender-id>"]
}
}
}
}
```
Keep the allowlist tight and avoid enabling it in public rooms. See [Slash commands](/tools/slash-commands) and [Security](/gateway/security).
### Why does it feel like the bot “ignores” rapid‑fire messages? ### Why does it feel like the bot “ignores” rapid‑fire messages?
Queue mode controls how new messages interact with an in‑flight run. Use `/queue` to change modes: Queue mode controls how new messages interact with an in‑flight run. Use `/queue` to change modes:
+1 -1
View File
@@ -38,7 +38,7 @@ clawdbot agent --to +15555550123 --message "Summon reply" --deliver
- `--local`: run locally (requires provider keys in your shell) - `--local`: run locally (requires provider keys in your shell)
- `--deliver`: send the reply to the chosen provider (requires `--to`) - `--deliver`: send the reply to the chosen provider (requires `--to`)
- `--provider`: `whatsapp|telegram|discord|slack|signal|imessage` (default: `whatsapp`) - `--provider`: `whatsapp|telegram|discord|slack|signal|imessage` (default: `whatsapp`)
- `--thinking <off|minimal|low|medium|high>`: persist thinking level - `--thinking <off|minimal|low|medium|high|xhigh>`: persist thinking level (GPT-5.2 + Codex models only)
- `--verbose <on|off>`: persist verbose level - `--verbose <on|off>`: persist verbose level
- `--timeout <seconds>`: override agent timeout - `--timeout <seconds>`: override agent timeout
- `--json`: output structured JSON - `--json`: output structured JSON
+3
View File
@@ -7,10 +7,13 @@ 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.
Security note: enabling elevated access or `/bash` effectively grants host shell access. Keep allowlists tight and avoid enabling it in public rooms.
## What it controls (and what it doesn’t) ## What it controls (and what it doesn’t)
- **Availability gates**: `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can further restrict elevated per agent (both must allow). - **Availability gates**: `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can further restrict elevated per agent (both must allow).
- **Per-session state**: `/elevated on|off` sets the elevated level for the current session key. - **Per-session state**: `/elevated on|off` sets the elevated level for the current session key.
+15 -1
View File
@@ -7,6 +7,7 @@ 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:
@@ -26,6 +27,8 @@ 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,
@@ -40,6 +43,9 @@ 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).
- When `commands.bash` is `false`, `/bash` replies with a short enablement hint (config keys + allowlist).
- `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.
@@ -60,15 +66,19 @@ Text + native (when enabled):
- `/activation mention|always` (groups only) - `/activation mention|always` (groups only)
- `/send on|off|inherit` (owner-only) - `/send on|off|inherit` (owner-only)
- `/reset` or `/new` - `/reset` or `/new`
- `/think <level>` (aliases: `/thinking`, `/t`) - `/think <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only; aliases: `/thinking`, `/t`)
- `/verbose on|off` (alias: `/v`) - `/verbose on|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only) - `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
- `/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:`).
@@ -81,6 +91,10 @@ Notes:
- **Inline shortcuts (allowlisted senders only):** `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`) also work when embedded in text. - **Inline shortcuts (allowlisted senders only):** `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`) also work when embedded in text.
- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text. - Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text.
## Security note: `!` / `/bash`
`! <cmd>` and `/bash <cmd>` run **directly on the gateway host** as the gateway user. Keep this disabled unless you fully trust the sender and have locked down allowlists. Treat it like giving someone SSH access to the host.
## Usage vs cost (what shows where) ## Usage vs cost (what shows where)
- **Provider usage/quota** (example: “Claude 80% left”) shows up in `/status` when provider usage tracking is enabled. - **Provider usage/quota** (example: “Claude 80% left”) shows up in `/status` when provider usage tracking is enabled.
+2 -1
View File
@@ -7,11 +7,12 @@ read_when:
## What it does ## What it does
- Inline directive in any inbound body: `/t <level>`, `/think:<level>`, or `/thinking <level>`. - Inline directive in any inbound body: `/t <level>`, `/think:<level>`, or `/thinking <level>`.
- Levels (aliases): `off | minimal | low | medium | high` - Levels (aliases): `off | minimal | low | medium | high | xhigh` (GPT-5.2 + Codex models only)
- minimal → “think” - minimal → “think”
- low → “think hard” - low → “think hard”
- medium → “think harder” - medium → “think harder”
- high → “ultrathink” (max budget) - high → “ultrathink” (max budget)
- xhigh → “ultrathink+” (GPT-5.2 + Codex models only)
- `highest`, `max` map to `high`. - `highest`, `max` map to `high`.
## Resolution order ## Resolution order
+1 -1
View File
@@ -50,7 +50,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
- `/agent <id>` (or `/agents`) - `/agent <id>` (or `/agents`)
- `/session <key>` (or `/sessions`) - `/session <key>` (or `/sessions`)
- `/model <provider/model>` (or `/model list`, `/models`) - `/model <provider/model>` (or `/model list`, `/models`)
- `/think <off|minimal|low|medium|high>` - `/think <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only)
- `/verbose <on|off>` - `/verbose <on|off>`
- `/reasoning <on|off|stream>` (stream = Telegram draft only) - `/reasoning <on|off|stream>` (stream = Telegram draft only)
- `/cost <on|off>` - `/cost <on|off>`
+5 -5
View File
@@ -132,10 +132,10 @@
"@grammyjs/runner": "^2.0.3", "@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1", "@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4", "@homebridge/ciao": "^1.3.4",
"@mariozechner/pi-agent-core": "^0.43.0", "@mariozechner/pi-agent-core": "^0.45.3",
"@mariozechner/pi-ai": "^0.43.0", "@mariozechner/pi-ai": "^0.45.3",
"@mariozechner/pi-coding-agent": "^0.43.0", "@mariozechner/pi-coding-agent": "^0.45.3",
"@mariozechner/pi-tui": "^0.43.0", "@mariozechner/pi-tui": "^0.45.3",
"@microsoft/agents-hosting": "^1.1.1", "@microsoft/agents-hosting": "^1.1.1",
"@microsoft/agents-hosting-express": "^1.1.1", "@microsoft/agents-hosting-express": "^1.1.1",
"@microsoft/agents-hosting-extensions-teams": "^1.1.1", "@microsoft/agents-hosting-extensions-teams": "^1.1.1",
@@ -207,7 +207,7 @@
"@sinclair/typebox": "0.34.47" "@sinclair/typebox": "0.34.47"
}, },
"patchedDependencies": { "patchedDependencies": {
"@mariozechner/pi-ai@0.43.0": "patches/@mariozechner__pi-ai@0.43.0.patch" "@mariozechner/pi-ai@0.45.3": "patches/@mariozechner__pi-ai@0.45.3.patch"
} }
}, },
"vitest": { "vitest": {
@@ -1,8 +1,8 @@
diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js
index 12540bb1069087a0d0a2967f792008627b9f79d9..f30b525620e6d8e45146b439ec3733e4053c9d2a 100644 index cc9e0cb..2b18ec4 100644
--- a/dist/providers/google-gemini-cli.js --- a/dist/providers/google-gemini-cli.js
+++ b/dist/providers/google-gemini-cli.js +++ b/dist/providers/google-gemini-cli.js
@@ -248,6 +248,11 @@ export const streamGoogleGeminiCli = (model, context, options) => { @@ -329,6 +329,11 @@ export const streamGoogleGeminiCli = (model, context, options) => {
break; // Success, exit retry loop break; // Success, exit retry loop
} }
const errorText = await response.text(); const errorText = await response.text();
@@ -14,41 +14,8 @@ index 12540bb1069087a0d0a2967f792008627b9f79d9..f30b525620e6d8e45146b439ec3733e4
// Check if retryable // Check if retryable
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) { if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
// Use server-provided delay or exponential backoff // Use server-provided delay or exponential backoff
diff --git a/dist/providers/google-shared.js b/dist/providers/google-shared.js
index ae4710b0f134ac4a48f5b7053f454d1068bee71f..b1b5bd94586f68461ccc44e4a9cdf3acb4e0d084 100644
--- a/dist/providers/google-shared.js
+++ b/dist/providers/google-shared.js
@@ -42,6 +42,8 @@ export function retainThoughtSignature(existing, incoming) {
export function convertMessages(model, context) {
const contents = [];
const transformedMessages = transformMessages(context.messages, model);
+ const shouldStripFunctionId = typeof model.provider === "string" &&
+ model.provider.startsWith("google");
for (const msg of transformedMessages) {
if (msg.role === "user") {
if (typeof msg.content === "string") {
@@ -113,6 +115,9 @@ export function convertMessages(model, context) {
args: block.arguments,
},
};
+ if (shouldStripFunctionId && part?.functionCall?.id) {
+ delete part.functionCall.id; // Google Gemini/Vertex do not support 'id' in functionCall
+ }
if (block.thoughtSignature) {
part.thoughtSignature = block.thoughtSignature;
}
@@ -155,6 +160,9 @@ export function convertMessages(model, context) {
...(hasImages && supportsMultimodalFunctionResponse && { parts: imageParts }),
},
};
+ if (shouldStripFunctionId && functionResponsePart.functionResponse?.id) {
+ delete functionResponsePart.functionResponse.id; // Google Gemini/Vertex do not support 'id' in functionResponse
+ }
// Cloud Code Assist API requires all function responses to be in a single user turn.
// Check if the last content is already a user turn with function responses and merge.
const lastContent = contents[contents.length - 1];
diff --git a/dist/providers/openai-codex-responses.js b/dist/providers/openai-codex-responses.js diff --git a/dist/providers/openai-codex-responses.js b/dist/providers/openai-codex-responses.js
index ad0a2aabbe10382cee4e463b68a02864dd235e57..8c001acfd0b4e0743181c246f1bedcf8cd2ffb02 100644 index 7488c79..4c34587 100644
--- a/dist/providers/openai-codex-responses.js --- a/dist/providers/openai-codex-responses.js
+++ b/dist/providers/openai-codex-responses.js +++ b/dist/providers/openai-codex-responses.js
@@ -517,7 +517,7 @@ function convertTools(tools) { @@ -517,7 +517,7 @@ function convertTools(tools) {
@@ -61,10 +28,10 @@ index ad0a2aabbe10382cee4e463b68a02864dd235e57..8c001acfd0b4e0743181c246f1bedcf8
} }
function mapStopReason(status) { function mapStopReason(status) {
diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js
index f07085c64390b211340d6a826b28ea9c2e77302f..7f758532246cc7b062df48e9cec4e6c904b76a99 100644 index c4714f4..4d1e6b0 100644
--- a/dist/providers/openai-responses.js --- a/dist/providers/openai-responses.js
+++ b/dist/providers/openai-responses.js +++ b/dist/providers/openai-responses.js
@@ -396,10 +396,16 @@ function convertMessages(model, context) { @@ -400,10 +400,16 @@ function convertMessages(model, context) {
} }
else if (msg.role === "assistant") { else if (msg.role === "assistant") {
const output = []; const output = [];
@@ -81,7 +48,7 @@ index f07085c64390b211340d6a826b28ea9c2e77302f..7f758532246cc7b062df48e9cec4e6c9
const reasoningItem = JSON.parse(block.thinkingSignature); const reasoningItem = JSON.parse(block.thinkingSignature);
output.push(reasoningItem); output.push(reasoningItem);
} }
@@ -434,6 +440,16 @@ function convertMessages(model, context) { @@ -438,6 +444,16 @@ function convertMessages(model, context) {
}); });
} }
} }
+1258 -315
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -10,4 +10,4 @@ onlyBuiltDependencies:
- sharp - sharp
patchedDependencies: patchedDependencies:
'@mariozechner/pi-ai@0.43.0': patches/@mariozechner__pi-ai@0.43.0.patch '@mariozechner/pi-ai@0.45.3': patches/@mariozechner__pi-ai@0.45.3.patch
+7 -1
View File
@@ -7,7 +7,13 @@ export type ModelRef = {
model: string; model: string;
}; };
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high"; export type ThinkLevel =
| "off"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh";
export type ModelAliasIndex = { export type ModelAliasIndex = {
byAlias: Map<string, { alias: string; ref: ModelRef }>; byAlias: Map<string, { alias: string; ref: ModelRef }>;
+1 -1
View File
@@ -1046,7 +1046,7 @@ export function resolveEmbeddedSessionLane(key: string) {
} }
function mapThinkingLevel(level?: ThinkLevel): ThinkingLevel { function mapThinkingLevel(level?: ThinkLevel): ThinkingLevel {
// pi-agent-core supports "xhigh" too; Clawdbot doesn't surface it for now. // pi-agent-core supports "xhigh"; Clawdbot enables it for specific models.
if (!level) return "off"; if (!level) return "off";
return level; return level;
} }
+8
View File
@@ -251,6 +251,13 @@ 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");
@@ -314,6 +321,7 @@ 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;
} }
+91
View File
@@ -69,6 +69,97 @@ describe("directive behavior", () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("accepts /thinking xhigh for codex models", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{
Body: "/thinking xhigh",
From: "+1004",
To: "+2000",
},
{},
{
agents: {
defaults: {
model: "openai-codex/gpt-5.2-codex",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
},
);
const texts = (Array.isArray(res) ? res : [res])
.map((entry) => entry?.text)
.filter(Boolean);
expect(texts).toContain("Thinking level set to xhigh.");
});
});
it("accepts /thinking xhigh for openai gpt-5.2", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{
Body: "/thinking xhigh",
From: "+1004",
To: "+2000",
},
{},
{
agents: {
defaults: {
model: "openai/gpt-5.2",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
},
);
const texts = (Array.isArray(res) ? res : [res])
.map((entry) => entry?.text)
.filter(Boolean);
expect(texts).toContain("Thinking level set to xhigh.");
});
});
it("rejects /thinking xhigh for non-codex models", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{
Body: "/thinking xhigh",
From: "+1004",
To: "+2000",
},
{},
{
agents: {
defaults: {
model: "openai/gpt-4.1-mini",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
},
);
const texts = (Array.isArray(res) ? res : [res])
.map((entry) => entry?.text)
.filter(Boolean);
expect(texts).toContain(
'Thinking level "xhigh" is only supported for openai/gpt-5.2, openai-codex/gpt-5.2-codex or openai-codex/gpt-5.1-codex.',
);
});
});
it("keeps reserved command aliases from matching after trimming", async () => { it("keeps reserved command aliases from matching after trimming", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(runEmbeddedPiAgent).mockReset();
+47 -2
View File
@@ -29,7 +29,10 @@ import {
type ClawdbotConfig, type ClawdbotConfig,
loadConfig, loadConfig,
} from "../config/config.js"; } from "../config/config.js";
import { resolveSessionFilePath } from "../config/sessions.js"; import {
resolveSessionFilePath,
saveSessionStore,
} from "../config/sessions.js";
import { logVerbose } from "../globals.js"; import { logVerbose } from "../globals.js";
import { clearCommandLane, getQueueSize } from "../process/command-queue.js"; import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
import { getProviderDock } from "../providers/dock.js"; import { getProviderDock } from "../providers/dock.js";
@@ -94,8 +97,10 @@ import {
import type { MsgContext, TemplateContext } from "./templating.js"; import type { MsgContext, TemplateContext } from "./templating.js";
import { import {
type ElevatedLevel, type ElevatedLevel,
formatXHighModelHint,
normalizeThinkLevel, normalizeThinkLevel,
type ReasoningLevel, type ReasoningLevel,
supportsXHighThinking,
type ThinkLevel, type ThinkLevel,
type VerboseLevel, type VerboseLevel,
} from "./thinking.js"; } from "./thinking.js";
@@ -976,6 +981,11 @@ 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,
@@ -1033,6 +1043,11 @@ export async function getReplyFromConfig(
command, command,
agentId, agentId,
directives, directives,
elevated: {
enabled: elevatedEnabled,
allowed: elevatedAllowed,
failures: elevatedFailures,
},
sessionEntry, sessionEntry,
sessionStore, sessionStore,
sessionKey, sessionKey,
@@ -1187,7 +1202,10 @@ export async function getReplyFromConfig(
if (!resolvedThinkLevel && prefixedCommandBody) { if (!resolvedThinkLevel && prefixedCommandBody) {
const parts = prefixedCommandBody.split(/\s+/); const parts = prefixedCommandBody.split(/\s+/);
const maybeLevel = normalizeThinkLevel(parts[0]); const maybeLevel = normalizeThinkLevel(parts[0]);
if (maybeLevel) { if (
maybeLevel &&
(maybeLevel !== "xhigh" || supportsXHighThinking(provider, model))
) {
resolvedThinkLevel = maybeLevel; resolvedThinkLevel = maybeLevel;
prefixedCommandBody = parts.slice(1).join(" ").trim(); prefixedCommandBody = parts.slice(1).join(" ").trim();
} }
@@ -1195,6 +1213,33 @@ export async function getReplyFromConfig(
if (!resolvedThinkLevel) { if (!resolvedThinkLevel) {
resolvedThinkLevel = await modelState.resolveDefaultThinkingLevel(); resolvedThinkLevel = await modelState.resolveDefaultThinkingLevel();
} }
if (
resolvedThinkLevel === "xhigh" &&
!supportsXHighThinking(provider, model)
) {
const explicitThink =
directives.hasThinkDirective && directives.thinkLevel !== undefined;
if (explicitThink) {
typing.cleanup();
return {
text: `Thinking level "xhigh" is only supported for ${formatXHighModelHint()}. Use /think high or switch to one of those models.`,
};
}
resolvedThinkLevel = "high";
if (
sessionEntry &&
sessionStore &&
sessionKey &&
sessionEntry.thinkingLevel === "xhigh"
) {
sessionEntry.thinkingLevel = "high";
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
}
const sessionIdFinal = sessionId ?? crypto.randomUUID(); const sessionIdFinal = sessionId ?? crypto.randomUUID();
const sessionFile = resolveSessionFilePath(sessionIdFinal, sessionEntry); const sessionFile = resolveSessionFilePath(sessionIdFinal, sessionEntry);
const queueBodyBase = transcribedText const queueBodyBase = transcribedText
+444
View File
@@ -0,0 +1,444 @@
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 buildBashDisabledReply(provider?: string): ReplyPayload {
const providerKey = provider?.trim().toLowerCase() || "<provider>";
return {
text: [
"⚠️ /bash is disabled (default).",
"Enable it in your config (host-only; trusted senders only):",
"```json5",
"{",
" commands: {",
" bash: true",
" },",
" tools: {",
" elevated: {",
" enabled: true,",
` allowFrom: { "${providerKey}": ["<sender-id>"] }`,
" }",
" }",
"}",
"```",
"Restart the Gateway after updating config.",
].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) {
logVerbose("Blocked /bash: commands.bash is disabled.");
const provider =
params.ctx.Provider ??
params.ctx.Surface ??
params.ctx.OriginatingChannel ??
undefined;
return buildBashDisabledReply(provider ?? undefined);
}
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,6 +2,7 @@ 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";
@@ -33,6 +34,7 @@ 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",
@@ -47,6 +49,37 @@ 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 },
@@ -70,6 +103,32 @@ 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,6 +83,7 @@ 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";
@@ -400,6 +401,11 @@ 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;
@@ -425,6 +431,7 @@ export async function handleCommands(params: {
cfg, cfg,
command, command,
directives, directives,
elevated,
sessionEntry, sessionEntry,
sessionStore, sessionStore,
sessionKey, sessionKey,
@@ -465,6 +472,30 @@ 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 {
+156 -120
View File
@@ -37,6 +37,11 @@ import { applyVerboseOverride } from "../../sessions/level-overrides.js";
import { shortenHomePath } from "../../utils.js"; import { shortenHomePath } from "../../utils.js";
import { extractModelDirective } from "../model.js"; import { extractModelDirective } from "../model.js";
import type { MsgContext } from "../templating.js"; import type { MsgContext } from "../templating.js";
import {
formatThinkingLevels,
formatXHighModelHint,
supportsXHighThinking,
} from "../thinking.js";
import type { ReplyPayload } from "../types.js"; import type { ReplyPayload } from "../types.js";
import { import {
type ElevatedLevel, type ElevatedLevel,
@@ -778,6 +783,7 @@ export async function handleDirectiveOnly(params: {
allowedModelCatalog, allowedModelCatalog,
resetModelOverride, resetModelOverride,
provider, provider,
model,
initialModelLabel, initialModelLabel,
formatModelSwitchEvent, formatModelSwitchEvent,
currentThinkLevel, currentThinkLevel,
@@ -943,6 +949,117 @@ export async function handleDirectiveOnly(params: {
} }
} }
let modelSelection: ModelDirectiveSelection | undefined;
let profileOverride: string | undefined;
if (directives.hasModelDirective && directives.rawModelDirective) {
const raw = directives.rawModelDirective.trim();
if (/^[0-9]+$/.test(raw)) {
const resolvedDefault = resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider,
defaultModel,
});
const pickerCatalog: ModelPickerCatalogEntry[] = (() => {
const keys = new Set<string>();
const out: ModelPickerCatalogEntry[] = [];
const push = (entry: ModelPickerCatalogEntry) => {
const provider = normalizeProviderId(entry.provider);
const id = String(entry.id ?? "").trim();
if (!provider || !id) return;
const key = modelKey(provider, id);
if (keys.has(key)) return;
keys.add(key);
out.push({ provider, id, name: entry.name });
};
for (const entry of allowedModelCatalog) push(entry);
for (const rawKey of Object.keys(
params.cfg.agents?.defaults?.models ?? {},
)) {
const resolved = resolveModelRefFromString({
raw: String(rawKey),
defaultProvider,
aliasIndex,
});
if (!resolved) continue;
push({
provider: resolved.ref.provider,
id: resolved.ref.model,
name: resolved.ref.model,
});
}
if (resolvedDefault.model) {
push({
provider: resolvedDefault.provider,
id: resolvedDefault.model,
name: resolvedDefault.model,
});
}
return out;
})();
const items = buildModelPickerItems(pickerCatalog);
const index = Number.parseInt(raw, 10) - 1;
const item = Number.isFinite(index) ? items[index] : undefined;
if (!item) {
return {
text: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const picked = pickProviderForModel({
item,
preferredProvider: params.provider,
});
if (!picked) {
return {
text: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const key = `${picked.provider}/${picked.model}`;
const aliases = aliasIndex.byKey.get(key);
const alias = aliases && aliases.length > 0 ? aliases[0] : undefined;
modelSelection = {
provider: picked.provider,
model: picked.model,
isDefault:
picked.provider === defaultProvider && picked.model === defaultModel,
...(alias ? { alias } : {}),
};
} else {
const resolved = resolveModelDirectiveSelection({
raw,
defaultProvider,
defaultModel,
aliasIndex,
allowedModelKeys,
});
if (resolved.error) {
return { text: resolved.error };
}
modelSelection = resolved.selection;
}
if (modelSelection && directives.rawModelProfile) {
const profileResolved = resolveProfileOverride({
rawProfile: directives.rawModelProfile,
provider: modelSelection.provider,
cfg: params.cfg,
agentDir,
});
if (profileResolved.error) {
return { text: profileResolved.error };
}
profileOverride = profileResolved.profileId;
}
}
if (directives.rawModelProfile && !modelSelection) {
return { text: "Auth profile override requires a model selection." };
}
const resolvedProvider = modelSelection?.provider ?? provider;
const resolvedModel = modelSelection?.model ?? model;
if (directives.hasThinkDirective && !directives.thinkLevel) { if (directives.hasThinkDirective && !directives.thinkLevel) {
// If no argument was provided, show the current level // If no argument was provided, show the current level
if (!directives.rawThinkLevel) { if (!directives.rawThinkLevel) {
@@ -950,12 +1067,12 @@ export async function handleDirectiveOnly(params: {
return { return {
text: withOptions( text: withOptions(
`Current thinking level: ${level}.`, `Current thinking level: ${level}.`,
"off, minimal, low, medium, high", formatThinkingLevels(resolvedProvider, resolvedModel),
), ),
}; };
} }
return { return {
text: `Unrecognized thinking level "${directives.rawThinkLevel}". Valid levels: off, minimal, low, medium, high.`, text: `Unrecognized thinking level "${directives.rawThinkLevel}". Valid levels: ${formatThinkingLevels(resolvedProvider, resolvedModel)}.`,
}; };
} }
if (directives.hasVerboseDirective && !directives.verboseLevel) { if (directives.hasVerboseDirective && !directives.verboseLevel) {
@@ -1098,126 +1215,25 @@ export async function handleDirectiveOnly(params: {
return { text: errors.join(" ") }; return { text: errors.join(" ") };
} }
let modelSelection: ModelDirectiveSelection | undefined; if (
let profileOverride: string | undefined; directives.hasThinkDirective &&
if (directives.hasModelDirective && directives.rawModelDirective) { directives.thinkLevel === "xhigh" &&
const raw = directives.rawModelDirective.trim(); !supportsXHighThinking(resolvedProvider, resolvedModel)
if (/^[0-9]+$/.test(raw)) { ) {
const resolvedDefault = resolveConfiguredModelRef({ return {
cfg: params.cfg, text: `Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`,
defaultProvider, };
defaultModel,
});
const pickerCatalog: ModelPickerCatalogEntry[] = (() => {
const keys = new Set<string>();
const out: ModelPickerCatalogEntry[] = [];
const push = (entry: ModelPickerCatalogEntry) => {
const provider = normalizeProviderId(entry.provider);
const id = String(entry.id ?? "").trim();
if (!provider || !id) return;
const key = modelKey(provider, id);
if (keys.has(key)) return;
keys.add(key);
out.push({ provider, id, name: entry.name });
};
for (const entry of allowedModelCatalog) push(entry);
for (const rawKey of Object.keys(
params.cfg.agents?.defaults?.models ?? {},
)) {
const resolved = resolveModelRefFromString({
raw: String(rawKey),
defaultProvider,
aliasIndex,
});
if (!resolved) continue;
push({
provider: resolved.ref.provider,
id: resolved.ref.model,
name: resolved.ref.model,
});
}
if (resolvedDefault.model) {
push({
provider: resolvedDefault.provider,
id: resolvedDefault.model,
name: resolvedDefault.model,
});
}
return out;
})();
const items = buildModelPickerItems(pickerCatalog);
const index = Number.parseInt(raw, 10) - 1;
const item = Number.isFinite(index) ? items[index] : undefined;
if (!item) {
return {
text: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const picked = pickProviderForModel({
item,
preferredProvider: params.provider,
});
if (!picked) {
return {
text: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const key = `${picked.provider}/${picked.model}`;
const aliases = aliasIndex.byKey.get(key);
const alias = aliases && aliases.length > 0 ? aliases[0] : undefined;
modelSelection = {
provider: picked.provider,
model: picked.model,
isDefault:
picked.provider === defaultProvider && picked.model === defaultModel,
...(alias ? { alias } : {}),
};
} else {
const resolved = resolveModelDirectiveSelection({
raw,
defaultProvider,
defaultModel,
aliasIndex,
allowedModelKeys,
});
if (resolved.error) {
return { text: resolved.error };
}
modelSelection = resolved.selection;
}
if (modelSelection) {
if (directives.rawModelProfile) {
const profileResolved = resolveProfileOverride({
rawProfile: directives.rawModelProfile,
provider: modelSelection.provider,
cfg: params.cfg,
agentDir,
});
if (profileResolved.error) {
return { text: profileResolved.error };
}
profileOverride = profileResolved.profileId;
}
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
if (nextLabel !== initialModelLabel) {
enqueueSystemEvent(
formatModelSwitchEvent(nextLabel, modelSelection.alias),
{
sessionKey,
contextKey: `model:${nextLabel}`,
},
);
}
}
}
if (directives.rawModelProfile && !modelSelection) {
return { text: "Auth profile override requires a model selection." };
} }
const nextThinkLevel = directives.hasThinkDirective
? directives.thinkLevel
: ((sessionEntry?.thinkingLevel as ThinkLevel | undefined) ??
currentThinkLevel);
const shouldDowngradeXHigh =
!directives.hasThinkDirective &&
nextThinkLevel === "xhigh" &&
!supportsXHighThinking(resolvedProvider, resolvedModel);
if (sessionEntry && sessionStore && sessionKey) { if (sessionEntry && sessionStore && sessionKey) {
const prevElevatedLevel = const prevElevatedLevel =
currentElevatedLevel ?? currentElevatedLevel ??
@@ -1239,6 +1255,9 @@ export async function handleDirectiveOnly(params: {
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel; if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
else sessionEntry.thinkingLevel = directives.thinkLevel; else sessionEntry.thinkingLevel = directives.thinkLevel;
} }
if (shouldDowngradeXHigh) {
sessionEntry.thinkingLevel = "high";
}
if (directives.hasVerboseDirective && directives.verboseLevel) { if (directives.hasVerboseDirective && directives.verboseLevel) {
applyVerboseOverride(sessionEntry, directives.verboseLevel); applyVerboseOverride(sessionEntry, directives.verboseLevel);
} }
@@ -1295,6 +1314,18 @@ export async function handleDirectiveOnly(params: {
if (storePath) { if (storePath) {
await saveSessionStore(storePath, sessionStore); await saveSessionStore(storePath, sessionStore);
} }
if (modelSelection) {
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
if (nextLabel !== initialModelLabel) {
enqueueSystemEvent(
formatModelSwitchEvent(nextLabel, modelSelection.alias),
{
sessionKey,
contextKey: `model:${nextLabel}`,
},
);
}
}
if (elevatedChanged) { if (elevatedChanged) {
const nextElevated = (sessionEntry.elevatedLevel ?? const nextElevated = (sessionEntry.elevatedLevel ??
"off") as ElevatedLevel; "off") as ElevatedLevel;
@@ -1345,6 +1376,11 @@ export async function handleDirectiveOnly(params: {
); );
if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint()); if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint());
} }
if (shouldDowngradeXHigh) {
parts.push(
`Thinking level set to high (xhigh not supported for ${resolvedProvider}/${resolvedModel}).`,
);
}
if (modelSelection) { if (modelSelection) {
const label = `${modelSelection.provider}/${modelSelection.model}`; const label = `${modelSelection.provider}/${modelSelection.model}`;
const labelWithAlias = modelSelection.alias const labelWithAlias = modelSelection.alias
+25 -1
View File
@@ -1,10 +1,34 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { normalizeReasoningLevel, normalizeThinkLevel } from "./thinking.js"; import {
listThinkingLevels,
normalizeReasoningLevel,
normalizeThinkLevel,
} from "./thinking.js";
describe("normalizeThinkLevel", () => { describe("normalizeThinkLevel", () => {
it("accepts mid as medium", () => { it("accepts mid as medium", () => {
expect(normalizeThinkLevel("mid")).toBe("medium"); expect(normalizeThinkLevel("mid")).toBe("medium");
}); });
it("accepts xhigh", () => {
expect(normalizeThinkLevel("xhigh")).toBe("xhigh");
});
});
describe("listThinkingLevels", () => {
it("includes xhigh for codex models", () => {
expect(listThinkingLevels(undefined, "gpt-5.2-codex")).toContain("xhigh");
});
it("includes xhigh for openai gpt-5.2", () => {
expect(listThinkingLevels("openai", "gpt-5.2")).toContain("xhigh");
});
it("excludes xhigh for non-codex models", () => {
expect(listThinkingLevels(undefined, "gpt-4.1-mini")).not.toContain(
"xhigh",
);
});
}); });
describe("normalizeReasoningLevel", () => { describe("normalizeReasoningLevel", () => {
+61 -1
View File
@@ -1,9 +1,30 @@
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high"; export type ThinkLevel =
| "off"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh";
export type VerboseLevel = "off" | "on"; export type VerboseLevel = "off" | "on";
export type ElevatedLevel = "off" | "on"; export type ElevatedLevel = "off" | "on";
export type ReasoningLevel = "off" | "on" | "stream"; export type ReasoningLevel = "off" | "on" | "stream";
export type UsageDisplayLevel = "off" | "on"; export type UsageDisplayLevel = "off" | "on";
export const XHIGH_MODEL_REFS = [
"openai/gpt-5.2",
"openai-codex/gpt-5.2-codex",
"openai-codex/gpt-5.1-codex",
] as const;
const XHIGH_MODEL_SET = new Set(
XHIGH_MODEL_REFS.map((entry) => entry.toLowerCase()),
);
const XHIGH_MODEL_IDS = new Set(
XHIGH_MODEL_REFS.map((entry) => entry.split("/")[1]?.toLowerCase()).filter(
(entry): entry is string => Boolean(entry),
),
);
// Normalize user-provided thinking level strings to the canonical enum. // Normalize user-provided thinking level strings to the canonical enum.
export function normalizeThinkLevel( export function normalizeThinkLevel(
raw?: string | null, raw?: string | null,
@@ -32,10 +53,49 @@ export function normalizeThinkLevel(
].includes(key) ].includes(key)
) )
return "high"; return "high";
if (["xhigh", "x-high", "x_high"].includes(key)) return "xhigh";
if (["think"].includes(key)) return "minimal"; if (["think"].includes(key)) return "minimal";
return undefined; return undefined;
} }
export function supportsXHighThinking(
provider?: string | null,
model?: string | null,
): boolean {
const modelKey = model?.trim().toLowerCase();
if (!modelKey) return false;
const providerKey = provider?.trim().toLowerCase();
if (providerKey) {
return XHIGH_MODEL_SET.has(`${providerKey}/${modelKey}`);
}
return XHIGH_MODEL_IDS.has(modelKey);
}
export function listThinkingLevels(
provider?: string | null,
model?: string | null,
): ThinkLevel[] {
const levels: ThinkLevel[] = ["off", "minimal", "low", "medium", "high"];
if (supportsXHighThinking(provider, model)) levels.push("xhigh");
return levels;
}
export function formatThinkingLevels(
provider?: string | null,
model?: string | null,
separator = ", ",
): string {
return listThinkingLevels(provider, model).join(separator);
}
export function formatXHighModelHint(): string {
const refs = [...XHIGH_MODEL_REFS] as string[];
if (refs.length === 0) return "unknown model";
if (refs.length === 1) return refs[0];
if (refs.length === 2) return `${refs[0]} or ${refs[1]}`;
return `${refs.slice(0, -1).join(", ")} or ${refs[refs.length - 1]}`;
}
// Normalize verbose flags used to toggle agent verbosity. // Normalize verbose flags used to toggle agent verbosity.
export function normalizeVerboseLevel( export function normalizeVerboseLevel(
raw?: string | null, raw?: string | null,
+37 -2
View File
@@ -30,8 +30,11 @@ import { hasNonzeroUsage } from "../agents/usage.js";
import { ensureAgentWorkspace } from "../agents/workspace.js"; import { ensureAgentWorkspace } from "../agents/workspace.js";
import type { MsgContext } from "../auto-reply/templating.js"; import type { MsgContext } from "../auto-reply/templating.js";
import { import {
formatThinkingLevels,
formatXHighModelHint,
normalizeThinkLevel, normalizeThinkLevel,
normalizeVerboseLevel, normalizeVerboseLevel,
supportsXHighThinking,
type ThinkLevel, type ThinkLevel,
type VerboseLevel, type VerboseLevel,
} from "../auto-reply/thinking.js"; } from "../auto-reply/thinking.js";
@@ -216,17 +219,26 @@ export async function agentCommand(
ensureBootstrapFiles: !agentCfg?.skipBootstrap, ensureBootstrapFiles: !agentCfg?.skipBootstrap,
}); });
const workspaceDir = workspace.dir; const workspaceDir = workspace.dir;
const configuredModel = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const thinkingLevelsHint = formatThinkingLevels(
configuredModel.provider,
configuredModel.model,
);
const thinkOverride = normalizeThinkLevel(opts.thinking); const thinkOverride = normalizeThinkLevel(opts.thinking);
const thinkOnce = normalizeThinkLevel(opts.thinkingOnce); const thinkOnce = normalizeThinkLevel(opts.thinkingOnce);
if (opts.thinking && !thinkOverride) { if (opts.thinking && !thinkOverride) {
throw new Error( throw new Error(
"Invalid thinking level. Use one of: off, minimal, low, medium, high.", `Invalid thinking level. Use one of: ${thinkingLevelsHint}.`,
); );
} }
if (opts.thinkingOnce && !thinkOnce) { if (opts.thinkingOnce && !thinkOnce) {
throw new Error( throw new Error(
"Invalid one-shot thinking level. Use one of: off, minimal, low, medium, high.", `Invalid one-shot thinking level. Use one of: ${thinkingLevelsHint}.`,
); );
} }
@@ -444,6 +456,29 @@ export async function agentCommand(
catalog: catalogForThinking, catalog: catalogForThinking,
}); });
} }
if (
resolvedThinkLevel === "xhigh" &&
!supportsXHighThinking(provider, model)
) {
const explicitThink = Boolean(thinkOnce || thinkOverride);
if (explicitThink) {
throw new Error(
`Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`,
);
}
resolvedThinkLevel = "high";
if (
sessionEntry &&
sessionStore &&
sessionKey &&
sessionEntry.thinkingLevel === "xhigh"
) {
sessionEntry.thinkingLevel = "high";
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore);
}
}
const sessionFile = resolveSessionFilePath(sessionId, sessionEntry, { const sessionFile = resolveSessionFilePath(sessionId, sessionEntry, {
agentId: sessionAgentId, agentId: sessionAgentId,
}); });
+6
View File
@@ -155,6 +155,8 @@ 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",
@@ -289,6 +291,10 @@ 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":
+5 -1
View File
@@ -1244,6 +1244,10 @@ 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). */
@@ -1629,7 +1633,7 @@ export type AgentDefaultsConfig = {
/** Vector memory search configuration (per-agent overrides supported). */ /** Vector memory search configuration (per-agent overrides supported). */
memorySearch?: MemorySearchConfig; memorySearch?: MemorySearchConfig;
/** Default thinking level when no /think directive is present. */ /** Default thinking level when no /think directive is present. */
thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high"; thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
/** Default verbose level when no /verbose directive is present. */ /** Default verbose level when no /verbose directive is present. */
verboseDefault?: "off" | "on"; verboseDefault?: "off" | "on";
/** Default elevated level when no /elevated directive is present. */ /** Default elevated level when no /elevated directive is present. */
+3
View File
@@ -724,6 +724,8 @@ 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(),
@@ -1248,6 +1250,7 @@ const AgentDefaultsSchema = z
z.literal("low"), z.literal("low"),
z.literal("medium"), z.literal("medium"),
z.literal("high"), z.literal("high"),
z.literal("xhigh"),
]) ])
.optional(), .optional(),
verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(), verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
+10 -1
View File
@@ -32,7 +32,11 @@ import {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS, DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
stripHeartbeatToken, stripHeartbeatToken,
} from "../auto-reply/heartbeat.js"; } from "../auto-reply/heartbeat.js";
import { normalizeThinkLevel } from "../auto-reply/thinking.js"; import {
formatXHighModelHint,
normalizeThinkLevel,
supportsXHighThinking,
} from "../auto-reply/thinking.js";
import type { CliDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { import {
@@ -367,6 +371,11 @@ export async function runCronIsolatedAgentTurn(params: {
catalog: await loadCatalog(), catalog: await loadCatalog(),
}); });
} }
if (thinkLevel === "xhigh" && !supportsXHighThinking(provider, model)) {
throw new Error(
`Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`,
);
}
const timeoutMs = resolveAgentTimeoutMs({ const timeoutMs = resolveAgentTimeoutMs({
cfg: cfgWithAgentDefaults, cfg: cfgWithAgentDefaults,
+31 -1
View File
@@ -8,10 +8,13 @@ import {
} from "../agents/model-selection.js"; } from "../agents/model-selection.js";
import { normalizeGroupActivation } from "../auto-reply/group-activation.js"; import { normalizeGroupActivation } from "../auto-reply/group-activation.js";
import { import {
formatThinkingLevels,
formatXHighModelHint,
normalizeElevatedLevel, normalizeElevatedLevel,
normalizeReasoningLevel, normalizeReasoningLevel,
normalizeThinkLevel, normalizeThinkLevel,
normalizeUsageDisplay, normalizeUsageDisplay,
supportsXHighThinking,
} from "../auto-reply/thinking.js"; } from "../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import type { SessionEntry } from "../config/sessions.js"; import type { SessionEntry } from "../config/sessions.js";
@@ -95,8 +98,17 @@ export async function applySessionsPatchToStore(params: {
} else if (raw !== undefined) { } else if (raw !== undefined) {
const normalized = normalizeThinkLevel(String(raw)); const normalized = normalizeThinkLevel(String(raw));
if (!normalized) { if (!normalized) {
const resolvedDefault = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const hintProvider =
existing?.providerOverride?.trim() || resolvedDefault.provider;
const hintModel =
existing?.modelOverride?.trim() || resolvedDefault.model;
return invalid( return invalid(
"invalid thinkingLevel (use off|minimal|low|medium|high)", `invalid thinkingLevel (use ${formatThinkingLevels(hintProvider, hintModel, "|")})`,
); );
} }
if (normalized === "off") delete next.thinkingLevel; if (normalized === "off") delete next.thinkingLevel;
@@ -196,6 +208,24 @@ export async function applySessionsPatchToStore(params: {
} }
} }
if (next.thinkingLevel === "xhigh") {
const resolvedDefault = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const effectiveProvider = next.providerOverride ?? resolvedDefault.provider;
const effectiveModel = next.modelOverride ?? resolvedDefault.model;
if (!supportsXHighThinking(effectiveProvider, effectiveModel)) {
if ("thinkingLevel" in patch) {
return invalid(
`thinkingLevel "xhigh" is only supported for ${formatXHighModelHint()}`,
);
}
next.thinkingLevel = "high";
}
}
if ("sendPolicy" in patch) { if ("sendPolicy" in patch) {
const raw = patch.sendPolicy; const raw = patch.sendPolicy;
if (raw === null) { if (raw === null) {
+4 -4
View File
@@ -171,7 +171,7 @@ describe("google-shared convertMessages", () => {
{ {
type: "thinking", type: "thinking",
thinking: "hidden", thinking: "hidden",
thinkingSignature: "sig", thinkingSignature: "c2ln",
}, },
], ],
api: "google-generative-ai", api: "google-generative-ai",
@@ -202,7 +202,7 @@ describe("google-shared convertMessages", () => {
expect(contents[0].role).toBe("model"); expect(contents[0].role).toBe("model");
expect(contents[0].parts?.[0]).toMatchObject({ expect(contents[0].parts?.[0]).toMatchObject({
thought: true, thought: true,
thoughtSignature: "sig", thoughtSignature: "c2ln",
}); });
}); });
@@ -216,7 +216,7 @@ describe("google-shared convertMessages", () => {
{ {
type: "thinking", type: "thinking",
thinking: "structured", thinking: "structured",
thinkingSignature: "sig", thinkingSignature: "c2ln",
}, },
], ],
api: "google-generative-ai", api: "google-generative-ai",
@@ -247,7 +247,7 @@ describe("google-shared convertMessages", () => {
expect(parts).toHaveLength(1); expect(parts).toHaveLength(1);
expect(parts[0]).toMatchObject({ expect(parts[0]).toMatchObject({
thought: true, thought: true,
thoughtSignature: "sig", thoughtSignature: "c2ln",
}); });
}); });
+23 -7
View File
@@ -1,6 +1,9 @@
import type { SlashCommand } from "@mariozechner/pi-tui"; import type { SlashCommand } from "@mariozechner/pi-tui";
import {
formatThinkingLevels,
listThinkingLevels,
} from "../auto-reply/thinking.js";
const THINK_LEVELS = ["off", "minimal", "low", "medium", "high"];
const VERBOSE_LEVELS = ["on", "off"]; const VERBOSE_LEVELS = ["on", "off"];
const REASONING_LEVELS = ["on", "off"]; const REASONING_LEVELS = ["on", "off"];
const ELEVATED_LEVELS = ["on", "off"]; const ELEVATED_LEVELS = ["on", "off"];
@@ -12,6 +15,11 @@ export type ParsedCommand = {
args: string; args: string;
}; };
export type SlashCommandOptions = {
provider?: string;
model?: string;
};
const COMMAND_ALIASES: Record<string, string> = { const COMMAND_ALIASES: Record<string, string> = {
elev: "elevated", elev: "elevated",
}; };
@@ -27,7 +35,10 @@ export function parseCommand(input: string): ParsedCommand {
}; };
} }
export function getSlashCommands(): SlashCommand[] { export function getSlashCommands(
options: SlashCommandOptions = {},
): SlashCommand[] {
const thinkLevels = listThinkingLevels(options.provider, options.model);
return [ return [
{ name: "help", description: "Show slash command help" }, { name: "help", description: "Show slash command help" },
{ name: "status", description: "Show gateway status summary" }, { name: "status", description: "Show gateway status summary" },
@@ -44,9 +55,9 @@ export function getSlashCommands(): SlashCommand[] {
name: "think", name: "think",
description: "Set thinking level", description: "Set thinking level",
getArgumentCompletions: (prefix) => getArgumentCompletions: (prefix) =>
THINK_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map( thinkLevels
(value) => ({ value, label: value }), .filter((v) => v.startsWith(prefix.toLowerCase()))
), .map((value) => ({ value, label: value })),
}, },
{ {
name: "verbose", name: "verbose",
@@ -105,7 +116,12 @@ export function getSlashCommands(): SlashCommand[] {
]; ];
} }
export function helpText(): string { export function helpText(options: SlashCommandOptions = {}): string {
const thinkLevels = formatThinkingLevels(
options.provider,
options.model,
"|",
);
return [ return [
"Slash commands:", "Slash commands:",
"/help", "/help",
@@ -113,7 +129,7 @@ export function helpText(): string {
"/agent <id> (or /agents)", "/agent <id> (or /agents)",
"/session <key> (or /sessions)", "/session <key> (or /sessions)",
"/model <provider/model> (or /models)", "/model <provider/model> (or /models)",
"/think <off|minimal|low|medium|high>", `/think <${thinkLevels}>`,
"/verbose <on|off>", "/verbose <on|off>",
"/reasoning <on|off>", "/reasoning <on|off>",
"/cost <on|off>", "/cost <on|off>",
+30 -6
View File
@@ -7,7 +7,10 @@ import {
TUI, TUI,
} from "@mariozechner/pi-tui"; } from "@mariozechner/pi-tui";
import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { normalizeUsageDisplay } from "../auto-reply/thinking.js"; import {
formatThinkingLevels,
normalizeUsageDisplay,
} from "../auto-reply/thinking.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { formatAge } from "../infra/provider-summary.js"; import { formatAge } from "../infra/provider-summary.js";
import { import {
@@ -239,6 +242,18 @@ export async function runTui(opts: TuiOptions) {
root.addChild(footer); root.addChild(footer);
root.addChild(editor); root.addChild(editor);
const updateAutocompleteProvider = () => {
editor.setAutocompleteProvider(
new CombinedAutocompleteProvider(
getSlashCommands({
provider: sessionInfo.modelProvider,
model: sessionInfo.model,
}),
process.cwd(),
),
);
};
const tui = new TUI(new ProcessTerminal()); const tui = new TUI(new ProcessTerminal());
tui.addChild(root); tui.addChild(root);
tui.setFocus(editor); tui.setFocus(editor);
@@ -524,6 +539,7 @@ export async function runTui(opts: TuiOptions) {
} catch (err) { } catch (err) {
chatLog.addSystem(`sessions list failed: ${String(err)}`); chatLog.addSystem(`sessions list failed: ${String(err)}`);
} }
updateAutocompleteProvider();
updateFooter(); updateFooter();
tui.requestRender(); tui.requestRender();
}; };
@@ -861,7 +877,12 @@ export async function runTui(opts: TuiOptions) {
if (!name) return; if (!name) return;
switch (name) { switch (name) {
case "help": case "help":
chatLog.addSystem(helpText()); chatLog.addSystem(
helpText({
provider: sessionInfo.modelProvider,
model: sessionInfo.model,
}),
);
break; break;
case "status": case "status":
try { try {
@@ -921,7 +942,12 @@ export async function runTui(opts: TuiOptions) {
break; break;
case "think": case "think":
if (!args) { if (!args) {
chatLog.addSystem("usage: /think <off|minimal|low|medium|high>"); const levels = formatThinkingLevels(
sessionInfo.modelProvider,
sessionInfo.model,
"|",
);
chatLog.addSystem(`usage: /think <${levels}>`);
break; break;
} }
try { try {
@@ -1071,9 +1097,7 @@ export async function runTui(opts: TuiOptions) {
tui.requestRender(); tui.requestRender();
}; };
editor.setAutocompleteProvider( updateAutocompleteProvider();
new CombinedAutocompleteProvider(getSlashCommands(), process.cwd()),
);
editor.onSubmit = (text) => { editor.onSubmit = (text) => {
const value = text.trim(); const value = text.trim();
editor.setText(""); editor.setText("");
+3 -3
View File
@@ -122,9 +122,9 @@ const spawnGatewayInstance = async (name: string): Promise<GatewayInstance> => {
try { try {
child = spawn( child = spawn(
"bun", "node",
[ [
"src/index.ts", "dist/index.js",
"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("bun", ["src/index.ts", ...args], { const child = spawn("node", ["dist/index.js", ...args], {
cwd: process.cwd(), cwd: process.cwd(),
env: { ...process.env, ...env }, env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
+2 -2
View File
@@ -15,10 +15,10 @@
"vite": "7.3.1" "vite": "7.3.1"
}, },
"devDependencies": { "devDependencies": {
"@vitest/browser-playwright": "4.0.16", "@vitest/browser-playwright": "4.0.17",
"playwright": "^1.57.0", "playwright": "^1.57.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "4.0.16" "vitest": "4.0.17"
}, },
"pnpm": { "pnpm": {
"minimumReleaseAge": 2880 "minimumReleaseAge": 2880