Compare commits
155
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 | ||
|
|
15f16de651 | ||
|
|
b46855d8c4 | ||
|
|
feaad8250b | ||
|
|
fa7df1976d | ||
|
|
2cd62f94a5 | ||
|
|
a74c19feed | ||
|
|
1ad4a7194e | ||
|
|
beec504ebd | ||
|
|
fe1133e2c5 | ||
|
|
6f37f1d8ff | ||
|
|
57700f33a9 | ||
|
|
2700794228 | ||
|
|
416894c642 | ||
|
|
db88378ae3 | ||
|
|
e97b4973bb | ||
|
|
832dfb02fe | ||
|
|
15e3a2a395 | ||
|
|
8c472c210f | ||
|
|
833bbcd166 | ||
|
|
d7440baef6 | ||
|
|
58b131919f | ||
|
|
186e86660a | ||
|
|
18d47b47d2 | ||
|
|
eb1e2c7a3b | ||
|
|
6ea4cb0012 | ||
|
|
184f5a5fc3 | ||
|
|
4ad359ffcd | ||
|
|
38cc2a3288 | ||
|
|
28c49db494 | ||
|
|
026e6c4df4 | ||
|
|
841dfc693e | ||
|
|
f38278d919 | ||
|
|
9545edcb49 | ||
|
|
f3554a3ad8 | ||
|
|
b30359e9cd | ||
|
|
d3898ee8df | ||
|
|
d1c2fc4bc8 | ||
|
|
a5a3ab958f | ||
|
|
165861e78d | ||
|
|
e7c355ee85 | ||
|
|
052a58f2f7 | ||
|
|
5ff56ffb4e | ||
|
|
9a3dd626a1 | ||
|
|
aae4b2952f | ||
|
|
aec622fe63 | ||
|
|
e6287270d9 | ||
|
|
c05a7b5390 | ||
|
|
020fecef5c | ||
|
|
caf9dec89c | ||
|
|
438a41f91f | ||
|
|
a0cd295c0f | ||
|
|
14d3d72bcc | ||
|
|
03916ed10e | ||
|
|
5bd55037e4 | ||
|
|
ec51bb700c | ||
|
|
051d518078 | ||
|
|
294886b54f | ||
|
|
6629e31789 | ||
|
|
9d7087168f | ||
|
|
daceeaa24c | ||
|
|
778800be70 | ||
|
|
1b973caf7a | ||
|
|
ea775025c0 | ||
|
|
0b2830470c | ||
|
|
e81ca7ab00 | ||
|
|
27acfa59c5 | ||
|
|
b333c4a994 | ||
|
|
23f7dd8b25 | ||
|
|
77d9451712 | ||
|
|
a4fc2b4536 | ||
|
|
20a7dd8a80 | ||
|
|
450d2d25e2 | ||
|
|
df024afc97 | ||
|
|
12168dc64f | ||
|
|
4232081fcb | ||
|
|
17f3635109 | ||
|
|
9206d21c76 | ||
|
|
96be166bd6 | ||
|
|
d8abd53a1d | ||
|
|
eff292eda4 | ||
|
|
c74551c2ae | ||
|
|
48b0d08493 | ||
|
|
dd38185e6c | ||
|
|
ec01e5c7e6 | ||
|
|
e447233533 | ||
|
|
00bcb01bb4 | ||
|
|
458850483a | ||
|
|
76bae8da40 | ||
|
|
c33c0629ec | ||
|
|
e5ea8a0d22 | ||
|
|
e083f678fd | ||
|
|
4ad9622efb | ||
|
|
7e59c15496 | ||
|
|
079af0d0b0 |
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.
|
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
||||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
||||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
- macOS logs: use `./scripts/clawlog.sh` to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||||
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
|
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
|
||||||
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
||||||
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
|
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
|
||||||
|
|||||||
+178
-250
@@ -2,274 +2,202 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
|
## 2026.1.21
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
||||||
|
- CLI: exec approvals mutations render tables instead of raw JSON.
|
||||||
|
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
||||||
|
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
|
||||||
|
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
||||||
|
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
|
||||||
|
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
|
||||||
|
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
|
||||||
|
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
|
||||||
|
- Model picker: list the full catalog when no model allowlist is configured.
|
||||||
|
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1369) Thanks @tyler6204.
|
||||||
|
- Infra: preserve fetch helper methods when wrapping abort signals. (#1369)
|
||||||
|
|
||||||
## 2026.1.20
|
## 2026.1.20
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Deps: update workspace + memory-lancedb dependencies.
|
- Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui
|
||||||
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
- Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui
|
||||||
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
|
- 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`.
|
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
|
||||||
- Channels: add the Nostr plugin channel with profile management + onboarding install defaults. (#1323) — thanks @joelklabo.
|
- Gateway/API: add `/v1/responses` (OpenResponses) with item-based input + semantic streaming events. (#1229)
|
||||||
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
|
- Gateway/API: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229)
|
||||||
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
|
- Usage: add `/usage cost` summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs
|
||||||
- Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow.
|
- Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.clawd.bot/cli/security
|
||||||
- Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0.
|
- Exec: add host/security/ask routing for gateway + node exec. https://docs.clawd.bot/tools/exec
|
||||||
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
|
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node). https://docs.clawd.bot/tools/exec
|
||||||
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
|
- 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
|
||||||
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
|
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`. https://docs.clawd.bot/cli/node
|
||||||
- Security: warn when <=300B models run without sandboxing and with web tools enabled.
|
- Nodes: add node daemon service install/status/start/stop/restart. https://docs.clawd.bot/cli/node
|
||||||
### Fixes
|
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
|
||||||
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
- 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
|
||||||
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
|
- Sessions: allow `sessions_spawn` to override thinking level for sub-agent runs. https://docs.clawd.bot/tools/subagents
|
||||||
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
|
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers. https://docs.clawd.bot/concepts/groups
|
||||||
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
|
- Models: add Qwen Portal OAuth provider support. (#1120) https://docs.clawd.bot/providers/qwen
|
||||||
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
|
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. https://docs.clawd.bot/start/onboarding
|
||||||
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
- Docs: clarify allowlist input types and onboarding behavior for messaging channels. https://docs.clawd.bot/start/onboarding
|
||||||
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
- Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.clawd.bot/platforms/android
|
||||||
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
|
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.clawd.bot/bedrock
|
||||||
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
- Docs: clarify WhatsApp voice notes. https://docs.clawd.bot/channels/whatsapp
|
||||||
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
|
- Docs: clarify Windows WSL portproxy LAN access notes. https://docs.clawd.bot/platforms/windows
|
||||||
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
|
- Docs: refresh bird skill install metadata and usage notes. (#1302) https://docs.clawd.bot/tools/browser-login
|
||||||
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
|
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
|
||||||
- CLI: load channel plugins for commands that need registry-backed lookups. (#1338) — thanks @MaudeBot.
|
|
||||||
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
|
|
||||||
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
|
|
||||||
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
|
|
||||||
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
|
|
||||||
- Anthropic: default API prompt caching to 1h with configurable TTL override; ignore TTL for OAuth.
|
|
||||||
- Discord: make resolve warnings avoid raw JSON payloads on rate limits.
|
|
||||||
- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)
|
|
||||||
- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
|
|
||||||
|
|
||||||
## 2026.1.19-3
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
|
|
||||||
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
|
|
||||||
- Gateway: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229) — thanks @RyanLisse.
|
|
||||||
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) — thanks @steipete.
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
|
|
||||||
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
|
|
||||||
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
|
|
||||||
- Browser: register AI snapshot refs for act commands. (#1282) — thanks @John-Rood.
|
|
||||||
|
|
||||||
## 2026.1.19-2
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.
|
|
||||||
- Android: bump okhttp + dnsjava to satisfy lint dependency checks.
|
|
||||||
- Docs: refresh Android node discovery docs for the Gateway WS service type.
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.
|
|
||||||
- CLI: skip runner rebuilds when dist is fresh. (#1231) — thanks @mukhtharcm, @thewilloftheshadow.
|
|
||||||
|
|
||||||
## 2026.1.19-1
|
|
||||||
|
|
||||||
### Breaking
|
|
||||||
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Gateway: add `/v1/responses` endpoint (OpenResponses API) for agentic workflows with item-based input and semantic streaming events. Enable via `gateway.http.endpoints.responses.enabled: true`.
|
|
||||||
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
|
||||||
- Agents: clarify node_modules read-only guidance in agent instructions.
|
- Agents: clarify node_modules read-only guidance in agent instructions.
|
||||||
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
|
||||||
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) — thanks @Whoaa512.
|
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
|
||||||
|
- Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.
|
||||||
### Fixes
|
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
|
||||||
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
|
||||||
- Agents: add `clawdbot agents set-identity` helper and update bootstrap guidance for multi-agent setups. (#1222) — thanks @ThePickle31.
|
- Android: bump okhttp + dnsjava to satisfy lint dependency checks.
|
||||||
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
|
- Build: update workspace + core/plugin deps.
|
||||||
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
|
- Build: use tsgo for dev/watch builds by default (opt out with `CLAWDBOT_TS_COMPILER=tsc`).
|
||||||
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
|
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
||||||
- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214) — thanks @ameno-.
|
|
||||||
- Memory: show total file counts + scan issues in `clawdbot memory status`; fall back to non-batch embeddings after repeated batch failures.
|
|
||||||
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
|
|
||||||
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
|
|
||||||
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
|
|
||||||
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226) — thanks @VACInc. (fixes #1225)
|
|
||||||
|
|
||||||
## 2026.1.18-5
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
|
||||||
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels.
|
|
||||||
- TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07.
|
|
||||||
- Docs: clarify allowlist input types and onboarding behavior for messaging channels.
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
|
|
||||||
- Docs: make docs:list fail fast with a clear error if the docs directory is missing.
|
|
||||||
- macOS: load menu session previews asynchronously so items populate while the menu is open.
|
|
||||||
- macOS: use label colors for session preview text so previews render in menu subviews.
|
|
||||||
- macOS: suppress usage error text in the menubar cost view.
|
|
||||||
- Telegram: honor pairing allowlists for native slash commands.
|
|
||||||
- TUI: highlight model search matches and stabilize search ordering.
|
|
||||||
- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195) — thanks @gumadeiras.
|
|
||||||
- Slack: resolve Bolt import interop for Bun + Node. (#1191) — thanks @CoreyH.
|
|
||||||
- Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.
|
|
||||||
- Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.
|
|
||||||
|
|
||||||
## 2026.1.18-4
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.
|
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.
|
||||||
- macOS: stop syncing Peekaboo in postinstall.
|
- macOS: stop syncing Peekaboo in postinstall.
|
||||||
- Swabble: use the tagged Commander Swift package release.
|
- Swabble: use the tagged Commander Swift package release.
|
||||||
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
|
|
||||||
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
|
### Breaking
|
||||||
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
|
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety. Run `clawdbot doctor --fix` to repair, then update plugins (`clawdbot plugins update`) if you use any.
|
||||||
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
|
|
||||||
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
|
|
||||||
- Memory: add native Gemini embeddings provider for memory search. (#1151)
|
|
||||||
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
|
|
||||||
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
|
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
|
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
||||||
|
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
|
||||||
|
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244)
|
||||||
|
- Diagnostics: gate heartbeat/webhook logging. (#1244)
|
||||||
|
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
|
||||||
|
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
||||||
|
- Gateway: allow mobile node client ids for iOS + Android handshake validation. (#1354)
|
||||||
|
- Gateway: clarify connect/validation errors for gateway params. (#1347)
|
||||||
|
- Gateway: preserve restart wake routing + thread replies across restarts. (#1337)
|
||||||
|
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
|
||||||
|
- Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.
|
||||||
|
- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
|
||||||
|
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241)
|
||||||
|
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
|
||||||
|
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. (#1137)
|
||||||
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
|
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
|
||||||
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166) — thanks @AlexMikhalev.
|
- Sessions: fall back to session labels when listing display names. (#1124)
|
||||||
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
|
||||||
|
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
|
||||||
|
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
|
||||||
|
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226)
|
||||||
|
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
||||||
|
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
|
||||||
|
- Docs: make docs:list fail fast with a clear error if the docs directory is missing.
|
||||||
|
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297)
|
||||||
|
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
|
||||||
|
- CLI: preserve cron delivery settings when editing message payloads. (#1322)
|
||||||
|
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output.
|
||||||
|
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
|
||||||
|
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207)
|
||||||
|
- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195)
|
||||||
|
- CLI: skip runner rebuilds when dist is fresh. (#1231)
|
||||||
|
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
|
||||||
|
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
|
||||||
|
- Status: show both usage windows with reset hints when usage data is available. (#1101)
|
||||||
|
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315)
|
||||||
|
- UI: preserve ordered list numbering in chat markdown. (#1341)
|
||||||
|
- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342)
|
||||||
|
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283)
|
||||||
|
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212)
|
||||||
|
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202)
|
||||||
|
- TUI: align custom editor initialization with the latest pi-tui API. (#1298)
|
||||||
|
- TUI: show generic empty-state text for searchable pickers. (#1201)
|
||||||
|
- TUI: highlight model search matches and stabilize search ordering.
|
||||||
|
- Configure: hide OpenRouter auto routing model from the model picker. (#1182)
|
||||||
|
- Memory: show total file counts + scan issues in `clawdbot memory status`.
|
||||||
|
- Memory: fall back to non-batch embeddings after repeated batch failures.
|
||||||
|
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
||||||
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
|
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
|
||||||
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
|
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
|
||||||
|
|
||||||
## 2026.1.18-3
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Exec: add host/security/ask routing for gateway + node exec.
|
|
||||||
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
|
|
||||||
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
|
|
||||||
- macOS: add approvals socket UI server + node exec lifecycle events.
|
|
||||||
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
|
|
||||||
- Nodes: add node daemon service install/status/start/stop/restart.
|
|
||||||
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
|
|
||||||
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
|
|
||||||
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
|
|
||||||
- Agents: auto-inject local image references for vision models and avoid reloading history images. (#1098) — thanks @tyler6204.
|
|
||||||
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
|
|
||||||
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
|
|
||||||
- ACP: add experimental ACP support for IDE integrations (`clawdbot acp`). Thanks @visionik.
|
|
||||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
|
||||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
|
||||||
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
|
||||||
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
|
||||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
|
||||||
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
|
||||||
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
|
||||||
- macOS: add exec-host IPC for node service `system.run` with HMAC + peer UID checks.
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
|
|
||||||
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
|
||||||
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
|
|
||||||
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
|
|
||||||
|
|
||||||
## 2026.1.18-2
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
|
||||||
|
|
||||||
## 2026.1.18-1
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
|
||||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
|
||||||
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
|
||||||
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
|
||||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
|
||||||
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
|
||||||
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
|
||||||
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
|
||||||
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
|
||||||
- Discord: only emit slow listener warnings after 30s.
|
|
||||||
|
|
||||||
## 2026.1.17-6
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Plugins: add exclusive plugin slots with a dedicated memory slot selector.
|
|
||||||
- Memory: ship core memory tools + CLI as the bundled `memory-core` plugin.
|
|
||||||
- Docs: document plugin slots and memory plugin behavior.
|
|
||||||
- Plugins: add the bundled BlueBubbles channel plugin (disabled by default).
|
|
||||||
- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
|
|
||||||
- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime.
|
|
||||||
- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime.
|
|
||||||
|
|
||||||
## 2026.1.17-5
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback.
|
|
||||||
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates.
|
|
||||||
- CLI: surface FTS + embedding cache state in `clawdbot memory status`.
|
|
||||||
- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default.
|
|
||||||
- Plugins: allow optional agent tools with explicit allowlists and add plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
|
|
||||||
- Tools: centralize plugin tool policy helpers.
|
|
||||||
- Commands: add `/subagents info` and show sub-agent counts in `/status`.
|
|
||||||
- Docs: clarify plugin agent tool configuration. https://docs.clawd.bot/plugins/agent-tools
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
|
|
||||||
|
|
||||||
## 2026.1.18-1
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
|
||||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
|
||||||
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
|
||||||
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
|
||||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
|
||||||
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
|
||||||
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
|
||||||
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
|
||||||
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
|
||||||
- Discord: only emit slow listener warnings after 30s.
|
|
||||||
## 2026.1.17-3
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Memory: add OpenAI Batch API indexing for embeddings when configured.
|
|
||||||
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings.
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
|
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
|
||||||
|
|
||||||
## 2026.1.17-2
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.
|
|
||||||
- Memory: parallelize embedding indexing with rate-limit retries.
|
- Memory: parallelize embedding indexing with rate-limit retries.
|
||||||
- Memory: split overly long lines to keep embeddings under token limits.
|
- Memory: split overly long lines to keep embeddings under token limits.
|
||||||
- Memory: skip empty chunks to avoid invalid embedding inputs.
|
- Memory: skip empty chunks to avoid invalid embedding inputs.
|
||||||
- Sessions: fall back to session labels when listing display names. (#1124) — thanks @abdaraxus.
|
|
||||||
- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123) — thanks @thewilloftheshadow.
|
|
||||||
|
|
||||||
## 2026.1.17-1
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Telegram: enrich forwarded message context with normalized origin details + legacy fallback. (#1090) — thanks @sleontenko.
|
|
||||||
- macOS: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x.
|
|
||||||
- macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg.
|
|
||||||
- CLI: surface update availability in `clawdbot status`.
|
|
||||||
- CLI: add `clawdbot memory status --deep/--index` probes.
|
|
||||||
- CLI: add playful update completion quips.
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Doctor: avoid re-adding WhatsApp ack reaction config when only legacy auth files exist. (#1087) — thanks @YuriNachos.
|
|
||||||
- Hooks: parse multi-line/YAML frontmatter metadata blocks (JSON5-friendly). (#1114) — thanks @sebslight.
|
|
||||||
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
|
|
||||||
- Windows: install gateway scheduled task as the current user; show friendly guidance instead of failing on access denied.
|
|
||||||
- Status: show both usage windows with reset hints when usage data is available. (#1101) — thanks @rhjoh.
|
|
||||||
- Memory: probe sqlite-vec availability in `clawdbot memory status`.
|
|
||||||
- Memory: split embedding batches to avoid OpenAI token limits during indexing.
|
- Memory: split embedding batches to avoid OpenAI token limits during indexing.
|
||||||
- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118) — thanks @sleontenko.
|
- Memory: probe sqlite-vec availability in `clawdbot memory status`.
|
||||||
|
- Exec approvals: enforce allowlist when ask is off.
|
||||||
|
- Exec approvals: prefer raw command for node approvals/events.
|
||||||
|
- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.
|
||||||
|
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
||||||
|
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
||||||
|
- Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).
|
||||||
|
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297)
|
||||||
|
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
|
||||||
|
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147)
|
||||||
|
- Discord: make resolve warnings avoid raw JSON payloads on rate limits.
|
||||||
|
- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)
|
||||||
|
- Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.
|
||||||
|
- Discord: only emit slow listener warnings after 30s.
|
||||||
|
- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123)
|
||||||
|
- Telegram: honor pairing allowlists for native slash commands.
|
||||||
|
- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118)
|
||||||
|
- Slack: resolve Bolt import interop for Bun + Node. (#1191)
|
||||||
|
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
||||||
|
- Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346)
|
||||||
|
- Browser: register AI snapshot refs for act commands. (#1282)
|
||||||
|
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
|
||||||
|
- Anthropic: default API prompt caching to 1h with configurable TTL override.
|
||||||
|
- Anthropic: ignore TTL for OAuth.
|
||||||
|
- Auth profiles: keep auto-pinned preference while allowing rotation on failover. (#1138)
|
||||||
|
- Auth profiles: user pins stay locked. (#1138)
|
||||||
|
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332)
|
||||||
|
- Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.
|
||||||
|
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
||||||
|
- Windows: install gateway scheduled task as the current user.
|
||||||
|
- Windows: show friendly guidance instead of failing on access denied.
|
||||||
|
- macOS: load menu session previews asynchronously so items populate while the menu is open.
|
||||||
|
- macOS: use label colors for session preview text so previews render in menu subviews.
|
||||||
|
- macOS: suppress usage error text in the menubar cost view.
|
||||||
|
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166)
|
||||||
|
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
||||||
|
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
||||||
|
- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214)
|
||||||
|
|
||||||
|
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
|
||||||
|
|
||||||
## 2026.1.16-2
|
## 2026.1.16-2
|
||||||
|
|
||||||
|
|||||||
@@ -471,35 +471,30 @@ by Peter Steinberger and the community.
|
|||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||||
AI/vibe-coded PRs welcome! 🤖
|
AI/vibe-coded PRs welcome! 🤖
|
||||||
|
|
||||||
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
|
|
||||||
|
|
||||||
Core contributors:
|
|
||||||
- @cpojer — Telegram onboarding UX + docs
|
|
||||||
|
|
||||||
Thanks to all clawtributors:
|
Thanks to all clawtributors:
|
||||||
|
|
||||||
<p align="left">
|
<p align="left">
|
||||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
|
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a>
|
||||||
<a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a>
|
<a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a>
|
||||||
<a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
|
<a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a>
|
||||||
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
|
<a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a>
|
||||||
<a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a>
|
<a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a>
|
||||||
<a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a>
|
<a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a>
|
||||||
<a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a>
|
<a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a>
|
||||||
<a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a>
|
<a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a>
|
||||||
<a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
|
<a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a>
|
||||||
<a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
|
<a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a>
|
||||||
<a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a>
|
<a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
|
||||||
<a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a>
|
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
|
||||||
<a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a>
|
<a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a>
|
||||||
<a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
|
<a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a>
|
||||||
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
|
<a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a>
|
||||||
<a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a>
|
<a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a>
|
||||||
<a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
|
<a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a>
|
||||||
<a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a>
|
<a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a>
|
||||||
<a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a>
|
<a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a>
|
||||||
<a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a>
|
<a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a>
|
||||||
<a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a>
|
<a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a>
|
||||||
<a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
|
<a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a>
|
||||||
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
<a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||||
</p>
|
</p>
|
||||||
+190
-172
@@ -2,6 +2,196 @@
|
|||||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
<title>Clawdbot</title>
|
<title>Clawdbot</title>
|
||||||
|
<item>
|
||||||
|
<title>2026.1.20</title>
|
||||||
|
<pubDate>Wed, 21 Jan 2026 08:18:22 +0000</pubDate>
|
||||||
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
|
<sparkle:version>7116</sparkle:version>
|
||||||
|
<sparkle:shortVersionString>2026.1.20</sparkle:shortVersionString>
|
||||||
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
|
<description><![CDATA[<h2>Clawdbot 2026.1.20</h2>
|
||||||
|
<h3>Changes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui</li>
|
||||||
|
<li>Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui</li>
|
||||||
|
<li>TUI: add syntax highlighting for code blocks. (#1200) https://docs.clawd.bot/tui</li>
|
||||||
|
<li>TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) https://docs.clawd.bot/tui</li>
|
||||||
|
<li>TUI: add a searchable model picker for quicker model selection. (#1198) https://docs.clawd.bot/tui</li>
|
||||||
|
<li>TUI: add input history (up/down) for submitted messages. (#1348) https://docs.clawd.bot/tui</li>
|
||||||
|
<li>ACP: add <code>clawdbot acp</code> for IDE integrations. https://docs.clawd.bot/cli/acp</li>
|
||||||
|
<li>ACP: add <code>clawdbot acp client</code> interactive harness for debugging. https://docs.clawd.bot/cli/acp</li>
|
||||||
|
<li>Skills: add download installs with OS-filtered options. https://docs.clawd.bot/tools/skills</li>
|
||||||
|
<li>Skills: add the local sherpa-onnx-tts skill. https://docs.clawd.bot/tools/skills</li>
|
||||||
|
<li>Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. https://docs.clawd.bot/concepts/memory</li>
|
||||||
|
<li>Memory: add SQLite embedding cache to speed up reindexing and frequent updates. https://docs.clawd.bot/concepts/memory</li>
|
||||||
|
<li>Memory: add OpenAI batch indexing for embeddings when configured. https://docs.clawd.bot/concepts/memory</li>
|
||||||
|
<li>Memory: enable OpenAI batch indexing by default for OpenAI embeddings. https://docs.clawd.bot/concepts/memory</li>
|
||||||
|
<li>Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). https://docs.clawd.bot/concepts/memory</li>
|
||||||
|
<li>Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default. https://docs.clawd.bot/concepts/memory</li>
|
||||||
|
<li>Memory: add <code>--verbose</code> logging for memory status + batch indexing details. https://docs.clawd.bot/concepts/memory</li>
|
||||||
|
<li>Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.clawd.bot/concepts/memory</li>
|
||||||
|
<li>Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) https://docs.clawd.bot/tools/browser</li>
|
||||||
|
<li>Nostr: add the Nostr channel plugin with profile management + onboarding defaults. (#1323) https://docs.clawd.bot/channels/nostr</li>
|
||||||
|
<li>Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) https://docs.clawd.bot/channels/matrix</li>
|
||||||
|
<li>Slack: add HTTP webhook mode via Bolt HTTP receiver. (#1143) https://docs.clawd.bot/channels/slack</li>
|
||||||
|
<li>Telegram: enrich forwarded-message context with normalized origin details + legacy fallback. (#1090) https://docs.clawd.bot/channels/telegram</li>
|
||||||
|
<li>Discord: fall back to <code>/skill</code> when native command limits are exceeded. (#1287)</li>
|
||||||
|
<li>Discord: expose <code>/skill</code> globally. (#1287)</li>
|
||||||
|
<li>Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) https://docs.clawd.bot/plugins/zalouser</li>
|
||||||
|
<li>Plugins: require manifest-embedded config schemas with preflight validation warnings. (#1272) https://docs.clawd.bot/plugins/manifest</li>
|
||||||
|
<li>Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest</li>
|
||||||
|
<li>Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest</li>
|
||||||
|
<li>Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui</li>
|
||||||
|
<li>Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools</li>
|
||||||
|
<li>Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles</li>
|
||||||
|
<li>Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.</li>
|
||||||
|
<li>Plugins: migrate the Zalo plugin to the shared plugin SDK runtime. https://docs.clawd.bot/channels/zalo</li>
|
||||||
|
<li>Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime. https://docs.clawd.bot/plugins/zalouser</li>
|
||||||
|
<li>Plugins: allow optional agent tools with explicit allowlists and add the plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools</li>
|
||||||
|
<li>Plugins: auto-enable bundled channel/provider plugins when configuration is present.</li>
|
||||||
|
<li>Plugins: sync plugin sources on channel switches and update npm-installed plugins during <code>clawdbot update</code>.</li>
|
||||||
|
<li>Plugins: share npm plugin update logic between <code>clawdbot update</code> and <code>clawdbot plugins update</code>.</li>
|
||||||
|
<li>Gateway/API: add <code>/v1/responses</code> (OpenResponses) with item-based input + semantic streaming events. (#1229)</li>
|
||||||
|
<li>Gateway/API: expand <code>/v1/responses</code> to support file/image inputs, tool_choice, usage, and output limits. (#1229)</li>
|
||||||
|
<li>Usage: add <code>/usage cost</code> summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs</li>
|
||||||
|
<li>Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.clawd.bot/cli/security</li>
|
||||||
|
<li>Exec: add host/security/ask routing for gateway + node exec. https://docs.clawd.bot/tools/exec</li>
|
||||||
|
<li>Exec: add <code>/exec</code> directive for per-session exec defaults (host/security/ask/node). https://docs.clawd.bot/tools/exec</li>
|
||||||
|
<li>Exec approvals: migrate approvals to <code>~/.clawdbot/exec-approvals.json</code> with per-agent allowlists + skill auto-allow toggle, and add approvals UI + node exec lifecycle events. https://docs.clawd.bot/tools/exec-approvals</li>
|
||||||
|
<li>Nodes: add headless node host (<code>clawdbot node start</code>) for <code>system.run</code>/<code>system.which</code>. https://docs.clawd.bot/cli/node</li>
|
||||||
|
<li>Nodes: add node daemon service install/status/start/stop/restart. https://docs.clawd.bot/cli/node</li>
|
||||||
|
<li>Bridge: add <code>skills.bins</code> RPC to support node host auto-allow skill bins.</li>
|
||||||
|
<li>Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) https://docs.clawd.bot/concepts/session</li>
|
||||||
|
<li>Sessions: allow <code>sessions_spawn</code> to override thinking level for sub-agent runs. https://docs.clawd.bot/tools/subagents</li>
|
||||||
|
<li>Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers. https://docs.clawd.bot/concepts/groups</li>
|
||||||
|
<li>Models: add Qwen Portal OAuth provider support. (#1120) https://docs.clawd.bot/providers/qwen</li>
|
||||||
|
<li>Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. https://docs.clawd.bot/start/onboarding</li>
|
||||||
|
<li>Docs: clarify allowlist input types and onboarding behavior for messaging channels. https://docs.clawd.bot/start/onboarding</li>
|
||||||
|
<li>Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.clawd.bot/platforms/android</li>
|
||||||
|
<li>Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.clawd.bot/bedrock</li>
|
||||||
|
<li>Docs: clarify WhatsApp voice notes. https://docs.clawd.bot/channels/whatsapp</li>
|
||||||
|
<li>Docs: clarify Windows WSL portproxy LAN access notes. https://docs.clawd.bot/platforms/windows</li>
|
||||||
|
<li>Docs: refresh bird skill install metadata and usage notes. (#1302) https://docs.clawd.bot/tools/browser-login</li>
|
||||||
|
<li>Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.</li>
|
||||||
|
<li>Agents: clarify node_modules read-only guidance in agent instructions.</li>
|
||||||
|
<li>Config: stamp last-touched metadata on write and warn if the config is newer than the running build.</li>
|
||||||
|
<li>macOS: hide usage section when usage is unavailable instead of showing provider errors.</li>
|
||||||
|
<li>Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.</li>
|
||||||
|
<li>Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.</li>
|
||||||
|
<li>Android: remove legacy bridge transport code now that nodes use the gateway protocol.</li>
|
||||||
|
<li>Android: bump okhttp + dnsjava to satisfy lint dependency checks.</li>
|
||||||
|
<li>Build: update workspace + core/plugin deps.</li>
|
||||||
|
<li>Build: use tsgo for dev/watch builds by default (opt out with <code>CLAWDBOT_TS_COMPILER=tsc</code>).</li>
|
||||||
|
<li>Repo: remove the Peekaboo git submodule now that the SPM release is used.</li>
|
||||||
|
<li>macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.</li>
|
||||||
|
<li>macOS: stop syncing Peekaboo in postinstall.</li>
|
||||||
|
<li>Swabble: use the tagged Commander Swift package release.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Breaking</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>BREAKING:</strong> Reject invalid/unknown config entries and refuse to start the gateway for safety. Run <code>clawdbot doctor --fix</code> to repair, then update plugins (<code>clawdbot plugins update</code>) if you use any.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Fixes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Discovery: shorten Bonjour DNS-SD service type to <code>_clawdbot-gw._tcp</code> and update discovery clients/docs.</li>
|
||||||
|
<li>Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.</li>
|
||||||
|
<li>Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244)</li>
|
||||||
|
<li>Diagnostics: gate heartbeat/webhook logging. (#1244)</li>
|
||||||
|
<li>Gateway: strip inbound envelope headers from chat history messages to keep clients clean.</li>
|
||||||
|
<li>Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.</li>
|
||||||
|
<li>Gateway: allow mobile node client ids for iOS + Android handshake validation. (#1354)</li>
|
||||||
|
<li>Gateway: clarify connect/validation errors for gateway params. (#1347)</li>
|
||||||
|
<li>Gateway: preserve restart wake routing + thread replies across restarts. (#1337)</li>
|
||||||
|
<li>Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.</li>
|
||||||
|
<li>Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.</li>
|
||||||
|
<li>Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)</li>
|
||||||
|
<li>Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241)</li>
|
||||||
|
<li>Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)</li>
|
||||||
|
<li>Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. (#1137)</li>
|
||||||
|
<li>Agents: sanitize oversized image payloads before send and surface image-dimension errors.</li>
|
||||||
|
<li>Sessions: fall back to session labels when listing display names. (#1124)</li>
|
||||||
|
<li>Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)</li>
|
||||||
|
<li>Config: log invalid config issues once per run and keep invalid-config errors stackless.</li>
|
||||||
|
<li>Config: allow Perplexity as a web_search provider in config validation. (#1230)</li>
|
||||||
|
<li>Config: allow custom fields under <code>skills.entries.<name>.config</code> for skill credentials/config. (#1226)</li>
|
||||||
|
<li>Doctor: clarify plugin auto-enable hint text in the startup banner.</li>
|
||||||
|
<li>Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)</li>
|
||||||
|
<li>Docs: make docs:list fail fast with a clear error if the docs directory is missing.</li>
|
||||||
|
<li>Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297)</li>
|
||||||
|
<li>Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.</li>
|
||||||
|
<li>CLI: preserve cron delivery settings when editing message payloads. (#1322)</li>
|
||||||
|
<li>CLI: keep <code>clawdbot logs</code> output resilient to broken pipes while preserving progress output.</li>
|
||||||
|
<li>CLI: avoid duplicating --profile/--dev flags when formatting commands.</li>
|
||||||
|
<li>CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207)</li>
|
||||||
|
<li>CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195)</li>
|
||||||
|
<li>CLI: skip runner rebuilds when dist is fresh. (#1231)</li>
|
||||||
|
<li>CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.</li>
|
||||||
|
<li>Status: route native <code>/status</code> to the active agent so model selection reflects the correct profile. (#1301)</li>
|
||||||
|
<li>Status: show both usage windows with reset hints when usage data is available. (#1101)</li>
|
||||||
|
<li>UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315)</li>
|
||||||
|
<li>UI: preserve ordered list numbering in chat markdown. (#1341)</li>
|
||||||
|
<li>UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342)</li>
|
||||||
|
<li>UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283)</li>
|
||||||
|
<li>UI: enable shell mode for sync Windows spawns to avoid <code>pnpm ui:build</code> EINVAL. (#1212)</li>
|
||||||
|
<li>TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202)</li>
|
||||||
|
<li>TUI: align custom editor initialization with the latest pi-tui API. (#1298)</li>
|
||||||
|
<li>TUI: show generic empty-state text for searchable pickers. (#1201)</li>
|
||||||
|
<li>TUI: highlight model search matches and stabilize search ordering.</li>
|
||||||
|
<li>Configure: hide OpenRouter auto routing model from the model picker. (#1182)</li>
|
||||||
|
<li>Memory: show total file counts + scan issues in <code>clawdbot memory status</code>.</li>
|
||||||
|
<li>Memory: fall back to non-batch embeddings after repeated batch failures.</li>
|
||||||
|
<li>Memory: apply OpenAI batch defaults even without explicit remote config.</li>
|
||||||
|
<li>Memory: index atomically so failed reindex preserves the previous memory database. (#1151)</li>
|
||||||
|
<li>Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)</li>
|
||||||
|
<li>Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.</li>
|
||||||
|
<li>Memory: parallelize embedding indexing with rate-limit retries.</li>
|
||||||
|
<li>Memory: split overly long lines to keep embeddings under token limits.</li>
|
||||||
|
<li>Memory: skip empty chunks to avoid invalid embedding inputs.</li>
|
||||||
|
<li>Memory: split embedding batches to avoid OpenAI token limits during indexing.</li>
|
||||||
|
<li>Memory: probe sqlite-vec availability in <code>clawdbot memory status</code>.</li>
|
||||||
|
<li>Exec approvals: enforce allowlist when ask is off.</li>
|
||||||
|
<li>Exec approvals: prefer raw command for node approvals/events.</li>
|
||||||
|
<li>Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.</li>
|
||||||
|
<li>Tools: return a companion-app-required message when node exec is requested with no paired node.</li>
|
||||||
|
<li>Tools: return a companion-app-required message when <code>system.run</code> is requested without a supporting node.</li>
|
||||||
|
<li>Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).</li>
|
||||||
|
<li>Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297)</li>
|
||||||
|
<li>Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)</li>
|
||||||
|
<li>Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147)</li>
|
||||||
|
<li>Discord: make resolve warnings avoid raw JSON payloads on rate limits.</li>
|
||||||
|
<li>Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)</li>
|
||||||
|
<li>Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.</li>
|
||||||
|
<li>Discord: only emit slow listener warnings after 30s.</li>
|
||||||
|
<li>Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123)</li>
|
||||||
|
<li>Telegram: honor pairing allowlists for native slash commands.</li>
|
||||||
|
<li>Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118)</li>
|
||||||
|
<li>Slack: resolve Bolt import interop for Bun + Node. (#1191)</li>
|
||||||
|
<li>Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).</li>
|
||||||
|
<li>Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346)</li>
|
||||||
|
<li>Browser: register AI snapshot refs for act commands. (#1282)</li>
|
||||||
|
<li>Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)</li>
|
||||||
|
<li>Anthropic: default API prompt caching to 1h with configurable TTL override.</li>
|
||||||
|
<li>Anthropic: ignore TTL for OAuth.</li>
|
||||||
|
<li>Auth profiles: keep auto-pinned preference while allowing rotation on failover. (#1138)</li>
|
||||||
|
<li>Auth profiles: user pins stay locked. (#1138)</li>
|
||||||
|
<li>Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332)</li>
|
||||||
|
<li>Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.</li>
|
||||||
|
<li>Tests: stabilize plugin SDK resolution and embedded agent timeouts.</li>
|
||||||
|
<li>Windows: install gateway scheduled task as the current user.</li>
|
||||||
|
<li>Windows: show friendly guidance instead of failing on access denied.</li>
|
||||||
|
<li>macOS: load menu session previews asynchronously so items populate while the menu is open.</li>
|
||||||
|
<li>macOS: use label colors for session preview text so previews render in menu subviews.</li>
|
||||||
|
<li>macOS: suppress usage error text in the menubar cost view.</li>
|
||||||
|
<li>macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166)</li>
|
||||||
|
<li>macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)</li>
|
||||||
|
<li>macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)</li>
|
||||||
|
<li>Daemon: include HOME in service environments to avoid missing HOME errors. (#1214)</li>
|
||||||
|
</ul>
|
||||||
|
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
|
||||||
|
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||||
|
]]></description>
|
||||||
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.20/Clawdbot-2026.1.20.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
|
||||||
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<title>2026.1.16-2</title>
|
<title>2026.1.16-2</title>
|
||||||
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
|
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
|
||||||
@@ -99,177 +289,5 @@
|
|||||||
]]></description>
|
]]></description>
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
|
||||||
<title>2026.1.14-1</title>
|
|
||||||
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
|
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
|
||||||
<sparkle:version>5825</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>2026.1.14-1</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.14-1</h2>
|
|
||||||
<h3>Highlights</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Web search: <code>web_search</code>/<code>web_fetch</code> tools (Brave API) + first-time setup in onboarding/configure.</li>
|
|
||||||
<li>Browser control: Chrome extension relay takeover mode + remote browser control via <code>clawdbot browser serve</code>.</li>
|
|
||||||
<li>Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.</li>
|
|
||||||
<li>Security: expanded <code>clawdbot security audit</code> (+ <code>--fix</code>), detect-secrets CI scan, and a <code>SECURITY.md</code> reporting policy.</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Changes</h3>
|
|
||||||
<h4>Web Tools</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Tools: add <code>web_search</code>/<code>web_fetch</code> (Brave API), including helpful setup hints when the key is missing.</li>
|
|
||||||
<li>Tools: enable <code>web_fetch</code> by default (unless explicitly disabled in config).</li>
|
|
||||||
<li>CLI/Docs: add <code>clawdbot configure --section web</code> for storing Brave API keys and update onboarding tips.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Browser / Control UI</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Browser: add Chrome extension relay takeover mode (toolbar button) + <code>clawdbot browser serve</code> remote control + <code>browser.controlToken</code>.</li>
|
|
||||||
<li>Browser: ship a built-in <code>chrome</code> profile for extension relay and start the relay automatically when running locally.</li>
|
|
||||||
<li>Browser: default <code>browser.defaultProfile</code> to <code>chrome</code> (existing Chrome takeover mode).</li>
|
|
||||||
<li>Browser: add <code>clawdbot browser extension install/path</code> and copy extension path to clipboard.</li>
|
|
||||||
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
|
|
||||||
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
|
|
||||||
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
|
||||||
<li>Control UI: show raw any-map entries in config views; move Docs link into the left nav.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Plugins</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.</li>
|
|
||||||
<li>Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.</li>
|
|
||||||
<li>Channels: add Matrix plugin (external) with docs + onboarding hooks.</li>
|
|
||||||
<li>Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Security</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Security: expand <code>clawdbot security audit</code> checks and publish a <code>SECURITY.md</code> reporting policy.</li>
|
|
||||||
<li>Security: extend <code>clawdbot security audit --fix</code> to tighten more sensitive state paths.</li>
|
|
||||||
<li>Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Onboarding / Daemon</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require <code>--accept-risk</code> for <code>--non-interactive</code>.</li>
|
|
||||||
<li>Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Auth / Usage / Config</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Usage: add MiniMax coding plan usage tracking.</li>
|
|
||||||
<li>Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.</li>
|
|
||||||
<li>Agents: add optional auth-profile copy prompt on <code>agents add</code> and improve auth error messaging.</li>
|
|
||||||
<li>Auth: add dynamic template variables to <code>messages.responsePrefix</code>. (#928) — thanks @sebslight.</li>
|
|
||||||
<li>Config: add <code>channels.<provider>.configWrites</code> gating for channel-initiated config writes; migrate Slack channel IDs.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Channels</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.</li>
|
|
||||||
<li>WhatsApp: add <code>channels.whatsapp.sendReadReceipts</code> to disable auto read receipts. (#882) — thanks @chrisrodz.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Docs</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.</li>
|
|
||||||
<li>Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.</li>
|
|
||||||
<li>Docs: expand gateway security hardening guidance and incident response checklist.</li>
|
|
||||||
<li>Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.</li>
|
|
||||||
<li>Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)</li>
|
|
||||||
<li>Docs: add per-command CLI doc pages and link them from <code>clawdbot <command> --help</code>.</li>
|
|
||||||
<li>Docs: add multi-gateway guide (sidebar + nav).</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Fixes</h3>
|
|
||||||
<h4>Gateway / Daemon / Sessions</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare <code>main</code> alias).</li>
|
|
||||||
<li>Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.</li>
|
|
||||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
|
||||||
<li>Browser: persist role snapshot refs per CDP target so <code>snapshot</code> → <code>act</code> clicks work even if Playwright returns a different Page instance.</li>
|
|
||||||
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
|
|
||||||
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
|
|
||||||
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
|
|
||||||
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
|
|
||||||
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
|
|
||||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
|
||||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
|
||||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
|
||||||
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
|
|
||||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
|
||||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
|
|
||||||
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
|
|
||||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
|
||||||
<li>Daemon: clear persisted launchd disabled state before bootstrap (fixes <code>daemon install</code> after uninstall). (#849) — thanks @ndraiman.</li>
|
|
||||||
<li>Sessions: return deep clones (<code>structuredClone</code>) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.</li>
|
|
||||||
<li>Heartbeat: keep <code>updatedAt</code> monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.</li>
|
|
||||||
<li>Agent: clear run context after CLI runs (<code>clearAgentRunContext</code>) to avoid runaway contexts. (#934) — thanks @ronak-guliani.</li>
|
|
||||||
<li>Gateway/Dev: ensure <code>pnpm gateway:dev</code> always uses the dev profile config + state (<code>~/.clawdbot-dev</code>).</li>
|
|
||||||
</ul>
|
|
||||||
<h4>CLI / Onboarding</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Onboarding: show web search setup at the end (not the beginning).</li>
|
|
||||||
<li>Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.</li>
|
|
||||||
<li>Health: colorize “not configured” provider lines for easier scanning.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Control UI / TUI</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)</li>
|
|
||||||
<li>UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.</li>
|
|
||||||
<li>UI: always apply <code>?token=</code> from URL (fixes unauthorized after re-onboard).</li>
|
|
||||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
|
||||||
<li>TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.</li>
|
|
||||||
<li>TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.</li>
|
|
||||||
<li>TUI: show LLM error messages (rate limits, auth, etc.) instead of <code>(no output)</code>.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Agents / Auth / Tools / Sandbox</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
|
||||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
|
||||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
|
||||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
|
||||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
|
||||||
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
|
|
||||||
<li>Logging: tolerate <code>EIO</code> from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.</li>
|
|
||||||
<li>Sandbox: restore <code>docker.binds</code> config validation and preserve configured PATH for <code>docker exec</code>. (#873) — thanks @akonyer.</li>
|
|
||||||
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>macOS / Apps</h4>
|
|
||||||
<ul>
|
|
||||||
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
|
|
||||||
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
|
|
||||||
<li>macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.</li>
|
|
||||||
<li>macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)</li>
|
|
||||||
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
|
|
||||||
<li>macOS: fix cron preview/testing payload to use <code>channel</code> key. (#867) — thanks @wes-davis.</li>
|
|
||||||
<li>macOS: update cron testing channel arg. (#896) — thanks @ngutman.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Channels / Messaging</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)</li>
|
|
||||||
<li>Slack: respect <code>channels.slack.requireMention</code> default when resolving channel mention gating. (#850) — thanks @evalexpr.</li>
|
|
||||||
<li>Slack: drop Socket Mode events with mismatched <code>api_app_id</code>/<code>team_id</code>. (#889) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.</li>
|
|
||||||
<li>Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.</li>
|
|
||||||
<li>Telegram: honor <code>channels.telegram.timeoutSeconds</code> for grammY API requests. (#863) — thanks @Snaver.</li>
|
|
||||||
<li>Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).</li>
|
|
||||||
<li>Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.</li>
|
|
||||||
<li>Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.</li>
|
|
||||||
<li>Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.</li>
|
|
||||||
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
|
|
||||||
<li>Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.</li>
|
|
||||||
<li>iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)</li>
|
|
||||||
<li>WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.</li>
|
|
||||||
<li>WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.</li>
|
|
||||||
<li>WhatsApp: harden owner command auth.</li>
|
|
||||||
<li>Auto-reply: treat trailing <code>NO_REPLY</code> tokens as silent replies.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Config / Doctor / Packaging</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).</li>
|
|
||||||
<li>Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).</li>
|
|
||||||
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
|
|
||||||
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
|
|
||||||
</ul>
|
|
||||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
|
||||||
]]></description>
|
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
|
|
||||||
</item>
|
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
@@ -529,7 +529,7 @@ class NodeRuntime(context: Context) {
|
|||||||
caps = buildCapabilities(),
|
caps = buildCapabilities(),
|
||||||
commands = buildInvokeCommands(),
|
commands = buildInvokeCommands(),
|
||||||
permissions = emptyMap(),
|
permissions = emptyMap(),
|
||||||
client = buildClientInfo(clientId = "node-host", clientMode = "node"),
|
client = buildClientInfo(clientId = "clawdbot-android", clientMode = "node"),
|
||||||
userAgent = buildUserAgent(),
|
userAgent = buildUserAgent(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,12 @@ final class TalkModeManager: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startRecognition() throws {
|
private func startRecognition() throws {
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
throw NSError(domain: "TalkMode", code: 2, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
|
||||||
|
])
|
||||||
|
#endif
|
||||||
|
|
||||||
self.stopRecognition()
|
self.stopRecognition()
|
||||||
self.speechRecognizer = SFSpeechRecognizer()
|
self.speechRecognizer = SFSpeechRecognizer()
|
||||||
guard let recognizer = self.speechRecognizer else {
|
guard let recognizer = self.speechRecognizer else {
|
||||||
@@ -146,6 +152,11 @@ final class TalkModeManager: NSObject {
|
|||||||
|
|
||||||
let input = self.audioEngine.inputNode
|
let input = self.audioEngine.inputNode
|
||||||
let format = input.outputFormat(forBus: 0)
|
let format = input.outputFormat(forBus: 0)
|
||||||
|
guard format.sampleRate > 0, format.channelCount > 0 else {
|
||||||
|
throw NSError(domain: "TalkMode", code: 3, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Invalid audio input format",
|
||||||
|
])
|
||||||
|
}
|
||||||
input.removeTap(onBus: 0)
|
input.removeTap(onBus: 0)
|
||||||
let tapBlock = Self.makeAudioTapAppendCallback(request: request)
|
let tapBlock = Self.makeAudioTapAppendCallback(request: request)
|
||||||
input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock)
|
input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock)
|
||||||
|
|||||||
@@ -6,15 +6,19 @@ struct ConfigSettings: View {
|
|||||||
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
||||||
@Bindable var store: ChannelsStore
|
@Bindable var store: ChannelsStore
|
||||||
@State private var hasLoaded = false
|
@State private var hasLoaded = false
|
||||||
|
@State private var activeSectionKey: String?
|
||||||
|
@State private var activeSubsection: SubsectionSelection?
|
||||||
|
|
||||||
init(store: ChannelsStore = .shared) {
|
init(store: ChannelsStore = .shared) {
|
||||||
self.store = store
|
self.store = store
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
HStack(spacing: 16) {
|
||||||
self.content
|
self.sidebar
|
||||||
|
self.detail
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.task {
|
.task {
|
||||||
guard !self.hasLoaded else { return }
|
guard !self.hasLoaded else { return }
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
@@ -22,42 +26,125 @@ struct ConfigSettings: View {
|
|||||||
await self.store.loadConfigSchema()
|
await self.store.loadConfigSchema()
|
||||||
await self.store.loadConfig()
|
await self.store.loadConfig()
|
||||||
}
|
}
|
||||||
|
.onAppear { self.ensureSelection() }
|
||||||
|
.onChange(of: self.store.configSchemaLoading) { _, loading in
|
||||||
|
if !loading { self.ensureSelection() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConfigSettings {
|
extension ConfigSettings {
|
||||||
private var content: some View {
|
private enum SubsectionSelection: Hashable {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
case all
|
||||||
self.header
|
case key(String)
|
||||||
if let status = self.store.configStatus {
|
}
|
||||||
Text(status)
|
|
||||||
.font(.callout)
|
private struct ConfigSection: Identifiable {
|
||||||
.foregroundStyle(.secondary)
|
let key: String
|
||||||
}
|
let label: String
|
||||||
self.actionRow
|
let help: String?
|
||||||
Group {
|
let node: ConfigSchemaNode
|
||||||
if self.store.configSchemaLoading {
|
|
||||||
ProgressView().controlSize(.small)
|
var id: String { self.key }
|
||||||
} else if let schema = self.store.configSchema {
|
}
|
||||||
ConfigSchemaForm(store: self.store, schema: schema, path: [])
|
|
||||||
.disabled(self.isNixMode)
|
private struct ConfigSubsection: Identifiable {
|
||||||
} else {
|
let key: String
|
||||||
Text("Schema unavailable.")
|
let label: String
|
||||||
|
let help: String?
|
||||||
|
let node: ConfigSchemaNode
|
||||||
|
let path: ConfigPath
|
||||||
|
|
||||||
|
var id: String { self.key }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sections: [ConfigSection] {
|
||||||
|
guard let schema = self.store.configSchema else { return [] }
|
||||||
|
return self.resolveSections(schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activeSection: ConfigSection? {
|
||||||
|
self.sections.first { $0.key == self.activeSectionKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sidebar: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
|
if self.sections.isEmpty {
|
||||||
|
Text("No config sections available.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
} else {
|
||||||
|
ForEach(self.sections) { section in
|
||||||
|
self.sidebarRow(section)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.store.configDirty, !self.isNixMode {
|
.padding(.vertical, 10)
|
||||||
Text("Unsaved changes")
|
.padding(.horizontal, 10)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.fill(Color(nsColor: .windowBackgroundColor)))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var detail: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
if self.store.configSchemaLoading {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else if let section = self.activeSection {
|
||||||
|
self.sectionDetail(section)
|
||||||
|
} else if self.store.configSchema != nil {
|
||||||
|
self.emptyDetail
|
||||||
|
} else {
|
||||||
|
Text("Schema unavailable.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyDetail: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
self.header
|
||||||
|
Text("Select a config section to view settings.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.vertical, 18)
|
.padding(.vertical, 18)
|
||||||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
}
|
||||||
|
|
||||||
|
private func sectionDetail(_ section: ConfigSection) -> some View {
|
||||||
|
ScrollView(.vertical) {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
self.header
|
||||||
|
if let status = self.store.configStatus {
|
||||||
|
Text(status)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
self.actionRow
|
||||||
|
self.sectionHeader(section)
|
||||||
|
self.subsectionNav(section)
|
||||||
|
self.sectionForm(section)
|
||||||
|
if self.store.configDirty, !self.isNixMode {
|
||||||
|
Text("Unsaved changes")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.vertical, 18)
|
||||||
|
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -71,6 +158,18 @@ extension ConfigSettings {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func sectionHeader(_ section: ConfigSection) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(section.label)
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
if let help = section.help {
|
||||||
|
Text(help)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var actionRow: some View {
|
private var actionRow: some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Button("Reload") {
|
Button("Reload") {
|
||||||
@@ -85,6 +184,204 @@ extension ConfigSettings {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func sidebarRow(_ section: ConfigSection) -> some View {
|
||||||
|
let isSelected = self.activeSectionKey == section.key
|
||||||
|
return Button {
|
||||||
|
self.selectSection(section)
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(section.label)
|
||||||
|
if let help = section.help {
|
||||||
|
Text(help)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
.background(Color.clear)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func subsectionNav(_ section: ConfigSection) -> some View {
|
||||||
|
let subsections = self.resolveSubsections(for: section)
|
||||||
|
if subsections.isEmpty {
|
||||||
|
EmptyView()
|
||||||
|
} else {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
self.subsectionButton(
|
||||||
|
title: "All",
|
||||||
|
isSelected: self.activeSubsection == .all)
|
||||||
|
{
|
||||||
|
self.activeSubsection = .all
|
||||||
|
}
|
||||||
|
ForEach(subsections) { subsection in
|
||||||
|
self.subsectionButton(
|
||||||
|
title: subsection.label,
|
||||||
|
isSelected: self.activeSubsection == .key(subsection.key))
|
||||||
|
{
|
||||||
|
self.activeSubsection = .key(subsection.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func subsectionButton(
|
||||||
|
title: String,
|
||||||
|
isSelected: Bool,
|
||||||
|
action: @escaping () -> Void) -> some View
|
||||||
|
{
|
||||||
|
Button(action: action) {
|
||||||
|
Text(title)
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.foregroundStyle(isSelected ? Color.accentColor : .primary)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(isSelected ? Color.accentColor.opacity(0.18) : Color(nsColor: .controlBackgroundColor))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionForm(_ section: ConfigSection) -> some View {
|
||||||
|
let subsection = self.activeSubsection
|
||||||
|
let defaultPath: ConfigPath = [.key(section.key)]
|
||||||
|
let subsections = self.resolveSubsections(for: section)
|
||||||
|
let resolved: (ConfigSchemaNode, ConfigPath) = {
|
||||||
|
if case let .key(key) = subsection,
|
||||||
|
let match = subsections.first(where: { $0.key == key })
|
||||||
|
{
|
||||||
|
return (match.node, match.path)
|
||||||
|
}
|
||||||
|
return (self.resolvedSchemaNode(section.node), defaultPath)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ConfigSchemaForm(store: self.store, schema: resolved.0, path: resolved.1)
|
||||||
|
.disabled(self.isNixMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureSelection() {
|
||||||
|
guard let schema = self.store.configSchema else { return }
|
||||||
|
let sections = self.resolveSections(schema)
|
||||||
|
guard !sections.isEmpty else { return }
|
||||||
|
|
||||||
|
let active = sections.first { $0.key == self.activeSectionKey } ?? sections[0]
|
||||||
|
if self.activeSectionKey != active.key {
|
||||||
|
self.activeSectionKey = active.key
|
||||||
|
}
|
||||||
|
self.ensureSubsection(for: active)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureSubsection(for section: ConfigSection) {
|
||||||
|
let subsections = self.resolveSubsections(for: section)
|
||||||
|
guard !subsections.isEmpty else {
|
||||||
|
self.activeSubsection = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch self.activeSubsection {
|
||||||
|
case .all:
|
||||||
|
return
|
||||||
|
case let .key(key):
|
||||||
|
if subsections.contains(where: { $0.key == key }) { return }
|
||||||
|
case .none:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if let first = subsections.first {
|
||||||
|
self.activeSubsection = .key(first.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectSection(_ section: ConfigSection) {
|
||||||
|
guard self.activeSectionKey != section.key else { return }
|
||||||
|
self.activeSectionKey = section.key
|
||||||
|
let subsections = self.resolveSubsections(for: section)
|
||||||
|
if let first = subsections.first {
|
||||||
|
self.activeSubsection = .key(first.key)
|
||||||
|
} else {
|
||||||
|
self.activeSubsection = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveSections(_ root: ConfigSchemaNode) -> [ConfigSection] {
|
||||||
|
let node = self.resolvedSchemaNode(root)
|
||||||
|
let hints = self.store.configUiHints
|
||||||
|
let keys = node.properties.keys.sorted { lhs, rhs in
|
||||||
|
let orderA = hintForPath([.key(lhs)], hints: hints)?.order ?? 0
|
||||||
|
let orderB = hintForPath([.key(rhs)], hints: hints)?.order ?? 0
|
||||||
|
if orderA != orderB { return orderA < orderB }
|
||||||
|
return lhs < rhs
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys.compactMap { key in
|
||||||
|
guard let child = node.properties[key] else { return nil }
|
||||||
|
let path: ConfigPath = [.key(key)]
|
||||||
|
let hint = hintForPath(path, hints: hints)
|
||||||
|
let label = hint?.label
|
||||||
|
?? child.title
|
||||||
|
?? self.humanize(key)
|
||||||
|
let help = hint?.help ?? child.description
|
||||||
|
return ConfigSection(key: key, label: label, help: help, node: child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] {
|
||||||
|
let node = self.resolvedSchemaNode(section.node)
|
||||||
|
guard node.schemaType == "object" else { return [] }
|
||||||
|
let hints = self.store.configUiHints
|
||||||
|
let keys = node.properties.keys.sorted { lhs, rhs in
|
||||||
|
let orderA = hintForPath([.key(section.key), .key(lhs)], hints: hints)?.order ?? 0
|
||||||
|
let orderB = hintForPath([.key(section.key), .key(rhs)], hints: hints)?.order ?? 0
|
||||||
|
if orderA != orderB { return orderA < orderB }
|
||||||
|
return lhs < rhs
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys.compactMap { key in
|
||||||
|
guard let child = node.properties[key] else { return nil }
|
||||||
|
let path: ConfigPath = [.key(section.key), .key(key)]
|
||||||
|
let hint = hintForPath(path, hints: hints)
|
||||||
|
let label = hint?.label
|
||||||
|
?? child.title
|
||||||
|
?? self.humanize(key)
|
||||||
|
let help = hint?.help ?? child.description
|
||||||
|
return ConfigSubsection(
|
||||||
|
key: key,
|
||||||
|
label: label,
|
||||||
|
help: help,
|
||||||
|
node: child,
|
||||||
|
path: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedSchemaNode(_ node: ConfigSchemaNode) -> ConfigSchemaNode {
|
||||||
|
let variants = node.anyOf.isEmpty ? node.oneOf : node.anyOf
|
||||||
|
if !variants.isEmpty {
|
||||||
|
let nonNull = variants.filter { !$0.isNullSchema }
|
||||||
|
if nonNull.count == 1, let only = nonNull.first { return only }
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
private func humanize(_ key: String) -> String {
|
||||||
|
key.replacingOccurrences(of: "_", with: " ")
|
||||||
|
.replacingOccurrences(of: "-", with: " ")
|
||||||
|
.capitalized
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ConfigSettings_Previews: PreviewProvider {
|
struct ConfigSettings_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -87,15 +87,7 @@ final class ControlChannel {
|
|||||||
|
|
||||||
func configure() async {
|
func configure() async {
|
||||||
self.logger.info("control channel configure mode=local")
|
self.logger.info("control channel configure mode=local")
|
||||||
self.state = .connecting
|
await self.refreshEndpoint(reason: "configure")
|
||||||
do {
|
|
||||||
try await GatewayConnection.shared.refresh()
|
|
||||||
self.state = .connected
|
|
||||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
|
||||||
} catch {
|
|
||||||
let message = self.friendlyGatewayMessage(error)
|
|
||||||
self.state = .degraded(message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func configure(mode: Mode = .local) async throws {
|
func configure(mode: Mode = .local) async throws {
|
||||||
@@ -111,7 +103,7 @@ final class ControlChannel {
|
|||||||
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
|
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
|
||||||
self.state = .connecting
|
self.state = .connecting
|
||||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||||
await self.configure()
|
await self.refreshEndpoint(reason: "configure")
|
||||||
} catch {
|
} catch {
|
||||||
self.state = .degraded(error.localizedDescription)
|
self.state = .degraded(error.localizedDescription)
|
||||||
throw error
|
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 {
|
func disconnect() async {
|
||||||
await GatewayConnection.shared.shutdown()
|
await GatewayConnection.shared.shutdown()
|
||||||
self.state = .disconnected
|
self.state = .disconnected
|
||||||
@@ -275,18 +280,28 @@ final class ControlChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
await self.refreshEndpoint(reason: "recovery:\(reasonText)")
|
||||||
try await GatewayConnection.shared.refresh()
|
if case .connected = self.state {
|
||||||
self.logger.info("control channel recovery finished")
|
self.logger.info("control channel recovery finished")
|
||||||
} catch {
|
} else if case let .degraded(message) = self.state {
|
||||||
self.logger.error(
|
self.logger.error("control channel recovery failed \(message, privacy: .public)")
|
||||||
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.recoveryTask = nil
|
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 {
|
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
||||||
var merged = params
|
var merged = params
|
||||||
merged["text"] = AnyHashable(text)
|
merged["text"] = AnyHashable(text)
|
||||||
|
|||||||
@@ -276,12 +276,13 @@ enum ExecApprovalsStore {
|
|||||||
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
|
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
: "default"
|
: "default"
|
||||||
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
|
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
|
||||||
|
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
|
||||||
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
||||||
security: agentEntry.security ?? resolvedDefaults.security,
|
security: agentEntry.security ?? wildcardEntry.security ?? resolvedDefaults.security,
|
||||||
ask: agentEntry.ask ?? resolvedDefaults.ask,
|
ask: agentEntry.ask ?? wildcardEntry.ask ?? resolvedDefaults.ask,
|
||||||
askFallback: agentEntry.askFallback ?? resolvedDefaults.askFallback,
|
askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback ?? resolvedDefaults.askFallback,
|
||||||
autoAllowSkills: agentEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills)
|
autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills)
|
||||||
let allowlist = (agentEntry.allowlist ?? [])
|
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
|
||||||
.map { entry in
|
.map { entry in
|
||||||
ExecAllowlistEntry(
|
ExecAllowlistEntry(
|
||||||
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ private enum ExecHostExecutor {
|
|||||||
security: context.security,
|
security: context.security,
|
||||||
allowlistMatch: context.allowlistMatch,
|
allowlistMatch: context.allowlistMatch,
|
||||||
skillAllow: context.skillAllow),
|
skillAllow: context.skillAllow),
|
||||||
approvalDecision == nil
|
approvalDecision == nil
|
||||||
{
|
{
|
||||||
let decision = ExecApprovalsPromptPresenter.prompt(
|
let decision = ExecApprovalsPromptPresenter.prompt(
|
||||||
ExecApprovalPromptRequest(
|
ExecApprovalPromptRequest(
|
||||||
|
|||||||
@@ -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)
|
env: ProcessInfo.processInfo.environment)
|
||||||
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
|
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
|
||||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||||
|
?? TailscaleService.fallbackTailnetIPv4()
|
||||||
return GatewayEndpointStore.resolveLocalGatewayHost(
|
return GatewayEndpointStore.resolveLocalGatewayHost(
|
||||||
bindMode: bind,
|
bindMode: bind,
|
||||||
customBindHost: customBindHost,
|
customBindHost: customBindHost,
|
||||||
@@ -172,6 +173,10 @@ actor GatewayEndpointStore {
|
|||||||
return configToken
|
return configToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isRemote {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!token.isEmpty
|
!token.isEmpty
|
||||||
{
|
{
|
||||||
@@ -483,6 +488,7 @@ actor GatewayEndpointStore {
|
|||||||
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
||||||
|
|
||||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||||
|
?? TailscaleService.fallbackTailnetIPv4()
|
||||||
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
|
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
|
||||||
|
|
||||||
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
||||||
@@ -554,13 +560,16 @@ actor GatewayEndpointStore {
|
|||||||
{
|
{
|
||||||
switch bindMode {
|
switch bindMode {
|
||||||
case "tailnet":
|
case "tailnet":
|
||||||
tailscaleIP ?? "127.0.0.1"
|
return tailscaleIP ?? "127.0.0.1"
|
||||||
case "auto":
|
case "auto":
|
||||||
"127.0.0.1"
|
if let tailscaleIP, !tailscaleIP.isEmpty {
|
||||||
|
return tailscaleIP
|
||||||
|
}
|
||||||
|
return "127.0.0.1"
|
||||||
case "custom":
|
case "custom":
|
||||||
customBindHost ?? "127.0.0.1"
|
return customBindHost ?? "127.0.0.1"
|
||||||
default:
|
default:
|
||||||
"127.0.0.1"
|
return "127.0.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -631,11 +640,12 @@ extension GatewayEndpointStore {
|
|||||||
|
|
||||||
static func _testResolveLocalGatewayHost(
|
static func _testResolveLocalGatewayHost(
|
||||||
bindMode: String?,
|
bindMode: String?,
|
||||||
tailscaleIP: String?) -> String
|
tailscaleIP: String?,
|
||||||
|
customBindHost: String? = nil) -> String
|
||||||
{
|
{
|
||||||
self.resolveLocalGatewayHost(
|
self.resolveLocalGatewayHost(
|
||||||
bindMode: bindMode,
|
bindMode: bindMode,
|
||||||
customBindHost: nil,
|
customBindHost: customBindHost,
|
||||||
tailscaleIP: tailscaleIP)
|
tailscaleIP: tailscaleIP)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,8 +235,8 @@ final class HealthStore {
|
|||||||
let lower = error.lowercased()
|
let lower = error.lowercased()
|
||||||
if lower.contains("connection refused") {
|
if lower.contains("connection refused") {
|
||||||
let port = GatewayEnvironment.gatewayPort()
|
let port = GatewayEnvironment.gatewayPort()
|
||||||
return "The gateway control port (127.0.0.1:\(port)) isn’t listening — " +
|
let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||||
"restart Clawdbot to bring it back."
|
return "The gateway control port (\(host)) isn’t listening — restart Clawdbot to bring it back."
|
||||||
}
|
}
|
||||||
if lower.contains("timeout") {
|
if lower.contains("timeout") {
|
||||||
return "Timed out waiting for the control server; the gateway may be crashed or still starting."
|
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 gatewayManager = GatewayProcessManager.shared
|
||||||
private let controlChannel = ControlChannel.shared
|
private let controlChannel = ControlChannel.shared
|
||||||
private let activityStore = WorkActivityStore.shared
|
private let activityStore = WorkActivityStore.shared
|
||||||
|
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
|
||||||
@State private var statusItem: NSStatusItem?
|
@State private var statusItem: NSStatusItem?
|
||||||
@State private var isMenuPresented = false
|
@State private var isMenuPresented = false
|
||||||
@State private var isPanelVisible = false
|
@State private var isPanelVisible = false
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ extension MenuSessionsInjector {
|
|||||||
}
|
}
|
||||||
case .local:
|
case .local:
|
||||||
platform = "local"
|
platform = "local"
|
||||||
host = "127.0.0.1:\(port)"
|
host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||||
case .unconfigured:
|
case .unconfigured:
|
||||||
platform = nil
|
platform = nil
|
||||||
host = nil
|
host = nil
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import AppKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import os
|
import os
|
||||||
|
#if canImport(Darwin)
|
||||||
|
import Darwin
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Manages Tailscale integration and status checking.
|
/// Manages Tailscale integration and status checking.
|
||||||
@Observable
|
@Observable
|
||||||
@@ -100,16 +103,14 @@ final class TailscaleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkTailscaleStatus() async {
|
func checkTailscaleStatus() async {
|
||||||
|
let previousIP = self.tailscaleIP
|
||||||
self.isInstalled = self.checkAppInstallation()
|
self.isInstalled = self.checkAppInstallation()
|
||||||
guard self.isInstalled else {
|
if !self.isInstalled {
|
||||||
self.isRunning = false
|
self.isRunning = false
|
||||||
self.tailscaleHostname = nil
|
self.tailscaleHostname = nil
|
||||||
self.tailscaleIP = nil
|
self.tailscaleIP = nil
|
||||||
self.statusError = "Tailscale is not installed"
|
self.statusError = "Tailscale is not installed"
|
||||||
return
|
} else if let apiResponse = await fetchTailscaleStatus() {
|
||||||
}
|
|
||||||
|
|
||||||
if let apiResponse = await fetchTailscaleStatus() {
|
|
||||||
self.isRunning = apiResponse.status.lowercased() == "running"
|
self.isRunning = apiResponse.status.lowercased() == "running"
|
||||||
|
|
||||||
if self.isRunning {
|
if self.isRunning {
|
||||||
@@ -138,6 +139,19 @@ final class TailscaleService {
|
|||||||
self.statusError = "Please start the Tailscale app"
|
self.statusError = "Please start the Tailscale app"
|
||||||
self.logger.info("Tailscale API not responding; app likely not running")
|
self.logger.info("Tailscale API not responding; app likely not running")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.tailscaleIP == nil, let fallback = Self.detectTailnetIPv4() {
|
||||||
|
self.tailscaleIP = fallback
|
||||||
|
if !self.isRunning {
|
||||||
|
self.isRunning = true
|
||||||
|
}
|
||||||
|
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() {
|
func openTailscaleApp() {
|
||||||
@@ -163,4 +177,50 @@ final class TailscaleService {
|
|||||||
NSWorkspace.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) }
|
||||||
|
guard octets.count == 4 else { return false }
|
||||||
|
let a = octets[0]
|
||||||
|
let b = octets[1]
|
||||||
|
return a == 100 && b >= 64 && b <= 127
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func detectTailnetIPv4() -> String? {
|
||||||
|
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||||
|
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||||
|
defer { freeifaddrs(addrList) }
|
||||||
|
|
||||||
|
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||||
|
let flags = Int32(ptr.pointee.ifa_flags)
|
||||||
|
let isUp = (flags & IFF_UP) != 0
|
||||||
|
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||||
|
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||||
|
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||||
|
|
||||||
|
var addr = ptr.pointee.ifa_addr.pointee
|
||||||
|
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||||
|
let result = getnameinfo(
|
||||||
|
&addr,
|
||||||
|
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||||
|
&buffer,
|
||||||
|
socklen_t(buffer.count),
|
||||||
|
nil,
|
||||||
|
0,
|
||||||
|
NI_NUMERICHOST)
|
||||||
|
guard result == 0 else { continue }
|
||||||
|
let len = buffer.prefix { $0 != 0 }
|
||||||
|
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||||
|
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||||
|
if Self.isTailnetIPv4(ip) { return ip }
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func fallbackTailnetIPv4() -> String? {
|
||||||
|
Self.detectTailnetIPv4()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import ClawdbotProtocol
|
import ClawdbotProtocol
|
||||||
import Foundation
|
import Foundation
|
||||||
|
#if canImport(Darwin)
|
||||||
|
import Darwin
|
||||||
|
#endif
|
||||||
|
|
||||||
struct ConnectOptions {
|
struct ConnectOptions {
|
||||||
var url: String?
|
var url: String?
|
||||||
var token: String?
|
var token: String?
|
||||||
var password: String?
|
var password: String?
|
||||||
var mode: String?
|
var mode: String?
|
||||||
var timeoutMs: Int = 15_000
|
var timeoutMs: Int = 15000
|
||||||
var json: Bool = false
|
var json: Bool = false
|
||||||
var probe: Bool = false
|
var probe: Bool = false
|
||||||
var clientId: String = "clawdbot-macos"
|
var clientId: String = "clawdbot-macos"
|
||||||
@@ -19,53 +22,43 @@ struct ConnectOptions {
|
|||||||
|
|
||||||
static func parse(_ args: [String]) -> ConnectOptions {
|
static func parse(_ args: [String]) -> ConnectOptions {
|
||||||
var opts = ConnectOptions()
|
var opts = ConnectOptions()
|
||||||
|
let flagHandlers: [String: (inout ConnectOptions) -> Void] = [
|
||||||
|
"-h": { $0.help = true },
|
||||||
|
"--help": { $0.help = true },
|
||||||
|
"--json": { $0.json = true },
|
||||||
|
"--probe": { $0.probe = true },
|
||||||
|
]
|
||||||
|
let valueHandlers: [String: (inout ConnectOptions, String) -> Void] = [
|
||||||
|
"--url": { $0.url = $1 },
|
||||||
|
"--token": { $0.token = $1 },
|
||||||
|
"--password": { $0.password = $1 },
|
||||||
|
"--mode": { $0.mode = $1 },
|
||||||
|
"--timeout": { opts, raw in
|
||||||
|
if let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||||
|
opts.timeoutMs = max(250, parsed)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"--client-id": { $0.clientId = $1 },
|
||||||
|
"--client-mode": { $0.clientMode = $1 },
|
||||||
|
"--display-name": { $0.displayName = $1 },
|
||||||
|
"--role": { $0.role = $1 },
|
||||||
|
"--scopes": { opts, raw in
|
||||||
|
opts.scopes = raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
},
|
||||||
|
]
|
||||||
var i = 0
|
var i = 0
|
||||||
while i < args.count {
|
while i < args.count {
|
||||||
let arg = args[i]
|
let arg = args[i]
|
||||||
switch arg {
|
if let handler = flagHandlers[arg] {
|
||||||
case "-h", "--help":
|
handler(&opts)
|
||||||
opts.help = true
|
i += 1
|
||||||
case "--json":
|
continue
|
||||||
opts.json = true
|
}
|
||||||
case "--probe":
|
if let handler = valueHandlers[arg], let value = self.nextValue(args, index: &i) {
|
||||||
opts.probe = true
|
handler(&opts, value)
|
||||||
case "--url":
|
i += 1
|
||||||
opts.url = self.nextValue(args, index: &i)
|
continue
|
||||||
case "--token":
|
|
||||||
opts.token = self.nextValue(args, index: &i)
|
|
||||||
case "--password":
|
|
||||||
opts.password = self.nextValue(args, index: &i)
|
|
||||||
case "--mode":
|
|
||||||
if let value = self.nextValue(args, index: &i) {
|
|
||||||
opts.mode = value
|
|
||||||
}
|
|
||||||
case "--timeout":
|
|
||||||
if let raw = self.nextValue(args, index: &i),
|
|
||||||
let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
||||||
{
|
|
||||||
opts.timeoutMs = max(250, parsed)
|
|
||||||
}
|
|
||||||
case "--client-id":
|
|
||||||
if let value = self.nextValue(args, index: &i) {
|
|
||||||
opts.clientId = value
|
|
||||||
}
|
|
||||||
case "--client-mode":
|
|
||||||
if let value = self.nextValue(args, index: &i) {
|
|
||||||
opts.clientMode = value
|
|
||||||
}
|
|
||||||
case "--display-name":
|
|
||||||
opts.displayName = self.nextValue(args, index: &i)
|
|
||||||
case "--role":
|
|
||||||
if let value = self.nextValue(args, index: &i) {
|
|
||||||
opts.role = value
|
|
||||||
}
|
|
||||||
case "--scopes":
|
|
||||||
if let value = self.nextValue(args, index: &i) {
|
|
||||||
opts.scopes = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
i += 1
|
i += 1
|
||||||
}
|
}
|
||||||
@@ -254,8 +247,12 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
|
|||||||
|
|
||||||
if resolvedMode == "remote" {
|
if resolvedMode == "remote" {
|
||||||
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
|
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!raw.isEmpty else {
|
!raw.isEmpty
|
||||||
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
|
else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "Gateway",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
|
||||||
}
|
}
|
||||||
guard let url = URL(string: raw) else {
|
guard let url = URL(string: raw) else {
|
||||||
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
|
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
|
||||||
@@ -268,9 +265,12 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
|
|||||||
}
|
}
|
||||||
|
|
||||||
let port = config.port ?? 18789
|
let port = config.port ?? 18789
|
||||||
let host = "127.0.0.1"
|
let host = resolveLocalHost(bind: config.bind)
|
||||||
guard let url = URL(string: "ws://\(host):\(port)") else {
|
guard let url = URL(string: "ws://\(host):\(port)") else {
|
||||||
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
|
throw NSError(
|
||||||
|
domain: "Gateway",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
|
||||||
}
|
}
|
||||||
return GatewayEndpoint(
|
return GatewayEndpoint(
|
||||||
url: url,
|
url: url,
|
||||||
@@ -280,7 +280,7 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? {
|
private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? {
|
||||||
return try? resolveGatewayEndpoint(opts: opts, config: config)
|
try? resolveGatewayEndpoint(opts: opts, config: config)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
|
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
|
||||||
@@ -304,3 +304,56 @@ private func resolvedPassword(opts: ConnectOptions, mode: String, config: Gatewa
|
|||||||
}
|
}
|
||||||
return config.password
|
return config.password
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func resolveLocalHost(bind: String?) -> String {
|
||||||
|
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let tailnetIP = detectTailnetIPv4()
|
||||||
|
switch normalized {
|
||||||
|
case "tailnet", "auto":
|
||||||
|
return tailnetIP ?? "127.0.0.1"
|
||||||
|
default:
|
||||||
|
return "127.0.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detectTailnetIPv4() -> String? {
|
||||||
|
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||||
|
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||||
|
defer { freeifaddrs(addrList) }
|
||||||
|
|
||||||
|
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||||
|
let flags = Int32(ptr.pointee.ifa_flags)
|
||||||
|
let isUp = (flags & IFF_UP) != 0
|
||||||
|
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||||
|
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||||
|
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||||
|
|
||||||
|
var addr = ptr.pointee.ifa_addr.pointee
|
||||||
|
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||||
|
let result = getnameinfo(
|
||||||
|
&addr,
|
||||||
|
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||||
|
&buffer,
|
||||||
|
socklen_t(buffer.count),
|
||||||
|
nil,
|
||||||
|
0,
|
||||||
|
NI_NUMERICHOST)
|
||||||
|
guard result == 0 else { continue }
|
||||||
|
let len = buffer.prefix { $0 != 0 }
|
||||||
|
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||||
|
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||||
|
if isTailnetIPv4(ip) { return ip }
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isTailnetIPv4(_ address: String) -> Bool {
|
||||||
|
let parts = address.split(separator: ".")
|
||||||
|
guard parts.count == 4 else { return false }
|
||||||
|
let octets = parts.compactMap { Int($0) }
|
||||||
|
guard octets.count == 4 else { return false }
|
||||||
|
let a = octets[0]
|
||||||
|
let b = octets[1]
|
||||||
|
return a == 100 && b >= 64 && b <= 127
|
||||||
|
}
|
||||||
@@ -473,6 +473,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
public let replychannel: String?
|
public let replychannel: String?
|
||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
public let replyaccountid: String?
|
public let replyaccountid: String?
|
||||||
|
public let threadid: String?
|
||||||
public let timeout: Int?
|
public let timeout: Int?
|
||||||
public let lane: String?
|
public let lane: String?
|
||||||
public let extrasystemprompt: String?
|
public let extrasystemprompt: String?
|
||||||
@@ -494,6 +495,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
replychannel: String?,
|
replychannel: String?,
|
||||||
accountid: String?,
|
accountid: String?,
|
||||||
replyaccountid: String?,
|
replyaccountid: String?,
|
||||||
|
threadid: String?,
|
||||||
timeout: Int?,
|
timeout: Int?,
|
||||||
lane: String?,
|
lane: String?,
|
||||||
extrasystemprompt: String?,
|
extrasystemprompt: String?,
|
||||||
@@ -514,6 +516,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
self.replychannel = replychannel
|
self.replychannel = replychannel
|
||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
self.replyaccountid = replyaccountid
|
self.replyaccountid = replyaccountid
|
||||||
|
self.threadid = threadid
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.lane = lane
|
self.lane = lane
|
||||||
self.extrasystemprompt = extrasystemprompt
|
self.extrasystemprompt = extrasystemprompt
|
||||||
@@ -535,6 +538,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
case replychannel = "replyChannel"
|
case replychannel = "replyChannel"
|
||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
case replyaccountid = "replyAccountId"
|
case replyaccountid = "replyAccountId"
|
||||||
|
case threadid = "threadId"
|
||||||
case timeout
|
case timeout
|
||||||
case lane
|
case lane
|
||||||
case extrasystemprompt = "extraSystemPrompt"
|
case extrasystemprompt = "extraSystemPrompt"
|
||||||
@@ -835,35 +839,47 @@ public struct SessionsListParams: Codable, Sendable {
|
|||||||
public let activeminutes: Int?
|
public let activeminutes: Int?
|
||||||
public let includeglobal: Bool?
|
public let includeglobal: Bool?
|
||||||
public let includeunknown: Bool?
|
public let includeunknown: Bool?
|
||||||
|
public let includederivedtitles: Bool?
|
||||||
|
public let includelastmessage: Bool?
|
||||||
public let label: String?
|
public let label: String?
|
||||||
public let spawnedby: String?
|
public let spawnedby: String?
|
||||||
public let agentid: String?
|
public let agentid: String?
|
||||||
|
public let search: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
limit: Int?,
|
limit: Int?,
|
||||||
activeminutes: Int?,
|
activeminutes: Int?,
|
||||||
includeglobal: Bool?,
|
includeglobal: Bool?,
|
||||||
includeunknown: Bool?,
|
includeunknown: Bool?,
|
||||||
|
includederivedtitles: Bool?,
|
||||||
|
includelastmessage: Bool?,
|
||||||
label: String?,
|
label: String?,
|
||||||
spawnedby: String?,
|
spawnedby: String?,
|
||||||
agentid: String?
|
agentid: String?,
|
||||||
|
search: String?
|
||||||
) {
|
) {
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
self.activeminutes = activeminutes
|
self.activeminutes = activeminutes
|
||||||
self.includeglobal = includeglobal
|
self.includeglobal = includeglobal
|
||||||
self.includeunknown = includeunknown
|
self.includeunknown = includeunknown
|
||||||
|
self.includederivedtitles = includederivedtitles
|
||||||
|
self.includelastmessage = includelastmessage
|
||||||
self.label = label
|
self.label = label
|
||||||
self.spawnedby = spawnedby
|
self.spawnedby = spawnedby
|
||||||
self.agentid = agentid
|
self.agentid = agentid
|
||||||
|
self.search = search
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case limit
|
case limit
|
||||||
case activeminutes = "activeMinutes"
|
case activeminutes = "activeMinutes"
|
||||||
case includeglobal = "includeGlobal"
|
case includeglobal = "includeGlobal"
|
||||||
case includeunknown = "includeUnknown"
|
case includeunknown = "includeUnknown"
|
||||||
|
case includederivedtitles = "includeDerivedTitles"
|
||||||
|
case includelastmessage = "includeLastMessage"
|
||||||
case label
|
case label
|
||||||
case spawnedby = "spawnedBy"
|
case spawnedby = "spawnedBy"
|
||||||
case agentid = "agentId"
|
case agentid = "agentId"
|
||||||
|
case search
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import SwiftUI
|
|||||||
import Testing
|
import Testing
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|
||||||
|
private typealias SnapshotAnyCodable = Clawdbot.AnyCodable
|
||||||
|
|
||||||
@Suite(.serialized)
|
@Suite(.serialized)
|
||||||
@MainActor
|
@MainActor
|
||||||
struct ChannelsSettingsSmokeTests {
|
struct ChannelsSettingsSmokeTests {
|
||||||
@@ -17,8 +19,11 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
"signal": "Signal",
|
"signal": "Signal",
|
||||||
"imessage": "iMessage",
|
"imessage": "iMessage",
|
||||||
],
|
],
|
||||||
|
channelDetailLabels: nil,
|
||||||
|
channelSystemImages: nil,
|
||||||
|
channelMeta: nil,
|
||||||
channels: [
|
channels: [
|
||||||
"whatsapp": AnyCodable([
|
"whatsapp": SnapshotAnyCodable([
|
||||||
"configured": true,
|
"configured": true,
|
||||||
"linked": true,
|
"linked": true,
|
||||||
"authAgeMs": 86_400_000,
|
"authAgeMs": 86_400_000,
|
||||||
@@ -37,7 +42,7 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
"lastEventAt": 1_700_000_060_000,
|
"lastEventAt": 1_700_000_060_000,
|
||||||
"lastError": "needs login",
|
"lastError": "needs login",
|
||||||
]),
|
]),
|
||||||
"telegram": AnyCodable([
|
"telegram": SnapshotAnyCodable([
|
||||||
"configured": true,
|
"configured": true,
|
||||||
"tokenSource": "env",
|
"tokenSource": "env",
|
||||||
"running": true,
|
"running": true,
|
||||||
@@ -52,7 +57,7 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
],
|
],
|
||||||
"lastProbeAt": 1_700_000_050_000,
|
"lastProbeAt": 1_700_000_050_000,
|
||||||
]),
|
]),
|
||||||
"signal": AnyCodable([
|
"signal": SnapshotAnyCodable([
|
||||||
"configured": true,
|
"configured": true,
|
||||||
"baseUrl": "http://127.0.0.1:8080",
|
"baseUrl": "http://127.0.0.1:8080",
|
||||||
"running": true,
|
"running": true,
|
||||||
@@ -65,7 +70,7 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
],
|
],
|
||||||
"lastProbeAt": 1_700_000_050_000,
|
"lastProbeAt": 1_700_000_050_000,
|
||||||
]),
|
]),
|
||||||
"imessage": AnyCodable([
|
"imessage": SnapshotAnyCodable([
|
||||||
"configured": false,
|
"configured": false,
|
||||||
"running": false,
|
"running": false,
|
||||||
"lastError": "not configured",
|
"lastError": "not configured",
|
||||||
@@ -100,15 +105,18 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
"signal": "Signal",
|
"signal": "Signal",
|
||||||
"imessage": "iMessage",
|
"imessage": "iMessage",
|
||||||
],
|
],
|
||||||
|
channelDetailLabels: nil,
|
||||||
|
channelSystemImages: nil,
|
||||||
|
channelMeta: nil,
|
||||||
channels: [
|
channels: [
|
||||||
"whatsapp": AnyCodable([
|
"whatsapp": SnapshotAnyCodable([
|
||||||
"configured": false,
|
"configured": false,
|
||||||
"linked": false,
|
"linked": false,
|
||||||
"running": false,
|
"running": false,
|
||||||
"connected": false,
|
"connected": false,
|
||||||
"reconnectAttempts": 0,
|
"reconnectAttempts": 0,
|
||||||
]),
|
]),
|
||||||
"telegram": AnyCodable([
|
"telegram": SnapshotAnyCodable([
|
||||||
"configured": false,
|
"configured": false,
|
||||||
"running": false,
|
"running": false,
|
||||||
"lastError": "bot missing",
|
"lastError": "bot missing",
|
||||||
@@ -120,7 +128,7 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
],
|
],
|
||||||
"lastProbeAt": 1_700_000_100_000,
|
"lastProbeAt": 1_700_000_100_000,
|
||||||
]),
|
]),
|
||||||
"signal": AnyCodable([
|
"signal": SnapshotAnyCodable([
|
||||||
"configured": false,
|
"configured": false,
|
||||||
"baseUrl": "http://127.0.0.1:8080",
|
"baseUrl": "http://127.0.0.1:8080",
|
||||||
"running": false,
|
"running": false,
|
||||||
@@ -133,7 +141,7 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
],
|
],
|
||||||
"lastProbeAt": 1_700_000_200_000,
|
"lastProbeAt": 1_700_000_200_000,
|
||||||
]),
|
]),
|
||||||
"imessage": AnyCodable([
|
"imessage": SnapshotAnyCodable([
|
||||||
"configured": false,
|
"configured": false,
|
||||||
"running": false,
|
"running": false,
|
||||||
"lastError": "not configured",
|
"lastError": "not configured",
|
||||||
|
|||||||
@@ -11,16 +11,19 @@ struct CronJobEditorSmokeTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func cronJobEditorBuildsBodyForNewJob() {
|
@Test func cronJobEditorBuildsBodyForNewJob() {
|
||||||
|
let channelsStore = ChannelsStore(isPreview: true)
|
||||||
let view = CronJobEditor(
|
let view = CronJobEditor(
|
||||||
job: nil,
|
job: nil,
|
||||||
isSaving: .constant(false),
|
isSaving: .constant(false),
|
||||||
error: .constant(nil),
|
error: .constant(nil),
|
||||||
|
channelsStore: channelsStore,
|
||||||
onCancel: {},
|
onCancel: {},
|
||||||
onSave: { _ in })
|
onSave: { _ in })
|
||||||
_ = view.body
|
_ = view.body
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func cronJobEditorBuildsBodyForExistingJob() {
|
@Test func cronJobEditorBuildsBodyForExistingJob() {
|
||||||
|
let channelsStore = ChannelsStore(isPreview: true)
|
||||||
let job = CronJob(
|
let job = CronJob(
|
||||||
id: "job-1",
|
id: "job-1",
|
||||||
agentId: "ops",
|
agentId: "ops",
|
||||||
@@ -54,31 +57,36 @@ struct CronJobEditorSmokeTests {
|
|||||||
job: job,
|
job: job,
|
||||||
isSaving: .constant(false),
|
isSaving: .constant(false),
|
||||||
error: .constant(nil),
|
error: .constant(nil),
|
||||||
|
channelsStore: channelsStore,
|
||||||
onCancel: {},
|
onCancel: {},
|
||||||
onSave: { _ in })
|
onSave: { _ in })
|
||||||
_ = view.body
|
_ = view.body
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func cronJobEditorExercisesBuilders() {
|
@Test func cronJobEditorExercisesBuilders() {
|
||||||
|
let channelsStore = ChannelsStore(isPreview: true)
|
||||||
var view = CronJobEditor(
|
var view = CronJobEditor(
|
||||||
job: nil,
|
job: nil,
|
||||||
isSaving: .constant(false),
|
isSaving: .constant(false),
|
||||||
error: .constant(nil),
|
error: .constant(nil),
|
||||||
|
channelsStore: channelsStore,
|
||||||
onCancel: {},
|
onCancel: {},
|
||||||
onSave: { _ in })
|
onSave: { _ in })
|
||||||
view.exerciseForTesting()
|
view.exerciseForTesting()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
|
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
|
||||||
|
let channelsStore = ChannelsStore(isPreview: true)
|
||||||
let view = CronJobEditor(
|
let view = CronJobEditor(
|
||||||
job: nil,
|
job: nil,
|
||||||
isSaving: .constant(false),
|
isSaving: .constant(false),
|
||||||
error: .constant(nil),
|
error: .constant(nil),
|
||||||
|
channelsStore: channelsStore,
|
||||||
onCancel: {},
|
onCancel: {},
|
||||||
onSave: { _ in })
|
onSave: { _ in })
|
||||||
|
|
||||||
var root: [String: Any] = [:]
|
var root: [String: Any] = [:]
|
||||||
view.applyDeleteAfterRun(to: &root, scheduleKind: .at, deleteAfterRun: true)
|
view.applyDeleteAfterRun(to: &root, scheduleKind: CronJobEditor.ScheduleKind.at, deleteAfterRun: true)
|
||||||
let raw = root["deleteAfterRun"] as? Bool
|
let raw = root["deleteAfterRun"] as? Bool
|
||||||
#expect(raw == true)
|
#expect(raw == true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
import Testing
|
import Testing
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
import Testing
|
import Testing
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
import Testing
|
import Testing
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
import Testing
|
import Testing
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|||||||
@@ -139,4 +139,40 @@ import Testing
|
|||||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||||
#expect(resolved.mode == .remote)
|
#expect(resolved.mode == .remote)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func resolveLocalGatewayHostPrefersTailnetForAuto() {
|
||||||
|
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
|
bindMode: "auto",
|
||||||
|
tailscaleIP: "100.64.1.2")
|
||||||
|
#expect(host == "100.64.1.2")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolveLocalGatewayHostFallsBackToLoopbackForAuto() {
|
||||||
|
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
|
bindMode: "auto",
|
||||||
|
tailscaleIP: nil)
|
||||||
|
#expect(host == "127.0.0.1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolveLocalGatewayHostPrefersTailnetForTailnetMode() {
|
||||||
|
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
|
bindMode: "tailnet",
|
||||||
|
tailscaleIP: "100.64.1.5")
|
||||||
|
#expect(host == "100.64.1.5")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolveLocalGatewayHostFallsBackToLoopbackForTailnetMode() {
|
||||||
|
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
|
bindMode: "tailnet",
|
||||||
|
tailscaleIP: nil)
|
||||||
|
#expect(host == "127.0.0.1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolveLocalGatewayHostUsesCustomBindHost() {
|
||||||
|
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
|
bindMode: "custom",
|
||||||
|
tailscaleIP: "100.64.1.9",
|
||||||
|
customBindHost: "192.168.1.10")
|
||||||
|
#expect(host == "192.168.1.10")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -7,15 +7,17 @@ import Testing
|
|||||||
|
|
||||||
@Suite(.serialized)
|
@Suite(.serialized)
|
||||||
struct LowCoverageHelperTests {
|
struct LowCoverageHelperTests {
|
||||||
|
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||||
|
|
||||||
@Test func anyCodableHelperAccessors() throws {
|
@Test func anyCodableHelperAccessors() throws {
|
||||||
let payload: [String: AnyCodable] = [
|
let payload: [String: ProtoAnyCodable] = [
|
||||||
"title": AnyCodable("Hello"),
|
"title": ProtoAnyCodable("Hello"),
|
||||||
"flag": AnyCodable(true),
|
"flag": ProtoAnyCodable(true),
|
||||||
"count": AnyCodable(3),
|
"count": ProtoAnyCodable(3),
|
||||||
"ratio": AnyCodable(1.25),
|
"ratio": ProtoAnyCodable(1.25),
|
||||||
"list": AnyCodable([AnyCodable("a"), AnyCodable(2)]),
|
"list": ProtoAnyCodable([ProtoAnyCodable("a"), ProtoAnyCodable(2)]),
|
||||||
]
|
]
|
||||||
let any = AnyCodable(payload)
|
let any = ProtoAnyCodable(payload)
|
||||||
let dict = try #require(any.dictionaryValue)
|
let dict = try #require(any.dictionaryValue)
|
||||||
#expect(dict["title"]?.stringValue == "Hello")
|
#expect(dict["title"]?.stringValue == "Hello")
|
||||||
#expect(dict["flag"]?.boolValue == true)
|
#expect(dict["flag"]?.boolValue == true)
|
||||||
@@ -76,31 +78,27 @@ struct LowCoverageHelperTests {
|
|||||||
#expect(result.stderr.contains("stderr-1999"))
|
#expect(result.stderr.contains("stderr-1999"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func pairedNodesStorePersists() async throws {
|
@Test func nodeInfoCodableRoundTrip() throws {
|
||||||
let dir = FileManager().temporaryDirectory
|
let info = NodeInfo(
|
||||||
.appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true)
|
|
||||||
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
|
|
||||||
let url = dir.appendingPathComponent("nodes.json")
|
|
||||||
let store = PairedNodesStore(fileURL: url)
|
|
||||||
await store.load()
|
|
||||||
#expect(await store.all().isEmpty)
|
|
||||||
|
|
||||||
let node = PairedNode(
|
|
||||||
nodeId: "node-1",
|
nodeId: "node-1",
|
||||||
displayName: "Node One",
|
displayName: "Node One",
|
||||||
platform: "macOS",
|
platform: "macOS",
|
||||||
version: "1.0",
|
version: "1.0",
|
||||||
|
coreVersion: "1.0-core",
|
||||||
|
uiVersion: "1.0-ui",
|
||||||
deviceFamily: "Mac",
|
deviceFamily: "Mac",
|
||||||
modelIdentifier: "MacBookPro",
|
modelIdentifier: "MacBookPro",
|
||||||
token: "token",
|
remoteIp: "192.168.1.2",
|
||||||
createdAtMs: 1,
|
caps: ["chat"],
|
||||||
lastSeenAtMs: nil)
|
commands: ["send"],
|
||||||
try await store.upsert(node)
|
permissions: ["send": true],
|
||||||
#expect(await store.find(nodeId: "node-1")?.displayName == "Node One")
|
paired: true,
|
||||||
|
connected: false)
|
||||||
try await store.touchSeen(nodeId: "node-1")
|
let data = try JSONEncoder().encode(info)
|
||||||
let updated = await store.find(nodeId: "node-1")
|
let decoded = try JSONDecoder().decode(NodeInfo.self, from: data)
|
||||||
#expect(updated?.lastSeenAtMs != nil)
|
#expect(decoded.nodeId == "node-1")
|
||||||
|
#expect(decoded.isPaired == true)
|
||||||
|
#expect(decoded.isConnected == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor func presenceReporterHelpers() {
|
@Test @MainActor func presenceReporterHelpers() {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import Testing
|
|||||||
features: [:],
|
features: [:],
|
||||||
snapshot: snapshot,
|
snapshot: snapshot,
|
||||||
canvashosturl: nil,
|
canvashosturl: nil,
|
||||||
|
auth: nil,
|
||||||
policy: [:])
|
policy: [:])
|
||||||
|
|
||||||
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello))
|
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello))
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import SwiftUI
|
|||||||
import Testing
|
import Testing
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|
||||||
|
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||||
|
|
||||||
@Suite(.serialized)
|
@Suite(.serialized)
|
||||||
@MainActor
|
@MainActor
|
||||||
struct OnboardingWizardStepViewTests {
|
struct OnboardingWizardStepViewTests {
|
||||||
@Test func noteStepBuilds() {
|
@Test func noteStepBuilds() {
|
||||||
let step = WizardStep(
|
let step = WizardStep(
|
||||||
id: "step-1",
|
id: "step-1",
|
||||||
type: AnyCodable("note"),
|
type: ProtoAnyCodable("note"),
|
||||||
title: "Welcome",
|
title: "Welcome",
|
||||||
message: "Hello",
|
message: "Hello",
|
||||||
options: nil,
|
options: nil,
|
||||||
@@ -22,17 +24,17 @@ struct OnboardingWizardStepViewTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func selectStepBuilds() {
|
@Test func selectStepBuilds() {
|
||||||
let options: [[String: AnyCodable]] = [
|
let options: [[String: ProtoAnyCodable]] = [
|
||||||
["value": AnyCodable("local"), "label": AnyCodable("Local"), "hint": AnyCodable("This Mac")],
|
["value": ProtoAnyCodable("local"), "label": ProtoAnyCodable("Local"), "hint": ProtoAnyCodable("This Mac")],
|
||||||
["value": AnyCodable("remote"), "label": AnyCodable("Remote")],
|
["value": ProtoAnyCodable("remote"), "label": ProtoAnyCodable("Remote")],
|
||||||
]
|
]
|
||||||
let step = WizardStep(
|
let step = WizardStep(
|
||||||
id: "step-2",
|
id: "step-2",
|
||||||
type: AnyCodable("select"),
|
type: ProtoAnyCodable("select"),
|
||||||
title: "Mode",
|
title: "Mode",
|
||||||
message: "Choose a mode",
|
message: "Choose a mode",
|
||||||
options: options,
|
options: options,
|
||||||
initialvalue: AnyCodable("local"),
|
initialvalue: ProtoAnyCodable("local"),
|
||||||
placeholder: nil,
|
placeholder: nil,
|
||||||
sensitive: nil,
|
sensitive: nil,
|
||||||
executor: nil)
|
executor: nil)
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ extension URLSessionWebSocketTask: WebSocketTasking {}
|
|||||||
|
|
||||||
public struct WebSocketTaskBox: @unchecked Sendable {
|
public struct WebSocketTaskBox: @unchecked Sendable {
|
||||||
public let task: any WebSocketTasking
|
public let task: any WebSocketTasking
|
||||||
|
public init(task: any WebSocketTasking) {
|
||||||
|
self.task = task
|
||||||
|
}
|
||||||
|
|
||||||
public var state: URLSessionTask.State { self.task.state }
|
public var state: URLSessionTask.State { self.task.state }
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,35 @@ public actor GatewayNodeSession {
|
|||||||
private var onConnected: (@Sendable () async -> Void)?
|
private var onConnected: (@Sendable () async -> Void)?
|
||||||
private var onDisconnected: (@Sendable (String) async -> Void)?
|
private var onDisconnected: (@Sendable (String) async -> Void)?
|
||||||
private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)?
|
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 serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
|
||||||
private var canvasHostUrl: String?
|
private var canvasHostUrl: String?
|
||||||
|
|
||||||
@@ -167,7 +196,11 @@ public actor GatewayNodeSession {
|
|||||||
let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
|
let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
|
||||||
guard let onInvoke else { return }
|
guard let onInvoke else { return }
|
||||||
let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON)
|
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)
|
await self.sendInvokeResult(request: request, response: response)
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
|
||||||
@@ -180,8 +213,10 @@ public actor GatewayNodeSession {
|
|||||||
"id": AnyCodable(request.id),
|
"id": AnyCodable(request.id),
|
||||||
"nodeId": AnyCodable(request.nodeId),
|
"nodeId": AnyCodable(request.nodeId),
|
||||||
"ok": AnyCodable(response.ok),
|
"ok": AnyCodable(response.ok),
|
||||||
"payloadJSON": AnyCodable(response.payloadJSON ?? NSNull()),
|
|
||||||
]
|
]
|
||||||
|
if let payloadJSON = response.payloadJSON {
|
||||||
|
params["payloadJSON"] = AnyCodable(payloadJSON)
|
||||||
|
}
|
||||||
if let error = response.error {
|
if let error = response.error {
|
||||||
params["error"] = AnyCodable([
|
params["error"] = AnyCodable([
|
||||||
"code": AnyCodable(error.code.rawValue),
|
"code": AnyCodable(error.code.rawValue),
|
||||||
|
|||||||
@@ -473,6 +473,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
public let replychannel: String?
|
public let replychannel: String?
|
||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
public let replyaccountid: String?
|
public let replyaccountid: String?
|
||||||
|
public let threadid: String?
|
||||||
public let timeout: Int?
|
public let timeout: Int?
|
||||||
public let lane: String?
|
public let lane: String?
|
||||||
public let extrasystemprompt: String?
|
public let extrasystemprompt: String?
|
||||||
@@ -494,6 +495,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
replychannel: String?,
|
replychannel: String?,
|
||||||
accountid: String?,
|
accountid: String?,
|
||||||
replyaccountid: String?,
|
replyaccountid: String?,
|
||||||
|
threadid: String?,
|
||||||
timeout: Int?,
|
timeout: Int?,
|
||||||
lane: String?,
|
lane: String?,
|
||||||
extrasystemprompt: String?,
|
extrasystemprompt: String?,
|
||||||
@@ -514,6 +516,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
self.replychannel = replychannel
|
self.replychannel = replychannel
|
||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
self.replyaccountid = replyaccountid
|
self.replyaccountid = replyaccountid
|
||||||
|
self.threadid = threadid
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.lane = lane
|
self.lane = lane
|
||||||
self.extrasystemprompt = extrasystemprompt
|
self.extrasystemprompt = extrasystemprompt
|
||||||
@@ -535,6 +538,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
case replychannel = "replyChannel"
|
case replychannel = "replyChannel"
|
||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
case replyaccountid = "replyAccountId"
|
case replyaccountid = "replyAccountId"
|
||||||
|
case threadid = "threadId"
|
||||||
case timeout
|
case timeout
|
||||||
case lane
|
case lane
|
||||||
case extrasystemprompt = "extraSystemPrompt"
|
case extrasystemprompt = "extraSystemPrompt"
|
||||||
@@ -835,35 +839,47 @@ public struct SessionsListParams: Codable, Sendable {
|
|||||||
public let activeminutes: Int?
|
public let activeminutes: Int?
|
||||||
public let includeglobal: Bool?
|
public let includeglobal: Bool?
|
||||||
public let includeunknown: Bool?
|
public let includeunknown: Bool?
|
||||||
|
public let includederivedtitles: Bool?
|
||||||
|
public let includelastmessage: Bool?
|
||||||
public let label: String?
|
public let label: String?
|
||||||
public let spawnedby: String?
|
public let spawnedby: String?
|
||||||
public let agentid: String?
|
public let agentid: String?
|
||||||
|
public let search: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
limit: Int?,
|
limit: Int?,
|
||||||
activeminutes: Int?,
|
activeminutes: Int?,
|
||||||
includeglobal: Bool?,
|
includeglobal: Bool?,
|
||||||
includeunknown: Bool?,
|
includeunknown: Bool?,
|
||||||
|
includederivedtitles: Bool?,
|
||||||
|
includelastmessage: Bool?,
|
||||||
label: String?,
|
label: String?,
|
||||||
spawnedby: String?,
|
spawnedby: String?,
|
||||||
agentid: String?
|
agentid: String?,
|
||||||
|
search: String?
|
||||||
) {
|
) {
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
self.activeminutes = activeminutes
|
self.activeminutes = activeminutes
|
||||||
self.includeglobal = includeglobal
|
self.includeglobal = includeglobal
|
||||||
self.includeunknown = includeunknown
|
self.includeunknown = includeunknown
|
||||||
|
self.includederivedtitles = includederivedtitles
|
||||||
|
self.includelastmessage = includelastmessage
|
||||||
self.label = label
|
self.label = label
|
||||||
self.spawnedby = spawnedby
|
self.spawnedby = spawnedby
|
||||||
self.agentid = agentid
|
self.agentid = agentid
|
||||||
|
self.search = search
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case limit
|
case limit
|
||||||
case activeminutes = "activeMinutes"
|
case activeminutes = "activeMinutes"
|
||||||
case includeglobal = "includeGlobal"
|
case includeglobal = "includeGlobal"
|
||||||
case includeunknown = "includeUnknown"
|
case includeunknown = "includeUnknown"
|
||||||
|
case includederivedtitles = "includeDerivedTitles"
|
||||||
|
case includelastmessage = "includeLastMessage"
|
||||||
case label
|
case label
|
||||||
case spawnedby = "spawnedBy"
|
case spawnedby = "spawnedBy"
|
||||||
case agentid = "agentId"
|
case agentid = "agentId"
|
||||||
|
case search
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1324,6 +1340,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
|||||||
public let ts: Int
|
public let ts: Int
|
||||||
public let channelorder: [String]
|
public let channelorder: [String]
|
||||||
public let channellabels: [String: AnyCodable]
|
public let channellabels: [String: AnyCodable]
|
||||||
|
public let channeldetaillabels: [String: AnyCodable]?
|
||||||
|
public let channelsystemimages: [String: AnyCodable]?
|
||||||
|
public let channelmeta: [[String: AnyCodable]]?
|
||||||
public let channels: [String: AnyCodable]
|
public let channels: [String: AnyCodable]
|
||||||
public let channelaccounts: [String: AnyCodable]
|
public let channelaccounts: [String: AnyCodable]
|
||||||
public let channeldefaultaccountid: [String: AnyCodable]
|
public let channeldefaultaccountid: [String: AnyCodable]
|
||||||
@@ -1332,6 +1351,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
|||||||
ts: Int,
|
ts: Int,
|
||||||
channelorder: [String],
|
channelorder: [String],
|
||||||
channellabels: [String: AnyCodable],
|
channellabels: [String: AnyCodable],
|
||||||
|
channeldetaillabels: [String: AnyCodable]?,
|
||||||
|
channelsystemimages: [String: AnyCodable]?,
|
||||||
|
channelmeta: [[String: AnyCodable]]?,
|
||||||
channels: [String: AnyCodable],
|
channels: [String: AnyCodable],
|
||||||
channelaccounts: [String: AnyCodable],
|
channelaccounts: [String: AnyCodable],
|
||||||
channeldefaultaccountid: [String: AnyCodable]
|
channeldefaultaccountid: [String: AnyCodable]
|
||||||
@@ -1339,6 +1361,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
|||||||
self.ts = ts
|
self.ts = ts
|
||||||
self.channelorder = channelorder
|
self.channelorder = channelorder
|
||||||
self.channellabels = channellabels
|
self.channellabels = channellabels
|
||||||
|
self.channeldetaillabels = channeldetaillabels
|
||||||
|
self.channelsystemimages = channelsystemimages
|
||||||
|
self.channelmeta = channelmeta
|
||||||
self.channels = channels
|
self.channels = channels
|
||||||
self.channelaccounts = channelaccounts
|
self.channelaccounts = channelaccounts
|
||||||
self.channeldefaultaccountid = channeldefaultaccountid
|
self.channeldefaultaccountid = channeldefaultaccountid
|
||||||
@@ -1347,6 +1372,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
|||||||
case ts
|
case ts
|
||||||
case channelorder = "channelOrder"
|
case channelorder = "channelOrder"
|
||||||
case channellabels = "channelLabels"
|
case channellabels = "channelLabels"
|
||||||
|
case channeldetaillabels = "channelDetailLabels"
|
||||||
|
case channelsystemimages = "channelSystemImages"
|
||||||
|
case channelmeta = "channelMeta"
|
||||||
case channels
|
case channels
|
||||||
case channelaccounts = "channelAccounts"
|
case channelaccounts = "channelAccounts"
|
||||||
case channeldefaultaccountid = "channelDefaultAccountId"
|
case channeldefaultaccountid = "channelDefaultAccountId"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -286,6 +286,11 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
|||||||
- CLI: `clawdbot message send --media <mp4> --gif-playback`
|
- CLI: `clawdbot message send --media <mp4> --gif-playback`
|
||||||
- Gateway: `send` params include `gifPlayback: true`
|
- Gateway: `send` params include `gifPlayback: true`
|
||||||
|
|
||||||
|
## Voice notes (PTT audio)
|
||||||
|
WhatsApp sends audio as **voice notes** (PTT bubble).
|
||||||
|
- Best results: OGG/Opus. Clawdbot rewrites `audio/ogg` to `audio/ogg; codecs=opus`.
|
||||||
|
- `[[audio_as_voice]]` is ignored for WhatsApp (audio already ships as voice note).
|
||||||
|
|
||||||
## Media limits + optimization
|
## Media limits + optimization
|
||||||
- Default outbound cap: 5 MB (per media item).
|
- Default outbound cap: 5 MB (per media item).
|
||||||
- Override: `agents.defaults.mediaMaxMb`.
|
- Override: `agents.defaults.mediaMaxMb`.
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ read_when:
|
|||||||
|
|
||||||
# `clawdbot approvals`
|
# `clawdbot approvals`
|
||||||
|
|
||||||
Manage exec approvals for the **gateway host** or a **node host**.
|
Manage exec approvals for the **local host**, **gateway host**, or a **node host**.
|
||||||
By default, commands target the gateway. Use `--node` to edit a node’s approvals.
|
By default, commands target the local approvals file on disk. Use `--gateway` to target the gateway, or `--node` to target a specific node.
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Exec approvals: [Exec approvals](/tools/exec-approvals)
|
- Exec approvals: [Exec approvals](/tools/exec-approvals)
|
||||||
@@ -19,6 +19,7 @@ Related:
|
|||||||
```bash
|
```bash
|
||||||
clawdbot approvals get
|
clawdbot approvals get
|
||||||
clawdbot approvals get --node <id|name|ip>
|
clawdbot approvals get --node <id|name|ip>
|
||||||
|
clawdbot approvals get --gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
## Replace approvals from a file
|
## Replace approvals from a file
|
||||||
@@ -26,6 +27,7 @@ clawdbot approvals get --node <id|name|ip>
|
|||||||
```bash
|
```bash
|
||||||
clawdbot approvals set --file ./exec-approvals.json
|
clawdbot approvals set --file ./exec-approvals.json
|
||||||
clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json
|
clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json
|
||||||
|
clawdbot approvals set --gateway --file ./exec-approvals.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## Allowlist helpers
|
## Allowlist helpers
|
||||||
@@ -33,6 +35,7 @@ clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json
|
|||||||
```bash
|
```bash
|
||||||
clawdbot approvals allowlist add "~/Projects/**/bin/rg"
|
clawdbot approvals allowlist add "~/Projects/**/bin/rg"
|
||||||
clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"
|
clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"
|
||||||
|
clawdbot approvals allowlist add --agent "*" "/usr/bin/uname"
|
||||||
|
|
||||||
clawdbot approvals allowlist remove "~/Projects/**/bin/rg"
|
clawdbot approvals allowlist remove "~/Projects/**/bin/rg"
|
||||||
```
|
```
|
||||||
@@ -40,5 +43,6 @@ clawdbot approvals allowlist remove "~/Projects/**/bin/rg"
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `--node` uses the same resolver as `clawdbot nodes` (id, name, ip, or id prefix).
|
- `--node` uses the same resolver as `clawdbot nodes` (id, name, ip, or id prefix).
|
||||||
|
- `--agent` defaults to `"*"`, which applies to all agents.
|
||||||
- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host).
|
- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host).
|
||||||
- Approvals files are stored per host at `~/.clawdbot/exec-approvals.json`.
|
- Approvals files are stored per host at `~/.clawdbot/exec-approvals.json`.
|
||||||
@@ -8,6 +8,9 @@ read_when:
|
|||||||
|
|
||||||
Interactive prompt to set up credentials, devices, and agent defaults.
|
Interactive prompt to set up credentials, devices, and agent defaults.
|
||||||
|
|
||||||
|
Note: The **Model** section now includes a multi-select for the
|
||||||
|
`agents.defaults.models` allowlist (what shows up in `/model` and the model picker).
|
||||||
|
|
||||||
Tip: `clawdbot config` without a subcommand opens the same wizard. Use
|
Tip: `clawdbot config` without a subcommand opens the same wizard. Use
|
||||||
`clawdbot config get|set|unset` for non-interactive edits.
|
`clawdbot config get|set|unset` for non-interactive edits.
|
||||||
|
|
||||||
|
|||||||
@@ -14,3 +14,16 @@ Related:
|
|||||||
|
|
||||||
Tip: run `clawdbot cron --help` for the full command surface.
|
Tip: run `clawdbot cron --help` for the full command surface.
|
||||||
|
|
||||||
|
## Common edits
|
||||||
|
|
||||||
|
Update delivery settings without changing the message:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot cron edit <job-id> --deliver --channel telegram --to "123456789"
|
||||||
|
```
|
||||||
|
|
||||||
|
Disable delivery for an isolated job:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot cron edit <job-id> --no-deliver
|
||||||
|
```
|
||||||
+1
-1
@@ -9,7 +9,7 @@ read_when:
|
|||||||
|
|
||||||
Manage the Gateway daemon (background service).
|
Manage the Gateway daemon (background service).
|
||||||
|
|
||||||
Note: `clawdbot service gateway …` is the preferred surface; `daemon` remains
|
Note: `clawdbot daemon …` is the preferred surface for Gateway service management; `daemon` remains
|
||||||
as a legacy alias for compatibility.
|
as a legacy alias for compatibility.
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
|
|||||||
+10
-29
@@ -29,7 +29,6 @@ This page describes the current CLI behavior. If commands change, update this do
|
|||||||
- [`sessions`](/cli/sessions)
|
- [`sessions`](/cli/sessions)
|
||||||
- [`gateway`](/cli/gateway)
|
- [`gateway`](/cli/gateway)
|
||||||
- [`daemon`](/cli/daemon)
|
- [`daemon`](/cli/daemon)
|
||||||
- [`service`](/cli/service)
|
|
||||||
- [`logs`](/cli/logs)
|
- [`logs`](/cli/logs)
|
||||||
- [`models`](/cli/models)
|
- [`models`](/cli/models)
|
||||||
- [`memory`](/cli/memory)
|
- [`memory`](/cli/memory)
|
||||||
@@ -146,21 +145,6 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
start
|
start
|
||||||
stop
|
stop
|
||||||
restart
|
restart
|
||||||
service
|
|
||||||
gateway
|
|
||||||
status
|
|
||||||
install
|
|
||||||
uninstall
|
|
||||||
start
|
|
||||||
stop
|
|
||||||
restart
|
|
||||||
node
|
|
||||||
status
|
|
||||||
install
|
|
||||||
uninstall
|
|
||||||
start
|
|
||||||
stop
|
|
||||||
restart
|
|
||||||
logs
|
logs
|
||||||
models
|
models
|
||||||
list
|
list
|
||||||
@@ -544,7 +528,7 @@ Options:
|
|||||||
- `--debug` (alias for `--verbose`)
|
- `--debug` (alias for `--verbose`)
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Overview includes Gateway + Node service status when available.
|
- Overview includes Gateway + node host service status when available.
|
||||||
|
|
||||||
### Usage tracking
|
### Usage tracking
|
||||||
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
|
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
|
||||||
@@ -806,16 +790,13 @@ All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
|
|||||||
[`clawdbot node`](/cli/node).
|
[`clawdbot node`](/cli/node).
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
- `node start --host <gateway-host> --port 18790`
|
- `node run --host <gateway-host> --port 18790`
|
||||||
- `node service status`
|
- `node status`
|
||||||
- `node service install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
|
- `node install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
|
||||||
- `node service uninstall`
|
- `node uninstall`
|
||||||
- `node service start`
|
- `node run`
|
||||||
- `node service stop`
|
- `node stop`
|
||||||
- `node service restart`
|
- `node restart`
|
||||||
|
|
||||||
Legacy alias:
|
|
||||||
- `node daemon …` (same as `node service …`)
|
|
||||||
|
|
||||||
## Nodes
|
## Nodes
|
||||||
|
|
||||||
@@ -825,9 +806,9 @@ Common options:
|
|||||||
- `--url`, `--token`, `--timeout`, `--json`
|
- `--url`, `--token`, `--timeout`, `--json`
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
- `nodes status`
|
- `nodes status [--connected] [--last-connected <duration>]`
|
||||||
- `nodes describe --node <id|name|ip>`
|
- `nodes describe --node <id|name|ip>`
|
||||||
- `nodes list`
|
- `nodes list [--connected] [--last-connected <duration>]`
|
||||||
- `nodes pending`
|
- `nodes pending`
|
||||||
- `nodes approve <requestId>`
|
- `nodes approve <requestId>`
|
||||||
- `nodes reject <requestId>`
|
- `nodes reject <requestId>`
|
||||||
|
|||||||
+7
-17
@@ -23,10 +23,10 @@ Common use cases:
|
|||||||
Execution is still guarded by **exec approvals** and per‑agent allowlists on the
|
Execution is still guarded by **exec approvals** and per‑agent allowlists on the
|
||||||
node host, so you can keep command access scoped and explicit.
|
node host, so you can keep command access scoped and explicit.
|
||||||
|
|
||||||
## Start (foreground)
|
## Run (foreground)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot node start --host <gateway-host> --port 18790
|
clawdbot node run --host <gateway-host> --port 18790
|
||||||
```
|
```
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
@@ -42,9 +42,7 @@ Options:
|
|||||||
Install a headless node host as a user service.
|
Install a headless node host as a user service.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot node service install --host <gateway-host> --port 18790
|
clawdbot node install --host <gateway-host> --port 18790
|
||||||
# or
|
|
||||||
clawdbot service node install --host <gateway-host> --port 18790
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
@@ -61,18 +59,10 @@ Manage the service:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot node status
|
clawdbot node status
|
||||||
clawdbot service node status
|
clawdbot node run
|
||||||
clawdbot node service status
|
clawdbot node stop
|
||||||
clawdbot node service start
|
clawdbot node restart
|
||||||
clawdbot node service stop
|
clawdbot node uninstall
|
||||||
clawdbot node service restart
|
|
||||||
clawdbot node service uninstall
|
|
||||||
```
|
|
||||||
|
|
||||||
Legacy alias:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot node daemon status
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Pairing
|
## Pairing
|
||||||
|
|||||||
+8
-1
@@ -18,15 +18,22 @@ Related:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot nodes list
|
clawdbot nodes list
|
||||||
|
clawdbot nodes list --connected
|
||||||
|
clawdbot nodes list --last-connected 24h
|
||||||
clawdbot nodes pending
|
clawdbot nodes pending
|
||||||
clawdbot nodes approve <requestId>
|
clawdbot nodes approve <requestId>
|
||||||
clawdbot nodes status
|
clawdbot nodes status
|
||||||
|
clawdbot nodes status --connected
|
||||||
|
clawdbot nodes status --last-connected 24h
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`nodes list` prints pending/paired tables. Paired rows include the most recent connect age (Last Connect).
|
||||||
|
Use `--connected` to only show currently-connected nodes. Use `--last-connected <duration>` to
|
||||||
|
filter to nodes that connected within a duration (e.g. `24h`, `7d`).
|
||||||
|
|
||||||
## Invoke / run
|
## Invoke / run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot nodes invoke --node <id|name|ip> --command <command> --params <json>
|
clawdbot nodes invoke --node <id|name|ip> --command <command> --params <json>
|
||||||
clawdbot nodes run --node <id|name|ip> <command...>
|
clawdbot nodes run --node <id|name|ip> <command...>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -21,4 +21,4 @@ clawdbot security audit --fix
|
|||||||
```
|
```
|
||||||
|
|
||||||
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
|
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
|
||||||
It also warns when small models (<=300B) are used without sandboxing and with web/browser tools enabled.
|
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
|
||||||
@@ -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:
|
Notes:
|
||||||
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
||||||
- Output includes per-agent session stores when multiple agents are configured.
|
- Output includes per-agent session stores when multiple agents are configured.
|
||||||
- Overview includes Gateway + Node service install/runtime status when available.
|
- Overview includes Gateway + node host service install/runtime status when available.
|
||||||
- Overview includes update channel + git SHA (for source checkouts).
|
- Overview includes update channel + git SHA (for source checkouts).
|
||||||
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
|
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
|
||||||
+12
-3
@@ -7,9 +7,9 @@ read_when:
|
|||||||
|
|
||||||
# `clawdbot update`
|
# `clawdbot update`
|
||||||
|
|
||||||
Safely update a **source checkout** (git install) of Clawdbot.
|
Safely update Clawdbot and switch between stable/beta/dev channels.
|
||||||
|
|
||||||
If you installed via **npm/pnpm** (global install, no git metadata), use the package manager flow in [Updating](/install/updating).
|
If you installed via **npm/pnpm** (global install, no git metadata), updates happen via the package manager flow in [Updating](/install/updating).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -48,7 +48,16 @@ Options:
|
|||||||
- `--json`: print machine-readable status JSON.
|
- `--json`: print machine-readable status JSON.
|
||||||
- `--timeout <seconds>`: timeout for checks (default is 3s).
|
- `--timeout <seconds>`: timeout for checks (default is 3s).
|
||||||
|
|
||||||
## What it does (git checkout)
|
## What it does
|
||||||
|
|
||||||
|
When you switch channels explicitly (`--channel ...`), Clawdbot also keeps the
|
||||||
|
install method aligned:
|
||||||
|
|
||||||
|
- `dev` → ensures a git checkout (default: `~/clawdbot`, override with `CLAWDBOT_GIT_DIR`),
|
||||||
|
updates it, and installs the global CLI from that checkout.
|
||||||
|
- `stable`/`beta` → installs from npm using the matching dist-tag.
|
||||||
|
|
||||||
|
## Git checkout flow
|
||||||
|
|
||||||
Channels:
|
Channels:
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,21 @@ Client Gateway
|
|||||||
safely retry; the server keeps a short‑lived dedupe cache.
|
safely retry; the server keeps a short‑lived dedupe cache.
|
||||||
- Nodes must include `role: "node"` plus caps/commands/permissions in `connect`.
|
- Nodes must include `role: "node"` plus caps/commands/permissions in `connect`.
|
||||||
|
|
||||||
|
## Pairing + local trust
|
||||||
|
|
||||||
|
- All WS clients (operators + nodes) include a **device identity** on `connect`.
|
||||||
|
- New device IDs require pairing approval; the Gateway issues a **device token**
|
||||||
|
for subsequent connects.
|
||||||
|
- **Local** connects (loopback or the gateway host’s own tailnet address) can be
|
||||||
|
auto‑approved to keep same‑host UX smooth.
|
||||||
|
- **Non‑local** connects must sign the `connect.challenge` nonce and require
|
||||||
|
explicit approval.
|
||||||
|
- Gateway auth (`gateway.auth.*`) still applies to **all** connections, local or
|
||||||
|
remote.
|
||||||
|
|
||||||
|
Details: [Gateway protocol](/gateway/protocol), [Pairing](/start/pairing),
|
||||||
|
[Security](/gateway/security).
|
||||||
|
|
||||||
## Protocol typing and codegen
|
## Protocol typing and codegen
|
||||||
|
|
||||||
- TypeBox schemas define the protocol.
|
- TypeBox schemas define the protocol.
|
||||||
|
|||||||
+20
-2
@@ -194,7 +194,7 @@ Local mode:
|
|||||||
|
|
||||||
- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`).
|
- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`).
|
||||||
- Index storage: per-agent SQLite at `~/.clawdbot/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
|
- Index storage: per-agent SQLite at `~/.clawdbot/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
|
||||||
- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync runs on session start, on first search when dirty, and optionally on an interval.
|
- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync is scheduled on session start, on search, or on an interval and runs asynchronously. Session transcripts use delta thresholds to trigger background sync.
|
||||||
- Reindex triggers: the index stores the embedding **provider/model + endpoint fingerprint + chunking params**. If any of those change, Clawdbot automatically resets and reindexes the entire store.
|
- Reindex triggers: the index stores the embedding **provider/model + endpoint fingerprint + chunking params**. If any of those change, Clawdbot automatically resets and reindexes the entire store.
|
||||||
|
|
||||||
### Hybrid search (BM25 + vector)
|
### Hybrid search (BM25 + vector)
|
||||||
@@ -299,11 +299,29 @@ agents: {
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Session indexing is **opt-in** (off by default).
|
- Session indexing is **opt-in** (off by default).
|
||||||
- Session updates are debounced and indexed lazily on the next `memory_search` (or manual `clawdbot memory index`).
|
- Session updates are debounced and **indexed asynchronously** once they cross delta thresholds (best-effort).
|
||||||
|
- `memory_search` never blocks on indexing; results can be slightly stale until background sync finishes.
|
||||||
- Results still include snippets only; `memory_get` remains limited to memory files.
|
- Results still include snippets only; `memory_get` remains limited to memory files.
|
||||||
- Session indexing is isolated per agent (only that agent’s session logs are indexed).
|
- Session indexing is isolated per agent (only that agent’s session logs are indexed).
|
||||||
- Session logs live on disk (`~/.clawdbot/agents/<agentId>/sessions/*.jsonl`). Any process/user with filesystem access can read them, so treat disk access as the trust boundary. For stricter isolation, run agents under separate OS users or hosts.
|
- Session logs live on disk (`~/.clawdbot/agents/<agentId>/sessions/*.jsonl`). Any process/user with filesystem access can read them, so treat disk access as the trust boundary. For stricter isolation, run agents under separate OS users or hosts.
|
||||||
|
|
||||||
|
Delta thresholds (defaults shown):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
memorySearch: {
|
||||||
|
sync: {
|
||||||
|
sessions: {
|
||||||
|
deltaBytes: 100000, // ~100 KB
|
||||||
|
deltaMessages: 50 // JSONL lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### SQLite vector acceleration (sqlite-vec)
|
### SQLite vector acceleration (sqlite-vec)
|
||||||
|
|
||||||
When the sqlite-vec extension is available, Clawdbot stores embeddings in a
|
When the sqlite-vec extension is available, Clawdbot stores embeddings in a
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Unknown keys, malformed types, or invalid values cause the Gateway to **refuse t
|
|||||||
|
|
||||||
When validation fails:
|
When validation fails:
|
||||||
- The Gateway does not boot.
|
- The Gateway does not boot.
|
||||||
- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot service`, `clawdbot help`).
|
- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot daemon`, `clawdbot help`).
|
||||||
- Run `clawdbot doctor` to see the exact issues.
|
- Run `clawdbot doctor` to see the exact issues.
|
||||||
- Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs.
|
- Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs.
|
||||||
|
|
||||||
@@ -1774,6 +1774,7 @@ Note: `applyPatch` is only under `tools.exec`.
|
|||||||
- `tools.web.fetch.maxChars` (default 50000)
|
- `tools.web.fetch.maxChars` (default 50000)
|
||||||
- `tools.web.fetch.timeoutSeconds` (default 30)
|
- `tools.web.fetch.timeoutSeconds` (default 30)
|
||||||
- `tools.web.fetch.cacheTtlMinutes` (default 15)
|
- `tools.web.fetch.cacheTtlMinutes` (default 15)
|
||||||
|
- `tools.web.fetch.maxRedirects` (default 3)
|
||||||
- `tools.web.fetch.userAgent` (optional override)
|
- `tools.web.fetch.userAgent` (optional override)
|
||||||
- `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only)
|
- `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only)
|
||||||
- `tools.web.fetch.firecrawl.enabled` (default true when an API key is set)
|
- `tools.web.fetch.firecrawl.enabled` (default true when an API key is set)
|
||||||
@@ -2614,10 +2615,13 @@ Defaults:
|
|||||||
// noSandbox: false,
|
// noSandbox: false,
|
||||||
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||||
// attachOnly: false, // set true when tunneling a remote CDP to localhost
|
// attachOnly: false, // set true when tunneling a remote CDP to localhost
|
||||||
|
// snapshotDefaults: { mode: "efficient" }, // tool/CLI default snapshot preset
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note: `browser.snapshotDefaults` only affects Clawdbot's browser tool + CLI. Direct HTTP clients must pass `mode` explicitly.
|
||||||
|
|
||||||
### `ui` (Appearance)
|
### `ui` (Appearance)
|
||||||
|
|
||||||
Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint).
|
Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint).
|
||||||
@@ -3018,6 +3022,9 @@ Template placeholders are expanded in `tools.media.*.models[].args` and `tools.m
|
|||||||
| `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per channel) |
|
| `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per channel) |
|
||||||
| `{{To}}` | Destination identifier |
|
| `{{To}}` | Destination identifier |
|
||||||
| `{{MessageSid}}` | Channel message id (when available) |
|
| `{{MessageSid}}` | Channel message id (when available) |
|
||||||
|
| `{{MessageSidFull}}` | Provider-specific full message id when `MessageSid` is shortened |
|
||||||
|
| `{{ReplyToId}}` | Reply-to message id (when available) |
|
||||||
|
| `{{ReplyToIdFull}}` | Provider-specific full reply-to id when `ReplyToId` is shortened |
|
||||||
| `{{SessionId}}` | Current session UUID |
|
| `{{SessionId}}` | Current session UUID |
|
||||||
| `{{IsNewSession}}` | `"true"` when a new session was created |
|
| `{{IsNewSession}}` | `"true"` when a new session was created |
|
||||||
| `{{MediaUrl}}` | Inbound media pseudo-URL (if present) |
|
| `{{MediaUrl}}` | Inbound media pseudo-URL (if present) |
|
||||||
|
|||||||
@@ -195,6 +195,8 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
|||||||
- Gateways issue tokens per device + role.
|
- Gateways issue tokens per device + role.
|
||||||
- Pairing approvals are required for new device IDs unless local auto-approval
|
- Pairing approvals are required for new device IDs unless local auto-approval
|
||||||
is enabled.
|
is enabled.
|
||||||
|
- **Local** connects include loopback and the gateway host’s own tailnet address
|
||||||
|
(so same‑host tailnet binds can still auto‑approve).
|
||||||
- All WS clients must include `device` identity during `connect` (operator + node).
|
- All WS clients must include `device` identity during `connect` (operator + node).
|
||||||
- Non-local connections must sign the server-provided `connect.challenge` nonce.
|
- Non-local connections must sign the server-provided `connect.challenge` nonce.
|
||||||
|
|
||||||
|
|||||||
@@ -270,6 +270,12 @@ Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
|
|||||||
protect local WS access.
|
protect local WS access.
|
||||||
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
|
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
|
||||||
|
|
||||||
|
Local device pairing:
|
||||||
|
- Device pairing is auto‑approved for **local** connects (loopback or the
|
||||||
|
gateway host’s own tailnet address) to keep same‑host clients smooth.
|
||||||
|
- Other tailnet peers are **not** treated as local; they still need pairing
|
||||||
|
approval.
|
||||||
|
|
||||||
Auth modes:
|
Auth modes:
|
||||||
- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups).
|
- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups).
|
||||||
- `gateway.auth.mode: "password"`: password auth (prefer setting via env: `CLAWDBOT_GATEWAY_PASSWORD`).
|
- `gateway.auth.mode: "password"`: password auth (prefer setting via env: `CLAWDBOT_GATEWAY_PASSWORD`).
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ clawdbot doctor --fix
|
|||||||
Notes:
|
Notes:
|
||||||
- `clawdbot doctor` reports every invalid entry.
|
- `clawdbot doctor` reports every invalid entry.
|
||||||
- `clawdbot doctor --fix` applies migrations/repairs and rewrites the config.
|
- `clawdbot doctor --fix` applies migrations/repairs and rewrites the config.
|
||||||
- Diagnostic commands like `clawdbot logs`, `clawdbot health`, `clawdbot status`, and `clawdbot service` still run even if the config is invalid.
|
- Diagnostic commands like `clawdbot logs`, `clawdbot health`, `clawdbot status`, and `clawdbot daemon` still run even if the config is invalid.
|
||||||
|
|
||||||
### “All models failed” — what should I check first?
|
### “All models failed” — what should I check first?
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ read_when:
|
|||||||
|
|
||||||
# Development channels
|
# Development channels
|
||||||
|
|
||||||
Last updated: 2026-01-20
|
Last updated: 2026-01-21
|
||||||
|
|
||||||
Clawdbot ships three update channels:
|
Clawdbot ships three update channels:
|
||||||
|
|
||||||
@@ -38,6 +38,13 @@ clawdbot update --channel dev
|
|||||||
|
|
||||||
This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`).
|
This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`).
|
||||||
|
|
||||||
|
When you **explicitly** switch channels with `--channel`, Clawdbot also aligns
|
||||||
|
the install method:
|
||||||
|
|
||||||
|
- `dev` ensures a git checkout (default `~/clawdbot`, override with `CLAWDBOT_GIT_DIR`),
|
||||||
|
updates it, and installs the global CLI from that checkout.
|
||||||
|
- `stable`/`beta` installs from npm using the matching dist-tag.
|
||||||
|
|
||||||
Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one.
|
Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one.
|
||||||
|
|
||||||
## Plugins and channels
|
## Plugins and channels
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
summary: "Network hub: gateway surfaces, pairing, discovery, and security"
|
||||||
|
read_when:
|
||||||
|
- You need the network architecture + security overview
|
||||||
|
- You are debugging local vs tailnet access or pairing
|
||||||
|
- You want the canonical list of networking docs
|
||||||
|
---
|
||||||
|
# Network hub
|
||||||
|
|
||||||
|
This hub links the core docs for how Clawdbot connects, pairs, and secures
|
||||||
|
devices across localhost, LAN, and tailnet.
|
||||||
|
|
||||||
|
## Core model
|
||||||
|
|
||||||
|
- [Gateway architecture](/concepts/architecture)
|
||||||
|
- [Gateway protocol](/gateway/protocol)
|
||||||
|
- [Gateway runbook](/gateway)
|
||||||
|
- [Web surfaces + bind modes](/web)
|
||||||
|
|
||||||
|
## Pairing + identity
|
||||||
|
|
||||||
|
- [Pairing overview (DM + nodes)](/start/pairing)
|
||||||
|
- [Gateway-owned node pairing](/gateway/pairing)
|
||||||
|
- [Devices CLI (pairing + token rotation)](/cli/devices)
|
||||||
|
- [Pairing CLI (DM approvals)](/cli/pairing)
|
||||||
|
|
||||||
|
Local trust:
|
||||||
|
- Local connections (loopback or the gateway host’s own tailnet address) can be
|
||||||
|
auto‑approved for pairing to keep same‑host UX smooth.
|
||||||
|
- Non‑local tailnet/LAN clients still require explicit pairing approval.
|
||||||
|
|
||||||
|
## Discovery + transports
|
||||||
|
|
||||||
|
- [Discovery & transports](/gateway/discovery)
|
||||||
|
- [Bonjour / mDNS](/gateway/bonjour)
|
||||||
|
- [Remote access (SSH)](/gateway/remote)
|
||||||
|
- [Tailscale](/gateway/tailscale)
|
||||||
|
|
||||||
|
## Nodes + bridge
|
||||||
|
|
||||||
|
- [Nodes overview](/nodes)
|
||||||
|
- [Bridge protocol (legacy nodes)](/gateway/bridge-protocol)
|
||||||
|
- [Node runbook: iOS](/platforms/ios)
|
||||||
|
- [Node runbook: Android](/platforms/android)
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- [Security overview](/gateway/security)
|
||||||
|
- [Gateway config reference](/gateway/configuration)
|
||||||
|
- [Troubleshooting](/gateway/troubleshooting)
|
||||||
|
- [Doctor](/gateway/doctor)
|
||||||
+79
-1
@@ -34,6 +34,81 @@ clawdbot nodes rename --node <idOrNameOrIp> --name "Kitchen iPad"
|
|||||||
Notes:
|
Notes:
|
||||||
- `nodes rename` stores a display name override in the gateway pairing store.
|
- `nodes rename` stores a display name override in the gateway pairing store.
|
||||||
|
|
||||||
|
## Remote node host (system.run)
|
||||||
|
|
||||||
|
Use a **node host** when your Gateway runs on one machine and you want commands
|
||||||
|
to execute on another. The model still talks to the **gateway**; the gateway
|
||||||
|
forwards `exec` calls to the **node host** when `host=node` is selected.
|
||||||
|
|
||||||
|
### What runs where
|
||||||
|
- **Gateway host**: receives messages, runs the model, routes tool calls.
|
||||||
|
- **Node host**: executes `system.run`/`system.which` on the node machine.
|
||||||
|
- **Approvals**: enforced on the node host via `~/.clawdbot/exec-approvals.json`.
|
||||||
|
|
||||||
|
### Start a node host (foreground)
|
||||||
|
|
||||||
|
On the node machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot node run --host <gateway-host> --port 18789 --display-name "Build Node"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start a node host (service)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot node install --host <gateway-host> --port 18789 --display-name "Build Node"
|
||||||
|
clawdbot node start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pair + name
|
||||||
|
|
||||||
|
On the gateway host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot nodes pending
|
||||||
|
clawdbot nodes approve <requestId>
|
||||||
|
clawdbot nodes list
|
||||||
|
```
|
||||||
|
|
||||||
|
Naming options:
|
||||||
|
- `--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
|
||||||
|
|
||||||
|
Exec approvals are **per node host**. Add allowlist entries from the gateway:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot approvals allowlist add --node <id|name|ip> "/usr/bin/uname"
|
||||||
|
clawdbot approvals allowlist add --node <id|name|ip> "/usr/bin/sw_vers"
|
||||||
|
```
|
||||||
|
|
||||||
|
Approvals live on the node host at `~/.clawdbot/exec-approvals.json`.
|
||||||
|
|
||||||
|
### Point exec at the node
|
||||||
|
|
||||||
|
Configure defaults (gateway config):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config set tools.exec.host node
|
||||||
|
clawdbot config set tools.exec.security allowlist
|
||||||
|
clawdbot config set tools.exec.node "<id-or-name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or per session:
|
||||||
|
|
||||||
|
```
|
||||||
|
/exec host=node security=allowlist node=<id-or-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Once set, any `exec` call with `host=node` runs on the node host (subject to the
|
||||||
|
node allowlist/approvals).
|
||||||
|
|
||||||
|
Related:
|
||||||
|
- [Node host CLI](/cli/node)
|
||||||
|
- [Exec tool](/tools/exec)
|
||||||
|
- [Exec approvals](/tools/exec-approvals)
|
||||||
|
|
||||||
## Invoking commands
|
## Invoking commands
|
||||||
|
|
||||||
Low-level (raw RPC):
|
Low-level (raw RPC):
|
||||||
@@ -206,7 +281,7 @@ or for running a minimal node alongside a server.
|
|||||||
Start it:
|
Start it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot node start --host <gateway-host> --port 18790
|
clawdbot node run --host <gateway-host> --port 18790
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
@@ -214,6 +289,9 @@ Notes:
|
|||||||
- The node host stores its node id + pairing token in `~/.clawdbot/node.json`.
|
- The node host stores its node id + pairing token in `~/.clawdbot/node.json`.
|
||||||
- Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json`
|
- Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json`
|
||||||
(see [Exec approvals](/tools/exec-approvals)).
|
(see [Exec approvals](/tools/exec-approvals)).
|
||||||
|
- On macOS, the headless node host prefers the companion app exec host when reachable and falls
|
||||||
|
back to local execution if the app is unavailable. Set `CLAWDBOT_NODE_EXEC_HOST=app` to require
|
||||||
|
the app, or `CLAWDBOT_NODE_EXEC_FALLBACK=0` to disable fallback.
|
||||||
- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
|
- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
|
||||||
|
|
||||||
## Mac node mode
|
## Mac node mode
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Clawdbot macOS IPC architecture
|
# Clawdbot macOS IPC architecture
|
||||||
|
|
||||||
**Current model:** a local Unix socket connects the **node service** to the **macOS app** for exec approvals + `system.run`. A `clawdbot-mac` debug CLI exists for discovery/connect checks; agent actions still flow through the Gateway WebSocket and `node.invoke`. UI automation uses PeekabooBridge.
|
**Current model:** a local Unix socket connects the **node host service** to the **macOS app** for exec approvals + `system.run`. A `clawdbot-mac` debug CLI exists for discovery/connect checks; agent actions still flow through the Gateway WebSocket and `node.invoke`. UI automation uses PeekabooBridge.
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
- Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript).
|
- Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript).
|
||||||
@@ -18,7 +18,7 @@ read_when:
|
|||||||
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
|
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
|
||||||
|
|
||||||
### Node service + app IPC
|
### Node service + app IPC
|
||||||
- A headless node service connects to the Gateway bridge.
|
- A headless node host service connects to the Gateway bridge.
|
||||||
- `system.run` requests are forwarded to the macOS app over a local Unix socket.
|
- `system.run` requests are forwarded to the macOS app over a local Unix socket.
|
||||||
- The app performs the exec in UI context, prompts if needed, and returns output.
|
- The app performs the exec in UI context, prompts if needed, and returns output.
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ The macOS app presents itself as a node. Common commands:
|
|||||||
The node reports a `permissions` map so agents can decide what’s allowed.
|
The node reports a `permissions` map so agents can decide what’s allowed.
|
||||||
|
|
||||||
Node service + app IPC:
|
Node service + app IPC:
|
||||||
- When the headless node service is running (remote mode), it connects to the Gateway WS as a node.
|
- When the headless node host service is running (remote mode), it connects to the Gateway WS as a node.
|
||||||
- `system.run` executes in the macOS app (UI/TCC context) over a local Unix socket; prompts + output stay in-app.
|
- `system.run` executes in the macOS app (UI/TCC context) over a local Unix socket; prompts + output stay in-app.
|
||||||
|
|
||||||
Diagram (SCI):
|
Diagram (SCI):
|
||||||
|
|||||||
@@ -49,6 +49,50 @@ Repair/migrate:
|
|||||||
clawdbot doctor
|
clawdbot doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Advanced: expose WSL services over LAN (portproxy)
|
||||||
|
|
||||||
|
WSL has its own virtual network. If another machine needs to reach a service
|
||||||
|
running **inside WSL** (SSH, a local TTS server, or the Gateway), you must
|
||||||
|
forward a Windows port to the current WSL IP. The WSL IP changes after restarts,
|
||||||
|
so you may need to refresh the forwarding rule.
|
||||||
|
|
||||||
|
Example (PowerShell **as Administrator**):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$Distro = "Ubuntu-24.04"
|
||||||
|
$ListenPort = 2222
|
||||||
|
$TargetPort = 22
|
||||||
|
|
||||||
|
$WslIp = (wsl -d $Distro -- hostname -I).Trim().Split(" ")[0]
|
||||||
|
if (-not $WslIp) { throw "WSL IP not found." }
|
||||||
|
|
||||||
|
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=$ListenPort `
|
||||||
|
connectaddress=$WslIp connectport=$TargetPort
|
||||||
|
```
|
||||||
|
|
||||||
|
Allow the port through Windows Firewall (one-time):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
New-NetFirewallRule -DisplayName "WSL SSH $ListenPort" -Direction Inbound `
|
||||||
|
-Protocol TCP -LocalPort $ListenPort -Action Allow
|
||||||
|
```
|
||||||
|
|
||||||
|
Refresh the portproxy after WSL restarts:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
netsh interface portproxy delete v4tov4 listenport=$ListenPort listenaddress=0.0.0.0 | Out-Null
|
||||||
|
netsh interface portproxy add v4tov4 listenport=$ListenPort listenaddress=0.0.0.0 `
|
||||||
|
connectaddress=$WslIp connectport=$TargetPort | Out-Null
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- SSH from another machine targets the **Windows host IP** (example: `ssh user@windows-host -p 2222`).
|
||||||
|
- Remote nodes must point at a **reachable** Gateway URL (not `127.0.0.1`); use
|
||||||
|
`clawdbot status --all` to confirm.
|
||||||
|
- Use `listenaddress=0.0.0.0` for LAN access; `127.0.0.1` keeps it local only.
|
||||||
|
- If you want this automatic, register a Scheduled Task to run the refresh
|
||||||
|
step at login.
|
||||||
|
|
||||||
## Step-by-step WSL2 install
|
## Step-by-step WSL2 install
|
||||||
|
|
||||||
### 1) Install WSL2 + Ubuntu
|
### 1) Install WSL2 + Ubuntu
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ read_when:
|
|||||||
- **Node identity:** use existing `nodeId`.
|
- **Node identity:** use existing `nodeId`.
|
||||||
- **Socket auth:** Unix socket + token (cross-platform); split later if needed.
|
- **Socket auth:** Unix socket + token (cross-platform); split later if needed.
|
||||||
- **Node host state:** `~/.clawdbot/node.json` (node id + pairing token).
|
- **Node host state:** `~/.clawdbot/node.json` (node id + pairing token).
|
||||||
- **macOS exec host:** run `system.run` inside the macOS app; node service forwards requests over local IPC.
|
- **macOS exec host:** run `system.run` inside the macOS app; node host service forwards requests over local IPC.
|
||||||
- **No XPC helper:** stick to Unix socket + token + peer checks.
|
- **No XPC helper:** stick to Unix socket + token + peer checks.
|
||||||
|
|
||||||
## Key concepts
|
## Key concepts
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ Allowed (diagnostic-only):
|
|||||||
- `clawdbot health`
|
- `clawdbot health`
|
||||||
- `clawdbot help`
|
- `clawdbot help`
|
||||||
- `clawdbot status`
|
- `clawdbot status`
|
||||||
- `clawdbot service`
|
- `clawdbot daemon`
|
||||||
|
|
||||||
Everything else must hard-fail with: “Config invalid. Run `clawdbot doctor --fix`.”
|
Everything else must hard-fail with: “Config invalid. Run `clawdbot doctor --fix`.”
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ When the operator says “release”, immediately do this preflight (no extra qu
|
|||||||
2) **Build & artifacts**
|
2) **Build & artifacts**
|
||||||
- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/clawdbot/clawdbot/blob/main/src/canvas-host/a2ui/a2ui.bundle.js).
|
- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/clawdbot/clawdbot/blob/main/src/canvas-host/a2ui/a2ui.bundle.js).
|
||||||
- [ ] `pnpm run build` (regenerates `dist/`).
|
- [ ] `pnpm run build` (regenerates `dist/`).
|
||||||
|
- [ ] Verify npm package `files` includes all required `dist/*` folders (notably `dist/node-host/**` and `dist/acp/**` for headless node + ACP CLI).
|
||||||
- [ ] Confirm `dist/build-info.json` exists and includes the expected `commit` hash (CLI banner uses this for npm installs).
|
- [ ] Confirm `dist/build-info.json` exists and includes the expected `commit` hash (CLI banner uses this for npm installs).
|
||||||
- [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it).
|
- [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it).
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
|||||||
## Core concepts
|
## Core concepts
|
||||||
|
|
||||||
- [Architecture](/concepts/architecture)
|
- [Architecture](/concepts/architecture)
|
||||||
|
- [Network hub](/network)
|
||||||
- [Agent runtime](/concepts/agent)
|
- [Agent runtime](/concepts/agent)
|
||||||
- [Agent workspace](/concepts/agent-workspace)
|
- [Agent workspace](/concepts/agent-workspace)
|
||||||
- [Memory](/concepts/memory)
|
- [Memory](/concepts/memory)
|
||||||
|
|||||||
@@ -500,6 +500,7 @@ Notes:
|
|||||||
- `--format ai` (default when Playwright is installed): returns an AI snapshot with numeric refs (`aria-ref="<n>"`).
|
- `--format ai` (default when Playwright is installed): returns an AI snapshot with numeric refs (`aria-ref="<n>"`).
|
||||||
- `--format aria`: returns the accessibility tree (no refs; inspection only).
|
- `--format aria`: returns the accessibility tree (no refs; inspection only).
|
||||||
- `--efficient` (or `--mode efficient`): compact role snapshot preset (interactive + compact + depth + lower maxChars).
|
- `--efficient` (or `--mode efficient`): compact role snapshot preset (interactive + compact + depth + lower maxChars).
|
||||||
|
- Config default (tool/CLI only): set `browser.snapshotDefaults.mode: "efficient"` to use efficient snapshots when the caller does not pass a mode (see [Gateway configuration](/gateway/configuration#browser-clawd-managed-browser)).
|
||||||
- Role snapshot options (`--interactive`, `--compact`, `--depth`, `--selector`) force a role-based snapshot with refs like `ref=e12`.
|
- Role snapshot options (`--interactive`, `--compact`, `--depth`, `--selector`) force a role-based snapshot with refs like `ref=e12`.
|
||||||
- `--frame "<iframe selector>"` scopes role snapshots to an iframe (pairs with role refs like `e12`).
|
- `--frame "<iframe selector>"` scopes role snapshots to an iframe (pairs with role refs like `e12`).
|
||||||
- `--interactive` outputs a flat, easy-to-pick list of interactive elements (best for driving actions).
|
- `--interactive` outputs a flat, easy-to-pick list of interactive elements (best for driving actions).
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ Exec approvals are enforced locally on the execution host:
|
|||||||
- **node host** → node runner (macOS companion app or headless node host)
|
- **node host** → node runner (macOS companion app or headless node host)
|
||||||
|
|
||||||
Planned macOS split:
|
Planned macOS split:
|
||||||
- **node service** forwards `system.run` to the **macOS app** over local IPC.
|
- **node host service** forwards `system.run` to the **macOS app** over local IPC.
|
||||||
- **macOS app** enforces approvals + executes the command in UI context.
|
- **macOS app** enforces approvals + executes the command in UI context.
|
||||||
|
|
||||||
## Settings and storage
|
## Settings and storage
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ Notes:
|
|||||||
|
|
||||||
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
|
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
|
||||||
- `tools.exec.host` (default: `sandbox`)
|
- `tools.exec.host` (default: `sandbox`)
|
||||||
- `tools.exec.security` (default: `deny`)
|
- `tools.exec.security` (default: `deny` for sandbox, `allowlist` for gateway + node when unset)
|
||||||
- `tools.exec.ask` (default: `on-miss`)
|
- `tools.exec.ask` (default: `on-miss`)
|
||||||
- `tools.exec.node` (default: unset)
|
- `tools.exec.node` (default: unset)
|
||||||
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
|
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
|
||||||
|
|||||||
+1
-1
@@ -181,7 +181,7 @@ Notes:
|
|||||||
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
|
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
|
||||||
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`.
|
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`.
|
||||||
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
|
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
|
||||||
- `host=node` can target a macOS companion app or a headless node host (`clawdbot node start`).
|
- `host=node` can target a macOS companion app or a headless node host (`clawdbot node run`).
|
||||||
- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals).
|
- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals).
|
||||||
|
|
||||||
### `process`
|
### `process`
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ Fields under `metadata.clawdbot`:
|
|||||||
- `requires.env` — list; env var must exist **or** be provided in config.
|
- `requires.env` — list; env var must exist **or** be provided in config.
|
||||||
- `requires.config` — list of `clawdbot.json` paths that must be truthy.
|
- `requires.config` — list of `clawdbot.json` paths that must be truthy.
|
||||||
- `primaryEnv` — env var name associated with `skills.entries.<name>.apiKey`.
|
- `primaryEnv` — env var name associated with `skills.entries.<name>.apiKey`.
|
||||||
- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv).
|
- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv/download).
|
||||||
|
|
||||||
Note on sandboxing:
|
Note on sandboxing:
|
||||||
- `requires.bins` is checked on the **host** at skill load time.
|
- `requires.bins` is checked on the **host** at skill load time.
|
||||||
@@ -134,10 +134,13 @@ metadata: {"clawdbot":{"emoji":"♊️","requires":{"bins":["gemini"]},"install"
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node).
|
- If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node).
|
||||||
|
- If all installers are `download`, Clawdbot lists each entry so you can see the available artifacts.
|
||||||
|
- Installer specs can include `os: ["darwin"|"linux"|"win32"]` to filter options by platform.
|
||||||
- Node installs honor `skills.install.nodeManager` in `clawdbot.json` (default: npm; options: npm/pnpm/yarn/bun).
|
- Node installs honor `skills.install.nodeManager` in `clawdbot.json` (default: npm; options: npm/pnpm/yarn/bun).
|
||||||
This only affects **skill installs**; the Gateway runtime should still be Node
|
This only affects **skill installs**; the Gateway runtime should still be Node
|
||||||
(Bun is not recommended for WhatsApp/Telegram).
|
(Bun is not recommended for WhatsApp/Telegram).
|
||||||
- Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible.
|
- Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible.
|
||||||
|
- Download installs: `url` (required), `archive` (`tar.gz` | `tar.bz2` | `zip`), `extract` (default: auto when archive detected), `stripComponents`, `targetDir` (default: `~/.clawdbot/tools/<skillKey>`).
|
||||||
|
|
||||||
If no `metadata.clawdbot` is present, the skill is always eligible (unless
|
If no `metadata.clawdbot` is present, the skill is always eligible (unless
|
||||||
disabled in config or blocked by `skills.allowBundled` for bundled skills).
|
disabled in config or blocked by `skills.allowBundled` for bundled skills).
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ Text + native (when enabled):
|
|||||||
- `/commands`
|
- `/commands`
|
||||||
- `/skill <name> [input]` (run a skill by name)
|
- `/skill <name> [input]` (run a skill by name)
|
||||||
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
|
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
|
||||||
|
- `/allowlist` (list/add/remove allowlist entries)
|
||||||
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||||
- `/whoami` (show your sender id; alias: `/id`)
|
- `/whoami` (show your sender id; alias: `/id`)
|
||||||
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
|
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
|
||||||
@@ -93,6 +94,7 @@ Notes:
|
|||||||
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
|
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
|
||||||
- `/new <model>` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body.
|
- `/new <model>` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body.
|
||||||
- For full provider usage breakdown, use `clawdbot status --usage`.
|
- For full provider usage breakdown, use `clawdbot status --usage`.
|
||||||
|
- `/allowlist add|remove` requires `commands.config=true` and honors channel `configWrites`.
|
||||||
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs.
|
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs.
|
||||||
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
|
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
|
||||||
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
|
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ Fetch a URL and extract readable content.
|
|||||||
maxChars: 50000,
|
maxChars: 50000,
|
||||||
timeoutSeconds: 30,
|
timeoutSeconds: 30,
|
||||||
cacheTtlMinutes: 15,
|
cacheTtlMinutes: 15,
|
||||||
|
maxRedirects: 3,
|
||||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
||||||
readability: true,
|
readability: true,
|
||||||
firecrawl: {
|
firecrawl: {
|
||||||
@@ -241,6 +242,7 @@ Notes:
|
|||||||
- `web_fetch` uses Readability (main-content extraction) first, then Firecrawl (if configured). If both fail, the tool returns an error.
|
- `web_fetch` uses Readability (main-content extraction) first, then Firecrawl (if configured). If both fail, the tool returns an error.
|
||||||
- Firecrawl requests use bot-circumvention mode and cache results by default.
|
- Firecrawl requests use bot-circumvention mode and cache results by default.
|
||||||
- `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed.
|
- `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed.
|
||||||
|
- `web_fetch` blocks private/internal hostnames and re-checks redirects (limit with `maxRedirects`).
|
||||||
- `web_fetch` is best-effort extraction; some sites will need the browser tool.
|
- `web_fetch` is best-effort extraction; some sites will need the browser tool.
|
||||||
- See [Firecrawl](/tools/firecrawl) for key setup and service details.
|
- See [Firecrawl](/tools/firecrawl) for key setup and service details.
|
||||||
- Responses are cached (default 15 minutes) to reduce repeated fetches.
|
- Responses are cached (default 15 minutes) to reduce repeated fetches.
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/bluebubbles",
|
"name": "@clawdbot/bluebubbles",
|
||||||
"version": "2026.1.15",
|
"version": "2026.1.21-1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot BlueBubbles channel plugin",
|
"description": "Clawdbot BlueBubbles channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"],
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
],
|
||||||
"channel": {
|
"channel": {
|
||||||
"id": "bluebubbles",
|
"id": "bluebubbles",
|
||||||
"label": "BlueBubbles",
|
"label": "BlueBubbles",
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ vi.mock("./attachments.js", () => ({
|
|||||||
sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }),
|
sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("./monitor.js", () => ({
|
||||||
|
resolveBlueBubblesMessageId: vi.fn((id: string) => id),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("bluebubblesMessageActions", () => {
|
describe("bluebubblesMessageActions", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -358,6 +362,106 @@ describe("bluebubblesMessageActions", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses toolContext currentChannelId when no explicit target is provided", async () => {
|
||||||
|
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
||||||
|
const { resolveChatGuidForTarget } = await import("./send.js");
|
||||||
|
vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15550001111");
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
bluebubbles: {
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
password: "test-password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await bluebubblesMessageActions.handleAction({
|
||||||
|
action: "react",
|
||||||
|
params: {
|
||||||
|
emoji: "👍",
|
||||||
|
messageId: "msg-456",
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
accountId: null,
|
||||||
|
toolContext: {
|
||||||
|
currentChannelId: "bluebubbles:chat_guid:iMessage;-;+15550001111",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
target: { kind: "chat_guid", chatGuid: "iMessage;-;+15550001111" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
chatGuid: "iMessage;-;+15550001111",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves short messageId before reacting", async () => {
|
||||||
|
const { resolveBlueBubblesMessageId } = await import("./monitor.js");
|
||||||
|
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
||||||
|
vi.mocked(resolveBlueBubblesMessageId).mockReturnValueOnce("resolved-uuid");
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
bluebubbles: {
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
password: "test-password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await bluebubblesMessageActions.handleAction({
|
||||||
|
action: "react",
|
||||||
|
params: {
|
||||||
|
emoji: "❤️",
|
||||||
|
messageId: "1",
|
||||||
|
chatGuid: "iMessage;-;+15551234567",
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
accountId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveBlueBubblesMessageId).toHaveBeenCalledWith("1", { requireKnownShortId: true });
|
||||||
|
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
messageGuid: "resolved-uuid",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates short-id errors from the resolver", async () => {
|
||||||
|
const { resolveBlueBubblesMessageId } = await import("./monitor.js");
|
||||||
|
vi.mocked(resolveBlueBubblesMessageId).mockImplementationOnce(() => {
|
||||||
|
throw new Error("short id expired");
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
bluebubbles: {
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
password: "test-password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
bluebubblesMessageActions.handleAction({
|
||||||
|
action: "react",
|
||||||
|
params: {
|
||||||
|
emoji: "❤️",
|
||||||
|
messageId: "999",
|
||||||
|
chatGuid: "iMessage;-;+15551234567",
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
accountId: null,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("short id expired");
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts message param for edit action", async () => {
|
it("accepts message param for edit action", async () => {
|
||||||
const { editBlueBubblesMessage } = await import("./chat.js");
|
const { editBlueBubblesMessage } = await import("./chat.js");
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
|
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||||
import { isMacOS26OrHigher } from "./probe.js";
|
import { isMacOS26OrHigher } from "./probe.js";
|
||||||
import { sendBlueBubblesReaction } from "./reactions.js";
|
import { sendBlueBubblesReaction } from "./reactions.js";
|
||||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||||
@@ -77,7 +78,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
||||||
return { to, accountId };
|
return { to, accountId };
|
||||||
},
|
},
|
||||||
handleAction: async ({ action, params, cfg, accountId }) => {
|
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
||||||
const account = resolveBlueBubblesAccount({
|
const account = resolveBlueBubblesAccount({
|
||||||
cfg: cfg as ClawdbotConfig,
|
cfg: cfg as ClawdbotConfig,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
@@ -86,7 +87,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
const password = account.config.password?.trim();
|
const password = account.config.password?.trim();
|
||||||
const opts = { cfg: cfg as ClawdbotConfig, accountId: accountId ?? undefined };
|
const opts = { cfg: cfg as ClawdbotConfig, accountId: accountId ?? undefined };
|
||||||
|
|
||||||
// Helper to resolve chatGuid from various params
|
// Helper to resolve chatGuid from various params or session context
|
||||||
const resolveChatGuid = async (): Promise<string> => {
|
const resolveChatGuid = async (): Promise<string> => {
|
||||||
const chatGuid = readStringParam(params, "chatGuid");
|
const chatGuid = readStringParam(params, "chatGuid");
|
||||||
if (chatGuid?.trim()) return chatGuid.trim();
|
if (chatGuid?.trim()) return chatGuid.trim();
|
||||||
@@ -94,6 +95,8 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
const chatIdentifier = readStringParam(params, "chatIdentifier");
|
const chatIdentifier = readStringParam(params, "chatIdentifier");
|
||||||
const chatId = readNumberParam(params, "chatId", { integer: true });
|
const chatId = readNumberParam(params, "chatId", { integer: true });
|
||||||
const to = readStringParam(params, "to");
|
const to = readStringParam(params, "to");
|
||||||
|
// Fall back to session context if no explicit target provided
|
||||||
|
const contextTarget = toolContext?.currentChannelId?.trim();
|
||||||
|
|
||||||
const target = chatIdentifier?.trim()
|
const target = chatIdentifier?.trim()
|
||||||
? ({
|
? ({
|
||||||
@@ -104,7 +107,9 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget)
|
? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget)
|
||||||
: to
|
: to
|
||||||
? mapTarget(to)
|
? mapTarget(to)
|
||||||
: null;
|
: contextTarget
|
||||||
|
? mapTarget(contextTarget)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`);
|
throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`);
|
||||||
@@ -127,16 +132,18 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
});
|
});
|
||||||
if (isEmpty && !remove) {
|
if (isEmpty && !remove) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"BlueBubbles react requires emoji parameter. Use action=react with emoji=<emoji> and messageId=<message_guid>.",
|
"BlueBubbles react requires emoji parameter. Use action=react with emoji=<emoji> and messageId=<message_id>.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const messageId = readStringParam(params, "messageId");
|
const rawMessageId = readStringParam(params, "messageId");
|
||||||
if (!messageId) {
|
if (!rawMessageId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"BlueBubbles react requires messageId parameter (the message GUID to react to). " +
|
"BlueBubbles react requires messageId parameter (the message ID to react to). " +
|
||||||
"Use action=react with messageId=<message_guid>, emoji=<emoji>, and to/chatGuid to identify the chat.",
|
"Use action=react with messageId=<message_id>, emoji=<emoji>, and to/chatGuid to identify the chat.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||||
|
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
||||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
const resolvedChatGuid = await resolveChatGuid();
|
const resolvedChatGuid = await resolveChatGuid();
|
||||||
|
|
||||||
@@ -161,20 +168,22 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
"Apple removed the ability to edit iMessages in this version.",
|
"Apple removed the ability to edit iMessages in this version.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const messageId = readStringParam(params, "messageId");
|
const rawMessageId = readStringParam(params, "messageId");
|
||||||
const newText =
|
const newText =
|
||||||
readStringParam(params, "text") ??
|
readStringParam(params, "text") ??
|
||||||
readStringParam(params, "newText") ??
|
readStringParam(params, "newText") ??
|
||||||
readStringParam(params, "message");
|
readStringParam(params, "message");
|
||||||
if (!messageId || !newText) {
|
if (!rawMessageId || !newText) {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
if (!messageId) missing.push("messageId (the message GUID to edit)");
|
if (!rawMessageId) missing.push("messageId (the message ID to edit)");
|
||||||
if (!newText) missing.push("text (the new message content)");
|
if (!newText) missing.push("text (the new message content)");
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`BlueBubbles edit requires: ${missing.join(", ")}. ` +
|
`BlueBubbles edit requires: ${missing.join(", ")}. ` +
|
||||||
`Use action=edit with messageId=<message_guid>, text=<new_content>.`,
|
`Use action=edit with messageId=<message_id>, text=<new_content>.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||||
|
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
||||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
|
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
|
||||||
|
|
||||||
@@ -184,18 +193,20 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
backwardsCompatMessage: backwardsCompatMessage ?? undefined,
|
backwardsCompatMessage: backwardsCompatMessage ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonResult({ ok: true, edited: messageId });
|
return jsonResult({ ok: true, edited: rawMessageId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle unsend action
|
// Handle unsend action
|
||||||
if (action === "unsend") {
|
if (action === "unsend") {
|
||||||
const messageId = readStringParam(params, "messageId");
|
const rawMessageId = readStringParam(params, "messageId");
|
||||||
if (!messageId) {
|
if (!rawMessageId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"BlueBubbles unsend requires messageId parameter (the message GUID to unsend). " +
|
"BlueBubbles unsend requires messageId parameter (the message ID to unsend). " +
|
||||||
"Use action=unsend with messageId=<message_guid>.",
|
"Use action=unsend with messageId=<message_id>.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||||
|
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
||||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
|
|
||||||
await unsendBlueBubblesMessage(messageId, {
|
await unsendBlueBubblesMessage(messageId, {
|
||||||
@@ -203,24 +214,26 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonResult({ ok: true, unsent: messageId });
|
return jsonResult({ ok: true, unsent: rawMessageId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle reply action
|
// Handle reply action
|
||||||
if (action === "reply") {
|
if (action === "reply") {
|
||||||
const messageId = readStringParam(params, "messageId");
|
const rawMessageId = readStringParam(params, "messageId");
|
||||||
const text = readMessageText(params);
|
const text = readMessageText(params);
|
||||||
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
|
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
|
||||||
if (!messageId || !text || !to) {
|
if (!rawMessageId || !text || !to) {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
if (!messageId) missing.push("messageId (the message GUID to reply to)");
|
if (!rawMessageId) missing.push("messageId (the message ID to reply to)");
|
||||||
if (!text) missing.push("text or message (the reply message content)");
|
if (!text) missing.push("text or message (the reply message content)");
|
||||||
if (!to) missing.push("to or target (the chat target)");
|
if (!to) missing.push("to or target (the chat target)");
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`BlueBubbles reply requires: ${missing.join(", ")}. ` +
|
`BlueBubbles reply requires: ${missing.join(", ")}. ` +
|
||||||
`Use action=reply with messageId=<message_guid>, message=<your reply>, target=<chat_target>.`,
|
`Use action=reply with messageId=<message_id>, message=<your reply>, target=<chat_target>.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||||
|
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
||||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
|
|
||||||
const result = await sendMessageBlueBubbles(to, text, {
|
const result = await sendMessageBlueBubbles(to, text, {
|
||||||
@@ -229,7 +242,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined,
|
replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonResult({ ok: true, messageId: result.messageId, repliedTo: messageId });
|
return jsonResult({ ok: true, messageId: result.messageId, repliedTo: rawMessageId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle sendWithEffect action
|
// Handle sendWithEffect action
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
resolveDefaultBlueBubblesAccountId,
|
resolveDefaultBlueBubblesAccountId,
|
||||||
} from "./accounts.js";
|
} from "./accounts.js";
|
||||||
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
||||||
|
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||||
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
||||||
import { sendMessageBlueBubbles } from "./send.js";
|
import { sendMessageBlueBubbles } from "./send.js";
|
||||||
import {
|
import {
|
||||||
@@ -65,7 +66,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
threading: {
|
threading: {
|
||||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||||
currentChannelId: context.To?.trim() || undefined,
|
currentChannelId: context.To?.trim() || undefined,
|
||||||
currentThreadTs: context.ReplyToId,
|
currentThreadTs: context.ReplyToIdFull ?? context.ReplyToId,
|
||||||
hasRepliedRef,
|
hasRepliedRef,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -237,7 +238,11 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
return { ok: true, to: trimmed };
|
return { ok: true, to: trimmed };
|
||||||
},
|
},
|
||||||
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
||||||
const replyToMessageGuid = typeof replyToId === "string" ? replyToId.trim() : "";
|
const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
|
||||||
|
// Resolve short ID (e.g., "5") to full UUID
|
||||||
|
const replyToMessageGuid = rawReplyToId
|
||||||
|
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
||||||
|
: "";
|
||||||
const result = await sendMessageBlueBubbles(to, text, {
|
const result = await sendMessageBlueBubbles(to, text, {
|
||||||
cfg: cfg as ClawdbotConfig,
|
cfg: cfg as ClawdbotConfig,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { resolveChannelMediaMaxBytes, type ClawdbotConfig } from "clawdbot/plugin-sdk";
|
import { resolveChannelMediaMaxBytes, type ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import { sendBlueBubblesAttachment } from "./attachments.js";
|
import { sendBlueBubblesAttachment } from "./attachments.js";
|
||||||
import { sendMessageBlueBubbles } from "./send.js";
|
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||||
import { getBlueBubblesRuntime } from "./runtime.js";
|
import { getBlueBubblesRuntime } from "./runtime.js";
|
||||||
|
import { sendMessageBlueBubbles } from "./send.js";
|
||||||
|
|
||||||
const HTTP_URL_RE = /^https?:\/\//i;
|
const HTTP_URL_RE = /^https?:\/\//i;
|
||||||
const MB = 1024 * 1024;
|
const MB = 1024 * 1024;
|
||||||
@@ -134,12 +135,17 @@ export async function sendBlueBubblesMedia(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve short ID (e.g., "5") to full UUID
|
||||||
|
const replyToMessageGuid = replyToId?.trim()
|
||||||
|
? resolveBlueBubblesMessageId(replyToId.trim(), { requireKnownShortId: true })
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const attachmentResult = await sendBlueBubblesAttachment({
|
const attachmentResult = await sendBlueBubblesAttachment({
|
||||||
to,
|
to,
|
||||||
buffer,
|
buffer,
|
||||||
filename: resolvedFilename ?? "attachment",
|
filename: resolvedFilename ?? "attachment",
|
||||||
contentType: resolvedContentType ?? undefined,
|
contentType: resolvedContentType ?? undefined,
|
||||||
replyToMessageGuid: replyToId?.trim() || undefined,
|
replyToMessageGuid,
|
||||||
opts: {
|
opts: {
|
||||||
cfg,
|
cfg,
|
||||||
accountId,
|
accountId,
|
||||||
@@ -151,7 +157,7 @@ export async function sendBlueBubblesMedia(params: {
|
|||||||
await sendMessageBlueBubbles(to, trimmedCaption, {
|
await sendMessageBlueBubbles(to, trimmedCaption, {
|
||||||
cfg,
|
cfg,
|
||||||
accountId,
|
accountId,
|
||||||
replyToMessageGuid: replyToId?.trim() || undefined,
|
replyToMessageGuid,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
|||||||
import {
|
import {
|
||||||
handleBlueBubblesWebhookRequest,
|
handleBlueBubblesWebhookRequest,
|
||||||
registerBlueBubblesWebhookTarget,
|
registerBlueBubblesWebhookTarget,
|
||||||
|
resolveBlueBubblesMessageId,
|
||||||
|
_resetBlueBubblesShortIdState,
|
||||||
} from "./monitor.js";
|
} from "./monitor.js";
|
||||||
import { setBlueBubblesRuntime } from "./runtime.js";
|
import { setBlueBubblesRuntime } from "./runtime.js";
|
||||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||||
@@ -223,6 +225,8 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Reset short ID state between tests for predictable behavior
|
||||||
|
_resetBlueBubblesShortIdState();
|
||||||
mockReadAllowFromStore.mockResolvedValue([]);
|
mockReadAllowFromStore.mockResolvedValue([]);
|
||||||
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
|
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
|
||||||
mockResolveRequireMention.mockReturnValue(false);
|
mockResolveRequireMention.mockReturnValue(false);
|
||||||
@@ -467,6 +471,98 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
|
|
||||||
expect(handled).toBe(false);
|
expect(handled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses chatId when provided as a string (webhook variant)", async () => {
|
||||||
|
const { resolveChatGuidForTarget } = await import("./send.js");
|
||||||
|
vi.mocked(resolveChatGuidForTarget).mockClear();
|
||||||
|
|
||||||
|
const account = createMockAccount({ groupPolicy: "open" });
|
||||||
|
const config: ClawdbotConfig = {};
|
||||||
|
const core = createMockRuntime();
|
||||||
|
setBlueBubblesRuntime(core);
|
||||||
|
|
||||||
|
unregister = registerBlueBubblesWebhookTarget({
|
||||||
|
account,
|
||||||
|
config,
|
||||||
|
runtime: { log: vi.fn(), error: vi.fn() },
|
||||||
|
core,
|
||||||
|
path: "/bluebubbles-webhook",
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
type: "new-message",
|
||||||
|
data: {
|
||||||
|
text: "hello from group",
|
||||||
|
handle: { address: "+15551234567" },
|
||||||
|
isGroup: true,
|
||||||
|
isFromMe: false,
|
||||||
|
guid: "msg-1",
|
||||||
|
chatId: "123",
|
||||||
|
date: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
target: { kind: "chat_id", chatId: 123 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts chatGuid from nested chat object fields (webhook variant)", async () => {
|
||||||
|
const { sendMessageBlueBubbles, resolveChatGuidForTarget } = await import("./send.js");
|
||||||
|
vi.mocked(sendMessageBlueBubbles).mockClear();
|
||||||
|
vi.mocked(resolveChatGuidForTarget).mockClear();
|
||||||
|
|
||||||
|
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
||||||
|
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const account = createMockAccount({ groupPolicy: "open" });
|
||||||
|
const config: ClawdbotConfig = {};
|
||||||
|
const core = createMockRuntime();
|
||||||
|
setBlueBubblesRuntime(core);
|
||||||
|
|
||||||
|
unregister = registerBlueBubblesWebhookTarget({
|
||||||
|
account,
|
||||||
|
config,
|
||||||
|
runtime: { log: vi.fn(), error: vi.fn() },
|
||||||
|
core,
|
||||||
|
path: "/bluebubbles-webhook",
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
type: "new-message",
|
||||||
|
data: {
|
||||||
|
text: "hello from group",
|
||||||
|
handle: { address: "+15551234567" },
|
||||||
|
isGroup: true,
|
||||||
|
isFromMe: false,
|
||||||
|
guid: "msg-1",
|
||||||
|
chat: { chatGuid: "iMessage;+;chat123456" },
|
||||||
|
date: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
|
||||||
|
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
|
||||||
|
"chat_guid:iMessage;+;chat123456",
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("DM pairing behavior vs allowFrom", () => {
|
describe("DM pairing behavior vs allowFrom", () => {
|
||||||
@@ -1075,13 +1171,86 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
|
// ReplyToId is the full UUID since it wasn't previously cached
|
||||||
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
|
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
|
||||||
expect(callArgs.ctx.ReplyToBody).toBe("original message");
|
expect(callArgs.ctx.ReplyToBody).toBe("original message");
|
||||||
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
|
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
|
||||||
expect(callArgs.ctx.Body).toContain("[Replying to +15550000000 id:msg-0]");
|
// Body uses just the ID (no sender) for token savings
|
||||||
|
expect(callArgs.ctx.Body).toContain("[Replying to id:msg-0]");
|
||||||
expect(callArgs.ctx.Body).toContain("original message");
|
expect(callArgs.ctx.Body).toContain("original message");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hydrates missing reply sender/body from the recent-message cache", async () => {
|
||||||
|
const account = createMockAccount({ dmPolicy: "open", groupPolicy: "open" });
|
||||||
|
const config: ClawdbotConfig = {};
|
||||||
|
const core = createMockRuntime();
|
||||||
|
setBlueBubblesRuntime(core);
|
||||||
|
|
||||||
|
unregister = registerBlueBubblesWebhookTarget({
|
||||||
|
account,
|
||||||
|
config,
|
||||||
|
runtime: { log: vi.fn(), error: vi.fn() },
|
||||||
|
core,
|
||||||
|
path: "/bluebubbles-webhook",
|
||||||
|
});
|
||||||
|
|
||||||
|
const chatGuid = "iMessage;+;chat-reply-cache";
|
||||||
|
|
||||||
|
const originalPayload = {
|
||||||
|
type: "new-message",
|
||||||
|
data: {
|
||||||
|
text: "original message (cached)",
|
||||||
|
handle: { address: "+15550000000" },
|
||||||
|
isGroup: true,
|
||||||
|
isFromMe: false,
|
||||||
|
guid: "cache-msg-0",
|
||||||
|
chatGuid,
|
||||||
|
date: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalReq = createMockRequest("POST", "/bluebubbles-webhook", originalPayload);
|
||||||
|
const originalRes = createMockResponse();
|
||||||
|
|
||||||
|
await handleBlueBubblesWebhookRequest(originalReq, originalRes);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
// Only assert the reply message behavior below.
|
||||||
|
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
|
||||||
|
|
||||||
|
const replyPayload = {
|
||||||
|
type: "new-message",
|
||||||
|
data: {
|
||||||
|
text: "replying now",
|
||||||
|
handle: { address: "+15551234567" },
|
||||||
|
isGroup: true,
|
||||||
|
isFromMe: false,
|
||||||
|
guid: "cache-msg-1",
|
||||||
|
chatGuid,
|
||||||
|
// Only the GUID is provided; sender/body must be hydrated.
|
||||||
|
replyToMessageGuid: "cache-msg-0",
|
||||||
|
date: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const replyReq = createMockRequest("POST", "/bluebubbles-webhook", replyPayload);
|
||||||
|
const replyRes = createMockResponse();
|
||||||
|
|
||||||
|
await handleBlueBubblesWebhookRequest(replyReq, replyRes);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
|
// ReplyToId uses short ID "1" (first cached message) for token savings
|
||||||
|
expect(callArgs.ctx.ReplyToId).toBe("1");
|
||||||
|
expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0");
|
||||||
|
expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)");
|
||||||
|
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
|
||||||
|
// Body uses just the short ID (no sender) for token savings
|
||||||
|
expect(callArgs.ctx.Body).toContain("[Replying to id:1]");
|
||||||
|
expect(callArgs.ctx.Body).toContain("original message (cached)");
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to threadOriginatorGuid when reply metadata is absent", async () => {
|
it("falls back to threadOriginatorGuid when reply metadata is absent", async () => {
|
||||||
const account = createMockAccount({ dmPolicy: "open" });
|
const account = createMockAccount({ dmPolicy: "open" });
|
||||||
const config: ClawdbotConfig = {};
|
const config: ClawdbotConfig = {};
|
||||||
@@ -1436,8 +1605,9 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
// Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2")
|
||||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||||
"BlueBubbles sent message id: msg-123",
|
'Assistant sent "replying now" [message_id:2]',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
|
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
|
||||||
}),
|
}),
|
||||||
@@ -1605,6 +1775,99 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("short message ID mapping", () => {
|
||||||
|
it("assigns sequential short IDs to messages", async () => {
|
||||||
|
const account = createMockAccount({ dmPolicy: "open" });
|
||||||
|
const config: ClawdbotConfig = {};
|
||||||
|
const core = createMockRuntime();
|
||||||
|
setBlueBubblesRuntime(core);
|
||||||
|
|
||||||
|
unregister = registerBlueBubblesWebhookTarget({
|
||||||
|
account,
|
||||||
|
config,
|
||||||
|
runtime: { log: vi.fn(), error: vi.fn() },
|
||||||
|
core,
|
||||||
|
path: "/bluebubbles-webhook",
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
type: "new-message",
|
||||||
|
data: {
|
||||||
|
text: "hello",
|
||||||
|
handle: { address: "+15551234567" },
|
||||||
|
isGroup: false,
|
||||||
|
isFromMe: false,
|
||||||
|
guid: "msg-uuid-12345",
|
||||||
|
chatGuid: "iMessage;-;+15551234567",
|
||||||
|
date: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
|
// MessageSid should be short ID "1" instead of full UUID
|
||||||
|
expect(callArgs.ctx.MessageSid).toBe("1");
|
||||||
|
expect(callArgs.ctx.MessageSidFull).toBe("msg-uuid-12345");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves short ID back to UUID", async () => {
|
||||||
|
const account = createMockAccount({ dmPolicy: "open" });
|
||||||
|
const config: ClawdbotConfig = {};
|
||||||
|
const core = createMockRuntime();
|
||||||
|
setBlueBubblesRuntime(core);
|
||||||
|
|
||||||
|
unregister = registerBlueBubblesWebhookTarget({
|
||||||
|
account,
|
||||||
|
config,
|
||||||
|
runtime: { log: vi.fn(), error: vi.fn() },
|
||||||
|
core,
|
||||||
|
path: "/bluebubbles-webhook",
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
type: "new-message",
|
||||||
|
data: {
|
||||||
|
text: "hello",
|
||||||
|
handle: { address: "+15551234567" },
|
||||||
|
isGroup: false,
|
||||||
|
isFromMe: false,
|
||||||
|
guid: "msg-uuid-12345",
|
||||||
|
chatGuid: "iMessage;-;+15551234567",
|
||||||
|
date: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
// The short ID "1" should resolve back to the full UUID
|
||||||
|
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns UUID unchanged when not in cache", () => {
|
||||||
|
expect(resolveBlueBubblesMessageId("msg-not-cached")).toBe("msg-not-cached");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns short ID unchanged when numeric but not in cache", () => {
|
||||||
|
expect(resolveBlueBubblesMessageId("999")).toBe("999");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when numeric short ID is missing and requireKnownShortId is set", () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveBlueBubblesMessageId("999", { requireKnownShortId: true }),
|
||||||
|
).toThrow(/short message id/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("fromMe messages", () => {
|
describe("fromMe messages", () => {
|
||||||
it("ignores messages from self (fromMe=true)", async () => {
|
it("ignores messages from self (fromMe=true)", async () => {
|
||||||
const account = createMockAccount();
|
const account = createMockAccount();
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import { resolveAckReaction } from "clawdbot/plugin-sdk";
|
||||||
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
||||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||||
import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender, normalizeBlueBubblesHandle } from "./targets.js";
|
import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender, normalizeBlueBubblesHandle } from "./targets.js";
|
||||||
import { resolveAckReaction } from "../../../src/agents/identity.js";
|
|
||||||
import { sendBlueBubblesMedia } from "./media-send.js";
|
import { sendBlueBubblesMedia } from "./media-send.js";
|
||||||
import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js";
|
import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js";
|
||||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||||
@@ -31,6 +31,173 @@ const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
|
|||||||
const DEFAULT_TEXT_LIMIT = 4000;
|
const DEFAULT_TEXT_LIMIT = 4000;
|
||||||
const invalidAckReactions = new Set<string>();
|
const invalidAckReactions = new Set<string>();
|
||||||
|
|
||||||
|
const REPLY_CACHE_MAX = 2000;
|
||||||
|
const REPLY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
type BlueBubblesReplyCacheEntry = {
|
||||||
|
accountId: string;
|
||||||
|
messageId: string;
|
||||||
|
shortId: string;
|
||||||
|
chatGuid?: string;
|
||||||
|
chatIdentifier?: string;
|
||||||
|
chatId?: number;
|
||||||
|
senderLabel?: string;
|
||||||
|
body?: string;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body.
|
||||||
|
const blueBubblesReplyCacheByMessageId = new Map<string, BlueBubblesReplyCacheEntry>();
|
||||||
|
|
||||||
|
// Bidirectional maps for short ID ↔ UUID resolution (token savings optimization)
|
||||||
|
const blueBubblesShortIdToUuid = new Map<string, string>();
|
||||||
|
const blueBubblesUuidToShortId = new Map<string, string>();
|
||||||
|
let blueBubblesShortIdCounter = 0;
|
||||||
|
|
||||||
|
function trimOrUndefined(value?: string | null): string | undefined {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
return trimmed ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateShortId(): string {
|
||||||
|
blueBubblesShortIdCounter += 1;
|
||||||
|
return String(blueBubblesShortIdCounter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberBlueBubblesReplyCache(
|
||||||
|
entry: Omit<BlueBubblesReplyCacheEntry, "shortId">,
|
||||||
|
): BlueBubblesReplyCacheEntry {
|
||||||
|
const messageId = entry.messageId.trim();
|
||||||
|
if (!messageId) {
|
||||||
|
return { ...entry, shortId: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we already have a short ID for this UUID
|
||||||
|
let shortId = blueBubblesUuidToShortId.get(messageId);
|
||||||
|
if (!shortId) {
|
||||||
|
shortId = generateShortId();
|
||||||
|
blueBubblesShortIdToUuid.set(shortId, messageId);
|
||||||
|
blueBubblesUuidToShortId.set(messageId, shortId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, shortId };
|
||||||
|
|
||||||
|
// Refresh insertion order.
|
||||||
|
blueBubblesReplyCacheByMessageId.delete(messageId);
|
||||||
|
blueBubblesReplyCacheByMessageId.set(messageId, fullEntry);
|
||||||
|
|
||||||
|
// Opportunistic prune.
|
||||||
|
const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
|
||||||
|
for (const [key, value] of blueBubblesReplyCacheByMessageId) {
|
||||||
|
if (value.timestamp < cutoff) {
|
||||||
|
blueBubblesReplyCacheByMessageId.delete(key);
|
||||||
|
// Clean up short ID mappings for expired entries
|
||||||
|
if (value.shortId) {
|
||||||
|
blueBubblesShortIdToUuid.delete(value.shortId);
|
||||||
|
blueBubblesUuidToShortId.delete(key);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
while (blueBubblesReplyCacheByMessageId.size > REPLY_CACHE_MAX) {
|
||||||
|
const oldest = blueBubblesReplyCacheByMessageId.keys().next().value as string | undefined;
|
||||||
|
if (!oldest) break;
|
||||||
|
const oldEntry = blueBubblesReplyCacheByMessageId.get(oldest);
|
||||||
|
blueBubblesReplyCacheByMessageId.delete(oldest);
|
||||||
|
// Clean up short ID mappings for evicted entries
|
||||||
|
if (oldEntry?.shortId) {
|
||||||
|
blueBubblesShortIdToUuid.delete(oldEntry.shortId);
|
||||||
|
blueBubblesUuidToShortId.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles UUID.
|
||||||
|
* Returns the input unchanged if it's already a UUID or not found in the mapping.
|
||||||
|
*/
|
||||||
|
export function resolveBlueBubblesMessageId(
|
||||||
|
shortOrUuid: string,
|
||||||
|
opts?: { requireKnownShortId?: boolean },
|
||||||
|
): string {
|
||||||
|
const trimmed = shortOrUuid.trim();
|
||||||
|
if (!trimmed) return trimmed;
|
||||||
|
|
||||||
|
// If it looks like a short ID (numeric), try to resolve it
|
||||||
|
if (/^\d+$/.test(trimmed)) {
|
||||||
|
const uuid = blueBubblesShortIdToUuid.get(trimmed);
|
||||||
|
if (uuid) return uuid;
|
||||||
|
if (opts?.requireKnownShortId) {
|
||||||
|
throw new Error(
|
||||||
|
`BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return as-is (either already a UUID or not found)
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the short ID state. Only use in tests.
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function _resetBlueBubblesShortIdState(): void {
|
||||||
|
blueBubblesShortIdToUuid.clear();
|
||||||
|
blueBubblesUuidToShortId.clear();
|
||||||
|
blueBubblesReplyCacheByMessageId.clear();
|
||||||
|
blueBubblesShortIdCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the short ID for a UUID, if one exists.
|
||||||
|
*/
|
||||||
|
function getShortIdForUuid(uuid: string): string | undefined {
|
||||||
|
return blueBubblesUuidToShortId.get(uuid.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReplyContextFromCache(params: {
|
||||||
|
accountId: string;
|
||||||
|
replyToId: string;
|
||||||
|
chatGuid?: string;
|
||||||
|
chatIdentifier?: string;
|
||||||
|
chatId?: number;
|
||||||
|
}): BlueBubblesReplyCacheEntry | null {
|
||||||
|
const replyToId = params.replyToId.trim();
|
||||||
|
if (!replyToId) return null;
|
||||||
|
|
||||||
|
const cached = blueBubblesReplyCacheByMessageId.get(replyToId);
|
||||||
|
if (!cached) return null;
|
||||||
|
if (cached.accountId !== params.accountId) return null;
|
||||||
|
|
||||||
|
const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
|
||||||
|
if (cached.timestamp < cutoff) {
|
||||||
|
blueBubblesReplyCacheByMessageId.delete(replyToId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatGuid = trimOrUndefined(params.chatGuid);
|
||||||
|
const chatIdentifier = trimOrUndefined(params.chatIdentifier);
|
||||||
|
const cachedChatGuid = trimOrUndefined(cached.chatGuid);
|
||||||
|
const cachedChatIdentifier = trimOrUndefined(cached.chatIdentifier);
|
||||||
|
const chatId = typeof params.chatId === "number" ? params.chatId : undefined;
|
||||||
|
const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined;
|
||||||
|
|
||||||
|
// Avoid cross-chat collisions if we have identifiers.
|
||||||
|
if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) return null;
|
||||||
|
if (!chatGuid && chatIdentifier && cachedChatIdentifier && chatIdentifier !== cachedChatIdentifier) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!chatGuid && !chatIdentifier && chatId && cachedChatId && chatId !== cachedChatId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
type BlueBubblesCoreRuntime = ReturnType<typeof getBlueBubblesRuntime>;
|
type BlueBubblesCoreRuntime = ReturnType<typeof getBlueBubblesRuntime>;
|
||||||
|
|
||||||
function logVerbose(core: BlueBubblesCoreRuntime, runtime: BlueBubblesRuntimeEnv, message: string): void {
|
function logVerbose(core: BlueBubblesCoreRuntime, runtime: BlueBubblesRuntimeEnv, message: string): void {
|
||||||
@@ -217,19 +384,29 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REPLY_BODY_TRUNCATE_LENGTH = 60;
|
||||||
|
|
||||||
function formatReplyContext(message: {
|
function formatReplyContext(message: {
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
|
replyToShortId?: string;
|
||||||
replyToBody?: string;
|
replyToBody?: string;
|
||||||
replyToSender?: string;
|
replyToSender?: string;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null;
|
if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null;
|
||||||
const sender = message.replyToSender?.trim() || "unknown sender";
|
// Prefer short ID for token savings
|
||||||
const idPart = message.replyToId ? ` id:${message.replyToId}` : "";
|
const displayId = message.replyToShortId || message.replyToId;
|
||||||
const body = message.replyToBody?.trim();
|
// Only include sender if we don't have an ID (fallback)
|
||||||
if (!body) {
|
const label = displayId ? `id:${displayId}` : (message.replyToSender?.trim() || "unknown");
|
||||||
return `[Replying to ${sender}${idPart}]\n[/Replying]`;
|
const rawBody = message.replyToBody?.trim();
|
||||||
|
if (!rawBody) {
|
||||||
|
return `[Replying to ${label}]\n[/Replying]`;
|
||||||
}
|
}
|
||||||
return `[Replying to ${sender}${idPart}]\n${body}\n[/Replying]`;
|
// Truncate long reply bodies for token savings
|
||||||
|
const body =
|
||||||
|
rawBody.length > REPLY_BODY_TRUNCATE_LENGTH
|
||||||
|
? `${rawBody.slice(0, REPLY_BODY_TRUNCATE_LENGTH)}…`
|
||||||
|
: rawBody;
|
||||||
|
return `[Replying to ${label}]\n${body}\n[/Replying]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
|
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
|
||||||
@@ -404,6 +581,15 @@ function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undef
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractChatIdentifierFromChatGuid(chatGuid?: string | null): string | undefined {
|
||||||
|
const guid = chatGuid?.trim();
|
||||||
|
if (!guid) return undefined;
|
||||||
|
const parts = guid.split(";");
|
||||||
|
if (parts.length < 3) return undefined;
|
||||||
|
const identifier = parts[2]?.trim();
|
||||||
|
return identifier || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function formatGroupAllowlistEntry(params: {
|
function formatGroupAllowlistEntry(params: {
|
||||||
chatGuid?: string;
|
chatGuid?: string;
|
||||||
chatId?: number;
|
chatId?: number;
|
||||||
@@ -550,20 +736,31 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
|
|||||||
const chatGuid =
|
const chatGuid =
|
||||||
readString(message, "chatGuid") ??
|
readString(message, "chatGuid") ??
|
||||||
readString(message, "chat_guid") ??
|
readString(message, "chat_guid") ??
|
||||||
|
readString(chat, "chatGuid") ??
|
||||||
|
readString(chat, "chat_guid") ??
|
||||||
readString(chat, "guid") ??
|
readString(chat, "guid") ??
|
||||||
|
readString(chatFromList, "chatGuid") ??
|
||||||
|
readString(chatFromList, "chat_guid") ??
|
||||||
readString(chatFromList, "guid");
|
readString(chatFromList, "guid");
|
||||||
const chatIdentifier =
|
const chatIdentifier =
|
||||||
readString(message, "chatIdentifier") ??
|
readString(message, "chatIdentifier") ??
|
||||||
readString(message, "chat_identifier") ??
|
readString(message, "chat_identifier") ??
|
||||||
|
readString(chat, "chatIdentifier") ??
|
||||||
|
readString(chat, "chat_identifier") ??
|
||||||
readString(chat, "identifier") ??
|
readString(chat, "identifier") ??
|
||||||
readString(chatFromList, "chatIdentifier") ??
|
readString(chatFromList, "chatIdentifier") ??
|
||||||
readString(chatFromList, "chat_identifier") ??
|
readString(chatFromList, "chat_identifier") ??
|
||||||
readString(chatFromList, "identifier");
|
readString(chatFromList, "identifier") ??
|
||||||
|
extractChatIdentifierFromChatGuid(chatGuid);
|
||||||
const chatId =
|
const chatId =
|
||||||
readNumber(message, "chatId") ??
|
readNumberLike(message, "chatId") ??
|
||||||
readNumber(message, "chat_id") ??
|
readNumberLike(message, "chat_id") ??
|
||||||
readNumber(chat, "id") ??
|
readNumberLike(chat, "chatId") ??
|
||||||
readNumber(chatFromList, "id");
|
readNumberLike(chat, "chat_id") ??
|
||||||
|
readNumberLike(chat, "id") ??
|
||||||
|
readNumberLike(chatFromList, "chatId") ??
|
||||||
|
readNumberLike(chatFromList, "chat_id") ??
|
||||||
|
readNumberLike(chatFromList, "id");
|
||||||
const chatName =
|
const chatName =
|
||||||
readString(message, "chatName") ??
|
readString(message, "chatName") ??
|
||||||
readString(chat, "displayName") ??
|
readString(chat, "displayName") ??
|
||||||
@@ -679,19 +876,30 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
|
|||||||
const chatGuid =
|
const chatGuid =
|
||||||
readString(message, "chatGuid") ??
|
readString(message, "chatGuid") ??
|
||||||
readString(message, "chat_guid") ??
|
readString(message, "chat_guid") ??
|
||||||
|
readString(chat, "chatGuid") ??
|
||||||
|
readString(chat, "chat_guid") ??
|
||||||
readString(chat, "guid") ??
|
readString(chat, "guid") ??
|
||||||
|
readString(chatFromList, "chatGuid") ??
|
||||||
|
readString(chatFromList, "chat_guid") ??
|
||||||
readString(chatFromList, "guid");
|
readString(chatFromList, "guid");
|
||||||
const chatIdentifier =
|
const chatIdentifier =
|
||||||
readString(message, "chatIdentifier") ??
|
readString(message, "chatIdentifier") ??
|
||||||
readString(message, "chat_identifier") ??
|
readString(message, "chat_identifier") ??
|
||||||
|
readString(chat, "chatIdentifier") ??
|
||||||
|
readString(chat, "chat_identifier") ??
|
||||||
readString(chat, "identifier") ??
|
readString(chat, "identifier") ??
|
||||||
readString(chatFromList, "chatIdentifier") ??
|
readString(chatFromList, "chatIdentifier") ??
|
||||||
readString(chatFromList, "chat_identifier") ??
|
readString(chatFromList, "chat_identifier") ??
|
||||||
readString(chatFromList, "identifier");
|
readString(chatFromList, "identifier") ??
|
||||||
|
extractChatIdentifierFromChatGuid(chatGuid);
|
||||||
const chatId =
|
const chatId =
|
||||||
readNumberLike(message, "chatId") ??
|
readNumberLike(message, "chatId") ??
|
||||||
readNumberLike(message, "chat_id") ??
|
readNumberLike(message, "chat_id") ??
|
||||||
|
readNumberLike(chat, "chatId") ??
|
||||||
|
readNumberLike(chat, "chat_id") ??
|
||||||
readNumberLike(chat, "id") ??
|
readNumberLike(chat, "id") ??
|
||||||
|
readNumberLike(chatFromList, "chatId") ??
|
||||||
|
readNumberLike(chatFromList, "chat_id") ??
|
||||||
readNumberLike(chatFromList, "id");
|
readNumberLike(chatFromList, "id");
|
||||||
const chatName =
|
const chatName =
|
||||||
readString(message, "chatName") ??
|
readString(message, "chatName") ??
|
||||||
@@ -901,14 +1109,39 @@ async function processMessage(
|
|||||||
target: WebhookTarget,
|
target: WebhookTarget,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { account, config, runtime, core, statusSink } = target;
|
const { account, config, runtime, core, statusSink } = target;
|
||||||
if (message.fromMe) return;
|
|
||||||
const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
|
const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
|
||||||
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
|
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
|
||||||
|
|
||||||
const text = message.text.trim();
|
const text = message.text.trim();
|
||||||
const attachments = message.attachments ?? [];
|
const attachments = message.attachments ?? [];
|
||||||
const placeholder = buildMessagePlaceholder(message);
|
const placeholder = buildMessagePlaceholder(message);
|
||||||
if (!text && !placeholder) {
|
const rawBody = text || placeholder;
|
||||||
|
|
||||||
|
const cacheMessageId = message.messageId?.trim();
|
||||||
|
let messageShortId: string | undefined;
|
||||||
|
const cacheInboundMessage = () => {
|
||||||
|
if (!cacheMessageId) return;
|
||||||
|
const cacheEntry = rememberBlueBubblesReplyCache({
|
||||||
|
accountId: account.accountId,
|
||||||
|
messageId: cacheMessageId,
|
||||||
|
chatGuid: message.chatGuid,
|
||||||
|
chatIdentifier: message.chatIdentifier,
|
||||||
|
chatId: message.chatId,
|
||||||
|
senderLabel: message.fromMe ? "me" : message.senderId,
|
||||||
|
body: rawBody,
|
||||||
|
timestamp: message.timestamp ?? Date.now(),
|
||||||
|
});
|
||||||
|
messageShortId = cacheEntry.shortId;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (message.fromMe) {
|
||||||
|
// Cache from-me messages so reply context can resolve sender/body.
|
||||||
|
cacheInboundMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rawBody) {
|
||||||
logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`);
|
logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1148,6 +1381,10 @@ async function processMessage(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache allowed inbound messages so later replies can resolve sender/body without
|
||||||
|
// surfacing dropped content (allowlist/mention/command gating).
|
||||||
|
cacheInboundMessage();
|
||||||
|
|
||||||
const baseUrl = account.config.serverUrl?.trim();
|
const baseUrl = account.config.serverUrl?.trim();
|
||||||
const password = account.config.password?.trim();
|
const password = account.config.password?.trim();
|
||||||
const maxBytes =
|
const maxBytes =
|
||||||
@@ -1199,12 +1436,42 @@ async function processMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const rawBody = text.trim() || placeholder;
|
let replyToId = message.replyToId;
|
||||||
const replyContext = formatReplyContext(message);
|
let replyToBody = message.replyToBody;
|
||||||
|
let replyToSender = message.replyToSender;
|
||||||
|
let replyToShortId: string | undefined;
|
||||||
|
|
||||||
|
if (replyToId && (!replyToBody || !replyToSender)) {
|
||||||
|
const cached = resolveReplyContextFromCache({
|
||||||
|
accountId: account.accountId,
|
||||||
|
replyToId,
|
||||||
|
chatGuid: message.chatGuid,
|
||||||
|
chatIdentifier: message.chatIdentifier,
|
||||||
|
chatId: message.chatId,
|
||||||
|
});
|
||||||
|
if (cached) {
|
||||||
|
if (!replyToBody && cached.body) replyToBody = cached.body;
|
||||||
|
if (!replyToSender && cached.senderLabel) replyToSender = cached.senderLabel;
|
||||||
|
replyToShortId = cached.shortId;
|
||||||
|
if (core.logging.shouldLogVerbose()) {
|
||||||
|
const preview = (cached.body ?? "").replace(/\s+/g, " ").slice(0, 120);
|
||||||
|
logVerbose(
|
||||||
|
core,
|
||||||
|
runtime,
|
||||||
|
`reply-context cache hit replyToId=${replyToId} sender=${replyToSender ?? ""} body="${preview}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no cached short ID, try to get one from the UUID directly
|
||||||
|
if (replyToId && !replyToShortId) {
|
||||||
|
replyToShortId = getShortIdForUuid(replyToId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyContext = formatReplyContext({ replyToId, replyToShortId, replyToBody, replyToSender });
|
||||||
const baseBody = replyContext ? `${rawBody}\n\n${replyContext}` : rawBody;
|
const baseBody = replyContext ? `${rawBody}\n\n${replyContext}` : rawBody;
|
||||||
const fromLabel = isGroup
|
const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`;
|
||||||
? `group:${peerId}`
|
|
||||||
: message.senderName || `user:${message.senderId}`;
|
|
||||||
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
|
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
|
||||||
const groupMembers = isGroup
|
const groupMembers = isGroup
|
||||||
? formatGroupMembers({
|
? formatGroupMembers({
|
||||||
@@ -1230,12 +1497,12 @@ async function processMessage(
|
|||||||
});
|
});
|
||||||
let chatGuidForActions = chatGuid;
|
let chatGuidForActions = chatGuid;
|
||||||
if (!chatGuidForActions && baseUrl && password) {
|
if (!chatGuidForActions && baseUrl && password) {
|
||||||
const target =
|
const target =
|
||||||
isGroup && (chatId || chatIdentifier)
|
isGroup && (chatId || chatIdentifier)
|
||||||
? chatId
|
? chatId
|
||||||
? { kind: "chat_id", chatId }
|
? ({ kind: "chat_id", chatId } as const)
|
||||||
: { kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" }
|
: ({ kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" } as const)
|
||||||
: { kind: "handle", address: message.senderId };
|
: ({ kind: "handle", address: message.senderId } as const);
|
||||||
if (target.kind !== "chat_identifier" || target.chatIdentifier) {
|
if (target.kind !== "chat_identifier" || target.chatIdentifier) {
|
||||||
chatGuidForActions =
|
chatGuidForActions =
|
||||||
(await resolveChatGuidForTarget({
|
(await resolveChatGuidForTarget({
|
||||||
@@ -1316,10 +1583,23 @@ async function processMessage(
|
|||||||
? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
|
? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
|
||||||
: message.senderId;
|
: message.senderId;
|
||||||
|
|
||||||
const maybeEnqueueOutboundMessageId = (messageId?: string) => {
|
const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string) => {
|
||||||
const trimmed = messageId?.trim();
|
const trimmed = messageId?.trim();
|
||||||
if (!trimmed || trimmed === "ok" || trimmed === "unknown") return;
|
if (!trimmed || trimmed === "ok" || trimmed === "unknown") return;
|
||||||
core.system.enqueueSystemEvent(`BlueBubbles sent message id: ${trimmed}`, {
|
// Cache outbound message to get short ID
|
||||||
|
const cacheEntry = rememberBlueBubblesReplyCache({
|
||||||
|
accountId: account.accountId,
|
||||||
|
messageId: trimmed,
|
||||||
|
chatGuid: chatGuidForActions ?? chatGuid,
|
||||||
|
chatIdentifier,
|
||||||
|
chatId,
|
||||||
|
senderLabel: "me",
|
||||||
|
body: snippet ?? "",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
const displayId = cacheEntry.shortId || trimmed;
|
||||||
|
const preview = snippet ? ` "${snippet.slice(0, 12)}${snippet.length > 12 ? "…" : ""}"` : "";
|
||||||
|
core.system.enqueueSystemEvent(`Assistant sent${preview} [message_id:${displayId}]`, {
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
|
contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
|
||||||
});
|
});
|
||||||
@@ -1343,16 +1623,20 @@ async function processMessage(
|
|||||||
AccountId: route.accountId,
|
AccountId: route.accountId,
|
||||||
ChatType: isGroup ? "group" : "direct",
|
ChatType: isGroup ? "group" : "direct",
|
||||||
ConversationLabel: fromLabel,
|
ConversationLabel: fromLabel,
|
||||||
ReplyToId: message.replyToId,
|
// Use short ID for token savings (agent can use this to reference the message)
|
||||||
ReplyToBody: message.replyToBody,
|
ReplyToId: replyToShortId || replyToId,
|
||||||
ReplyToSender: message.replyToSender,
|
ReplyToIdFull: replyToId,
|
||||||
|
ReplyToBody: replyToBody,
|
||||||
|
ReplyToSender: replyToSender,
|
||||||
GroupSubject: groupSubject,
|
GroupSubject: groupSubject,
|
||||||
GroupMembers: groupMembers,
|
GroupMembers: groupMembers,
|
||||||
SenderName: message.senderName || undefined,
|
SenderName: message.senderName || undefined,
|
||||||
SenderId: message.senderId,
|
SenderId: message.senderId,
|
||||||
Provider: "bluebubbles",
|
Provider: "bluebubbles",
|
||||||
Surface: "bluebubbles",
|
Surface: "bluebubbles",
|
||||||
MessageSid: message.messageId,
|
// Use short ID for token savings (agent can use this to reference the message)
|
||||||
|
MessageSid: messageShortId || message.messageId,
|
||||||
|
MessageSidFull: message.messageId,
|
||||||
Timestamp: message.timestamp,
|
Timestamp: message.timestamp,
|
||||||
OriginatingChannel: "bluebubbles",
|
OriginatingChannel: "bluebubbles",
|
||||||
OriginatingTo: `bluebubbles:${outboundTarget}`,
|
OriginatingTo: `bluebubbles:${outboundTarget}`,
|
||||||
@@ -1367,6 +1651,11 @@ async function processMessage(
|
|||||||
cfg: config,
|
cfg: config,
|
||||||
dispatcherOptions: {
|
dispatcherOptions: {
|
||||||
deliver: async (payload) => {
|
deliver: async (payload) => {
|
||||||
|
const rawReplyToId = typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
|
||||||
|
// Resolve short ID (e.g., "5") to full UUID
|
||||||
|
const replyToMessageGuid = rawReplyToId
|
||||||
|
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
||||||
|
: "";
|
||||||
const mediaList = payload.mediaUrls?.length
|
const mediaList = payload.mediaUrls?.length
|
||||||
? payload.mediaUrls
|
? payload.mediaUrls
|
||||||
: payload.mediaUrl
|
: payload.mediaUrl
|
||||||
@@ -1382,10 +1671,11 @@ async function processMessage(
|
|||||||
to: outboundTarget,
|
to: outboundTarget,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
caption: caption ?? undefined,
|
caption: caption ?? undefined,
|
||||||
replyToId: payload.replyToId ?? null,
|
replyToId: replyToMessageGuid || null,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
});
|
});
|
||||||
maybeEnqueueOutboundMessageId(result.messageId);
|
const cachedBody = (caption ?? "").trim() || "<media:attachment>";
|
||||||
|
maybeEnqueueOutboundMessageId(result.messageId, cachedBody);
|
||||||
sentMessage = true;
|
sentMessage = true;
|
||||||
statusSink?.({ lastOutboundAt: Date.now() });
|
statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
}
|
}
|
||||||
@@ -1400,14 +1690,12 @@ async function processMessage(
|
|||||||
if (!chunks.length && payload.text) chunks.push(payload.text);
|
if (!chunks.length && payload.text) chunks.push(payload.text);
|
||||||
if (!chunks.length) return;
|
if (!chunks.length) return;
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
const replyToMessageGuid =
|
|
||||||
typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
|
|
||||||
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
|
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
|
||||||
cfg: config,
|
cfg: config,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
replyToMessageGuid: replyToMessageGuid || undefined,
|
replyToMessageGuid: replyToMessageGuid || undefined,
|
||||||
});
|
});
|
||||||
maybeEnqueueOutboundMessageId(result.messageId);
|
maybeEnqueueOutboundMessageId(result.messageId, chunk);
|
||||||
sentMessage = true;
|
sentMessage = true;
|
||||||
statusSink?.({ lastOutboundAt: Date.now() });
|
statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
}
|
}
|
||||||
@@ -1541,7 +1829,9 @@ async function processReaction(
|
|||||||
|
|
||||||
const senderLabel = reaction.senderName || reaction.senderId;
|
const senderLabel = reaction.senderName || reaction.senderId;
|
||||||
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
||||||
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${reaction.messageId}`;
|
// Use short ID for token savings
|
||||||
|
const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
|
||||||
|
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${messageDisplayId}`;
|
||||||
core.system.enqueueSystemEvent(text, {
|
core.system.enqueueSystemEvent(text, {
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import type { ClawdbotConfig, DmPolicy, WizardPrompter } from "clawdbot/plugin-sdk";
|
|
||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
|
||||||
import type {
|
import type {
|
||||||
ChannelOnboardingAdapter,
|
ChannelOnboardingAdapter,
|
||||||
ChannelOnboardingDmPolicy,
|
ChannelOnboardingDmPolicy,
|
||||||
} from "../../../src/channels/plugins/onboarding-types.js";
|
ClawdbotConfig,
|
||||||
import { addWildcardAllowFrom, promptAccountId } from "../../../src/channels/plugins/onboarding/helpers.js";
|
DmPolicy,
|
||||||
import { formatDocsLink } from "../../../src/terminal/links.js";
|
WizardPrompter,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
import {
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
|
addWildcardAllowFrom,
|
||||||
|
formatDocsLink,
|
||||||
|
normalizeAccountId,
|
||||||
|
promptAccountId,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
import {
|
import {
|
||||||
listBlueBubblesAccountIds,
|
listBlueBubblesAccountIds,
|
||||||
resolveBlueBubblesAccount,
|
resolveBlueBubblesAccount,
|
||||||
|
|||||||
@@ -20,32 +20,101 @@ const REACTION_TYPES = new Set([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const REACTION_ALIASES = new Map<string, string>([
|
const REACTION_ALIASES = new Map<string, string>([
|
||||||
|
// General
|
||||||
["heart", "love"],
|
["heart", "love"],
|
||||||
|
["love", "love"],
|
||||||
|
["❤", "love"],
|
||||||
|
["❤️", "love"],
|
||||||
|
["red_heart", "love"],
|
||||||
["thumbs_up", "like"],
|
["thumbs_up", "like"],
|
||||||
["thumbs-down", "dislike"],
|
["thumbsup", "like"],
|
||||||
|
["thumbs-up", "like"],
|
||||||
|
["thumbsup", "like"],
|
||||||
|
["like", "like"],
|
||||||
|
["thumb", "like"],
|
||||||
|
["ok", "like"],
|
||||||
["thumbs_down", "dislike"],
|
["thumbs_down", "dislike"],
|
||||||
|
["thumbsdown", "dislike"],
|
||||||
|
["thumbs-down", "dislike"],
|
||||||
|
["dislike", "dislike"],
|
||||||
|
["boo", "dislike"],
|
||||||
|
["no", "dislike"],
|
||||||
|
// Laugh
|
||||||
["haha", "laugh"],
|
["haha", "laugh"],
|
||||||
["lol", "laugh"],
|
["lol", "laugh"],
|
||||||
|
["lmao", "laugh"],
|
||||||
|
["rofl", "laugh"],
|
||||||
|
["😂", "laugh"],
|
||||||
|
["🤣", "laugh"],
|
||||||
|
["xd", "laugh"],
|
||||||
|
["laugh", "laugh"],
|
||||||
|
// Emphasize / exclaim
|
||||||
["emphasis", "emphasize"],
|
["emphasis", "emphasize"],
|
||||||
|
["emphasize", "emphasize"],
|
||||||
["exclaim", "emphasize"],
|
["exclaim", "emphasize"],
|
||||||
|
["!!", "emphasize"],
|
||||||
|
["‼", "emphasize"],
|
||||||
|
["‼️", "emphasize"],
|
||||||
|
["❗", "emphasize"],
|
||||||
|
["important", "emphasize"],
|
||||||
|
["bang", "emphasize"],
|
||||||
|
// Question
|
||||||
["question", "question"],
|
["question", "question"],
|
||||||
|
["?", "question"],
|
||||||
|
["❓", "question"],
|
||||||
|
["❔", "question"],
|
||||||
|
["ask", "question"],
|
||||||
|
// Apple/Messages names
|
||||||
|
["loved", "love"],
|
||||||
|
["liked", "like"],
|
||||||
|
["disliked", "dislike"],
|
||||||
|
["laughed", "laugh"],
|
||||||
|
["emphasized", "emphasize"],
|
||||||
|
["questioned", "question"],
|
||||||
|
// Colloquial / informal
|
||||||
|
["fire", "love"],
|
||||||
|
["🔥", "love"],
|
||||||
|
["wow", "emphasize"],
|
||||||
|
["!", "emphasize"],
|
||||||
|
// Edge: generic emoji name forms
|
||||||
|
["heart_eyes", "love"],
|
||||||
|
["smile", "laugh"],
|
||||||
|
["smiley", "laugh"],
|
||||||
|
["happy", "laugh"],
|
||||||
|
["joy", "laugh"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const REACTION_EMOJIS = new Map<string, string>([
|
const REACTION_EMOJIS = new Map<string, string>([
|
||||||
|
// Love
|
||||||
["❤️", "love"],
|
["❤️", "love"],
|
||||||
["❤", "love"],
|
["❤", "love"],
|
||||||
["♥️", "love"],
|
["♥️", "love"],
|
||||||
|
["♥", "love"],
|
||||||
["😍", "love"],
|
["😍", "love"],
|
||||||
|
["💕", "love"],
|
||||||
|
// Like
|
||||||
["👍", "like"],
|
["👍", "like"],
|
||||||
|
["👌", "like"],
|
||||||
|
// Dislike
|
||||||
["👎", "dislike"],
|
["👎", "dislike"],
|
||||||
|
["🙅", "dislike"],
|
||||||
|
// Laugh
|
||||||
["😂", "laugh"],
|
["😂", "laugh"],
|
||||||
["🤣", "laugh"],
|
["🤣", "laugh"],
|
||||||
["😆", "laugh"],
|
["😆", "laugh"],
|
||||||
|
["😁", "laugh"],
|
||||||
|
["😹", "laugh"],
|
||||||
|
// Emphasize
|
||||||
["‼️", "emphasize"],
|
["‼️", "emphasize"],
|
||||||
["‼", "emphasize"],
|
["‼", "emphasize"],
|
||||||
|
["!!", "emphasize"],
|
||||||
["❗", "emphasize"],
|
["❗", "emphasize"],
|
||||||
|
["❕", "emphasize"],
|
||||||
|
["!", "emphasize"],
|
||||||
|
// Question
|
||||||
["❓", "question"],
|
["❓", "question"],
|
||||||
["❔", "question"],
|
["❔", "question"],
|
||||||
|
["?", "question"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function resolveAccount(params: BlueBubblesReactionOpts) {
|
function resolveAccount(params: BlueBubblesReactionOpts) {
|
||||||
|
|||||||
@@ -96,6 +96,33 @@ describe("send", () => {
|
|||||||
expect(result).toBe("iMessage;-;chat123");
|
expect(result).toBe("iMessage;-;chat123");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("matches chat_identifier against the 3rd component of chat GUID", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
guid: "iMessage;+;chat660250192681427962",
|
||||||
|
participants: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const target: BlueBubblesSendTarget = {
|
||||||
|
kind: "chat_identifier",
|
||||||
|
chatIdentifier: "chat660250192681427962",
|
||||||
|
};
|
||||||
|
const result = await resolveChatGuidForTarget({
|
||||||
|
baseUrl: "http://localhost:1234",
|
||||||
|
password: "test",
|
||||||
|
target,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe("iMessage;+;chat660250192681427962");
|
||||||
|
});
|
||||||
|
|
||||||
it("resolves handle target by matching participant", async () => {
|
it("resolves handle target by matching participant", async () => {
|
||||||
mockFetch.mockResolvedValueOnce({
|
mockFetch.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -133,6 +133,13 @@ function extractChatId(chat: BlueBubblesChatRecord): number | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractChatIdentifierFromChatGuid(chatGuid: string): string | null {
|
||||||
|
const parts = chatGuid.split(";");
|
||||||
|
if (parts.length < 3) return null;
|
||||||
|
const identifier = parts[2]?.trim();
|
||||||
|
return identifier ? identifier : null;
|
||||||
|
}
|
||||||
|
|
||||||
function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
|
function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
|
||||||
const raw =
|
const raw =
|
||||||
(Array.isArray(chat.participants) ? chat.participants : null) ??
|
(Array.isArray(chat.participants) ? chat.participants : null) ??
|
||||||
@@ -223,7 +230,16 @@ export async function resolveChatGuidForTarget(params: {
|
|||||||
}
|
}
|
||||||
if (targetChatIdentifier) {
|
if (targetChatIdentifier) {
|
||||||
const guid = extractChatGuid(chat);
|
const guid = extractChatGuid(chat);
|
||||||
if (guid && guid === targetChatIdentifier) return guid;
|
if (guid) {
|
||||||
|
// Back-compat: some callers might pass a full chat GUID.
|
||||||
|
if (guid === targetChatIdentifier) return guid;
|
||||||
|
|
||||||
|
// Primary match: BlueBubbles `chat_identifier:*` targets correspond to the
|
||||||
|
// third component of the chat GUID: `service;(+|-) ;identifier`.
|
||||||
|
const guidIdentifier = extractChatIdentifierFromChatGuid(guid);
|
||||||
|
if (guidIdentifier && guidIdentifier === targetChatIdentifier) return guid;
|
||||||
|
}
|
||||||
|
|
||||||
const identifier =
|
const identifier =
|
||||||
typeof chat.identifier === "string"
|
typeof chat.identifier === "string"
|
||||||
? chat.identifier
|
? chat.identifier
|
||||||
@@ -232,7 +248,7 @@ export async function resolveChatGuidForTarget(params: {
|
|||||||
: typeof chat.chat_identifier === "string"
|
: typeof chat.chat_identifier === "string"
|
||||||
? chat.chat_identifier
|
? chat.chat_identifier
|
||||||
: "";
|
: "";
|
||||||
if (identifier && identifier === targetChatIdentifier) return extractChatGuid(chat);
|
if (identifier && identifier === targetChatIdentifier) return guid ?? extractChatGuid(chat);
|
||||||
}
|
}
|
||||||
if (normalizedHandle) {
|
if (normalizedHandle) {
|
||||||
const guid = extractChatGuid(chat);
|
const guid = extractChatGuid(chat);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/copilot-proxy",
|
"name": "@clawdbot/copilot-proxy",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20-2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Copilot Proxy provider plugin",
|
"description": "Clawdbot Copilot Proxy provider plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/diagnostics-otel",
|
"name": "@clawdbot/diagnostics-otel",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20-2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot diagnostics OpenTelemetry exporter",
|
"description": "Clawdbot diagnostics OpenTelemetry exporter",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"]
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/discord",
|
"name": "@clawdbot/discord",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20-2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Discord channel plugin",
|
"description": "Clawdbot Discord channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"]
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/google-antigravity-auth",
|
"name": "@clawdbot/google-antigravity-auth",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20-2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Google Antigravity OAuth provider plugin",
|
"description": "Clawdbot Google Antigravity OAuth provider plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/google-gemini-cli-auth",
|
"name": "@clawdbot/google-gemini-cli-auth",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20-2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Gemini CLI OAuth provider plugin",
|
"description": "Clawdbot Gemini CLI OAuth provider plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/imessage",
|
"name": "@clawdbot/imessage",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20-2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot iMessage channel plugin",
|
"description": "Clawdbot iMessage channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"]
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026.1.20-2
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Version alignment with core Clawdbot release numbers.
|
||||||
|
|
||||||
|
## 2026.1.20
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Version alignment with core Clawdbot release numbers.
|
||||||
|
|
||||||
## 2026.1.17-1
|
## 2026.1.17-1
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/matrix",
|
"name": "@clawdbot/matrix",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20-2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Matrix channel plugin",
|
"description": "Clawdbot Matrix channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/memory-core",
|
"name": "@clawdbot/memory-core",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20-2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot core memory search plugin",
|
"description": "Clawdbot core memory search plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/memory-lancedb",
|
"name": "@clawdbot/memory-lancedb",
|
||||||
"version": "0.0.1",
|
"version": "2026.1.20-2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
|
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026.1.20-2
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Version alignment with core Clawdbot release numbers.
|
||||||
|
|
||||||
|
## 2026.1.20
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Version alignment with core Clawdbot release numbers.
|
||||||
|
|
||||||
## 2026.1.17-1
|
## 2026.1.17-1
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/msteams",
|
"name": "@clawdbot/msteams",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20-2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Microsoft Teams channel plugin",
|
"description": "Clawdbot Microsoft Teams channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
"docsPath": "/channels/msteams",
|
"docsPath": "/channels/msteams",
|
||||||
"docsLabel": "msteams",
|
"docsLabel": "msteams",
|
||||||
"blurb": "Bot Framework; enterprise support.",
|
"blurb": "Bot Framework; enterprise support.",
|
||||||
"aliases": ["teams"],
|
"aliases": [
|
||||||
|
"teams"
|
||||||
|
],
|
||||||
"order": 60
|
"order": 60
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/nextcloud-talk",
|
"name": "@clawdbot/nextcloud-talk",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20-2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Nextcloud Talk channel plugin",
|
"description": "Clawdbot Nextcloud Talk channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"],
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
],
|
||||||
"channel": {
|
"channel": {
|
||||||
"id": "nextcloud-talk",
|
"id": "nextcloud-talk",
|
||||||
"label": "Nextcloud Talk",
|
"label": "Nextcloud Talk",
|
||||||
@@ -12,7 +14,10 @@
|
|||||||
"docsPath": "/channels/nextcloud-talk",
|
"docsPath": "/channels/nextcloud-talk",
|
||||||
"docsLabel": "nextcloud-talk",
|
"docsLabel": "nextcloud-talk",
|
||||||
"blurb": "Self-hosted chat via Nextcloud Talk webhook bots.",
|
"blurb": "Self-hosted chat via Nextcloud Talk webhook bots.",
|
||||||
"aliases": ["nc-talk", "nc"],
|
"aliases": [
|
||||||
|
"nc-talk",
|
||||||
|
"nc"
|
||||||
|
],
|
||||||
"order": 65,
|
"order": 65,
|
||||||
"quickstartAllowFrom": true
|
"quickstartAllowFrom": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026.1.20-2
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Version alignment with core Clawdbot release numbers.
|
||||||
|
|
||||||
|
## 2026.1.20
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Version alignment with core Clawdbot release numbers.
|
||||||
|
|
||||||
## 2026.1.19-1
|
## 2026.1.19-1
|
||||||
|
|
||||||
Initial release.
|
Initial release.
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/nostr",
|
"name": "@clawdbot/nostr",
|
||||||
"version": "2026.1.19-1",
|
"version": "2026.1.20-2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
|
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"],
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
],
|
||||||
"channel": {
|
"channel": {
|
||||||
"id": "nostr",
|
"id": "nostr",
|
||||||
"label": "Nostr",
|
"label": "Nostr",
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/signal",
|
"name": "@clawdbot/signal",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20-2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Signal channel plugin",
|
"description": "Clawdbot Signal channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"]
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/slack",
|
"name": "@clawdbot/slack",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20-2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Slack channel plugin",
|
"description": "Clawdbot Slack channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"]
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/telegram",
|
"name": "@clawdbot/telegram",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20-2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Telegram channel plugin",
|
"description": "Clawdbot Telegram channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"]
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026.1.20-2
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Version alignment with core Clawdbot release numbers.
|
||||||
|
|
||||||
|
## 2026.1.20
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Version alignment with core Clawdbot release numbers.
|
||||||
|
|
||||||
## 2026.1.17-1
|
## 2026.1.17-1
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/voice-call",
|
"name": "@clawdbot/voice-call",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20-2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot voice-call plugin",
|
"description": "Clawdbot voice-call plugin",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/whatsapp",
|
"name": "@clawdbot/whatsapp",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20-2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot WhatsApp channel plugin",
|
"description": "Clawdbot WhatsApp channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"]
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loaded 100 of 334 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user