Compare commits

..
Author SHA1 Message Date
Peter Steinberger 56fab1df69 fix: harden bluebubbles short ids and fetch wrapper (#1369) (thanks @tyler6204) 2026-01-21 17:05:36 +00:00
Tyler Yust bf529f56b4 feat: enhance message context with full ID support for replies and caching
- Updated message processing to include full message IDs alongside short IDs for better context resolution.
- Improved reply handling by caching inbound messages, allowing for accurate sender and body resolution without exposing dropped content.
- Adjusted tests to validate the new full ID properties and their integration into the message handling workflow.
2026-01-21 16:50:29 +00:00
Tyler Yust 1ada67ffb3 feat: enhance message handling with short ID resolution and reply context improvements
- Implemented resolution of short message IDs to full UUIDs in both text and media sending functions.
- Updated reply context formatting to optimize token usage by including only necessary information.
- Introduced truncation for long reply bodies to further reduce token consumption.
- Adjusted tests to reflect changes in reply context handling and message ID resolution.
2026-01-21 16:50:28 +00:00
Tyler Yust c607dfc1d6 feat: implement short ID mapping for BlueBubbles messages and enhance reply context caching
- Added functionality to resolve short message IDs to full UUIDs and vice versa, optimizing token usage.
- Introduced a reply cache to store message context for replies when metadata is omitted in webhook payloads.
- Updated message handling to utilize short IDs for outbound messages and replies, improving efficiency.
- Enhanced error messages to clarify required parameters for actions like react, edit, and unsend.
- Added tests to ensure correct behavior of new features and maintain existing functionality.
2026-01-21 16:50:28 +00:00
Peter Steinberger 39e24c9937 docs: update node CLI references 2026-01-21 16:48:42 +00:00
Peter Steinberger fa1bc589e4 feat: flatten node CLI commands 2026-01-21 16:48:42 +00:00
Peter Steinberger 0e003cb7f1 fix: normalize abort signals for telegram fetch 2026-01-21 16:46:58 +00:00
Peter Steinberger fb164b321e fix: model picker allowlist fallbacks 2026-01-21 11:22:33 +00:00
Peter Steinberger 884211a924 feat: render approvals tables on write 2026-01-21 11:10:03 +00:00
Peter Steinberger 9bd6b3fd54 feat: show node PATH and bootstrap node host env 2026-01-21 11:06:56 +00:00
Peter Steinberger dc06b225cd fix: narrow configure model allowlist for Anthropic OAuth 2026-01-21 11:00:28 +00:00
Peter Steinberger cdb35c3aae test: stabilize exec approvals homedir 2026-01-21 10:49:12 +00:00
Peter Steinberger 4e4f5558fc fix: limit /model list output 2026-01-21 10:47:37 +00:00
Peter Steinberger 8479dc97da fix: make session memory indexing async 2026-01-21 10:39:00 +00:00
Peter Steinberger 86ddd3c69c Merge pull request #1370 from parubets/fix-debug-ttl-cache
WIP: cache trace mvp for Anthropic
2026-01-21 10:35:31 +00:00
Peter Steinberger 49d53ff0bb fix: honor wildcard exec approvals on macOS 2026-01-21 10:27:19 +00:00
Peter Steinberger 97e8f9d619 fix: add diagnostics cache trace config (#1370) (thanks @parubets) 2026-01-21 10:23:30 +00:00
Andrii 5392fa0dfa cache trace mvp
Added a standalone cache tracing module and wired it into the embedded
runner so you can capture message flow and the exact context sent to
  Anthropic in a separate JSONL file.

  What changed

  - New tracing module: src/agents/cache-trace.ts (self‑contained,
env‑gated, writes JSONL, computes per‑message digests).
  - Hook points in src/agents/pi-embedded-runner/run/attempt.ts: logs
stage snapshots (loaded/sanitized/limited/prompt/stream/after) and wraps
the
    stream fn to record the real context.messages at send time.

  How to enable

  - CLAWDBOT_CACHE_TRACE=1 enables tracing.
  - CLAWDBOT_CACHE_TRACE_FILE=~/.clawdbot/logs/cache-trace.jsonl
overrides output (default is
$CLAWDBOT_STATE_DIR/logs/cache-trace.jsonl).
  - Optional filters:
      - CLAWDBOT_CACHE_TRACE_MESSAGES=0 to omit full messages (still
logs digests).
      - CLAWDBOT_CACHE_TRACE_PROMPT=0 to omit prompt text.
      - CLAWDBOT_CACHE_TRACE_SYSTEM=0 to omit system prompt.

  What you’ll see

  - One JSON object per line with stage, messagesDigest, per‑message
messageFingerprints, and the actual messages if enabled.
  - The most important line is stage: "stream:context" — that is the
exact payload pi‑mono is sending. If this diverges from earlier stages,
you’ve
    found the mutation point.
2026-01-21 10:23:30 +00:00
Peter Steinberger 63d017c3af fix: add node tool failure context 2026-01-21 09:55:10 +00:00
Peter Steinberger 40646c73af feat: improve exec approvals defaults and wildcard 2026-01-21 09:55:10 +00:00
Peter Steinberger 43ea7665ef chore: bump bluebubbles to 2026.1.21-1 2026-01-21 09:43:19 +00:00
Peter Steinberger ba131b0164 Update README to remove contributor acknowledgments
Removed special thanks and core contributors section.
2026-01-21 09:21:50 +00:00
Peter Steinberger 0693c7804f test: skip plugin tools in sessions tools test 2026-01-21 09:17:27 +00:00
Peter Steinberger 6c69ea2c91 refactor: centralize sandbox runtime label 2026-01-21 09:07:21 +00:00
Peter Steinberger 1e10dc1d3b fix: use plugin-sdk exports in bluebubbles 2026-01-21 09:03:09 +00:00
Peter Steinberger c22a37976d fix: report sandboxed runtime in status 2026-01-21 08:59:32 +00:00
Peter Steinberger 89c5035aa2 docs: restore 2026.1.20 release notes 2026-01-21 08:43:05 +00:00
Peter Steinberger cb7791c8a4 chore: release 2026.1.20-2 2026-01-21 08:30:33 +00:00
Peter Steinberger 9a14267dfa chore: update appcast 2026-01-21 08:25:20 +00:00
Peter Steinberger 010d305401 chore: tidy package files list 2026-01-21 08:25:01 +00:00
Peter Steinberger 3210c91f6b chore: release 2026.1.20 2026-01-21 08:23:49 +00:00
Peter Steinberger e3cea55d72 docs: add npm files check to release checklist 2026-01-21 08:10:53 +00:00
Peter Steinberger 687a902f3e fix: align chat composer 2026-01-21 07:48:00 +00:00
Peter Steinberger fe860de148 fix: quiet update banner and skip duplicate plugin CLI 2026-01-21 07:37:22 +00:00
Peter Steinberger bc8a59faa4 chore: release 2026.1.20-1 2026-01-21 07:37:22 +00:00
Peter Steinberger 91bcdad503 fix: guard anthropic refusal trigger 2026-01-21 07:28:49 +00:00
Peter Steinberger ab97c6880b Merge pull request #1360 from SocialNerd42069/fix/duplicate-assistant-texts
fix: prevent duplicate assistant texts from whitespace differences
2026-01-21 06:31:01 +00:00
Peter Steinberger 65dd73b4c3 fix: clean up slack threading landings (#1360) (thanks @SocialNerd42069) 2026-01-21 06:29:36 +00:00
SocialNerd42069 b69aa011fe Add auto-notify on completion to coding-agent skill 2026-01-21 06:29:36 +00:00
SocialNerd42069 e3a44b10bc fix: prevent duplicate assistant texts from whitespace differences
- Add per-message dedup tracking in subscribeEmbeddedPiSession
- Compare both trimmed and normalized text to catch near-duplicates
- Reset dedup state on each new assistant message
- Add test for trailing whitespace edge case

Fixes duplicate Slack message delivery when the same text appears
with minor whitespace differences (e.g., trailing newline).
2026-01-21 06:29:36 +00:00
SocialNerd42069 5b8007784b fix(slack): handle Bolt ESM/CJS import for Node 25.x
The slackBoltModule.default points to App class directly on Node 25.x,
not the module object. Check for App property first before using default.
2026-01-21 06:29:36 +00:00
SocialNerd42069 0d6e78b718 fix(slack): respect verbose setting and preserve thread context for tool notifications
Fixes two bugs in Slack tool notification delivery:

1. Tool notifications ignored verbose=false - normalized verbose values so
   boolean false/'false' are properly treated as 'off'

2. Thread context lost - Slack outbound adapter now falls back to threadId
   when replyToId is missing, and MessageThreadId is set for thread replies

Closes #1333
2026-01-21 06:29:36 +00:00
SocialNerd42069 46ab4cb19e my local tweaks 2026-01-21 06:29:36 +00:00
Peter Steinberger 32edaad823 fix: address update cli type import 2026-01-21 06:10:27 +00:00
Peter Steinberger 5dcd48544a feat: align update channel installs 2026-01-21 06:00:54 +00:00
Peter Steinberger 1e05925e47 fix: normalize model override auth handling 2026-01-21 06:00:21 +00:00
Peter Steinberger fb47f1cbeb chore: rename clawlog references 2026-01-21 05:53:32 +00:00
Peter Steinberger 15d1421cf2 Merge pull request #1357 from vignesh07/fix/node-invoke-timeout
fix(node): enforce timeout for node.invoke handlers
2026-01-21 05:49:36 +00:00
Peter Steinberger 899bbd40d7 Merge pull request #1358 from vignesh07/fix/ios-talkmode-simulator
fix(ios): prevent Talk mode crash on simulator
2026-01-21 05:42:17 +00:00
Peter Steinberger 555b2578a8 feat: add /allowlist command 2026-01-21 05:34:53 +00:00
Peter Steinberger 0229b8bbd8 docs: expand 2026.1.20 highlights 2026-01-21 05:34:29 +00:00
Peter Steinberger 552f9eff7b docs: add 2026.1.20 highlight 2026-01-21 05:31:37 +00:00
Peter Steinberger 36e0cffaaf fix: stabilize directory cli output 2026-01-21 05:25:28 +00:00
Peter Steinberger e17a9c6abf docs: expand 2026.1.20 changelog 2026-01-21 05:24:23 +00:00
Peter Steinberger 6180603ef4 feat: improve doctor update flow 2026-01-21 05:23:37 +00:00
Peter Steinberger 810374d648 fix: align cli output tests and help examples 2026-01-21 05:20:31 +00:00
Peter Steinberger 968b967854 Merge pull request #1354 from vignesh07/fix/gateway-ios-client-id
fix(gateway): allow clawdbot-ios client id
2026-01-21 05:09:15 +00:00
Peter Steinberger 110079d99d fix: guard nodes status duration parsing (#1354) (thanks @vignesh07) 2026-01-21 05:07:27 +00:00
Peter Steinberger 34a126a6d7 fix: allow mobile node client ids (#1354) (thanks @vignesh07) 2026-01-21 05:07:26 +00:00
Vignesh Natarajan 31462f64d8 fix: allow clawdbot-ios gateway client id
The iOS app currently identifies as clientId=clawdbot-ios when
connecting in node mode. Add this ID to the allowed gateway client
IDs so the handshake schema accepts it.

Also fixes a TS strictness issue in auto-reply status formatting
(parts filter) that caused
> clawdbot@2026.1.20 build /Users/vignesh/clawd/clawdbot-upstream
> tsc -p tsconfig.json && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts

[copy-hook-metadata] Copied boot-md/HOOK.md
[copy-hook-metadata] Copied command-logger/HOOK.md
[copy-hook-metadata] Copied session-memory/HOOK.md
[copy-hook-metadata] Copied soul-evil/HOOK.md
[copy-hook-metadata] Done to fail.
2026-01-21 05:07:26 +00:00
Vignesh Natarajan b46855d8c4 fix(ios): prevent Talk mode crash on simulator
- Disable Talk mode start on iOS simulator (no audio input)
- Validate audio input format before installing tap to avoid
  AVFAudio assertion crashes on misconfigured devices.

Tested:
- Launched app on iOS simulator and tapping Talk no longer crashes
  (shows error path instead).
2026-01-20 20:52:42 -08:00
Vignesh Natarajan feaad8250b fix(node): enforce node.invoke timeout in node client
Use the timeout provided on node invoke requests to ensure node
clients always respond with a result.

This prevents gateway-side node.invoke calls from hanging until the
gateway timeout when a node command stalls.

Tests:
- swift test --filter GatewayNodeSessionTests
2026-01-20 20:50:20 -08:00
171 changed files with 6012 additions and 2068 deletions

No files matched your search

+1 -1
View File
@@ -100,7 +100,7 @@
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars. - CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes. - Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.** - Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`. - macOS logs: use `./scripts/clawlog.sh` to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance. - If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code. - SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync. - Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
+178 -267
View File
@@ -2,291 +2,202 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 2026.1.21
### Changes
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
- CLI: exec approvals mutations render tables instead of raw JSON.
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
### Fixes
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
- Model picker: list the full catalog when no model allowlist is configured.
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1369) Thanks @tyler6204.
- Infra: preserve fetch helper methods when wrapping abort signals. (#1369)
## 2026.1.20 ## 2026.1.20
### Changes ### Changes
- Deps: update workspace + memory-lancedb dependencies. - Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui
- Dev: use tsgo for dev/watch builds by default; set `CLAWDBOT_TS_COMPILER=tsc` to opt out. - Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui
- Repo: remove the Peekaboo git submodule now that the SPM release is used. - TUI: add syntax highlighting for code blocks. (#1200) https://docs.clawd.bot/tui
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`. - TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) https://docs.clawd.bot/tui
- TUI: add a searchable model picker for quicker model selection. (#1198) https://docs.clawd.bot/tui
- TUI: add input history (up/down) for submitted messages. (#1348) https://docs.clawd.bot/tui
- ACP: add `clawdbot acp` for IDE integrations. https://docs.clawd.bot/cli/acp
- ACP: add `clawdbot acp client` interactive harness for debugging. https://docs.clawd.bot/cli/acp
- Skills: add download installs with OS-filtered options. https://docs.clawd.bot/tools/skills
- Skills: add the local sherpa-onnx-tts skill. https://docs.clawd.bot/tools/skills
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. https://docs.clawd.bot/concepts/memory
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates. https://docs.clawd.bot/concepts/memory
- Memory: add OpenAI batch indexing for embeddings when configured. https://docs.clawd.bot/concepts/memory
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings. https://docs.clawd.bot/concepts/memory
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). https://docs.clawd.bot/concepts/memory
- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default. https://docs.clawd.bot/concepts/memory
- Memory: add `--verbose` logging for memory status + batch indexing details. https://docs.clawd.bot/concepts/memory
- Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.clawd.bot/concepts/memory
- Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) https://docs.clawd.bot/tools/browser
- Nostr: add the Nostr channel plugin with profile management + onboarding defaults. (#1323) https://docs.clawd.bot/channels/nostr
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) https://docs.clawd.bot/channels/matrix
- Slack: add HTTP webhook mode via Bolt HTTP receiver. (#1143) https://docs.clawd.bot/channels/slack
- Telegram: enrich forwarded-message context with normalized origin details + legacy fallback. (#1090) https://docs.clawd.bot/channels/telegram
- Discord: fall back to `/skill` when native command limits are exceeded. (#1287)
- Discord: expose `/skill` globally. (#1287)
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) https://docs.clawd.bot/plugins/zalouser
- Plugins: require manifest-embedded config schemas with preflight validation warnings. (#1272) https://docs.clawd.bot/plugins/manifest
- Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest
- Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui
- Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools
- Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles
- Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.
- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime. https://docs.clawd.bot/channels/zalo
- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime. https://docs.clawd.bot/plugins/zalouser
- Plugins: allow optional agent tools with explicit allowlists and add the plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
- Plugins: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`. - Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
- Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) — thanks @sebslight. - Gateway/API: add `/v1/responses` (OpenResponses) with item-based input + semantic streaming events. (#1229)
- Channels: add the Nostr plugin channel with profile management + onboarding install defaults. (#1323) — thanks @joelklabo. - Gateway/API: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229)
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow. - Usage: add `/usage cost` summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel. - Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.clawd.bot/cli/security
- Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow. - Exec: add host/security/ask routing for gateway + node exec. https://docs.clawd.bot/tools/exec
- Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0. - Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node). https://docs.clawd.bot/tools/exec
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl. - Exec approvals: migrate approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists + skill auto-allow toggle, and add approvals UI + node exec lifecycle events. https://docs.clawd.bot/tools/exec-approvals
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete. - Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`. https://docs.clawd.bot/cli/node
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh. - Nodes: add node daemon service install/status/start/stop/restart. https://docs.clawd.bot/cli/node
- Security: warn when <=300B models run without sandboxing and with web tools enabled. - Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
- Skills: add download installs with OS-filtered install options; add local sherpa-onnx-tts skill. - Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) https://docs.clawd.bot/concepts/session
- Docs: clarify WhatsApp voice notes and Windows WSL portproxy LAN access notes. - Sessions: allow `sessions_spawn` to override thinking level for sub-agent runs. https://docs.clawd.bot/tools/subagents
- UI: add copy-as-markdown with error feedback and drop legacy list view. (#1345) — thanks @bradleypriest. - Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers. https://docs.clawd.bot/concepts/groups
- TUI: add input history (up/down) for submitted messages. (#1348) — thanks @vignesh07. - Models: add Qwen Portal OAuth provider support. (#1120) https://docs.clawd.bot/providers/qwen
### Fixes - Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. https://docs.clawd.bot/start/onboarding
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs. - Docs: clarify allowlist input types and onboarding behavior for messaging channels. https://docs.clawd.bot/start/onboarding
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco. - Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.clawd.bot/platforms/android
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. - Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.clawd.bot/bedrock
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry. - Docs: clarify WhatsApp voice notes. https://docs.clawd.bot/channels/whatsapp
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch; gate heartbeat/webhook logging. (#1244) — thanks @oscargavin. - Docs: clarify Windows WSL portproxy LAN access notes. https://docs.clawd.bot/platforms/windows
- CLI: preserve cron delivery settings when editing message payloads. (#1322) — thanks @KrauseFx. - Docs: refresh bird skill install metadata and usage notes. (#1302) https://docs.clawd.bot/tools/browser-login
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output. - Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
- Nodes: enforce node.invoke timeouts for node handlers. (#1357) — thanks @vignesh07.
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
- Doctor: clarify plugin auto-enable hint text in the startup banner.
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
- Gateway: clarify connect/validation errors for gateway params. (#1347) — thanks @vignesh07.
- Gateway: preserve restart wake routing + thread replies across restarts. (#1337) — thanks @John-Rood.
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
- Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
- UI: preserve ordered list numbering in chat markdown. (#1341) — thanks @bradleypriest.
- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342) — thanks @ameno-.
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346) — thanks @fogboots.
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
- Anthropic: default API prompt caching to 1h with configurable TTL override; ignore TTL for OAuth.
- Discord: make resolve warnings avoid raw JSON payloads on rate limits.
- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)
- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
## 2026.1.19-3
### Changes
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
- Gateway: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229) — thanks @RyanLisse.
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) — thanks @steipete.
### Fixes
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
- Browser: register AI snapshot refs for act commands. (#1282) — thanks @John-Rood.
## 2026.1.19-2
### Changes
- Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.
- Android: bump okhttp + dnsjava to satisfy lint dependency checks.
- Docs: refresh Android node discovery docs for the Gateway WS service type.
### Fixes
- Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.
- CLI: skip runner rebuilds when dist is fresh. (#1231) — thanks @mukhtharcm, @thewilloftheshadow.
## 2026.1.19-1
### Breaking
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
### Changes
- Gateway: add `/v1/responses` endpoint (OpenResponses API) for agentic workflows with item-based input and semantic streaming events. Enable via `gateway.http.endpoints.responses.enabled: true`.
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
- Agents: clarify node_modules read-only guidance in agent instructions. - Agents: clarify node_modules read-only guidance in agent instructions.
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07. - Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) — thanks @Whoaa512. - macOS: hide usage section when usage is unavailable instead of showing provider errors.
- Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.
### Fixes - Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba. - Android: remove legacy bridge transport code now that nodes use the gateway protocol.
- Agents: add `clawdbot agents set-identity` helper and update bootstrap guidance for multi-agent setups. (#1222) — thanks @ThePickle31. - Android: bump okhttp + dnsjava to satisfy lint dependency checks.
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context. - Build: update workspace + core/plugin deps.
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058) - Build: use tsgo for dev/watch builds by default (opt out with `CLAWDBOT_TS_COMPILER=tsc`).
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084) - Repo: remove the Peekaboo git submodule now that the SPM release is used.
- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214) — thanks @ameno-.
- Memory: show total file counts + scan issues in `clawdbot memory status`; fall back to non-batch embeddings after repeated batch failures.
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226) — thanks @VACInc. (fixes #1225)
## 2026.1.18-5
### Changes
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels.
- TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07.
- Docs: clarify allowlist input types and onboarding behavior for messaging channels.
### Fixes
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
- Docs: make docs:list fail fast with a clear error if the docs directory is missing.
- macOS: load menu session previews asynchronously so items populate while the menu is open.
- macOS: use label colors for session preview text so previews render in menu subviews.
- macOS: suppress usage error text in the menubar cost view.
- Telegram: honor pairing allowlists for native slash commands.
- TUI: highlight model search matches and stabilize search ordering.
- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195) — thanks @gumadeiras.
- Slack: resolve Bolt import interop for Bun + Node. (#1191) — thanks @CoreyH.
- Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.
- Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.
## 2026.1.18-4
### Changes
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release. - macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.
- macOS: stop syncing Peekaboo in postinstall. - macOS: stop syncing Peekaboo in postinstall.
- Swabble: use the tagged Commander Swift package release. - Swabble: use the tagged Commander Swift package release.
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK. ### Breaking
- Plugins: auto-enable bundled channel/provider plugins when configuration is present. - **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety. Run `clawdbot doctor --fix` to repair, then update plugins (`clawdbot plugins update`) if you use any.
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
- Memory: add native Gemini embeddings provider for memory search. (#1151)
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
### Fixes ### Fixes
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee. - Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244)
- Diagnostics: gate heartbeat/webhook logging. (#1244)
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
- Gateway: allow mobile node client ids for iOS + Android handshake validation. (#1354)
- Gateway: clarify connect/validation errors for gateway params. (#1347)
- Gateway: preserve restart wake routing + thread replies across restarts. (#1337)
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
- Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.
- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241)
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. (#1137)
- Agents: sanitize oversized image payloads before send and surface image-dimension errors. - Agents: sanitize oversized image payloads before send and surface image-dimension errors.
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166) — thanks @AlexMikhalev. - Sessions: fall back to session labels when listing display names. (#1124)
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105) - Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226)
- Doctor: clarify plugin auto-enable hint text in the startup banner.
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
- Docs: make docs:list fail fast with a clear error if the docs directory is missing.
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297)
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
- CLI: preserve cron delivery settings when editing message payloads. (#1322)
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output.
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207)
- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195)
- CLI: skip runner rebuilds when dist is fresh. (#1231)
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
- Status: show both usage windows with reset hints when usage data is available. (#1101)
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315)
- UI: preserve ordered list numbering in chat markdown. (#1341)
- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342)
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283)
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212)
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202)
- TUI: align custom editor initialization with the latest pi-tui API. (#1298)
- TUI: show generic empty-state text for searchable pickers. (#1201)
- TUI: highlight model search matches and stabilize search ordering.
- Configure: hide OpenRouter auto routing model from the model picker. (#1182)
- Memory: show total file counts + scan issues in `clawdbot memory status`.
- Memory: fall back to non-batch embeddings after repeated batch failures.
- Memory: apply OpenAI batch defaults even without explicit remote config.
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151) - Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151) - Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
## 2026.1.18-3
### Changes
- Exec: add host/security/ask routing for gateway + node exec.
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
- macOS: add approvals socket UI server + node exec lifecycle events.
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
- Nodes: add node daemon service install/status/start/stop/restart.
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
- Agents: auto-inject local image references for vision models and avoid reloading history images. (#1098) — thanks @tyler6204.
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
- ACP: add experimental ACP support for IDE integrations (`clawdbot acp`). Thanks @visionik.
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
- macOS: add exec-host IPC for node service `system.run` with HMAC + peer UID checks.
### Fixes
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
- Tools: return a companion-app-required message when node exec is requested with no paired node.
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
## 2026.1.18-2
### Fixes
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
## 2026.1.18-1
### Changes
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
### Fixes
- Memory: apply OpenAI batch defaults even without explicit remote config.
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
- Discord: only emit slow listener warnings after 30s.
## 2026.1.17-6
### Changes
- Plugins: add exclusive plugin slots with a dedicated memory slot selector.
- Memory: ship core memory tools + CLI as the bundled `memory-core` plugin.
- Docs: document plugin slots and memory plugin behavior.
- Plugins: add the bundled BlueBubbles channel plugin (disabled by default).
- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime.
- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime.
## 2026.1.17-5
### Changes
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback.
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates.
- CLI: surface FTS + embedding cache state in `clawdbot memory status`.
- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default.
- Plugins: allow optional agent tools with explicit allowlists and add plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
- Tools: centralize plugin tool policy helpers.
- Commands: add `/subagents info` and show sub-agent counts in `/status`.
- Docs: clarify plugin agent tool configuration. https://docs.clawd.bot/plugins/agent-tools
### Fixes
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
## 2026.1.18-1
### Changes
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
### Fixes
- Memory: apply OpenAI batch defaults even without explicit remote config.
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
- Discord: only emit slow listener warnings after 30s.
## 2026.1.17-3
### Changes
- Memory: add OpenAI Batch API indexing for embeddings when configured.
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings.
### Fixes
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing. - Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
## 2026.1.17-2
### Changes
### Fixes
- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.
- Memory: parallelize embedding indexing with rate-limit retries. - Memory: parallelize embedding indexing with rate-limit retries.
- Memory: split overly long lines to keep embeddings under token limits. - Memory: split overly long lines to keep embeddings under token limits.
- Memory: skip empty chunks to avoid invalid embedding inputs. - Memory: skip empty chunks to avoid invalid embedding inputs.
- Sessions: fall back to session labels when listing display names. (#1124) — thanks @abdaraxus.
- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123) — thanks @thewilloftheshadow.
## 2026.1.17-1
### Changes
- Telegram: enrich forwarded message context with normalized origin details + legacy fallback. (#1090) — thanks @sleontenko.
- macOS: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x.
- macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg.
- CLI: surface update availability in `clawdbot status`.
- CLI: add `clawdbot memory status --deep/--index` probes.
- CLI: add playful update completion quips.
### Fixes
- Doctor: avoid re-adding WhatsApp ack reaction config when only legacy auth files exist. (#1087) — thanks @YuriNachos.
- Hooks: parse multi-line/YAML frontmatter metadata blocks (JSON5-friendly). (#1114) — thanks @sebslight.
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
- Windows: install gateway scheduled task as the current user; show friendly guidance instead of failing on access denied.
- Status: show both usage windows with reset hints when usage data is available. (#1101) — thanks @rhjoh.
- Memory: probe sqlite-vec availability in `clawdbot memory status`.
- Memory: split embedding batches to avoid OpenAI token limits during indexing. - Memory: split embedding batches to avoid OpenAI token limits during indexing.
- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118) — thanks @sleontenko. - Memory: probe sqlite-vec availability in `clawdbot memory status`.
- Exec approvals: enforce allowlist when ask is off.
- Exec approvals: prefer raw command for node approvals/events.
- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.
- Tools: return a companion-app-required message when node exec is requested with no paired node.
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
- Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297)
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147)
- Discord: make resolve warnings avoid raw JSON payloads on rate limits.
- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)
- Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.
- Discord: only emit slow listener warnings after 30s.
- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123)
- Telegram: honor pairing allowlists for native slash commands.
- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118)
- Slack: resolve Bolt import interop for Bun + Node. (#1191)
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346)
- Browser: register AI snapshot refs for act commands. (#1282)
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
- Anthropic: default API prompt caching to 1h with configurable TTL override.
- Anthropic: ignore TTL for OAuth.
- Auth profiles: keep auto-pinned preference while allowing rotation on failover. (#1138)
- Auth profiles: user pins stay locked. (#1138)
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332)
- Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
- Windows: install gateway scheduled task as the current user.
- Windows: show friendly guidance instead of failing on access denied.
- macOS: load menu session previews asynchronously so items populate while the menu is open.
- macOS: use label colors for session preview text so previews render in menu subviews.
- macOS: suppress usage error text in the menubar cost view.
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166)
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214)
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
## 2026.1.16-2 ## 2026.1.16-2
-5
View File
@@ -471,11 +471,6 @@ by Peter Steinberger and the community.
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
AI/vibe-coded PRs welcome! 🤖 AI/vibe-coded PRs welcome! 🤖
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
Core contributors:
- @cpojer — Telegram onboarding UX + docs
Thanks to all clawtributors: Thanks to all clawtributors:
<p align="left"> <p align="left">
+190 -172
View File
@@ -2,6 +2,196 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0"> <rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel> <channel>
<title>Clawdbot</title> <title>Clawdbot</title>
<item>
<title>2026.1.20</title>
<pubDate>Wed, 21 Jan 2026 08:18:22 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>7116</sparkle:version>
<sparkle:shortVersionString>2026.1.20</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.20</h2>
<h3>Changes</h3>
<ul>
<li>Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui</li>
<li>Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui</li>
<li>TUI: add syntax highlighting for code blocks. (#1200) https://docs.clawd.bot/tui</li>
<li>TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) https://docs.clawd.bot/tui</li>
<li>TUI: add a searchable model picker for quicker model selection. (#1198) https://docs.clawd.bot/tui</li>
<li>TUI: add input history (up/down) for submitted messages. (#1348) https://docs.clawd.bot/tui</li>
<li>ACP: add <code>clawdbot acp</code> for IDE integrations. https://docs.clawd.bot/cli/acp</li>
<li>ACP: add <code>clawdbot acp client</code> interactive harness for debugging. https://docs.clawd.bot/cli/acp</li>
<li>Skills: add download installs with OS-filtered options. https://docs.clawd.bot/tools/skills</li>
<li>Skills: add the local sherpa-onnx-tts skill. https://docs.clawd.bot/tools/skills</li>
<li>Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: add SQLite embedding cache to speed up reindexing and frequent updates. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: add OpenAI batch indexing for embeddings when configured. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: enable OpenAI batch indexing by default for OpenAI embeddings. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). https://docs.clawd.bot/concepts/memory</li>
<li>Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: add <code>--verbose</code> logging for memory status + batch indexing details. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.clawd.bot/concepts/memory</li>
<li>Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) https://docs.clawd.bot/tools/browser</li>
<li>Nostr: add the Nostr channel plugin with profile management + onboarding defaults. (#1323) https://docs.clawd.bot/channels/nostr</li>
<li>Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) https://docs.clawd.bot/channels/matrix</li>
<li>Slack: add HTTP webhook mode via Bolt HTTP receiver. (#1143) https://docs.clawd.bot/channels/slack</li>
<li>Telegram: enrich forwarded-message context with normalized origin details + legacy fallback. (#1090) https://docs.clawd.bot/channels/telegram</li>
<li>Discord: fall back to <code>/skill</code> when native command limits are exceeded. (#1287)</li>
<li>Discord: expose <code>/skill</code> globally. (#1287)</li>
<li>Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) https://docs.clawd.bot/plugins/zalouser</li>
<li>Plugins: require manifest-embedded config schemas with preflight validation warnings. (#1272) https://docs.clawd.bot/plugins/manifest</li>
<li>Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest</li>
<li>Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest</li>
<li>Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui</li>
<li>Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools</li>
<li>Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles</li>
<li>Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.</li>
<li>Plugins: migrate the Zalo plugin to the shared plugin SDK runtime. https://docs.clawd.bot/channels/zalo</li>
<li>Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime. https://docs.clawd.bot/plugins/zalouser</li>
<li>Plugins: allow optional agent tools with explicit allowlists and add the plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools</li>
<li>Plugins: auto-enable bundled channel/provider plugins when configuration is present.</li>
<li>Plugins: sync plugin sources on channel switches and update npm-installed plugins during <code>clawdbot update</code>.</li>
<li>Plugins: share npm plugin update logic between <code>clawdbot update</code> and <code>clawdbot plugins update</code>.</li>
<li>Gateway/API: add <code>/v1/responses</code> (OpenResponses) with item-based input + semantic streaming events. (#1229)</li>
<li>Gateway/API: expand <code>/v1/responses</code> to support file/image inputs, tool_choice, usage, and output limits. (#1229)</li>
<li>Usage: add <code>/usage cost</code> summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs</li>
<li>Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.clawd.bot/cli/security</li>
<li>Exec: add host/security/ask routing for gateway + node exec. https://docs.clawd.bot/tools/exec</li>
<li>Exec: add <code>/exec</code> directive for per-session exec defaults (host/security/ask/node). https://docs.clawd.bot/tools/exec</li>
<li>Exec approvals: migrate approvals to <code>~/.clawdbot/exec-approvals.json</code> with per-agent allowlists + skill auto-allow toggle, and add approvals UI + node exec lifecycle events. https://docs.clawd.bot/tools/exec-approvals</li>
<li>Nodes: add headless node host (<code>clawdbot node start</code>) for <code>system.run</code>/<code>system.which</code>. https://docs.clawd.bot/cli/node</li>
<li>Nodes: add node daemon service install/status/start/stop/restart. https://docs.clawd.bot/cli/node</li>
<li>Bridge: add <code>skills.bins</code> RPC to support node host auto-allow skill bins.</li>
<li>Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) https://docs.clawd.bot/concepts/session</li>
<li>Sessions: allow <code>sessions_spawn</code> to override thinking level for sub-agent runs. https://docs.clawd.bot/tools/subagents</li>
<li>Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers. https://docs.clawd.bot/concepts/groups</li>
<li>Models: add Qwen Portal OAuth provider support. (#1120) https://docs.clawd.bot/providers/qwen</li>
<li>Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. https://docs.clawd.bot/start/onboarding</li>
<li>Docs: clarify allowlist input types and onboarding behavior for messaging channels. https://docs.clawd.bot/start/onboarding</li>
<li>Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.clawd.bot/platforms/android</li>
<li>Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.clawd.bot/bedrock</li>
<li>Docs: clarify WhatsApp voice notes. https://docs.clawd.bot/channels/whatsapp</li>
<li>Docs: clarify Windows WSL portproxy LAN access notes. https://docs.clawd.bot/platforms/windows</li>
<li>Docs: refresh bird skill install metadata and usage notes. (#1302) https://docs.clawd.bot/tools/browser-login</li>
<li>Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.</li>
<li>Agents: clarify node_modules read-only guidance in agent instructions.</li>
<li>Config: stamp last-touched metadata on write and warn if the config is newer than the running build.</li>
<li>macOS: hide usage section when usage is unavailable instead of showing provider errors.</li>
<li>Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.</li>
<li>Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.</li>
<li>Android: remove legacy bridge transport code now that nodes use the gateway protocol.</li>
<li>Android: bump okhttp + dnsjava to satisfy lint dependency checks.</li>
<li>Build: update workspace + core/plugin deps.</li>
<li>Build: use tsgo for dev/watch builds by default (opt out with <code>CLAWDBOT_TS_COMPILER=tsc</code>).</li>
<li>Repo: remove the Peekaboo git submodule now that the SPM release is used.</li>
<li>macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.</li>
<li>macOS: stop syncing Peekaboo in postinstall.</li>
<li>Swabble: use the tagged Commander Swift package release.</li>
</ul>
<h3>Breaking</h3>
<ul>
<li><strong>BREAKING:</strong> Reject invalid/unknown config entries and refuse to start the gateway for safety. Run <code>clawdbot doctor --fix</code> to repair, then update plugins (<code>clawdbot plugins update</code>) if you use any.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Discovery: shorten Bonjour DNS-SD service type to <code>_clawdbot-gw._tcp</code> and update discovery clients/docs.</li>
<li>Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.</li>
<li>Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244)</li>
<li>Diagnostics: gate heartbeat/webhook logging. (#1244)</li>
<li>Gateway: strip inbound envelope headers from chat history messages to keep clients clean.</li>
<li>Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.</li>
<li>Gateway: allow mobile node client ids for iOS + Android handshake validation. (#1354)</li>
<li>Gateway: clarify connect/validation errors for gateway params. (#1347)</li>
<li>Gateway: preserve restart wake routing + thread replies across restarts. (#1337)</li>
<li>Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.</li>
<li>Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.</li>
<li>Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)</li>
<li>Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241)</li>
<li>Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)</li>
<li>Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. (#1137)</li>
<li>Agents: sanitize oversized image payloads before send and surface image-dimension errors.</li>
<li>Sessions: fall back to session labels when listing display names. (#1124)</li>
<li>Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)</li>
<li>Config: log invalid config issues once per run and keep invalid-config errors stackless.</li>
<li>Config: allow Perplexity as a web_search provider in config validation. (#1230)</li>
<li>Config: allow custom fields under <code>skills.entries.<name>.config</code> for skill credentials/config. (#1226)</li>
<li>Doctor: clarify plugin auto-enable hint text in the startup banner.</li>
<li>Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)</li>
<li>Docs: make docs:list fail fast with a clear error if the docs directory is missing.</li>
<li>Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297)</li>
<li>Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.</li>
<li>CLI: preserve cron delivery settings when editing message payloads. (#1322)</li>
<li>CLI: keep <code>clawdbot logs</code> output resilient to broken pipes while preserving progress output.</li>
<li>CLI: avoid duplicating --profile/--dev flags when formatting commands.</li>
<li>CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207)</li>
<li>CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195)</li>
<li>CLI: skip runner rebuilds when dist is fresh. (#1231)</li>
<li>CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.</li>
<li>Status: route native <code>/status</code> to the active agent so model selection reflects the correct profile. (#1301)</li>
<li>Status: show both usage windows with reset hints when usage data is available. (#1101)</li>
<li>UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315)</li>
<li>UI: preserve ordered list numbering in chat markdown. (#1341)</li>
<li>UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342)</li>
<li>UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283)</li>
<li>UI: enable shell mode for sync Windows spawns to avoid <code>pnpm ui:build</code> EINVAL. (#1212)</li>
<li>TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202)</li>
<li>TUI: align custom editor initialization with the latest pi-tui API. (#1298)</li>
<li>TUI: show generic empty-state text for searchable pickers. (#1201)</li>
<li>TUI: highlight model search matches and stabilize search ordering.</li>
<li>Configure: hide OpenRouter auto routing model from the model picker. (#1182)</li>
<li>Memory: show total file counts + scan issues in <code>clawdbot memory status</code>.</li>
<li>Memory: fall back to non-batch embeddings after repeated batch failures.</li>
<li>Memory: apply OpenAI batch defaults even without explicit remote config.</li>
<li>Memory: index atomically so failed reindex preserves the previous memory database. (#1151)</li>
<li>Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)</li>
<li>Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.</li>
<li>Memory: parallelize embedding indexing with rate-limit retries.</li>
<li>Memory: split overly long lines to keep embeddings under token limits.</li>
<li>Memory: skip empty chunks to avoid invalid embedding inputs.</li>
<li>Memory: split embedding batches to avoid OpenAI token limits during indexing.</li>
<li>Memory: probe sqlite-vec availability in <code>clawdbot memory status</code>.</li>
<li>Exec approvals: enforce allowlist when ask is off.</li>
<li>Exec approvals: prefer raw command for node approvals/events.</li>
<li>Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.</li>
<li>Tools: return a companion-app-required message when node exec is requested with no paired node.</li>
<li>Tools: return a companion-app-required message when <code>system.run</code> is requested without a supporting node.</li>
<li>Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).</li>
<li>Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297)</li>
<li>Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)</li>
<li>Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147)</li>
<li>Discord: make resolve warnings avoid raw JSON payloads on rate limits.</li>
<li>Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)</li>
<li>Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.</li>
<li>Discord: only emit slow listener warnings after 30s.</li>
<li>Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123)</li>
<li>Telegram: honor pairing allowlists for native slash commands.</li>
<li>Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118)</li>
<li>Slack: resolve Bolt import interop for Bun + Node. (#1191)</li>
<li>Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).</li>
<li>Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346)</li>
<li>Browser: register AI snapshot refs for act commands. (#1282)</li>
<li>Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)</li>
<li>Anthropic: default API prompt caching to 1h with configurable TTL override.</li>
<li>Anthropic: ignore TTL for OAuth.</li>
<li>Auth profiles: keep auto-pinned preference while allowing rotation on failover. (#1138)</li>
<li>Auth profiles: user pins stay locked. (#1138)</li>
<li>Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332)</li>
<li>Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.</li>
<li>Tests: stabilize plugin SDK resolution and embedded agent timeouts.</li>
<li>Windows: install gateway scheduled task as the current user.</li>
<li>Windows: show friendly guidance instead of failing on access denied.</li>
<li>macOS: load menu session previews asynchronously so items populate while the menu is open.</li>
<li>macOS: use label colors for session preview text so previews render in menu subviews.</li>
<li>macOS: suppress usage error text in the menubar cost view.</li>
<li>macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166)</li>
<li>macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)</li>
<li>macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)</li>
<li>Daemon: include HOME in service environments to avoid missing HOME errors. (#1214)</li>
</ul>
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.20/Clawdbot-2026.1.20.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
</item>
<item> <item>
<title>2026.1.16-2</title> <title>2026.1.16-2</title>
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate> <pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
@@ -99,177 +289,5 @@
]]></description> ]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/> <enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
</item> </item>
<item>
<title>2026.1.14-1</title>
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5825</sparkle:version>
<sparkle:shortVersionString>2026.1.14-1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.14-1</h2>
<h3>Highlights</h3>
<ul>
<li>Web search: <code>web_search</code>/<code>web_fetch</code> tools (Brave API) + first-time setup in onboarding/configure.</li>
<li>Browser control: Chrome extension relay takeover mode + remote browser control via <code>clawdbot browser serve</code>.</li>
<li>Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.</li>
<li>Security: expanded <code>clawdbot security audit</code> (+ <code>--fix</code>), detect-secrets CI scan, and a <code>SECURITY.md</code> reporting policy.</li>
</ul>
<h3>Changes</h3>
<h4>Web Tools</h4>
<ul>
<li>Tools: add <code>web_search</code>/<code>web_fetch</code> (Brave API), including helpful setup hints when the key is missing.</li>
<li>Tools: enable <code>web_fetch</code> by default (unless explicitly disabled in config).</li>
<li>CLI/Docs: add <code>clawdbot configure --section web</code> for storing Brave API keys and update onboarding tips.</li>
</ul>
<h4>Browser / Control UI</h4>
<ul>
<li>Browser: add Chrome extension relay takeover mode (toolbar button) + <code>clawdbot browser serve</code> remote control + <code>browser.controlToken</code>.</li>
<li>Browser: ship a built-in <code>chrome</code> profile for extension relay and start the relay automatically when running locally.</li>
<li>Browser: default <code>browser.defaultProfile</code> to <code>chrome</code> (existing Chrome takeover mode).</li>
<li>Browser: add <code>clawdbot browser extension install/path</code> and copy extension path to clipboard.</li>
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
<li>Control UI: show raw any-map entries in config views; move Docs link into the left nav.</li>
</ul>
<h4>Plugins</h4>
<ul>
<li>Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.</li>
<li>Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.</li>
<li>Channels: add Matrix plugin (external) with docs + onboarding hooks.</li>
<li>Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.</li>
</ul>
<h4>Security</h4>
<ul>
<li>Security: expand <code>clawdbot security audit</code> checks and publish a <code>SECURITY.md</code> reporting policy.</li>
<li>Security: extend <code>clawdbot security audit --fix</code> to tighten more sensitive state paths.</li>
<li>Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.</li>
</ul>
<h4>Onboarding / Daemon</h4>
<ul>
<li>Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require <code>--accept-risk</code> for <code>--non-interactive</code>.</li>
<li>Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.</li>
</ul>
<h4>Auth / Usage / Config</h4>
<ul>
<li>Usage: add MiniMax coding plan usage tracking.</li>
<li>Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.</li>
<li>Agents: add optional auth-profile copy prompt on <code>agents add</code> and improve auth error messaging.</li>
<li>Auth: add dynamic template variables to <code>messages.responsePrefix</code>. (#928) — thanks @sebslight.</li>
<li>Config: add <code>channels.<provider>.configWrites</code> gating for channel-initiated config writes; migrate Slack channel IDs.</li>
</ul>
<h4>Channels</h4>
<ul>
<li>Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.</li>
<li>WhatsApp: add <code>channels.whatsapp.sendReadReceipts</code> to disable auto read receipts. (#882) — thanks @chrisrodz.</li>
</ul>
<h4>Docs</h4>
<ul>
<li>Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.</li>
<li>Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.</li>
<li>Docs: expand gateway security hardening guidance and incident response checklist.</li>
<li>Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.</li>
<li>Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)</li>
<li>Docs: add per-command CLI doc pages and link them from <code>clawdbot <command> --help</code>.</li>
<li>Docs: add multi-gateway guide (sidebar + nav).</li>
</ul>
<h3>Fixes</h3>
<h4>Gateway / Daemon / Sessions</h4>
<ul>
<li>Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.</li>
<li>Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare <code>main</code> alias).</li>
<li>Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.</li>
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
<li>Browser: persist role snapshot refs per CDP target so <code>snapshot</code> → <code>act</code> clicks work even if Playwright returns a different Page instance.</li>
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
<li>Daemon: clear persisted launchd disabled state before bootstrap (fixes <code>daemon install</code> after uninstall). (#849) — thanks @ndraiman.</li>
<li>Sessions: return deep clones (<code>structuredClone</code>) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.</li>
<li>Heartbeat: keep <code>updatedAt</code> monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.</li>
<li>Agent: clear run context after CLI runs (<code>clearAgentRunContext</code>) to avoid runaway contexts. (#934) — thanks @ronak-guliani.</li>
<li>Gateway/Dev: ensure <code>pnpm gateway:dev</code> always uses the dev profile config + state (<code>~/.clawdbot-dev</code>).</li>
</ul>
<h4>CLI / Onboarding</h4>
<ul>
<li>Onboarding: show web search setup at the end (not the beginning).</li>
<li>Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.</li>
<li>Health: colorize “not configured” provider lines for easier scanning.</li>
</ul>
<h4>Control UI / TUI</h4>
<ul>
<li>Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)</li>
<li>UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.</li>
<li>UI: always apply <code>?token=</code> from URL (fixes unauthorized after re-onboard).</li>
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
<li>TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.</li>
<li>TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.</li>
<li>TUI: show LLM error messages (rate limits, auth, etc.) instead of <code>(no output)</code>.</li>
</ul>
<h4>Agents / Auth / Tools / Sandbox</h4>
<ul>
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
<li>Logging: tolerate <code>EIO</code> from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.</li>
<li>Sandbox: restore <code>docker.binds</code> config validation and preserve configured PATH for <code>docker exec</code>. (#873) — thanks @akonyer.</li>
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
</ul>
<h4>macOS / Apps</h4>
<ul>
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
<li>macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.</li>
<li>macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)</li>
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
<li>macOS: fix cron preview/testing payload to use <code>channel</code> key. (#867) — thanks @wes-davis.</li>
<li>macOS: update cron testing channel arg. (#896) — thanks @ngutman.</li>
</ul>
<h4>Channels / Messaging</h4>
<ul>
<li>Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)</li>
<li>Slack: respect <code>channels.slack.requireMention</code> default when resolving channel mention gating. (#850) — thanks @evalexpr.</li>
<li>Slack: drop Socket Mode events with mismatched <code>api_app_id</code>/<code>team_id</code>. (#889) — thanks @roshanasingh4.</li>
<li>Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.</li>
<li>Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.</li>
<li>Telegram: honor <code>channels.telegram.timeoutSeconds</code> for grammY API requests. (#863) — thanks @Snaver.</li>
<li>Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).</li>
<li>Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.</li>
<li>Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.</li>
<li>Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.</li>
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
<li>Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.</li>
<li>iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)</li>
<li>WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.</li>
<li>WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.</li>
<li>WhatsApp: harden owner command auth.</li>
<li>Auto-reply: treat trailing <code>NO_REPLY</code> tokens as silent replies.</li>
</ul>
<h4>Config / Doctor / Packaging</h4>
<ul>
<li>Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).</li>
<li>Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).</li>
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
</item>
</channel> </channel>
</rss> </rss>
@@ -529,7 +529,7 @@ class NodeRuntime(context: Context) {
caps = buildCapabilities(), caps = buildCapabilities(),
commands = buildInvokeCommands(), commands = buildInvokeCommands(),
permissions = emptyMap(), permissions = emptyMap(),
client = buildClientInfo(clientId = "node-host", clientMode = "node"), client = buildClientInfo(clientId = "clawdbot-android", clientMode = "node"),
userAgent = buildUserAgent(), userAgent = buildUserAgent(),
) )
} }
@@ -132,6 +132,12 @@ final class TalkModeManager: NSObject {
} }
private func startRecognition() throws { private func startRecognition() throws {
#if targetEnvironment(simulator)
throw NSError(domain: "TalkMode", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
])
#endif
self.stopRecognition() self.stopRecognition()
self.speechRecognizer = SFSpeechRecognizer() self.speechRecognizer = SFSpeechRecognizer()
guard let recognizer = self.speechRecognizer else { guard let recognizer = self.speechRecognizer else {
@@ -146,6 +152,11 @@ final class TalkModeManager: NSObject {
let input = self.audioEngine.inputNode let input = self.audioEngine.inputNode
let format = input.outputFormat(forBus: 0) let format = input.outputFormat(forBus: 0)
guard format.sampleRate > 0, format.channelCount > 0 else {
throw NSError(domain: "TalkMode", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Invalid audio input format",
])
}
input.removeTap(onBus: 0) input.removeTap(onBus: 0)
let tapBlock = Self.makeAudioTapAppendCallback(request: request) let tapBlock = Self.makeAudioTapAppendCallback(request: request)
input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock) input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock)
@@ -276,12 +276,13 @@ enum ExecApprovalsStore {
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines) ? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
: "default" : "default"
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent() let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
let resolvedAgent = ExecApprovalsResolvedDefaults( let resolvedAgent = ExecApprovalsResolvedDefaults(
security: agentEntry.security ?? resolvedDefaults.security, security: agentEntry.security ?? wildcardEntry.security ?? resolvedDefaults.security,
ask: agentEntry.ask ?? resolvedDefaults.ask, ask: agentEntry.ask ?? wildcardEntry.ask ?? resolvedDefaults.ask,
askFallback: agentEntry.askFallback ?? resolvedDefaults.askFallback, askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback ?? resolvedDefaults.askFallback,
autoAllowSkills: agentEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills) autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills)
let allowlist = (agentEntry.allowlist ?? []) let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
.map { entry in .map { entry in
ExecAllowlistEntry( ExecAllowlistEntry(
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines), pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
@@ -178,7 +178,7 @@ final class TailscaleService {
} }
} }
private static func isTailnetIPv4(_ address: String) -> Bool { private nonisolated static func isTailnetIPv4(_ address: String) -> Bool {
let parts = address.split(separator: ".") let parts = address.split(separator: ".")
guard parts.count == 4 else { return false } guard parts.count == 4 else { return false }
let octets = parts.compactMap { Int($0) } let octets = parts.compactMap { Int($0) }
@@ -188,7 +188,7 @@ final class TailscaleService {
return a == 100 && b >= 64 && b <= 127 return a == 100 && b >= 64 && b <= 127
} }
private static func detectTailnetIPv4() -> String? { private nonisolated static func detectTailnetIPv4() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>? var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
defer { freeifaddrs(addrList) } defer { freeifaddrs(addrList) }
@@ -11,35 +11,6 @@ private struct NodeInvokeRequestPayload: Codable, Sendable {
var idempotencyKey: String? var idempotencyKey: String?
} }
// Ensures the timeout can win even if the invoke task never completes.
private actor InvokeTimeoutRace {
private var finished = false
private let continuation: CheckedContinuation<BridgeInvokeResponse, Never>
private var invokeTask: Task<Void, Never>?
private var timeoutTask: Task<Void, Never>?
init(continuation: CheckedContinuation<BridgeInvokeResponse, Never>) {
self.continuation = continuation
}
func registerTasks(invoke: Task<Void, Never>, timeout: Task<Void, Never>) {
self.invokeTask = invoke
self.timeoutTask = timeout
if finished {
invoke.cancel()
timeout.cancel()
}
}
func finish(_ response: BridgeInvokeResponse) {
guard !finished else { return }
finished = true
continuation.resume(returning: response)
invokeTask?.cancel()
timeoutTask?.cancel()
}
}
public actor GatewayNodeSession { public actor GatewayNodeSession {
private let logger = Logger(subsystem: "com.clawdbot", category: "node.gateway") private let logger = Logger(subsystem: "com.clawdbot", category: "node.gateway")
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
@@ -63,32 +34,22 @@ public actor GatewayNodeSession {
return await onInvoke(request) return await onInvoke(request)
} }
let cappedTimeout = min(timeout, Int(UInt64.max / 1_000_000)) return await withTaskGroup(of: BridgeInvokeResponse.self) { group in
let timeoutResponse = BridgeInvokeResponse( group.addTask { await onInvoke(request) }
id: request.id, group.addTask {
ok: false, try? await Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000)
error: ClawdbotNodeError( return BridgeInvokeResponse(
code: .unavailable, id: request.id,
message: "node invoke timed out") ok: false,
) error: ClawdbotNodeError(
code: .unavailable,
message: "node invoke timed out")
)
}
return await withCheckedContinuation { continuation in let first = await group.next()!
let race = InvokeTimeoutRace(continuation: continuation) group.cancelAll()
let invokeTask = Task { return first
let response = await onInvoke(request)
await race.finish(response)
}
let timeoutTask = Task {
do {
try await Task.sleep(nanoseconds: UInt64(cappedTimeout) * 1_000_000)
} catch {
return
}
await race.finish(timeoutResponse)
}
Task {
await race.registerTasks(invoke: invokeTask, timeout: timeoutTask)
}
} }
} }
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:] private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
@@ -38,28 +38,6 @@ struct GatewayNodeSessionTests {
#expect(response.error?.message.contains("timed out") == true) #expect(response.error?.message.contains("timed out") == true)
} }
@Test
func invokeWithTimeoutReturnsWhenHandlerNeverCompletes() async {
let request = BridgeInvokeRequest(id: "stall", command: "x", paramsJSON: nil)
let response = try? await AsyncTimeout.withTimeoutMs(
timeoutMs: 200,
onTimeout: { NSError(domain: "GatewayNodeSessionTests", code: 1) },
operation: {
await GatewayNodeSession.invokeWithTimeout(
request: request,
timeoutMs: 10,
onInvoke: { _ in
await withCheckedContinuation { _ in }
}
)
}
)
#expect(response != nil)
#expect(response?.ok == false)
#expect(response?.error?.code == .unavailable)
}
@Test @Test
func invokeWithTimeoutZeroDisablesTimeout() async { func invokeWithTimeoutZeroDisablesTimeout() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil) let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
+6 -2
View File
@@ -7,8 +7,8 @@ read_when:
# `clawdbot approvals` # `clawdbot approvals`
Manage exec approvals for the **gateway host** or a **node host**. Manage exec approvals for the **local host**, **gateway host**, or a **node host**.
By default, commands target the gateway. Use `--node` to edit a node’s approvals. By default, commands target the local approvals file on disk. Use `--gateway` to target the gateway, or `--node` to target a specific node.
Related: Related:
- Exec approvals: [Exec approvals](/tools/exec-approvals) - Exec approvals: [Exec approvals](/tools/exec-approvals)
@@ -19,6 +19,7 @@ Related:
```bash ```bash
clawdbot approvals get clawdbot approvals get
clawdbot approvals get --node <id|name|ip> clawdbot approvals get --node <id|name|ip>
clawdbot approvals get --gateway
``` ```
## Replace approvals from a file ## Replace approvals from a file
@@ -26,6 +27,7 @@ clawdbot approvals get --node <id|name|ip>
```bash ```bash
clawdbot approvals set --file ./exec-approvals.json clawdbot approvals set --file ./exec-approvals.json
clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json
clawdbot approvals set --gateway --file ./exec-approvals.json
``` ```
## Allowlist helpers ## Allowlist helpers
@@ -33,6 +35,7 @@ clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json
```bash ```bash
clawdbot approvals allowlist add "~/Projects/**/bin/rg" clawdbot approvals allowlist add "~/Projects/**/bin/rg"
clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime" clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"
clawdbot approvals allowlist add --agent "*" "/usr/bin/uname"
clawdbot approvals allowlist remove "~/Projects/**/bin/rg" clawdbot approvals allowlist remove "~/Projects/**/bin/rg"
``` ```
@@ -40,5 +43,6 @@ clawdbot approvals allowlist remove "~/Projects/**/bin/rg"
## Notes ## Notes
- `--node` uses the same resolver as `clawdbot nodes` (id, name, ip, or id prefix). - `--node` uses the same resolver as `clawdbot nodes` (id, name, ip, or id prefix).
- `--agent` defaults to `"*"`, which applies to all agents.
- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host). - The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host).
- Approvals files are stored per host at `~/.clawdbot/exec-approvals.json`. - Approvals files are stored per host at `~/.clawdbot/exec-approvals.json`.
+3
View File
@@ -8,6 +8,9 @@ read_when:
Interactive prompt to set up credentials, devices, and agent defaults. Interactive prompt to set up credentials, devices, and agent defaults.
Note: The **Model** section now includes a multi-select for the
`agents.defaults.models` allowlist (what shows up in `/model` and the model picker).
Tip: `clawdbot config` without a subcommand opens the same wizard. Use Tip: `clawdbot config` without a subcommand opens the same wizard. Use
`clawdbot config get|set|unset` for non-interactive edits. `clawdbot config get|set|unset` for non-interactive edits.
+1 -1
View File
@@ -9,7 +9,7 @@ read_when:
Manage the Gateway daemon (background service). Manage the Gateway daemon (background service).
Note: `clawdbot service gateway …` is the preferred surface; `daemon` remains Note: `clawdbot daemon …` is the preferred surface for Gateway service management; `daemon` remains
as a legacy alias for compatibility. as a legacy alias for compatibility.
Related: Related:
+8 -27
View File
@@ -29,7 +29,6 @@ This page describes the current CLI behavior. If commands change, update this do
- [`sessions`](/cli/sessions) - [`sessions`](/cli/sessions)
- [`gateway`](/cli/gateway) - [`gateway`](/cli/gateway)
- [`daemon`](/cli/daemon) - [`daemon`](/cli/daemon)
- [`service`](/cli/service)
- [`logs`](/cli/logs) - [`logs`](/cli/logs)
- [`models`](/cli/models) - [`models`](/cli/models)
- [`memory`](/cli/memory) - [`memory`](/cli/memory)
@@ -146,21 +145,6 @@ clawdbot [--dev] [--profile <name>] <command>
start start
stop stop
restart restart
service
gateway
status
install
uninstall
start
stop
restart
node
status
install
uninstall
start
stop
restart
logs logs
models models
list list
@@ -544,7 +528,7 @@ Options:
- `--debug` (alias for `--verbose`) - `--debug` (alias for `--verbose`)
Notes: Notes:
- Overview includes Gateway + Node service status when available. - Overview includes Gateway + node host service status when available.
### Usage tracking ### Usage tracking
Clawdbot can surface provider usage/quota when OAuth/API creds are available. Clawdbot can surface provider usage/quota when OAuth/API creds are available.
@@ -806,16 +790,13 @@ All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
[`clawdbot node`](/cli/node). [`clawdbot node`](/cli/node).
Subcommands: Subcommands:
- `node start --host <gateway-host> --port 18790` - `node run --host <gateway-host> --port 18790`
- `node service status` - `node status`
- `node service install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]` - `node install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
- `node service uninstall` - `node uninstall`
- `node service start` - `node run`
- `node service stop` - `node stop`
- `node service restart` - `node restart`
Legacy alias:
- `node daemon …` (same as `node service …`)
## Nodes ## Nodes
+7 -17
View File
@@ -23,10 +23,10 @@ Common use cases:
Execution is still guarded by **exec approvals** and per‑agent allowlists on the Execution is still guarded by **exec approvals** and per‑agent allowlists on the
node host, so you can keep command access scoped and explicit. node host, so you can keep command access scoped and explicit.
## Start (foreground) ## Run (foreground)
```bash ```bash
clawdbot node start --host <gateway-host> --port 18790 clawdbot node run --host <gateway-host> --port 18790
``` ```
Options: Options:
@@ -42,9 +42,7 @@ Options:
Install a headless node host as a user service. Install a headless node host as a user service.
```bash ```bash
clawdbot node service install --host <gateway-host> --port 18790 clawdbot node install --host <gateway-host> --port 18790
# or
clawdbot service node install --host <gateway-host> --port 18790
``` ```
Options: Options:
@@ -61,18 +59,10 @@ Manage the service:
```bash ```bash
clawdbot node status clawdbot node status
clawdbot service node status clawdbot node run
clawdbot node service status clawdbot node stop
clawdbot node service start clawdbot node restart
clawdbot node service stop clawdbot node uninstall
clawdbot node service restart
clawdbot node service uninstall
```
Legacy alias:
```bash
clawdbot node daemon status
``` ```
## Pairing ## Pairing
-51
View File
@@ -1,51 +0,0 @@
---
summary: "CLI reference for `clawdbot service` (manage gateway + node services)"
read_when:
- You want to manage Gateway or node services cross-platform
- You want a single surface for start/stop/install/uninstall
---
# `clawdbot service`
Manage the **Gateway** service and **node host** services.
Related:
- Gateway daemon (legacy alias): [Daemon](/cli/daemon)
- Node host: [Node](/cli/node)
## Gateway service
```bash
clawdbot service gateway status
clawdbot service gateway install --port 18789
clawdbot service gateway start
clawdbot service gateway stop
clawdbot service gateway restart
clawdbot service gateway uninstall
```
Notes:
- `service gateway status` supports `--json` and `--deep` for system checks.
- `service gateway install` supports `--runtime node|bun` and `--token`.
## Node host service
```bash
clawdbot service node status
clawdbot service node install --host <gateway-host> --port 18790
clawdbot service node start
clawdbot service node stop
clawdbot service node restart
clawdbot service node uninstall
```
Notes:
- `service node install` supports `--runtime node|bun`, `--node-id`, `--display-name`,
and TLS options (`--tls`, `--tls-fingerprint`).
## Aliases
- `clawdbot daemon …` → `clawdbot service gateway …`
- `clawdbot node service …` → `clawdbot service node …`
- `clawdbot node status` → `clawdbot service node status`
- `clawdbot node daemon …` → `clawdbot service node …` (legacy)
+1 -1
View File
@@ -19,6 +19,6 @@ clawdbot status --usage
Notes: Notes:
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal). - `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
- Output includes per-agent session stores when multiple agents are configured. - Output includes per-agent session stores when multiple agents are configured.
- Overview includes Gateway + Node service install/runtime status when available. - Overview includes Gateway + node host service install/runtime status when available.
- Overview includes update channel + git SHA (for source checkouts). - Overview includes update channel + git SHA (for source checkouts).
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)). - Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
+12 -3
View File
@@ -7,9 +7,9 @@ read_when:
# `clawdbot update` # `clawdbot update`
Safely update a **source checkout** (git install) of Clawdbot. Safely update Clawdbot and switch between stable/beta/dev channels.
If you installed via **npm/pnpm** (global install, no git metadata), use the package manager flow in [Updating](/install/updating). If you installed via **npm/pnpm** (global install, no git metadata), updates happen via the package manager flow in [Updating](/install/updating).
## Usage ## Usage
@@ -48,7 +48,16 @@ Options:
- `--json`: print machine-readable status JSON. - `--json`: print machine-readable status JSON.
- `--timeout <seconds>`: timeout for checks (default is 3s). - `--timeout <seconds>`: timeout for checks (default is 3s).
## What it does (git checkout) ## What it does
When you switch channels explicitly (`--channel ...`), Clawdbot also keeps the
install method aligned:
- `dev` → ensures a git checkout (default: `~/clawdbot`, override with `CLAWDBOT_GIT_DIR`),
updates it, and installs the global CLI from that checkout.
- `stable`/`beta` → installs from npm using the matching dist-tag.
## Git checkout flow
Channels: Channels:
+20 -2
View File
@@ -194,7 +194,7 @@ Local mode:
- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`). - File type: Markdown only (`MEMORY.md`, `memory/**/*.md`).
- Index storage: per-agent SQLite at `~/.clawdbot/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token). - Index storage: per-agent SQLite at `~/.clawdbot/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync runs on session start, on first search when dirty, and optionally on an interval. - Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync is scheduled on session start, on search, or on an interval and runs asynchronously. Session transcripts use delta thresholds to trigger background sync.
- Reindex triggers: the index stores the embedding **provider/model + endpoint fingerprint + chunking params**. If any of those change, Clawdbot automatically resets and reindexes the entire store. - Reindex triggers: the index stores the embedding **provider/model + endpoint fingerprint + chunking params**. If any of those change, Clawdbot automatically resets and reindexes the entire store.
### Hybrid search (BM25 + vector) ### Hybrid search (BM25 + vector)
@@ -299,11 +299,29 @@ agents: {
Notes: Notes:
- Session indexing is **opt-in** (off by default). - Session indexing is **opt-in** (off by default).
- Session updates are debounced and indexed lazily on the next `memory_search` (or manual `clawdbot memory index`). - Session updates are debounced and **indexed asynchronously** once they cross delta thresholds (best-effort).
- `memory_search` never blocks on indexing; results can be slightly stale until background sync finishes.
- Results still include snippets only; `memory_get` remains limited to memory files. - Results still include snippets only; `memory_get` remains limited to memory files.
- Session indexing is isolated per agent (only that agent’s session logs are indexed). - Session indexing is isolated per agent (only that agent’s session logs are indexed).
- Session logs live on disk (`~/.clawdbot/agents/<agentId>/sessions/*.jsonl`). Any process/user with filesystem access can read them, so treat disk access as the trust boundary. For stricter isolation, run agents under separate OS users or hosts. - Session logs live on disk (`~/.clawdbot/agents/<agentId>/sessions/*.jsonl`). Any process/user with filesystem access can read them, so treat disk access as the trust boundary. For stricter isolation, run agents under separate OS users or hosts.
Delta thresholds (defaults shown):
```json5
agents: {
defaults: {
memorySearch: {
sync: {
sessions: {
deltaBytes: 100000, // ~100 KB
deltaMessages: 50 // JSONL lines
}
}
}
}
}
```
### SQLite vector acceleration (sqlite-vec) ### SQLite vector acceleration (sqlite-vec)
When the sqlite-vec extension is available, Clawdbot stores embeddings in a When the sqlite-vec extension is available, Clawdbot stores embeddings in a
+4 -1
View File
@@ -24,7 +24,7 @@ Unknown keys, malformed types, or invalid values cause the Gateway to **refuse t
When validation fails: When validation fails:
- The Gateway does not boot. - The Gateway does not boot.
- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot service`, `clawdbot help`). - Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot daemon`, `clawdbot help`).
- Run `clawdbot doctor` to see the exact issues. - Run `clawdbot doctor` to see the exact issues.
- Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs. - Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs.
@@ -3022,6 +3022,9 @@ Template placeholders are expanded in `tools.media.*.models[].args` and `tools.m
| `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per channel) | | `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per channel) |
| `{{To}}` | Destination identifier | | `{{To}}` | Destination identifier |
| `{{MessageSid}}` | Channel message id (when available) | | `{{MessageSid}}` | Channel message id (when available) |
| `{{MessageSidFull}}` | Provider-specific full message id when `MessageSid` is shortened |
| `{{ReplyToId}}` | Reply-to message id (when available) |
| `{{ReplyToIdFull}}` | Provider-specific full reply-to id when `ReplyToId` is shortened |
| `{{SessionId}}` | Current session UUID | | `{{SessionId}}` | Current session UUID |
| `{{IsNewSession}}` | `"true"` when a new session was created | | `{{IsNewSession}}` | `"true"` when a new session was created |
| `{{MediaUrl}}` | Inbound media pseudo-URL (if present) | | `{{MediaUrl}}` | Inbound media pseudo-URL (if present) |
+1 -1
View File
@@ -354,7 +354,7 @@ clawdbot doctor --fix
Notes: Notes:
- `clawdbot doctor` reports every invalid entry. - `clawdbot doctor` reports every invalid entry.
- `clawdbot doctor --fix` applies migrations/repairs and rewrites the config. - `clawdbot doctor --fix` applies migrations/repairs and rewrites the config.
- Diagnostic commands like `clawdbot logs`, `clawdbot health`, `clawdbot status`, and `clawdbot service` still run even if the config is invalid. - Diagnostic commands like `clawdbot logs`, `clawdbot health`, `clawdbot status`, and `clawdbot daemon` still run even if the config is invalid.
### “All models failed” — what should I check first? ### “All models failed” — what should I check first?
+8 -1
View File
@@ -7,7 +7,7 @@ read_when:
# Development channels # Development channels
Last updated: 2026-01-20 Last updated: 2026-01-21
Clawdbot ships three update channels: Clawdbot ships three update channels:
@@ -38,6 +38,13 @@ clawdbot update --channel dev
This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`). This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`).
When you **explicitly** switch channels with `--channel`, Clawdbot also aligns
the install method:
- `dev` ensures a git checkout (default `~/clawdbot`, override with `CLAWDBOT_GIT_DIR`),
updates it, and installs the global CLI from that checkout.
- `stable`/`beta` installs from npm using the matching dist-tag.
Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one. Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one.
## Plugins and channels ## Plugins and channels
+5 -5
View File
@@ -50,14 +50,14 @@ forwards `exec` calls to the **node host** when `host=node` is selected.
On the node machine: On the node machine:
```bash ```bash
clawdbot node start --host <gateway-host> --port 18789 --display-name "Build Node" clawdbot node run --host <gateway-host> --port 18789 --display-name "Build Node"
``` ```
### Start a node host (service) ### Start a node host (service)
```bash ```bash
clawdbot node service install --host <gateway-host> --port 18789 --display-name "Build Node" clawdbot node install --host <gateway-host> --port 18789 --display-name "Build Node"
clawdbot node service start clawdbot node start
``` ```
### Pair + name ### Pair + name
@@ -71,7 +71,7 @@ clawdbot nodes list
``` ```
Naming options: Naming options:
- `--display-name` on `clawdbot node start/service install` (persists in `~/.clawdbot/node.json` on the node). - `--display-name` on `clawdbot node run` / `clawdbot node install` (persists in `~/.clawdbot/node.json` on the node).
- `clawdbot nodes rename --node <id|name|ip> --name "Build Node"` (gateway override). - `clawdbot nodes rename --node <id|name|ip> --name "Build Node"` (gateway override).
### Allowlist the commands ### Allowlist the commands
@@ -281,7 +281,7 @@ or for running a minimal node alongside a server.
Start it: Start it:
```bash ```bash
clawdbot node start --host <gateway-host> --port 18790 clawdbot node run --host <gateway-host> --port 18790
``` ```
Notes: Notes:
+2 -2
View File
@@ -5,7 +5,7 @@ read_when:
--- ---
# Clawdbot macOS IPC architecture # Clawdbot macOS IPC architecture
**Current model:** a local Unix socket connects the **node service** to the **macOS app** for exec approvals + `system.run`. A `clawdbot-mac` debug CLI exists for discovery/connect checks; agent actions still flow through the Gateway WebSocket and `node.invoke`. UI automation uses PeekabooBridge. **Current model:** a local Unix socket connects the **node host service** to the **macOS app** for exec approvals + `system.run`. A `clawdbot-mac` debug CLI exists for discovery/connect checks; agent actions still flow through the Gateway WebSocket and `node.invoke`. UI automation uses PeekabooBridge.
## Goals ## Goals
- Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript). - Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript).
@@ -18,7 +18,7 @@ read_when:
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`). - Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
### Node service + app IPC ### Node service + app IPC
- A headless node service connects to the Gateway bridge. - A headless node host service connects to the Gateway bridge.
- `system.run` requests are forwarded to the macOS app over a local Unix socket. - `system.run` requests are forwarded to the macOS app over a local Unix socket.
- The app performs the exec in UI context, prompts if needed, and returns output. - The app performs the exec in UI context, prompts if needed, and returns output.
+1 -1
View File
@@ -57,7 +57,7 @@ The macOS app presents itself as a node. Common commands:
The node reports a `permissions` map so agents can decide what’s allowed. The node reports a `permissions` map so agents can decide what’s allowed.
Node service + app IPC: Node service + app IPC:
- When the headless node service is running (remote mode), it connects to the Gateway WS as a node. - When the headless node host service is running (remote mode), it connects to the Gateway WS as a node.
- `system.run` executes in the macOS app (UI/TCC context) over a local Unix socket; prompts + output stay in-app. - `system.run` executes in the macOS app (UI/TCC context) over a local Unix socket; prompts + output stay in-app.
Diagram (SCI): Diagram (SCI):
+1 -1
View File
@@ -30,7 +30,7 @@ read_when:
- **Node identity:** use existing `nodeId`. - **Node identity:** use existing `nodeId`.
- **Socket auth:** Unix socket + token (cross-platform); split later if needed. - **Socket auth:** Unix socket + token (cross-platform); split later if needed.
- **Node host state:** `~/.clawdbot/node.json` (node id + pairing token). - **Node host state:** `~/.clawdbot/node.json` (node id + pairing token).
- **macOS exec host:** run `system.run` inside the macOS app; node service forwards requests over local IPC. - **macOS exec host:** run `system.run` inside the macOS app; node host service forwards requests over local IPC.
- **No XPC helper:** stick to Unix socket + token + peer checks. - **No XPC helper:** stick to Unix socket + token + peer checks.
## Key concepts ## Key concepts
+1 -1
View File
@@ -54,7 +54,7 @@ Allowed (diagnostic-only):
- `clawdbot health` - `clawdbot health`
- `clawdbot help` - `clawdbot help`
- `clawdbot status` - `clawdbot status`
- `clawdbot service` - `clawdbot daemon`
Everything else must hard-fail with: “Config invalid. Run `clawdbot doctor --fix`.” Everything else must hard-fail with: “Config invalid. Run `clawdbot doctor --fix`.”
+1
View File
@@ -26,6 +26,7 @@ When the operator says “release”, immediately do this preflight (no extra qu
2) **Build & artifacts** 2) **Build & artifacts**
- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/clawdbot/clawdbot/blob/main/src/canvas-host/a2ui/a2ui.bundle.js). - [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/clawdbot/clawdbot/blob/main/src/canvas-host/a2ui/a2ui.bundle.js).
- [ ] `pnpm run build` (regenerates `dist/`). - [ ] `pnpm run build` (regenerates `dist/`).
- [ ] Verify npm package `files` includes all required `dist/*` folders (notably `dist/node-host/**` and `dist/acp/**` for headless node + ACP CLI).
- [ ] Confirm `dist/build-info.json` exists and includes the expected `commit` hash (CLI banner uses this for npm installs). - [ ] Confirm `dist/build-info.json` exists and includes the expected `commit` hash (CLI banner uses this for npm installs).
- [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it). - [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it).
+1 -1
View File
@@ -23,7 +23,7 @@ Exec approvals are enforced locally on the execution host:
- **node host** → node runner (macOS companion app or headless node host) - **node host** → node runner (macOS companion app or headless node host)
Planned macOS split: Planned macOS split:
- **node service** forwards `system.run` to the **macOS app** over local IPC. - **node host service** forwards `system.run` to the **macOS app** over local IPC.
- **macOS app** enforces approvals + executes the command in UI context. - **macOS app** enforces approvals + executes the command in UI context.
## Settings and storage ## Settings and storage
+1 -1
View File
@@ -181,7 +181,7 @@ Notes:
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`. - If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`. - `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`.
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op). - `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
- `host=node` can target a macOS companion app or a headless node host (`clawdbot node start`). - `host=node` can target a macOS companion app or a headless node host (`clawdbot node run`).
- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals). - gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals).
### `process` ### `process`
+2
View File
@@ -60,6 +60,7 @@ Text + native (when enabled):
- `/commands` - `/commands`
- `/skill <name> [input]` (run a skill by name) - `/skill <name> [input]` (run a skill by name)
- `/status` (show current status; includes provider usage/quota for the current model provider when available) - `/status` (show current status; includes provider usage/quota for the current model provider when available)
- `/allowlist` (list/add/remove allowlist entries)
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size) - `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/whoami` (show your sender id; alias: `/id`) - `/whoami` (show your sender id; alias: `/id`)
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session) - `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
@@ -93,6 +94,7 @@ Notes:
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`). - Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
- `/new <model>` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body. - `/new <model>` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body.
- For full provider usage breakdown, use `clawdbot status --usage`. - For full provider usage breakdown, use `clawdbot status --usage`.
- `/allowlist add|remove` requires `commands.config=true` and honors channel `configWrites`.
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs. - `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs.
- `/restart` is disabled by default; set `commands.restart: true` to enable it. - `/restart` is disabled by default; set `commands.restart: true` to enable it.
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
+4 -2
View File
@@ -1,10 +1,12 @@
{ {
"name": "@clawdbot/bluebubbles", "name": "@clawdbot/bluebubbles",
"version": "2026.1.15", "version": "2026.1.21-1",
"type": "module", "type": "module",
"description": "Clawdbot BlueBubbles channel plugin", "description": "Clawdbot BlueBubbles channel plugin",
"clawdbot": { "clawdbot": {
"extensions": ["./index.ts"], "extensions": [
"./index.ts"
],
"channel": { "channel": {
"id": "bluebubbles", "id": "bluebubbles",
"label": "BlueBubbles", "label": "BlueBubbles",
+104
View File
@@ -38,6 +38,10 @@ vi.mock("./attachments.js", () => ({
sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }), sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }),
})); }));
vi.mock("./monitor.js", () => ({
resolveBlueBubblesMessageId: vi.fn((id: string) => id),
}));
describe("bluebubblesMessageActions", () => { describe("bluebubblesMessageActions", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -358,6 +362,106 @@ describe("bluebubblesMessageActions", () => {
); );
}); });
it("uses toolContext currentChannelId when no explicit target is provided", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
const { resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15550001111");
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await bluebubblesMessageActions.handleAction({
action: "react",
params: {
emoji: "👍",
messageId: "msg-456",
},
cfg,
accountId: null,
toolContext: {
currentChannelId: "bluebubbles:chat_guid:iMessage;-;+15550001111",
},
});
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
expect.objectContaining({
target: { kind: "chat_guid", chatGuid: "iMessage;-;+15550001111" },
}),
);
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
chatGuid: "iMessage;-;+15550001111",
}),
);
});
it("resolves short messageId before reacting", async () => {
const { resolveBlueBubblesMessageId } = await import("./monitor.js");
const { sendBlueBubblesReaction } = await import("./reactions.js");
vi.mocked(resolveBlueBubblesMessageId).mockReturnValueOnce("resolved-uuid");
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await bluebubblesMessageActions.handleAction({
action: "react",
params: {
emoji: "❤️",
messageId: "1",
chatGuid: "iMessage;-;+15551234567",
},
cfg,
accountId: null,
});
expect(resolveBlueBubblesMessageId).toHaveBeenCalledWith("1", { requireKnownShortId: true });
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
messageGuid: "resolved-uuid",
}),
);
});
it("propagates short-id errors from the resolver", async () => {
const { resolveBlueBubblesMessageId } = await import("./monitor.js");
vi.mocked(resolveBlueBubblesMessageId).mockImplementationOnce(() => {
throw new Error("short id expired");
});
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await expect(
bluebubblesMessageActions.handleAction({
action: "react",
params: {
emoji: "❤️",
messageId: "999",
chatGuid: "iMessage;-;+15551234567",
},
cfg,
accountId: null,
}),
).rejects.toThrow("short id expired");
});
it("accepts message param for edit action", async () => { it("accepts message param for edit action", async () => {
const { editBlueBubblesMessage } = await import("./chat.js"); const { editBlueBubblesMessage } = await import("./chat.js");
+36 -23
View File
@@ -14,6 +14,7 @@ import {
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js"; import { resolveBlueBubblesAccount } from "./accounts.js";
import { resolveBlueBubblesMessageId } from "./monitor.js";
import { isMacOS26OrHigher } from "./probe.js"; import { isMacOS26OrHigher } from "./probe.js";
import { sendBlueBubblesReaction } from "./reactions.js"; import { sendBlueBubblesReaction } from "./reactions.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
@@ -77,7 +78,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined; const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
return { to, accountId }; return { to, accountId };
}, },
handleAction: async ({ action, params, cfg, accountId }) => { handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
const account = resolveBlueBubblesAccount({ const account = resolveBlueBubblesAccount({
cfg: cfg as ClawdbotConfig, cfg: cfg as ClawdbotConfig,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
@@ -86,7 +87,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
const password = account.config.password?.trim(); const password = account.config.password?.trim();
const opts = { cfg: cfg as ClawdbotConfig, accountId: accountId ?? undefined }; const opts = { cfg: cfg as ClawdbotConfig, accountId: accountId ?? undefined };
// Helper to resolve chatGuid from various params // Helper to resolve chatGuid from various params or session context
const resolveChatGuid = async (): Promise<string> => { const resolveChatGuid = async (): Promise<string> => {
const chatGuid = readStringParam(params, "chatGuid"); const chatGuid = readStringParam(params, "chatGuid");
if (chatGuid?.trim()) return chatGuid.trim(); if (chatGuid?.trim()) return chatGuid.trim();
@@ -94,6 +95,8 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
const chatIdentifier = readStringParam(params, "chatIdentifier"); const chatIdentifier = readStringParam(params, "chatIdentifier");
const chatId = readNumberParam(params, "chatId", { integer: true }); const chatId = readNumberParam(params, "chatId", { integer: true });
const to = readStringParam(params, "to"); const to = readStringParam(params, "to");
// Fall back to session context if no explicit target provided
const contextTarget = toolContext?.currentChannelId?.trim();
const target = chatIdentifier?.trim() const target = chatIdentifier?.trim()
? ({ ? ({
@@ -104,7 +107,9 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget) ? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget)
: to : to
? mapTarget(to) ? mapTarget(to)
: null; : contextTarget
? mapTarget(contextTarget)
: null;
if (!target) { if (!target) {
throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`); throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`);
@@ -127,16 +132,18 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
}); });
if (isEmpty && !remove) { if (isEmpty && !remove) {
throw new Error( throw new Error(
"BlueBubbles react requires emoji parameter. Use action=react with emoji=<emoji> and messageId=<message_guid>.", "BlueBubbles react requires emoji parameter. Use action=react with emoji=<emoji> and messageId=<message_id>.",
); );
} }
const messageId = readStringParam(params, "messageId"); const rawMessageId = readStringParam(params, "messageId");
if (!messageId) { if (!rawMessageId) {
throw new Error( throw new Error(
"BlueBubbles react requires messageId parameter (the message GUID to react to). " + "BlueBubbles react requires messageId parameter (the message ID to react to). " +
"Use action=react with messageId=<message_guid>, emoji=<emoji>, and to/chatGuid to identify the chat.", "Use action=react with messageId=<message_id>, emoji=<emoji>, and to/chatGuid to identify the chat.",
); );
} }
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
const partIndex = readNumberParam(params, "partIndex", { integer: true }); const partIndex = readNumberParam(params, "partIndex", { integer: true });
const resolvedChatGuid = await resolveChatGuid(); const resolvedChatGuid = await resolveChatGuid();
@@ -161,20 +168,22 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
"Apple removed the ability to edit iMessages in this version.", "Apple removed the ability to edit iMessages in this version.",
); );
} }
const messageId = readStringParam(params, "messageId"); const rawMessageId = readStringParam(params, "messageId");
const newText = const newText =
readStringParam(params, "text") ?? readStringParam(params, "text") ??
readStringParam(params, "newText") ?? readStringParam(params, "newText") ??
readStringParam(params, "message"); readStringParam(params, "message");
if (!messageId || !newText) { if (!rawMessageId || !newText) {
const missing: string[] = []; const missing: string[] = [];
if (!messageId) missing.push("messageId (the message GUID to edit)"); if (!rawMessageId) missing.push("messageId (the message ID to edit)");
if (!newText) missing.push("text (the new message content)"); if (!newText) missing.push("text (the new message content)");
throw new Error( throw new Error(
`BlueBubbles edit requires: ${missing.join(", ")}. ` + `BlueBubbles edit requires: ${missing.join(", ")}. ` +
`Use action=edit with messageId=<message_guid>, text=<new_content>.`, `Use action=edit with messageId=<message_id>, text=<new_content>.`,
); );
} }
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
const partIndex = readNumberParam(params, "partIndex", { integer: true }); const partIndex = readNumberParam(params, "partIndex", { integer: true });
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage"); const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
@@ -184,18 +193,20 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
backwardsCompatMessage: backwardsCompatMessage ?? undefined, backwardsCompatMessage: backwardsCompatMessage ?? undefined,
}); });
return jsonResult({ ok: true, edited: messageId }); return jsonResult({ ok: true, edited: rawMessageId });
} }
// Handle unsend action // Handle unsend action
if (action === "unsend") { if (action === "unsend") {
const messageId = readStringParam(params, "messageId"); const rawMessageId = readStringParam(params, "messageId");
if (!messageId) { if (!rawMessageId) {
throw new Error( throw new Error(
"BlueBubbles unsend requires messageId parameter (the message GUID to unsend). " + "BlueBubbles unsend requires messageId parameter (the message ID to unsend). " +
"Use action=unsend with messageId=<message_guid>.", "Use action=unsend with messageId=<message_id>.",
); );
} }
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
const partIndex = readNumberParam(params, "partIndex", { integer: true }); const partIndex = readNumberParam(params, "partIndex", { integer: true });
await unsendBlueBubblesMessage(messageId, { await unsendBlueBubblesMessage(messageId, {
@@ -203,24 +214,26 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
partIndex: typeof partIndex === "number" ? partIndex : undefined, partIndex: typeof partIndex === "number" ? partIndex : undefined,
}); });
return jsonResult({ ok: true, unsent: messageId }); return jsonResult({ ok: true, unsent: rawMessageId });
} }
// Handle reply action // Handle reply action
if (action === "reply") { if (action === "reply") {
const messageId = readStringParam(params, "messageId"); const rawMessageId = readStringParam(params, "messageId");
const text = readMessageText(params); const text = readMessageText(params);
const to = readStringParam(params, "to") ?? readStringParam(params, "target"); const to = readStringParam(params, "to") ?? readStringParam(params, "target");
if (!messageId || !text || !to) { if (!rawMessageId || !text || !to) {
const missing: string[] = []; const missing: string[] = [];
if (!messageId) missing.push("messageId (the message GUID to reply to)"); if (!rawMessageId) missing.push("messageId (the message ID to reply to)");
if (!text) missing.push("text or message (the reply message content)"); if (!text) missing.push("text or message (the reply message content)");
if (!to) missing.push("to or target (the chat target)"); if (!to) missing.push("to or target (the chat target)");
throw new Error( throw new Error(
`BlueBubbles reply requires: ${missing.join(", ")}. ` + `BlueBubbles reply requires: ${missing.join(", ")}. ` +
`Use action=reply with messageId=<message_guid>, message=<your reply>, target=<chat_target>.`, `Use action=reply with messageId=<message_id>, message=<your reply>, target=<chat_target>.`,
); );
} }
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
const partIndex = readNumberParam(params, "partIndex", { integer: true }); const partIndex = readNumberParam(params, "partIndex", { integer: true });
const result = await sendMessageBlueBubbles(to, text, { const result = await sendMessageBlueBubbles(to, text, {
@@ -229,7 +242,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined, replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined,
}); });
return jsonResult({ ok: true, messageId: result.messageId, repliedTo: messageId }); return jsonResult({ ok: true, messageId: result.messageId, repliedTo: rawMessageId });
} }
// Handle sendWithEffect action // Handle sendWithEffect action
+7 -2
View File
@@ -20,6 +20,7 @@ import {
resolveDefaultBlueBubblesAccountId, resolveDefaultBlueBubblesAccountId,
} from "./accounts.js"; } from "./accounts.js";
import { BlueBubblesConfigSchema } from "./config-schema.js"; import { BlueBubblesConfigSchema } from "./config-schema.js";
import { resolveBlueBubblesMessageId } from "./monitor.js";
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js"; import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
import { sendMessageBlueBubbles } from "./send.js"; import { sendMessageBlueBubbles } from "./send.js";
import { import {
@@ -65,7 +66,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
threading: { threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({ buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined, currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId, currentThreadTs: context.ReplyToIdFull ?? context.ReplyToId,
hasRepliedRef, hasRepliedRef,
}), }),
}, },
@@ -237,7 +238,11 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
return { ok: true, to: trimmed }; return { ok: true, to: trimmed };
}, },
sendText: async ({ cfg, to, text, accountId, replyToId }) => { sendText: async ({ cfg, to, text, accountId, replyToId }) => {
const replyToMessageGuid = typeof replyToId === "string" ? replyToId.trim() : ""; const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
// Resolve short ID (e.g., "5") to full UUID
const replyToMessageGuid = rawReplyToId
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
: "";
const result = await sendMessageBlueBubbles(to, text, { const result = await sendMessageBlueBubbles(to, text, {
cfg: cfg as ClawdbotConfig, cfg: cfg as ClawdbotConfig,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
+9 -3
View File
@@ -4,8 +4,9 @@ import { fileURLToPath } from "node:url";
import { resolveChannelMediaMaxBytes, type ClawdbotConfig } from "clawdbot/plugin-sdk"; import { resolveChannelMediaMaxBytes, type ClawdbotConfig } from "clawdbot/plugin-sdk";
import { sendBlueBubblesAttachment } from "./attachments.js"; import { sendBlueBubblesAttachment } from "./attachments.js";
import { sendMessageBlueBubbles } from "./send.js"; import { resolveBlueBubblesMessageId } from "./monitor.js";
import { getBlueBubblesRuntime } from "./runtime.js"; import { getBlueBubblesRuntime } from "./runtime.js";
import { sendMessageBlueBubbles } from "./send.js";
const HTTP_URL_RE = /^https?:\/\//i; const HTTP_URL_RE = /^https?:\/\//i;
const MB = 1024 * 1024; const MB = 1024 * 1024;
@@ -134,12 +135,17 @@ export async function sendBlueBubblesMedia(params: {
} }
} }
// Resolve short ID (e.g., "5") to full UUID
const replyToMessageGuid = replyToId?.trim()
? resolveBlueBubblesMessageId(replyToId.trim(), { requireKnownShortId: true })
: undefined;
const attachmentResult = await sendBlueBubblesAttachment({ const attachmentResult = await sendBlueBubblesAttachment({
to, to,
buffer, buffer,
filename: resolvedFilename ?? "attachment", filename: resolvedFilename ?? "attachment",
contentType: resolvedContentType ?? undefined, contentType: resolvedContentType ?? undefined,
replyToMessageGuid: replyToId?.trim() || undefined, replyToMessageGuid,
opts: { opts: {
cfg, cfg,
accountId, accountId,
@@ -151,7 +157,7 @@ export async function sendBlueBubblesMedia(params: {
await sendMessageBlueBubbles(to, trimmedCaption, { await sendMessageBlueBubbles(to, trimmedCaption, {
cfg, cfg,
accountId, accountId,
replyToMessageGuid: replyToId?.trim() || undefined, replyToMessageGuid,
}); });
} }
+265 -2
View File
@@ -6,6 +6,8 @@ import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
import { import {
handleBlueBubblesWebhookRequest, handleBlueBubblesWebhookRequest,
registerBlueBubblesWebhookTarget, registerBlueBubblesWebhookTarget,
resolveBlueBubblesMessageId,
_resetBlueBubblesShortIdState,
} from "./monitor.js"; } from "./monitor.js";
import { setBlueBubblesRuntime } from "./runtime.js"; import { setBlueBubblesRuntime } from "./runtime.js";
import type { ResolvedBlueBubblesAccount } from "./accounts.js"; import type { ResolvedBlueBubblesAccount } from "./accounts.js";
@@ -223,6 +225,8 @@ describe("BlueBubbles webhook monitor", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// Reset short ID state between tests for predictable behavior
_resetBlueBubblesShortIdState();
mockReadAllowFromStore.mockResolvedValue([]); mockReadAllowFromStore.mockResolvedValue([]);
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true }); mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
mockResolveRequireMention.mockReturnValue(false); mockResolveRequireMention.mockReturnValue(false);
@@ -467,6 +471,98 @@ describe("BlueBubbles webhook monitor", () => {
expect(handled).toBe(false); expect(handled).toBe(false);
}); });
it("parses chatId when provided as a string (webhook variant)", async () => {
const { resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(resolveChatGuidForTarget).mockClear();
const account = createMockAccount({ groupPolicy: "open" });
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello from group",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chatId: "123",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
expect.objectContaining({
target: { kind: "chat_id", chatId: 123 },
}),
);
});
it("extracts chatGuid from nested chat object fields (webhook variant)", async () => {
const { sendMessageBlueBubbles, resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(sendMessageBlueBubbles).mockClear();
vi.mocked(resolveChatGuidForTarget).mockClear();
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
});
const account = createMockAccount({ groupPolicy: "open" });
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello from group",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chat: { chatGuid: "iMessage;+;chat123456" },
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
"chat_guid:iMessage;+;chat123456",
expect.any(String),
expect.any(Object),
);
});
}); });
describe("DM pairing behavior vs allowFrom", () => { describe("DM pairing behavior vs allowFrom", () => {
@@ -1075,13 +1171,86 @@ describe("BlueBubbles webhook monitor", () => {
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
// ReplyToId is the full UUID since it wasn't previously cached
expect(callArgs.ctx.ReplyToId).toBe("msg-0"); expect(callArgs.ctx.ReplyToId).toBe("msg-0");
expect(callArgs.ctx.ReplyToBody).toBe("original message"); expect(callArgs.ctx.ReplyToBody).toBe("original message");
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000"); expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
expect(callArgs.ctx.Body).toContain("[Replying to +15550000000 id:msg-0]"); // Body uses just the ID (no sender) for token savings
expect(callArgs.ctx.Body).toContain("[Replying to id:msg-0]");
expect(callArgs.ctx.Body).toContain("original message"); expect(callArgs.ctx.Body).toContain("original message");
}); });
it("hydrates missing reply sender/body from the recent-message cache", async () => {
const account = createMockAccount({ dmPolicy: "open", groupPolicy: "open" });
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const chatGuid = "iMessage;+;chat-reply-cache";
const originalPayload = {
type: "new-message",
data: {
text: "original message (cached)",
handle: { address: "+15550000000" },
isGroup: true,
isFromMe: false,
guid: "cache-msg-0",
chatGuid,
date: Date.now(),
},
};
const originalReq = createMockRequest("POST", "/bluebubbles-webhook", originalPayload);
const originalRes = createMockResponse();
await handleBlueBubblesWebhookRequest(originalReq, originalRes);
await new Promise((resolve) => setTimeout(resolve, 50));
// Only assert the reply message behavior below.
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
const replyPayload = {
type: "new-message",
data: {
text: "replying now",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "cache-msg-1",
chatGuid,
// Only the GUID is provided; sender/body must be hydrated.
replyToMessageGuid: "cache-msg-0",
date: Date.now(),
},
};
const replyReq = createMockRequest("POST", "/bluebubbles-webhook", replyPayload);
const replyRes = createMockResponse();
await handleBlueBubblesWebhookRequest(replyReq, replyRes);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
// ReplyToId uses short ID "1" (first cached message) for token savings
expect(callArgs.ctx.ReplyToId).toBe("1");
expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0");
expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)");
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
// Body uses just the short ID (no sender) for token savings
expect(callArgs.ctx.Body).toContain("[Replying to id:1]");
expect(callArgs.ctx.Body).toContain("original message (cached)");
});
it("falls back to threadOriginatorGuid when reply metadata is absent", async () => { it("falls back to threadOriginatorGuid when reply metadata is absent", async () => {
const account = createMockAccount({ dmPolicy: "open" }); const account = createMockAccount({ dmPolicy: "open" });
const config: ClawdbotConfig = {}; const config: ClawdbotConfig = {};
@@ -1436,8 +1605,9 @@ describe("BlueBubbles webhook monitor", () => {
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
// Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2")
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
"BlueBubbles sent message id: msg-123", 'Assistant sent "replying now" [message_id:2]',
expect.objectContaining({ expect.objectContaining({
sessionKey: "agent:main:bluebubbles:dm:+15551234567", sessionKey: "agent:main:bluebubbles:dm:+15551234567",
}), }),
@@ -1605,6 +1775,99 @@ describe("BlueBubbles webhook monitor", () => {
}); });
}); });
describe("short message ID mapping", () => {
it("assigns sequential short IDs to messages", async () => {
const account = createMockAccount({ dmPolicy: "open" });
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-uuid-12345",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
// MessageSid should be short ID "1" instead of full UUID
expect(callArgs.ctx.MessageSid).toBe("1");
expect(callArgs.ctx.MessageSidFull).toBe("msg-uuid-12345");
});
it("resolves short ID back to UUID", async () => {
const account = createMockAccount({ dmPolicy: "open" });
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-uuid-12345",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50));
// The short ID "1" should resolve back to the full UUID
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
});
it("returns UUID unchanged when not in cache", () => {
expect(resolveBlueBubblesMessageId("msg-not-cached")).toBe("msg-not-cached");
});
it("returns short ID unchanged when numeric but not in cache", () => {
expect(resolveBlueBubblesMessageId("999")).toBe("999");
});
it("throws when numeric short ID is missing and requireKnownShortId is set", () => {
expect(() =>
resolveBlueBubblesMessageId("999", { requireKnownShortId: true }),
).toThrow(/short message id/i);
});
});
describe("fromMe messages", () => { describe("fromMe messages", () => {
it("ignores messages from self (fromMe=true)", async () => { it("ignores messages from self (fromMe=true)", async () => {
const account = createMockAccount(); const account = createMockAccount();
+326 -36
View File
@@ -1,11 +1,11 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { resolveAckReaction } from "clawdbot/plugin-sdk";
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { downloadBlueBubblesAttachment } from "./attachments.js"; import { downloadBlueBubblesAttachment } from "./attachments.js";
import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender, normalizeBlueBubblesHandle } from "./targets.js"; import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender, normalizeBlueBubblesHandle } from "./targets.js";
import { resolveAckReaction } from "../../../src/agents/identity.js";
import { sendBlueBubblesMedia } from "./media-send.js"; import { sendBlueBubblesMedia } from "./media-send.js";
import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js"; import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js";
import type { ResolvedBlueBubblesAccount } from "./accounts.js"; import type { ResolvedBlueBubblesAccount } from "./accounts.js";
@@ -31,6 +31,173 @@ const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
const DEFAULT_TEXT_LIMIT = 4000; const DEFAULT_TEXT_LIMIT = 4000;
const invalidAckReactions = new Set<string>(); const invalidAckReactions = new Set<string>();
const REPLY_CACHE_MAX = 2000;
const REPLY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
type BlueBubblesReplyCacheEntry = {
accountId: string;
messageId: string;
shortId: string;
chatGuid?: string;
chatIdentifier?: string;
chatId?: number;
senderLabel?: string;
body?: string;
timestamp: number;
};
// Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body.
const blueBubblesReplyCacheByMessageId = new Map<string, BlueBubblesReplyCacheEntry>();
// Bidirectional maps for short ID ↔ UUID resolution (token savings optimization)
const blueBubblesShortIdToUuid = new Map<string, string>();
const blueBubblesUuidToShortId = new Map<string, string>();
let blueBubblesShortIdCounter = 0;
function trimOrUndefined(value?: string | null): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function generateShortId(): string {
blueBubblesShortIdCounter += 1;
return String(blueBubblesShortIdCounter);
}
function rememberBlueBubblesReplyCache(
entry: Omit<BlueBubblesReplyCacheEntry, "shortId">,
): BlueBubblesReplyCacheEntry {
const messageId = entry.messageId.trim();
if (!messageId) {
return { ...entry, shortId: "" };
}
// Check if we already have a short ID for this UUID
let shortId = blueBubblesUuidToShortId.get(messageId);
if (!shortId) {
shortId = generateShortId();
blueBubblesShortIdToUuid.set(shortId, messageId);
blueBubblesUuidToShortId.set(messageId, shortId);
}
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, shortId };
// Refresh insertion order.
blueBubblesReplyCacheByMessageId.delete(messageId);
blueBubblesReplyCacheByMessageId.set(messageId, fullEntry);
// Opportunistic prune.
const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
for (const [key, value] of blueBubblesReplyCacheByMessageId) {
if (value.timestamp < cutoff) {
blueBubblesReplyCacheByMessageId.delete(key);
// Clean up short ID mappings for expired entries
if (value.shortId) {
blueBubblesShortIdToUuid.delete(value.shortId);
blueBubblesUuidToShortId.delete(key);
}
continue;
}
break;
}
while (blueBubblesReplyCacheByMessageId.size > REPLY_CACHE_MAX) {
const oldest = blueBubblesReplyCacheByMessageId.keys().next().value as string | undefined;
if (!oldest) break;
const oldEntry = blueBubblesReplyCacheByMessageId.get(oldest);
blueBubblesReplyCacheByMessageId.delete(oldest);
// Clean up short ID mappings for evicted entries
if (oldEntry?.shortId) {
blueBubblesShortIdToUuid.delete(oldEntry.shortId);
blueBubblesUuidToShortId.delete(oldest);
}
}
return fullEntry;
}
/**
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles UUID.
* Returns the input unchanged if it's already a UUID or not found in the mapping.
*/
export function resolveBlueBubblesMessageId(
shortOrUuid: string,
opts?: { requireKnownShortId?: boolean },
): string {
const trimmed = shortOrUuid.trim();
if (!trimmed) return trimmed;
// If it looks like a short ID (numeric), try to resolve it
if (/^\d+$/.test(trimmed)) {
const uuid = blueBubblesShortIdToUuid.get(trimmed);
if (uuid) return uuid;
if (opts?.requireKnownShortId) {
throw new Error(
`BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`,
);
}
}
// Return as-is (either already a UUID or not found)
return trimmed;
}
/**
* Resets the short ID state. Only use in tests.
* @internal
*/
export function _resetBlueBubblesShortIdState(): void {
blueBubblesShortIdToUuid.clear();
blueBubblesUuidToShortId.clear();
blueBubblesReplyCacheByMessageId.clear();
blueBubblesShortIdCounter = 0;
}
/**
* Gets the short ID for a UUID, if one exists.
*/
function getShortIdForUuid(uuid: string): string | undefined {
return blueBubblesUuidToShortId.get(uuid.trim());
}
function resolveReplyContextFromCache(params: {
accountId: string;
replyToId: string;
chatGuid?: string;
chatIdentifier?: string;
chatId?: number;
}): BlueBubblesReplyCacheEntry | null {
const replyToId = params.replyToId.trim();
if (!replyToId) return null;
const cached = blueBubblesReplyCacheByMessageId.get(replyToId);
if (!cached) return null;
if (cached.accountId !== params.accountId) return null;
const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
if (cached.timestamp < cutoff) {
blueBubblesReplyCacheByMessageId.delete(replyToId);
return null;
}
const chatGuid = trimOrUndefined(params.chatGuid);
const chatIdentifier = trimOrUndefined(params.chatIdentifier);
const cachedChatGuid = trimOrUndefined(cached.chatGuid);
const cachedChatIdentifier = trimOrUndefined(cached.chatIdentifier);
const chatId = typeof params.chatId === "number" ? params.chatId : undefined;
const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined;
// Avoid cross-chat collisions if we have identifiers.
if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) return null;
if (!chatGuid && chatIdentifier && cachedChatIdentifier && chatIdentifier !== cachedChatIdentifier) {
return null;
}
if (!chatGuid && !chatIdentifier && chatId && cachedChatId && chatId !== cachedChatId) {
return null;
}
return cached;
}
type BlueBubblesCoreRuntime = ReturnType<typeof getBlueBubblesRuntime>; type BlueBubblesCoreRuntime = ReturnType<typeof getBlueBubblesRuntime>;
function logVerbose(core: BlueBubblesCoreRuntime, runtime: BlueBubblesRuntimeEnv, message: string): void { function logVerbose(core: BlueBubblesCoreRuntime, runtime: BlueBubblesRuntimeEnv, message: string): void {
@@ -217,19 +384,29 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
return ""; return "";
} }
const REPLY_BODY_TRUNCATE_LENGTH = 60;
function formatReplyContext(message: { function formatReplyContext(message: {
replyToId?: string; replyToId?: string;
replyToShortId?: string;
replyToBody?: string; replyToBody?: string;
replyToSender?: string; replyToSender?: string;
}): string | null { }): string | null {
if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null; if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null;
const sender = message.replyToSender?.trim() || "unknown sender"; // Prefer short ID for token savings
const idPart = message.replyToId ? ` id:${message.replyToId}` : ""; const displayId = message.replyToShortId || message.replyToId;
const body = message.replyToBody?.trim(); // Only include sender if we don't have an ID (fallback)
if (!body) { const label = displayId ? `id:${displayId}` : (message.replyToSender?.trim() || "unknown");
return `[Replying to ${sender}${idPart}]\n[/Replying]`; const rawBody = message.replyToBody?.trim();
if (!rawBody) {
return `[Replying to ${label}]\n[/Replying]`;
} }
return `[Replying to ${sender}${idPart}]\n${body}\n[/Replying]`; // Truncate long reply bodies for token savings
const body =
rawBody.length > REPLY_BODY_TRUNCATE_LENGTH
? `${rawBody.slice(0, REPLY_BODY_TRUNCATE_LENGTH)}…`
: rawBody;
return `[Replying to ${label}]\n${body}\n[/Replying]`;
} }
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined { function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
@@ -404,6 +581,15 @@ function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undef
return undefined; return undefined;
} }
function extractChatIdentifierFromChatGuid(chatGuid?: string | null): string | undefined {
const guid = chatGuid?.trim();
if (!guid) return undefined;
const parts = guid.split(";");
if (parts.length < 3) return undefined;
const identifier = parts[2]?.trim();
return identifier || undefined;
}
function formatGroupAllowlistEntry(params: { function formatGroupAllowlistEntry(params: {
chatGuid?: string; chatGuid?: string;
chatId?: number; chatId?: number;
@@ -550,20 +736,31 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
const chatGuid = const chatGuid =
readString(message, "chatGuid") ?? readString(message, "chatGuid") ??
readString(message, "chat_guid") ?? readString(message, "chat_guid") ??
readString(chat, "chatGuid") ??
readString(chat, "chat_guid") ??
readString(chat, "guid") ?? readString(chat, "guid") ??
readString(chatFromList, "chatGuid") ??
readString(chatFromList, "chat_guid") ??
readString(chatFromList, "guid"); readString(chatFromList, "guid");
const chatIdentifier = const chatIdentifier =
readString(message, "chatIdentifier") ?? readString(message, "chatIdentifier") ??
readString(message, "chat_identifier") ?? readString(message, "chat_identifier") ??
readString(chat, "chatIdentifier") ??
readString(chat, "chat_identifier") ??
readString(chat, "identifier") ?? readString(chat, "identifier") ??
readString(chatFromList, "chatIdentifier") ?? readString(chatFromList, "chatIdentifier") ??
readString(chatFromList, "chat_identifier") ?? readString(chatFromList, "chat_identifier") ??
readString(chatFromList, "identifier"); readString(chatFromList, "identifier") ??
extractChatIdentifierFromChatGuid(chatGuid);
const chatId = const chatId =
readNumber(message, "chatId") ?? readNumberLike(message, "chatId") ??
readNumber(message, "chat_id") ?? readNumberLike(message, "chat_id") ??
readNumber(chat, "id") ?? readNumberLike(chat, "chatId") ??
readNumber(chatFromList, "id"); readNumberLike(chat, "chat_id") ??
readNumberLike(chat, "id") ??
readNumberLike(chatFromList, "chatId") ??
readNumberLike(chatFromList, "chat_id") ??
readNumberLike(chatFromList, "id");
const chatName = const chatName =
readString(message, "chatName") ?? readString(message, "chatName") ??
readString(chat, "displayName") ?? readString(chat, "displayName") ??
@@ -679,19 +876,30 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
const chatGuid = const chatGuid =
readString(message, "chatGuid") ?? readString(message, "chatGuid") ??
readString(message, "chat_guid") ?? readString(message, "chat_guid") ??
readString(chat, "chatGuid") ??
readString(chat, "chat_guid") ??
readString(chat, "guid") ?? readString(chat, "guid") ??
readString(chatFromList, "chatGuid") ??
readString(chatFromList, "chat_guid") ??
readString(chatFromList, "guid"); readString(chatFromList, "guid");
const chatIdentifier = const chatIdentifier =
readString(message, "chatIdentifier") ?? readString(message, "chatIdentifier") ??
readString(message, "chat_identifier") ?? readString(message, "chat_identifier") ??
readString(chat, "chatIdentifier") ??
readString(chat, "chat_identifier") ??
readString(chat, "identifier") ?? readString(chat, "identifier") ??
readString(chatFromList, "chatIdentifier") ?? readString(chatFromList, "chatIdentifier") ??
readString(chatFromList, "chat_identifier") ?? readString(chatFromList, "chat_identifier") ??
readString(chatFromList, "identifier"); readString(chatFromList, "identifier") ??
extractChatIdentifierFromChatGuid(chatGuid);
const chatId = const chatId =
readNumberLike(message, "chatId") ?? readNumberLike(message, "chatId") ??
readNumberLike(message, "chat_id") ?? readNumberLike(message, "chat_id") ??
readNumberLike(chat, "chatId") ??
readNumberLike(chat, "chat_id") ??
readNumberLike(chat, "id") ?? readNumberLike(chat, "id") ??
readNumberLike(chatFromList, "chatId") ??
readNumberLike(chatFromList, "chat_id") ??
readNumberLike(chatFromList, "id"); readNumberLike(chatFromList, "id");
const chatName = const chatName =
readString(message, "chatName") ?? readString(message, "chatName") ??
@@ -901,14 +1109,39 @@ async function processMessage(
target: WebhookTarget, target: WebhookTarget,
): Promise<void> { ): Promise<void> {
const { account, config, runtime, core, statusSink } = target; const { account, config, runtime, core, statusSink } = target;
if (message.fromMe) return;
const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid); const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup; const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
const text = message.text.trim(); const text = message.text.trim();
const attachments = message.attachments ?? []; const attachments = message.attachments ?? [];
const placeholder = buildMessagePlaceholder(message); const placeholder = buildMessagePlaceholder(message);
if (!text && !placeholder) { const rawBody = text || placeholder;
const cacheMessageId = message.messageId?.trim();
let messageShortId: string | undefined;
const cacheInboundMessage = () => {
if (!cacheMessageId) return;
const cacheEntry = rememberBlueBubblesReplyCache({
accountId: account.accountId,
messageId: cacheMessageId,
chatGuid: message.chatGuid,
chatIdentifier: message.chatIdentifier,
chatId: message.chatId,
senderLabel: message.fromMe ? "me" : message.senderId,
body: rawBody,
timestamp: message.timestamp ?? Date.now(),
});
messageShortId = cacheEntry.shortId;
};
if (message.fromMe) {
// Cache from-me messages so reply context can resolve sender/body.
cacheInboundMessage();
return;
}
if (!rawBody) {
logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`); logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`);
return; return;
} }
@@ -1148,6 +1381,10 @@ async function processMessage(
return; return;
} }
// Cache allowed inbound messages so later replies can resolve sender/body without
// surfacing dropped content (allowlist/mention/command gating).
cacheInboundMessage();
const baseUrl = account.config.serverUrl?.trim(); const baseUrl = account.config.serverUrl?.trim();
const password = account.config.password?.trim(); const password = account.config.password?.trim();
const maxBytes = const maxBytes =
@@ -1199,12 +1436,42 @@ async function processMessage(
} }
} }
} }
const rawBody = text.trim() || placeholder; let replyToId = message.replyToId;
const replyContext = formatReplyContext(message); let replyToBody = message.replyToBody;
let replyToSender = message.replyToSender;
let replyToShortId: string | undefined;
if (replyToId && (!replyToBody || !replyToSender)) {
const cached = resolveReplyContextFromCache({
accountId: account.accountId,
replyToId,
chatGuid: message.chatGuid,
chatIdentifier: message.chatIdentifier,
chatId: message.chatId,
});
if (cached) {
if (!replyToBody && cached.body) replyToBody = cached.body;
if (!replyToSender && cached.senderLabel) replyToSender = cached.senderLabel;
replyToShortId = cached.shortId;
if (core.logging.shouldLogVerbose()) {
const preview = (cached.body ?? "").replace(/\s+/g, " ").slice(0, 120);
logVerbose(
core,
runtime,
`reply-context cache hit replyToId=${replyToId} sender=${replyToSender ?? ""} body="${preview}"`,
);
}
}
}
// If no cached short ID, try to get one from the UUID directly
if (replyToId && !replyToShortId) {
replyToShortId = getShortIdForUuid(replyToId);
}
const replyContext = formatReplyContext({ replyToId, replyToShortId, replyToBody, replyToSender });
const baseBody = replyContext ? `${rawBody}\n\n${replyContext}` : rawBody; const baseBody = replyContext ? `${rawBody}\n\n${replyContext}` : rawBody;
const fromLabel = isGroup const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`;
? `group:${peerId}`
: message.senderName || `user:${message.senderId}`;
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined; const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
const groupMembers = isGroup const groupMembers = isGroup
? formatGroupMembers({ ? formatGroupMembers({
@@ -1230,12 +1497,12 @@ async function processMessage(
}); });
let chatGuidForActions = chatGuid; let chatGuidForActions = chatGuid;
if (!chatGuidForActions && baseUrl && password) { if (!chatGuidForActions && baseUrl && password) {
const target = const target =
isGroup && (chatId || chatIdentifier) isGroup && (chatId || chatIdentifier)
? chatId ? chatId
? { kind: "chat_id", chatId } ? ({ kind: "chat_id", chatId } as const)
: { kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" } : ({ kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" } as const)
: { kind: "handle", address: message.senderId }; : ({ kind: "handle", address: message.senderId } as const);
if (target.kind !== "chat_identifier" || target.chatIdentifier) { if (target.kind !== "chat_identifier" || target.chatIdentifier) {
chatGuidForActions = chatGuidForActions =
(await resolveChatGuidForTarget({ (await resolveChatGuidForTarget({
@@ -1316,10 +1583,23 @@ async function processMessage(
? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions }) ? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
: message.senderId; : message.senderId;
const maybeEnqueueOutboundMessageId = (messageId?: string) => { const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string) => {
const trimmed = messageId?.trim(); const trimmed = messageId?.trim();
if (!trimmed || trimmed === "ok" || trimmed === "unknown") return; if (!trimmed || trimmed === "ok" || trimmed === "unknown") return;
core.system.enqueueSystemEvent(`BlueBubbles sent message id: ${trimmed}`, { // Cache outbound message to get short ID
const cacheEntry = rememberBlueBubblesReplyCache({
accountId: account.accountId,
messageId: trimmed,
chatGuid: chatGuidForActions ?? chatGuid,
chatIdentifier,
chatId,
senderLabel: "me",
body: snippet ?? "",
timestamp: Date.now(),
});
const displayId = cacheEntry.shortId || trimmed;
const preview = snippet ? ` "${snippet.slice(0, 12)}${snippet.length > 12 ? "…" : ""}"` : "";
core.system.enqueueSystemEvent(`Assistant sent${preview} [message_id:${displayId}]`, {
sessionKey: route.sessionKey, sessionKey: route.sessionKey,
contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`, contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
}); });
@@ -1343,16 +1623,20 @@ async function processMessage(
AccountId: route.accountId, AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct", ChatType: isGroup ? "group" : "direct",
ConversationLabel: fromLabel, ConversationLabel: fromLabel,
ReplyToId: message.replyToId, // Use short ID for token savings (agent can use this to reference the message)
ReplyToBody: message.replyToBody, ReplyToId: replyToShortId || replyToId,
ReplyToSender: message.replyToSender, ReplyToIdFull: replyToId,
ReplyToBody: replyToBody,
ReplyToSender: replyToSender,
GroupSubject: groupSubject, GroupSubject: groupSubject,
GroupMembers: groupMembers, GroupMembers: groupMembers,
SenderName: message.senderName || undefined, SenderName: message.senderName || undefined,
SenderId: message.senderId, SenderId: message.senderId,
Provider: "bluebubbles", Provider: "bluebubbles",
Surface: "bluebubbles", Surface: "bluebubbles",
MessageSid: message.messageId, // Use short ID for token savings (agent can use this to reference the message)
MessageSid: messageShortId || message.messageId,
MessageSidFull: message.messageId,
Timestamp: message.timestamp, Timestamp: message.timestamp,
OriginatingChannel: "bluebubbles", OriginatingChannel: "bluebubbles",
OriginatingTo: `bluebubbles:${outboundTarget}`, OriginatingTo: `bluebubbles:${outboundTarget}`,
@@ -1367,6 +1651,11 @@ async function processMessage(
cfg: config, cfg: config,
dispatcherOptions: { dispatcherOptions: {
deliver: async (payload) => { deliver: async (payload) => {
const rawReplyToId = typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
// Resolve short ID (e.g., "5") to full UUID
const replyToMessageGuid = rawReplyToId
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
: "";
const mediaList = payload.mediaUrls?.length const mediaList = payload.mediaUrls?.length
? payload.mediaUrls ? payload.mediaUrls
: payload.mediaUrl : payload.mediaUrl
@@ -1382,10 +1671,11 @@ async function processMessage(
to: outboundTarget, to: outboundTarget,
mediaUrl, mediaUrl,
caption: caption ?? undefined, caption: caption ?? undefined,
replyToId: payload.replyToId ?? null, replyToId: replyToMessageGuid || null,
accountId: account.accountId, accountId: account.accountId,
}); });
maybeEnqueueOutboundMessageId(result.messageId); const cachedBody = (caption ?? "").trim() || "<media:attachment>";
maybeEnqueueOutboundMessageId(result.messageId, cachedBody);
sentMessage = true; sentMessage = true;
statusSink?.({ lastOutboundAt: Date.now() }); statusSink?.({ lastOutboundAt: Date.now() });
} }
@@ -1400,14 +1690,12 @@ async function processMessage(
if (!chunks.length && payload.text) chunks.push(payload.text); if (!chunks.length && payload.text) chunks.push(payload.text);
if (!chunks.length) return; if (!chunks.length) return;
for (const chunk of chunks) { for (const chunk of chunks) {
const replyToMessageGuid =
typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
const result = await sendMessageBlueBubbles(outboundTarget, chunk, { const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
cfg: config, cfg: config,
accountId: account.accountId, accountId: account.accountId,
replyToMessageGuid: replyToMessageGuid || undefined, replyToMessageGuid: replyToMessageGuid || undefined,
}); });
maybeEnqueueOutboundMessageId(result.messageId); maybeEnqueueOutboundMessageId(result.messageId, chunk);
sentMessage = true; sentMessage = true;
statusSink?.({ lastOutboundAt: Date.now() }); statusSink?.({ lastOutboundAt: Date.now() });
} }
@@ -1541,7 +1829,9 @@ async function processReaction(
const senderLabel = reaction.senderName || reaction.senderId; const senderLabel = reaction.senderName || reaction.senderId;
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : ""; const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${reaction.messageId}`; // Use short ID for token savings
const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${messageDisplayId}`;
core.system.enqueueSystemEvent(text, { core.system.enqueueSystemEvent(text, {
sessionKey: route.sessionKey, sessionKey: route.sessionKey,
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`, contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
+11 -5
View File
@@ -1,11 +1,17 @@
import type { ClawdbotConfig, DmPolicy, WizardPrompter } from "clawdbot/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
import type { import type {
ChannelOnboardingAdapter, ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy, ChannelOnboardingDmPolicy,
} from "../../../src/channels/plugins/onboarding-types.js"; ClawdbotConfig,
import { addWildcardAllowFrom, promptAccountId } from "../../../src/channels/plugins/onboarding/helpers.js"; DmPolicy,
import { formatDocsLink } from "../../../src/terminal/links.js"; WizardPrompter,
} from "clawdbot/plugin-sdk";
import {
DEFAULT_ACCOUNT_ID,
addWildcardAllowFrom,
formatDocsLink,
normalizeAccountId,
promptAccountId,
} from "clawdbot/plugin-sdk";
import { import {
listBlueBubblesAccountIds, listBlueBubblesAccountIds,
resolveBlueBubblesAccount, resolveBlueBubblesAccount,
+70 -1
View File
@@ -20,32 +20,101 @@ const REACTION_TYPES = new Set([
]); ]);
const REACTION_ALIASES = new Map<string, string>([ const REACTION_ALIASES = new Map<string, string>([
// General
["heart", "love"], ["heart", "love"],
["love", "love"],
["❤", "love"],
["❤️", "love"],
["red_heart", "love"],
["thumbs_up", "like"], ["thumbs_up", "like"],
["thumbs-down", "dislike"], ["thumbsup", "like"],
["thumbs-up", "like"],
["thumbsup", "like"],
["like", "like"],
["thumb", "like"],
["ok", "like"],
["thumbs_down", "dislike"], ["thumbs_down", "dislike"],
["thumbsdown", "dislike"],
["thumbs-down", "dislike"],
["dislike", "dislike"],
["boo", "dislike"],
["no", "dislike"],
// Laugh
["haha", "laugh"], ["haha", "laugh"],
["lol", "laugh"], ["lol", "laugh"],
["lmao", "laugh"],
["rofl", "laugh"],
["😂", "laugh"],
["🤣", "laugh"],
["xd", "laugh"],
["laugh", "laugh"],
// Emphasize / exclaim
["emphasis", "emphasize"], ["emphasis", "emphasize"],
["emphasize", "emphasize"],
["exclaim", "emphasize"], ["exclaim", "emphasize"],
["!!", "emphasize"],
["‼", "emphasize"],
["‼️", "emphasize"],
["❗", "emphasize"],
["important", "emphasize"],
["bang", "emphasize"],
// Question
["question", "question"], ["question", "question"],
["?", "question"],
["❓", "question"],
["❔", "question"],
["ask", "question"],
// Apple/Messages names
["loved", "love"],
["liked", "like"],
["disliked", "dislike"],
["laughed", "laugh"],
["emphasized", "emphasize"],
["questioned", "question"],
// Colloquial / informal
["fire", "love"],
["🔥", "love"],
["wow", "emphasize"],
["!", "emphasize"],
// Edge: generic emoji name forms
["heart_eyes", "love"],
["smile", "laugh"],
["smiley", "laugh"],
["happy", "laugh"],
["joy", "laugh"],
]); ]);
const REACTION_EMOJIS = new Map<string, string>([ const REACTION_EMOJIS = new Map<string, string>([
// Love
["❤️", "love"], ["❤️", "love"],
["❤", "love"], ["❤", "love"],
["♥️", "love"], ["♥️", "love"],
["♥", "love"],
["😍", "love"], ["😍", "love"],
["💕", "love"],
// Like
["👍", "like"], ["👍", "like"],
["👌", "like"],
// Dislike
["👎", "dislike"], ["👎", "dislike"],
["🙅", "dislike"],
// Laugh
["😂", "laugh"], ["😂", "laugh"],
["🤣", "laugh"], ["🤣", "laugh"],
["😆", "laugh"], ["😆", "laugh"],
["😁", "laugh"],
["😹", "laugh"],
// Emphasize
["‼️", "emphasize"], ["‼️", "emphasize"],
["‼", "emphasize"], ["‼", "emphasize"],
["!!", "emphasize"],
["❗", "emphasize"], ["❗", "emphasize"],
["❕", "emphasize"],
["!", "emphasize"],
// Question
["❓", "question"], ["❓", "question"],
["❔", "question"], ["❔", "question"],
["?", "question"],
]); ]);
function resolveAccount(params: BlueBubblesReactionOpts) { function resolveAccount(params: BlueBubblesReactionOpts) {
+27
View File
@@ -96,6 +96,33 @@ describe("send", () => {
expect(result).toBe("iMessage;-;chat123"); expect(result).toBe("iMessage;-;chat123");
}); });
it("matches chat_identifier against the 3rd component of chat GUID", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;+;chat660250192681427962",
participants: [],
},
],
}),
});
const target: BlueBubblesSendTarget = {
kind: "chat_identifier",
chatIdentifier: "chat660250192681427962",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("iMessage;+;chat660250192681427962");
});
it("resolves handle target by matching participant", async () => { it("resolves handle target by matching participant", async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
ok: true, ok: true,
+18 -2
View File
@@ -133,6 +133,13 @@ function extractChatId(chat: BlueBubblesChatRecord): number | null {
return null; return null;
} }
function extractChatIdentifierFromChatGuid(chatGuid: string): string | null {
const parts = chatGuid.split(";");
if (parts.length < 3) return null;
const identifier = parts[2]?.trim();
return identifier ? identifier : null;
}
function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] { function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
const raw = const raw =
(Array.isArray(chat.participants) ? chat.participants : null) ?? (Array.isArray(chat.participants) ? chat.participants : null) ??
@@ -223,7 +230,16 @@ export async function resolveChatGuidForTarget(params: {
} }
if (targetChatIdentifier) { if (targetChatIdentifier) {
const guid = extractChatGuid(chat); const guid = extractChatGuid(chat);
if (guid && guid === targetChatIdentifier) return guid; if (guid) {
// Back-compat: some callers might pass a full chat GUID.
if (guid === targetChatIdentifier) return guid;
// Primary match: BlueBubbles `chat_identifier:*` targets correspond to the
// third component of the chat GUID: `service;(+|-) ;identifier`.
const guidIdentifier = extractChatIdentifierFromChatGuid(guid);
if (guidIdentifier && guidIdentifier === targetChatIdentifier) return guid;
}
const identifier = const identifier =
typeof chat.identifier === "string" typeof chat.identifier === "string"
? chat.identifier ? chat.identifier
@@ -232,7 +248,7 @@ export async function resolveChatGuidForTarget(params: {
: typeof chat.chat_identifier === "string" : typeof chat.chat_identifier === "string"
? chat.chat_identifier ? chat.chat_identifier
: ""; : "";
if (identifier && identifier === targetChatIdentifier) return extractChatGuid(chat); if (identifier && identifier === targetChatIdentifier) return guid ?? extractChatGuid(chat);
} }
if (normalizedHandle) { if (normalizedHandle) {
const guid = extractChatGuid(chat); const guid = extractChatGuid(chat);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/copilot-proxy", "name": "@clawdbot/copilot-proxy",
"version": "2026.1.17-1", "version": "2026.1.20-2",
"type": "module", "type": "module",
"description": "Clawdbot Copilot Proxy provider plugin", "description": "Clawdbot Copilot Proxy provider plugin",
"clawdbot": { "clawdbot": {
+4 -2
View File
@@ -1,10 +1,12 @@
{ {
"name": "@clawdbot/diagnostics-otel", "name": "@clawdbot/diagnostics-otel",
"version": "2026.1.17-1", "version": "2026.1.20-2",
"type": "module", "type": "module",
"description": "Clawdbot diagnostics OpenTelemetry exporter", "description": "Clawdbot diagnostics OpenTelemetry exporter",
"clawdbot": { "clawdbot": {
"extensions": ["./index.ts"] "extensions": [
"./index.ts"
]
}, },
"dependencies": { "dependencies": {
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
+4 -2
View File
@@ -1,9 +1,11 @@
{ {
"name": "@clawdbot/discord", "name": "@clawdbot/discord",
"version": "2026.1.17-1", "version": "2026.1.20-2",
"type": "module", "type": "module",
"description": "Clawdbot Discord channel plugin", "description": "Clawdbot Discord channel plugin",
"clawdbot": { "clawdbot": {
"extensions": ["./index.ts"] "extensions": [
"./index.ts"
]
} }
} }
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/google-antigravity-auth", "name": "@clawdbot/google-antigravity-auth",
"version": "2026.1.17-1", "version": "2026.1.20-2",
"type": "module", "type": "module",
"description": "Clawdbot Google Antigravity OAuth provider plugin", "description": "Clawdbot Google Antigravity OAuth provider plugin",
"clawdbot": { "clawdbot": {
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/google-gemini-cli-auth", "name": "@clawdbot/google-gemini-cli-auth",
"version": "2026.1.17-1", "version": "2026.1.20-2",
"type": "module", "type": "module",
"description": "Clawdbot Gemini CLI OAuth provider plugin", "description": "Clawdbot Gemini CLI OAuth provider plugin",
"clawdbot": { "clawdbot": {
+4 -2
View File
@@ -1,9 +1,11 @@
{ {
"name": "@clawdbot/imessage", "name": "@clawdbot/imessage",
"version": "2026.1.17-1", "version": "2026.1.20-2",
"type": "module", "type": "module",
"description": "Clawdbot iMessage channel plugin", "description": "Clawdbot iMessage channel plugin",
"clawdbot": { "clawdbot": {
"extensions": ["./index.ts"] "extensions": [
"./index.ts"
]
} }
} }
+10
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## 2026.1.20-2
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.20
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.17-1 ## 2026.1.17-1
### Changes ### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/matrix", "name": "@clawdbot/matrix",
"version": "2026.1.17-1", "version": "2026.1.20-2",
"type": "module", "type": "module",
"description": "Clawdbot Matrix channel plugin", "description": "Clawdbot Matrix channel plugin",
"clawdbot": { "clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/memory-core", "name": "@clawdbot/memory-core",
"version": "2026.1.17-1", "version": "2026.1.20-2",
"type": "module", "type": "module",
"description": "Clawdbot core memory search plugin", "description": "Clawdbot core memory search plugin",
"clawdbot": { "clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/memory-lancedb", "name": "@clawdbot/memory-lancedb",
"version": "0.0.1", "version": "2026.1.20-2",
"type": "module", "type": "module",
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture", "description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
"dependencies": { "dependencies": {
+10
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## 2026.1.20-2
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.20
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.17-1 ## 2026.1.17-1
### Changes ### Changes
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/msteams", "name": "@clawdbot/msteams",
"version": "2026.1.17-1", "version": "2026.1.20-2",
"type": "module", "type": "module",
"description": "Clawdbot Microsoft Teams channel plugin", "description": "Clawdbot Microsoft Teams channel plugin",
"clawdbot": { "clawdbot": {
@@ -14,7 +14,9 @@
"docsPath": "/channels/msteams", "docsPath": "/channels/msteams",
"docsLabel": "msteams", "docsLabel": "msteams",
"blurb": "Bot Framework; enterprise support.", "blurb": "Bot Framework; enterprise support.",
"aliases": ["teams"], "aliases": [
"teams"
],
"order": 60 "order": 60
}, },
"install": { "install": {
+8 -3
View File
@@ -1,10 +1,12 @@
{ {
"name": "@clawdbot/nextcloud-talk", "name": "@clawdbot/nextcloud-talk",
"version": "2026.1.17-1", "version": "2026.1.20-2",
"type": "module", "type": "module",
"description": "Clawdbot Nextcloud Talk channel plugin", "description": "Clawdbot Nextcloud Talk channel plugin",
"clawdbot": { "clawdbot": {
"extensions": ["./index.ts"], "extensions": [
"./index.ts"
],
"channel": { "channel": {
"id": "nextcloud-talk", "id": "nextcloud-talk",
"label": "Nextcloud Talk", "label": "Nextcloud Talk",
@@ -12,7 +14,10 @@
"docsPath": "/channels/nextcloud-talk", "docsPath": "/channels/nextcloud-talk",
"docsLabel": "nextcloud-talk", "docsLabel": "nextcloud-talk",
"blurb": "Self-hosted chat via Nextcloud Talk webhook bots.", "blurb": "Self-hosted chat via Nextcloud Talk webhook bots.",
"aliases": ["nc-talk", "nc"], "aliases": [
"nc-talk",
"nc"
],
"order": 65, "order": 65,
"quickstartAllowFrom": true "quickstartAllowFrom": true
}, },
+10
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## 2026.1.20-2
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.20
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.19-1 ## 2026.1.19-1
Initial release. Initial release.
+4 -2
View File
@@ -1,10 +1,12 @@
{ {
"name": "@clawdbot/nostr", "name": "@clawdbot/nostr",
"version": "2026.1.19-1", "version": "2026.1.20-2",
"type": "module", "type": "module",
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs", "description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
"clawdbot": { "clawdbot": {
"extensions": ["./index.ts"], "extensions": [
"./index.ts"
],
"channel": { "channel": {
"id": "nostr", "id": "nostr",
"label": "Nostr", "label": "Nostr",
+4 -2
View File
@@ -1,9 +1,11 @@
{ {
"name": "@clawdbot/signal", "name": "@clawdbot/signal",
"version": "2026.1.17-1", "version": "2026.1.20-2",
"type": "module", "type": "module",
"description": "Clawdbot Signal channel plugin", "description": "Clawdbot Signal channel plugin",
"clawdbot": { "clawdbot": {
"extensions": ["./index.ts"] "extensions": [
"./index.ts"
]
} }
} }
+4 -2
View File
@@ -1,9 +1,11 @@
{ {
"name": "@clawdbot/slack", "name": "@clawdbot/slack",
"version": "2026.1.17-1", "version": "2026.1.20-2",
"type": "module", "type": "module",
"description": "Clawdbot Slack channel plugin", "description": "Clawdbot Slack channel plugin",
"clawdbot": { "clawdbot": {
"extensions": ["./index.ts"] "extensions": [
"./index.ts"
]
} }
} }
+4 -2
View File
@@ -1,9 +1,11 @@
{ {
"name": "@clawdbot/telegram", "name": "@clawdbot/telegram",
"version": "2026.1.17-1", "version": "2026.1.20-2",
"type": "module", "type": "module",
"description": "Clawdbot Telegram channel plugin", "description": "Clawdbot Telegram channel plugin",
"clawdbot": { "clawdbot": {
"extensions": ["./index.ts"] "extensions": [
"./index.ts"
]
} }
} }
+10
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## 2026.1.20-2
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.20
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.17-1 ## 2026.1.17-1
### Changes ### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/voice-call", "name": "@clawdbot/voice-call",
"version": "2026.1.17-1", "version": "2026.1.20-2",
"type": "module", "type": "module",
"description": "Clawdbot voice-call plugin", "description": "Clawdbot voice-call plugin",
"dependencies": { "dependencies": {
+4 -2
View File
@@ -1,9 +1,11 @@
{ {
"name": "@clawdbot/whatsapp", "name": "@clawdbot/whatsapp",
"version": "2026.1.17-1", "version": "2026.1.20-2",
"type": "module", "type": "module",
"description": "Clawdbot WhatsApp channel plugin", "description": "Clawdbot WhatsApp channel plugin",
"clawdbot": { "clawdbot": {
"extensions": ["./index.ts"] "extensions": [
"./index.ts"
]
} }
} }
+10
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## 2026.1.20-2
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.20
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.17-1 ## 2026.1.17-1
### Changes ### Changes
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/zalo", "name": "@clawdbot/zalo",
"version": "2026.1.17-1", "version": "2026.1.20-2",
"type": "module", "type": "module",
"description": "Clawdbot Zalo channel plugin", "description": "Clawdbot Zalo channel plugin",
"clawdbot": { "clawdbot": {
@@ -14,7 +14,9 @@
"docsPath": "/channels/zalo", "docsPath": "/channels/zalo",
"docsLabel": "zalo", "docsLabel": "zalo",
"blurb": "Vietnam-focused messaging platform with Bot API.", "blurb": "Vietnam-focused messaging platform with Bot API.",
"aliases": ["zl"], "aliases": [
"zl"
],
"order": 80, "order": 80,
"quickstartAllowFrom": true "quickstartAllowFrom": true
}, },
+10
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## 2026.1.20-2
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.20
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.17-1 ## 2026.1.17-1
- Initial version with full channel plugin support - Initial version with full channel plugin support
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/zalouser", "name": "@clawdbot/zalouser",
"version": "2026.1.17-1", "version": "2026.1.20-2",
"type": "module", "type": "module",
"description": "Clawdbot Zalo Personal Account plugin via zca-cli", "description": "Clawdbot Zalo Personal Account plugin via zca-cli",
"dependencies": { "dependencies": {
+4 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "clawdbot", "name": "clawdbot",
"version": "2026.1.20", "version": "2026.1.20-2",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent", "description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -13,6 +13,7 @@
"clawdbot": "dist/entry.js" "clawdbot": "dist/entry.js"
}, },
"files": [ "files": [
"dist/acp/**",
"dist/agents/**", "dist/agents/**",
"dist/auto-reply/**", "dist/auto-reply/**",
"dist/browser/**", "dist/browser/**",
@@ -50,6 +51,7 @@
"extensions/**", "extensions/**",
"assets/**", "assets/**",
"skills/**", "skills/**",
"patches/**",
"README.md", "README.md",
"README-header.png", "README-header.png",
"CHANGELOG.md", "CHANGELOG.md",
@@ -64,6 +66,7 @@
"dist/logging/**", "dist/logging/**",
"dist/memory/**", "dist/memory/**",
"dist/markdown/**", "dist/markdown/**",
"dist/node-host/**",
"dist/pairing/**", "dist/pairing/**",
"dist/whatsapp/**" "dist/whatsapp/**"
], ],
+15 -15
View File
@@ -21,7 +21,7 @@ handle_sudo_error() {
echo -e "\n${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo -e "\n${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${YELLOW}⚠️ Password Required for Log Access${NC}" echo -e "${YELLOW}⚠️ Password Required for Log Access${NC}"
echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n" echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
echo -e "vtlog needs to use sudo to show complete log data (Apple hides sensitive info by default)." echo -e "clawlog needs to use sudo to show complete log data (Apple hides sensitive info by default)."
echo -e "\nTo avoid password prompts, configure passwordless sudo for the log command:" echo -e "\nTo avoid password prompts, configure passwordless sudo for the log command:"
echo -e "See: ${BLUE}apple/docs/logging-private-fix.md${NC}\n" echo -e "See: ${BLUE}apple/docs/logging-private-fix.md${NC}\n"
echo -e "Quick fix:" echo -e "Quick fix:"
@@ -51,7 +51,7 @@ show_usage() {
clawlog - Clawdbot Logging Utility clawlog - Clawdbot Logging Utility
USAGE: USAGE:
vtlog [OPTIONS] clawlog [OPTIONS]
DESCRIPTION: DESCRIPTION:
View Clawdbot logs with full details (bypasses Apple's privacy redaction). View Clawdbot logs with full details (bypasses Apple's privacy redaction).
@@ -69,10 +69,10 @@ LOG CATEGORIES (examples):
• shell - ShellExecutor • shell - ShellExecutor
QUICK START: QUICK START:
vtlog -n 100 Show last 100 lines from all components clawlog -n 100 Show last 100 lines from all components
vtlog -f Follow logs in real-time clawlog -f Follow logs in real-time
vtlog -e Show only errors clawlog -e Show only errors
vtlog -c ServerManager Show logs from ServerManager only clawlog -c ServerManager Show logs from ServerManager only
OPTIONS: OPTIONS:
-h, --help Show this help message -h, --help Show this help message
@@ -91,15 +91,15 @@ OPTIONS:
--json Output in JSON format --json Output in JSON format
EXAMPLES: EXAMPLES:
vtlog Show last 50 lines from past 5 minutes (default) clawlog Show last 50 lines from past 5 minutes (default)
vtlog -f Stream logs continuously clawlog -f Stream logs continuously
vtlog -n 100 Show last 100 lines clawlog -n 100 Show last 100 lines
vtlog -e Show only recent errors clawlog -e Show only recent errors
vtlog -l 30m -n 200 Show last 200 lines from past 30 minutes clawlog -l 30m -n 200 Show last 200 lines from past 30 minutes
vtlog -c ServerManager Show recent ServerManager logs clawlog -c ServerManager Show recent ServerManager logs
vtlog -s "fail" Search for "fail" in recent logs clawlog -s "fail" Search for "fail" in recent logs
vtlog --server -e Show recent server errors clawlog --server -e Show recent server errors
vtlog -f -d Stream debug logs continuously clawlog -f -d Stream debug logs continuously
CATEGORIES: CATEGORIES:
Common categories include: Common categories include:
+50 -10
View File
@@ -76,7 +76,7 @@ bash pty:true workdir:~/project background:true command:"codex exec --full-auto
# Monitor progress # Monitor progress
process action:log sessionId:XXX process action:log sessionId:XXX
# Check if done # Check if done
process action:poll sessionId:XXX process action:poll sessionId:XXX
# Send input (if agent asks a question) # Send input (if agent asks a question)
@@ -217,15 +217,55 @@ git worktree remove /tmp/issue-99
## ⚠️ Rules ## ⚠️ Rules
1. **Always use pty:true** — coding agents need a terminal! 1. **Always use pty:true** - coding agents need a terminal!
2. **Respect tool choice** — if user asks for Codex, use Codex. NEVER offer to build it yourself! 2. **Respect tool choice** - if user asks for Codex, use Codex.
3. **Be patient** — don't kill sessions because they're "slow" - Orchestrator mode: do NOT hand-code patches yourself.
4. **Monitor with process:log** — check progress without interfering - If an agent fails/hangs, respawn it or ask the user for direction, but don't silently take over.
5. **--full-auto for building** — auto-approves changes 3. **Be patient** - don't kill sessions because they're "slow"
6. **vanilla for reviewing** — no special flags needed 4. **Monitor with process:log** - check progress without interfering
7. **Parallel is OK** — run many Codex processes at once for batch work 5. **--full-auto for building** - auto-approves changes
8. **NEVER start Codex in ~/clawd/** — it'll read your soul docs and get weird ideas about the org chart! 6. **vanilla for reviewing** - no special flags needed
9. **NEVER checkout branches in ~/Projects/clawdbot/** — that's the LIVE Clawdbot instance! 7. **Parallel is OK** - run many Codex processes at once for batch work
8. **NEVER start Codex in ~/clawd/** - it'll read your soul docs and get weird ideas about the org chart!
9. **NEVER checkout branches in ~/Projects/clawdbot/** - that's the LIVE Clawdbot instance!
---
## Progress Updates (Critical)
When you spawn coding agents in the background, keep the user in the loop.
- Send 1 short message when you start (what's running + where).
- Then only update again when something changes:
- a milestone completes (build finished, tests passed)
- the agent asks a question / needs input
- you hit an error or need user action
- the agent finishes (include what changed + where)
- If you kill a session, immediately say you killed it and why.
This prevents the user from seeing only "Agent failed before reply" and having no idea what happened.
---
## Auto-Notify on Completion
For long-running background tasks, append a wake trigger to your prompt so Clawdbot gets notified immediately when the agent finishes (instead of waiting for the next heartbeat):
```
... your task here.
When completely finished, run this command to notify me:
clawdbot gateway wake --text "Done: [brief summary of what was built]" --mode now
```
**Example:**
```bash
bash pty:true workdir:~/project background:true command:"codex --yolo exec 'Build a REST API for todos.
When completely finished, run: clawdbot gateway wake --text \"Done: Built todos REST API with CRUD endpoints\" --mode now'"
```
This triggers an immediate wake event — Skippy gets pinged in seconds, not 10 minutes.
--- ---
@@ -0,0 +1,61 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { resolveSessionAuthProfileOverride } from "./session-override.js";
async function writeAuthStore(agentDir: string) {
const authPath = path.join(agentDir, "auth-profiles.json");
const payload = {
version: 1,
profiles: {
"zai:work": { type: "api_key", provider: "zai", key: "sk-test" },
},
order: {
zai: ["zai:work"],
},
};
await fs.writeFile(authPath, JSON.stringify(payload), "utf-8");
}
describe("resolveSessionAuthProfileOverride", () => {
it("keeps user override when provider alias differs", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-"));
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
process.env.CLAWDBOT_STATE_DIR = tmpDir;
try {
const agentDir = path.join(tmpDir, "agent");
await fs.mkdir(agentDir, { recursive: true });
await writeAuthStore(agentDir);
const sessionEntry: SessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
authProfileOverride: "zai:work",
authProfileOverrideSource: "user",
};
const sessionStore = { "agent:main:main": sessionEntry };
const resolved = await resolveSessionAuthProfileOverride({
cfg: {} as ClawdbotConfig,
provider: "z.ai",
agentDir,
sessionEntry,
sessionStore,
sessionKey: "agent:main:main",
storePath: undefined,
isNewSession: false,
});
expect(resolved).toBe("zai:work");
expect(sessionEntry.authProfileOverride).toBe("zai:work");
} finally {
if (prevStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
else process.env.CLAWDBOT_STATE_DIR = prevStateDir;
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,139 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { updateSessionStore, type SessionEntry } from "../../config/sessions.js";
import { normalizeProviderId } from "../model-selection.js";
import {
ensureAuthProfileStore,
isProfileInCooldown,
resolveAuthProfileOrder,
} from "../auth-profiles.js";
function isProfileForProvider(params: {
provider: string;
profileId: string;
store: ReturnType<typeof ensureAuthProfileStore>;
}): boolean {
const entry = params.store.profiles[params.profileId];
if (!entry?.provider) return false;
return normalizeProviderId(entry.provider) === normalizeProviderId(params.provider);
}
export async function clearSessionAuthProfileOverride(params: {
sessionEntry: SessionEntry;
sessionStore: Record<string, SessionEntry>;
sessionKey: string;
storePath?: string;
}) {
const { sessionEntry, sessionStore, sessionKey, storePath } = params;
delete sessionEntry.authProfileOverride;
delete sessionEntry.authProfileOverrideSource;
delete sessionEntry.authProfileOverrideCompactionCount;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
}
export async function resolveSessionAuthProfileOverride(params: {
cfg: ClawdbotConfig;
provider: string;
agentDir: string;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
isNewSession: boolean;
}): Promise<string | undefined> {
const {
cfg,
provider,
agentDir,
sessionEntry,
sessionStore,
sessionKey,
storePath,
isNewSession,
} = params;
if (!sessionEntry || !sessionStore || !sessionKey) return sessionEntry?.authProfileOverride;
const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
const order = resolveAuthProfileOrder({ cfg, store, provider });
let current = sessionEntry.authProfileOverride?.trim();
if (current && !store.profiles[current]) {
await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
current = undefined;
}
if (current && !isProfileForProvider({ provider, profileId: current, store })) {
await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
current = undefined;
}
if (current && order.length > 0 && !order.includes(current)) {
await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
current = undefined;
}
if (order.length === 0) return undefined;
const pickFirstAvailable = () =>
order.find((profileId) => !isProfileInCooldown(store, profileId)) ?? order[0];
const pickNextAvailable = (active: string) => {
const startIndex = order.indexOf(active);
if (startIndex < 0) return pickFirstAvailable();
for (let offset = 1; offset <= order.length; offset += 1) {
const candidate = order[(startIndex + offset) % order.length];
if (!isProfileInCooldown(store, candidate)) return candidate;
}
return order[startIndex] ?? order[0];
};
const compactionCount = sessionEntry.compactionCount ?? 0;
const storedCompaction =
typeof sessionEntry.authProfileOverrideCompactionCount === "number"
? sessionEntry.authProfileOverrideCompactionCount
: compactionCount;
const source =
sessionEntry.authProfileOverrideSource ??
(typeof sessionEntry.authProfileOverrideCompactionCount === "number"
? "auto"
: current
? "user"
: undefined);
if (source === "user" && current && !isNewSession) {
return current;
}
let next = current;
if (isNewSession) {
next = current ? pickNextAvailable(current) : pickFirstAvailable();
} else if (current && compactionCount > storedCompaction) {
next = pickNextAvailable(current);
} else if (!current || isProfileInCooldown(store, current)) {
next = pickFirstAvailable();
}
if (!next) return current;
const shouldPersist =
next !== sessionEntry.authProfileOverride ||
sessionEntry.authProfileOverrideSource !== "auto" ||
sessionEntry.authProfileOverrideCompactionCount !== compactionCount;
if (shouldPersist) {
sessionEntry.authProfileOverride = next;
sessionEntry.authProfileOverrideSource = "auto";
sessionEntry.authProfileOverrideCompactionCount = compactionCount;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
}
return next;
}
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveUserPath } from "../utils.js";
import { createCacheTrace } from "./cache-trace.js";
describe("createCacheTrace", () => {
it("returns null when diagnostics cache tracing is disabled", () => {
const trace = createCacheTrace({
cfg: {} as ClawdbotConfig,
env: {},
});
expect(trace).toBeNull();
});
it("honors diagnostics cache trace config and expands file paths", () => {
const lines: string[] = [];
const trace = createCacheTrace({
cfg: {
diagnostics: {
cacheTrace: {
enabled: true,
filePath: "~/.clawdbot/logs/cache-trace.jsonl",
},
},
},
env: {},
writer: {
filePath: "memory",
write: (line) => lines.push(line),
},
});
expect(trace).not.toBeNull();
expect(trace?.filePath).toBe(resolveUserPath("~/.clawdbot/logs/cache-trace.jsonl"));
trace?.recordStage("session:loaded", {
messages: [],
system: "sys",
});
expect(lines.length).toBe(1);
});
it("records empty prompt/system values when enabled", () => {
const lines: string[] = [];
const trace = createCacheTrace({
cfg: {
diagnostics: {
cacheTrace: {
enabled: true,
includePrompt: true,
includeSystem: true,
},
},
},
env: {},
writer: {
filePath: "memory",
write: (line) => lines.push(line),
},
});
trace?.recordStage("prompt:before", { prompt: "", system: "" });
const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record<string, unknown>;
expect(event.prompt).toBe("");
expect(event.system).toBe("");
});
it("respects env overrides for enablement", () => {
const lines: string[] = [];
const trace = createCacheTrace({
cfg: {
diagnostics: {
cacheTrace: {
enabled: true,
},
},
},
env: {
CLAWDBOT_CACHE_TRACE: "0",
},
writer: {
filePath: "memory",
write: (line) => lines.push(line),
},
});
expect(trace).toBeNull();
});
});
+271
View File
@@ -0,0 +1,271 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { AgentMessage, StreamFn } from "@mariozechner/pi-agent-core";
import type { Api, Model } from "@mariozechner/pi-ai";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { parseBooleanValue } from "../utils/boolean.js";
import { resolveUserPath } from "../utils.js";
export type CacheTraceStage =
| "session:loaded"
| "session:sanitized"
| "session:limited"
| "prompt:before"
| "prompt:images"
| "stream:context"
| "session:after";
export type CacheTraceEvent = {
ts: string;
seq: number;
stage: CacheTraceStage;
runId?: string;
sessionId?: string;
sessionKey?: string;
provider?: string;
modelId?: string;
modelApi?: string | null;
workspaceDir?: string;
prompt?: string;
system?: unknown;
options?: Record<string, unknown>;
model?: Record<string, unknown>;
messages?: AgentMessage[];
messageCount?: number;
messageRoles?: Array<string | undefined>;
messageFingerprints?: string[];
messagesDigest?: string;
systemDigest?: string;
note?: string;
error?: string;
};
export type CacheTrace = {
enabled: true;
filePath: string;
recordStage: (stage: CacheTraceStage, payload?: Partial<CacheTraceEvent>) => void;
wrapStreamFn: (streamFn: StreamFn) => StreamFn;
};
type CacheTraceInit = {
cfg?: ClawdbotConfig;
env?: NodeJS.ProcessEnv;
runId?: string;
sessionId?: string;
sessionKey?: string;
provider?: string;
modelId?: string;
modelApi?: string | null;
workspaceDir?: string;
writer?: CacheTraceWriter;
};
type CacheTraceConfig = {
enabled: boolean;
filePath: string;
includeMessages: boolean;
includePrompt: boolean;
includeSystem: boolean;
};
type CacheTraceWriter = {
filePath: string;
write: (line: string) => void;
};
const writers = new Map<string, CacheTraceWriter>();
function resolveCacheTraceConfig(params: CacheTraceInit): CacheTraceConfig {
const env = params.env ?? process.env;
const config = params.cfg?.diagnostics?.cacheTrace;
const envEnabled = parseBooleanValue(env.CLAWDBOT_CACHE_TRACE);
const enabled = envEnabled ?? config?.enabled ?? false;
const fileOverride = config?.filePath?.trim() || env.CLAWDBOT_CACHE_TRACE_FILE?.trim();
const filePath = fileOverride
? resolveUserPath(fileOverride)
: path.join(resolveStateDir(env), "logs", "cache-trace.jsonl");
const includeMessages =
parseBooleanValue(env.CLAWDBOT_CACHE_TRACE_MESSAGES) ?? config?.includeMessages;
const includePrompt = parseBooleanValue(env.CLAWDBOT_CACHE_TRACE_PROMPT) ?? config?.includePrompt;
const includeSystem = parseBooleanValue(env.CLAWDBOT_CACHE_TRACE_SYSTEM) ?? config?.includeSystem;
return {
enabled,
filePath,
includeMessages: includeMessages ?? true,
includePrompt: includePrompt ?? true,
includeSystem: includeSystem ?? true,
};
}
function getWriter(filePath: string): CacheTraceWriter {
const existing = writers.get(filePath);
if (existing) return existing;
const dir = path.dirname(filePath);
const ready = fs.mkdir(dir, { recursive: true }).catch(() => undefined);
let queue = Promise.resolve();
const writer: CacheTraceWriter = {
filePath,
write: (line: string) => {
queue = queue
.then(() => ready)
.then(() => fs.appendFile(filePath, line, "utf8"))
.catch(() => undefined);
},
};
writers.set(filePath, writer);
return writer;
}
function stableStringify(value: unknown): string {
if (value === null || value === undefined) return String(value);
if (typeof value === "number" && !Number.isFinite(value)) return JSON.stringify(String(value));
if (typeof value === "bigint") return JSON.stringify(value.toString());
if (typeof value !== "object") return JSON.stringify(value) ?? "null";
if (value instanceof Error) {
return stableStringify({
name: value.name,
message: value.message,
stack: value.stack,
});
}
if (value instanceof Uint8Array) {
return stableStringify({
type: "Uint8Array",
data: Buffer.from(value).toString("base64"),
});
}
if (Array.isArray(value)) {
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
}
const record = value as Record<string, unknown>;
const keys = Object.keys(record).sort();
const entries = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
return `{${entries.join(",")}}`;
}
function digest(value: unknown): string {
const serialized = stableStringify(value);
return crypto.createHash("sha256").update(serialized).digest("hex");
}
function summarizeMessages(messages: AgentMessage[]): {
messageCount: number;
messageRoles: Array<string | undefined>;
messageFingerprints: string[];
messagesDigest: string;
} {
const messageFingerprints = messages.map((msg) => digest(msg));
return {
messageCount: messages.length,
messageRoles: messages.map((msg) => (msg as { role?: string }).role),
messageFingerprints,
messagesDigest: digest(messageFingerprints.join("|")),
};
}
function safeJsonStringify(value: unknown): string | null {
try {
return JSON.stringify(value, (_key, val) => {
if (typeof val === "bigint") return val.toString();
if (typeof val === "function") return "[Function]";
if (val instanceof Error) {
return { name: val.name, message: val.message, stack: val.stack };
}
if (val instanceof Uint8Array) {
return { type: "Uint8Array", data: Buffer.from(val).toString("base64") };
}
return val;
});
} catch {
return null;
}
}
export function createCacheTrace(params: CacheTraceInit): CacheTrace | null {
const cfg = resolveCacheTraceConfig(params);
if (!cfg.enabled) return null;
const writer = params.writer ?? getWriter(cfg.filePath);
let seq = 0;
const base: Omit<CacheTraceEvent, "ts" | "seq" | "stage"> = {
runId: params.runId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
provider: params.provider,
modelId: params.modelId,
modelApi: params.modelApi,
workspaceDir: params.workspaceDir,
};
const recordStage: CacheTrace["recordStage"] = (stage, payload = {}) => {
const event: CacheTraceEvent = {
...base,
ts: new Date().toISOString(),
seq: (seq += 1),
stage,
};
if (payload.prompt !== undefined && cfg.includePrompt) {
event.prompt = payload.prompt;
}
if (payload.system !== undefined && cfg.includeSystem) {
event.system = payload.system;
event.systemDigest = digest(payload.system);
}
if (payload.options) event.options = payload.options;
if (payload.model) event.model = payload.model;
const messages = payload.messages;
if (Array.isArray(messages)) {
const summary = summarizeMessages(messages);
event.messageCount = summary.messageCount;
event.messageRoles = summary.messageRoles;
event.messageFingerprints = summary.messageFingerprints;
event.messagesDigest = summary.messagesDigest;
if (cfg.includeMessages) {
event.messages = messages;
}
}
if (payload.note) event.note = payload.note;
if (payload.error) event.error = payload.error;
const line = safeJsonStringify(event);
if (!line) return;
writer.write(`${line}\n`);
};
const wrapStreamFn: CacheTrace["wrapStreamFn"] = (streamFn) => {
const wrapped: StreamFn = (model, context, options) => {
recordStage("stream:context", {
model: {
id: (model as Model<Api>)?.id,
provider: (model as Model<Api>)?.provider,
api: (model as Model<Api>)?.api,
},
system: (context as { system?: unknown }).system,
messages: (context as { messages?: AgentMessage[] }).messages ?? [],
options: (options ?? {}) as Record<string, unknown>,
});
return streamFn(model, context, options);
};
return wrapped;
};
return {
enabled: true,
filePath: cfg.filePath,
recordStage,
wrapStreamFn,
};
}
@@ -4,6 +4,9 @@ const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({ vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts), callGateway: (opts: unknown) => callGatewayMock(opts),
})); }));
vi.mock("../plugins/tools.js", () => ({
resolvePluginTools: () => [],
}));
vi.mock("../config/config.js", async (importOriginal) => { vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>(); const actual = await importOriginal<typeof import("../config/config.js")>();
+17
View File
@@ -136,6 +136,23 @@ describe("memory search config", () => {
}); });
}); });
it("defaults session delta thresholds", () => {
const cfg = {
agents: {
defaults: {
memorySearch: {
provider: "openai",
},
},
},
};
const resolved = resolveMemorySearchConfig(cfg, "main");
expect(resolved?.sync.sessions).toEqual({
deltaBytes: 100000,
deltaMessages: 50,
});
});
it("merges remote defaults with agent overrides", () => { it("merges remote defaults with agent overrides", () => {
const cfg = { const cfg = {
agents: { agents: {
+25 -1
View File
@@ -49,6 +49,10 @@ export type ResolvedMemorySearchConfig = {
watch: boolean; watch: boolean;
watchDebounceMs: number; watchDebounceMs: number;
intervalMinutes: number; intervalMinutes: number;
sessions: {
deltaBytes: number;
deltaMessages: number;
};
}; };
query: { query: {
maxResults: number; maxResults: number;
@@ -71,6 +75,8 @@ const DEFAULT_GEMINI_MODEL = "gemini-embedding-001";
const DEFAULT_CHUNK_TOKENS = 400; const DEFAULT_CHUNK_TOKENS = 400;
const DEFAULT_CHUNK_OVERLAP = 80; const DEFAULT_CHUNK_OVERLAP = 80;
const DEFAULT_WATCH_DEBOUNCE_MS = 1500; const DEFAULT_WATCH_DEBOUNCE_MS = 1500;
const DEFAULT_SESSION_DELTA_BYTES = 100_000;
const DEFAULT_SESSION_DELTA_MESSAGES = 50;
const DEFAULT_MAX_RESULTS = 6; const DEFAULT_MAX_RESULTS = 6;
const DEFAULT_MIN_SCORE = 0.35; const DEFAULT_MIN_SCORE = 0.35;
const DEFAULT_HYBRID_ENABLED = true; const DEFAULT_HYBRID_ENABLED = true;
@@ -172,6 +178,16 @@ function mergeConfig(
defaults?.sync?.watchDebounceMs ?? defaults?.sync?.watchDebounceMs ??
DEFAULT_WATCH_DEBOUNCE_MS, DEFAULT_WATCH_DEBOUNCE_MS,
intervalMinutes: overrides?.sync?.intervalMinutes ?? defaults?.sync?.intervalMinutes ?? 0, intervalMinutes: overrides?.sync?.intervalMinutes ?? defaults?.sync?.intervalMinutes ?? 0,
sessions: {
deltaBytes:
overrides?.sync?.sessions?.deltaBytes ??
defaults?.sync?.sessions?.deltaBytes ??
DEFAULT_SESSION_DELTA_BYTES,
deltaMessages:
overrides?.sync?.sessions?.deltaMessages ??
defaults?.sync?.sessions?.deltaMessages ??
DEFAULT_SESSION_DELTA_MESSAGES,
},
}; };
const query = { const query = {
maxResults: overrides?.query?.maxResults ?? defaults?.query?.maxResults ?? DEFAULT_MAX_RESULTS, maxResults: overrides?.query?.maxResults ?? defaults?.query?.maxResults ?? DEFAULT_MAX_RESULTS,
@@ -208,6 +224,8 @@ function mergeConfig(
const normalizedVectorWeight = sum > 0 ? vectorWeight / sum : DEFAULT_HYBRID_VECTOR_WEIGHT; const normalizedVectorWeight = sum > 0 ? vectorWeight / sum : DEFAULT_HYBRID_VECTOR_WEIGHT;
const normalizedTextWeight = sum > 0 ? textWeight / sum : DEFAULT_HYBRID_TEXT_WEIGHT; const normalizedTextWeight = sum > 0 ? textWeight / sum : DEFAULT_HYBRID_TEXT_WEIGHT;
const candidateMultiplier = clampInt(hybrid.candidateMultiplier, 1, 20); const candidateMultiplier = clampInt(hybrid.candidateMultiplier, 1, 20);
const deltaBytes = clampInt(sync.sessions.deltaBytes, 0, Number.MAX_SAFE_INTEGER);
const deltaMessages = clampInt(sync.sessions.deltaMessages, 0, Number.MAX_SAFE_INTEGER);
return { return {
enabled, enabled,
sources, sources,
@@ -221,7 +239,13 @@ function mergeConfig(
local, local,
store, store,
chunking: { tokens: Math.max(1, chunking.tokens), overlap }, chunking: { tokens: Math.max(1, chunking.tokens), overlap },
sync, sync: {
...sync,
sessions: {
deltaBytes,
deltaMessages,
},
},
query: { query: {
...query, ...query,
minScore, minScore,
@@ -92,13 +92,16 @@ const makeConfig = (): ClawdbotConfig =>
}, },
}) satisfies ClawdbotConfig; }) satisfies ClawdbotConfig;
const writeAuthStore = async (agentDir: string) => { const writeAuthStore = async (agentDir: string, opts?: { includeAnthropic?: boolean }) => {
const authPath = path.join(agentDir, "auth-profiles.json"); const authPath = path.join(agentDir, "auth-profiles.json");
const payload = { const payload = {
version: 1, version: 1,
profiles: { profiles: {
"openai:p1": { type: "api_key", provider: "openai", key: "sk-one" }, "openai:p1": { type: "api_key", provider: "openai", key: "sk-one" },
"openai:p2": { type: "api_key", provider: "openai", key: "sk-two" }, "openai:p2": { type: "api_key", provider: "openai", key: "sk-two" },
...(opts?.includeAnthropic
? { "anthropic:default": { type: "api_key", provider: "anthropic", key: "sk-anth" } }
: {}),
}, },
usageStats: { usageStats: {
"openai:p1": { lastUsed: 1 }, "openai:p1": { lastUsed: 1 },
@@ -206,4 +209,43 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
await fs.rm(workspaceDir, { recursive: true, force: true }); await fs.rm(workspaceDir, { recursive: true, force: true });
} }
}); });
it("ignores user-locked profile when provider mismatches", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
try {
await writeAuthStore(agentDir, { includeAnthropic: true });
runEmbeddedAttemptMock.mockResolvedValueOnce(
makeAttempt({
assistantTexts: ["ok"],
lastAssistant: buildAssistant({
stopReason: "stop",
content: [{ type: "text", text: "ok" }],
}),
}),
);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:test:mismatch",
sessionFile: path.join(workspaceDir, "session.jsonl"),
workspaceDir,
agentDir,
config: makeConfig(),
prompt: "hello",
provider: "openai",
model: "mock-1",
authProfileId: "anthropic:default",
authProfileIdSource: "user",
timeoutMs: 5_000,
runId: "run:mismatch",
});
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
}); });
+27 -3
View File
@@ -23,6 +23,7 @@ import {
resolveAuthProfileOrder, resolveAuthProfileOrder,
type ResolvedProviderAuth, type ResolvedProviderAuth,
} from "../model-auth.js"; } from "../model-auth.js";
import { normalizeProviderId } from "../model-selection.js";
import { ensureClawdbotModelsJson } from "../models-config.js"; import { ensureClawdbotModelsJson } from "../models-config.js";
import { import {
classifyFailoverReason, classifyFailoverReason,
@@ -50,6 +51,18 @@ import { describeUnknownError } from "./utils.js";
type ApiKeyInfo = ResolvedProviderAuth; type ApiKeyInfo = ResolvedProviderAuth;
// Avoid Anthropic's refusal test token poisoning session transcripts.
const ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL = "ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL";
const ANTHROPIC_MAGIC_STRING_REPLACEMENT = "ANTHROPIC MAGIC STRING TRIGGER REFUSAL (redacted)";
function scrubAnthropicRefusalMagic(prompt: string): string {
if (!prompt.includes(ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL)) return prompt;
return prompt.replaceAll(
ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL,
ANTHROPIC_MAGIC_STRING_REPLACEMENT,
);
}
export async function runEmbeddedPiAgent( export async function runEmbeddedPiAgent(
params: RunEmbeddedPiAgentParams, params: RunEmbeddedPiAgentParams,
): Promise<EmbeddedPiRunResult> { ): Promise<EmbeddedPiRunResult> {
@@ -116,8 +129,16 @@ export async function runEmbeddedPiAgent(
const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
const preferredProfileId = params.authProfileId?.trim(); const preferredProfileId = params.authProfileId?.trim();
const lockedProfileId = let lockedProfileId = params.authProfileIdSource === "user" ? preferredProfileId : undefined;
params.authProfileIdSource === "user" ? preferredProfileId : undefined; if (lockedProfileId) {
const lockedProfile = authStore.profiles[lockedProfileId];
if (
!lockedProfile ||
normalizeProviderId(lockedProfile.provider) !== normalizeProviderId(provider)
) {
lockedProfileId = undefined;
}
}
const profileOrder = resolveAuthProfileOrder({ const profileOrder = resolveAuthProfileOrder({
cfg: params.config, cfg: params.config,
store: authStore, store: authStore,
@@ -202,6 +223,9 @@ export async function runEmbeddedPiAgent(
attemptedThinking.add(thinkLevel); attemptedThinking.add(thinkLevel);
await fs.mkdir(resolvedWorkspace, { recursive: true }); await fs.mkdir(resolvedWorkspace, { recursive: true });
const prompt =
provider === "anthropic" ? scrubAnthropicRefusalMagic(params.prompt) : params.prompt;
const attempt = await runEmbeddedAttempt({ const attempt = await runEmbeddedAttempt({
sessionId: params.sessionId, sessionId: params.sessionId,
sessionKey: params.sessionKey, sessionKey: params.sessionKey,
@@ -219,7 +243,7 @@ export async function runEmbeddedPiAgent(
agentDir, agentDir,
config: params.config, config: params.config,
skillsSnapshot: params.skillsSnapshot, skillsSnapshot: params.skillsSnapshot,
prompt: params.prompt, prompt,
images: params.images, images: params.images,
provider, provider,
modelId, modelId,
@@ -16,6 +16,7 @@ import { normalizeMessageChannel } from "../../../utils/message-channel.js";
import { isReasoningTagProvider } from "../../../utils/provider-utils.js"; import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
import { isSubagentSessionKey } from "../../../routing/session-key.js"; import { isSubagentSessionKey } from "../../../routing/session-key.js";
import { resolveUserPath } from "../../../utils.js"; import { resolveUserPath } from "../../../utils.js";
import { createCacheTrace } from "../../cache-trace.js";
import { resolveClawdbotAgentDir } from "../../agent-paths.js"; import { resolveClawdbotAgentDir } from "../../agent-paths.js";
import { resolveSessionAgentIds } from "../../agent-scope.js"; import { resolveSessionAgentIds } from "../../agent-scope.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
@@ -367,6 +368,17 @@ export async function runEmbeddedAttempt(
throw new Error("Embedded agent session missing"); throw new Error("Embedded agent session missing");
} }
const activeSession = session; const activeSession = session;
const cacheTrace = createCacheTrace({
cfg: params.config,
env: process.env,
runId: params.runId,
sessionId: activeSession.sessionId,
sessionKey: params.sessionKey,
provider: params.provider,
modelId: params.modelId,
modelApi: params.model.api,
workspaceDir: params.workspaceDir,
});
// Force a stable streamFn reference so vitest can reliably mock @mariozechner/pi-ai. // Force a stable streamFn reference so vitest can reliably mock @mariozechner/pi-ai.
activeSession.agent.streamFn = streamSimple; activeSession.agent.streamFn = streamSimple;
@@ -379,6 +391,15 @@ export async function runEmbeddedAttempt(
params.streamParams, params.streamParams,
); );
if (cacheTrace) {
cacheTrace.recordStage("session:loaded", {
messages: activeSession.messages,
system: systemPrompt,
note: "after session create",
});
activeSession.agent.streamFn = cacheTrace.wrapStreamFn(activeSession.agent.streamFn);
}
try { try {
const prior = await sanitizeSessionHistory({ const prior = await sanitizeSessionHistory({
messages: activeSession.messages, messages: activeSession.messages,
@@ -388,12 +409,14 @@ export async function runEmbeddedAttempt(
sessionManager, sessionManager,
sessionId: params.sessionId, sessionId: params.sessionId,
}); });
cacheTrace?.recordStage("session:sanitized", { messages: prior });
const validatedGemini = validateGeminiTurns(prior); const validatedGemini = validateGeminiTurns(prior);
const validated = validateAnthropicTurns(validatedGemini); const validated = validateAnthropicTurns(validatedGemini);
const limited = limitHistoryTurns( const limited = limitHistoryTurns(
validated, validated,
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config), getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
); );
cacheTrace?.recordStage("session:limited", { messages: limited });
if (limited.length > 0) { if (limited.length > 0) {
activeSession.agent.replaceMessages(limited); activeSession.agent.replaceMessages(limited);
} }
@@ -564,6 +587,10 @@ export async function runEmbeddedAttempt(
} }
log.debug(`embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`); log.debug(`embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`);
cacheTrace?.recordStage("prompt:before", {
prompt: effectivePrompt,
messages: activeSession.messages,
});
// Repair orphaned trailing user messages so new prompts don't violate role ordering. // Repair orphaned trailing user messages so new prompts don't violate role ordering.
const leafEntry = sessionManager.getLeafEntry(); const leafEntry = sessionManager.getLeafEntry();
@@ -633,6 +660,12 @@ export async function runEmbeddedAttempt(
} }
} }
cacheTrace?.recordStage("prompt:images", {
prompt: effectivePrompt,
messages: activeSession.messages,
note: `images: prompt=${imageResult.images.length} history=${imageResult.historyImagesByIndex.size}`,
});
// Only pass images option if there are actually images to pass // Only pass images option if there are actually images to pass
// This avoids potential issues with models that don't expect the images parameter // This avoids potential issues with models that don't expect the images parameter
if (imageResult.images.length > 0) { if (imageResult.images.length > 0) {
@@ -660,6 +693,10 @@ export async function runEmbeddedAttempt(
messagesSnapshot = activeSession.messages.slice(); messagesSnapshot = activeSession.messages.slice();
sessionIdUsed = activeSession.sessionId; sessionIdUsed = activeSession.sessionId;
cacheTrace?.recordStage("session:after", {
messages: messagesSnapshot,
note: promptError ? "prompt error" : undefined,
});
// Run agent_end hooks to allow plugins to analyze the conversation // Run agent_end hooks to allow plugins to analyze the conversation
// This is fire-and-forget, so we don't await // This is fire-and-forget, so we don't await
@@ -39,6 +39,10 @@ export type EmbeddedPiSubscribeState = {
lastStreamedAssistant?: string; lastStreamedAssistant?: string;
lastStreamedReasoning?: string; lastStreamedReasoning?: string;
lastBlockReplyText?: string; lastBlockReplyText?: string;
assistantMessageIndex: number;
lastAssistantTextMessageIndex: number;
lastAssistantTextNormalized?: string;
lastAssistantTextTrimmed?: string;
assistantTextBaseline: number; assistantTextBaseline: number;
suppressBlockChunks: boolean; suppressBlockChunks: boolean;
lastReasoningSent?: string; lastReasoningSent?: string;
@@ -86,6 +86,35 @@ describe("subscribeEmbeddedPiSession", () => {
expect(subscription.assistantTexts).toEqual(["Hello world"]); expect(subscription.assistantTexts).toEqual(["Hello world"]);
}); });
it("does not duplicate assistantTexts when message_end repeats with trailing whitespace changes", () => {
let handler: SessionEventHandler | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const subscription = subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
});
const assistantMessageWithNewline = {
role: "assistant",
content: [{ type: "text", text: "Hello world\n" }],
} as AssistantMessage;
const assistantMessageTrimmed = {
role: "assistant",
content: [{ type: "text", text: "Hello world" }],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessageWithNewline });
handler?.({ type: "message_end", message: assistantMessageTrimmed });
expect(subscription.assistantTexts).toEqual(["Hello world"]);
});
it("does not duplicate assistantTexts when message_end repeats with reasoning blocks", () => { it("does not duplicate assistantTexts when message_end repeats with reasoning blocks", () => {
let handler: SessionEventHandler | undefined; let handler: SessionEventHandler | undefined;
const session: StubSession = { const session: StubSession = {
+37 -4
View File
@@ -48,6 +48,10 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
lastStreamedAssistant: undefined, lastStreamedAssistant: undefined,
lastStreamedReasoning: undefined, lastStreamedReasoning: undefined,
lastBlockReplyText: undefined, lastBlockReplyText: undefined,
assistantMessageIndex: 0,
lastAssistantTextMessageIndex: -1,
lastAssistantTextNormalized: undefined,
lastAssistantTextTrimmed: undefined,
assistantTextBaseline: 0, assistantTextBaseline: 0,
suppressBlockChunks: false, // Avoid late chunk inserts after final text merge. suppressBlockChunks: false, // Avoid late chunk inserts after final text merge.
lastReasoningSent: undefined, lastReasoningSent: undefined,
@@ -84,9 +88,36 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
state.lastStreamedReasoning = undefined; state.lastStreamedReasoning = undefined;
state.lastReasoningSent = undefined; state.lastReasoningSent = undefined;
state.suppressBlockChunks = false; state.suppressBlockChunks = false;
state.assistantMessageIndex += 1;
state.lastAssistantTextMessageIndex = -1;
state.lastAssistantTextNormalized = undefined;
state.lastAssistantTextTrimmed = undefined;
state.assistantTextBaseline = nextAssistantTextBaseline; state.assistantTextBaseline = nextAssistantTextBaseline;
}; };
const rememberAssistantText = (text: string) => {
state.lastAssistantTextMessageIndex = state.assistantMessageIndex;
state.lastAssistantTextTrimmed = text.trimEnd();
const normalized = normalizeTextForComparison(text);
state.lastAssistantTextNormalized = normalized.length > 0 ? normalized : undefined;
};
const shouldSkipAssistantText = (text: string) => {
if (state.lastAssistantTextMessageIndex !== state.assistantMessageIndex) return false;
const trimmed = text.trimEnd();
if (trimmed && trimmed === state.lastAssistantTextTrimmed) return true;
const normalized = normalizeTextForComparison(text);
if (normalized.length > 0 && normalized === state.lastAssistantTextNormalized) return true;
return false;
};
const pushAssistantText = (text: string) => {
if (!text) return;
if (shouldSkipAssistantText(text)) return;
assistantTexts.push(text);
rememberAssistantText(text);
};
const finalizeAssistantTexts = (args: { const finalizeAssistantTexts = (args: {
text: string; text: string;
addedDuringMessage: boolean; addedDuringMessage: boolean;
@@ -103,16 +134,15 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
assistantTexts.length - state.assistantTextBaseline, assistantTexts.length - state.assistantTextBaseline,
text, text,
); );
rememberAssistantText(text);
} else { } else {
const last = assistantTexts.at(-1); pushAssistantText(text);
if (!last || last !== text) assistantTexts.push(text);
} }
state.suppressBlockChunks = true; state.suppressBlockChunks = true;
} else if (!addedDuringMessage && !chunkerHasBuffered && text) { } else if (!addedDuringMessage && !chunkerHasBuffered && text) {
// Non-streaming models (no text_delta): ensure assistantTexts gets the final // Non-streaming models (no text_delta): ensure assistantTexts gets the final
// text when the chunker has nothing buffered to drain. // text when the chunker has nothing buffered to drain.
const last = assistantTexts.at(-1); pushAssistantText(text);
if (!last || last !== text) assistantTexts.push(text);
} }
state.assistantTextBaseline = assistantTexts.length; state.assistantTextBaseline = assistantTexts.length;
@@ -338,8 +368,11 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
return; return;
} }
if (shouldSkipAssistantText(chunk)) return;
state.lastBlockReplyText = chunk; state.lastBlockReplyText = chunk;
assistantTexts.push(chunk); assistantTexts.push(chunk);
rememberAssistantText(chunk);
if (!params.onBlockReply) return; if (!params.onBlockReply) return;
const splitResult = parseReplyDirectives(chunk); const splitResult = parseReplyDirectives(chunk);
const { const {
+326 -311
View File
@@ -112,334 +112,349 @@ export function createNodesTool(options?: {
timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : undefined, timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
}; };
switch (action) { try {
case "status": switch (action) {
return jsonResult(await callGatewayTool("node.list", gatewayOpts, {})); case "status":
case "describe": { return jsonResult(await callGatewayTool("node.list", gatewayOpts, {}));
const node = readStringParam(params, "node", { required: true }); case "describe": {
const nodeId = await resolveNodeId(gatewayOpts, node); const node = readStringParam(params, "node", { required: true });
return jsonResult(await callGatewayTool("node.describe", gatewayOpts, { nodeId })); const nodeId = await resolveNodeId(gatewayOpts, node);
} return jsonResult(await callGatewayTool("node.describe", gatewayOpts, { nodeId }));
case "pending":
return jsonResult(await callGatewayTool("node.pair.list", gatewayOpts, {}));
case "approve": {
const requestId = readStringParam(params, "requestId", {
required: true,
});
return jsonResult(
await callGatewayTool("node.pair.approve", gatewayOpts, {
requestId,
}),
);
}
case "reject": {
const requestId = readStringParam(params, "requestId", {
required: true,
});
return jsonResult(
await callGatewayTool("node.pair.reject", gatewayOpts, {
requestId,
}),
);
}
case "notify": {
const node = readStringParam(params, "node", { required: true });
const title = typeof params.title === "string" ? params.title : "";
const body = typeof params.body === "string" ? params.body : "";
if (!title.trim() && !body.trim()) {
throw new Error("title or body required");
} }
const nodeId = await resolveNodeId(gatewayOpts, node); case "pending":
await callGatewayTool("node.invoke", gatewayOpts, { return jsonResult(await callGatewayTool("node.pair.list", gatewayOpts, {}));
nodeId, case "approve": {
command: "system.notify", const requestId = readStringParam(params, "requestId", {
params: { required: true,
title: title.trim() || undefined, });
body: body.trim() || undefined, return jsonResult(
sound: typeof params.sound === "string" ? params.sound : undefined, await callGatewayTool("node.pair.approve", gatewayOpts, {
priority: typeof params.priority === "string" ? params.priority : undefined, requestId,
delivery: typeof params.delivery === "string" ? params.delivery : undefined, }),
}, );
idempotencyKey: crypto.randomUUID(), }
}); case "reject": {
return jsonResult({ ok: true }); const requestId = readStringParam(params, "requestId", {
} required: true,
case "camera_snap": { });
const node = readStringParam(params, "node", { required: true }); return jsonResult(
const nodeId = await resolveNodeId(gatewayOpts, node); await callGatewayTool("node.pair.reject", gatewayOpts, {
const facingRaw = requestId,
typeof params.facing === "string" ? params.facing.toLowerCase() : "both"; }),
const facings: CameraFacing[] = );
facingRaw === "both" }
? ["front", "back"] case "notify": {
: facingRaw === "front" || facingRaw === "back" const node = readStringParam(params, "node", { required: true });
? [facingRaw] const title = typeof params.title === "string" ? params.title : "";
: (() => { const body = typeof params.body === "string" ? params.body : "";
throw new Error("invalid facing (front|back|both)"); if (!title.trim() && !body.trim()) {
})(); throw new Error("title or body required");
const maxWidth = }
typeof params.maxWidth === "number" && Number.isFinite(params.maxWidth) const nodeId = await resolveNodeId(gatewayOpts, node);
? params.maxWidth await callGatewayTool("node.invoke", gatewayOpts, {
: undefined; nodeId,
const quality = command: "system.notify",
typeof params.quality === "number" && Number.isFinite(params.quality) params: {
? params.quality title: title.trim() || undefined,
: undefined; body: body.trim() || undefined,
const delayMs = sound: typeof params.sound === "string" ? params.sound : undefined,
typeof params.delayMs === "number" && Number.isFinite(params.delayMs) priority: typeof params.priority === "string" ? params.priority : undefined,
? params.delayMs delivery: typeof params.delivery === "string" ? params.delivery : undefined,
: undefined; },
const deviceId = idempotencyKey: crypto.randomUUID(),
typeof params.deviceId === "string" && params.deviceId.trim() });
? params.deviceId.trim() return jsonResult({ ok: true });
: undefined; }
case "camera_snap": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const facingRaw =
typeof params.facing === "string" ? params.facing.toLowerCase() : "both";
const facings: CameraFacing[] =
facingRaw === "both"
? ["front", "back"]
: facingRaw === "front" || facingRaw === "back"
? [facingRaw]
: (() => {
throw new Error("invalid facing (front|back|both)");
})();
const maxWidth =
typeof params.maxWidth === "number" && Number.isFinite(params.maxWidth)
? params.maxWidth
: undefined;
const quality =
typeof params.quality === "number" && Number.isFinite(params.quality)
? params.quality
: undefined;
const delayMs =
typeof params.delayMs === "number" && Number.isFinite(params.delayMs)
? params.delayMs
: undefined;
const deviceId =
typeof params.deviceId === "string" && params.deviceId.trim()
? params.deviceId.trim()
: undefined;
const content: AgentToolResult<unknown>["content"] = []; const content: AgentToolResult<unknown>["content"] = [];
const details: Array<Record<string, unknown>> = []; const details: Array<Record<string, unknown>> = [];
for (const facing of facings) { for (const facing of facings) {
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "camera.snap",
params: {
facing,
maxWidth,
quality,
format: "jpg",
delayMs,
deviceId,
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
const payload = parseCameraSnapPayload(raw?.payload);
const normalizedFormat = payload.format.toLowerCase();
if (
normalizedFormat !== "jpg" &&
normalizedFormat !== "jpeg" &&
normalizedFormat !== "png"
) {
throw new Error(`unsupported camera.snap format: ${payload.format}`);
}
const isJpeg = normalizedFormat === "jpg" || normalizedFormat === "jpeg";
const filePath = cameraTempPath({
kind: "snap",
facing,
ext: isJpeg ? "jpg" : "png",
});
await writeBase64ToFile(filePath, payload.base64);
content.push({ type: "text", text: `MEDIA:${filePath}` });
content.push({
type: "image",
data: payload.base64,
mimeType:
imageMimeFromFormat(payload.format) ?? (isJpeg ? "image/jpeg" : "image/png"),
});
details.push({
facing,
path: filePath,
width: payload.width,
height: payload.height,
});
}
const result: AgentToolResult<unknown> = { content, details };
return await sanitizeToolResultImages(result, "nodes:camera_snap");
}
case "camera_list": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const raw = (await callGatewayTool("node.invoke", gatewayOpts, { const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId, nodeId,
command: "camera.snap", command: "camera.list",
params: {},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
const payload =
raw && typeof raw.payload === "object" && raw.payload !== null ? raw.payload : {};
return jsonResult(payload);
}
case "camera_clip": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const facing =
typeof params.facing === "string" ? params.facing.toLowerCase() : "front";
if (facing !== "front" && facing !== "back") {
throw new Error("invalid facing (front|back)");
}
const durationMs =
typeof params.durationMs === "number" && Number.isFinite(params.durationMs)
? params.durationMs
: typeof params.duration === "string"
? parseDurationMs(params.duration)
: 3000;
const includeAudio =
typeof params.includeAudio === "boolean" ? params.includeAudio : true;
const deviceId =
typeof params.deviceId === "string" && params.deviceId.trim()
? params.deviceId.trim()
: undefined;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "camera.clip",
params: { params: {
facing, facing,
maxWidth, durationMs,
quality, includeAudio,
format: "jpg", format: "mp4",
delayMs,
deviceId, deviceId,
}, },
idempotencyKey: crypto.randomUUID(), idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown }; })) as { payload?: unknown };
const payload = parseCameraSnapPayload(raw?.payload); const payload = parseCameraClipPayload(raw?.payload);
const normalizedFormat = payload.format.toLowerCase();
if (
normalizedFormat !== "jpg" &&
normalizedFormat !== "jpeg" &&
normalizedFormat !== "png"
) {
throw new Error(`unsupported camera.snap format: ${payload.format}`);
}
const isJpeg = normalizedFormat === "jpg" || normalizedFormat === "jpeg";
const filePath = cameraTempPath({ const filePath = cameraTempPath({
kind: "snap", kind: "clip",
facing, facing,
ext: isJpeg ? "jpg" : "png", ext: payload.format,
}); });
await writeBase64ToFile(filePath, payload.base64); await writeBase64ToFile(filePath, payload.base64);
content.push({ type: "text", text: `MEDIA:${filePath}` }); return {
content.push({ content: [{ type: "text", text: `FILE:${filePath}` }],
type: "image", details: {
data: payload.base64, facing,
mimeType: path: filePath,
imageMimeFromFormat(payload.format) ?? (isJpeg ? "image/jpeg" : "image/png"), durationMs: payload.durationMs,
}); hasAudio: payload.hasAudio,
details.push({ },
facing, };
path: filePath,
width: payload.width,
height: payload.height,
});
} }
case "screen_record": {
const result: AgentToolResult<unknown> = { content, details }; const node = readStringParam(params, "node", { required: true });
return await sanitizeToolResultImages(result, "nodes:camera_snap"); const nodeId = await resolveNodeId(gatewayOpts, node);
const durationMs =
typeof params.durationMs === "number" && Number.isFinite(params.durationMs)
? params.durationMs
: typeof params.duration === "string"
? parseDurationMs(params.duration)
: 10_000;
const fps =
typeof params.fps === "number" && Number.isFinite(params.fps) ? params.fps : 10;
const screenIndex =
typeof params.screenIndex === "number" && Number.isFinite(params.screenIndex)
? params.screenIndex
: 0;
const includeAudio =
typeof params.includeAudio === "boolean" ? params.includeAudio : true;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "screen.record",
params: {
durationMs,
screenIndex,
fps,
format: "mp4",
includeAudio,
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
const payload = parseScreenRecordPayload(raw?.payload);
const filePath =
typeof params.outPath === "string" && params.outPath.trim()
? params.outPath.trim()
: screenRecordTempPath({ ext: payload.format || "mp4" });
const written = await writeScreenRecordToFile(filePath, payload.base64);
return {
content: [{ type: "text", text: `FILE:${written.path}` }],
details: {
path: written.path,
durationMs: payload.durationMs,
fps: payload.fps,
screenIndex: payload.screenIndex,
hasAudio: payload.hasAudio,
},
};
}
case "location_get": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const maxAgeMs =
typeof params.maxAgeMs === "number" && Number.isFinite(params.maxAgeMs)
? params.maxAgeMs
: undefined;
const desiredAccuracy =
params.desiredAccuracy === "coarse" ||
params.desiredAccuracy === "balanced" ||
params.desiredAccuracy === "precise"
? params.desiredAccuracy
: undefined;
const locationTimeoutMs =
typeof params.locationTimeoutMs === "number" &&
Number.isFinite(params.locationTimeoutMs)
? params.locationTimeoutMs
: undefined;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "location.get",
params: {
maxAgeMs,
desiredAccuracy,
timeoutMs: locationTimeoutMs,
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
return jsonResult(raw?.payload ?? {});
}
case "run": {
const node = readStringParam(params, "node", { required: true });
const nodes = await listNodes(gatewayOpts);
if (nodes.length === 0) {
throw new Error(
"system.run requires a paired companion app or node host (no nodes available).",
);
}
const nodeId = resolveNodeIdFromList(nodes, node);
const nodeInfo = nodes.find((entry) => entry.nodeId === nodeId);
const supportsSystemRun = Array.isArray(nodeInfo?.commands)
? nodeInfo?.commands?.includes("system.run")
: false;
if (!supportsSystemRun) {
throw new Error(
"system.run requires a companion app or node host; the selected node does not support system.run.",
);
}
const commandRaw = params.command;
if (!commandRaw) {
throw new Error("command required (argv array, e.g. ['echo', 'Hello'])");
}
if (!Array.isArray(commandRaw)) {
throw new Error("command must be an array of strings (argv), e.g. ['echo', 'Hello']");
}
const command = commandRaw.map((c) => String(c));
if (command.length === 0) {
throw new Error("command must not be empty");
}
const cwd =
typeof params.cwd === "string" && params.cwd.trim() ? params.cwd.trim() : undefined;
const env = parseEnvPairs(params.env);
const commandTimeoutMs = parseTimeoutMs(params.commandTimeoutMs);
const invokeTimeoutMs = parseTimeoutMs(params.invokeTimeoutMs);
const needsScreenRecording =
typeof params.needsScreenRecording === "boolean"
? params.needsScreenRecording
: undefined;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "system.run",
params: {
command,
cwd,
env,
timeoutMs: commandTimeoutMs,
needsScreenRecording,
agentId,
sessionKey,
},
timeoutMs: invokeTimeoutMs,
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
return jsonResult(raw?.payload ?? {});
}
default:
throw new Error(`Unknown action: ${action}`);
} }
case "camera_list": { } catch (err) {
const node = readStringParam(params, "node", { required: true }); const nodeLabel =
const nodeId = await resolveNodeId(gatewayOpts, node); typeof params.node === "string" && params.node.trim() ? params.node.trim() : "auto";
const raw = (await callGatewayTool("node.invoke", gatewayOpts, { const gatewayLabel =
nodeId, gatewayOpts.gatewayUrl && gatewayOpts.gatewayUrl.trim()
command: "camera.list", ? gatewayOpts.gatewayUrl.trim()
params: {}, : "default";
idempotencyKey: crypto.randomUUID(), const agentLabel = agentId ?? "unknown";
})) as { payload?: unknown }; const message = err instanceof Error ? err.message : String(err);
const payload = throw new Error(
raw && typeof raw.payload === "object" && raw.payload !== null ? raw.payload : {}; `agent=${agentLabel} node=${nodeLabel} gateway=${gatewayLabel} action=${action}: ${message}`,
return jsonResult(payload); );
}
case "camera_clip": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const facing = typeof params.facing === "string" ? params.facing.toLowerCase() : "front";
if (facing !== "front" && facing !== "back") {
throw new Error("invalid facing (front|back)");
}
const durationMs =
typeof params.durationMs === "number" && Number.isFinite(params.durationMs)
? params.durationMs
: typeof params.duration === "string"
? parseDurationMs(params.duration)
: 3000;
const includeAudio =
typeof params.includeAudio === "boolean" ? params.includeAudio : true;
const deviceId =
typeof params.deviceId === "string" && params.deviceId.trim()
? params.deviceId.trim()
: undefined;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "camera.clip",
params: {
facing,
durationMs,
includeAudio,
format: "mp4",
deviceId,
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
const payload = parseCameraClipPayload(raw?.payload);
const filePath = cameraTempPath({
kind: "clip",
facing,
ext: payload.format,
});
await writeBase64ToFile(filePath, payload.base64);
return {
content: [{ type: "text", text: `FILE:${filePath}` }],
details: {
facing,
path: filePath,
durationMs: payload.durationMs,
hasAudio: payload.hasAudio,
},
};
}
case "screen_record": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const durationMs =
typeof params.durationMs === "number" && Number.isFinite(params.durationMs)
? params.durationMs
: typeof params.duration === "string"
? parseDurationMs(params.duration)
: 10_000;
const fps =
typeof params.fps === "number" && Number.isFinite(params.fps) ? params.fps : 10;
const screenIndex =
typeof params.screenIndex === "number" && Number.isFinite(params.screenIndex)
? params.screenIndex
: 0;
const includeAudio =
typeof params.includeAudio === "boolean" ? params.includeAudio : true;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "screen.record",
params: {
durationMs,
screenIndex,
fps,
format: "mp4",
includeAudio,
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
const payload = parseScreenRecordPayload(raw?.payload);
const filePath =
typeof params.outPath === "string" && params.outPath.trim()
? params.outPath.trim()
: screenRecordTempPath({ ext: payload.format || "mp4" });
const written = await writeScreenRecordToFile(filePath, payload.base64);
return {
content: [{ type: "text", text: `FILE:${written.path}` }],
details: {
path: written.path,
durationMs: payload.durationMs,
fps: payload.fps,
screenIndex: payload.screenIndex,
hasAudio: payload.hasAudio,
},
};
}
case "location_get": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const maxAgeMs =
typeof params.maxAgeMs === "number" && Number.isFinite(params.maxAgeMs)
? params.maxAgeMs
: undefined;
const desiredAccuracy =
params.desiredAccuracy === "coarse" ||
params.desiredAccuracy === "balanced" ||
params.desiredAccuracy === "precise"
? params.desiredAccuracy
: undefined;
const locationTimeoutMs =
typeof params.locationTimeoutMs === "number" &&
Number.isFinite(params.locationTimeoutMs)
? params.locationTimeoutMs
: undefined;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "location.get",
params: {
maxAgeMs,
desiredAccuracy,
timeoutMs: locationTimeoutMs,
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
return jsonResult(raw?.payload ?? {});
}
case "run": {
const node = readStringParam(params, "node", { required: true });
const nodes = await listNodes(gatewayOpts);
if (nodes.length === 0) {
throw new Error(
"system.run requires a paired companion app or node host (no nodes available).",
);
}
const nodeId = resolveNodeIdFromList(nodes, node);
const nodeInfo = nodes.find((entry) => entry.nodeId === nodeId);
const supportsSystemRun = Array.isArray(nodeInfo?.commands)
? nodeInfo?.commands?.includes("system.run")
: false;
if (!supportsSystemRun) {
throw new Error(
"system.run requires a companion app or node host; the selected node does not support system.run.",
);
}
const commandRaw = params.command;
if (!commandRaw) {
throw new Error("command required (argv array, e.g. ['echo', 'Hello'])");
}
if (!Array.isArray(commandRaw)) {
throw new Error("command must be an array of strings (argv), e.g. ['echo', 'Hello']");
}
const command = commandRaw.map((c) => String(c));
if (command.length === 0) {
throw new Error("command must not be empty");
}
const cwd =
typeof params.cwd === "string" && params.cwd.trim() ? params.cwd.trim() : undefined;
const env = parseEnvPairs(params.env);
const commandTimeoutMs = parseTimeoutMs(params.commandTimeoutMs);
const invokeTimeoutMs = parseTimeoutMs(params.invokeTimeoutMs);
const needsScreenRecording =
typeof params.needsScreenRecording === "boolean"
? params.needsScreenRecording
: undefined;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "system.run",
params: {
command,
cwd,
env,
timeoutMs: commandTimeoutMs,
needsScreenRecording,
agentId,
sessionKey,
},
timeoutMs: invokeTimeoutMs,
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
return jsonResult(raw?.payload ?? {});
}
default:
throw new Error(`Unknown action: ${action}`);
} }
}, },
}; };
+25 -23
View File
@@ -36,6 +36,7 @@ import {
DEFAULT_AGENT_ID, DEFAULT_AGENT_ID,
resolveAgentIdFromSessionKey, resolveAgentIdFromSessionKey,
} from "../../routing/session-key.js"; } from "../../routing/session-key.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import type { AnyAgentTool } from "./common.js"; import type { AnyAgentTool } from "./common.js";
import { readStringParam } from "./common.js"; import { readStringParam } from "./common.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js"; import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
@@ -240,6 +241,7 @@ export function createSessionStatusTool(opts?: {
throw new Error(`Unknown sessionKey: ${requestedKeyRaw}`); throw new Error(`Unknown sessionKey: ${requestedKeyRaw}`);
} }
const configured = resolveDefaultModelForAgent({ cfg, agentId });
const modelRaw = readStringParam(params, "model"); const modelRaw = readStringParam(params, "model");
let changedModel = false; let changedModel = false;
if (typeof modelRaw === "string") { if (typeof modelRaw === "string") {
@@ -249,33 +251,33 @@ export function createSessionStatusTool(opts?: {
sessionEntry: resolved.entry, sessionEntry: resolved.entry,
agentId, agentId,
}); });
const nextEntry: SessionEntry = { const nextEntry: SessionEntry = { ...resolved.entry };
...resolved.entry, const applied = applyModelOverrideToSessionEntry({
updatedAt: Date.now(), entry: nextEntry,
}; selection:
if (selection.kind === "reset" || selection.isDefault) { selection.kind === "reset"
delete nextEntry.providerOverride; ? {
delete nextEntry.modelOverride; provider: configured.provider,
delete nextEntry.authProfileOverride; model: configured.model,
delete nextEntry.authProfileOverrideSource; isDefault: true,
delete nextEntry.authProfileOverrideCompactionCount; }
} else { : {
nextEntry.providerOverride = selection.provider; provider: selection.provider,
nextEntry.modelOverride = selection.model; model: selection.model,
delete nextEntry.authProfileOverride; isDefault: selection.isDefault,
delete nextEntry.authProfileOverrideSource; },
delete nextEntry.authProfileOverrideCompactionCount;
}
store[resolved.key] = nextEntry;
await updateSessionStore(storePath, (nextStore) => {
nextStore[resolved.key] = nextEntry;
}); });
resolved.entry = nextEntry; if (applied.updated) {
changedModel = true; store[resolved.key] = nextEntry;
await updateSessionStore(storePath, (nextStore) => {
nextStore[resolved.key] = nextEntry;
});
resolved.entry = nextEntry;
changedModel = true;
}
} }
const agentDir = resolveAgentDir(cfg, agentId); const agentDir = resolveAgentDir(cfg, agentId);
const configured = resolveDefaultModelForAgent({ cfg, agentId });
const providerForCard = resolved.entry.providerOverride?.trim() || configured.provider; const providerForCard = resolved.entry.providerOverride?.trim() || configured.provider;
const usageProvider = resolveUsageProviderId(providerForCard); const usageProvider = resolveUsageProviderId(providerForCard);
let usageLine: string | undefined; let usageLine: string | undefined;
+7
View File
@@ -157,6 +157,13 @@ function buildChatCommands(): ChatCommandDefinition[] {
description: "Show current status.", description: "Show current status.",
textAlias: "/status", textAlias: "/status",
}), }),
defineChatCommand({
key: "allowlist",
description: "List/add/remove allowlist entries.",
textAlias: "/allowlist",
acceptsArgs: true,
scope: "text",
}),
defineChatCommand({ defineChatCommand({
key: "context", key: "context",
nativeName: "context", nativeName: "context",
@@ -121,6 +121,42 @@ describe("directive behavior", () => {
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });
it("includes catalog models when no allowlist is set", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
{ id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" },
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
{ id: "grok-4", name: "Grok 4", provider: "xai" },
]);
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: {
primary: "anthropic/claude-opus-4-5",
fallbacks: ["openai/gpt-4.1-mini"],
},
imageModel: { primary: "minimax/MiniMax-M2.1" },
workspace: path.join(home, "clawd"),
},
},
session: { store: storePath },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).toContain("minimax/MiniMax-M2.1");
expect(text).toContain("xai/grok-4");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("merges config allowlist models even when catalog is present", async () => { it("merges config allowlist models even when catalog is present", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(runEmbeddedPiAgent).mockReset();
@@ -299,6 +299,8 @@ export async function runAgentTurnWithFallback(params: {
const { text, skip } = normalizeStreamingText(payload); const { text, skip } = normalizeStreamingText(payload);
const hasPayloadMedia = (payload.mediaUrls?.length ?? 0) > 0; const hasPayloadMedia = (payload.mediaUrls?.length ?? 0) > 0;
if (skip && !hasPayloadMedia) return; if (skip && !hasPayloadMedia) return;
const currentMessageId =
params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid;
const taggedPayload = applyReplyTagsToPayload( const taggedPayload = applyReplyTagsToPayload(
{ {
text, text,
@@ -308,12 +310,12 @@ export async function runAgentTurnWithFallback(params: {
replyToTag: payload.replyToTag, replyToTag: payload.replyToTag,
replyToCurrent: payload.replyToCurrent, replyToCurrent: payload.replyToCurrent,
}, },
params.sessionCtx.MessageSid, currentMessageId,
); );
// Let through payloads with audioAsVoice flag even if empty (need to track it) // Let through payloads with audioAsVoice flag even if empty (need to track it)
if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return; if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return;
const parsed = parseReplyDirectives(taggedPayload.text ?? "", { const parsed = parseReplyDirectives(taggedPayload.text ?? "", {
currentMessageId: params.sessionCtx.MessageSid, currentMessageId,
silentToken: SILENT_REPLY_TOKEN, silentToken: SILENT_REPLY_TOKEN,
}); });
const cleaned = parsed.text || undefined; const cleaned = parsed.text || undefined;
+10 -6
View File
@@ -16,19 +16,21 @@ export const createShouldEmitToolResult = (params: {
storePath?: string; storePath?: string;
resolvedVerboseLevel: VerboseLevel; resolvedVerboseLevel: VerboseLevel;
}): (() => boolean) => { }): (() => boolean) => {
// Normalize verbose values from session store/config so false/"false" still means off.
const fallbackVerbose = normalizeVerboseLevel(String(params.resolvedVerboseLevel ?? "")) ?? "off";
return () => { return () => {
if (!params.sessionKey || !params.storePath) { if (!params.sessionKey || !params.storePath) {
return params.resolvedVerboseLevel !== "off"; return fallbackVerbose !== "off";
} }
try { try {
const store = loadSessionStore(params.storePath); const store = loadSessionStore(params.storePath);
const entry = store[params.sessionKey]; const entry = store[params.sessionKey];
const current = normalizeVerboseLevel(entry?.verboseLevel); const current = normalizeVerboseLevel(String(entry?.verboseLevel ?? ""));
if (current) return current !== "off"; if (current) return current !== "off";
} catch { } catch {
// ignore store read failures // ignore store read failures
} }
return params.resolvedVerboseLevel !== "off"; return fallbackVerbose !== "off";
}; };
}; };
@@ -37,19 +39,21 @@ export const createShouldEmitToolOutput = (params: {
storePath?: string; storePath?: string;
resolvedVerboseLevel: VerboseLevel; resolvedVerboseLevel: VerboseLevel;
}): (() => boolean) => { }): (() => boolean) => {
// Normalize verbose values from session store/config so false/"false" still means off.
const fallbackVerbose = normalizeVerboseLevel(String(params.resolvedVerboseLevel ?? "")) ?? "off";
return () => { return () => {
if (!params.sessionKey || !params.storePath) { if (!params.sessionKey || !params.storePath) {
return params.resolvedVerboseLevel === "full"; return fallbackVerbose === "full";
} }
try { try {
const store = loadSessionStore(params.storePath); const store = loadSessionStore(params.storePath);
const entry = store[params.sessionKey]; const entry = store[params.sessionKey];
const current = normalizeVerboseLevel(entry?.verboseLevel); const current = normalizeVerboseLevel(String(entry?.verboseLevel ?? ""));
if (current) return current === "full"; if (current) return current === "full";
} catch { } catch {
// ignore store read failures // ignore store read failures
} }
return params.resolvedVerboseLevel === "full"; return fallbackVerbose === "full";
}; };
}; };
+16 -8
View File
@@ -1,6 +1,6 @@
import type { NormalizedUsage } from "../../agents/usage.js"; import type { NormalizedUsage } from "../../agents/usage.js";
import { getChannelDock } from "../../channels/dock.js"; import { getChannelDock } from "../../channels/dock.js";
import type { ChannelThreadingToolContext } from "../../channels/plugins/types.js"; import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js";
import { normalizeChannelId } from "../../channels/registry.js"; import { normalizeChannelId } from "../../channels/registry.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js";
@@ -21,17 +21,25 @@ export function buildThreadingToolContext(params: {
}): ChannelThreadingToolContext { }): ChannelThreadingToolContext {
const { sessionCtx, config, hasRepliedRef } = params; const { sessionCtx, config, hasRepliedRef } = params;
if (!config) return {}; if (!config) return {};
const provider = normalizeChannelId(sessionCtx.Provider); const rawProvider = sessionCtx.Provider?.trim().toLowerCase();
if (!provider) return {}; if (!rawProvider) return {};
const dock = getChannelDock(provider); const provider = normalizeChannelId(rawProvider);
if (!dock?.threading?.buildToolContext) return {};
// WhatsApp context isolation keys off conversation id, not the bot's own number. // WhatsApp context isolation keys off conversation id, not the bot's own number.
const threadingTo = const threadingTo =
provider === "whatsapp" rawProvider === "whatsapp"
? (sessionCtx.From ?? sessionCtx.To) ? (sessionCtx.From ?? sessionCtx.To)
: provider === "imessage" && sessionCtx.ChatType === "direct" : rawProvider === "imessage" && sessionCtx.ChatType === "direct"
? (sessionCtx.From ?? sessionCtx.To) ? (sessionCtx.From ?? sessionCtx.To)
: sessionCtx.To; : sessionCtx.To;
// Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init)
const dock = provider ? getChannelDock(provider) : undefined;
if (!dock?.threading?.buildToolContext) {
return {
currentChannelId: threadingTo?.trim() || undefined,
currentChannelProvider: provider ?? (rawProvider as ChannelId),
hasRepliedRef,
};
}
const context = const context =
dock.threading.buildToolContext({ dock.threading.buildToolContext({
cfg: config, cfg: config,
@@ -47,7 +55,7 @@ export function buildThreadingToolContext(params: {
}) ?? {}; }) ?? {};
return { return {
...context, ...context,
currentChannelProvider: provider, currentChannelProvider: provider!, // guaranteed non-null since dock exists
}; };
} }
+1 -1
View File
@@ -377,7 +377,7 @@ export async function runReplyAgent(params: {
directlySentBlockKeys, directlySentBlockKeys,
replyToMode, replyToMode,
replyToChannel, replyToChannel,
currentMessageId: sessionCtx.MessageSid, currentMessageId: sessionCtx.MessageSidFull ?? sessionCtx.MessageSid,
messageProvider: followupRun.run.messageProvider, messageProvider: followupRun.run.messageProvider,
messagingToolSentTexts: runResult.messagingToolSentTexts, messagingToolSentTexts: runResult.messagingToolSentTexts,
messagingToolSentTargets: runResult.messagingToolSentTargets, messagingToolSentTargets: runResult.messagingToolSentTargets,
@@ -0,0 +1,139 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import { buildCommandContext, handleCommands } from "./commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
const writeConfigFileMock = vi.hoisted(() => vi.fn());
vi.mock("../../config/config.js", async () => {
const actual =
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
return {
...actual,
readConfigFileSnapshot: readConfigFileSnapshotMock,
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
writeConfigFile: writeConfigFileMock,
};
});
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
vi.mock("../../pairing/pairing-store.js", async () => {
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
"../../pairing/pairing-store.js",
);
return {
...actual,
readChannelAllowFromStore: readChannelAllowFromStoreMock,
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
};
});
vi.mock("../../channels/plugins/pairing.js", async () => {
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
"../../channels/plugins/pairing.js",
);
return {
...actual,
listPairingChannels: () => ["telegram"],
};
});
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
const ctx = {
Body: commandBody,
CommandBody: commandBody,
CommandSource: "text",
CommandAuthorized: true,
Provider: "telegram",
Surface: "telegram",
...ctxOverrides,
} as MsgContext;
const command = buildCommandContext({
ctx,
cfg,
isGroup: false,
triggerBodyNormalized: commandBody.trim().toLowerCase(),
commandAuthorized: true,
});
return {
ctx,
cfg,
command,
directives: parseInlineDirectives(commandBody),
elevated: { enabled: true, allowed: true, failures: [] },
sessionKey: "agent:main:main",
workspaceDir: "/tmp",
defaultGroupActivation: () => "mention",
resolvedVerboseLevel: "off" as const,
resolvedReasoningLevel: "off" as const,
resolveDefaultThinkingLevel: async () => undefined,
provider: "telegram",
model: "test-model",
contextTokens: 0,
isGroup: false,
};
}
describe("handleCommands /allowlist", () => {
it("lists config + store allowFrom entries", async () => {
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
const cfg = {
commands: { text: true },
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
} as ClawdbotConfig;
const params = buildParams("/allowlist list dm", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Channel: telegram");
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
});
it("adds entries to config and pairing store", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: {
channels: { telegram: { allowFrom: ["123"] } },
},
});
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
ok: true,
config,
}));
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
changed: true,
allowFrom: ["123", "789"],
});
const cfg = {
commands: { text: true, config: true },
channels: { telegram: { allowFrom: ["123"] } },
} as ClawdbotConfig;
const params = buildParams("/allowlist add dm 789", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
channels: { telegram: { allowFrom: ["123", "789"] } },
}),
);
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
channel: "telegram",
entry: "789",
});
expect(result.reply?.text).toContain("DM allowlist added");
});
});
+657
View File
@@ -0,0 +1,657 @@
import {
readConfigFileSnapshot,
validateConfigObjectWithPlugins,
writeConfigFile,
} from "../../config/config.js";
import { resolveChannelConfigWrites } from "../../channels/plugins/config-writes.js";
import { getChannelDock } from "../../channels/dock.js";
import { normalizeChannelId } from "../../channels/registry.js";
import { listPairingChannels } from "../../channels/plugins/pairing.js";
import { logVerbose } from "../../globals.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { resolveDiscordAccount } from "../../discord/accounts.js";
import { resolveIMessageAccount } from "../../imessage/accounts.js";
import { resolveSignalAccount } from "../../signal/accounts.js";
import { resolveSlackAccount } from "../../slack/accounts.js";
import { resolveTelegramAccount } from "../../telegram/accounts.js";
import { resolveWhatsAppAccount } from "../../web/accounts.js";
import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js";
import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js";
import {
addChannelAllowFromStoreEntry,
readChannelAllowFromStore,
removeChannelAllowFromStoreEntry,
} from "../../pairing/pairing-store.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import type { CommandHandler } from "./commands-types.js";
type AllowlistScope = "dm" | "group" | "all";
type AllowlistAction = "list" | "add" | "remove";
type AllowlistTarget = "both" | "config" | "store";
type AllowlistCommand =
| {
action: "list";
scope: AllowlistScope;
channel?: string;
account?: string;
resolve?: boolean;
}
| {
action: "add" | "remove";
scope: AllowlistScope;
channel?: string;
account?: string;
entry: string;
resolve?: boolean;
target: AllowlistTarget;
}
| { action: "error"; message: string };
const ACTIONS = new Set(["list", "add", "remove"]);
const SCOPES = new Set<AllowlistScope>(["dm", "group", "all"]);
function parseAllowlistCommand(raw: string): AllowlistCommand | null {
const trimmed = raw.trim();
if (!trimmed.toLowerCase().startsWith("/allowlist")) return null;
const rest = trimmed.slice("/allowlist".length).trim();
if (!rest) return { action: "list", scope: "dm" };
const tokens = rest.split(/\s+/);
let action: AllowlistAction = "list";
let scope: AllowlistScope = "dm";
let resolve = false;
let target: AllowlistTarget = "both";
let channel: string | undefined;
let account: string | undefined;
const entryTokens: string[] = [];
let i = 0;
if (tokens[i] && ACTIONS.has(tokens[i].toLowerCase())) {
action = tokens[i].toLowerCase() as AllowlistAction;
i += 1;
}
if (tokens[i] && SCOPES.has(tokens[i].toLowerCase() as AllowlistScope)) {
scope = tokens[i].toLowerCase() as AllowlistScope;
i += 1;
}
for (; i < tokens.length; i += 1) {
const token = tokens[i];
const lowered = token.toLowerCase();
if (lowered === "--resolve" || lowered === "resolve") {
resolve = true;
continue;
}
if (lowered === "--config" || lowered === "config") {
target = "config";
continue;
}
if (lowered === "--store" || lowered === "store") {
target = "store";
continue;
}
if (lowered === "--channel" && tokens[i + 1]) {
channel = tokens[i + 1];
i += 1;
continue;
}
if (lowered === "--account" && tokens[i + 1]) {
account = tokens[i + 1];
i += 1;
continue;
}
const kv = token.split("=");
if (kv.length === 2) {
const key = kv[0]?.trim().toLowerCase();
const value = kv[1]?.trim();
if (key === "channel") {
if (value) channel = value;
continue;
}
if (key === "account") {
if (value) account = value;
continue;
}
if (key === "scope" && value && SCOPES.has(value.toLowerCase() as AllowlistScope)) {
scope = value.toLowerCase() as AllowlistScope;
continue;
}
}
entryTokens.push(token);
}
if (action === "add" || action === "remove") {
const entry = entryTokens.join(" ").trim();
if (!entry) {
return { action: "error", message: "Usage: /allowlist add|remove <entry>" };
}
return { action, scope, entry, channel, account, resolve, target };
}
return { action: "list", scope, channel, account, resolve };
}
function normalizeAllowFrom(params: {
cfg: ClawdbotConfig;
channelId: ChannelId;
accountId?: string | null;
values: Array<string | number>;
}): string[] {
const dock = getChannelDock(params.channelId);
if (dock?.config?.formatAllowFrom) {
return dock.config.formatAllowFrom({
cfg: params.cfg,
accountId: params.accountId,
allowFrom: params.values,
});
}
return params.values.map((entry) => String(entry).trim()).filter(Boolean);
}
function formatEntryList(entries: string[], resolved?: Map<string, string>): string {
if (entries.length === 0) return "(none)";
return entries
.map((entry) => {
const name = resolved?.get(entry);
return name ? `${entry} (${name})` : entry;
})
.join(", ");
}
function resolveAccountTarget(
parsed: Record<string, unknown>,
channelId: ChannelId,
accountId?: string | null,
) {
const channels = (parsed.channels ??= {}) as Record<string, unknown>;
const channel = (channels[channelId] ??= {}) as Record<string, unknown>;
const normalizedAccountId = normalizeAccountId(accountId);
const hasAccounts = Boolean(channel.accounts && typeof channel.accounts === "object");
const useAccount = normalizedAccountId !== DEFAULT_ACCOUNT_ID || hasAccounts;
if (!useAccount) {
return { target: channel, pathPrefix: `channels.${channelId}`, accountId: normalizedAccountId };
}
const accounts = (channel.accounts ??= {}) as Record<string, unknown>;
const account = (accounts[normalizedAccountId] ??= {}) as Record<string, unknown>;
return {
target: account,
pathPrefix: `channels.${channelId}.accounts.${normalizedAccountId}`,
accountId: normalizedAccountId,
};
}
function getNestedValue(root: Record<string, unknown>, path: string[]): unknown {
let current: unknown = root;
for (const key of path) {
if (!current || typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[key];
}
return current;
}
function ensureNestedObject(
root: Record<string, unknown>,
path: string[],
): Record<string, unknown> {
let current = root;
for (const key of path) {
const existing = current[key];
if (!existing || typeof existing !== "object") {
current[key] = {};
}
current = current[key] as Record<string, unknown>;
}
return current;
}
function setNestedValue(root: Record<string, unknown>, path: string[], value: unknown) {
if (path.length === 0) return;
if (path.length === 1) {
root[path[0]] = value;
return;
}
const parent = ensureNestedObject(root, path.slice(0, -1));
parent[path[path.length - 1]] = value;
}
function deleteNestedValue(root: Record<string, unknown>, path: string[]) {
if (path.length === 0) return;
if (path.length === 1) {
delete root[path[0]];
return;
}
const parent = getNestedValue(root, path.slice(0, -1));
if (!parent || typeof parent !== "object") return;
delete (parent as Record<string, unknown>)[path[path.length - 1]];
}
function resolveChannelAllowFromPaths(
channelId: ChannelId,
scope: AllowlistScope,
): string[] | null {
if (scope === "all") return null;
if (scope === "dm") {
if (channelId === "slack" || channelId === "discord") return ["dm", "allowFrom"];
if (
channelId === "telegram" ||
channelId === "whatsapp" ||
channelId === "signal" ||
channelId === "imessage"
) {
return ["allowFrom"];
}
return null;
}
if (scope === "group") {
if (
channelId === "telegram" ||
channelId === "whatsapp" ||
channelId === "signal" ||
channelId === "imessage"
) {
return ["groupAllowFrom"];
}
return null;
}
return null;
}
async function resolveSlackNames(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
entries: string[];
}) {
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const token = account.config.userToken?.trim() || account.botToken?.trim();
if (!token) return new Map<string, string>();
const resolved = await resolveSlackUserAllowlist({ token, entries: params.entries });
const map = new Map<string, string>();
for (const entry of resolved) {
if (entry.resolved && entry.name) map.set(entry.input, entry.name);
}
return map;
}
async function resolveDiscordNames(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
entries: string[];
}) {
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
const token = account.token?.trim();
if (!token) return new Map<string, string>();
const resolved = await resolveDiscordUserAllowlist({ token, entries: params.entries });
const map = new Map<string, string>();
for (const entry of resolved) {
if (entry.resolved && entry.name) map.set(entry.input, entry.name);
}
return map;
}
export const handleAllowlistCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const parsed = parseAllowlistCommand(params.command.commandBodyNormalized);
if (!parsed) return null;
if (parsed.action === "error") {
return { shouldContinue: false, reply: { text: `⚠️ ${parsed.message}` } };
}
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /allowlist from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
const channelId =
normalizeChannelId(parsed.channel) ??
params.command.channelId ??
normalizeChannelId(params.command.channel);
if (!channelId) {
return {
shouldContinue: false,
reply: { text: "⚠️ Unknown channel. Add channel=<id> to the command." },
};
}
const accountId = normalizeAccountId(parsed.account ?? params.ctx.AccountId);
const scope = parsed.scope;
if (parsed.action === "list") {
const pairingChannels = listPairingChannels();
const supportsStore = pairingChannels.includes(channelId);
const storeAllowFrom = supportsStore
? await readChannelAllowFromStore(channelId).catch(() => [])
: [];
let dmAllowFrom: string[] = [];
let groupAllowFrom: string[] = [];
let groupOverrides: Array<{ label: string; entries: string[] }> = [];
let dmPolicy: string | undefined;
let groupPolicy: string | undefined;
if (channelId === "telegram") {
const account = resolveTelegramAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.config.allowFrom ?? []).map(String);
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
dmPolicy = account.config.dmPolicy;
groupPolicy = account.config.groupPolicy;
const groups = account.config.groups ?? {};
for (const [groupId, groupCfg] of Object.entries(groups)) {
const entries = (groupCfg?.allowFrom ?? []).map(String).filter(Boolean);
if (entries.length > 0) {
groupOverrides.push({ label: groupId, entries });
}
const topics = groupCfg?.topics ?? {};
for (const [topicId, topicCfg] of Object.entries(topics)) {
const topicEntries = (topicCfg?.allowFrom ?? []).map(String).filter(Boolean);
if (topicEntries.length > 0) {
groupOverrides.push({ label: `${groupId} topic ${topicId}`, entries: topicEntries });
}
}
}
} else if (channelId === "whatsapp") {
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.allowFrom ?? []).map(String);
groupAllowFrom = (account.groupAllowFrom ?? []).map(String);
dmPolicy = account.dmPolicy;
groupPolicy = account.groupPolicy;
} else if (channelId === "signal") {
const account = resolveSignalAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.config.allowFrom ?? []).map(String);
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
dmPolicy = account.config.dmPolicy;
groupPolicy = account.config.groupPolicy;
} else if (channelId === "imessage") {
const account = resolveIMessageAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.config.allowFrom ?? []).map(String);
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
dmPolicy = account.config.dmPolicy;
groupPolicy = account.config.groupPolicy;
} else if (channelId === "slack") {
const account = resolveSlackAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.dm?.allowFrom ?? []).map(String);
groupPolicy = account.groupPolicy;
const channels = account.channels ?? {};
groupOverrides = Object.entries(channels)
.map(([key, value]) => {
const entries = (value?.users ?? []).map(String).filter(Boolean);
return entries.length > 0 ? { label: key, entries } : null;
})
.filter(Boolean) as Array<{ label: string; entries: string[] }>;
} else if (channelId === "discord") {
const account = resolveDiscordAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.config.dm?.allowFrom ?? []).map(String);
groupPolicy = account.config.groupPolicy;
const guilds = account.config.guilds ?? {};
for (const [guildKey, guildCfg] of Object.entries(guilds)) {
const entries = (guildCfg?.users ?? []).map(String).filter(Boolean);
if (entries.length > 0) {
groupOverrides.push({ label: `guild ${guildKey}`, entries });
}
const channels = guildCfg?.channels ?? {};
for (const [channelKey, channelCfg] of Object.entries(channels)) {
const channelEntries = (channelCfg?.users ?? []).map(String).filter(Boolean);
if (channelEntries.length > 0) {
groupOverrides.push({
label: `guild ${guildKey} / channel ${channelKey}`,
entries: channelEntries,
});
}
}
}
}
const dmDisplay = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId,
values: dmAllowFrom,
});
const groupDisplay = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId,
values: groupAllowFrom,
});
const groupOverrideEntries = groupOverrides.flatMap((entry) => entry.entries);
const groupOverrideDisplay = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId,
values: groupOverrideEntries,
});
const resolvedDm =
parsed.resolve && dmDisplay.length > 0 && channelId === "slack"
? await resolveSlackNames({ cfg: params.cfg, accountId, entries: dmDisplay })
: parsed.resolve && dmDisplay.length > 0 && channelId === "discord"
? await resolveDiscordNames({ cfg: params.cfg, accountId, entries: dmDisplay })
: undefined;
const resolvedGroup =
parsed.resolve && groupOverrideDisplay.length > 0 && channelId === "slack"
? await resolveSlackNames({
cfg: params.cfg,
accountId,
entries: groupOverrideDisplay,
})
: parsed.resolve && groupOverrideDisplay.length > 0 && channelId === "discord"
? await resolveDiscordNames({
cfg: params.cfg,
accountId,
entries: groupOverrideDisplay,
})
: undefined;
const lines: string[] = ["🧾 Allowlist"];
lines.push(`Channel: ${channelId}${accountId ? ` (account ${accountId})` : ""}`);
if (dmPolicy) lines.push(`DM policy: ${dmPolicy}`);
if (groupPolicy) lines.push(`Group policy: ${groupPolicy}`);
const showDm = scope === "dm" || scope === "all";
const showGroup = scope === "group" || scope === "all";
if (showDm) {
lines.push(`DM allowFrom (config): ${formatEntryList(dmDisplay, resolvedDm)}`);
}
if (supportsStore && storeAllowFrom.length > 0) {
const storeLabel = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId,
values: storeAllowFrom,
});
lines.push(`Paired allowFrom (store): ${formatEntryList(storeLabel)}`);
}
if (showGroup) {
if (groupAllowFrom.length > 0) {
lines.push(`Group allowFrom (config): ${formatEntryList(groupDisplay)}`);
}
if (groupOverrides.length > 0) {
lines.push("Group overrides:");
for (const entry of groupOverrides) {
const normalized = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId,
values: entry.entries,
});
lines.push(`- ${entry.label}: ${formatEntryList(normalized, resolvedGroup)}`);
}
}
}
return { shouldContinue: false, reply: { text: lines.join("\n") } };
}
if (params.cfg.commands?.config !== true) {
return {
shouldContinue: false,
reply: { text: "⚠️ /allowlist edits are disabled. Set commands.config=true to enable." },
};
}
const shouldUpdateConfig = parsed.target !== "store";
const shouldTouchStore = parsed.target !== "config" && listPairingChannels().includes(channelId);
if (shouldUpdateConfig) {
const allowWrites = resolveChannelConfigWrites({
cfg: params.cfg,
channelId,
accountId: params.ctx.AccountId,
});
if (!allowWrites) {
const hint = `channels.${channelId}.configWrites=true`;
return {
shouldContinue: false,
reply: { text: `⚠️ Config writes are disabled for ${channelId}. Set ${hint} to enable.` },
};
}
const allowlistPath = resolveChannelAllowFromPaths(channelId, scope);
if (!allowlistPath) {
return {
shouldContinue: false,
reply: {
text: `⚠️ ${channelId} does not support ${scope} allowlist edits via /allowlist.`,
},
};
}
const snapshot = await readConfigFileSnapshot();
if (!snapshot.valid || !snapshot.parsed || typeof snapshot.parsed !== "object") {
return {
shouldContinue: false,
reply: { text: "⚠️ Config file is invalid; fix it before using /allowlist." },
};
}
const parsedConfig = structuredClone(snapshot.parsed as Record<string, unknown>);
const {
target,
pathPrefix,
accountId: normalizedAccountId,
} = resolveAccountTarget(parsedConfig, channelId, accountId);
const existingRaw = getNestedValue(target, allowlistPath);
const existing = Array.isArray(existingRaw)
? existingRaw.map((entry) => String(entry).trim()).filter(Boolean)
: [];
const normalizedEntry = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId: normalizedAccountId,
values: [parsed.entry],
});
if (normalizedEntry.length === 0) {
return {
shouldContinue: false,
reply: { text: "⚠️ Invalid allowlist entry." },
};
}
const existingNormalized = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId: normalizedAccountId,
values: existing,
});
const shouldMatch = (value: string) => normalizedEntry.includes(value);
let configChanged = false;
let next = existing;
const configHasEntry = existingNormalized.some((value) => shouldMatch(value));
if (parsed.action === "add") {
if (!configHasEntry) {
next = [...existing, parsed.entry.trim()];
configChanged = true;
}
}
if (parsed.action === "remove") {
const keep: string[] = [];
for (const entry of existing) {
const normalized = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId: normalizedAccountId,
values: [entry],
});
if (normalized.some((value) => shouldMatch(value))) {
configChanged = true;
continue;
}
keep.push(entry);
}
next = keep;
}
if (configChanged) {
if (next.length === 0) {
deleteNestedValue(target, allowlistPath);
} else {
setNestedValue(target, allowlistPath, next);
}
}
if (configChanged) {
const validated = validateConfigObjectWithPlugins(parsedConfig);
if (!validated.ok) {
const issue = validated.issues[0];
return {
shouldContinue: false,
reply: { text: `⚠️ Config invalid after update (${issue.path}: ${issue.message}).` },
};
}
await writeConfigFile(validated.config);
}
if (!configChanged && !shouldTouchStore) {
const message = parsed.action === "add" ? "✅ Already allowlisted." : "⚠️ Entry not found.";
return { shouldContinue: false, reply: { text: message } };
}
if (shouldTouchStore) {
if (parsed.action === "add") {
await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
} else if (parsed.action === "remove") {
await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
}
}
const actionLabel = parsed.action === "add" ? "added" : "removed";
const scopeLabel = scope === "dm" ? "DM" : "group";
const locations: string[] = [];
if (configChanged) {
locations.push(`${pathPrefix}.${allowlistPath.join(".")}`);
}
if (shouldTouchStore) {
locations.push("pairing store");
}
const targetLabel = locations.length > 0 ? locations.join(" + ") : "no-op";
return {
shouldContinue: false,
reply: {
text: `✅ ${scopeLabel} allowlist ${actionLabel}: ${targetLabel}.`,
},
};
}
if (!shouldTouchStore) {
return {
shouldContinue: false,
reply: { text: "⚠️ This channel does not support allowlist storage." },
};
}
if (parsed.action === "add") {
await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
} else if (parsed.action === "remove") {
await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
}
const actionLabel = parsed.action === "add" ? "added" : "removed";
const scopeLabel = scope === "dm" ? "DM" : "group";
return {
shouldContinue: false,
reply: { text: `✅ ${scopeLabel} allowlist ${actionLabel} in pairing store.` },
};
};
+2
View File
@@ -13,6 +13,7 @@ import {
handleStatusCommand, handleStatusCommand,
handleWhoamiCommand, handleWhoamiCommand,
} from "./commands-info.js"; } from "./commands-info.js";
import { handleAllowlistCommand } from "./commands-allowlist.js";
import { handleSubagentsCommand } from "./commands-subagents.js"; import { handleSubagentsCommand } from "./commands-subagents.js";
import { import {
handleAbortTrigger, handleAbortTrigger,
@@ -37,6 +38,7 @@ const HANDLERS: CommandHandler[] = [
handleHelpCommand, handleHelpCommand,
handleCommandsListCommand, handleCommandsListCommand,
handleStatusCommand, handleStatusCommand,
handleAllowlistCommand,
handleContextCommand, handleContextCommand,
handleWhoamiCommand, handleWhoamiCommand,
handleSubagentsCommand, handleSubagentsCommand,
@@ -10,6 +10,7 @@ import { type SessionEntry, updateSessionStore } from "../../config/sessions.js"
import type { ExecAsk, ExecHost, ExecSecurity } from "../../infra/exec-approvals.js"; import type { ExecAsk, ExecHost, ExecSecurity } from "../../infra/exec-approvals.js";
import { enqueueSystemEvent } from "../../infra/system-events.js"; import { enqueueSystemEvent } from "../../infra/system-events.js";
import { applyVerboseOverride } from "../../sessions/level-overrides.js"; import { applyVerboseOverride } from "../../sessions/level-overrides.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import { formatThinkingLevels, formatXHighModelHint, supportsXHighThinking } from "../thinking.js"; import { formatThinkingLevels, formatXHighModelHint, supportsXHighThinking } from "../thinking.js";
import type { ReplyPayload } from "../types.js"; import type { ReplyPayload } from "../types.js";
import { import {
@@ -340,22 +341,11 @@ export async function handleDirectiveOnly(params: {
} }
} }
if (modelSelection) { if (modelSelection) {
if (modelSelection.isDefault) { applyModelOverrideToSessionEntry({
delete sessionEntry.providerOverride; entry: sessionEntry,
delete sessionEntry.modelOverride; selection: modelSelection,
} else { profileOverride,
sessionEntry.providerOverride = modelSelection.provider; });
sessionEntry.modelOverride = modelSelection.model;
}
if (profileOverride) {
sessionEntry.authProfileOverride = profileOverride;
sessionEntry.authProfileOverrideSource = "user";
delete sessionEntry.authProfileOverrideCompactionCount;
} else if (directives.hasModelDirective) {
delete sessionEntry.authProfileOverride;
delete sessionEntry.authProfileOverrideSource;
delete sessionEntry.authProfileOverrideCompactionCount;
}
} }
if (directives.hasQueueDirective && directives.queueReset) { if (directives.hasQueueDirective && directives.queueReset) {
delete sessionEntry.queueMode; delete sessionEntry.queueMode;
@@ -36,6 +36,56 @@ function buildModelPickerCatalog(params: {
defaultModel: params.defaultModel, defaultModel: params.defaultModel,
}); });
const buildConfiguredCatalog = (): ModelPickerCatalogEntry[] => {
const out: ModelPickerCatalogEntry[] = [];
const keys = new Set<string>();
const pushRef = (ref: { provider: string; model: string }, name?: string) => {
const provider = normalizeProviderId(ref.provider);
const id = String(ref.model ?? "").trim();
if (!provider || !id) return;
const key = modelKey(provider, id);
if (keys.has(key)) return;
keys.add(key);
out.push({ provider, id, name: name ?? id });
};
const pushRaw = (raw?: string) => {
const value = String(raw ?? "").trim();
if (!value) return;
const resolved = resolveModelRefFromString({
raw: value,
defaultProvider: params.defaultProvider,
aliasIndex: params.aliasIndex,
});
if (!resolved) return;
pushRef(resolved.ref);
};
pushRef(resolvedDefault);
const modelConfig = params.cfg.agents?.defaults?.model;
const modelFallbacks =
modelConfig && typeof modelConfig === "object" ? (modelConfig.fallbacks ?? []) : [];
for (const fallback of modelFallbacks) {
pushRaw(String(fallback ?? ""));
}
const imageConfig = params.cfg.agents?.defaults?.imageModel;
if (imageConfig && typeof imageConfig === "object") {
pushRaw(imageConfig.primary);
for (const fallback of imageConfig.fallbacks ?? []) {
pushRaw(String(fallback ?? ""));
}
}
for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) {
pushRaw(raw);
}
return out;
};
const keys = new Set<string>(); const keys = new Set<string>();
const out: ModelPickerCatalogEntry[] = []; const out: ModelPickerCatalogEntry[] = [];
@@ -49,6 +99,21 @@ function buildModelPickerCatalog(params: {
out.push({ provider, id, name: entry.name }); out.push({ provider, id, name: entry.name });
}; };
const hasAllowlist = Object.keys(params.cfg.agents?.defaults?.models ?? {}).length > 0;
if (!hasAllowlist) {
for (const entry of params.allowedModelCatalog) {
push({
provider: entry.provider,
id: entry.id ?? "",
name: entry.name,
});
}
for (const entry of buildConfiguredCatalog()) {
push(entry);
}
return out;
}
// Prefer catalog entries (when available), but always merge in config-only // Prefer catalog entries (when available), but always merge in config-only
// allowlist entries. This keeps custom providers/models visible in /model. // allowlist entries. This keeps custom providers/models visible in /model.
for (const entry of params.allowedModelCatalog) { for (const entry of params.allowedModelCatalog) {
@@ -16,6 +16,7 @@ import type { ClawdbotConfig } from "../../config/config.js";
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js"; import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
import { enqueueSystemEvent } from "../../infra/system-events.js"; import { enqueueSystemEvent } from "../../infra/system-events.js";
import { applyVerboseOverride } from "../../sessions/level-overrides.js"; import { applyVerboseOverride } from "../../sessions/level-overrides.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import { resolveProfileOverride } from "./directive-handling.auth.js"; import { resolveProfileOverride } from "./directive-handling.auth.js";
import type { InlineDirectives } from "./directive-handling.parse.js"; import type { InlineDirectives } from "./directive-handling.parse.js";
import { formatElevatedEvent, formatReasoningEvent } from "./directive-handling.shared.js"; import { formatElevatedEvent, formatReasoningEvent } from "./directive-handling.shared.js";
@@ -164,22 +165,15 @@ export async function persistInlineDirectives(params: {
} }
const isDefault = const isDefault =
resolved.ref.provider === defaultProvider && resolved.ref.model === defaultModel; resolved.ref.provider === defaultProvider && resolved.ref.model === defaultModel;
if (isDefault) { const { updated: modelUpdated } = applyModelOverrideToSessionEntry({
delete sessionEntry.providerOverride; entry: sessionEntry,
delete sessionEntry.modelOverride; selection: {
} else { provider: resolved.ref.provider,
sessionEntry.providerOverride = resolved.ref.provider; model: resolved.ref.model,
sessionEntry.modelOverride = resolved.ref.model; isDefault,
} },
if (profileOverride) { profileOverride,
sessionEntry.authProfileOverride = profileOverride; });
sessionEntry.authProfileOverrideSource = "user";
delete sessionEntry.authProfileOverrideCompactionCount;
} else if (directives.hasModelDirective) {
delete sessionEntry.authProfileOverride;
delete sessionEntry.authProfileOverrideSource;
delete sessionEntry.authProfileOverrideCompactionCount;
}
provider = resolved.ref.provider; provider = resolved.ref.provider;
model = resolved.ref.model; model = resolved.ref.model;
const nextLabel = `${provider}/${model}`; const nextLabel = `${provider}/${model}`;
@@ -189,7 +183,7 @@ export async function persistInlineDirectives(params: {
contextKey: `model:${nextLabel}`, contextKey: `model:${nextLabel}`,
}); });
} }
updated = true; updated = updated || modelUpdated;
} }
} }
} }
+2 -93
View File
@@ -5,16 +5,11 @@ import {
isEmbeddedPiRunStreaming, isEmbeddedPiRunStreaming,
resolveEmbeddedSessionLane, resolveEmbeddedSessionLane,
} from "../../agents/pi-embedded.js"; } from "../../agents/pi-embedded.js";
import { import { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js";
ensureAuthProfileStore,
isProfileInCooldown,
resolveAuthProfileOrder,
} from "../../agents/auth-profiles.js";
import type { ExecToolDefaults } from "../../agents/bash-tools.js"; import type { ExecToolDefaults } from "../../agents/bash-tools.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { import {
resolveSessionFilePath, resolveSessionFilePath,
saveSessionStore,
type SessionEntry, type SessionEntry,
updateSessionStore, updateSessionStore,
} from "../../config/sessions.js"; } from "../../config/sessions.js";
@@ -108,92 +103,6 @@ type RunPreparedReplyParams = {
abortedLastRun: boolean; abortedLastRun: boolean;
}; };
async function resolveSessionAuthProfileOverride(params: {
cfg: ClawdbotConfig;
provider: string;
agentDir: string;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
isNewSession: boolean;
}): Promise<string | undefined> {
const {
cfg,
provider,
agentDir,
sessionEntry,
sessionStore,
sessionKey,
storePath,
isNewSession,
} = params;
if (!sessionEntry || !sessionStore || !sessionKey) return sessionEntry?.authProfileOverride;
const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
const order = resolveAuthProfileOrder({ cfg, store, provider });
if (order.length === 0) return sessionEntry.authProfileOverride;
const pickFirstAvailable = () =>
order.find((profileId) => !isProfileInCooldown(store, profileId)) ?? order[0];
const pickNextAvailable = (current: string) => {
const startIndex = order.indexOf(current);
if (startIndex < 0) return pickFirstAvailable();
for (let offset = 1; offset <= order.length; offset += 1) {
const candidate = order[(startIndex + offset) % order.length];
if (!isProfileInCooldown(store, candidate)) return candidate;
}
return order[startIndex] ?? order[0];
};
const compactionCount = sessionEntry.compactionCount ?? 0;
const storedCompaction =
typeof sessionEntry.authProfileOverrideCompactionCount === "number"
? sessionEntry.authProfileOverrideCompactionCount
: compactionCount;
let current = sessionEntry.authProfileOverride?.trim();
if (current && !order.includes(current)) current = undefined;
const source =
sessionEntry.authProfileOverrideSource ??
(typeof sessionEntry.authProfileOverrideCompactionCount === "number"
? "auto"
: current
? "user"
: undefined);
if (source === "user" && current && !isNewSession) {
return current;
}
let next = current;
if (isNewSession) {
next = current ? pickNextAvailable(current) : pickFirstAvailable();
} else if (current && compactionCount > storedCompaction) {
next = pickNextAvailable(current);
} else if (!current || isProfileInCooldown(store, current)) {
next = pickFirstAvailable();
}
if (!next) return current;
const shouldPersist =
next !== sessionEntry.authProfileOverride ||
sessionEntry.authProfileOverrideSource !== "auto" ||
sessionEntry.authProfileOverrideCompactionCount !== compactionCount;
if (shouldPersist) {
sessionEntry.authProfileOverride = next;
sessionEntry.authProfileOverrideSource = "auto";
sessionEntry.authProfileOverrideCompactionCount = compactionCount;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
return next;
}
export async function runPreparedReply( export async function runPreparedReply(
params: RunPreparedReplyParams, params: RunPreparedReplyParams,
): Promise<ReplyPayload | ReplyPayload[] | undefined> { ): Promise<ReplyPayload | ReplyPayload[] | undefined> {
@@ -441,7 +350,7 @@ export async function runPreparedReply(
const authProfileIdSource = sessionEntry?.authProfileOverrideSource; const authProfileIdSource = sessionEntry?.authProfileOverrideSource;
const followupRun = { const followupRun = {
prompt: queuedBody, prompt: queuedBody,
messageId: sessionCtx.MessageSid, messageId: sessionCtx.MessageSidFull ?? sessionCtx.MessageSid,
summaryLine: baseBodyTrimmedRaw, summaryLine: baseBodyTrimmedRaw,
enqueuedAt: Date.now(), enqueuedAt: Date.now(),
// Originating channel for reply routing. // Originating channel for reply routing.
+22 -20
View File
@@ -11,6 +11,8 @@ import {
} from "../../agents/model-selection.js"; } from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js"; import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
import { clearSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import type { ThinkLevel } from "./directives.js"; import type { ThinkLevel } from "./directives.js";
export type ModelDirectiveSelection = { export type ModelDirectiveSelection = {
@@ -184,16 +186,19 @@ export async function createModelSelectionState(params: {
if (overrideModel) { if (overrideModel) {
const key = modelKey(overrideProvider, overrideModel); const key = modelKey(overrideProvider, overrideModel);
if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) { if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) {
delete sessionEntry.providerOverride; const { updated } = applyModelOverrideToSessionEntry({
delete sessionEntry.modelOverride; entry: sessionEntry,
sessionEntry.updatedAt = Date.now(); selection: { provider: defaultProvider, model: defaultModel, isDefault: true },
sessionStore[sessionKey] = sessionEntry; });
if (storePath) { if (updated) {
await updateSessionStore(storePath, (store) => { sessionStore[sessionKey] = sessionEntry;
store[sessionKey] = sessionEntry; if (storePath) {
}); await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
} }
resetModelOverride = true; resetModelOverride = updated;
} }
} }
} }
@@ -215,17 +220,14 @@ export async function createModelSelectionState(params: {
allowKeychainPrompt: false, allowKeychainPrompt: false,
}); });
const profile = store.profiles[sessionEntry.authProfileOverride]; const profile = store.profiles[sessionEntry.authProfileOverride];
if (!profile || profile.provider !== provider) { const providerKey = normalizeProviderId(provider);
delete sessionEntry.authProfileOverride; if (!profile || normalizeProviderId(profile.provider) !== providerKey) {
delete sessionEntry.authProfileOverrideSource; await clearSessionAuthProfileOverride({
delete sessionEntry.authProfileOverrideCompactionCount; sessionEntry,
sessionEntry.updatedAt = Date.now(); sessionStore,
sessionStore[sessionKey] = sessionEntry; sessionKey,
if (storePath) { storePath,
await updateSessionStore(storePath, (store) => { });
store[sessionKey] = sessionEntry;
});
}
} }
} }
+16
View File
@@ -209,6 +209,22 @@ describe("routeReply", () => {
expect(mocks.sendMessageSlack).toHaveBeenCalledWith("channel:C123", "hi", expect.any(Object)); expect(mocks.sendMessageSlack).toHaveBeenCalledWith("channel:C123", "hi", expect.any(Object));
}); });
it("uses threadId for Slack when replyToId is missing", async () => {
mocks.sendMessageSlack.mockClear();
await routeReply({
payload: { text: "hi" },
channel: "slack",
to: "channel:C123",
threadId: "456.789",
cfg: {} as never,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"hi",
expect.objectContaining({ threadTs: "456.789" }),
);
});
it("passes thread id to Telegram sends", async () => { it("passes thread id to Telegram sends", async () => {
mocks.sendMessageTelegram.mockClear(); mocks.sendMessageTelegram.mockClear();
await routeReply({ await routeReply({
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff. Show more