Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56fab1df69 | ||
|
|
bf529f56b4 | ||
|
|
1ada67ffb3 | ||
|
|
c607dfc1d6 | ||
|
|
39e24c9937 | ||
|
|
fa1bc589e4 | ||
|
|
0e003cb7f1 | ||
|
|
fb164b321e | ||
|
|
884211a924 | ||
|
|
9bd6b3fd54 | ||
|
|
dc06b225cd | ||
|
|
cdb35c3aae | ||
|
|
4e4f5558fc | ||
|
|
8479dc97da | ||
|
|
86ddd3c69c | ||
|
|
49d53ff0bb | ||
|
|
97e8f9d619 | ||
|
|
5392fa0dfa | ||
|
|
63d017c3af | ||
|
|
40646c73af | ||
|
|
43ea7665ef | ||
|
|
ba131b0164 | ||
|
|
0693c7804f | ||
|
|
6c69ea2c91 | ||
|
|
1e10dc1d3b | ||
|
|
c22a37976d | ||
|
|
89c5035aa2 | ||
|
|
cb7791c8a4 | ||
|
|
9a14267dfa | ||
|
|
010d305401 | ||
|
|
3210c91f6b | ||
|
|
e3cea55d72 | ||
|
|
687a902f3e | ||
|
|
fe860de148 | ||
|
|
bc8a59faa4 | ||
|
|
91bcdad503 | ||
|
|
ab97c6880b | ||
|
|
65dd73b4c3 | ||
|
|
b69aa011fe | ||
|
|
e3a44b10bc | ||
|
|
5b8007784b | ||
|
|
0d6e78b718 | ||
|
|
46ab4cb19e | ||
|
|
32edaad823 | ||
|
|
5dcd48544a | ||
|
|
1e05925e47 | ||
|
|
fb47f1cbeb | ||
|
|
15d1421cf2 | ||
|
|
899bbd40d7 | ||
|
|
555b2578a8 | ||
|
|
0229b8bbd8 | ||
|
|
552f9eff7b | ||
|
|
36e0cffaaf | ||
|
|
e17a9c6abf | ||
|
|
6180603ef4 | ||
|
|
810374d648 | ||
|
|
968b967854 | ||
|
|
110079d99d | ||
|
|
34a126a6d7 | ||
|
|
31462f64d8 | ||
|
|
de0a488985 | ||
|
|
b46855d8c4 | ||
|
|
feaad8250b |
No files matched your search
@@ -100,7 +100,7 @@
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
+178
-267
@@ -2,291 +2,202 @@
|
||||
|
||||
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
|
||||
|
||||
### Changes
|
||||
- Deps: update workspace + memory-lancedb dependencies.
|
||||
- Dev: use tsgo for dev/watch builds by default; set `CLAWDBOT_TS_COMPILER=tsc` to opt out.
|
||||
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
||||
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
|
||||
- Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui
|
||||
- Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui
|
||||
- TUI: add syntax highlighting for code blocks. (#1200) https://docs.clawd.bot/tui
|
||||
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) https://docs.clawd.bot/tui
|
||||
- TUI: add a searchable model picker for quicker model selection. (#1198) https://docs.clawd.bot/tui
|
||||
- TUI: add input history (up/down) for submitted messages. (#1348) https://docs.clawd.bot/tui
|
||||
- ACP: add `clawdbot acp` for IDE integrations. https://docs.clawd.bot/cli/acp
|
||||
- ACP: add `clawdbot acp client` interactive harness for debugging. https://docs.clawd.bot/cli/acp
|
||||
- Skills: add download installs with OS-filtered options. https://docs.clawd.bot/tools/skills
|
||||
- Skills: add the local sherpa-onnx-tts skill. https://docs.clawd.bot/tools/skills
|
||||
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: add OpenAI batch indexing for embeddings when configured. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). https://docs.clawd.bot/concepts/memory
|
||||
- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: add `--verbose` logging for memory status + batch indexing details. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.clawd.bot/concepts/memory
|
||||
- Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) https://docs.clawd.bot/tools/browser
|
||||
- Nostr: add the Nostr channel plugin with profile management + onboarding defaults. (#1323) https://docs.clawd.bot/channels/nostr
|
||||
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) https://docs.clawd.bot/channels/matrix
|
||||
- Slack: add HTTP webhook mode via Bolt HTTP receiver. (#1143) https://docs.clawd.bot/channels/slack
|
||||
- Telegram: enrich forwarded-message context with normalized origin details + legacy fallback. (#1090) https://docs.clawd.bot/channels/telegram
|
||||
- Discord: fall back to `/skill` when native command limits are exceeded. (#1287)
|
||||
- Discord: expose `/skill` globally. (#1287)
|
||||
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) https://docs.clawd.bot/plugins/zalouser
|
||||
- Plugins: require manifest-embedded config schemas with preflight validation warnings. (#1272) https://docs.clawd.bot/plugins/manifest
|
||||
- Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest
|
||||
- Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest
|
||||
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui
|
||||
- Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools
|
||||
- Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles
|
||||
- Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.
|
||||
- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime. https://docs.clawd.bot/channels/zalo
|
||||
- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime. https://docs.clawd.bot/plugins/zalouser
|
||||
- Plugins: allow optional agent tools with explicit allowlists and add the plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
|
||||
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
|
||||
- Plugins: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
|
||||
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
|
||||
- Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) — thanks @sebslight.
|
||||
- Channels: add the Nostr plugin channel with profile management + onboarding install defaults. (#1323) — thanks @joelklabo.
|
||||
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
|
||||
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
|
||||
- Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow.
|
||||
- Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0.
|
||||
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
|
||||
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
|
||||
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
|
||||
- Security: warn when <=300B models run without sandboxing and with web tools enabled.
|
||||
- Skills: add download installs with OS-filtered install options; add local sherpa-onnx-tts skill.
|
||||
- Docs: clarify WhatsApp voice notes and Windows WSL portproxy LAN access notes.
|
||||
- UI: add copy-as-markdown with error feedback and drop legacy list view. (#1345) — thanks @bradleypriest.
|
||||
- TUI: add input history (up/down) for submitted messages. (#1348) — thanks @vignesh07.
|
||||
### Fixes
|
||||
- iOS: explain Talk mode is unavailable on the simulator to avoid Speech live-audio crashes. (#1358) — thanks @vignesh07.
|
||||
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
||||
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
|
||||
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
|
||||
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
|
||||
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch; gate heartbeat/webhook logging. (#1244) — thanks @oscargavin.
|
||||
- CLI: preserve cron delivery settings when editing message payloads. (#1322) — thanks @KrauseFx.
|
||||
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output.
|
||||
- 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.
|
||||
- Gateway/API: add `/v1/responses` (OpenResponses) with item-based input + semantic streaming events. (#1229)
|
||||
- Gateway/API: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229)
|
||||
- Usage: add `/usage cost` summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs
|
||||
- Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.clawd.bot/cli/security
|
||||
- Exec: add host/security/ask routing for gateway + node exec. https://docs.clawd.bot/tools/exec
|
||||
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node). https://docs.clawd.bot/tools/exec
|
||||
- Exec approvals: migrate approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists + skill auto-allow toggle, and add approvals UI + node exec lifecycle events. https://docs.clawd.bot/tools/exec-approvals
|
||||
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`. https://docs.clawd.bot/cli/node
|
||||
- Nodes: add node daemon service install/status/start/stop/restart. https://docs.clawd.bot/cli/node
|
||||
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
|
||||
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) https://docs.clawd.bot/concepts/session
|
||||
- Sessions: allow `sessions_spawn` to override thinking level for sub-agent runs. https://docs.clawd.bot/tools/subagents
|
||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers. https://docs.clawd.bot/concepts/groups
|
||||
- Models: add Qwen Portal OAuth provider support. (#1120) https://docs.clawd.bot/providers/qwen
|
||||
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. https://docs.clawd.bot/start/onboarding
|
||||
- Docs: clarify allowlist input types and onboarding behavior for messaging channels. https://docs.clawd.bot/start/onboarding
|
||||
- Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.clawd.bot/platforms/android
|
||||
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.clawd.bot/bedrock
|
||||
- Docs: clarify WhatsApp voice notes. https://docs.clawd.bot/channels/whatsapp
|
||||
- Docs: clarify Windows WSL portproxy LAN access notes. https://docs.clawd.bot/platforms/windows
|
||||
- Docs: refresh bird skill install metadata and usage notes. (#1302) https://docs.clawd.bot/tools/browser-login
|
||||
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
|
||||
- Agents: clarify node_modules read-only guidance in agent instructions.
|
||||
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
||||
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) — thanks @Whoaa512.
|
||||
|
||||
### Fixes
|
||||
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
||||
- Agents: add `clawdbot agents set-identity` helper and update bootstrap guidance for multi-agent setups. (#1222) — thanks @ThePickle31.
|
||||
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
|
||||
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
|
||||
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
|
||||
- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214) — thanks @ameno-.
|
||||
- Memory: show total file counts + scan issues in `clawdbot memory status`; fall back to non-batch embeddings after repeated batch failures.
|
||||
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
|
||||
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
|
||||
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
|
||||
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226) — thanks @VACInc. (fixes #1225)
|
||||
|
||||
## 2026.1.18-5
|
||||
|
||||
### Changes
|
||||
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
||||
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels.
|
||||
- TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07.
|
||||
- Docs: clarify allowlist input types and onboarding behavior for messaging channels.
|
||||
|
||||
### Fixes
|
||||
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
|
||||
- Docs: make docs:list fail fast with a clear error if the docs directory is missing.
|
||||
- macOS: load menu session previews asynchronously so items populate while the menu is open.
|
||||
- macOS: 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
|
||||
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
|
||||
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
|
||||
- Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.
|
||||
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
|
||||
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
|
||||
- Android: bump okhttp + dnsjava to satisfy lint dependency checks.
|
||||
- Build: update workspace + core/plugin deps.
|
||||
- Build: use tsgo for dev/watch builds by default (opt out with `CLAWDBOT_TS_COMPILER=tsc`).
|
||||
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
||||
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.
|
||||
- macOS: stop syncing Peekaboo in postinstall.
|
||||
- Swabble: use the tagged Commander Swift package release.
|
||||
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
|
||||
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
|
||||
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
|
||||
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
|
||||
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
|
||||
- Memory: add native Gemini embeddings provider for memory search. (#1151)
|
||||
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
|
||||
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
|
||||
|
||||
### Breaking
|
||||
- **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.
|
||||
|
||||
### 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.
|
||||
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166) — thanks @AlexMikhalev.
|
||||
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
||||
- Sessions: fall back to session labels when listing display names. (#1124)
|
||||
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
|
||||
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
|
||||
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
|
||||
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226)
|
||||
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
||||
- Doctor: 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: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
|
||||
|
||||
## 2026.1.18-3
|
||||
|
||||
### Changes
|
||||
- Exec: add host/security/ask routing for gateway + node exec.
|
||||
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
|
||||
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
|
||||
- macOS: add approvals socket UI server + node exec lifecycle events.
|
||||
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
|
||||
- Nodes: add node daemon service install/status/start/stop/restart.
|
||||
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
|
||||
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
|
||||
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
|
||||
- Agents: auto-inject local image references for vision models and avoid reloading history images. (#1098) — thanks @tyler6204.
|
||||
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
|
||||
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
|
||||
- ACP: add experimental ACP support for IDE integrations (`clawdbot acp`). Thanks @visionik.
|
||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
||||
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
||||
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||
- macOS: add exec-host IPC for node service `system.run` with HMAC + peer UID checks.
|
||||
|
||||
### Fixes
|
||||
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
|
||||
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
||||
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
|
||||
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
|
||||
|
||||
## 2026.1.18-2
|
||||
|
||||
### Fixes
|
||||
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
||||
|
||||
## 2026.1.18-1
|
||||
|
||||
### Changes
|
||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
||||
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
||||
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||
|
||||
### Fixes
|
||||
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
||||
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
||||
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
||||
- Discord: only emit slow listener warnings after 30s.
|
||||
|
||||
## 2026.1.17-6
|
||||
|
||||
### Changes
|
||||
- Plugins: add exclusive plugin slots with a dedicated memory slot selector.
|
||||
- Memory: ship core memory tools + CLI as the bundled `memory-core` plugin.
|
||||
- Docs: document plugin slots and memory plugin behavior.
|
||||
- Plugins: add the bundled BlueBubbles channel plugin (disabled by default).
|
||||
- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
|
||||
- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime.
|
||||
- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime.
|
||||
|
||||
## 2026.1.17-5
|
||||
|
||||
### Changes
|
||||
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback.
|
||||
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates.
|
||||
- CLI: surface FTS + embedding cache state in `clawdbot memory status`.
|
||||
- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default.
|
||||
- Plugins: allow optional agent tools with explicit allowlists and add plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
|
||||
- Tools: centralize plugin tool policy helpers.
|
||||
- Commands: add `/subagents info` and show sub-agent counts in `/status`.
|
||||
- Docs: clarify plugin agent tool configuration. https://docs.clawd.bot/plugins/agent-tools
|
||||
|
||||
### Fixes
|
||||
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
|
||||
|
||||
## 2026.1.18-1
|
||||
|
||||
### Changes
|
||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
||||
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
||||
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||
|
||||
### Fixes
|
||||
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
||||
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
||||
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
||||
- Discord: only emit slow listener warnings after 30s.
|
||||
## 2026.1.17-3
|
||||
|
||||
### Changes
|
||||
- Memory: add OpenAI Batch API indexing for embeddings when configured.
|
||||
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings.
|
||||
|
||||
### Fixes
|
||||
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
|
||||
|
||||
## 2026.1.17-2
|
||||
|
||||
### Changes
|
||||
|
||||
### Fixes
|
||||
- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.
|
||||
- Memory: parallelize embedding indexing with rate-limit retries.
|
||||
- Memory: split overly long lines to keep embeddings under token limits.
|
||||
- Memory: skip empty chunks to avoid invalid embedding inputs.
|
||||
- Sessions: fall back to session labels when listing display names. (#1124) — thanks @abdaraxus.
|
||||
- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123) — thanks @thewilloftheshadow.
|
||||
|
||||
## 2026.1.17-1
|
||||
|
||||
### Changes
|
||||
- Telegram: enrich forwarded message context with normalized origin details + legacy fallback. (#1090) — thanks @sleontenko.
|
||||
- macOS: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x.
|
||||
- macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg.
|
||||
- CLI: surface update availability in `clawdbot status`.
|
||||
- CLI: add `clawdbot memory status --deep/--index` probes.
|
||||
- CLI: add playful update completion quips.
|
||||
|
||||
### Fixes
|
||||
- Doctor: avoid re-adding WhatsApp ack reaction config when only legacy auth files exist. (#1087) — thanks @YuriNachos.
|
||||
- Hooks: parse multi-line/YAML frontmatter metadata blocks (JSON5-friendly). (#1114) — thanks @sebslight.
|
||||
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
|
||||
- Windows: install gateway scheduled task as the current user; show friendly guidance instead of failing on access denied.
|
||||
- Status: show both usage windows with reset hints when usage data is available. (#1101) — thanks @rhjoh.
|
||||
- Memory: probe sqlite-vec availability in `clawdbot memory status`.
|
||||
- Memory: split embedding batches to avoid OpenAI token limits during indexing.
|
||||
- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118) — thanks @sleontenko.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -471,11 +471,6 @@ by Peter Steinberger and the community.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
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:
|
||||
|
||||
<p align="left">
|
||||
|
||||
+190
-172
@@ -2,6 +2,196 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<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>
|
||||
<title>2026.1.16-2</title>
|
||||
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
|
||||
@@ -99,177 +289,5 @@
|
||||
]]></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=="/>
|
||||
</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>
|
||||
</rss>
|
||||
@@ -529,7 +529,7 @@ class NodeRuntime(context: Context) {
|
||||
caps = buildCapabilities(),
|
||||
commands = buildInvokeCommands(),
|
||||
permissions = emptyMap(),
|
||||
client = buildClientInfo(clientId = "node-host", clientMode = "node"),
|
||||
client = buildClientInfo(clientId = "clawdbot-android", clientMode = "node"),
|
||||
userAgent = buildUserAgent(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -133,9 +133,8 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
private func startRecognition() throws {
|
||||
#if targetEnvironment(simulator)
|
||||
// Apple Speech live-audio recognition is not supported on Simulator.
|
||||
throw NSError(domain: "TalkMode", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator (Speech live audio requires a device).",
|
||||
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
|
||||
])
|
||||
#endif
|
||||
|
||||
|
||||
@@ -87,15 +87,7 @@ final class ControlChannel {
|
||||
|
||||
func configure() async {
|
||||
self.logger.info("control channel configure mode=local")
|
||||
self.state = .connecting
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
self.state = .connected
|
||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
||||
} catch {
|
||||
let message = self.friendlyGatewayMessage(error)
|
||||
self.state = .degraded(message)
|
||||
}
|
||||
await self.refreshEndpoint(reason: "configure")
|
||||
}
|
||||
|
||||
func configure(mode: Mode = .local) async throws {
|
||||
@@ -111,7 +103,7 @@ final class ControlChannel {
|
||||
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
|
||||
self.state = .connecting
|
||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||
await self.configure()
|
||||
await self.refreshEndpoint(reason: "configure")
|
||||
} catch {
|
||||
self.state = .degraded(error.localizedDescription)
|
||||
throw error
|
||||
@@ -119,6 +111,19 @@ final class ControlChannel {
|
||||
}
|
||||
}
|
||||
|
||||
func refreshEndpoint(reason: String) async {
|
||||
self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)")
|
||||
self.state = .connecting
|
||||
do {
|
||||
try await self.establishGatewayConnection()
|
||||
self.state = .connected
|
||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
||||
} catch {
|
||||
let message = self.friendlyGatewayMessage(error)
|
||||
self.state = .degraded(message)
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
await GatewayConnection.shared.shutdown()
|
||||
self.state = .disconnected
|
||||
@@ -275,18 +280,28 @@ final class ControlChannel {
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
await self.refreshEndpoint(reason: "recovery:\(reasonText)")
|
||||
if case .connected = self.state {
|
||||
self.logger.info("control channel recovery finished")
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
|
||||
} else if case let .degraded(message) = self.state {
|
||||
self.logger.error("control channel recovery failed \(message, privacy: .public)")
|
||||
}
|
||||
|
||||
self.recoveryTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func establishGatewayConnection(timeoutMs: Int = 5000) async throws {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
let ok = try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
|
||||
if ok == false {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
|
||||
}
|
||||
}
|
||||
|
||||
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
||||
var merged = params
|
||||
merged["text"] = AnyHashable(text)
|
||||
|
||||
@@ -276,12 +276,13 @@ enum ExecApprovalsStore {
|
||||
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: "default"
|
||||
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
|
||||
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
|
||||
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
||||
security: agentEntry.security ?? resolvedDefaults.security,
|
||||
ask: agentEntry.ask ?? resolvedDefaults.ask,
|
||||
askFallback: agentEntry.askFallback ?? resolvedDefaults.askFallback,
|
||||
autoAllowSkills: agentEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills)
|
||||
let allowlist = (agentEntry.allowlist ?? [])
|
||||
security: agentEntry.security ?? wildcardEntry.security ?? resolvedDefaults.security,
|
||||
ask: agentEntry.ask ?? wildcardEntry.ask ?? resolvedDefaults.ask,
|
||||
askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback ?? resolvedDefaults.askFallback,
|
||||
autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills)
|
||||
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
|
||||
.map { entry in
|
||||
ExecAllowlistEntry(
|
||||
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class GatewayConnectivityCoordinator {
|
||||
static let shared = GatewayConnectivityCoordinator()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.connectivity")
|
||||
private var endpointTask: Task<Void, Never>?
|
||||
private var lastResolvedURL: URL?
|
||||
|
||||
private(set) var endpointState: GatewayEndpointState?
|
||||
private(set) var resolvedURL: URL?
|
||||
private(set) var resolvedMode: AppState.ConnectionMode?
|
||||
private(set) var resolvedHostLabel: String?
|
||||
|
||||
private init() {
|
||||
self.start()
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.endpointTask == nil else { return }
|
||||
self.endpointTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let stream = await GatewayEndpointStore.shared.subscribe()
|
||||
for await state in stream {
|
||||
await MainActor.run { self.handleEndpointState(state) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var localEndpointHostLabel: String? {
|
||||
guard self.resolvedMode == .local, let url = self.resolvedURL else { return nil }
|
||||
return Self.hostLabel(for: url)
|
||||
}
|
||||
|
||||
private func handleEndpointState(_ state: GatewayEndpointState) {
|
||||
self.endpointState = state
|
||||
switch state {
|
||||
case let .ready(mode, url, _, _):
|
||||
self.resolvedMode = mode
|
||||
self.resolvedURL = url
|
||||
self.resolvedHostLabel = Self.hostLabel(for: url)
|
||||
let urlChanged = self.lastResolvedURL?.absoluteString != url.absoluteString
|
||||
if urlChanged {
|
||||
self.lastResolvedURL = url
|
||||
Task { await ControlChannel.shared.refreshEndpoint(reason: "endpoint changed") }
|
||||
}
|
||||
case let .connecting(mode, _):
|
||||
self.resolvedMode = mode
|
||||
case let .unavailable(mode, _):
|
||||
self.resolvedMode = mode
|
||||
}
|
||||
}
|
||||
|
||||
private static func hostLabel(for url: URL) -> String {
|
||||
let host = url.host ?? url.absoluteString
|
||||
if let port = url.port { return "\(host):\(port)" }
|
||||
return host
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ actor GatewayEndpointStore {
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
|
||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||
?? TailscaleService.fallbackTailnetIPv4()
|
||||
return GatewayEndpointStore.resolveLocalGatewayHost(
|
||||
bindMode: bind,
|
||||
customBindHost: customBindHost,
|
||||
@@ -487,6 +488,7 @@ actor GatewayEndpointStore {
|
||||
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
||||
|
||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||
?? TailscaleService.fallbackTailnetIPv4()
|
||||
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
|
||||
|
||||
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
||||
|
||||
@@ -235,8 +235,8 @@ final class HealthStore {
|
||||
let lower = error.lowercased()
|
||||
if lower.contains("connection refused") {
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
return "The gateway control port (127.0.0.1:\(port)) isn’t listening — " +
|
||||
"restart Clawdbot to bring it back."
|
||||
let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||
return "The gateway control port (\(host)) isn’t listening — restart Clawdbot to bring it back."
|
||||
}
|
||||
if lower.contains("timeout") {
|
||||
return "Timed out waiting for the control server; the gateway may be crashed or still starting."
|
||||
|
||||
@@ -13,6 +13,7 @@ struct ClawdbotApp: App {
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
private let controlChannel = ControlChannel.shared
|
||||
private let activityStore = WorkActivityStore.shared
|
||||
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
|
||||
@State private var statusItem: NSStatusItem?
|
||||
@State private var isMenuPresented = false
|
||||
@State private var isPanelVisible = false
|
||||
|
||||
@@ -469,7 +469,7 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
case .local:
|
||||
platform = "local"
|
||||
host = "127.0.0.1:\(port)"
|
||||
host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||
case .unconfigured:
|
||||
platform = nil
|
||||
host = nil
|
||||
|
||||
@@ -103,6 +103,7 @@ final class TailscaleService {
|
||||
}
|
||||
|
||||
func checkTailscaleStatus() async {
|
||||
let previousIP = self.tailscaleIP
|
||||
self.isInstalled = self.checkAppInstallation()
|
||||
if !self.isInstalled {
|
||||
self.isRunning = false
|
||||
@@ -147,6 +148,10 @@ final class TailscaleService {
|
||||
self.statusError = nil
|
||||
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
|
||||
}
|
||||
|
||||
if previousIP != self.tailscaleIP {
|
||||
await GatewayEndpointStore.shared.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func openTailscaleApp() {
|
||||
@@ -173,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: ".")
|
||||
guard parts.count == 4 else { return false }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
@@ -183,7 +188,7 @@ final class TailscaleService {
|
||||
return a == 100 && b >= 64 && b <= 127
|
||||
}
|
||||
|
||||
private static func detectTailnetIPv4() -> String? {
|
||||
private nonisolated static func detectTailnetIPv4() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
defer { freeifaddrs(addrList) }
|
||||
@@ -214,4 +219,8 @@ final class TailscaleService {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated static func fallbackTailnetIPv4() -> String? {
|
||||
Self.detectTailnetIPv4()
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,35 @@ public actor GatewayNodeSession {
|
||||
private var onConnected: (@Sendable () async -> Void)?
|
||||
private var onDisconnected: (@Sendable (String) async -> Void)?
|
||||
private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)?
|
||||
|
||||
static func invokeWithTimeout(
|
||||
request: BridgeInvokeRequest,
|
||||
timeoutMs: Int?,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
|
||||
) async -> BridgeInvokeResponse {
|
||||
let timeout = max(0, timeoutMs ?? 0)
|
||||
guard timeout > 0 else {
|
||||
return await onInvoke(request)
|
||||
}
|
||||
|
||||
return await withTaskGroup(of: BridgeInvokeResponse.self) { group in
|
||||
group.addTask { await onInvoke(request) }
|
||||
group.addTask {
|
||||
try? await Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000)
|
||||
return BridgeInvokeResponse(
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: ClawdbotNodeError(
|
||||
code: .unavailable,
|
||||
message: "node invoke timed out")
|
||||
)
|
||||
}
|
||||
|
||||
let first = await group.next()!
|
||||
group.cancelAll()
|
||||
return first
|
||||
}
|
||||
}
|
||||
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
|
||||
private var canvasHostUrl: String?
|
||||
|
||||
@@ -167,7 +196,11 @@ public actor GatewayNodeSession {
|
||||
let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
|
||||
guard let onInvoke else { return }
|
||||
let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON)
|
||||
let response = await onInvoke(req)
|
||||
let response = await Self.invokeWithTimeout(
|
||||
request: req,
|
||||
timeoutMs: request.timeoutMs,
|
||||
onInvoke: onInvoke
|
||||
)
|
||||
await self.sendInvokeResult(request: request, response: response)
|
||||
} catch {
|
||||
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
|
||||
struct GatewayNodeSessionTests {
|
||||
@Test
|
||||
func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async {
|
||||
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
|
||||
let response = await GatewayNodeSession.invokeWithTimeout(
|
||||
request: request,
|
||||
timeoutMs: 50,
|
||||
onInvoke: { req in
|
||||
#expect(req.id == "1")
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: "{}", error: nil)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(response.ok == true)
|
||||
#expect(response.error == nil)
|
||||
#expect(response.payloadJSON == "{}")
|
||||
}
|
||||
|
||||
@Test
|
||||
func invokeWithTimeoutReturnsTimeoutError() async {
|
||||
let request = BridgeInvokeRequest(id: "abc", command: "x", paramsJSON: nil)
|
||||
let response = await GatewayNodeSession.invokeWithTimeout(
|
||||
request: request,
|
||||
timeoutMs: 10,
|
||||
onInvoke: { _ in
|
||||
try? await Task.sleep(nanoseconds: 200_000_000) // 200ms
|
||||
return BridgeInvokeResponse(id: "abc", ok: true, payloadJSON: "{}", error: nil)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.code == .unavailable)
|
||||
#expect(response.error?.message.contains("timed out") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func invokeWithTimeoutZeroDisablesTimeout() async {
|
||||
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
|
||||
let response = await GatewayNodeSession.invokeWithTimeout(
|
||||
request: request,
|
||||
timeoutMs: 0,
|
||||
onInvoke: { req in
|
||||
try? await Task.sleep(nanoseconds: 5_000_000)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(response.ok == true)
|
||||
#expect(response.error == nil)
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ read_when:
|
||||
|
||||
# `clawdbot approvals`
|
||||
|
||||
Manage exec approvals for the **gateway host** or a **node host**.
|
||||
By default, commands target the gateway. Use `--node` to edit a node’s approvals.
|
||||
Manage exec approvals for the **local host**, **gateway host**, or a **node host**.
|
||||
By default, commands target the local approvals file on disk. Use `--gateway` to target the gateway, or `--node` to target a specific node.
|
||||
|
||||
Related:
|
||||
- Exec approvals: [Exec approvals](/tools/exec-approvals)
|
||||
@@ -19,6 +19,7 @@ Related:
|
||||
```bash
|
||||
clawdbot approvals get
|
||||
clawdbot approvals get --node <id|name|ip>
|
||||
clawdbot approvals get --gateway
|
||||
```
|
||||
|
||||
## Replace approvals from a file
|
||||
@@ -26,6 +27,7 @@ clawdbot approvals get --node <id|name|ip>
|
||||
```bash
|
||||
clawdbot approvals set --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
|
||||
@@ -33,6 +35,7 @@ clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json
|
||||
```bash
|
||||
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 "*" "/usr/bin/uname"
|
||||
|
||||
clawdbot approvals allowlist remove "~/Projects/**/bin/rg"
|
||||
```
|
||||
@@ -40,5 +43,6 @@ clawdbot approvals allowlist remove "~/Projects/**/bin/rg"
|
||||
## Notes
|
||||
|
||||
- `--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).
|
||||
- Approvals files are stored per host at `~/.clawdbot/exec-approvals.json`.
|
||||
@@ -8,6 +8,9 @@ read_when:
|
||||
|
||||
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
|
||||
`clawdbot config get|set|unset` for non-interactive edits.
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ read_when:
|
||||
|
||||
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.
|
||||
|
||||
Related:
|
||||
|
||||
+8
-27
@@ -29,7 +29,6 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`sessions`](/cli/sessions)
|
||||
- [`gateway`](/cli/gateway)
|
||||
- [`daemon`](/cli/daemon)
|
||||
- [`service`](/cli/service)
|
||||
- [`logs`](/cli/logs)
|
||||
- [`models`](/cli/models)
|
||||
- [`memory`](/cli/memory)
|
||||
@@ -146,21 +145,6 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
start
|
||||
stop
|
||||
restart
|
||||
service
|
||||
gateway
|
||||
status
|
||||
install
|
||||
uninstall
|
||||
start
|
||||
stop
|
||||
restart
|
||||
node
|
||||
status
|
||||
install
|
||||
uninstall
|
||||
start
|
||||
stop
|
||||
restart
|
||||
logs
|
||||
models
|
||||
list
|
||||
@@ -544,7 +528,7 @@ Options:
|
||||
- `--debug` (alias for `--verbose`)
|
||||
|
||||
Notes:
|
||||
- Overview includes Gateway + Node service status when available.
|
||||
- Overview includes Gateway + node host service status when available.
|
||||
|
||||
### Usage tracking
|
||||
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).
|
||||
|
||||
Subcommands:
|
||||
- `node start --host <gateway-host> --port 18790`
|
||||
- `node service status`
|
||||
- `node service install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
|
||||
- `node service uninstall`
|
||||
- `node service start`
|
||||
- `node service stop`
|
||||
- `node service restart`
|
||||
|
||||
Legacy alias:
|
||||
- `node daemon …` (same as `node service …`)
|
||||
- `node run --host <gateway-host> --port 18790`
|
||||
- `node status`
|
||||
- `node install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
|
||||
- `node uninstall`
|
||||
- `node run`
|
||||
- `node stop`
|
||||
- `node restart`
|
||||
|
||||
## Nodes
|
||||
|
||||
|
||||
+7
-17
@@ -23,10 +23,10 @@ Common use cases:
|
||||
Execution is still guarded by **exec approvals** and per‑agent allowlists on the
|
||||
node host, so you can keep command access scoped and explicit.
|
||||
|
||||
## Start (foreground)
|
||||
## Run (foreground)
|
||||
|
||||
```bash
|
||||
clawdbot node start --host <gateway-host> --port 18790
|
||||
clawdbot node run --host <gateway-host> --port 18790
|
||||
```
|
||||
|
||||
Options:
|
||||
@@ -42,9 +42,7 @@ Options:
|
||||
Install a headless node host as a user service.
|
||||
|
||||
```bash
|
||||
clawdbot node service install --host <gateway-host> --port 18790
|
||||
# or
|
||||
clawdbot service node install --host <gateway-host> --port 18790
|
||||
clawdbot node install --host <gateway-host> --port 18790
|
||||
```
|
||||
|
||||
Options:
|
||||
@@ -61,18 +59,10 @@ Manage the service:
|
||||
|
||||
```bash
|
||||
clawdbot node status
|
||||
clawdbot service node status
|
||||
clawdbot node service status
|
||||
clawdbot node service start
|
||||
clawdbot node service stop
|
||||
clawdbot node service restart
|
||||
clawdbot node service uninstall
|
||||
```
|
||||
|
||||
Legacy alias:
|
||||
|
||||
```bash
|
||||
clawdbot node daemon status
|
||||
clawdbot node run
|
||||
clawdbot node stop
|
||||
clawdbot node restart
|
||||
clawdbot node uninstall
|
||||
```
|
||||
|
||||
## Pairing
|
||||
|
||||
@@ -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
@@ -19,6 +19,6 @@ clawdbot status --usage
|
||||
Notes:
|
||||
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
||||
- 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).
|
||||
- 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
@@ -7,9 +7,9 @@ read_when:
|
||||
|
||||
# `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
|
||||
|
||||
@@ -48,7 +48,16 @@ Options:
|
||||
- `--json`: print machine-readable status JSON.
|
||||
- `--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:
|
||||
|
||||
|
||||
+20
-2
@@ -194,7 +194,7 @@ Local mode:
|
||||
|
||||
- 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).
|
||||
- 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.
|
||||
|
||||
### Hybrid search (BM25 + vector)
|
||||
@@ -299,11 +299,29 @@ agents: {
|
||||
|
||||
Notes:
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
Delta thresholds (defaults shown):
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
sync: {
|
||||
sessions: {
|
||||
deltaBytes: 100000, // ~100 KB
|
||||
deltaMessages: 50 // JSONL lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SQLite vector acceleration (sqlite-vec)
|
||||
|
||||
When the sqlite-vec extension is available, Clawdbot stores embeddings in a
|
||||
|
||||
@@ -24,7 +24,7 @@ Unknown keys, malformed types, or invalid values cause the Gateway to **refuse t
|
||||
|
||||
When validation fails:
|
||||
- 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 --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) |
|
||||
| `{{To}}` | Destination identifier |
|
||||
| `{{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 |
|
||||
| `{{IsNewSession}}` | `"true"` when a new session was created |
|
||||
| `{{MediaUrl}}` | Inbound media pseudo-URL (if present) |
|
||||
|
||||
@@ -354,7 +354,7 @@ clawdbot doctor --fix
|
||||
Notes:
|
||||
- `clawdbot doctor` reports every invalid entry.
|
||||
- `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?
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ read_when:
|
||||
|
||||
# Development channels
|
||||
|
||||
Last updated: 2026-01-20
|
||||
Last updated: 2026-01-21
|
||||
|
||||
Clawdbot ships three update channels:
|
||||
|
||||
@@ -38,6 +38,13 @@ clawdbot update --channel 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.
|
||||
|
||||
## Plugins and channels
|
||||
|
||||
+5
-5
@@ -50,14 +50,14 @@ forwards `exec` calls to the **node host** when `host=node` is selected.
|
||||
On the node machine:
|
||||
|
||||
```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)
|
||||
|
||||
```bash
|
||||
clawdbot node service install --host <gateway-host> --port 18789 --display-name "Build Node"
|
||||
clawdbot node service start
|
||||
clawdbot node install --host <gateway-host> --port 18789 --display-name "Build Node"
|
||||
clawdbot node start
|
||||
```
|
||||
|
||||
### Pair + name
|
||||
@@ -71,7 +71,7 @@ clawdbot nodes list
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
### Allowlist the commands
|
||||
@@ -281,7 +281,7 @@ or for running a minimal node alongside a server.
|
||||
Start it:
|
||||
|
||||
```bash
|
||||
clawdbot node start --host <gateway-host> --port 18790
|
||||
clawdbot node run --host <gateway-host> --port 18790
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
@@ -5,7 +5,7 @@ read_when:
|
||||
---
|
||||
# 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
|
||||
- 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.*`).
|
||||
|
||||
### 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.
|
||||
- The app performs the exec in UI context, prompts if needed, and returns output.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
Diagram (SCI):
|
||||
|
||||
@@ -30,7 +30,7 @@ read_when:
|
||||
- **Node identity:** use existing `nodeId`.
|
||||
- **Socket auth:** Unix socket + token (cross-platform); split later if needed.
|
||||
- **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.
|
||||
|
||||
## Key concepts
|
||||
|
||||
@@ -54,7 +54,7 @@ Allowed (diagnostic-only):
|
||||
- `clawdbot health`
|
||||
- `clawdbot help`
|
||||
- `clawdbot status`
|
||||
- `clawdbot service`
|
||||
- `clawdbot daemon`
|
||||
|
||||
Everything else must hard-fail with: “Config invalid. Run `clawdbot doctor --fix`.”
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ When the operator says “release”, immediately do this preflight (no extra qu
|
||||
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).
|
||||
- [ ] `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).
|
||||
- [ ] 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).
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ Exec approvals are enforced locally on the execution host:
|
||||
- **node host** → node runner (macOS companion app or headless node host)
|
||||
|
||||
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.
|
||||
|
||||
## Settings and storage
|
||||
|
||||
+1
-1
@@ -181,7 +181,7 @@ Notes:
|
||||
- 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` 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).
|
||||
|
||||
### `process`
|
||||
|
||||
@@ -60,6 +60,7 @@ Text + native (when enabled):
|
||||
- `/commands`
|
||||
- `/skill <name> [input]` (run a skill by name)
|
||||
- `/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)
|
||||
- `/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)
|
||||
@@ -93,6 +94,7 @@ Notes:
|
||||
- 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.
|
||||
- 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.
|
||||
- `/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.
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "@clawdbot/bluebubbles",
|
||||
"version": "2026.1.15",
|
||||
"version": "2026.1.21-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot BlueBubbles channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"],
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"channel": {
|
||||
"id": "bluebubbles",
|
||||
"label": "BlueBubbles",
|
||||
|
||||
@@ -38,6 +38,10 @@ vi.mock("./attachments.js", () => ({
|
||||
sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }),
|
||||
}));
|
||||
|
||||
vi.mock("./monitor.js", () => ({
|
||||
resolveBlueBubblesMessageId: vi.fn((id: string) => id),
|
||||
}));
|
||||
|
||||
describe("bluebubblesMessageActions", () => {
|
||||
beforeEach(() => {
|
||||
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 () => {
|
||||
const { editBlueBubblesMessage } = await import("./chat.js");
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||
import { isMacOS26OrHigher } from "./probe.js";
|
||||
import { sendBlueBubblesReaction } from "./reactions.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;
|
||||
return { to, accountId };
|
||||
},
|
||||
handleAction: async ({ action, params, cfg, accountId }) => {
|
||||
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
||||
const account = resolveBlueBubblesAccount({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
accountId: accountId ?? undefined,
|
||||
@@ -86,7 +87,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
const password = account.config.password?.trim();
|
||||
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 chatGuid = readStringParam(params, "chatGuid");
|
||||
if (chatGuid?.trim()) return chatGuid.trim();
|
||||
@@ -94,6 +95,8 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
const chatIdentifier = readStringParam(params, "chatIdentifier");
|
||||
const chatId = readNumberParam(params, "chatId", { integer: true });
|
||||
const to = readStringParam(params, "to");
|
||||
// Fall back to session context if no explicit target provided
|
||||
const contextTarget = toolContext?.currentChannelId?.trim();
|
||||
|
||||
const target = chatIdentifier?.trim()
|
||||
? ({
|
||||
@@ -104,7 +107,9 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget)
|
||||
: to
|
||||
? mapTarget(to)
|
||||
: null;
|
||||
: contextTarget
|
||||
? mapTarget(contextTarget)
|
||||
: null;
|
||||
|
||||
if (!target) {
|
||||
throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`);
|
||||
@@ -127,16 +132,18 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
});
|
||||
if (isEmpty && !remove) {
|
||||
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");
|
||||
if (!messageId) {
|
||||
const rawMessageId = readStringParam(params, "messageId");
|
||||
if (!rawMessageId) {
|
||||
throw new Error(
|
||||
"BlueBubbles react requires messageId parameter (the message GUID to react to). " +
|
||||
"Use action=react with messageId=<message_guid>, emoji=<emoji>, and to/chatGuid to identify the chat.",
|
||||
"BlueBubbles react requires messageId parameter (the message ID to react to). " +
|
||||
"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 resolvedChatGuid = await resolveChatGuid();
|
||||
|
||||
@@ -161,20 +168,22 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
"Apple removed the ability to edit iMessages in this version.",
|
||||
);
|
||||
}
|
||||
const messageId = readStringParam(params, "messageId");
|
||||
const rawMessageId = readStringParam(params, "messageId");
|
||||
const newText =
|
||||
readStringParam(params, "text") ??
|
||||
readStringParam(params, "newText") ??
|
||||
readStringParam(params, "message");
|
||||
if (!messageId || !newText) {
|
||||
if (!rawMessageId || !newText) {
|
||||
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)");
|
||||
throw new Error(
|
||||
`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 backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
|
||||
|
||||
@@ -184,18 +193,20 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
backwardsCompatMessage: backwardsCompatMessage ?? undefined,
|
||||
});
|
||||
|
||||
return jsonResult({ ok: true, edited: messageId });
|
||||
return jsonResult({ ok: true, edited: rawMessageId });
|
||||
}
|
||||
|
||||
// Handle unsend action
|
||||
if (action === "unsend") {
|
||||
const messageId = readStringParam(params, "messageId");
|
||||
if (!messageId) {
|
||||
const rawMessageId = readStringParam(params, "messageId");
|
||||
if (!rawMessageId) {
|
||||
throw new Error(
|
||||
"BlueBubbles unsend requires messageId parameter (the message GUID to unsend). " +
|
||||
"Use action=unsend with messageId=<message_guid>.",
|
||||
"BlueBubbles unsend requires messageId parameter (the message ID to unsend). " +
|
||||
"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 });
|
||||
|
||||
await unsendBlueBubblesMessage(messageId, {
|
||||
@@ -203,24 +214,26 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||
});
|
||||
|
||||
return jsonResult({ ok: true, unsent: messageId });
|
||||
return jsonResult({ ok: true, unsent: rawMessageId });
|
||||
}
|
||||
|
||||
// Handle reply action
|
||||
if (action === "reply") {
|
||||
const messageId = readStringParam(params, "messageId");
|
||||
const rawMessageId = readStringParam(params, "messageId");
|
||||
const text = readMessageText(params);
|
||||
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
|
||||
if (!messageId || !text || !to) {
|
||||
if (!rawMessageId || !text || !to) {
|
||||
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 (!to) missing.push("to or target (the chat target)");
|
||||
throw new Error(
|
||||
`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 result = await sendMessageBlueBubbles(to, text, {
|
||||
@@ -229,7 +242,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
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
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
resolveDefaultBlueBubblesAccountId,
|
||||
} from "./accounts.js";
|
||||
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
||||
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
||||
import { sendMessageBlueBubbles } from "./send.js";
|
||||
import {
|
||||
@@ -65,7 +66,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
threading: {
|
||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||
currentChannelId: context.To?.trim() || undefined,
|
||||
currentThreadTs: context.ReplyToId,
|
||||
currentThreadTs: context.ReplyToIdFull ?? context.ReplyToId,
|
||||
hasRepliedRef,
|
||||
}),
|
||||
},
|
||||
@@ -237,7 +238,11 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
return { ok: true, to: trimmed };
|
||||
},
|
||||
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, {
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
accountId: accountId ?? undefined,
|
||||
|
||||
@@ -4,8 +4,9 @@ import { fileURLToPath } from "node:url";
|
||||
import { resolveChannelMediaMaxBytes, type ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { sendBlueBubblesAttachment } from "./attachments.js";
|
||||
import { sendMessageBlueBubbles } from "./send.js";
|
||||
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||
import { getBlueBubblesRuntime } from "./runtime.js";
|
||||
import { sendMessageBlueBubbles } from "./send.js";
|
||||
|
||||
const HTTP_URL_RE = /^https?:\/\//i;
|
||||
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({
|
||||
to,
|
||||
buffer,
|
||||
filename: resolvedFilename ?? "attachment",
|
||||
contentType: resolvedContentType ?? undefined,
|
||||
replyToMessageGuid: replyToId?.trim() || undefined,
|
||||
replyToMessageGuid,
|
||||
opts: {
|
||||
cfg,
|
||||
accountId,
|
||||
@@ -151,7 +157,7 @@ export async function sendBlueBubblesMedia(params: {
|
||||
await sendMessageBlueBubbles(to, trimmedCaption, {
|
||||
cfg,
|
||||
accountId,
|
||||
replyToMessageGuid: replyToId?.trim() || undefined,
|
||||
replyToMessageGuid,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
handleBlueBubblesWebhookRequest,
|
||||
registerBlueBubblesWebhookTarget,
|
||||
resolveBlueBubblesMessageId,
|
||||
_resetBlueBubblesShortIdState,
|
||||
} from "./monitor.js";
|
||||
import { setBlueBubblesRuntime } from "./runtime.js";
|
||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
@@ -223,6 +225,8 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset short ID state between tests for predictable behavior
|
||||
_resetBlueBubblesShortIdState();
|
||||
mockReadAllowFromStore.mockResolvedValue([]);
|
||||
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
|
||||
mockResolveRequireMention.mockReturnValue(false);
|
||||
@@ -467,6 +471,98 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
|
||||
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", () => {
|
||||
@@ -1075,13 +1171,86 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
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.ReplyToBody).toBe("original message");
|
||||
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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const account = createMockAccount({ dmPolicy: "open" });
|
||||
const config: ClawdbotConfig = {};
|
||||
@@ -1436,8 +1605,9 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
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(
|
||||
"BlueBubbles sent message id: msg-123",
|
||||
'Assistant sent "replying now" [message_id:2]',
|
||||
expect.objectContaining({
|
||||
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", () => {
|
||||
it("ignores messages from self (fromMe=true)", async () => {
|
||||
const account = createMockAccount();
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import { resolveAckReaction } from "clawdbot/plugin-sdk";
|
||||
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||
import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender, normalizeBlueBubblesHandle } from "./targets.js";
|
||||
import { resolveAckReaction } from "../../../src/agents/identity.js";
|
||||
import { sendBlueBubblesMedia } from "./media-send.js";
|
||||
import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js";
|
||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
@@ -31,6 +31,173 @@ const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
|
||||
const DEFAULT_TEXT_LIMIT = 4000;
|
||||
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>;
|
||||
|
||||
function logVerbose(core: BlueBubblesCoreRuntime, runtime: BlueBubblesRuntimeEnv, message: string): void {
|
||||
@@ -217,19 +384,29 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
const REPLY_BODY_TRUNCATE_LENGTH = 60;
|
||||
|
||||
function formatReplyContext(message: {
|
||||
replyToId?: string;
|
||||
replyToShortId?: string;
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
}): string | null {
|
||||
if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null;
|
||||
const sender = message.replyToSender?.trim() || "unknown sender";
|
||||
const idPart = message.replyToId ? ` id:${message.replyToId}` : "";
|
||||
const body = message.replyToBody?.trim();
|
||||
if (!body) {
|
||||
return `[Replying to ${sender}${idPart}]\n[/Replying]`;
|
||||
// Prefer short ID for token savings
|
||||
const displayId = message.replyToShortId || message.replyToId;
|
||||
// Only include sender if we don't have an ID (fallback)
|
||||
const label = displayId ? `id:${displayId}` : (message.replyToSender?.trim() || "unknown");
|
||||
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 {
|
||||
@@ -404,6 +581,15 @@ function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undef
|
||||
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: {
|
||||
chatGuid?: string;
|
||||
chatId?: number;
|
||||
@@ -550,20 +736,31 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
|
||||
const chatGuid =
|
||||
readString(message, "chatGuid") ??
|
||||
readString(message, "chat_guid") ??
|
||||
readString(chat, "chatGuid") ??
|
||||
readString(chat, "chat_guid") ??
|
||||
readString(chat, "guid") ??
|
||||
readString(chatFromList, "chatGuid") ??
|
||||
readString(chatFromList, "chat_guid") ??
|
||||
readString(chatFromList, "guid");
|
||||
const chatIdentifier =
|
||||
readString(message, "chatIdentifier") ??
|
||||
readString(message, "chat_identifier") ??
|
||||
readString(chat, "chatIdentifier") ??
|
||||
readString(chat, "chat_identifier") ??
|
||||
readString(chat, "identifier") ??
|
||||
readString(chatFromList, "chatIdentifier") ??
|
||||
readString(chatFromList, "chat_identifier") ??
|
||||
readString(chatFromList, "identifier");
|
||||
readString(chatFromList, "identifier") ??
|
||||
extractChatIdentifierFromChatGuid(chatGuid);
|
||||
const chatId =
|
||||
readNumber(message, "chatId") ??
|
||||
readNumber(message, "chat_id") ??
|
||||
readNumber(chat, "id") ??
|
||||
readNumber(chatFromList, "id");
|
||||
readNumberLike(message, "chatId") ??
|
||||
readNumberLike(message, "chat_id") ??
|
||||
readNumberLike(chat, "chatId") ??
|
||||
readNumberLike(chat, "chat_id") ??
|
||||
readNumberLike(chat, "id") ??
|
||||
readNumberLike(chatFromList, "chatId") ??
|
||||
readNumberLike(chatFromList, "chat_id") ??
|
||||
readNumberLike(chatFromList, "id");
|
||||
const chatName =
|
||||
readString(message, "chatName") ??
|
||||
readString(chat, "displayName") ??
|
||||
@@ -679,19 +876,30 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
|
||||
const chatGuid =
|
||||
readString(message, "chatGuid") ??
|
||||
readString(message, "chat_guid") ??
|
||||
readString(chat, "chatGuid") ??
|
||||
readString(chat, "chat_guid") ??
|
||||
readString(chat, "guid") ??
|
||||
readString(chatFromList, "chatGuid") ??
|
||||
readString(chatFromList, "chat_guid") ??
|
||||
readString(chatFromList, "guid");
|
||||
const chatIdentifier =
|
||||
readString(message, "chatIdentifier") ??
|
||||
readString(message, "chat_identifier") ??
|
||||
readString(chat, "chatIdentifier") ??
|
||||
readString(chat, "chat_identifier") ??
|
||||
readString(chat, "identifier") ??
|
||||
readString(chatFromList, "chatIdentifier") ??
|
||||
readString(chatFromList, "chat_identifier") ??
|
||||
readString(chatFromList, "identifier");
|
||||
readString(chatFromList, "identifier") ??
|
||||
extractChatIdentifierFromChatGuid(chatGuid);
|
||||
const chatId =
|
||||
readNumberLike(message, "chatId") ??
|
||||
readNumberLike(message, "chat_id") ??
|
||||
readNumberLike(chat, "chatId") ??
|
||||
readNumberLike(chat, "chat_id") ??
|
||||
readNumberLike(chat, "id") ??
|
||||
readNumberLike(chatFromList, "chatId") ??
|
||||
readNumberLike(chatFromList, "chat_id") ??
|
||||
readNumberLike(chatFromList, "id");
|
||||
const chatName =
|
||||
readString(message, "chatName") ??
|
||||
@@ -901,14 +1109,39 @@ async function processMessage(
|
||||
target: WebhookTarget,
|
||||
): Promise<void> {
|
||||
const { account, config, runtime, core, statusSink } = target;
|
||||
if (message.fromMe) return;
|
||||
|
||||
const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
|
||||
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
|
||||
|
||||
const text = message.text.trim();
|
||||
const attachments = message.attachments ?? [];
|
||||
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}`);
|
||||
return;
|
||||
}
|
||||
@@ -1148,6 +1381,10 @@ async function processMessage(
|
||||
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 password = account.config.password?.trim();
|
||||
const maxBytes =
|
||||
@@ -1199,12 +1436,42 @@ async function processMessage(
|
||||
}
|
||||
}
|
||||
}
|
||||
const rawBody = text.trim() || placeholder;
|
||||
const replyContext = formatReplyContext(message);
|
||||
let replyToId = message.replyToId;
|
||||
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 fromLabel = isGroup
|
||||
? `group:${peerId}`
|
||||
: message.senderName || `user:${message.senderId}`;
|
||||
const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`;
|
||||
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
|
||||
const groupMembers = isGroup
|
||||
? formatGroupMembers({
|
||||
@@ -1230,12 +1497,12 @@ async function processMessage(
|
||||
});
|
||||
let chatGuidForActions = chatGuid;
|
||||
if (!chatGuidForActions && baseUrl && password) {
|
||||
const target =
|
||||
const target =
|
||||
isGroup && (chatId || chatIdentifier)
|
||||
? chatId
|
||||
? { kind: "chat_id", chatId }
|
||||
: { kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" }
|
||||
: { kind: "handle", address: message.senderId };
|
||||
? ({ kind: "chat_id", chatId } as const)
|
||||
: ({ kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" } as const)
|
||||
: ({ kind: "handle", address: message.senderId } as const);
|
||||
if (target.kind !== "chat_identifier" || target.chatIdentifier) {
|
||||
chatGuidForActions =
|
||||
(await resolveChatGuidForTarget({
|
||||
@@ -1316,10 +1583,23 @@ async function processMessage(
|
||||
? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
|
||||
: message.senderId;
|
||||
|
||||
const maybeEnqueueOutboundMessageId = (messageId?: string) => {
|
||||
const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string) => {
|
||||
const trimmed = messageId?.trim();
|
||||
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,
|
||||
contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
|
||||
});
|
||||
@@ -1343,16 +1623,20 @@ async function processMessage(
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
ReplyToId: message.replyToId,
|
||||
ReplyToBody: message.replyToBody,
|
||||
ReplyToSender: message.replyToSender,
|
||||
// Use short ID for token savings (agent can use this to reference the message)
|
||||
ReplyToId: replyToShortId || replyToId,
|
||||
ReplyToIdFull: replyToId,
|
||||
ReplyToBody: replyToBody,
|
||||
ReplyToSender: replyToSender,
|
||||
GroupSubject: groupSubject,
|
||||
GroupMembers: groupMembers,
|
||||
SenderName: message.senderName || undefined,
|
||||
SenderId: message.senderId,
|
||||
Provider: "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,
|
||||
OriginatingChannel: "bluebubbles",
|
||||
OriginatingTo: `bluebubbles:${outboundTarget}`,
|
||||
@@ -1367,6 +1651,11 @@ async function processMessage(
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
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
|
||||
? payload.mediaUrls
|
||||
: payload.mediaUrl
|
||||
@@ -1382,10 +1671,11 @@ async function processMessage(
|
||||
to: outboundTarget,
|
||||
mediaUrl,
|
||||
caption: caption ?? undefined,
|
||||
replyToId: payload.replyToId ?? null,
|
||||
replyToId: replyToMessageGuid || null,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
maybeEnqueueOutboundMessageId(result.messageId);
|
||||
const cachedBody = (caption ?? "").trim() || "<media:attachment>";
|
||||
maybeEnqueueOutboundMessageId(result.messageId, cachedBody);
|
||||
sentMessage = true;
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
}
|
||||
@@ -1400,14 +1690,12 @@ async function processMessage(
|
||||
if (!chunks.length && payload.text) chunks.push(payload.text);
|
||||
if (!chunks.length) return;
|
||||
for (const chunk of chunks) {
|
||||
const replyToMessageGuid =
|
||||
typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
|
||||
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
replyToMessageGuid: replyToMessageGuid || undefined,
|
||||
});
|
||||
maybeEnqueueOutboundMessageId(result.messageId);
|
||||
maybeEnqueueOutboundMessageId(result.messageId, chunk);
|
||||
sentMessage = true;
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
}
|
||||
@@ -1541,7 +1829,9 @@ async function processReaction(
|
||||
|
||||
const senderLabel = reaction.senderName || reaction.senderId;
|
||||
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, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { ClawdbotConfig, DmPolicy, WizardPrompter } from "clawdbot/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
} from "../../../src/channels/plugins/onboarding-types.js";
|
||||
import { addWildcardAllowFrom, promptAccountId } from "../../../src/channels/plugins/onboarding/helpers.js";
|
||||
import { formatDocsLink } from "../../../src/terminal/links.js";
|
||||
ClawdbotConfig,
|
||||
DmPolicy,
|
||||
WizardPrompter,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
addWildcardAllowFrom,
|
||||
formatDocsLink,
|
||||
normalizeAccountId,
|
||||
promptAccountId,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
listBlueBubblesAccountIds,
|
||||
resolveBlueBubblesAccount,
|
||||
|
||||
@@ -20,32 +20,101 @@ const REACTION_TYPES = new Set([
|
||||
]);
|
||||
|
||||
const REACTION_ALIASES = new Map<string, string>([
|
||||
// General
|
||||
["heart", "love"],
|
||||
["love", "love"],
|
||||
["❤", "love"],
|
||||
["❤️", "love"],
|
||||
["red_heart", "love"],
|
||||
["thumbs_up", "like"],
|
||||
["thumbs-down", "dislike"],
|
||||
["thumbsup", "like"],
|
||||
["thumbs-up", "like"],
|
||||
["thumbsup", "like"],
|
||||
["like", "like"],
|
||||
["thumb", "like"],
|
||||
["ok", "like"],
|
||||
["thumbs_down", "dislike"],
|
||||
["thumbsdown", "dislike"],
|
||||
["thumbs-down", "dislike"],
|
||||
["dislike", "dislike"],
|
||||
["boo", "dislike"],
|
||||
["no", "dislike"],
|
||||
// Laugh
|
||||
["haha", "laugh"],
|
||||
["lol", "laugh"],
|
||||
["lmao", "laugh"],
|
||||
["rofl", "laugh"],
|
||||
["😂", "laugh"],
|
||||
["🤣", "laugh"],
|
||||
["xd", "laugh"],
|
||||
["laugh", "laugh"],
|
||||
// Emphasize / exclaim
|
||||
["emphasis", "emphasize"],
|
||||
["emphasize", "emphasize"],
|
||||
["exclaim", "emphasize"],
|
||||
["!!", "emphasize"],
|
||||
["‼", "emphasize"],
|
||||
["‼️", "emphasize"],
|
||||
["❗", "emphasize"],
|
||||
["important", "emphasize"],
|
||||
["bang", "emphasize"],
|
||||
// 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>([
|
||||
// Love
|
||||
["❤️", "love"],
|
||||
["❤", "love"],
|
||||
["♥️", "love"],
|
||||
["♥", "love"],
|
||||
["😍", "love"],
|
||||
["💕", "love"],
|
||||
// Like
|
||||
["👍", "like"],
|
||||
["👌", "like"],
|
||||
// Dislike
|
||||
["👎", "dislike"],
|
||||
["🙅", "dislike"],
|
||||
// Laugh
|
||||
["😂", "laugh"],
|
||||
["🤣", "laugh"],
|
||||
["😆", "laugh"],
|
||||
["😁", "laugh"],
|
||||
["😹", "laugh"],
|
||||
// Emphasize
|
||||
["‼️", "emphasize"],
|
||||
["‼", "emphasize"],
|
||||
["!!", "emphasize"],
|
||||
["❗", "emphasize"],
|
||||
["❕", "emphasize"],
|
||||
["!", "emphasize"],
|
||||
// Question
|
||||
["❓", "question"],
|
||||
["❔", "question"],
|
||||
["?", "question"],
|
||||
]);
|
||||
|
||||
function resolveAccount(params: BlueBubblesReactionOpts) {
|
||||
|
||||
@@ -96,6 +96,33 @@ describe("send", () => {
|
||||
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 () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
||||
@@ -133,6 +133,13 @@ function extractChatId(chat: BlueBubblesChatRecord): number | 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[] {
|
||||
const raw =
|
||||
(Array.isArray(chat.participants) ? chat.participants : null) ??
|
||||
@@ -223,7 +230,16 @@ export async function resolveChatGuidForTarget(params: {
|
||||
}
|
||||
if (targetChatIdentifier) {
|
||||
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 =
|
||||
typeof chat.identifier === "string"
|
||||
? chat.identifier
|
||||
@@ -232,7 +248,7 @@ export async function resolveChatGuidForTarget(params: {
|
||||
: typeof chat.chat_identifier === "string"
|
||||
? chat.chat_identifier
|
||||
: "";
|
||||
if (identifier && identifier === targetChatIdentifier) return extractChatGuid(chat);
|
||||
if (identifier && identifier === targetChatIdentifier) return guid ?? extractChatGuid(chat);
|
||||
}
|
||||
if (normalizedHandle) {
|
||||
const guid = extractChatGuid(chat);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/copilot-proxy",
|
||||
"version": "2026.1.17-1",
|
||||
"version": "2026.1.20-2",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Copilot Proxy provider plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "@clawdbot/diagnostics-otel",
|
||||
"version": "2026.1.17-1",
|
||||
"version": "2026.1.20-2",
|
||||
"type": "module",
|
||||
"description": "Clawdbot diagnostics OpenTelemetry exporter",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"name": "@clawdbot/discord",
|
||||
"version": "2026.1.17-1",
|
||||
"version": "2026.1.20-2",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Discord channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/google-antigravity-auth",
|
||||
"version": "2026.1.17-1",
|
||||
"version": "2026.1.20-2",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Google Antigravity OAuth provider plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/google-gemini-cli-auth",
|
||||
"version": "2026.1.17-1",
|
||||
"version": "2026.1.20-2",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Gemini CLI OAuth provider plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"name": "@clawdbot/imessage",
|
||||
"version": "2026.1.17-1",
|
||||
"version": "2026.1.20-2",
|
||||
"type": "module",
|
||||
"description": "Clawdbot iMessage channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
# 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
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/matrix",
|
||||
"version": "2026.1.17-1",
|
||||
"version": "2026.1.20-2",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Matrix channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/memory-core",
|
||||
"version": "2026.1.17-1",
|
||||
"version": "2026.1.20-2",
|
||||
"type": "module",
|
||||
"description": "Clawdbot core memory search plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/memory-lancedb",
|
||||
"version": "0.0.1",
|
||||
"version": "2026.1.20-2",
|
||||
"type": "module",
|
||||
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 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
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/msteams",
|
||||
"version": "2026.1.17-1",
|
||||
"version": "2026.1.20-2",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Microsoft Teams channel plugin",
|
||||
"clawdbot": {
|
||||
@@ -14,7 +14,9 @@
|
||||
"docsPath": "/channels/msteams",
|
||||
"docsLabel": "msteams",
|
||||
"blurb": "Bot Framework; enterprise support.",
|
||||
"aliases": ["teams"],
|
||||
"aliases": [
|
||||
"teams"
|
||||
],
|
||||
"order": 60
|
||||
},
|
||||
"install": {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "@clawdbot/nextcloud-talk",
|
||||
"version": "2026.1.17-1",
|
||||
"version": "2026.1.20-2",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Nextcloud Talk channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"],
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"channel": {
|
||||
"id": "nextcloud-talk",
|
||||
"label": "Nextcloud Talk",
|
||||
@@ -12,7 +14,10 @@
|
||||
"docsPath": "/channels/nextcloud-talk",
|
||||
"docsLabel": "nextcloud-talk",
|
||||
"blurb": "Self-hosted chat via Nextcloud Talk webhook bots.",
|
||||
"aliases": ["nc-talk", "nc"],
|
||||
"aliases": [
|
||||
"nc-talk",
|
||||
"nc"
|
||||
],
|
||||
"order": 65,
|
||||
"quickstartAllowFrom": true
|
||||
},
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 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
|
||||
|
||||
Initial release.
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "@clawdbot/nostr",
|
||||
"version": "2026.1.19-1",
|
||||
"version": "2026.1.20-2",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"],
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"channel": {
|
||||
"id": "nostr",
|
||||
"label": "Nostr",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"name": "@clawdbot/signal",
|
||||
"version": "2026.1.17-1",
|
||||
"version": "2026.1.20-2",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Signal channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"name": "@clawdbot/slack",
|
||||
"version": "2026.1.17-1",
|
||||
"version": "2026.1.20-2",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Slack channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"name": "@clawdbot/telegram",
|
||||
"version": "2026.1.17-1",
|
||||
"version": "2026.1.20-2",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Telegram channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
# 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
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/voice-call",
|
||||
"version": "2026.1.17-1",
|
||||
"version": "2026.1.20-2",
|
||||
"type": "module",
|
||||
"description": "Clawdbot voice-call plugin",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"name": "@clawdbot/whatsapp",
|
||||
"version": "2026.1.17-1",
|
||||
"version": "2026.1.20-2",
|
||||
"type": "module",
|
||||
"description": "Clawdbot WhatsApp channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
# 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
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/zalo",
|
||||
"version": "2026.1.17-1",
|
||||
"version": "2026.1.20-2",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Zalo channel plugin",
|
||||
"clawdbot": {
|
||||
@@ -14,7 +14,9 @@
|
||||
"docsPath": "/channels/zalo",
|
||||
"docsLabel": "zalo",
|
||||
"blurb": "Vietnam-focused messaging platform with Bot API.",
|
||||
"aliases": ["zl"],
|
||||
"aliases": [
|
||||
"zl"
|
||||
],
|
||||
"order": 80,
|
||||
"quickstartAllowFrom": true
|
||||
},
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 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
|
||||
|
||||
- Initial version with full channel plugin support
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/zalouser",
|
||||
"version": "2026.1.17-1",
|
||||
"version": "2026.1.20-2",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
|
||||
"dependencies": {
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawdbot",
|
||||
"version": "2026.1.20",
|
||||
"version": "2026.1.20-2",
|
||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -13,6 +13,7 @@
|
||||
"clawdbot": "dist/entry.js"
|
||||
},
|
||||
"files": [
|
||||
"dist/acp/**",
|
||||
"dist/agents/**",
|
||||
"dist/auto-reply/**",
|
||||
"dist/browser/**",
|
||||
@@ -50,6 +51,7 @@
|
||||
"extensions/**",
|
||||
"assets/**",
|
||||
"skills/**",
|
||||
"patches/**",
|
||||
"README.md",
|
||||
"README-header.png",
|
||||
"CHANGELOG.md",
|
||||
@@ -64,6 +66,7 @@
|
||||
"dist/logging/**",
|
||||
"dist/memory/**",
|
||||
"dist/markdown/**",
|
||||
"dist/node-host/**",
|
||||
"dist/pairing/**",
|
||||
"dist/whatsapp/**"
|
||||
],
|
||||
|
||||
+15
-15
@@ -21,7 +21,7 @@ handle_sudo_error() {
|
||||
echo -e "\n${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${YELLOW}⚠️ Password Required for Log Access${NC}"
|
||||
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 "See: ${BLUE}apple/docs/logging-private-fix.md${NC}\n"
|
||||
echo -e "Quick fix:"
|
||||
@@ -51,7 +51,7 @@ show_usage() {
|
||||
clawlog - Clawdbot Logging Utility
|
||||
|
||||
USAGE:
|
||||
vtlog [OPTIONS]
|
||||
clawlog [OPTIONS]
|
||||
|
||||
DESCRIPTION:
|
||||
View Clawdbot logs with full details (bypasses Apple's privacy redaction).
|
||||
@@ -69,10 +69,10 @@ LOG CATEGORIES (examples):
|
||||
• shell - ShellExecutor
|
||||
|
||||
QUICK START:
|
||||
vtlog -n 100 Show last 100 lines from all components
|
||||
vtlog -f Follow logs in real-time
|
||||
vtlog -e Show only errors
|
||||
vtlog -c ServerManager Show logs from ServerManager only
|
||||
clawlog -n 100 Show last 100 lines from all components
|
||||
clawlog -f Follow logs in real-time
|
||||
clawlog -e Show only errors
|
||||
clawlog -c ServerManager Show logs from ServerManager only
|
||||
|
||||
OPTIONS:
|
||||
-h, --help Show this help message
|
||||
@@ -91,15 +91,15 @@ OPTIONS:
|
||||
--json Output in JSON format
|
||||
|
||||
EXAMPLES:
|
||||
vtlog Show last 50 lines from past 5 minutes (default)
|
||||
vtlog -f Stream logs continuously
|
||||
vtlog -n 100 Show last 100 lines
|
||||
vtlog -e Show only recent errors
|
||||
vtlog -l 30m -n 200 Show last 200 lines from past 30 minutes
|
||||
vtlog -c ServerManager Show recent ServerManager logs
|
||||
vtlog -s "fail" Search for "fail" in recent logs
|
||||
vtlog --server -e Show recent server errors
|
||||
vtlog -f -d Stream debug logs continuously
|
||||
clawlog Show last 50 lines from past 5 minutes (default)
|
||||
clawlog -f Stream logs continuously
|
||||
clawlog -n 100 Show last 100 lines
|
||||
clawlog -e Show only recent errors
|
||||
clawlog -l 30m -n 200 Show last 200 lines from past 30 minutes
|
||||
clawlog -c ServerManager Show recent ServerManager logs
|
||||
clawlog -s "fail" Search for "fail" in recent logs
|
||||
clawlog --server -e Show recent server errors
|
||||
clawlog -f -d Stream debug logs continuously
|
||||
|
||||
CATEGORIES:
|
||||
Common categories include:
|
||||
|
||||
@@ -76,7 +76,7 @@ bash pty:true workdir:~/project background:true command:"codex exec --full-auto
|
||||
# Monitor progress
|
||||
process action:log sessionId:XXX
|
||||
|
||||
# Check if done
|
||||
# Check if done
|
||||
process action:poll sessionId:XXX
|
||||
|
||||
# Send input (if agent asks a question)
|
||||
@@ -217,15 +217,55 @@ git worktree remove /tmp/issue-99
|
||||
|
||||
## ⚠️ Rules
|
||||
|
||||
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!
|
||||
3. **Be patient** — don't kill sessions because they're "slow"
|
||||
4. **Monitor with process:log** — check progress without interfering
|
||||
5. **--full-auto for building** — auto-approves changes
|
||||
6. **vanilla for reviewing** — no special flags needed
|
||||
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!
|
||||
1. **Always use pty:true** - coding agents need a terminal!
|
||||
2. **Respect tool choice** - if user asks for Codex, use Codex.
|
||||
- Orchestrator mode: do NOT hand-code patches yourself.
|
||||
- If an agent fails/hangs, respawn it or ask the user for direction, but don't silently take over.
|
||||
3. **Be patient** - don't kill sessions because they're "slow"
|
||||
4. **Monitor with process:log** - check progress without interfering
|
||||
5. **--full-auto for building** - auto-approves changes
|
||||
6. **vanilla for reviewing** - no special flags needed
|
||||
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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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", () => ({
|
||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
vi.mock("../plugins/tools.js", () => ({
|
||||
resolvePluginTools: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
|
||||
@@ -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", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
|
||||
@@ -49,6 +49,10 @@ export type ResolvedMemorySearchConfig = {
|
||||
watch: boolean;
|
||||
watchDebounceMs: number;
|
||||
intervalMinutes: number;
|
||||
sessions: {
|
||||
deltaBytes: number;
|
||||
deltaMessages: number;
|
||||
};
|
||||
};
|
||||
query: {
|
||||
maxResults: number;
|
||||
@@ -71,6 +75,8 @@ const DEFAULT_GEMINI_MODEL = "gemini-embedding-001";
|
||||
const DEFAULT_CHUNK_TOKENS = 400;
|
||||
const DEFAULT_CHUNK_OVERLAP = 80;
|
||||
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_MIN_SCORE = 0.35;
|
||||
const DEFAULT_HYBRID_ENABLED = true;
|
||||
@@ -172,6 +178,16 @@ function mergeConfig(
|
||||
defaults?.sync?.watchDebounceMs ??
|
||||
DEFAULT_WATCH_DEBOUNCE_MS,
|
||||
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 = {
|
||||
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 normalizedTextWeight = sum > 0 ? textWeight / sum : DEFAULT_HYBRID_TEXT_WEIGHT;
|
||||
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 {
|
||||
enabled,
|
||||
sources,
|
||||
@@ -221,7 +239,13 @@ function mergeConfig(
|
||||
local,
|
||||
store,
|
||||
chunking: { tokens: Math.max(1, chunking.tokens), overlap },
|
||||
sync,
|
||||
sync: {
|
||||
...sync,
|
||||
sessions: {
|
||||
deltaBytes,
|
||||
deltaMessages,
|
||||
},
|
||||
},
|
||||
query: {
|
||||
...query,
|
||||
minScore,
|
||||
|
||||
@@ -92,13 +92,16 @@ const makeConfig = (): 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 payload = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:p1": { type: "api_key", provider: "openai", key: "sk-one" },
|
||||
"openai:p2": { type: "api_key", provider: "openai", key: "sk-two" },
|
||||
...(opts?.includeAnthropic
|
||||
? { "anthropic:default": { type: "api_key", provider: "anthropic", key: "sk-anth" } }
|
||||
: {}),
|
||||
},
|
||||
usageStats: {
|
||||
"openai:p1": { lastUsed: 1 },
|
||||
@@ -206,4 +209,43 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
resolveAuthProfileOrder,
|
||||
type ResolvedProviderAuth,
|
||||
} from "../model-auth.js";
|
||||
import { normalizeProviderId } from "../model-selection.js";
|
||||
import { ensureClawdbotModelsJson } from "../models-config.js";
|
||||
import {
|
||||
classifyFailoverReason,
|
||||
@@ -50,6 +51,18 @@ import { describeUnknownError } from "./utils.js";
|
||||
|
||||
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(
|
||||
params: RunEmbeddedPiAgentParams,
|
||||
): Promise<EmbeddedPiRunResult> {
|
||||
@@ -116,8 +129,16 @@ export async function runEmbeddedPiAgent(
|
||||
|
||||
const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
|
||||
const preferredProfileId = params.authProfileId?.trim();
|
||||
const lockedProfileId =
|
||||
params.authProfileIdSource === "user" ? preferredProfileId : undefined;
|
||||
let lockedProfileId = params.authProfileIdSource === "user" ? preferredProfileId : undefined;
|
||||
if (lockedProfileId) {
|
||||
const lockedProfile = authStore.profiles[lockedProfileId];
|
||||
if (
|
||||
!lockedProfile ||
|
||||
normalizeProviderId(lockedProfile.provider) !== normalizeProviderId(provider)
|
||||
) {
|
||||
lockedProfileId = undefined;
|
||||
}
|
||||
}
|
||||
const profileOrder = resolveAuthProfileOrder({
|
||||
cfg: params.config,
|
||||
store: authStore,
|
||||
@@ -202,6 +223,9 @@ export async function runEmbeddedPiAgent(
|
||||
attemptedThinking.add(thinkLevel);
|
||||
await fs.mkdir(resolvedWorkspace, { recursive: true });
|
||||
|
||||
const prompt =
|
||||
provider === "anthropic" ? scrubAnthropicRefusalMagic(params.prompt) : params.prompt;
|
||||
|
||||
const attempt = await runEmbeddedAttempt({
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
@@ -219,7 +243,7 @@ export async function runEmbeddedPiAgent(
|
||||
agentDir,
|
||||
config: params.config,
|
||||
skillsSnapshot: params.skillsSnapshot,
|
||||
prompt: params.prompt,
|
||||
prompt,
|
||||
images: params.images,
|
||||
provider,
|
||||
modelId,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { normalizeMessageChannel } from "../../../utils/message-channel.js";
|
||||
import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
|
||||
import { isSubagentSessionKey } from "../../../routing/session-key.js";
|
||||
import { resolveUserPath } from "../../../utils.js";
|
||||
import { createCacheTrace } from "../../cache-trace.js";
|
||||
import { resolveClawdbotAgentDir } from "../../agent-paths.js";
|
||||
import { resolveSessionAgentIds } from "../../agent-scope.js";
|
||||
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
|
||||
@@ -367,6 +368,17 @@ export async function runEmbeddedAttempt(
|
||||
throw new Error("Embedded agent session missing");
|
||||
}
|
||||
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.
|
||||
activeSession.agent.streamFn = streamSimple;
|
||||
@@ -379,6 +391,15 @@ export async function runEmbeddedAttempt(
|
||||
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 {
|
||||
const prior = await sanitizeSessionHistory({
|
||||
messages: activeSession.messages,
|
||||
@@ -388,12 +409,14 @@ export async function runEmbeddedAttempt(
|
||||
sessionManager,
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
cacheTrace?.recordStage("session:sanitized", { messages: prior });
|
||||
const validatedGemini = validateGeminiTurns(prior);
|
||||
const validated = validateAnthropicTurns(validatedGemini);
|
||||
const limited = limitHistoryTurns(
|
||||
validated,
|
||||
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
|
||||
);
|
||||
cacheTrace?.recordStage("session:limited", { messages: limited });
|
||||
if (limited.length > 0) {
|
||||
activeSession.agent.replaceMessages(limited);
|
||||
}
|
||||
@@ -564,6 +587,10 @@ export async function runEmbeddedAttempt(
|
||||
}
|
||||
|
||||
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.
|
||||
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
|
||||
// This avoids potential issues with models that don't expect the images parameter
|
||||
if (imageResult.images.length > 0) {
|
||||
@@ -660,6 +693,10 @@ export async function runEmbeddedAttempt(
|
||||
|
||||
messagesSnapshot = activeSession.messages.slice();
|
||||
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
|
||||
// This is fire-and-forget, so we don't await
|
||||
|
||||
@@ -39,6 +39,10 @@ export type EmbeddedPiSubscribeState = {
|
||||
lastStreamedAssistant?: string;
|
||||
lastStreamedReasoning?: string;
|
||||
lastBlockReplyText?: string;
|
||||
assistantMessageIndex: number;
|
||||
lastAssistantTextMessageIndex: number;
|
||||
lastAssistantTextNormalized?: string;
|
||||
lastAssistantTextTrimmed?: string;
|
||||
assistantTextBaseline: number;
|
||||
suppressBlockChunks: boolean;
|
||||
lastReasoningSent?: string;
|
||||
|
||||
+29
@@ -86,6 +86,35 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
|
||||
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", () => {
|
||||
let handler: SessionEventHandler | undefined;
|
||||
const session: StubSession = {
|
||||
|
||||
@@ -48,6 +48,10 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
lastStreamedAssistant: undefined,
|
||||
lastStreamedReasoning: undefined,
|
||||
lastBlockReplyText: undefined,
|
||||
assistantMessageIndex: 0,
|
||||
lastAssistantTextMessageIndex: -1,
|
||||
lastAssistantTextNormalized: undefined,
|
||||
lastAssistantTextTrimmed: undefined,
|
||||
assistantTextBaseline: 0,
|
||||
suppressBlockChunks: false, // Avoid late chunk inserts after final text merge.
|
||||
lastReasoningSent: undefined,
|
||||
@@ -84,9 +88,36 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
state.lastStreamedReasoning = undefined;
|
||||
state.lastReasoningSent = undefined;
|
||||
state.suppressBlockChunks = false;
|
||||
state.assistantMessageIndex += 1;
|
||||
state.lastAssistantTextMessageIndex = -1;
|
||||
state.lastAssistantTextNormalized = undefined;
|
||||
state.lastAssistantTextTrimmed = undefined;
|
||||
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: {
|
||||
text: string;
|
||||
addedDuringMessage: boolean;
|
||||
@@ -103,16 +134,15 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
assistantTexts.length - state.assistantTextBaseline,
|
||||
text,
|
||||
);
|
||||
rememberAssistantText(text);
|
||||
} else {
|
||||
const last = assistantTexts.at(-1);
|
||||
if (!last || last !== text) assistantTexts.push(text);
|
||||
pushAssistantText(text);
|
||||
}
|
||||
state.suppressBlockChunks = true;
|
||||
} else if (!addedDuringMessage && !chunkerHasBuffered && text) {
|
||||
// Non-streaming models (no text_delta): ensure assistantTexts gets the final
|
||||
// text when the chunker has nothing buffered to drain.
|
||||
const last = assistantTexts.at(-1);
|
||||
if (!last || last !== text) assistantTexts.push(text);
|
||||
pushAssistantText(text);
|
||||
}
|
||||
|
||||
state.assistantTextBaseline = assistantTexts.length;
|
||||
@@ -338,8 +368,11 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldSkipAssistantText(chunk)) return;
|
||||
|
||||
state.lastBlockReplyText = chunk;
|
||||
assistantTexts.push(chunk);
|
||||
rememberAssistantText(chunk);
|
||||
if (!params.onBlockReply) return;
|
||||
const splitResult = parseReplyDirectives(chunk);
|
||||
const {
|
||||
|
||||
+326
-311
@@ -112,334 +112,349 @@ export function createNodesTool(options?: {
|
||||
timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
|
||||
};
|
||||
|
||||
switch (action) {
|
||||
case "status":
|
||||
return jsonResult(await callGatewayTool("node.list", gatewayOpts, {}));
|
||||
case "describe": {
|
||||
const node = readStringParam(params, "node", { required: true });
|
||||
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");
|
||||
try {
|
||||
switch (action) {
|
||||
case "status":
|
||||
return jsonResult(await callGatewayTool("node.list", gatewayOpts, {}));
|
||||
case "describe": {
|
||||
const node = readStringParam(params, "node", { required: true });
|
||||
const nodeId = await resolveNodeId(gatewayOpts, node);
|
||||
return jsonResult(await callGatewayTool("node.describe", gatewayOpts, { nodeId }));
|
||||
}
|
||||
const nodeId = await resolveNodeId(gatewayOpts, node);
|
||||
await callGatewayTool("node.invoke", gatewayOpts, {
|
||||
nodeId,
|
||||
command: "system.notify",
|
||||
params: {
|
||||
title: title.trim() || undefined,
|
||||
body: body.trim() || undefined,
|
||||
sound: typeof params.sound === "string" ? params.sound : undefined,
|
||||
priority: typeof params.priority === "string" ? params.priority : undefined,
|
||||
delivery: typeof params.delivery === "string" ? params.delivery : undefined,
|
||||
},
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
});
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
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;
|
||||
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);
|
||||
await callGatewayTool("node.invoke", gatewayOpts, {
|
||||
nodeId,
|
||||
command: "system.notify",
|
||||
params: {
|
||||
title: title.trim() || undefined,
|
||||
body: body.trim() || undefined,
|
||||
sound: typeof params.sound === "string" ? params.sound : undefined,
|
||||
priority: typeof params.priority === "string" ? params.priority : undefined,
|
||||
delivery: typeof params.delivery === "string" ? params.delivery : undefined,
|
||||
},
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
});
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
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 details: Array<Record<string, unknown>> = [];
|
||||
const content: AgentToolResult<unknown>["content"] = [];
|
||||
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, {
|
||||
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: {
|
||||
facing,
|
||||
maxWidth,
|
||||
quality,
|
||||
format: "jpg",
|
||||
delayMs,
|
||||
durationMs,
|
||||
includeAudio,
|
||||
format: "mp4",
|
||||
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 payload = parseCameraClipPayload(raw?.payload);
|
||||
const filePath = cameraTempPath({
|
||||
kind: "snap",
|
||||
kind: "clip",
|
||||
facing,
|
||||
ext: isJpeg ? "jpg" : "png",
|
||||
ext: payload.format,
|
||||
});
|
||||
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,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: `FILE:${filePath}` }],
|
||||
details: {
|
||||
facing,
|
||||
path: filePath,
|
||||
durationMs: payload.durationMs,
|
||||
hasAudio: payload.hasAudio,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const result: AgentToolResult<unknown> = { content, details };
|
||||
return await sanitizeToolResultImages(result, "nodes:camera_snap");
|
||||
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}`);
|
||||
}
|
||||
case "camera_list": {
|
||||
const node = readStringParam(params, "node", { required: true });
|
||||
const nodeId = await resolveNodeId(gatewayOpts, node);
|
||||
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
|
||||
nodeId,
|
||||
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: {
|
||||
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}`);
|
||||
} catch (err) {
|
||||
const nodeLabel =
|
||||
typeof params.node === "string" && params.node.trim() ? params.node.trim() : "auto";
|
||||
const gatewayLabel =
|
||||
gatewayOpts.gatewayUrl && gatewayOpts.gatewayUrl.trim()
|
||||
? gatewayOpts.gatewayUrl.trim()
|
||||
: "default";
|
||||
const agentLabel = agentId ?? "unknown";
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`agent=${agentLabel} node=${nodeLabel} gateway=${gatewayLabel} action=${action}: ${message}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
DEFAULT_AGENT_ID,
|
||||
resolveAgentIdFromSessionKey,
|
||||
} from "../../routing/session-key.js";
|
||||
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { readStringParam } from "./common.js";
|
||||
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
|
||||
@@ -240,6 +241,7 @@ export function createSessionStatusTool(opts?: {
|
||||
throw new Error(`Unknown sessionKey: ${requestedKeyRaw}`);
|
||||
}
|
||||
|
||||
const configured = resolveDefaultModelForAgent({ cfg, agentId });
|
||||
const modelRaw = readStringParam(params, "model");
|
||||
let changedModel = false;
|
||||
if (typeof modelRaw === "string") {
|
||||
@@ -249,33 +251,33 @@ export function createSessionStatusTool(opts?: {
|
||||
sessionEntry: resolved.entry,
|
||||
agentId,
|
||||
});
|
||||
const nextEntry: SessionEntry = {
|
||||
...resolved.entry,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (selection.kind === "reset" || selection.isDefault) {
|
||||
delete nextEntry.providerOverride;
|
||||
delete nextEntry.modelOverride;
|
||||
delete nextEntry.authProfileOverride;
|
||||
delete nextEntry.authProfileOverrideSource;
|
||||
delete nextEntry.authProfileOverrideCompactionCount;
|
||||
} else {
|
||||
nextEntry.providerOverride = selection.provider;
|
||||
nextEntry.modelOverride = selection.model;
|
||||
delete nextEntry.authProfileOverride;
|
||||
delete nextEntry.authProfileOverrideSource;
|
||||
delete nextEntry.authProfileOverrideCompactionCount;
|
||||
}
|
||||
store[resolved.key] = nextEntry;
|
||||
await updateSessionStore(storePath, (nextStore) => {
|
||||
nextStore[resolved.key] = nextEntry;
|
||||
const nextEntry: SessionEntry = { ...resolved.entry };
|
||||
const applied = applyModelOverrideToSessionEntry({
|
||||
entry: nextEntry,
|
||||
selection:
|
||||
selection.kind === "reset"
|
||||
? {
|
||||
provider: configured.provider,
|
||||
model: configured.model,
|
||||
isDefault: true,
|
||||
}
|
||||
: {
|
||||
provider: selection.provider,
|
||||
model: selection.model,
|
||||
isDefault: selection.isDefault,
|
||||
},
|
||||
});
|
||||
resolved.entry = nextEntry;
|
||||
changedModel = true;
|
||||
if (applied.updated) {
|
||||
store[resolved.key] = nextEntry;
|
||||
await updateSessionStore(storePath, (nextStore) => {
|
||||
nextStore[resolved.key] = nextEntry;
|
||||
});
|
||||
resolved.entry = nextEntry;
|
||||
changedModel = true;
|
||||
}
|
||||
}
|
||||
|
||||
const agentDir = resolveAgentDir(cfg, agentId);
|
||||
const configured = resolveDefaultModelForAgent({ cfg, agentId });
|
||||
const providerForCard = resolved.entry.providerOverride?.trim() || configured.provider;
|
||||
const usageProvider = resolveUsageProviderId(providerForCard);
|
||||
let usageLine: string | undefined;
|
||||
|
||||
@@ -157,6 +157,13 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
||||
description: "Show current status.",
|
||||
textAlias: "/status",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "allowlist",
|
||||
description: "List/add/remove allowlist entries.",
|
||||
textAlias: "/allowlist",
|
||||
acceptsArgs: true,
|
||||
scope: "text",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "context",
|
||||
nativeName: "context",
|
||||
|
||||
+36
@@ -121,6 +121,42 @@ describe("directive behavior", () => {
|
||||
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 () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
@@ -299,6 +299,8 @@ export async function runAgentTurnWithFallback(params: {
|
||||
const { text, skip } = normalizeStreamingText(payload);
|
||||
const hasPayloadMedia = (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (skip && !hasPayloadMedia) return;
|
||||
const currentMessageId =
|
||||
params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid;
|
||||
const taggedPayload = applyReplyTagsToPayload(
|
||||
{
|
||||
text,
|
||||
@@ -308,12 +310,12 @@ export async function runAgentTurnWithFallback(params: {
|
||||
replyToTag: payload.replyToTag,
|
||||
replyToCurrent: payload.replyToCurrent,
|
||||
},
|
||||
params.sessionCtx.MessageSid,
|
||||
currentMessageId,
|
||||
);
|
||||
// Let through payloads with audioAsVoice flag even if empty (need to track it)
|
||||
if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return;
|
||||
const parsed = parseReplyDirectives(taggedPayload.text ?? "", {
|
||||
currentMessageId: params.sessionCtx.MessageSid,
|
||||
currentMessageId,
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
});
|
||||
const cleaned = parsed.text || undefined;
|
||||
|
||||
@@ -16,19 +16,21 @@ export const createShouldEmitToolResult = (params: {
|
||||
storePath?: string;
|
||||
resolvedVerboseLevel: VerboseLevel;
|
||||
}): (() => boolean) => {
|
||||
// Normalize verbose values from session store/config so false/"false" still means off.
|
||||
const fallbackVerbose = normalizeVerboseLevel(String(params.resolvedVerboseLevel ?? "")) ?? "off";
|
||||
return () => {
|
||||
if (!params.sessionKey || !params.storePath) {
|
||||
return params.resolvedVerboseLevel !== "off";
|
||||
return fallbackVerbose !== "off";
|
||||
}
|
||||
try {
|
||||
const store = loadSessionStore(params.storePath);
|
||||
const entry = store[params.sessionKey];
|
||||
const current = normalizeVerboseLevel(entry?.verboseLevel);
|
||||
const current = normalizeVerboseLevel(String(entry?.verboseLevel ?? ""));
|
||||
if (current) return current !== "off";
|
||||
} catch {
|
||||
// ignore store read failures
|
||||
}
|
||||
return params.resolvedVerboseLevel !== "off";
|
||||
return fallbackVerbose !== "off";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -37,19 +39,21 @@ export const createShouldEmitToolOutput = (params: {
|
||||
storePath?: string;
|
||||
resolvedVerboseLevel: VerboseLevel;
|
||||
}): (() => boolean) => {
|
||||
// Normalize verbose values from session store/config so false/"false" still means off.
|
||||
const fallbackVerbose = normalizeVerboseLevel(String(params.resolvedVerboseLevel ?? "")) ?? "off";
|
||||
return () => {
|
||||
if (!params.sessionKey || !params.storePath) {
|
||||
return params.resolvedVerboseLevel === "full";
|
||||
return fallbackVerbose === "full";
|
||||
}
|
||||
try {
|
||||
const store = loadSessionStore(params.storePath);
|
||||
const entry = store[params.sessionKey];
|
||||
const current = normalizeVerboseLevel(entry?.verboseLevel);
|
||||
const current = normalizeVerboseLevel(String(entry?.verboseLevel ?? ""));
|
||||
if (current) return current === "full";
|
||||
} catch {
|
||||
// ignore store read failures
|
||||
}
|
||||
return params.resolvedVerboseLevel === "full";
|
||||
return fallbackVerbose === "full";
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NormalizedUsage } from "../../agents/usage.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 type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
|
||||
@@ -21,17 +21,25 @@ export function buildThreadingToolContext(params: {
|
||||
}): ChannelThreadingToolContext {
|
||||
const { sessionCtx, config, hasRepliedRef } = params;
|
||||
if (!config) return {};
|
||||
const provider = normalizeChannelId(sessionCtx.Provider);
|
||||
if (!provider) return {};
|
||||
const dock = getChannelDock(provider);
|
||||
if (!dock?.threading?.buildToolContext) return {};
|
||||
const rawProvider = sessionCtx.Provider?.trim().toLowerCase();
|
||||
if (!rawProvider) return {};
|
||||
const provider = normalizeChannelId(rawProvider);
|
||||
// WhatsApp context isolation keys off conversation id, not the bot's own number.
|
||||
const threadingTo =
|
||||
provider === "whatsapp"
|
||||
rawProvider === "whatsapp"
|
||||
? (sessionCtx.From ?? sessionCtx.To)
|
||||
: provider === "imessage" && sessionCtx.ChatType === "direct"
|
||||
: rawProvider === "imessage" && sessionCtx.ChatType === "direct"
|
||||
? (sessionCtx.From ?? 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 =
|
||||
dock.threading.buildToolContext({
|
||||
cfg: config,
|
||||
@@ -47,7 +55,7 @@ export function buildThreadingToolContext(params: {
|
||||
}) ?? {};
|
||||
return {
|
||||
...context,
|
||||
currentChannelProvider: provider,
|
||||
currentChannelProvider: provider!, // guaranteed non-null since dock exists
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -377,7 +377,7 @@ export async function runReplyAgent(params: {
|
||||
directlySentBlockKeys,
|
||||
replyToMode,
|
||||
replyToChannel,
|
||||
currentMessageId: sessionCtx.MessageSid,
|
||||
currentMessageId: sessionCtx.MessageSidFull ?? sessionCtx.MessageSid,
|
||||
messageProvider: followupRun.run.messageProvider,
|
||||
messagingToolSentTexts: runResult.messagingToolSentTexts,
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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.` },
|
||||
};
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
handleStatusCommand,
|
||||
handleWhoamiCommand,
|
||||
} from "./commands-info.js";
|
||||
import { handleAllowlistCommand } from "./commands-allowlist.js";
|
||||
import { handleSubagentsCommand } from "./commands-subagents.js";
|
||||
import {
|
||||
handleAbortTrigger,
|
||||
@@ -37,6 +38,7 @@ const HANDLERS: CommandHandler[] = [
|
||||
handleHelpCommand,
|
||||
handleCommandsListCommand,
|
||||
handleStatusCommand,
|
||||
handleAllowlistCommand,
|
||||
handleContextCommand,
|
||||
handleWhoamiCommand,
|
||||
handleSubagentsCommand,
|
||||
|
||||
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user