Compare commits

...
Author SHA1 Message Date
Peter Steinberger a482845031 fix: keep approvals for elevated ask (#1616) (thanks @ivancasco) 2026-01-24 20:54:54 +00:00
Ivan Casco 7481086d74 fix(exec): only set security=full when elevated mode is full 2026-01-24 20:47:55 +00:00
Peter Steinberger a1ed671636 docs: add backup strategy faq 2026-01-24 20:34:05 +00:00
Peter Steinberger 8c47d226ad docs: add subscription requirement faq 2026-01-24 20:32:33 +00:00
Peter Steinberger e1942603e9 docs: add xfinity unblock link 2026-01-24 20:30:03 +00:00
Peter Steinberger 926c2647b8 docs: mention local-only model option 2026-01-24 20:15:58 +00:00
Peter Steinberger c427f4a2fc docs: add imessage mac requirement faq 2026-01-24 20:13:13 +00:00
Peter Steinberger f99f9a6b64 docs: add self-update faq entry 2026-01-24 20:10:47 +00:00
Petter BlombergandPeter Steinberger 39d8c441eb fix: reduce log noise for node disconnect/late invoke errors (#1607)
* fix: reduce log noise for node disconnect/late invoke errors

- Handle both 'node not connected' and 'node disconnected' errors at info level
- Return success with late:true for unknown invoke IDs instead of error
- Add 30-second throttle to skills change listener to prevent rapid-fire probes
- Add tests for isNodeUnavailableError and late invoke handling

* fix: clean up skills refresh timer and listener on shutdown

Store the return value from registerSkillsChangeListener() and call it
on gateway shutdown. Also clear any pending refresh timer. This follows
the same pattern used for agentUnsub and heartbeatUnsub.

* refactor: simplify KISS/YAGNI - inline checks, remove unit tests for internal utilities

* fix: reduce gateway log noise (#1607) (thanks @petter-b)

* test: align agent id casing expectations (#1607)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 20:05:41 +00:00
Peter Steinberger 40ef3b5d30 docs: add linux install faq entry 2026-01-24 19:58:42 +00:00
Peter Steinberger 390b730b37 fix: unify reasoning tags + agent ids (#1613) (thanks @kyleok) (#1629) 2026-01-24 19:56:02 +00:00
Ganghyun KimandClaude Code 71457fa100 fix(tui): strip <final> tags in TUI display (#1613)
Add <final> tag handling to stripThinkingTags() to prevent reasoning-tag
provider responses from leaking incomplete tags during streaming.

When using providers like google-antigravity/*, ollama, or minimax, the
model wraps responses in <think>...</think> and <final>...</final> tags.
The TUI was only stripping <think> tags, causing <final> to leak through
and display as the response ~50% of the time.

This is a defense-in-depth fix for the TUI layer.

Fixes: #1561

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-01-24 19:52:34 +00:00
Peter Steinberger da7a45b3a5 docs: clarify migration state vs workspace 2026-01-24 19:50:02 +00:00
Denys VitaliandClaude 15a9c21203 Add Build & Release Docker Image workflows (#1602)
* ci: build & release docker image

* ci: sync docker-release workflow updates

Squashes:
- ci: use correct runs-on
- ci: build images

Co-Authored-By: Claude <noreply@anthropic.com>

* Remove submodule checkout from docker-release.yml

Removed submodule checkout step from Docker release workflow.

* Simplify Docker release workflow by removing submodule checkout

Removed submodule checkout step from Docker release workflow.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-24 19:23:55 +00:00
Peter Steinberger 6d79c6cd26 fix: clean docker onboarding warnings + preserve agentId casing 2026-01-24 19:07:01 +00:00
Peter Steinberger bcedeb4e1f chore: bump 2026.1.24 2026-01-24 15:00:00 +00:00
Peter Steinberger f076eba98a docs: add hackable install faq 2026-01-24 14:52:26 +00:00
Peter Steinberger f3bd6bf342 docs: add docs ssl error faq 2026-01-24 14:51:26 +00:00
Peter Steinberger c29c9a1e3e docs: add pi sizing guidance 2026-01-24 14:51:26 +00:00
Peter Steinberger 7a384ea07c docs: add update fly signal 2026-01-24 14:37:31 +00:00
Peter Steinberger 5fc866e8fe docs: add openai subscription faq 2026-01-24 14:37:17 +00:00
Peter Steinberger 437535ee94 docs: clarify gpt-5.2 vs glm 2026-01-24 14:37:17 +00:00
Peter Steinberger 3b929ff843 docs: add glm budget option 2026-01-24 14:37:17 +00:00
Peter Steinberger 93737ee152 test: align agent id normalization 2026-01-24 14:36:31 +00:00
Peter Steinberger 765626b492 test: trim cron agentId label 2026-01-24 14:36:31 +00:00
Peter Steinberger 42b8fce4e5 docs: link models concept in faq 2026-01-24 14:28:38 +00:00
Peter Steinberger c27294133e docs: add recommended models faq 2026-01-24 14:28:38 +00:00
Peter Steinberger 94095386b3 docs: add installer verbose troubleshooting 2026-01-24 14:25:29 +00:00
Peter Steinberger 7a524e8667 docs: add migration scheduling and concurrency faqs 2026-01-24 14:21:26 +00:00
Peter Steinberger 876bbb742a test: skip opencode alpha GLM in live suite 2026-01-24 14:08:16 +00:00
Peter Steinberger ef7971e3a4 fix: normalize heartbeat targets 2026-01-24 13:53:00 +00:00
Peter Steinberger 9d742ba51f fix: hide message_id hints in web chat 2026-01-24 13:52:31 +00:00
Peter Steinberger 386d21b6d1 fix: sync tests with config normalization 2026-01-24 13:32:26 +00:00
Peter Steinberger c8afa8207c chore: prepare 2026.1.23-1 2026-01-24 13:28:22 +00:00
Peter Steinberger d0e21f05a6 fix: drop opencode alpha GLM model 2026-01-24 13:19:55 +00:00
Peter Steinberger 11f039ef85 fix: include tts dist in npm package 2026-01-24 13:18:20 +00:00
Peter Steinberger e90e3ba954 docs: link macos node to cli node 2026-01-24 13:17:28 +00:00
Peter Steinberger 0de7852d46 docs: finalize 2026.1.23 changelog 2026-01-24 13:16:05 +00:00
Nicolas Zullo 834663dfef feat(templates): add emoji reactions guidance to AGENTS.md (#1591)
## What
Add emoji reactions guidance to the default AGENTS.md template.

## Why  
Reactions are a natural, human-like way to acknowledge messages without cluttering chat. This should be default behavior.

## Testing
- Tested locally on Discord DM ✅
- Tested locally on Discord guild channel ✅

## AI-Assisted
This change was drafted with help from my Clawdbot instance (Clawd 🦞). 
We tested the behavior together before submitting.
2026-01-24 13:12:16 +00:00
Peter Steinberger a72d7a9f36 docs: add vps install faq 2026-01-24 13:11:31 +00:00
Peter Steinberger 67e57e7c99 docs: add beta vs dev install faq 2026-01-24 13:11:31 +00:00
Peter Steinberger 4c98d6c121 docs: add latest version faq 2026-01-24 13:11:31 +00:00
Peter Steinberger 174a1cb68a docs: clarify mac mini + imessage ssh 2026-01-24 13:11:31 +00:00
Peter Steinberger a4d56bd06e docs: add mac mini faq 2026-01-24 13:11:31 +00:00
49 changed files with 1166 additions and 222 deletions

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
name: Docker Release
on:
push:
branches:
- main
tags:
- "v*"
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# Build amd64 image
build-amd64:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
outputs:
image-digest: ${{ steps.build.outputs.digest }}
image-metadata: ${{ steps.meta.outputs.json }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{version}},suffix=-amd64
type=semver,pattern={{version}},suffix=-arm64
type=ref,event=branch,suffix=-amd64
type=ref,event=branch,suffix=-arm64
- name: Build and push amd64 image
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
push: true
# Build arm64 image
build-arm64:
runs-on: ubuntu-24.04-arm
permissions:
packages: write
contents: read
outputs:
image-digest: ${{ steps.build.outputs.digest }}
image-metadata: ${{ steps.meta.outputs.json }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{version}},suffix=-amd64
type=semver,pattern={{version}},suffix=-arm64
type=ref,event=branch,suffix=-amd64
type=ref,event=branch,suffix=-arm64
- name: Build and push arm64 image
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/arm64
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
push: true
# Create multi-platform manifest
create-manifest:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
needs: [build-amd64, build-arm64]
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for manifest
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
- name: Create and push manifest
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
${{ needs.build-amd64.outputs.image-digest }} \
${{ needs.build-arm64.outputs.image-digest }}
env:
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}
+1
View File
@@ -106,6 +106,7 @@
## Agent-Specific Notes
- Vocabulary: "makeup" = "mac app".
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/clawdbot && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency.
+65 -64
View File
@@ -2,81 +2,82 @@
Docs: https://docs.clawd.bot
## 2026.1.23 (Unreleased)
### Highlights
- TTS: allow model-driven TTS tags by default for expressive audio replies (laughter, singing cues, etc.).
## 2026.1.24
### Changes
- Gateway: add /tools/invoke HTTP endpoint for direct tool calls and document it. (#1575) Thanks @vignesh07.
- Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits.
- Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).
- Heartbeat: add per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer.
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`.
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
- Docs: add verbose installer troubleshooting guidance.
- Docs: update Fly.io guide notes.
### Fixes
- Web UI: hide internal `message_id` hints in chat bubbles.
- Heartbeat: normalize target identifiers for consistent routing.
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
## 2026.1.23-1
### Fixes
- Packaging: include dist/tts output in npm tarball (fixes missing dist/tts/tts.js).
## 2026.1.23
### Highlights
- TTS: move Telegram TTS into core + enable model-driven TTS tags by default for expressive audio replies. (#1559) Thanks @Glucksberg. https://docs.clawd.bot/tts
- Gateway: add `/tools/invoke` HTTP endpoint for direct tool calls (auth + tool policy enforced). (#1575) Thanks @vignesh07. https://docs.clawd.bot/gateway/tools-invoke-http-api
- Heartbeat: per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer. https://docs.clawd.bot/gateway/heartbeat
- Deploy: add Fly.io deployment support + guide. (#1570) https://docs.clawd.bot/platforms/fly
- Channels: add Tlon/Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a. https://docs.clawd.bot/channels/tlon
### Changes
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt. https://docs.clawd.bot/multi-agent-sandbox-tools
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3. https://docs.clawd.bot/bedrock
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`. (commit 71203829d) https://docs.clawd.bot/cli/system
- CLI: add live auth probes to `clawdbot models status` for per-profile verification. (commit 40181afde) https://docs.clawd.bot/cli/models
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it. (commit 2c85b1b40)
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node). (commit c3cb26f7c)
- Plugins: add optional `llm-task` JSON-only tool for workflows. (#1498) Thanks @vignesh07. https://docs.clawd.bot/tools/llm-task
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.
- TTS: move Telegram TTS into core with auto-replies, commands, and gateway methods. (#1559) Thanks @Glucksberg.
- Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits. (commit 66eec295b)
- Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc. https://docs.clawd.bot/automation/cron-vs-heartbeat
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc. https://docs.clawd.bot/gateway/heartbeat
### Fixes
- Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518)
- Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.
- Messaging: mirror outbound sends into target session keys (threads + dmScope) and create session entries on send. (#1520)
- Sessions: normalize session key casing to lowercase for consistent routing.
- BlueBubbles: normalize group session keys for outbound mirroring. (#1520)
- Slack: match auto-thread mirroring channel ids case-insensitively. (#1520)
- Gateway: lowercase provided session keys when mirroring outbound sends. (#1520)
- Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
- Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops).
- Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)
- Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
- Heartbeat: accept plugin channel ids for heartbeat target validation + UI hints.
- Messaging/Sessions: mirror outbound sends into target session keys (threads + dmScope), create session entries on send, and normalize session key casing. (#1520, commit 4b6cdd1d3)
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
- Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes.
- Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp).
- Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts).
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
- TUI: include Gateway slash commands in autocomplete and `/help`.
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
- CLI: suppress diagnostic session/run noise during auth probes.
- CLI: hide auth probe timeout warnings from embedded runs.
- CLI: render auth probe results as a table in `clawdbot models status`.
- CLI: suppress probe-only embedded logs unless `--verbose` is set.
- CLI: move auth probe errors below the table to reduce wrapping.
- CLI: prevent ANSI color bleed when table cells wrap.
- CLI: explain when auth profiles are excluded by auth.order in probe details.
- CLI: drop the em dash when the banner tagline wraps to a second line.
- CLI: inline auth probe errors in status rows to reduce wrapping.
- Telegram: render markdown in media captions. (#1478)
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests.
- Agents: trigger model fallback when auth profiles are all in cooldown or unavailable. (#1522)
- Daemon: use platform PATH delimiters when building minimal service paths.
- Tests: skip embedded runner ordering assertion on Windows to avoid CI timeouts.
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts). (commit 5662a9cdf)
- Daemon: use platform PATH delimiters when building minimal service paths. (commit a4e57d3ac)
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
- Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops). (commit 8ea8801d0)
- Agents: ignore IDENTITY.md template placeholders when parsing identity. (#1556)
- Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
- Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests. (commit 084002998)
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
- Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes. (commit f70ac0c7c)
- Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp). (commit d905ca0e0)
- Telegram: render markdown in media captions. (#1478)
- MS Teams: remove `.default` suffix from Graph scopes and Bot Framework probe scopes. (#1507, #1574) Thanks @Evizero.
- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS. (commit 69f645c66)
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank. (commit d57cb2e1a)
- TUI: forward unknown slash commands, include Gateway commands in autocomplete, and render slash replies as system output. (commit 1af227b61, commit 8195497ce, commit 6fba598ea)
- CLI: auth probe output polish (table output, inline errors, reduced noise, and wrap fixes in `clawdbot models status`). (commit da3f2b489, commit 00ae21bed, commit 31e59cd58, commit f7dc27f2d, commit 438e782f8, commit 886752217, commit aabe0bed3, commit 81535d512, commit c63144ab1)
- Media: only parse `MEDIA:` tags when they start the line to avoid stripping prose mentions. (#1206)
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.
- MS Teams (plugin): remove `.default` suffix from Bot Framework probe scope to avoid double-appending. (#1574) Thanks @Evizero.
- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)
- Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)
- Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.
## 2026.1.22
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "com.clawdbot.android"
minSdk = 31
targetSdk = 36
versionCode = 202601230
versionName = "2026.1.23"
versionCode = 202601240
versionName = "2026.1.24"
}
buildTypes {
+2 -2
View File
@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.23</string>
<string>2026.1.24</string>
<key>CFBundleVersion</key>
<string>20260123</string>
<string>20260124</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
+2 -2
View File
@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.23</string>
<string>2026.1.24</string>
<key>CFBundleVersion</key>
<string>20260123</string>
<string>20260124</string>
</dict>
</plist>
+4 -4
View File
@@ -81,8 +81,8 @@ targets:
properties:
CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.1.23"
CFBundleVersion: "20260123"
CFBundleShortVersionString: "2026.1.24"
CFBundleVersion: "20260124"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: ClawdbotTests
CFBundleShortVersionString: "2026.1.23"
CFBundleVersion: "20260123"
CFBundleShortVersionString: "2026.1.24"
CFBundleVersion: "20260124"
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.23</string>
<string>2026.1.24</string>
<key>CFBundleVersion</key>
<string>202601230</string>
<string>202601240</string>
<key>CFBundleIconFile</key>
<string>Clawdbot</string>
<key>CFBundleURLTypes</key>
+1 -1
View File
@@ -82,7 +82,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
every: "30m", // default: 30m (0m disables)
model: "anthropic/claude-opus-4-5",
includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none
target: "last", // last | none | <channel id> (core or plugin, e.g. "bluebubbles")
to: "+15551234567", // optional channel-specific override
prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
ackMaxChars: 300 // max chars allowed after HEARTBEAT_OK
+277 -1
View File
@@ -9,19 +9,34 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [What is Clawdbot?](#what-is-clawdbot)
- [What is Clawdbot, in one paragraph?](#what-is-clawdbot-in-one-paragraph)
- [What’s the value proposition?](#whats-the-value-proposition)
- [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)
- [Does it run on Raspberry Pi?](#does-it-run-on-raspberry-pi)
- [Can I migrate my setup to a new machine (Mac mini) without redoing onboarding?](#can-i-migrate-my-setup-to-a-new-machine-mac-mini-without-redoing-onboarding)
- [Where do I see what’s new in the latest version?](#where-do-i-see-whats-new-in-the-latest-version)
- [I can't access docs.clawd.bot (SSL error). What now?](#i-cant-access-docsclawdbot-ssl-error-what-now)
- [How do I install the beta version, and what’s the difference between beta and dev?](#how-do-i-install-the-beta-version-and-whats-the-difference-between-beta-and-dev)
- [The docs didn’t answer my question — how do I get a better answer?](#the-docs-didnt-answer-my-question--how-do-i-get-a-better-answer)
- [How do I install Clawdbot on Linux?](#how-do-i-install-clawdbot-on-linux)
- [How do I install Clawdbot on a VPS?](#how-do-i-install-clawdbot-on-a-vps)
- [Can I ask Clawd to update itself?](#can-i-ask-clawd-to-update-itself)
- [What does the onboarding wizard actually do?](#what-does-the-onboarding-wizard-actually-do)
- [Do I need a Claude or OpenAI subscription to run this?](#do-i-need-a-claude-or-openai-subscription-to-run-this)
- [How does Anthropic "setup-token" auth work?](#how-does-anthropic-setup-token-auth-work)
- [Where do I find an Anthropic setup-token?](#where-do-i-find-an-anthropic-setup-token)
- [Do you support Claude subscription auth (Claude Code OAuth)?](#do-you-support-claude-subscription-auth-claude-code-oauth)
- [Why am I seeing `HTTP 429: rate_limit_error` from Anthropic?](#why-am-i-seeing-http-429-rate_limit_error-from-anthropic)
- [Is AWS Bedrock supported?](#is-aws-bedrock-supported)
- [How does Codex auth work?](#how-does-codex-auth-work)
- [Do you support OpenAI subscription auth (Codex OAuth)?](#do-you-support-openai-subscription-auth-codex-oauth)
- [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)
- [Do I have to buy a Mac Mini to install this?](#do-i-have-to-buy-a-mac-mini-to-install-this)
- [Do I need a Mac mini for iMessage support?](#do-i-need-a-mac-mini-for-imessage-support)
- [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)
@@ -34,6 +49,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [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)
- [Can Clawdbot run tasks on a schedule or continuously in the background?](#can-clawdbot-run-tasks-on-a-schedule-or-continuously-in-the-background)
- [Can I run Apple/macOS-only skills from Linux?](#can-i-run-applemacos-only-skills-from-linux)
- [Do you have a Notion or HeyGen integration?](#do-you-have-a-notion-or-heygen-integration)
- [How do I install the Chrome extension for browser takeover?](#how-do-i-install-the-chrome-extension-for-browser-takeover)
@@ -45,6 +61,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Where things live on disk](#where-things-live-on-disk)
- [Where does Clawdbot store its data?](#where-does-clawdbot-store-its-data)
- [Where should AGENTS.md / SOUL.md / USER.md / MEMORY.md live?](#where-should-agentsmd--soulmd--usermd--memorymd-live)
- [What’s the recommended backup strategy?](#whats-the-recommended-backup-strategy)
- [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)
@@ -81,8 +98,11 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [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)
- [How many workspaces and agents can I create?](#how-many-workspaces-and-agents-can-i-create)
- [Can I run multiple bots or chats at the same time (Slack), and how should I set that up?](#can-i-run-multiple-bots-or-chats-at-the-same-time-slack-and-how-should-i-set-that-up)
- [Models: defaults, selection, aliases, switching](#models-defaults-selection-aliases-switching)
- [What is the “default model”?](#what-is-the-default-model)
- [What model do you recommend?](#what-model-do-you-recommend)
- [What do Clawd, Flawd, and Krill use for models?](#what-do-clawd-flawd-and-krill-use-for-models)
- [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)
- [Why do I see “Unknown model: minimax/MiniMax-M2.1”?](#why-do-i-see-unknown-model-minimaxminimax-m21)
@@ -183,6 +203,28 @@ 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, Mattermost (plugin), 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.
### What’s the value proposition?
Clawdbot is not “just a Claude wrapper.” It’s a **local-first control plane** that lets you run a
capable assistant on **your own hardware**, reachable from the chat apps you already use, with
stateful sessions, memory, and tools — without handing control of your workflows to a hosted
SaaS.
Highlights:
- **Your devices, your data:** run the Gateway wherever you want (Mac, Linux, VPS) and keep the
workspace + session history local.
- **Real channels, not a web sandbox:** WhatsApp/Telegram/Slack/Discord/Signal/iMessage/etc,
plus mobile voice and Canvas on supported platforms.
- **Model-agnostic:** use Anthropic, OpenAI, MiniMax, OpenRouter, etc., with per‑agent routing
and failover.
- **Local-only option:** run local models so **all data can stay on your device** if you want.
- **Multi-agent routing:** separate agents per channel, account, or task, each with its own
workspace and defaults.
- **Open source and hackable:** inspect, extend, and self-host without vendor lock‑in.
Docs: [Gateway](/gateway), [Channels](/channels), [Multi‑agent](/concepts/multi-agent),
[Memory](/concepts/memory).
## Quick start and first-run setup
### What’s the recommended way to install and set up Clawdbot?
@@ -231,6 +273,126 @@ See [Dashboard](/web/dashboard) and [Web surfaces](/web) for bind modes and auth
Node **>= 22** is required. `pnpm` is recommended. Bun is **not recommended** for the Gateway.
### Does it run on Raspberry Pi?
Yes. The Gateway is lightweight — docs list **512MB–1GB RAM**, **1 core**, and about **500MB**
disk as enough for personal use, and note that a **Raspberry Pi 4 can run it**.
If you want extra headroom (logs, media, other services), **2GB is recommended**, but it’s
not a hard minimum.
### Can I migrate my setup to a new machine (Mac mini) without redoing onboarding?
Yes. Copy the **state directory** and **workspace**, then run Doctor once. This
keeps your bot “exactly the same” (memory, session history, auth, and channel
state) as long as you copy **both** locations:
1) Install Clawdbot on the new machine.
2) Copy `$CLAWDBOT_STATE_DIR` (default: `~/.clawdbot`) from the old machine.
3) Copy your workspace (default: `~/clawd`).
4) Run `clawdbot doctor` and restart the Gateway service.
That preserves config, auth profiles, WhatsApp creds, sessions, and memory. If you’re in
remote mode, remember the gateway host owns the session store and workspace.
**Important:** if you only commit/push your workspace to GitHub, you’re backing
up **memory + bootstrap files**, but **not** session history or auth. Those live
under `~/.clawdbot/` (for example `~/.clawdbot/agents/<agentId>/sessions/`).
Related: [Where things live on disk](/help/faq#where-does-clawdbot-store-its-data),
[Agent workspace](/concepts/agent-workspace), [Doctor](/gateway/doctor),
[Remote mode](/gateway/remote).
### Where do I see what’s new in the latest version?
Check the GitHub changelog:
https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md
Newest entries are at the top. If the top section is marked **Unreleased**, the next dated
section is the latest shipped version. Entries are grouped by **Highlights**, **Changes**, and
**Fixes** (plus docs/other sections when needed).
### I can't access docs.clawd.bot (SSL error). What now?
Some Comcast/Xfinity connections incorrectly block `docs.clawd.bot` via Xfinity
Advanced Security. Disable it or allowlist `docs.clawd.bot`, then retry. More
detail: [Troubleshooting](/help/troubleshooting#docsclawdbot-shows-an-ssl-error-comcastxfinity).
Please help us unblock it by reporting here: https://spa.xfinity.com/check_url_status.
If you still can't reach the site, the docs are mirrored on GitHub:
https://github.com/clawdbot/clawdbot/tree/main/docs
### How do I install the beta version, and what’s the difference between beta and dev?
**Beta** is a prerelease tag (`vYYYY.M.D-beta.N`) published to the npm dist‑tag `beta`.
**Dev** is the moving head of `main` (git); when published, it uses the npm dist‑tag `dev`.
One‑liners (macOS/Linux):
```bash
curl -fsSL --proto '=https' --tlsv1.2 https://clawd.bot/install.sh | bash -s -- --beta
```
```bash
curl -fsSL --proto '=https' --tlsv1.2 https://clawd.bot/install.sh | bash -s -- --install-method git
```
Windows installer (PowerShell):
https://clawd.bot/install.ps1
More detail: [Development channels](/install/development-channels) and [Installer flags](/install/installer).
### The docs didn’t answer my question — how do I get a better answer?
Use the **hackable (git) install** so you have the full source and docs locally, then ask
your bot (or Claude/Codex) *from that folder* so it can read the repo and answer precisely.
```bash
curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git
```
More detail: [Install](/install) and [Installer flags](/install/installer).
### How do I install Clawdbot on Linux?
Short answer: follow the Linux guide, then run the onboarding wizard.
- Linux quick path + service install: [Linux](/platforms/linux).
- Full walkthrough: [Getting Started](/start/getting-started).
- Installer + updates: [Install & updates](/install/updating).
### How do I install Clawdbot on a VPS?
Any Linux VPS works. Install on the server, then use SSH/Tailscale to reach the Gateway.
Guides: [exe.dev](/platforms/exe-dev), [Hetzner](/platforms/hetzner), [Fly.io](/platforms/fly).
Remote access: [Gateway remote](/gateway/remote).
### Can I ask Clawd to update itself?
Short answer: **possible, not recommended**. The update flow can restart the
Gateway (which drops the active session), may need a clean git checkout, and
can prompt for confirmation. Safer: run updates from a shell as the operator.
Use the CLI:
```bash
clawdbot update
clawdbot update status
clawdbot update --channel stable|beta|dev
clawdbot update --tag <dist-tag|version>
clawdbot update --no-restart
```
If you must automate from an agent:
```bash
clawdbot update --yes --no-restart
clawdbot gateway restart
```
Docs: [Update](/cli/update), [Updating](/install/updating).
### What does the onboarding wizard actually do?
`clawdbot onboard` is the recommended setup path. In **local mode** it walks you through:
@@ -244,6 +406,15 @@ Node **>= 22** is required. `pnpm` is recommended. Bun is **not recommended** fo
It also warns if your configured model is unknown or missing auth.
### Do I need a Claude or OpenAI subscription to run this?
No. You can run Clawdbot with **API keys** (Anthropic/OpenAI/others) or with
**local‑only models** so your data stays on your device. Subscriptions (Claude
Pro/Max or OpenAI Codex) are optional ways to authenticate those providers.
Docs: [Anthropic](/providers/anthropic), [OpenAI](/providers/openai),
[Local models](/gateway/local-models), [Models](/concepts/models).
### How does Anthropic "setup-token" auth work?
`claude setup-token` generates a **token string** via the Claude Code CLI (it is not available in the web console). You can run it on **any machine**. If Claude Code CLI credentials are present on the gateway host, Clawdbot can reuse them; otherwise choose **Anthropic token (paste setup-token)** and paste the string. The token is stored as an auth profile for the **anthropic** provider and used like an API key or OAuth profile. More detail: [OAuth](/concepts/oauth).
@@ -268,6 +439,16 @@ Yes. Clawdbot can **reuse Claude Code CLI credentials** (OAuth) and also support
Note: Claude subscription access is governed by Anthropic’s terms. For production or multi‑user workloads, API keys are usually the safer choice.
### Why am I seeing `HTTP 429: rate_limit_error` from Anthropic?
That means your **Anthropic quota/rate limit** is exhausted for the current window. If you
use a **Claude subscription** (setup‑token or Claude Code OAuth), wait for the window to
reset or upgrade your plan. If you use an **Anthropic API key**, check the Anthropic Console
for usage/billing and raise limits as needed.
Tip: set a **fallback model** so Clawdbot can keep replying while a provider is rate‑limited.
See [Models](/cli/models) and [OAuth](/concepts/oauth).
### Is AWS Bedrock supported?
Yes — via pi‑ai’s **Amazon Bedrock (Converse)** provider with **manual config**. You must supply AWS credentials/region on the gateway host and add a Bedrock provider entry in your models config. See [Amazon Bedrock](/bedrock) and [Model providers](/providers/models). If you prefer a managed key flow, an OpenAI‑compatible proxy in front of Bedrock is still a valid option.
@@ -276,6 +457,14 @@ Yes — via pi‑ai’s **Amazon Bedrock (Converse)** provider with **manual con
Clawdbot supports **OpenAI Code (Codex)** via OAuth or by reusing your Codex CLI login (`~/.codex/auth.json`). The wizard can import the CLI login or run the OAuth flow and will set the default model to `openai-codex/gpt-5.2` when appropriate. See [Model providers](/concepts/model-providers) and [Wizard](/start/wizard).
### Do you support OpenAI subscription auth (Codex OAuth)?
Yes. Clawdbot fully supports **OpenAI Code (Codex) subscription OAuth** and can also reuse an
existing Codex CLI login (`~/.codex/auth.json`) on the gateway host. The onboarding wizard
can import the CLI login or run the OAuth flow for you.
See [OAuth](/concepts/oauth), [Model providers](/concepts/model-providers), and [Wizard](/start/wizard).
### Is a local model OK for casual chats?
Usually no. Clawdbot needs large context + strong safety; small cards truncate and leak. If you must, run the **largest** MiniMax M2.1 build you can locally (LM Studio) and see [/gateway/local-models](/gateway/local-models). Smaller/quantized models increase prompt-injection risk — see [Security](/gateway/security).
@@ -284,6 +473,31 @@ Usually no. Clawdbot needs large context + strong safety; small cards truncate a
Pick region-pinned endpoints. OpenRouter exposes US-hosted options for MiniMax, Kimi, and GLM; choose the US-hosted variant to keep data in-region. You can still list Anthropic/OpenAI alongside these by using `models.mode: "merge"` so fallbacks stay available while respecting the regioned provider you select.
### Do I have to buy a Mac Mini to install this?
No. Clawdbot runs on macOS or Linux (Windows via WSL2). A Mac mini is optional — some people
buy one as an always‑on host, but a small VPS, home server, or Raspberry Pi‑class box works too.
You only need a Mac **for macOS‑only tools**. For iMessage, you can keep the Gateway on Linux
and run `imsg` on any Mac over SSH by pointing `channels.imessage.cliPath` at an SSH wrapper.
If you want other macOS‑only tools, run the Gateway on a Mac or pair a macOS node.
Docs: [iMessage](/channels/imessage), [Nodes](/nodes), [Mac remote mode](/platforms/mac/remote).
### Do I need a Mac mini for iMessage support?
You need **some macOS device** signed into Messages. It does **not** have to be a Mac mini —
any Mac works. Clawdbot’s iMessage integrations run on macOS (BlueBubbles or `imsg`), while
the Gateway can run elsewhere.
Common setups:
- Run the Gateway on Linux/VPS, and point `channels.imessage.cliPath` at an SSH wrapper that
runs `imsg` on the Mac.
- Run everything on the Mac if you want the simplest single‑machine setup.
Docs: [iMessage](/channels/imessage), [BlueBubbles](/channels/bluebubbles),
[Mac remote mode](/platforms/mac/remote).
### Can I use Bun?
Bun is **not recommended**. We see runtime bugs, especially with WhatsApp and Telegram.
@@ -405,6 +619,17 @@ npm i -g clawdhub
pnpm add -g clawdhub
```
### Can Clawdbot run tasks on a schedule or continuously in the background?
Yes. Use the Gateway scheduler:
- **Cron jobs** for scheduled or recurring tasks (persist across restarts).
- **Heartbeat** for “main session” periodic checks.
- **Isolated jobs** for autonomous agents that post summaries or deliver to chats.
Docs: [Cron jobs](/automation/cron-jobs), [Cron vs Heartbeat](/automation/cron-vs-heartbeat),
[Heartbeat](/gateway/heartbeat).
### Is there a way to run Apple/macOS-only skills if my Gateway runs on Linux?
Not directly. macOS skills are gated by `metadata.clawdbot.os` plus required binaries, and skills only appear in the system prompt when they are eligible on the **Gateway host**. On Linux, `darwin`-only skills (like `imsg`, `apple-notes`, `apple-reminders`) will not load unless you override the gating.
@@ -573,6 +798,18 @@ workspace, not your local laptop).
See [Agent workspace](/concepts/agent-workspace) and [Memory](/concepts/memory).
### What’s the recommended backup strategy?
Put your **agent workspace** in a **private** git repo and back it up somewhere
private (for example GitHub private). This captures memory + AGENTS/SOUL/USER
files, and lets you restore the assistant’s “mind” later.
Do **not** commit anything under `~/.clawdbot` (credentials, sessions, tokens).
If you need a full restore, back up both the workspace and the state directory
separately (see the migration question above).
Docs: [Agent workspace](/concepts/agent-workspace).
### How do I completely uninstall Clawdbot?
See the dedicated guide: [Uninstall](/install/uninstall).
@@ -774,7 +1011,8 @@ Docs: [Nodes](/nodes), [Nodes CLI](/cli/nodes), [Chrome extension](/tools/chrome
### Do nodes run a gateway service?
No. Only **one gateway** should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). Nodes are peripherals that connect
to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app). For headless node
hosts and CLI control, see [Node host CLI](/cli/node).
A full restart is required for `gateway`, `discovery`, and `canvasHost` changes.
@@ -1041,6 +1279,24 @@ Tips:
- Prune old sessions (delete JSONL or store entries) if disk grows.
- Use `clawdbot doctor` to spot stray workspaces and profile mismatches.
### Can I run multiple bots or chats at the same time (Slack), and how should I set that up?
Yes. Use **Multi‑Agent Routing** to run multiple isolated agents and route inbound messages by
channel/account/peer. Slack is supported as a channel and can be bound to specific agents.
Browser access is powerful but not “do anything a human can” — anti‑bot, CAPTCHAs, and MFA can
still block automation. For the most reliable browser control, use the Chrome extension relay
on the machine that runs the browser (and keep the Gateway anywhere).
Best‑practice setup:
- Always‑on Gateway host (VPS/Mac mini).
- One agent per role (bindings).
- Slack channel(s) bound to those agents.
- Local browser via extension relay (or a node) when needed.
Docs: [Multi‑Agent Routing](/concepts/multi-agent), [Slack](/channels/slack),
[Browser](/tools/browser), [Chrome extension](/tools/chrome-extension), [Nodes](/nodes).
## Models: defaults, selection, aliases, switching
### What is the “default model”?
@@ -1053,6 +1309,26 @@ agents.defaults.model.primary
Models are referenced as `provider/model` (example: `anthropic/claude-opus-4-5`). If you omit the provider, Clawdbot currently assumes `anthropic` as a temporary deprecation fallback — but you should still **explicitly** set `provider/model`.
### What model do you recommend?
**Recommended default:** `anthropic/claude-opus-4-5`.
**Good alternative:** `anthropic/claude-sonnet-4-5`.
**Reliable (less character):** `openai/gpt-5.2` — nearly as good as Opus, just less personality.
**Budget:** `zai/glm-4.7`.
MiniMax M2.1 has its own docs: [MiniMax](/providers/minimax) and
[Local models](/gateway/local-models).
Strong warning: weaker/over-quantized models are more vulnerable to prompt
injection and unsafe behavior. See [Security](/gateway/security).
More context: [Models](/concepts/models).
### What do Clawd, Flawd, and Krill use for models?
- **Clawd + Flawd:** Anthropic Opus (`anthropic/claude-opus-4-5`) — see [Anthropic](/providers/anthropic).
- **Krill:** MiniMax M2.1 (`minimax/MiniMax-M2.1`) — see [MiniMax](/providers/minimax).
### How do I switch models on the fly (without restarting)?
Use the `/model` command as a standalone message:
+16
View File
@@ -33,6 +33,22 @@ Almost always a Node/npm PATH issue. Start here:
- [Install (Node/npm PATH sanity)](/install#nodejs--npm-path-sanity)
### Installer fails (or you need full logs)
Re-run the installer in verbose mode to see the full trace and npm output:
```bash
curl -fsSL https://clawd.bot/install.sh | bash -s -- --verbose
```
For beta installs:
```bash
curl -fsSL https://clawd.bot/install.sh | bash -s -- --beta --verbose
```
You can also set `CLAWDBOT_VERBOSE=1` instead of the flag.
### Gateway “unauthorized”, can’t connect, or keeps reconnecting
- [Gateway troubleshooting](/gateway/troubleshooting)
+1 -1
View File
@@ -181,7 +181,7 @@ cat > /data/clawdbot.json << 'EOF'
"bind": "auto"
},
"meta": {
"lastTouchedVersion": "2026.1.23"
"lastTouchedVersion": "2026.1.24"
}
}
EOF
+7 -7
View File
@@ -30,17 +30,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.23 \
APP_VERSION=2026.1.24 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-app.sh
# Zip for distribution (includes resource forks for Sparkle delta support)
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.23.zip
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.24.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.23.dmg
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.24.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
@@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.23.dmg
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.23 \
APP_VERSION=2026.1.24 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.23.dSYM.zip
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.24.dSYM.zip
```
## Appcast entry
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.23.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.24.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
## Publish & verify
- Upload `Clawdbot-2026.1.23.zip` (and `Clawdbot-2026.1.23.dSYM.zip`) to the GitHub release for tag `v2026.1.23`.
- Upload `Clawdbot-2026.1.24.zip` (and `Clawdbot-2026.1.24.dSYM.zip`) to the GitHub release for tag `v2026.1.24`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
+15
View File
@@ -92,6 +92,21 @@ In group chats where you receive every message, be **smart about when to contrib
Participate, don't dominate.
### 😊 React Like a Human!
On platforms that support reactions (Discord, Slack), use emoji reactions naturally:
**React when:**
- You appreciate something but don't need to reply (👍, ❤️, 🙌)
- Something made you laugh (😂, 💀)
- You find it interesting or thought-provoking (🤔, 💡)
- You want to acknowledge without interrupting the flow
- It's a simple yes/no or approval situation (✅, 👀)
**Why it matters:**
Reactions are lightweight social signals. Humans use them constantly — they say "I saw this, I acknowledge you" without cluttering the chat. You should too.
**Don't overdo it:** One reaction per message max. Pick the one that fits best.
## Tools
Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (camera names, SSH details, voice preferences) in `TOOLS.md`.
+4 -3
View File
@@ -6,9 +6,10 @@ read_when:
# Elevated Mode (/elevated directives)
## What it does
- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full` (approvals still apply).
- `/elevated on` runs on the gateway host and keeps exec approvals (same as `/elevated ask`).
- `/elevated full` runs on the gateway host **and** auto-approves exec (skips exec approvals).
- `/elevated ask` runs on the gateway host but keeps exec approvals (same as `/elevated on`).
- `on`/`ask` do **not** force `exec.security=full`; configured security/ask policy still applies.
- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host).
- Directive forms: `/elevated on|off|ask|full`, `/elev on|off|ask|full`.
- Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state.
@@ -18,8 +19,8 @@ read_when:
- **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key.
- **Inline directive**: `/elevated on|ask|full` inside a message applies to that message only.
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
- **Host execution**: elevated forces `exec` onto the gateway host with full security.
- **Approvals**: `full` skips exec approvals; `on`/`ask` still honor them.
- **Host execution**: elevated forces `exec` onto the gateway host; `full` also sets `security=full`.
- **Approvals**: `full` skips exec approvals; `on`/`ask` honor them when allowlist/ask rules require.
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
+1 -1
View File
@@ -24,7 +24,7 @@ Background sessions are scoped per agent; `process` only sees sessions from the
- `security` (`deny | allowlist | full`): enforcement mode for `gateway`/`node`
- `ask` (`off | on-miss | always`): approval prompts for `gateway`/`node`
- `node` (string): node id/name for `host=node`
- `elevated` (bool): alias for `host=gateway` + `security=full` when sandboxed and allowed
- `elevated` (bool): request elevated mode (gateway host); `security=full` is only forced when elevated resolves to `full`
Notes:
- `host` defaults to `sandbox`.
+2 -2
View File
@@ -8,7 +8,7 @@
"./index.ts"
]
},
"dependencies": {
"clawdbot": "workspace:*"
"peerDependencies": {
"clawdbot": ">=2026.1.23-1"
}
}
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawdbot",
"version": "2026.1.23",
"version": "2026.1.24-0",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
@@ -43,6 +43,7 @@
"dist/slack/**",
"dist/telegram/**",
"dist/tui/**",
"dist/tts/**",
"dist/web/**",
"dist/wizard/**",
"dist/*.js",
+83 -2
View File
@@ -335,8 +335,8 @@ importers:
extensions/memory-core:
dependencies:
clawdbot:
specifier: workspace:*
version: link:../..
specifier: '>=2026.1.23-1'
version: 2026.1.23(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3)
extensions/memory-lancedb:
dependencies:
@@ -2987,6 +2987,11 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
clawdbot@2026.1.23:
resolution: {integrity: sha512-w8RjScbxj3YbJYtcB0GBITqmyUYegVbBXDgu/zRxB4AB/SEErR6BNWGeZDWQNFaMftIyJhjttACxSd8G20aREA==}
engines: {node: '>=22.12.0'}
hasBin: true
cli-cursor@5.0.0:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'}
@@ -8368,6 +8373,82 @@ snapshots:
dependencies:
clsx: 2.1.1
clawdbot@2026.1.23(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3):
dependencies:
'@agentclientprotocol/sdk': 0.13.1(zod@4.3.6)
'@aws-sdk/client-bedrock': 3.975.0
'@buape/carbon': 0.14.0(hono@4.11.4)
'@clack/prompts': 0.11.0
'@grammyjs/runner': 2.0.3(grammy@1.39.3)
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3)
'@homebridge/ciao': 1.3.4
'@lydell/node-pty': 1.2.0-beta.3
'@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui': 0.49.3
'@mozilla/readability': 0.6.0
'@sinclair/typebox': 0.34.47
'@slack/bolt': 4.6.0(@types/express@5.0.6)
'@slack/web-api': 7.13.0
'@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
ajv: 8.17.1
body-parser: 2.2.2
chalk: 5.6.2
chokidar: 5.0.0
chromium-bidi: 13.0.1(devtools-protocol@0.0.1561482)
cli-highlight: 2.1.11
commander: 14.0.2
croner: 9.1.0
detect-libc: 2.1.2
discord-api-types: 0.38.37
dotenv: 17.2.3
express: 5.2.1
file-type: 21.3.0
grammy: 1.39.3
hono: 4.11.4
jiti: 2.6.1
json5: 2.2.3
jszip: 3.10.1
linkedom: 0.18.12
long: 5.3.2
markdown-it: 14.1.0
osc-progress: 0.3.0
pdfjs-dist: 5.4.530
playwright-core: 1.58.0
proper-lockfile: 4.1.2
qrcode-terminal: 0.12.0
sharp: 0.34.5
sqlite-vec: 0.1.7-alpha.2
tar: 7.5.4
tslog: 4.10.2
undici: 7.19.0
ws: 8.19.0
yaml: 2.8.2
zod: 4.3.6
optionalDependencies:
'@napi-rs/canvas': 0.1.88
node-llama-cpp: 3.15.0(typescript@5.9.3)
transitivePeerDependencies:
- '@discordjs/opus'
- '@modelcontextprotocol/sdk'
- '@types/express'
- audio-decode
- aws-crt
- bufferutil
- canvas
- debug
- devtools-protocol
- encoding
- ffmpeg-static
- jimp
- link-preview-js
- node-opus
- opusscript
- supports-color
- typescript
- utf-8-validate
cli-cursor@5.0.0:
dependencies:
restore-cursor: 5.1.0
+2
View File
@@ -13,9 +13,11 @@ COPY scripts ./scripts
COPY docs ./docs
COPY skills ./skills
COPY patches ./patches
COPY ui ./ui
COPY extensions/memory-core ./extensions/memory-core
RUN pnpm install --frozen-lockfile
RUN pnpm build
RUN pnpm ui:build
CMD ["bash"]
@@ -150,4 +150,35 @@ describe("exec approvals", () => {
expect(result.details.status).toBe("completed");
expect(calls).not.toContain("exec.approval.request");
});
it("requires approval for elevated ask when allowlist misses", async () => {
const { callGatewayTool } = await import("./tools/gateway.js");
const calls: string[] = [];
let resolveApproval: (() => void) | undefined;
const approvalSeen = new Promise<void>((resolve) => {
resolveApproval = resolve;
});
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
calls.push(method);
if (method === "exec.approval.request") {
resolveApproval?.();
return { decision: "deny" };
}
return { ok: true };
});
const { createExecTool } = await import("./bash-tools.exec.js");
const tool = createExecTool({
ask: "on-miss",
security: "allowlist",
approvalRunningNoticeMs: 0,
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
});
const result = await tool.execute("call4", { command: "echo ok", elevated: true });
expect(result.details.status).toBe("approval-pending");
await approvalSeen;
expect(calls).toContain("exec.approval.request");
});
});
+1 -1
View File
@@ -791,7 +791,7 @@ export function createExecTool(
const configuredSecurity = defaults?.security ?? (host === "sandbox" ? "deny" : "allowlist");
const requestedSecurity = normalizeExecSecurity(params.security);
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
if (elevatedRequested) {
if (elevatedRequested && elevatedMode === "full") {
security = "full";
}
const configuredAsk = defaults?.ask ?? "on-miss";
+3
View File
@@ -69,6 +69,9 @@ export function isModernModelRef(ref: ModelRef): boolean {
if (provider === "opencode" && id.endsWith("-free")) {
return false;
}
if (provider === "opencode" && id === "alpha-glm-4.7") {
return false;
}
if (provider === "openrouter" || provider === "opencode") {
return matchesAny(id, [
+1 -1
View File
@@ -54,7 +54,7 @@ describe("getOpencodeZenStaticFallbackModels", () => {
it("returns an array of models", () => {
const models = getOpencodeZenStaticFallbackModels();
expect(Array.isArray(models)).toBe(true);
expect(models.length).toBe(10);
expect(models.length).toBe(9);
});
it("includes Claude, GPT, Gemini, and GLM models", () => {
+1 -7
View File
@@ -67,10 +67,9 @@ export const OPENCODE_ZEN_MODEL_ALIASES: Record<string, string> = {
"gemini-2.5-pro": "gemini-3-pro",
"gemini-2.5-flash": "gemini-3-flash",
// GLM (free + alpha)
// GLM (free)
glm: "glm-4.7",
"glm-free": "glm-4.7",
"alpha-glm": "alpha-glm-4.7",
};
/**
@@ -122,7 +121,6 @@ const MODEL_COSTS: Record<
},
"claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
"gemini-3-pro": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 },
"alpha-glm-4.7": { input: 0.6, output: 2.2, cacheRead: 0.6, cacheWrite: 0 },
"gpt-5.1-codex-mini": {
input: 0.25,
output: 2,
@@ -147,7 +145,6 @@ const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
"gpt-5.1-codex": 400000,
"claude-opus-4-5": 200000,
"gemini-3-pro": 1048576,
"alpha-glm-4.7": 204800,
"gpt-5.1-codex-mini": 400000,
"gpt-5.1": 400000,
"glm-4.7": 204800,
@@ -164,7 +161,6 @@ const MODEL_MAX_TOKENS: Record<string, number> = {
"gpt-5.1-codex": 128000,
"claude-opus-4-5": 64000,
"gemini-3-pro": 65536,
"alpha-glm-4.7": 131072,
"gpt-5.1-codex-mini": 128000,
"gpt-5.1": 128000,
"glm-4.7": 131072,
@@ -201,7 +197,6 @@ const MODEL_NAMES: Record<string, string> = {
"gpt-5.1-codex": "GPT-5.1 Codex",
"claude-opus-4-5": "Claude Opus 4.5",
"gemini-3-pro": "Gemini 3 Pro",
"alpha-glm-4.7": "Alpha GLM-4.7",
"gpt-5.1-codex-mini": "GPT-5.1 Codex Mini",
"gpt-5.1": "GPT-5.1",
"glm-4.7": "GLM-4.7",
@@ -229,7 +224,6 @@ export function getOpencodeZenStaticFallbackModels(): ModelDefinitionConfig[] {
"gpt-5.1-codex",
"claude-opus-4-5",
"gemini-3-pro",
"alpha-glm-4.7",
"gpt-5.1-codex-mini",
"gpt-5.1",
"glm-4.7",
+16
View File
@@ -460,6 +460,22 @@ File contents here`,
expect(result).toBe("The actual answer.");
});
it("strips final tags while keeping content", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: "<final>\nAnswer\n</final>",
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe("Answer");
});
it("strips thought tags", () => {
const msg: AssistantMessage = {
role: "assistant",
+2 -30
View File
@@ -1,4 +1,5 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { stripReasoningTagsFromText } from "../shared/text/reasoning-tags.js";
import { sanitizeUserFacingText } from "./pi-embedded-helpers.js";
import { formatToolDetail, resolveToolDisplay } from "./tool-display.js";
@@ -166,36 +167,7 @@ export function stripDowngradedToolCallText(text: string): string {
* that slip through other filtering mechanisms.
*/
export function stripThinkingTagsFromText(text: string): string {
if (!text) return text;
// Quick check to avoid regex overhead when no tags present.
if (!/(?:think(?:ing)?|thought|antthinking)/i.test(text)) return text;
const tagRe = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\b[^>]*>/gi;
let result = "";
let lastIndex = 0;
let inThinking = false;
for (const match of text.matchAll(tagRe)) {
const idx = match.index ?? 0;
const isClose = match[1] === "/";
if (!inThinking && !isClose) {
// Opening tag - save text before it.
result += text.slice(lastIndex, idx);
inThinking = true;
} else if (inThinking && isClose) {
// Closing tag - skip content inside.
inThinking = false;
}
lastIndex = idx + match[0].length;
}
// Append remaining text if we're not inside thinking.
if (!inThinking) {
result += text.slice(lastIndex);
}
return result.trim();
return stripReasoningTagsFromText(text, { mode: "strict", trim: "both" });
}
export function extractAssistantText(msg: AssistantMessage): string {
+4 -1
View File
@@ -2,6 +2,7 @@ import type { Command } from "commander";
import type { CronJob } from "../../cron/types.js";
import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import { sanitizeAgentId } from "../../routing/session-key.js";
import type { GatewayRpcOpts } from "../gateway-rpc.js";
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import { parsePositiveIntOrUndefined } from "../program/helpers.js";
@@ -138,7 +139,9 @@ export function registerCronAddCommand(cron: Command) {
}
const agentId =
typeof opts.agent === "string" && opts.agent.trim() ? opts.agent.trim() : undefined;
typeof opts.agent === "string" && opts.agent.trim()
? sanitizeAgentId(opts.agent.trim())
: undefined;
const payload = (() => {
const systemEvent = typeof opts.systemEvent === "string" ? opts.systemEvent.trim() : "";
+2 -1
View File
@@ -1,6 +1,7 @@
import type { Command } from "commander";
import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import { sanitizeAgentId } from "../../routing/session-key.js";
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import {
getCronChannelOptions,
@@ -90,7 +91,7 @@ export function registerCronEditCommand(cron: Command) {
throw new Error("Use --agent or --clear-agent, not both");
}
if (typeof opts.agent === "string" && opts.agent.trim()) {
patch.agentId = opts.agent.trim();
patch.agentId = sanitizeAgentId(opts.agent.trim());
}
if (opts.clearAgent) {
patch.agentId = null;
+48 -8
View File
@@ -9,6 +9,7 @@ async function writePluginFixture(params: {
dir: string;
id: string;
schema: Record<string, unknown>;
channels?: string[];
}) {
await fs.mkdir(params.dir, { recursive: true });
await fs.writeFile(
@@ -16,16 +17,16 @@ async function writePluginFixture(params: {
`export default { id: "${params.id}", register() {} };`,
"utf-8",
);
const manifest: Record<string, unknown> = {
id: params.id,
configSchema: params.schema,
};
if (params.channels) {
manifest.channels = params.channels;
}
await fs.writeFile(
path.join(params.dir, "clawdbot.plugin.json"),
JSON.stringify(
{
id: params.id,
configSchema: params.schema,
},
null,
2,
),
JSON.stringify(manifest, null, 2),
"utf-8",
);
}
@@ -149,4 +150,43 @@ describe("config plugin validation", () => {
expect(res.ok).toBe(true);
});
});
it("accepts plugin heartbeat targets", async () => {
await withTempHome(async (home) => {
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
const pluginDir = path.join(home, "bluebubbles-plugin");
await writePluginFixture({
dir: pluginDir,
id: "bluebubbles-plugin",
channels: ["bluebubbles"],
schema: { type: "object" },
});
vi.resetModules();
const { validateConfigObjectWithPlugins } = await import("./config.js");
const res = validateConfigObjectWithPlugins({
agents: { defaults: { heartbeat: { target: "bluebubbles" } }, list: [{ id: "pi" }] },
plugins: { enabled: false, load: { paths: [pluginDir] } },
});
expect(res.ok).toBe(true);
});
});
it("rejects unknown heartbeat targets", async () => {
await withTempHome(async (home) => {
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
vi.resetModules();
const { validateConfigObjectWithPlugins } = await import("./config.js");
const res = validateConfigObjectWithPlugins({
agents: { defaults: { heartbeat: { target: "not-a-channel" } }, list: [{ id: "pi" }] },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues).toContainEqual({
path: "agents.defaults.heartbeat.target",
message: "unknown heartbeat target: not-a-channel",
});
}
});
});
});
+18
View File
@@ -84,4 +84,22 @@ describe("config schema", () => {
const channelProps = channelSchema?.properties as Record<string, unknown> | undefined;
expect(channelProps?.accessToken).toBeTruthy();
});
it("adds heartbeat target hints with dynamic channels", () => {
const res = buildConfigSchema({
channels: [
{
id: "bluebubbles",
label: "BlueBubbles",
configSchema: { type: "object" },
},
],
});
const defaultsHint = res.uiHints["agents.defaults.heartbeat.target"];
const listHint = res.uiHints["agents.list.*.heartbeat.target"];
expect(defaultsHint?.help).toContain("bluebubbles");
expect(defaultsHint?.help).toContain("last");
expect(listHint?.help).toContain("bluebubbles");
});
});
+43 -1
View File
@@ -1,3 +1,4 @@
import { CHANNEL_IDS } from "../channels/registry.js";
import { VERSION } from "../version.js";
import { ClawdbotSchema } from "./zod-schema.js";
@@ -807,6 +808,44 @@ function applyChannelHints(hints: ConfigUiHints, channels: ChannelUiMetadata[]):
return next;
}
function listHeartbeatTargetChannels(channels: ChannelUiMetadata[]): string[] {
const seen = new Set<string>();
const ordered: string[] = [];
for (const id of CHANNEL_IDS) {
const normalized = id.trim().toLowerCase();
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
ordered.push(normalized);
}
for (const channel of channels) {
const normalized = channel.id.trim().toLowerCase();
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
ordered.push(normalized);
}
return ordered;
}
function applyHeartbeatTargetHints(
hints: ConfigUiHints,
channels: ChannelUiMetadata[],
): ConfigUiHints {
const next: ConfigUiHints = { ...hints };
const channelList = listHeartbeatTargetChannels(channels);
const channelHelp = channelList.length ? ` Known channels: ${channelList.join(", ")}.` : "";
const help = `Delivery target ("last", "none", or a channel id).${channelHelp}`;
const paths = ["agents.defaults.heartbeat.target", "agents.list.*.heartbeat.target"];
for (const path of paths) {
const current = next[path] ?? {};
next[path] = {
...current,
help: current.help ?? help,
placeholder: current.placeholder ?? "last",
};
}
return next;
}
function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]): ConfigSchema {
const next = cloneSchema(schema);
const root = asSchemaObject(next);
@@ -908,7 +947,10 @@ export function buildConfigSchema(params?: {
const channels = params?.channels ?? [];
if (plugins.length === 0 && channels.length === 0) return base;
const mergedHints = applySensitiveHints(
applyChannelHints(applyPluginHints(base.uiHints, plugins), channels),
applyHeartbeatTargetHints(
applyChannelHints(applyPluginHints(base.uiHints, plugins), channels),
channels,
),
);
const mergedSchema = applyChannelSchemas(applyPluginSchemas(base.schema, plugins), channels);
return {
+3 -12
View File
@@ -4,6 +4,7 @@ import type {
HumanDelayConfig,
TypingMode,
} from "./types.base.js";
import type { ChannelId } from "../channels/plugins/types.js";
import type {
SandboxBrowserSettings,
SandboxDockerSettings,
@@ -177,18 +178,8 @@ export type AgentDefaultsConfig = {
model?: string;
/** Session key for heartbeat runs ("main" or explicit session key). */
session?: string;
/** Delivery target (last|whatsapp|telegram|discord|slack|mattermost|msteams|signal|imessage|none). */
target?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "mattermost"
| "msteams"
| "signal"
| "imessage"
| "none";
/** Delivery target ("last", "none", or a channel id). */
target?: "last" | "none" | ChannelId;
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
to?: string;
/** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK."). */
+36 -1
View File
@@ -1,7 +1,7 @@
import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { CHANNEL_IDS } from "../channels/registry.js";
import { CHANNEL_IDS, normalizeChatChannelId } from "../channels/registry.js";
import {
normalizePluginsConfig,
resolveEnableState,
@@ -226,6 +226,41 @@ export function validateConfigObjectWithPlugins(raw: unknown):
}
}
const heartbeatChannelIds = new Set<string>();
for (const channelId of CHANNEL_IDS) {
heartbeatChannelIds.add(channelId.toLowerCase());
}
for (const record of registry.plugins) {
for (const channelId of record.channels) {
const trimmed = channelId.trim();
if (trimmed) heartbeatChannelIds.add(trimmed.toLowerCase());
}
}
const validateHeartbeatTarget = (target: string | undefined, path: string) => {
if (typeof target !== "string") return;
const trimmed = target.trim();
if (!trimmed) {
issues.push({ path, message: "heartbeat target must not be empty" });
return;
}
const normalized = trimmed.toLowerCase();
if (normalized === "last" || normalized === "none") return;
if (normalizeChatChannelId(trimmed)) return;
if (heartbeatChannelIds.has(normalized)) return;
issues.push({ path, message: `unknown heartbeat target: ${target}` });
};
validateHeartbeatTarget(
config.agents?.defaults?.heartbeat?.target,
"agents.defaults.heartbeat.target",
);
if (Array.isArray(config.agents?.list)) {
for (const [index, entry] of config.agents.list.entries()) {
validateHeartbeatTarget(entry?.heartbeat?.target, `agents.list.${index}.heartbeat.target`);
}
}
let selectedMemoryPluginId: string | null = null;
const seenPlugins = new Set<string>();
for (const record of registry.plugins) {
+1 -13
View File
@@ -22,19 +22,7 @@ export const HeartbeatSchema = z
model: z.string().optional(),
session: z.string().optional(),
includeReasoning: z.boolean().optional(),
target: z
.union([
z.literal("last"),
z.literal("whatsapp"),
z.literal("telegram"),
z.literal("discord"),
z.literal("slack"),
z.literal("msteams"),
z.literal("signal"),
z.literal("imessage"),
z.literal("none"),
])
.optional(),
target: z.string().optional(),
to: z.string().optional(),
prompt: z.string().optional(),
ackMaxChars: z.number().int().nonnegative().optional(),
+1 -1
View File
@@ -24,7 +24,7 @@ describe("normalizeCronJobCreate", () => {
expect("provider" in payload).toBe(false);
});
it("normalizes agentId and drops null", () => {
it("trims agentId and drops null", () => {
const normalized = normalizeCronJobCreate({
name: "agent-set",
enabled: true,
+2 -1
View File
@@ -1,3 +1,4 @@
import { sanitizeAgentId } from "../routing/session-key.js";
import { parseAbsoluteTimeMs } from "./parse.js";
import { migrateLegacyCronPayload } from "./payload-migration.js";
import type { CronJobCreate, CronJobPatch } from "./types.js";
@@ -75,7 +76,7 @@ export function normalizeCronJobInput(
next.agentId = null;
} else if (typeof agentId === "string") {
const trimmed = agentId.trim();
if (trimmed) next.agentId = trimmed;
if (trimmed) next.agentId = sanitizeAgentId(trimmed);
else delete next.agentId;
}
}
+3 -1
View File
@@ -1,3 +1,4 @@
import { inspect } from "node:util";
import { Client } from "@buape/carbon";
import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
import { Routes } from "discord-api-types/v10";
@@ -95,7 +96,8 @@ function formatDiscordDeployErrorDetails(err: unknown): string {
try {
bodyText = JSON.stringify(rawBody);
} catch {
bodyText = String(rawBody);
bodyText =
typeof rawBody === "string" ? rawBody : inspect(rawBody, { depth: 3, breakLength: 120 });
}
if (bodyText) {
const maxLen = 800;
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, test } from "vitest";
import { stripEnvelopeFromMessage } from "./chat-sanitize.js";
describe("stripEnvelopeFromMessage", () => {
test("removes message_id hint lines from user messages", () => {
const input = {
role: "user",
content: "[WhatsApp 2026-01-24 13:36] yolo\n[message_id: 7b8b]",
};
const result = stripEnvelopeFromMessage(input) as { content?: string };
expect(result.content).toBe("yolo");
});
test("removes message_id hint lines from text content arrays", () => {
const input = {
role: "user",
content: [{ type: "text", text: "hi\n[message_id: abc123]" }],
};
const result = stripEnvelopeFromMessage(input) as {
content?: Array<{ type: string; text?: string }>;
};
expect(result.content?.[0]?.text).toBe("hi");
});
test("does not strip inline message_id text that is part of a line", () => {
const input = {
role: "user",
content: "I typed [message_id: 123] on purpose",
};
const result = stripEnvelopeFromMessage(input) as { content?: string };
expect(result.content).toBe("I typed [message_id: 123] on purpose");
});
test("does not strip assistant messages", () => {
const input = {
role: "assistant",
content: "note\n[message_id: 123]",
};
const result = stripEnvelopeFromMessage(input) as { content?: string };
expect(result.content).toBe("note\n[message_id: 123]");
});
});
+12 -3
View File
@@ -14,6 +14,8 @@ const ENVELOPE_CHANNELS = [
"BlueBubbles",
];
const MESSAGE_ID_LINE = /^\s*\[message_id:\s*[^\]]+\]\s*$/i;
function looksLikeEnvelopeHeader(header: string): boolean {
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true;
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true;
@@ -28,13 +30,20 @@ export function stripEnvelope(text: string): string {
return text.slice(match[0].length);
}
function stripMessageIdHints(text: string): string {
if (!text.includes("[message_id:")) return text;
const lines = text.split(/\r?\n/);
const filtered = lines.filter((line) => !MESSAGE_ID_LINE.test(line));
return filtered.length === lines.length ? text : filtered.join("\n");
}
function stripEnvelopeFromContent(content: unknown[]): { content: unknown[]; changed: boolean } {
let changed = false;
const next = content.map((item) => {
if (!item || typeof item !== "object") return item;
const entry = item as Record<string, unknown>;
if (entry.type !== "text" || typeof entry.text !== "string") return item;
const stripped = stripEnvelope(entry.text);
const stripped = stripMessageIdHints(stripEnvelope(entry.text));
if (stripped === entry.text) return item;
changed = true;
return {
@@ -55,7 +64,7 @@ export function stripEnvelopeFromMessage(message: unknown): unknown {
const next: Record<string, unknown> = { ...entry };
if (typeof entry.content === "string") {
const stripped = stripEnvelope(entry.content);
const stripped = stripMessageIdHints(stripEnvelope(entry.content));
if (stripped !== entry.content) {
next.content = stripped;
changed = true;
@@ -67,7 +76,7 @@ export function stripEnvelopeFromMessage(message: unknown): unknown {
changed = true;
}
} else if (typeof entry.text === "string") {
const stripped = stripEnvelope(entry.text);
const stripped = stripMessageIdHints(stripEnvelope(entry.text));
if (stripped !== entry.text) {
next.text = stripped;
changed = true;
+4 -1
View File
@@ -468,7 +468,10 @@ export const nodeHandlers: GatewayRequestHandlers = {
error: p.error ?? null,
});
if (!ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown invoke id"));
// Late-arriving results (after invoke timeout) are expected and harmless.
// Return success instead of error to reduce log noise; client can discard.
context.logGateway.debug(`late invoke result ignored: id=${p.id} node=${p.nodeId}`);
respond(true, { ok: true, ignored: true }, undefined);
return;
}
respond(true, { ok: true }, undefined);
+18 -3
View File
@@ -344,9 +344,19 @@ export async function startGatewayServer(
setSkillsRemoteRegistry(nodeRegistry);
void primeRemoteSkillsCache();
registerSkillsChangeListener(() => {
const latest = loadConfig();
void refreshRemoteBinsForConnectedNodes(latest);
// Debounce skills-triggered node probes to avoid feedback loops and rapid-fire invokes.
// Skills changes can happen in bursts (e.g., file watcher events), and each probe
// takes time to complete. A 30-second delay ensures we batch changes together.
let skillsRefreshTimer: ReturnType<typeof setTimeout> | null = null;
const skillsRefreshDelayMs = 30_000;
const skillsChangeUnsub = registerSkillsChangeListener((event) => {
if (event.reason === "remote-node") return;
if (skillsRefreshTimer) clearTimeout(skillsRefreshTimer);
skillsRefreshTimer = setTimeout(() => {
skillsRefreshTimer = null;
const latest = loadConfig();
void refreshRemoteBinsForConnectedNodes(latest);
}, skillsRefreshDelayMs);
});
const { tickInterval, healthInterval, dedupeCleanup } = startGatewayMaintenanceTimers({
@@ -544,6 +554,11 @@ export async function startGatewayServer(
if (diagnosticsEnabled) {
stopDiagnosticHeartbeat();
}
if (skillsRefreshTimer) {
clearTimeout(skillsRefreshTimer);
skillsRefreshTimer = null;
}
skillsChangeUnsub();
await close(opts);
},
};
@@ -0,0 +1,125 @@
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
vi.mock("../infra/update-runner.js", () => ({
runGatewayUpdate: vi.fn(async () => ({
status: "ok",
mode: "git",
root: "/repo",
steps: [],
durationMs: 12,
})),
}));
import {
connectOk,
installGatewayTestHooks,
rpcReq,
startServerWithClient,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
let server: Awaited<ReturnType<typeof startServerWithClient>>["server"];
let ws: WebSocket;
let port: number;
beforeAll(async () => {
const started = await startServerWithClient();
server = started.server;
ws = started.ws;
port = started.port;
await connectOk(ws);
});
afterAll(async () => {
ws.close();
await server.close();
});
describe("late-arriving invoke results", () => {
test("returns success for unknown invoke id (late arrival after timeout)", async () => {
// Create a node client WebSocket
const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => nodeWs.once("open", resolve));
try {
// Connect as a node with device identity
const identity = loadOrCreateDeviceIdentity();
const nodeId = identity.deviceId;
await connectOk(nodeWs, {
role: "node",
client: {
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
version: "1.0.0",
platform: "ios",
mode: GATEWAY_CLIENT_MODES.NODE,
},
commands: ["canvas.snapshot"],
});
// Send an invoke result with an unknown ID (simulating late arrival after timeout)
const result = await rpcReq<{ ok?: boolean; ignored?: boolean }>(
nodeWs,
"node.invoke.result",
{
id: "unknown-invoke-id-12345",
nodeId,
ok: true,
payloadJSON: JSON.stringify({ result: "late" }),
},
);
// Late-arriving results return success instead of error to reduce log noise
expect(result.ok).toBe(true);
expect(result.payload?.ok).toBe(true);
expect(result.payload?.ignored).toBe(true);
} finally {
nodeWs.close();
}
});
test("returns success for unknown invoke id with error payload", async () => {
// Verifies late results are accepted regardless of their ok/error status
const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => nodeWs.once("open", resolve));
try {
await connectOk(nodeWs, {
role: "node",
client: {
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
version: "1.0.0",
platform: "darwin",
mode: GATEWAY_CLIENT_MODES.NODE,
},
commands: [],
});
const identity = loadOrCreateDeviceIdentity();
const nodeId = identity.deviceId;
// Late invoke result with error payload - should still return success
const result = await rpcReq<{ ok?: boolean; ignored?: boolean }>(
nodeWs,
"node.invoke.result",
{
id: "another-unknown-invoke-id",
nodeId,
ok: false,
error: { code: "FAILED", message: "test error" },
},
);
expect(result.ok).toBe(true);
expect(result.payload?.ok).toBe(true);
expect(result.payload?.ignored).toBe(true);
} finally {
nodeWs.close();
}
});
});
+17 -4
View File
@@ -55,10 +55,10 @@ function extractErrorMessage(err: unknown): string | undefined {
function logRemoteBinProbeFailure(nodeId: string, err: unknown) {
const message = extractErrorMessage(err);
const label = describeNode(nodeId);
if (message?.includes("node not connected")) {
log.info(
`remote bin probe skipped: node not connected (${label}); check nodes list/status for ${label}`,
);
// Node unavailable errors (not connected or disconnected mid-operation) are expected
// when nodes have transient connections - log at info level instead of warn
if (message?.includes("node not connected") || message?.includes("node disconnected")) {
log.info(`remote bin probe skipped: node unavailable (${label})`);
return;
}
if (message?.includes("invoke timed out") || message?.includes("timeout")) {
@@ -213,6 +213,15 @@ function parseBinProbePayload(payloadJSON: string | null | undefined, payload?:
return [];
}
function areBinSetsEqual(a: Set<string> | undefined, b: Set<string>): boolean {
if (!a) return false;
if (a.size !== b.size) return false;
for (const bin of b) {
if (!a.has(bin)) return false;
}
return true;
}
export async function refreshRemoteNodeBins(params: {
nodeId: string;
platform?: string;
@@ -261,7 +270,11 @@ export async function refreshRemoteNodeBins(params: {
return;
}
const bins = parseBinProbePayload(res.payloadJSON, res.payload);
const existingBins = remoteNodes.get(params.nodeId)?.bins;
const nextBins = new Set(bins);
const hasChanged = !areBinSetsEqual(existingBins, nextBins);
recordRemoteNodeBins(params.nodeId, bins);
if (!hasChanged) return;
await updatePairedNodeMetadata(params.nodeId, { bins });
bumpSkillsSnapshotVersion({ reason: "remote-node" });
} catch (err) {
+5 -4
View File
@@ -7,6 +7,7 @@ import {
DEFAULT_ACCOUNT_ID,
DEFAULT_MAIN_KEY,
normalizeAgentId,
sanitizeAgentId,
} from "./session-key.js";
export type RoutePeerKind = "dm" | "group" | "channel";
@@ -93,13 +94,13 @@ function listAgents(cfg: ClawdbotConfig) {
function pickFirstExistingAgentId(cfg: ClawdbotConfig, agentId: string): string {
const trimmed = (agentId ?? "").trim();
if (!trimmed) return normalizeAgentId(resolveDefaultAgentId(cfg));
if (!trimmed) return sanitizeAgentId(resolveDefaultAgentId(cfg));
const normalized = normalizeAgentId(trimmed);
const agents = listAgents(cfg);
if (agents.length === 0) return trimmed;
if (agents.length === 0) return sanitizeAgentId(trimmed);
const match = agents.find((agent) => normalizeAgentId(agent.id) === normalized);
if (match?.id?.trim()) return match.id.trim();
return normalizeAgentId(resolveDefaultAgentId(cfg));
if (match?.id?.trim()) return sanitizeAgentId(match.id.trim());
return sanitizeAgentId(resolveDefaultAgentId(cfg));
}
function matchesChannel(
+14
View File
@@ -64,6 +64,20 @@ export function normalizeAgentId(value: string | undefined | null): string {
);
}
export function sanitizeAgentId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) return DEFAULT_AGENT_ID;
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase();
return (
trimmed
.toLowerCase()
.replace(/[^a-z0-9_-]+/gi, "-")
.replace(/^-+/, "")
.replace(/-+$/, "")
.slice(0, 64) || DEFAULT_AGENT_ID
);
}
export function normalizeAccountId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) return DEFAULT_ACCOUNT_ID;
+61
View File
@@ -0,0 +1,61 @@
export type ReasoningTagMode = "strict" | "preserve";
export type ReasoningTagTrim = "none" | "start" | "both";
const QUICK_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking|final)\b/i;
const FINAL_TAG_RE = /<\s*\/?\s*final\b[^>]*>/gi;
const THINKING_TAG_RE = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\b[^>]*>/gi;
function applyTrim(value: string, mode: ReasoningTagTrim): string {
if (mode === "none") return value;
if (mode === "start") return value.trimStart();
return value.trim();
}
export function stripReasoningTagsFromText(
text: string,
options?: {
mode?: ReasoningTagMode;
trim?: ReasoningTagTrim;
},
): string {
if (!text) return text;
if (!QUICK_TAG_RE.test(text)) return text;
const mode = options?.mode ?? "strict";
const trimMode = options?.trim ?? "both";
let cleaned = text;
if (FINAL_TAG_RE.test(cleaned)) {
FINAL_TAG_RE.lastIndex = 0;
cleaned = cleaned.replace(FINAL_TAG_RE, "");
} else {
FINAL_TAG_RE.lastIndex = 0;
}
THINKING_TAG_RE.lastIndex = 0;
let result = "";
let lastIndex = 0;
let inThinking = false;
for (const match of cleaned.matchAll(THINKING_TAG_RE)) {
const idx = match.index ?? 0;
const isClose = match[1] === "/";
if (!inThinking) {
result += cleaned.slice(lastIndex, idx);
if (!isClose) {
inThinking = true;
}
} else if (isClose) {
inThinking = false;
}
lastIndex = idx + match[0].length;
}
if (!inThinking || mode === "preserve") {
result += cleaned.slice(lastIndex);
}
return applyTrim(result, trimMode);
}
+17
View File
@@ -21,5 +21,22 @@ describe("stripThinkingTags", () => {
it("returns original text when no tags exist", () => {
expect(stripThinkingTags("Hello")).toBe("Hello");
});
it("strips <final>…</final> segments", () => {
const input = "<final>\n\nHello there\n\n</final>";
expect(stripThinkingTags(input)).toBe("Hello there\n\n");
});
it("strips mixed <think> and <final> tags", () => {
const input = "<think>reasoning</think>\n\n<final>Hello</final>";
expect(stripThinkingTags(input)).toBe("Hello");
});
it("handles incomplete <final tag gracefully", () => {
// When streaming splits mid-tag, we may see "<final" without closing ">"
// This should not crash and should handle gracefully
expect(stripThinkingTags("<final\nHello")).toBe("<final\nHello");
expect(stripThinkingTags("Hello</final>")).toBe("Hello");
});
});
+3 -33
View File
@@ -1,3 +1,5 @@
import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js";
export function formatMs(ms?: number | null): string {
if (!ms && ms !== 0) return "n/a";
return new Date(ms).toLocaleString();
@@ -67,38 +69,6 @@ export function parseList(input: string): string[] {
.filter((v) => v.length > 0);
}
const THINKING_TAG_RE = /<\s*\/?\s*think(?:ing)?\s*>/gi;
const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i;
const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i;
export function stripThinkingTags(value: string): string {
if (!value) return value;
const hasOpen = THINKING_OPEN_RE.test(value);
const hasClose = THINKING_CLOSE_RE.test(value);
if (!hasOpen && !hasClose) return value;
// If we don't have a balanced pair, avoid dropping trailing content.
if (hasOpen !== hasClose) {
if (!hasOpen) return value.replace(THINKING_CLOSE_RE, "").trimStart();
return value.replace(THINKING_OPEN_RE, "").trimStart();
}
if (!THINKING_TAG_RE.test(value)) return value;
THINKING_TAG_RE.lastIndex = 0;
let result = "";
let lastIndex = 0;
let inThinking = false;
for (const match of value.matchAll(THINKING_TAG_RE)) {
const idx = match.index ?? 0;
if (!inThinking) {
result += value.slice(lastIndex, idx);
}
const tag = match[0].toLowerCase();
inThinking = !tag.includes("/");
lastIndex = idx + match[0].length;
}
if (!inThinking) {
result += value.slice(lastIndex);
}
return result.trimStart();
return stripReasoningTagsFromText(value, { mode: "preserve", trim: "start" });
}