Compare commits

..

820 Commits

Author SHA1 Message Date
Peter Steinberger
d0ee03bd20 fix: document /models directive regression (#1753) (thanks @uos-status) 2026-01-25 10:01:42 +00:00
Clawdbot Bot
9589e02362 Auto-reply: cover bare /models regression 2026-01-25 09:48:51 +00:00
Clawdbot Bot
4ab568cede Auto-reply: add /models directive regression test 2026-01-25 09:48:51 +00:00
Clawdbot Bot
9520b6d970 Auto-reply: ignore /models in model directive 2026-01-25 09:48:51 +00:00
Peter Steinberger
83f92e34af refactor: align voice-call TTS with core config 2026-01-25 09:29:57 +00:00
Vignesh Natarajan
9366cbc7db Docs: add Discord MESSAGE_CONTENT intent step to Railway guide 2026-01-25 01:26:53 -08:00
Peter Steinberger
d4f895d8f2 fix: move gateway lock to temp dir 2026-01-25 09:21:46 +00:00
Vignesh Natarajan
f08c34a73f Docs: fix Railway deploy URL and add PORT variable 2026-01-25 01:18:12 -08:00
zhixian
6a9301c27d feat(tts): support custom OpenAI-compatible TTS endpoints (#1701)
* feat(tts): support custom OpenAI-compatible TTS endpoints

Add OPENAI_TTS_BASE_URL environment variable to allow using self-hosted
or third-party OpenAI-compatible TTS services like Kokoro, LocalAI, or
OpenedAI-Speech.

Changes:
- Add OPENAI_TTS_BASE_URL env var (defaults to OpenAI official API)
- Relax model/voice validation when using custom endpoints
- Add tts-1 and tts-1-hd to the model allowlist

This enables users to:
- Use local TTS for privacy and cost savings
- Use models with better non-English language support (Chinese, Japanese)
- Reduce latency with local inference

Example usage:
  OPENAI_TTS_BASE_URL=http://localhost:8880/v1

Tested with Kokoro-FastAPI.

* fix: strip trailing slashes from OPENAI_TTS_BASE_URL

Address review feedback: normalize the base URL by removing trailing
slashes to prevent double-slash paths like /v1//audio/speech which
cause 404 errors on some OpenAI-compatible servers.

* style: format code with oxfmt

* test: update tests for expanded OpenAI TTS model list

- Accept tts-1 and tts-1-hd as valid models
- Update OPENAI_TTS_MODELS length expectation to 3

---------

Co-authored-by: zhixian <zhixian@bunker.local>
2026-01-25 08:04:20 +00:00
Peter Steinberger
653401774d fix(telegram): honor linkPreview on fallback (#1730)
* feat: add notice directive parsing

* fix: honor telegram linkPreview config (#1700) (thanks @zerone0x)
2026-01-25 07:55:39 +00:00
Peter Steinberger
c6cdbb630c fix: harden exec spawn fallback 2026-01-25 06:37:39 +00:00
Peter Steinberger
da2439f2cc docs: clarify Claude Pro Max auth 2026-01-25 06:05:11 +00:00
zerone0x
92ab3f22dc feat(telegram): add linkPreview config option
Add channels.telegram.linkPreview config to control whether link previews
are shown in outbound messages. When set to false, uses Telegram's
link_preview_options.is_disabled to suppress URL previews.

- Add linkPreview to TelegramAccountConfig type
- Add Zod schema validation for linkPreview
- Pass link_preview_options to sendMessage in send.ts and bot/delivery.ts
- Propagate linkPreview config through deliverReplies callers
- Add tests for link preview behavior

Fixes #1675

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-25 06:00:05 +00:00
Peter Steinberger
43a6c5b77f docs: clarify Gemini CLI OAuth 2026-01-25 05:53:25 +00:00
Peter Steinberger
495616d13e fix(ui): refine config save guardrails (#1707)
* fix: refine config save guardrails

* docs: add changelog for config save guardrails (#1707) (thanks @Glucksberg)
2026-01-25 05:52:32 +00:00
Peter Steinberger
bac80f0886 fix: listen on ipv6 loopback for gateway 2026-01-25 05:49:48 +00:00
Peter Steinberger
ef078fec70 docs: add Windows install troubleshooting 2026-01-25 05:48:24 +00:00
Glucksberg
8e3ac01db6 fix(ui): improve config save UX (#1678)
Follow-up to #1609 fix:
- Remove formUnsafe check from canSave (was blocking save even with valid changes)
- Suppress disconnect message for code 1012 (service restart is expected during config save)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 05:46:55 +00:00
Peter Steinberger
c3f90dd4e2 docs: add macOS VM link 2026-01-25 05:32:04 +00:00
Peter Steinberger
00c4556d7b docs: add Claude Max FAQ 2026-01-25 05:28:01 +00:00
Peter Steinberger
a4bc69dbec docs: add first steps FAQ 2026-01-25 05:24:47 +00:00
Peter Steinberger
3f1457de2a docs: add new FAQ entries 2026-01-25 05:20:16 +00:00
Peter Steinberger
8507ea08bd docs: expand macOS VM guide (#1693) (thanks @f-trycua) 2026-01-25 05:16:41 +00:00
f-trycua
7ae2548fc6 docs: add macOS VM (Lume) platform guide
Add documentation for running Clawdbot in a sandboxed macOS VM
using Lume. This provides an alternative to buying dedicated
hardware or using cloud instances.

The guide covers:
- Installing Lume on Apple Silicon Macs
- Creating and configuring a macOS VM
- Installing Clawdbot inside the VM
- Running headlessly for 24/7 operation
- iMessage integration via BlueBubbles
- Saving golden images for easy reset
2026-01-25 05:14:13 +00:00
Peter Steinberger
f06f83ddd0 docs: add CC comparison faq 2026-01-25 05:00:47 +00:00
Peter Steinberger
c78297d80f docs: add account isolation faq 2026-01-25 04:56:41 +00:00
Peter Steinberger
69f6e1a20b docs: add multi-agent team faq 2026-01-25 04:55:22 +00:00
Peter Steinberger
5f6409a73d fix: configurable signal startup timeout 2026-01-25 04:51:35 +00:00
Rohan Nagpal
06a7e1e8ce Telegram: threaded conversation support (#1597)
* Telegram: isolate dm topic sessions

* Tests: cap vitest workers

* Tests: cap Vitest workers on CI macOS

* Tests: avoid timer-based pi-ai stream mock

* Tests: increase embedded runner timeout

* fix: harden telegram dm thread handling (#1597) (thanks @rohannagpal)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-25 04:48:51 +00:00
Peter Steinberger
9eaaadf8ee fix: clarify control ui auth hints (fixes #1690) 2026-01-25 04:46:42 +00:00
Seb Slight
d4f60bf16a TTS: gate auto audio on inbound voice notes (#1667)
Co-authored-by: Sebastian <sebslight@gmail.com>
2026-01-25 04:35:20 +00:00
Peter Steinberger
ede5145191 docs: sweep support troubleshooting updates 2026-01-25 04:33:14 +00:00
Peter Steinberger
26d3fbb09f docs: add faq answers from support stream 2026-01-25 04:30:37 +00:00
Peter Steinberger
f7c89ba796 docs: fix faq wording + add heading guardrail 2026-01-25 04:25:17 +00:00
Peter Steinberger
9afde64e26 fix: validate web_search freshness (#1688) (thanks @JonUleis) 2026-01-25 04:23:25 +00:00
Jon Uleis
8682524da3 feat(web_search): add freshness parameter for Brave time filtering
Adds support for Brave Search API's freshness parameter to filter results
by discovery time:
- 'pd' - Past 24 hours
- 'pw' - Past week
- 'pm' - Past month
- 'py' - Past year
- 'YYYY-MM-DDtoYYYY-MM-DD' - Custom date range

Useful for cron jobs and monitoring tasks that need recent results.

Note: Perplexity provider ignores this parameter (Brave only).

---
🤖 AI-assisted: This PR was created with Claude (Opus). Lightly tested via
build script. The change follows existing patterns for optional parameters
(country, search_lang, ui_lang).
2026-01-25 04:19:58 +00:00
Peter Steinberger
5956dde459 docs: expand faq on heavy work + self-hosted models 2026-01-25 04:17:12 +00:00
Peter Steinberger
50bb418fe7 fix: guard discord thread channel 2026-01-25 04:11:55 +00:00
Peter Steinberger
458e731f8b fix: newline chunking across channels 2026-01-25 04:11:36 +00:00
Peter Steinberger
ca78ccf74c docs: add dedicated host faq 2026-01-25 04:11:08 +00:00
Peter Steinberger
580fd7abbd fix: guard discord forum thread access 2026-01-25 04:11:04 +00:00
Peter Steinberger
4a82c258c7 docs: add windows restart faq 2026-01-25 04:09:51 +00:00
Peter Steinberger
58c7c61e62 fix: add duplex for fetch uploads 2026-01-25 04:05:30 +00:00
Peter Steinberger
629ce4454d docs: add tips + clawd-to-clawd faq 2026-01-25 04:04:18 +00:00
Peter Steinberger
617d8a12d7 chore: update clawtributors
Co-authored-by: vilkasdev <vilkasdev@users.noreply.github.com>
2026-01-25 04:01:10 +00:00
Shadow
cdceff2284 Discord: add forum parent context 2026-01-24 21:57:48 -06:00
Peter Steinberger
cb52ffb842 fix: drop unused cli import 2026-01-25 03:42:32 +00:00
Peter Steinberger
3a35d313d9 fix: signal reactions 2026-01-25 03:24:44 +00:00
Tom McKenzie
116fbb747f CLI: fix subcommand registration to work without --help/--version flags (#1683)
## Problem

The clawdbot-gateway systemd service was crash-looping on Linux (Fedora 42,
aarch64) with the error:

    error: unknown command '/usr/bin/node-22'

After ~20 seconds of runtime, the gateway would exit with status 1/FAILURE
and systemd would restart it, repeating the cycle indefinitely (80+ restarts
observed).

## Root Cause Analysis

### Investigation Steps

1. Examined systemd service logs via `journalctl --user -u clawdbot-gateway.service`
2. Found the error appeared consistently after the service had been running
   for 20-30 seconds
3. Added debug logging to trace argv at parseAsync() call
4. Discovered that argv was being passed to Commander.js with the node binary
   and script paths still present: `["/usr/bin/node-22", "/path/to/entry.js", "gateway", "--port", "18789"]`
5. Traced the issue to the lazy subcommand registration logic in runCli()

### The Bug

The lazy-loading logic for subcommands was gated behind `hasHelpOrVersion(parseArgv)`:

```typescript
if (hasHelpOrVersion(parseArgv)) {
  const primary = getPrimaryCommand(parseArgv);
  if (primary) {
    const { registerSubCliByName } = await import("./program/register.subclis.js");
    await registerSubCliByName(program, primary);
  }
}
```

This meant that when running `clawdbot gateway --port 18789` (without --help
or --version), the `gateway` subcommand was never registered before
`program.parseAsync(parseArgv)` was called. Commander.js would then try to
parse the arguments without knowing about the gateway command, leading to
parse errors.

The error message "unknown command '/usr/bin/node-22'" appeared because
Commander was treating the first positional argument as a command name due to
argv not being properly stripped on non-Windows platforms in some code paths.

## The Fix

Remove the `hasHelpOrVersion()` gate and always register the primary
subcommand when one is detected:

```typescript
// Register the primary subcommand if one exists (for lazy-loading)
const primary = getPrimaryCommand(parseArgv);
if (primary) {
  const { registerSubCliByName } = await import("./program/register.subclis.js");
  await registerSubCliByName(program, primary);
}
```

This ensures that subcommands like `gateway` are properly registered before
parsing begins, regardless of what flags are present.

## Environment

- OS: Fedora 42 (Linux 6.15.9-201.fc42.aarch64)
- Arch: aarch64
- Node: /usr/bin/node-22 (symlink to node-22)
- Deployment: systemd user service
- Runtime: Gateway started via `clawdbot gateway --port 18789`

## Why This Should Be Merged

1. **Critical Bug**: The gateway service cannot run reliably on Linux without
   this fix, making it a blocking issue for production deployments via systemd.

2. **Affects All Non-Help Invocations**: Any direct subcommand invocation
   (gateway, channels, etc.) without --help/--version is broken.

3. **Simple & Safe Fix**: The change removes an unnecessary condition that was
   preventing lazy-loading from working correctly. Subcommands should always be
   registered when detected, not just for help/version requests.

4. **No Regression Risk**: The fix maintains the lazy-loading behavior (only
   loads the requested subcommand), just ensures it works in all cases instead
   of only help/version scenarios.

5. **Tested**: Verified that the gateway service now runs stably for extended
   periods (45+ seconds continuous runtime with no crashes) after applying this
   fix.

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-25 03:17:02 +00:00
Peter Steinberger
b1a555da13 fix: skip tailscale dns probe when off 2026-01-25 02:51:20 +00:00
Peter Steinberger
c92aaca8b0 docs: answer local data storage faq 2026-01-25 02:48:28 +00:00
Peter Steinberger
c3e777e3e1 fix: keep raw config edits scoped to config view (#1673) (thanks @Glucksberg) 2026-01-25 02:48:07 +00:00
Peter Steinberger
2e3b14187b fix: stabilize venice model discovery 2026-01-25 02:43:08 +00:00
Peter Steinberger
f8a22521bd docs: clarify WSL2 recommendation 2026-01-25 02:30:09 +00:00
Peter Steinberger
8477394414 docs: explain unstuck commands 2026-01-25 02:04:32 +00:00
Peter Steinberger
e6e71457e0 fix: honor trusted proxy client IPs (PR #1654)
Thanks @ndbroadbent.

Co-authored-by: Nathan Broadbent <git@ndbroadbent.com>
2026-01-25 01:52:19 +00:00
Peter Steinberger
2684a364c6 docs: add basic debug commands to unstuck faq 2026-01-25 01:51:38 +00:00
Peter Steinberger
b9dc117309 docs: refine venice highlight 2026-01-25 01:49:53 +00:00
Peter Steinberger
9205ee55de docs: add fastest-unstuck guidance 2026-01-25 01:22:22 +00:00
Peter Steinberger
6e23e81678 docs: clarify lobster DSL rationale 2026-01-25 01:13:55 +00:00
Peter Steinberger
0163f53f5d fix: regenerate protocol models 2026-01-25 01:12:49 +00:00
jonisjongithub
25f2d2adb3 docs: remove rate limits claim from Venice docs 2026-01-25 01:11:57 +00:00
jonisjongithub
7540d1e8c1 feat: add Venice AI provider integration
Venice AI is a privacy-focused AI inference provider with support for
uncensored models and access to major proprietary models via their
anonymized proxy.

This integration adds:

- Complete model catalog with 25 models:
  - 15 private models (Llama, Qwen, DeepSeek, Venice Uncensored, etc.)
  - 10 anonymized models (Claude, GPT-5.2, Gemini, Grok, Kimi, MiniMax)
- Auto-discovery from Venice API with fallback to static catalog
- VENICE_API_KEY environment variable support
- Interactive onboarding via 'venice-api-key' auth choice
- Model selection prompt showing all available Venice models
- Provider auto-registration when API key is detected
- Comprehensive documentation covering:
  - Privacy modes (private vs anonymized)
  - All 25 models with context windows and features
  - Streaming, function calling, and vision support
  - Model selection recommendations

Privacy modes:
- Private: Fully private, no logging (open-source models)
- Anonymized: Proxied through Venice (proprietary models)

Default model: venice/llama-3.3-70b (good balance of capability + privacy)
Venice API: https://api.venice.ai/api/v1 (OpenAI-compatible)
2026-01-25 01:11:57 +00:00
Peter Steinberger
fc0e303e05 feat: add edge tts fallback provider 2026-01-25 01:05:43 +00:00
Peter Steinberger
6a7a1d7085 fix: add chat stop button
Co-authored-by: Nathan Broadbent <ndbroadbent@users.noreply.github.com>
2026-01-25 01:00:23 +00:00
Tyler Yust
92e794dc18 feat: add chunking mode option for BlueBubbles (#1645)
* feat: add chunking mode for outbound messages

- Introduced `chunkMode` option in various account configurations to allow splitting messages by "length" or "newline".
- Updated message processing to handle chunking based on the selected mode.
- Added tests for new chunking functionality, ensuring correct behavior for both modes.

* feat: enhance chunking mode documentation and configuration

- Added `chunkMode` option to the BlueBubbles account configuration, allowing users to choose between "length" and "newline" for message chunking.
- Updated documentation to clarify the behavior of the `chunkMode` setting.
- Adjusted account merging logic to incorporate the new `chunkMode` configuration.

* refactor: simplify chunk mode handling for BlueBubbles

- Removed `chunkMode` configuration from various account schemas and types, centralizing chunk mode logic to BlueBubbles only.
- Updated `processMessage` to default to "newline" for BlueBubbles chunking.
- Adjusted tests to reflect changes in chunk mode handling for BlueBubbles, ensuring proper functionality.

* fix: update default chunk mode to 'length' for BlueBubbles

- Changed the default value of `chunkMode` from 'newline' to 'length' in the BlueBubbles configuration and related processing functions.
- Updated documentation to reflect the new default behavior for chunking messages.
- Adjusted tests to ensure the correct default value is returned for BlueBubbles chunk mode.
2026-01-25 00:47:10 +00:00
Peter Steinberger
6375ee836f docs: clarify remote transport IP reporting 2026-01-25 00:39:54 +00:00
Peter Steinberger
a6c97b5a48 fix: reload TUI history after reconnect 2026-01-25 00:36:36 +00:00
Peter Steinberger
5ea15ff7fe docs: add aws mention to vps hub 2026-01-25 00:23:24 +00:00
Peter Steinberger
dd57483e5e docs: add vps hosting hub 2026-01-25 00:20:07 +00:00
Peter Steinberger
3696aade09 chore: refresh pnpm lock 2026-01-25 00:19:02 +00:00
Richard Pinedo
426168a338 Add link understanding tool support (#1637)
* Add

* Fix

---------

Co-authored-by: Richard <dasilva333@DESKTOP-74E3GJO.localdomain>
2026-01-25 00:15:54 +00:00
Peter Steinberger
2f58d59f22 docs: add nodes note to cloud guides 2026-01-25 00:13:44 +00:00
Peter Steinberger
cbe19ad2f2 docs: add hosting hub links 2026-01-25 00:12:31 +00:00
Peter Steinberger
d57b88c7af docs: add railway quick checklist 2026-01-25 00:10:25 +00:00
Peter Steinberger
ce89bc2b40 docs: add anthropic auth error troubleshooting 2026-01-25 00:07:19 +00:00
Peter Steinberger
85b27fe5fe docs: fix ollama links 2026-01-25 00:05:38 +00:00
Peter Steinberger
c147962434 fix: normalize googlechat targets 2026-01-25 00:04:47 +00:00
Vignesh
72858a5311 Merge pull request #1658 from clawdbot/fix/railway-redirect-loop
Docs: fix /railway redirect loop
2026-01-24 16:04:32 -08:00
Vignesh Natarajan
21445cfc0a Docs: fix /railway redirect loop 2026-01-24 16:03:55 -08:00
Peter Steinberger
5ad203e47b fix: default custom provider model fields 2026-01-25 00:02:53 +00:00
Vignesh
3b53213b41 Merge pull request #1657 from clawdbot/docs/railway
Docs: add Railway deployment guide
2026-01-24 16:01:43 -08:00
Vignesh Natarajan
81c6ab0ec0 Docs: clarify Railway service domain 2026-01-24 16:01:00 -08:00
Vignesh Natarajan
3ea887be5a Docs: add Railway deployment guide 2026-01-24 15:58:58 -08:00
Peter Steinberger
c565de0f71 docs: add anthropic troubleshooting 2026-01-24 23:58:45 +00:00
Peter Steinberger
913d2f4b3e docs: add gateway stop/start detail 2026-01-24 23:37:18 +00:00
Peter Steinberger
8e159ab0b7 fix: follow up config.patch restarts/docs/tests (#1653)
* fix: land config.patch restarts/docs/tests (#1624) (thanks @Glucksberg)

* docs: update changelog entry for config.patch follow-up (#1653) (thanks @Glucksberg)
2026-01-24 23:33:13 +00:00
Peter Steinberger
5570e1a946 fix: polish Google Chat plugin (#1635) (thanks @iHildy)
Co-authored-by: Ian Hildebrand <ian@jedi.net>
2026-01-24 23:30:45 +00:00
iHildy
99dae0302b Revert unrelated changes to control-ui dist files 2026-01-24 23:30:45 +00:00
iHildy
c64184fcfa googlechat: implement typing indicator via message editing 2026-01-24 23:30:45 +00:00
iHildy
70e7034a1c docs(googlechat): update Tailscale setup for private dashboard and public webhook 2026-01-24 23:30:45 +00:00
iHildy
5991bed32e feat(googlechat): support Google Workspace Add-on event format 2026-01-24 23:30:45 +00:00
iHildy
0f6e39b9e8 feat: add beta googlechat channel 2026-01-24 23:30:45 +00:00
iHildy
b76cd6695d feat: add beta googlechat channel 2026-01-24 23:30:45 +00:00
Glucksberg
60661441b1 feat(gateway-tool): add config.patch action for safe partial config updates (#1624)
* fix(ui): enable save button only when config has changes

The save button in the Control UI config editor was not properly gating
on whether actual changes were made. This adds:
- `configRawOriginal` state to track the original raw config for comparison
- Change detection for both form mode (via computeDiff) and raw mode
- `hasChanges` check in canSave/canApply logic
- Set `configFormDirty` when raw mode edits occur
- Handle raw mode UI correctly (badge shows "Unsaved changes", no diff panel)

Fixes #1609

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

* feat(gateway-tool): add config.patch action for safe partial config updates

Exposes the existing config.patch server method to agents, allowing safe
partial config updates that merge with existing config instead of replacing it.

- Add config.patch to GATEWAY_ACTIONS in gateway tool
- Add restart + sentinel logic to config.patch server method
- Extend ConfigPatchParamsSchema with sessionKey, note, restartDelayMs
- Add unit test for config.patch gateway tool action

Closes #1617

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 23:30:32 +00:00
Peter Steinberger
0752ae6d6d fix: return TwiML for outbound conversation calls 2026-01-24 23:20:52 +00:00
Peter Steinberger
1b17453942 docs: highlight Ollama provider discovery 2026-01-24 23:09:53 +00:00
Peter Steinberger
ee2918c3b1 fix: preserve BlueBubbles reply tag GUIDs 2026-01-24 23:09:28 +00:00
Peter Steinberger
e5aa84ee48 docs: expand Ollama configuration examples 2026-01-24 23:05:57 +00:00
Tyler Yust
445b58550c feat(bluebubbles): improve reaction handling and inline reply tags (#1641)
* refactor: update reply formatting to use inline [[reply_to:N]] tag and normalize message IDs

* test: add unit tests for tapback text parsing in BlueBubbles webhook

* refactor: update message ID handling to use GUIDs instead of UUIDs for consistency
2026-01-24 22:42:42 +00:00
Peter Steinberger
c2d68a87f7 docs: add whatsapp group jid faq 2026-01-24 22:41:43 +00:00
Abhay
51e3d16be9 feat: Add Ollama provider with automatic model discovery (#1606)
* feat: Add Ollama provider with automatic model discovery

- Add Ollama provider builder with automatic model detection
- Discover available models from local Ollama instance via /api/tags API
- Make resolveImplicitProviders async to support dynamic model discovery
- Add comprehensive Ollama documentation with setup and usage guide
- Add tests for Ollama provider integration
- Update provider index and model providers documentation

Closes #1531

* fix: Correct Ollama provider type definitions and error handling

- Fix input property type to match ModelDefinitionConfig
- Import ModelDefinitionConfig type properly
- Fix error template literal to use String() for type safety
- Simplify return type signature of discoverOllamaModels

* fix: Suppress unhandled promise warnings from ensureClawdbotModelsJson in tests

- Cast unused promise returns to 'unknown' to suppress TypeScript warnings
- Tests that don't await the promise are intentionally not awaiting it
- This fixes the failing test suite caused by unawaited async calls

* fix: Skip Ollama model discovery during tests

- Check for VITEST or NODE_ENV=test before making HTTP requests
- Prevents test timeouts and hangs from network calls
- Ollama discovery will still work in production/normal usage

* fix: Set VITEST environment variable in test setup

- Ensures Ollama discovery is skipped in all test runs
- Prevents network calls during tests that could cause timeouts

* test: Temporarily skip Ollama provider tests to diagnose CI failures

* fix: Make Ollama provider opt-in to avoid breaking existing tests

**Root Cause:**
The Ollama provider was being added to ALL configurations by default
(with a fallback API key of 'ollama-local'), which broke tests that
expected NO providers when no API keys were configured.

**Solution:**
- Removed the default fallback API key for Ollama
- Ollama provider now requires explicit configuration via:
  - OLLAMA_API_KEY environment variable, OR
  - Ollama profile in auth store
- Updated documentation to reflect the explicit configuration requirement
- Added a test to verify Ollama is not added by default

This fixes all 4 failing test suites:
- checks (node, test, pnpm test)
- checks (bun, test, bunx vitest run)
- checks-windows (node, test, pnpm test)
- checks-macos (test, pnpm test)

Closes #1531
2026-01-24 22:38:52 +00:00
Peter Steinberger
c00cbd080d docs: add verbose installer example 2026-01-24 22:38:13 +00:00
Peter Steinberger
dd150d69c6 fix: use active auth profile for auto-compaction 2026-01-24 22:23:49 +00:00
Rodrigo Uroz
9ceac415c5 fix: auto-compact on context overflow promptError before returning error (#1627)
* fix: detect Anthropic 'Request size exceeds model context window' as context overflow

Anthropic now returns 'Request size exceeds model context window' instead of
the previously detected 'prompt is too long' format. This new error message
was not recognized by isContextOverflowError(), causing auto-compaction to
NOT trigger. Users would see the raw error twice without any recovery attempt.

Changes:
- Add 'exceeds model context window' and 'request size exceeds' to
  isContextOverflowError() detection patterns
- Add tests that fail without the fix, verifying both the raw error
  string and the JSON-wrapped format from Anthropic's API
- Add test for formatAssistantErrorText to ensure the friendly
  'Context overflow' message is shown instead of the raw error

Note: The upstream pi-ai package (@mariozechner/pi-ai) also needs a fix
in its OVERFLOW_PATTERNS regex: /exceeds the context window/i should be
changed to /exceeds.*context window/i to match both 'the' and 'model'
variants for triggering auto-compaction retry.

* fix(tests): remove unused imports and helper from test files

Remove WorkspaceBootstrapFile references and _makeFile helper that were
incorrectly copied from another test file. These caused type errors and
were unrelated to the context overflow detection tests.

* fix: trigger auto-compaction on context overflow promptError

When the LLM rejects a request with a context overflow error that surfaces
as a promptError (thrown exception rather than streamed error), the existing
auto-compaction in pi-coding-agent never triggers. This happens because the
error bypasses the agent's message_end → agent_end → _checkCompaction path.

This fix adds a fallback compaction attempt directly in the run loop:
- Detects context overflow in promptError (excluding compaction_failure)
- Calls compactEmbeddedPiSessionDirect (bypassing lane queues since already in-lane)
- Retries the prompt after successful compaction
- Limits to one compaction attempt per run to prevent infinite loops

Fixes: context overflow errors shown to user without auto-compaction attempt

* style: format compact.ts and run.ts with oxfmt

* fix: tighten context overflow match (#1627) (thanks @rodrigouroz)

---------

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 22:09:24 +00:00
Peter Steinberger
ac00065727 fix: normalize telegram fetch for long-polling 2026-01-24 21:58:42 +00:00
Peter Steinberger
30534c5c33 docs: add Bedrock EC2 role notes (#1625) (thanks @sergical) 2026-01-24 21:18:18 +00:00
Sergiy Dybskiy
97755683c7 docs: add EC2 instance role setup for Bedrock (#1625)
- Add EC2 Instance Roles section with workaround for IMDS credential detection
- Include step-by-step IAM role and instance profile setup
- Document required permissions (bedrock:InvokeModel, ListFoundationModels)
- Update example model to Claude Opus 4.5 (latest)

The AWS SDK auto-detects EC2 instance roles via IMDS, but Clawdbot's
credential detection only checks environment variables. The workaround
is to set AWS_PROFILE=default to signal credentials are available.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 21:17:21 +00:00
Peter Steinberger
a4f6b3528a fix: cover elevated ask approvals (#1636) 2026-01-24 21:12:46 +00:00
Peter Steinberger
9f8e66359e fix: default direct gateway port + docs (#1603) (thanks @ngutman) 2026-01-24 21:10:54 +00:00
Hunter Miller
8a2720db4c fix(tlon): Fix Zod v4 record() and @urbit/aura v3 API changes (#1631)
* fix(tlon): Fix Zod v4 record() and @urbit/aura v3 API changes

- Fix Zod v4.3.6 bug: single-arg z.record() fails with toJSONSchema()
  - Use two-arg form: z.record(z.string(), schema)
  - Fixes 'Cannot read properties of undefined (reading _zod)' error

- Fix @urbit/aura v3.0.0 API migration:
  - unixToDa() → da.fromUnix()
  - formatUd() → scot('ud', ...)
  - Fixes '(0 , _aura.unixToDa) is not a function' error

These were blocking Tlon plugin loading and outbound messaging.

* fix: add tlon schema/aura tests (#1631) (thanks @arthyn)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 21:09:18 +00:00
Nimrod Gutman
5330595a5a feat(macos): add direct gateway transport 2026-01-24 21:02:13 +00:00
Peter Steinberger
2c5141d7df docs: clarify beta promotion flow 2026-01-24 20:59:41 +00:00
Lucas Czekaj
483fba41b9 feat(discord): add exec approval forwarding to DMs (#1621)
* feat(discord): add exec approval forwarding to DMs

Add support for forwarding exec approval requests to Discord DMs,
allowing users to approve/deny command execution via interactive buttons.

Features:
- New DiscordExecApprovalHandler that connects to gateway and listens
  for exec.approval.requested/resolved events
- Sends DMs with embeds showing command details and 3 buttons:
  Allow once, Always allow, Deny
- Configurable via channels.discord.execApprovals with:
  - enabled: boolean
  - approvers: Discord user IDs to notify
  - agentFilter: only forward for specific agents
  - sessionFilter: only forward for matching session patterns
- Updates message embed when approval is resolved or expires

Also fixes exec completion routing: when async exec completes after
approval, the heartbeat now uses a specialized prompt to ensure the
model relays the result to the user instead of responding HEARTBEAT_OK.

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

* feat: generic exec approvals forwarding (#1621) (thanks @czekaj)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 20:56:40 +00:00
Ivan Casco
fe7436a1f6 fix(exec): only set security=full when elevated mode is full (#1616) 2026-01-24 20:55:21 +00:00
Peter Steinberger
a1ed671636 docs: add backup strategy faq 2026-01-24 20:34:05 +00:00
Peter Steinberger
8c47d226ad docs: add subscription requirement faq 2026-01-24 20:32:33 +00:00
Peter Steinberger
e1942603e9 docs: add xfinity unblock link 2026-01-24 20:30:03 +00:00
Peter Steinberger
926c2647b8 docs: mention local-only model option 2026-01-24 20:15:58 +00:00
Peter Steinberger
c427f4a2fc docs: add imessage mac requirement faq 2026-01-24 20:13:13 +00:00
Peter Steinberger
f99f9a6b64 docs: add self-update faq entry 2026-01-24 20:10:47 +00:00
Petter Blomberg
39d8c441eb fix: reduce log noise for node disconnect/late invoke errors (#1607)
* fix: reduce log noise for node disconnect/late invoke errors

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

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

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

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

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

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

---------

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

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

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

Fixes: #1561

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

* ci: sync docker-release workflow updates

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

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

* Remove submodule checkout from docker-release.yml

Removed submodule checkout step from Docker release workflow.

* Simplify Docker release workflow by removing submodule checkout

Removed submodule checkout step from Docker release workflow.

---------

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

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

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

## AI-Assisted
This change was drafted with help from my Clawdbot instance (Clawd 🦞). 
We tested the behavior together before submitting.
2026-01-24 13:12:16 +00:00
Peter Steinberger
a72d7a9f36 docs: add vps install faq 2026-01-24 13:11:31 +00:00
Peter Steinberger
67e57e7c99 docs: add beta vs dev install faq 2026-01-24 13:11:31 +00:00
Peter Steinberger
4c98d6c121 docs: add latest version faq 2026-01-24 13:11:31 +00:00
Peter Steinberger
174a1cb68a docs: clarify mac mini + imessage ssh 2026-01-24 13:11:31 +00:00
Peter Steinberger
a4d56bd06e docs: add mac mini faq 2026-01-24 13:11:31 +00:00
Peter Steinberger
c9e98376b3 chore: update appcast for 2026.1.23 2026-01-24 13:03:00 +00:00
Peter Steinberger
62c9255b6a fix: harden outbound mirroring normalization 2026-01-24 12:57:58 +00:00
Peter Steinberger
8b4e40c602 build: refresh control-ui dist + release docs 2026-01-24 12:51:32 +00:00
Peter Steinberger
6a9d7f7a01 docs: clarify node host sizing 2026-01-24 12:50:22 +00:00
Peter Steinberger
39d8e9be0f docs: add node vs ssh faq 2026-01-24 12:48:29 +00:00
hsrvc
ac45c8b404 fix: preserve Telegram topic (message_thread_id) in sub-agent announcements
When native slash commands are executed in Telegram topics/forums, the
originating topic context was not being preserved. This caused sub-agent
announcements to be delivered to the wrong topic.

Root cause: Native slash command context did not set OriginatingChannel
and OriginatingTo, causing session delivery context to fallback to the
user's personal ID instead of the group ID + topic.

Fix: Added OriginatingChannel and OriginatingTo to native slash command
context, ensuring topic information is preserved for sub-agent announcements.

Related session fields:
- lastThreadId: preserved via MessageThreadId
- lastTo: now correctly set to group ID via OriginatingTo
- deliveryContext: includes threadId for proper routing
2026-01-24 12:26:29 +00:00
Peter Steinberger
fa746b05de fix: preserve agent id casing 2026-01-24 12:23:44 +00:00
Peter Steinberger
49c518951c fix: align bluebubbles outbound group sessions 2026-01-24 12:23:26 +00:00
Peter Steinberger
0dca8acbe2 docs: reorder 2026.1.23 changelog 2026-01-24 12:10:59 +00:00
Peter Steinberger
c42e9b1d19 fix: log discord deploy error details 2026-01-24 12:10:59 +00:00
Peter Steinberger
298901208d fix: align agent id normalization 2026-01-24 12:10:08 +00:00
Peter Steinberger
ef9ba66798 chore: tune fly deployment defaults 2026-01-24 11:58:25 +00:00
Peter Steinberger
4b6cdd1d3c fix: normalize session keys and outbound mirroring 2026-01-24 11:57:11 +00:00
Peter Steinberger
eaeb52f70a chore: update protocol artifacts 2026-01-24 11:28:24 +00:00
Luke
be1cdc9370 fix(agents): treat provider request-aborted as timeout for fallback (#1576)
* fix(agents): treat request-aborted as timeout for fallback

* test(e2e): add provider timeout fallback
2026-01-24 11:27:24 +00:00
Peter Steinberger
8002143d92 fix: guard cli session update 2026-01-24 11:21:34 +00:00
Peter Steinberger
4a9123d415 chore: suppress remaining deprecation warnings 2026-01-24 11:16:46 +00:00
Peter Steinberger
dbf139d14e test: cover explicit mention gating across channels 2026-01-24 11:09:33 +00:00
Peter Steinberger
d905ca0e02 fix: enforce explicit mention gating across channels 2026-01-24 11:09:33 +00:00
Peter Steinberger
ab000398be fix: resolve session ids in session tools 2026-01-24 11:09:11 +00:00
Peter Steinberger
1bbbb10abf fix: persist session usage metadata on suppressed replies 2026-01-24 11:05:02 +00:00
Peter Steinberger
c02204fd1e chore: update fly config defaults 2026-01-24 10:58:55 +00:00
Peter Steinberger
5482803547 chore: filter noisy warnings 2026-01-24 10:48:33 +00:00
Peter Steinberger
3dcaa70531 chore: update deps and test timeout 2026-01-24 10:30:30 +00:00
Peter Steinberger
a6ddd82a14 feat: add TTS hint to system prompt 2026-01-24 10:25:42 +00:00
Peter Steinberger
585e20b72e docs: fix redirects and help links 2026-01-24 10:21:05 +00:00
Peter Steinberger
d8a6317dfc fix: show voice mode in status 2026-01-24 10:03:19 +00:00
Peter Steinberger
c8c58c0537 fix: avoid Discord /tts conflict 2026-01-24 09:58:06 +00:00
Peter Steinberger
cfdd5a8c2e docs: consolidate faq under help 2026-01-24 09:49:38 +00:00
Peter Steinberger
6765fd15eb feat: default TTS model overrides on (#1559) (thanks @Glucksberg)
Co-authored-by: Glucksberg <80581902+Glucksberg@users.noreply.github.com>
2026-01-24 09:42:32 +00:00
Peter Steinberger
4074fa0471 docs: restore faq and fix redirect 2026-01-24 09:39:24 +00:00
Peter Steinberger
ea2ccd8ae6 docs(fly): update guide with deployment lessons
- Increase recommended memory to 2GB (512MB/1GB OOM)
- Add OOM symptoms (SIGABRT, v8 allocation errors)
- Fix lock file path (/data/gateway.*.lock)
- Add complete config example with failover, auth, bindings
- Document Discord token from env var vs config
- Add machine update commands for command/memory changes
- Add config writing tips (echo+tee, sftp caveats)

Learned from FLAWD deployment debugging.
2026-01-24 09:36:54 +00:00
Peter Steinberger
b1ac7e0501 docs: move cross-context faq to troubleshooting 2026-01-24 09:36:44 +00:00
Peter Steinberger
b4a2dc81a2 docs: expand heartbeat visibility config examples 2026-01-24 09:31:04 +00:00
Peter Steinberger
d73e8ecca3 fix: document tools invoke + honor main session key (#1575) (thanks @vignesh07) 2026-01-24 09:29:32 +00:00
Vignesh Natarajan
faa90fc206 docs(lobster): document clawd.invoke tool allowlisting 2026-01-24 09:29:32 +00:00
Vignesh Natarajan
f1083cd52c gateway: add /tools/invoke HTTP endpoint 2026-01-24 09:29:32 +00:00
Peter Steinberger
7f7550e53c docs: add cross-context messaging faq 2026-01-24 09:28:59 +00:00
Peter Steinberger
d4d17025cf docs: add oauth refresh troubleshooting 2026-01-24 09:21:15 +00:00
Peter Steinberger
7b76db2841 fix: document heartbeat visibility controls (#1452) (thanks @dlauer) 2026-01-24 09:07:03 +00:00
Dave Lauer
f9cf508cff feat(heartbeat): add configurable visibility for heartbeat responses
Add per-channel and per-account heartbeat visibility settings:
- showOk: hide/show HEARTBEAT_OK messages (default: false)
- showAlerts: hide/show alert messages (default: true)
- useIndicator: emit typing indicator events (default: true)

Config precedence: per-account > per-channel > channel-defaults > global

This allows silencing routine heartbeat acks while still surfacing
alerts when something needs attention.
2026-01-24 09:07:03 +00:00
Peter Steinberger
9b12275fe1 fix(hooks): emit message_received metadata 2026-01-24 08:56:16 +00:00
Peter Steinberger
f70ac0c7c2 fix: harden discord rate-limit handling 2026-01-24 08:43:28 +00:00
Peter Steinberger
09a72f1ede docs: changelog msteams probe (#1574) (thanks @Evizero) 2026-01-24 08:35:10 +00:00
Christof
2b8b3c4b10 fix(msteams): remove remaining /.default postfix (#1574)
This fixes the msteams probe which otherwise incorrectly assumes teams is not working.

The @microsoft/agents-hosting SDK's MsalTokenProvider automatically appends /.default to all scope strings in its token acquisition methods (acquireAccessTokenViaSecret, acquireAccessTokenViaFIC, acquireAccessTokenViaWID, acquireTokenWithCertificate in msalTokenProvider.ts). This is consistent SDK behavior, not a recent change.

The current code is including .default in scope URLs, resulting in invalid double suffixes like https://graph.microsoft.com/.default/.default. I am not sure how the .default postfixed worked in the past for you if I am honest.

This was confirmed to cause Graph API authentication errors. Removing the .default suffix from our scope strings allows the SDK to append it correctly, resolving the issue. I confirmed it manually on my teams setup

Before: we pass .default -> SDK appends -> double .default (broken)
After: we pass base URL -> SDK appends -> single .default (works)

Co-authored-by: Christof Salis <c.salis@vertifymed.com>
2026-01-24 08:30:34 +00:00
Peter Steinberger
8ea8801d06 fix: show tool error fallback for tool-only replies 2026-01-24 08:17:50 +00:00
Peter Steinberger
c97bf23a4a fix: gate openai reasoning downgrade on model switches (#1562) (thanks @roshanasingh4) 2026-01-24 08:16:42 +00:00
Peter Steinberger
3fff943ba1 fix: harden gateway lock validation (#1572) (thanks @steipete) 2026-01-24 08:15:07 +00:00
Peter Steinberger
90685ef814 docs(fly): comprehensive deployment guide with real-world learnings
Based on actual Flawd deployment experience:
- Proper fly.toml configuration with all required settings
- Step-by-step guide following exe.dev doc format
- Troubleshooting section with common issues and fixes
- Config file creation via SSH
- Cost estimates
2026-01-24 08:15:07 +00:00
Peter Steinberger
a8f2ac5411 docs(fly): add configuration guidance for bind mode, memory, and troubleshooting 2026-01-24 08:15:07 +00:00
Peter Steinberger
dea96a2c3d fix: handle PID recycling in container gateway lock
In containers, PIDs can be recycled quickly after restarts. When a container
restarts, a different process might get the same PID as the previous gateway,
causing the lock check to incorrectly think the old gateway is still running.

This fix adds isGatewayProcess() which verifies on Linux that the PID actually
belongs to a clawdbot gateway by checking /proc/PID/cmdline. If the cmdline
doesn't contain 'clawdbot' or 'gateway', we assume the lock is stale.

Fixes gateway boot-loop in Docker/Fly.io deployments.
2026-01-24 08:15:07 +00:00
Peter Steinberger
90ae2f541c feat: add Fly.io deployment support
- Add fly.toml configuration for Fly.io deployment
- Add docs/platforms/fly.md with deployment guide
- Uses London (lhr) region by default
- Includes persistent volume for data storage
2026-01-24 08:15:07 +00:00
Peter Steinberger
d9a467fe3b feat: move TTS into core (#1559) (thanks @Glucksberg) 2026-01-24 08:00:44 +00:00
Glucksberg
aef88cd9f1 test(telegram-tts): add unit tests for summarizeText function
- Export summarizeText in _test for testing
- Add 8 tests covering:
  - Successful summarization with metrics
  - OpenAI API call parameters verification
  - targetLength validation (min/max boundaries)
  - Error handling (API failures, empty responses)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 08:00:44 +00:00
Glucksberg
104d977d12 feat(telegram-tts): add latency logging, status tracking, and unit tests
- Add latency metrics to summarizeText and textToSpeech functions
- Add /tts_status command showing config and last attempt result
- Add /tts_summary command for feature flag control
- Fix atomic write to clean up temp file on rename failure
- Add timer.unref() to prevent blocking process shutdown
- Add unit tests for validation functions (13 tests)
- Update README with new commands and features

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 08:00:44 +00:00
Glucksberg
4b24753be7 feat(telegram-tts): add /tts_limit command and auto-summarization
- Add /tts_limit command to configure max text length (default 1500)
- Auto-summarize long texts with gpt-4o-mini before TTS conversion
- Add truncation safeguard if summary exceeds hard limit
- Validate targetLength parameter (100-10000)
- Use conservative max_tokens for multilingual text
- Add prompt injection defense with XML delimiters

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 08:00:44 +00:00
Glucksberg
df09e583aa feat(telegram-tts): add auto-TTS hook and provider switching
- Integrate message_sending hook into Telegram delivery path
- Send text first, then audio as voice message after
- Add /tts_provider command to switch between OpenAI and ElevenLabs
- Implement automatic fallback when primary provider fails
- Use gpt-4o-mini-tts as default OpenAI model
- Add hook integration to route-reply.ts for other channels

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 08:00:44 +00:00
Glucksberg
46e6546bb9 feat(telegram-tts): make extension self-contained with direct API calls
- Remove sag CLI dependency
- Add direct ElevenLabs API integration via fetch
- Add OpenAI TTS as alternative provider
- Support multi-provider configuration
- Add tts.providers RPC method
- Update config schema with OpenAI options
- Bump version to 0.2.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 08:00:44 +00:00
Glucksberg
5428c97685 feat(extensions): add telegram-tts extension for voice responses
Add a new extension that provides automatic text-to-speech for chat
responses using ElevenLabs API.

Features:
- `speak` tool for converting text to voice messages
- RPC methods: tts.status, tts.enable, tts.disable, tts.convert
- User preferences file for persistent TTS state
- Configurable voice ID, model, and max text length

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 08:00:44 +00:00
Roshan Singh
202d7af855 Fix OpenAI Responses transcript after model switch 2026-01-24 07:58:25 +00:00
Bradley Priest
72020b37c3 fix(bird skill): gate brew install to macOS (#1569)
* fix(bird skill): gate brew install to macOS

* fix: gate bird brew install to macOS (#1569) (thanks @bradleypriest)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 07:53:29 +00:00
Peter Steinberger
b051621bd4 fix: update changelog + clawtributors (#1571) (thanks @Takhoffman) 2026-01-24 07:47:35 +00:00
Tak hoffman
ff52aec38e Agents: drop bash tool alias 2026-01-24 07:44:04 +00:00
Peter Steinberger
15620b1092 fix: guard tool allowlists with warnings 2026-01-24 07:38:42 +00:00
Peter Steinberger
ad7fc4964a fix: gate TUI lifecycle updates to active run (#1567) (thanks @vignesh07) 2026-01-24 07:23:41 +00:00
Tak Hoffman
8f4426052c CLI: fix Windows node argv stripping (#1564)
Co-authored-by: Tak hoffman <takayukihoffman@gmail.com>
2026-01-24 07:10:40 +00:00
Peter Steinberger
6a60d47c53 fix: cover slack open policy gating (#1563) (thanks @itsjaydesu) 2026-01-24 07:09:26 +00:00
Peter Steinberger
b1482957f5 feat: add cron time context 2026-01-24 07:08:33 +00:00
Jay Winder
4d2e9e8113 fix(slack): apply open policy consistently to slash commands
Address reviewer feedback: slash commands now use the same
hasExplicitConfig check as regular messages, so unlisted
channels are allowed under groupPolicy: "open" for both
message handling and slash commands.
2026-01-24 07:05:55 +00:00
Jay Winder
72d62a54c6 fix: groupPolicy: "open" ignored when channel-specific config exists
## Summary

Fix Slack `groupPolicy: "open"` to allow unlisted channels even when `channels.slack.channels` contains custom entries.

## Problem

When `groupPolicy` is set to `"open"`, the bot should respond in **any channel** it's invited to. However, if `channels.slack.channels` contains *any* entries—even just one channel with a custom system prompt—the open policy is ignored. Only explicitly listed channels receive responses; all others get an ephemeral "This channel is not allowed" error.

### Example config

```json
{
  "channels": {
    "slack": {
      "groupPolicy": "open",
      "channels": {
        "C0123456789": { "systemPrompt": "Custom prompt for this channel" }
      }
    }
  }
}
```

With this config, the bot only responds in `C0123456789`. Messages in any other channel are blocked—even though the policy is `"open"`.

## Root Cause

In `src/slack/monitor/context.ts`, `isChannelAllowed()` has two sequential checks:

1. `isSlackChannelAllowedByPolicy()` — correctly returns `true` for open policy
2. A secondary `!channelAllowed` check — was blocking channels when `resolveSlackChannelConfig()` returned `{ allowed: false }` for unlisted channels

The second check conflated "channel not in config" with "channel explicitly denied."

## Fix

Use `matchSource` to distinguish explicit denial from absence of config:

```ts
const hasExplicitConfig = Boolean(channelConfig?.matchSource);
if (!channelAllowed && (params.groupPolicy !== "open" || hasExplicitConfig)) {
  return false;
}
```

When `matchSource` is undefined, the channel has no explicit config entry and should be allowed under open policy.

## Behavior After Fix

| Scenario | Result |
|----------|--------|
| `groupPolicy: "open"`, channel unlisted |  Allowed |
| `groupPolicy: "open"`, channel explicitly denied (`allow: false`) |  Blocked |
| `groupPolicy: "open"`, channel with custom config |  Allowed |
| `groupPolicy: "allowlist"`, channel unlisted |  Blocked |

## Test Plan

- [x] Open policy + unlisted channel → allowed
- [x] Open policy + explicitly denied channel → blocked
- [x] Allowlist policy + unlisted channel → blocked
- [x] Allowlist policy + listed channel → allowed
2026-01-24 07:05:55 +00:00
Peter Steinberger
ae48066d28 fix: track TUI agent events for external runs (#1567) (thanks @vignesh07) 2026-01-24 07:00:01 +00:00
Vignesh Natarajan
f56f799990 tui: filter agent events by active chat run id
Agent events are emitted per run; filter against activeChatRunId instead of session id. Adds unit tests for tool + lifecycle events.
2026-01-24 07:00:01 +00:00
Andrii
7e498ab94a anthropic-payload-log mvp
Added a dedicated Anthropic payload logger that writes exact request
JSON (as sent) plus per‑run usage stats (input/output/cache read/write)
to a
  standalone JSONL file, gated by an env flag.

  Changes

  - New logger: src/agents/anthropic-payload-log.ts (writes
logs/anthropic-payload.jsonl under the state dir, optional override via
env).
  - Hooked into embedded runs to wrap the stream function and record
usage: src/agents/pi-embedded-runner/run/attempt.ts.

  How to enable

  - CLAWDBOT_ANTHROPIC_PAYLOAD_LOG=1
  - Optional:
CLAWDBOT_ANTHROPIC_PAYLOAD_LOG_FILE=/path/to/anthropic-payload.jsonl

  What you’ll get (JSONL)

  - stage: "request" with payload (exact Anthropic params) +
payloadDigest
  - stage: "usage" with usage
(input/output/cacheRead/cacheWrite/totalTokens/etc.)

  Notes

  - Usage is taken from the last assistant message in the run; if the
run fails before usage is present, you’ll only see an error field.

  Files touched

  - src/agents/anthropic-payload-log.ts
  - src/agents/pi-embedded-runner/run/attempt.ts

  Tests not run.
2026-01-24 06:43:51 +00:00
Glucksberg
6bd6ae41b1 fix: address code review findings for plugin commands
- Add registry lock during command execution to prevent race conditions
- Add input sanitization for command arguments (defense in depth)
- Validate handler is a function during registration
- Remove redundant case-insensitive regex flag
- Add success logging for command execution
- Simplify handler return type (always returns result now)
- Remove dead code branch in commands-plugin.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:28:22 +00:00
Glucksberg
f648aae440 fix: clear plugin commands on reload to prevent duplicates
Add clearPluginCommands() call in loadClawdbotPlugins() to ensure
previously registered commands are cleaned up before reloading plugins.
This prevents command conflicts during hot-reload scenarios.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:28:22 +00:00
Glucksberg
b56587f26e fix: address code review findings for plugin command API
Blockers fixed:
- Fix documentation: requireAuth defaults to true (not false)
- Add command name validation (must start with letter, alphanumeric only)
- Add reserved commands list to prevent shadowing built-in commands
- Emit diagnostic errors for invalid/duplicate command registration

Other improvements:
- Return user-friendly message for unauthorized commands (instead of silence)
- Sanitize error messages to avoid leaking internal details
- Document acceptsArgs behavior when arguments are provided
- Add notes about reserved commands and validation rules to docs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:28:22 +00:00
Glucksberg
4ee808dbcb feat: add plugin command API for LLM-free auto-reply commands
This adds a new `api.registerCommand()` method to the plugin API, allowing
plugins to register slash commands that execute without invoking the AI agent.

Features:
- Plugin commands are processed before built-in commands and the agent
- Commands can optionally require authorization
- Commands can accept arguments
- Async handlers are supported

Use case: plugins can implement toggle commands (like /tts_on, /tts_off)
that respond immediately without consuming LLM API calls.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:28:22 +00:00
Peter Steinberger
66eec295b8 perf: stabilize system prompt time 2026-01-24 06:24:04 +00:00
Peter Steinberger
675019cb6f fix: trigger fallback on auth profile exhaustion 2026-01-24 06:14:23 +00:00
Peter Steinberger
795b592286 fix: sync protocol swift models 2026-01-24 06:01:19 +00:00
Peter Steinberger
9d98e55ed5 fix: enforce group tool policy inheritance for subagents (#1557) (thanks @adam91holt) 2026-01-24 05:49:39 +00:00
Adam Holt
c07949a99c Channels: add per-group tool policies 2026-01-24 05:49:39 +00:00
Peter Steinberger
e51bf46abe fix: regenerate protocol swift models 2026-01-24 05:41:00 +00:00
Peter Steinberger
eba0625a70 fix: ignore identity template placeholders 2026-01-24 05:35:50 +00:00
Peter Steinberger
886752217d fix: gate diagnostic logs behind verbose 2026-01-24 05:06:42 +00:00
Peter Steinberger
5662a9cdfc fix: honor tools.exec ask/security in approvals 2026-01-24 04:53:44 +00:00
Peter Steinberger
fd23b9b209 fix: normalize outbound media payloads 2026-01-24 04:53:34 +00:00
Peter Steinberger
975f5a5284 fix: guard session store against array corruption 2026-01-24 04:51:46 +00:00
Peter Steinberger
63176ccb8a test: isolate heartbeat runner workspace in tests 2026-01-24 04:48:01 +00:00
Peter Steinberger
6c3a9fc092 fix: handle extension relay session reuse 2026-01-24 04:41:28 +00:00
Peter Steinberger
d9f173a03d test: stabilize service-env path tests on windows 2026-01-24 04:36:52 +00:00
Peter Steinberger
c3cb26f7ca feat: add node browser proxy routing 2026-01-24 04:21:47 +00:00
JustYannicc
dd06028827 feat(heartbeat): skip API calls when HEARTBEAT.md is effectively empty (#1535)
* feat: skip heartbeat API calls when HEARTBEAT.md is effectively empty

- Added isHeartbeatContentEffectivelyEmpty() to detect files with only headers/comments
- Modified runHeartbeatOnce() to check HEARTBEAT.md content before polling the LLM
- Returns early with 'empty-heartbeat-file' reason when no actionable tasks exist
- Preserves existing behavior when file is missing (lets LLM decide)
- Added comprehensive test coverage for empty file detection
- Saves API calls/costs when heartbeat file has no meaningful content

* chore: update HEARTBEAT.md template to be effectively empty by default

Changed instruction text to comment format so new workspaces benefit from
heartbeat optimization immediately. Users still get clear guidance on usage.

* fix: only treat markdown headers (# followed by space) as comments, not #TODO etc

* refactor: simplify regex per code review suggestion

* docs: clarify heartbeat empty file behavior (#1535) (thanks @JustYannicc)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 04:19:01 +00:00
Peter Steinberger
71203829d8 feat: add system cli 2026-01-24 04:03:07 +00:00
Peter Steinberger
dfa80e1e5d fix(ui): align control ui chat and config rendering 2026-01-24 03:55:43 +00:00
Peter Steinberger
951a4ea065 fix: anchor MEDIA tag parsing 2026-01-24 03:46:27 +00:00
Peter Steinberger
4fa1517e6d docs: add channels list usage troubleshooting 2026-01-24 03:44:44 +00:00
Peter Steinberger
de2d986008 fix: render Telegram media captions 2026-01-24 03:39:25 +00:00
Peter Steinberger
d57cb2e1a8 fix(ui): cache control ui markdown 2026-01-24 03:27:28 +00:00
Peter Steinberger
b697374ce5 fix: update docker gateway command 2026-01-24 03:24:28 +00:00
Peter Steinberger
b9106ba5f9 fix: guard console settings recursion (#1555) (thanks @travisp) 2026-01-24 03:15:05 +00:00
Travis
3ba9821254 Logging: guard console settings recursion 2026-01-24 03:12:40 +00:00
Peter Steinberger
17f2a990a8 docs: add changelog entry for memory slot none (#1554) (thanks @andreabadesso) 2026-01-24 03:11:31 +00:00
André Abadesso
71f7bd1cfd test: add tests for normalizePluginsConfig memory slot handling 2026-01-24 03:08:27 +00:00
André Abadesso
c4c01089ab fix: respect "none" value for plugins.slots.memory 2026-01-24 03:08:27 +00:00
Peter Steinberger
b6591c3f69 fix: add log hint for agent failure (#1550) (thanks @sweepies) 2026-01-24 02:56:38 +00:00
google-labs-jules[bot]
e6fdbae79b Fix formatting of 'Agent failed before reply' error messages
- Remove hardcoded period after error message to avoid double periods
- Move 'Check gateway logs for details' to a new line for better readability
2026-01-24 02:54:42 +00:00
Peter Steinberger
a4e57d3ac4 fix: align service path tests with platform delimiters 2026-01-24 02:34:54 +00:00
Peter Steinberger
1d862cf5c2 fix: add readability fallback extraction 2026-01-24 02:15:13 +00:00
Peter Steinberger
0840029982 fix: stabilize embedded runner queueing 2026-01-24 02:05:41 +00:00
Peter Steinberger
309fcc5321 fix: publish llm-task docs and harden tool 2026-01-24 01:44:51 +00:00
Peter Steinberger
00ae21bed2 fix: inline auth probe errors in status table 2026-01-24 01:37:08 +00:00
Peter Steinberger
00fd57b8f5 fix: honor wildcard tool allowlists 2026-01-24 01:30:44 +00:00
Peter Steinberger
aabe0bed30 fix: clean wrapped banner tagline 2026-01-24 01:26:17 +00:00
Peter Steinberger
350131b4d7 fix: improve web image optimization 2026-01-24 01:18:58 +00:00
Vignesh
95d45c0aa7 feat: add optional llm-task JSON-only tool (#1498)
* feat(llm-task): add optional JSON-only LLM task tool

* fix(llm-task): fix invalid package.json

* fix(llm-task): fix invalid plugin manifest JSON

* fix(llm-task): fix index.ts import quoting

* fix(llm-task): load embedded runner from src or bundled dist
2026-01-24 01:18:47 +00:00
Peter Steinberger
cb06e133ca docs: update bedrock discovery changelog ref (#1553) (thanks @fal3) 2026-01-24 01:18:33 +00:00
Peter Steinberger
4e77483051 fix: refine bedrock discovery defaults (#1543) (thanks @fal3) 2026-01-24 01:18:33 +00:00
Peter Steinberger
81535d512a fix: clarify auth order exclusions 2026-01-24 01:18:03 +00:00
Alex Fallah
8effb557d5 feat: add dynamic Bedrock model discovery
Add automatic discovery of AWS Bedrock models using ListFoundationModels API.
When AWS credentials are detected, models that support streaming and text output
are automatically discovered and made available.

- Add @aws-sdk/client-bedrock dependency
- Add discoverBedrockModels() with caching (default 1 hour)
- Add resolveImplicitBedrockProvider() for auto-registration
- Add BedrockDiscoveryConfig for optional filtering by provider/region
- Filter to active, streaming, text-output models only
- Update docs/bedrock.md with auto-discovery documentation
2026-01-24 01:15:06 +00:00
Peter Steinberger
c66b1fd18b docs: add changelog entry for sidebar fix (#1515) (thanks @pookNast) 2026-01-24 01:00:19 +00:00
pookNast
c04f8ba1ea fix(ui): Make sidebar sticky while scrolling content (#1515)
The left navigation sidebar now stays fixed when scrolling through
long content pages like /skills. Changed .shell from min-height to
fixed height with overflow: hidden, allowing nav and content to
scroll independently within their grid cells.

Co-authored-by: pookNast <pook@nast.local>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 00:58:37 +00:00
Peter Steinberger
c1b7f6b6ba Merge pull request #1544 from wca4a/feature/add-tlon-plugin
Add Tlon/Urbit channel plugin
2026-01-24 00:57:44 +00:00
Peter Steinberger
e4708b3b99 test: relax tailscale binary expectations 2026-01-24 00:49:04 +00:00
Peter Steinberger
f938f6617b docs: extend cron vs heartbeat guide 2026-01-24 00:42:56 +00:00
justyannicc
e882f7d207 docs: add cron vs heartbeat decision guide
- New docs/automation/cron-vs-heartbeat.md with complete guidance
- Cross-links from heartbeat.md and cron-jobs.md
- Updated AGENTS.md template with practical guidance
- Added navigation entry in docs.json
2026-01-24 00:42:56 +00:00
AJ (@techfren)
e38fd8603f docs: remove misplaced Google Docs Editor from showcase (#1547)
- Was incorrectly placed in Voice & Phone section
- Not a Clawdbot project (Claude Code skill)
- No valid link available
2026-01-24 00:42:48 +00:00
Travis
89283aa788 Plugins: move clawdbot to devDependencies + add zod 2026-01-24 00:42:33 +00:00
Peter Steinberger
f7dc27f2d0 fix: move probe errors below table 2026-01-24 00:32:49 +00:00
google-labs-jules[bot]
ed560e466f fix(doctor): align sandbox image check with main logic
Updated `dockerImageExists` in `src/commands/doctor-sandbox.ts` to mirror the logic in `src/agents/sandbox/docker.ts`. It now re-throws errors unless they are explicitly "No such image" errors.
2026-01-24 00:30:24 +00:00
google-labs-jules[bot]
b5f1dc9d95 chore(tests): remove reproduction test
Removed the test file `src/agents/sandbox/docker.test.ts` as requested in code review.
2026-01-24 00:30:24 +00:00
google-labs-jules[bot]
f58ad7625f fix(sandbox): simplify docker image check
Simplify the stderr check in `dockerImageExists` to only look for "No such image", as requested in code review.
2026-01-24 00:30:24 +00:00
google-labs-jules[bot]
49c6d8019f fix(sandbox): improve docker image existence check error handling
Previously, `dockerImageExists` assumed any error from `docker image inspect` meant the image did not exist. This masked other errors like socket permission issues.

This change:
- Modifies `dockerImageExists` to inspect stderr when the exit code is non-zero.
- Returns `false` only if the error explicitly indicates "No such image" or "No such object".
- Throws an error with the stderr content for all other failures.
- Adds a reproduction test in `src/agents/sandbox/docker.test.ts`.
2026-01-24 00:30:24 +00:00
Peter Steinberger
86db180a17 docs: clarify PR merge preference 2026-01-24 00:30:11 +00:00
Peter Steinberger
c69111a4e6 Merge branch 'main' into feature/add-tlon-plugin 2026-01-24 00:27:24 +00:00
Peter Steinberger
31e59cd583 fix: hide probe logs without verbose 2026-01-24 00:27:05 +00:00
Peter Steinberger
d2bfcd70e7 fix: stabilize tests and sync protocol models 2026-01-24 00:25:58 +00:00
Peter Steinberger
12d22e1c89 chore: update clawtributors 2026-01-24 00:25:39 +00:00
Peter Steinberger
75cb78a5b1 chore: drop tlon node_modules 2026-01-24 00:25:39 +00:00
Peter Steinberger
791b568f78 feat: add tlon channel plugin 2026-01-24 00:25:39 +00:00
william arzt
d46642319b Add Tlon/Urbit channel plugin
Adds built-in Tlon (Urbit) channel plugin to support decentralized messaging on the Urbit network.

Features:
- DM and group chat support
- SSE-based real-time message monitoring
- Auto-discovery of group channels
- Thread replies and reactions
- Integration with Urbit's HTTP API

This resolves cron delivery issues with external Tlon plugins by making it a first-class built-in channel alongside Telegram, Signal, and other messaging platforms.

Implementation includes:
- Plugin registration via ClawdbotPluginApi
- Outbound delivery with sendText and sendMedia
- Gateway adapter for inbound message handling
- Urbit SSE client for event streaming
- Core bridge for Clawdbot runtime integration

Co-authored-by: William Arzt <william@arzt.co>
2026-01-24 00:25:38 +00:00
Peter Steinberger
a96d37ca69 docs: clarify plugin dependency rules 2026-01-24 00:23:21 +00:00
Peter Steinberger
f8046268bc chore: drop tlon node_modules 2026-01-24 00:18:58 +00:00
Peter Steinberger
9cdd0c28be feat: add tlon channel plugin 2026-01-24 00:17:58 +00:00
Peter Steinberger
05b0b82937 fix: guard tailscale sudo fallback (#1551) (thanks @sweepies) 2026-01-24 00:17:20 +00:00
google-labs-jules[bot]
908d9331af feat: use sudo fallback for tailscale configuration commands
To avoid permission denied errors when modifying Tailscale configuration (serve/funnel),
we now attempt the command directly first. If it fails, we catch the error and retry
with `sudo -n`. This preserves existing behavior for users where it works, but
attempts to escalate privileges (non-interactively) if needed.

- Added `execWithSudoFallback` helper in `src/infra/tailscale.ts`.
- Updated `ensureFunnel`, `enableTailscaleServe`, `disableTailscaleServe`,
  `enableTailscaleFunnel`, and `disableTailscaleFunnel` to use the fallback helper.
- Added tests in `src/infra/tailscale.test.ts` to verify fallback behavior.
2026-01-24 00:17:20 +00:00
google-labs-jules[bot]
29f0463f65 feat: use sudo for tailscale configuration commands
To avoid permission denied errors when modifying Tailscale configuration (serve/funnel),
we now prepend `sudo -n` to these commands. This ensures that if the user has appropriate
sudo privileges (specifically passwordless for these commands or generally), the operation
succeeds. If sudo fails (e.g. requires password non-interactively), it will throw an error
which is caught and logged as a warning, preserving existing behavior but attempting to escalate privileges first.

- Updated `ensureFunnel` to use `sudo -n` for the enabling step.
- Updated `enableTailscaleServe`, `disableTailscaleServe`, `enableTailscaleFunnel`, `disableTailscaleFunnel` to use `sudo -n`.
2026-01-24 00:17:20 +00:00
google-labs-jules[bot]
66f353fe7a feat: use sudo for tailscale configuration commands
To avoid permission denied errors when modifying Tailscale configuration (serve/funnel),
we now prepend `sudo -n` to these commands. This ensures that if the user has appropriate
sudo privileges (specifically passwordless for these commands or generally), the operation
succeeds. If sudo fails (e.g. requires password non-interactively), it will throw an error
which is caught and logged as a warning, preserving existing behavior but attempting to escalate privileges first.

- Updated `ensureFunnel` to use `sudo -n` for the enabling step.
- Updated `enableTailscaleServe`, `disableTailscaleServe`, `enableTailscaleFunnel`, `disableTailscaleFunnel` to use `sudo -n`.
- Added tests in `src/infra/tailscale.test.ts` to verify `sudo` usage.
2026-01-24 00:17:20 +00:00
Robby
511a0c22b7 fix(sessions): reset token counts to 0 on /new (#1523)
- Set inputTokens, outputTokens, totalTokens to 0 in sessions.reset
- Clear TUI sessionInfo tokens immediately before async reset
- Prevents stale token display after session reset

Fixes #1523
2026-01-24 00:15:42 +00:00
Peter Steinberger
da3f2b4898 fix: table auth probe output 2026-01-24 00:11:04 +00:00
Peter Steinberger
438e782f81 fix: silence probe timeouts 2026-01-24 00:11:04 +00:00
Peter Steinberger
d354030974 docs: changelog for MS Teams scopes (#1507) (thanks @Evizero) 2026-01-24 00:08:10 +00:00
Christof
ef777d6bb6 fix(msteams): remove .default suffix from graph scopes (#1507)
The @microsoft/agents-hosting SDK's MsalTokenProvider automatically
appends `/.default` to all scope strings in its token acquisition
methods (acquireAccessTokenViaSecret, acquireAccessTokenViaFIC,
acquireAccessTokenViaWID, acquireTokenWithCertificate in
msalTokenProvider.ts). This is consistent SDK behavior, not a recent
change.

Our code was including `.default` in scope URLs, resulting in invalid
double suffixes like `https://graph.microsoft.com/.default/.default`.

This was confirmed to cause Graph API authentication errors. Removing
the `.default` suffix from our scope strings allows the SDK to append
it correctly, resolving the issue.

Before: we pass `.default` -> SDK appends -> double `.default` (broken)
After:  we pass base URL  -> SDK appends -> single `.default` (works)

Co-authored-by: Christof Salis <c.salis@vertifymed.com>
2026-01-24 00:07:22 +00:00
Peter Steinberger
b9c35d9fdc docs: add Comcast SSL troubleshooting note 2026-01-24 00:01:20 +00:00
Peter Steinberger
69f645c662 fix: auto-save voice wake words across apps 2026-01-23 23:59:08 +00:00
Peter Steinberger
efec5fc751 docs: remove channel unify checklist 2026-01-23 23:37:04 +00:00
Peter Steinberger
bf4544784a fix: stabilize typing + summary merge 2026-01-23 23:34:30 +00:00
Peter Steinberger
c9a7c77b24 test: cover typing and history helpers 2026-01-23 23:34:30 +00:00
Peter Steinberger
aeb6b2ffad refactor: standardize channel logging 2026-01-23 23:34:30 +00:00
Peter Steinberger
07ce1d73ff refactor: standardize control command gating 2026-01-23 23:34:30 +00:00
Peter Steinberger
1113f17d4c refactor: share reply prefix context 2026-01-23 23:34:30 +00:00
Peter Steinberger
8252ae2da1 refactor: unify typing callbacks 2026-01-23 23:33:32 +00:00
Peter Steinberger
d82ecaf9dc refactor: centralize inbound session updates 2026-01-23 23:33:32 +00:00
Peter Steinberger
521ea4ae5b refactor: unify pending history helpers 2026-01-23 23:33:32 +00:00
Peter Steinberger
05e7e06146 docs: add channel unification checklist 2026-01-23 23:32:14 +00:00
Peter Steinberger
cb8c8fee9a refactor: centralize ack reaction removal 2026-01-23 23:32:14 +00:00
Peter Steinberger
ed05152cb1 fix: align compaction summary message types 2026-01-23 23:03:04 +00:00
Peter Steinberger
a8054d1e83 fix: complete inbound dispatch refactor 2026-01-23 22:58:54 +00:00
Peter Steinberger
2e0a835e07 fix: unify inbound dispatch pipeline 2026-01-23 22:58:54 +00:00
Peter Steinberger
da26954dd0 test(compaction): cover staged pruning 2026-01-23 22:25:07 +00:00
Peter Steinberger
892197c43e refactor: reuse ack reaction helper for whatsapp 2026-01-23 22:24:31 +00:00
Peter Steinberger
02bd6e4a24 refactor: centralize ack reaction gating 2026-01-23 22:24:31 +00:00
Peter Steinberger
99d4820b39 docs: clarify exe.dev ops 2026-01-23 22:23:23 +00:00
Peter Steinberger
022aa10063 feat(compaction): apply staged pruning 2026-01-23 22:23:23 +00:00
Peter Steinberger
ae0741a346 feat(compaction): add staged helpers 2026-01-23 22:23:23 +00:00
Peter Steinberger
4ee70be690 chore: bump version to 2026.1.23 2026-01-23 22:14:56 +00:00
william arzt
24de8cecf6 Add Tlon/Urbit channel plugin
Adds built-in Tlon (Urbit) channel plugin to support decentralized messaging on the Urbit network.

Features:
- DM and group chat support
- SSE-based real-time message monitoring
- Auto-discovery of group channels
- Thread replies and reactions
- Integration with Urbit's HTTP API

This resolves cron delivery issues with external Tlon plugins by making it a first-class built-in channel alongside Telegram, Signal, and other messaging platforms.

Implementation includes:
- Plugin registration via ClawdbotPluginApi
- Outbound delivery with sendText and sendMedia
- Gateway adapter for inbound message handling
- Urbit SSE client for event streaming
- Core bridge for Clawdbot runtime integration

Co-authored-by: William Arzt <william@arzt.co>
2026-01-23 15:17:07 -05:00
Shiva Prasad
fdbaae6a33 macOS: fix trigger word input disappearing when typing and on add (#1506)
Fixed issue where trigger words would disappear when typing or when adding new trigger words. The problem was that `swabbleTriggerWords` changes were triggering `VoiceWakeRuntime.refresh()` which sanitized the array by removing empty strings in real-time.

Solution: Introduced local `@State` buffer `triggerEntries` with stable UUID identifiers for each trigger word entry. User edits now only affect the local state buffer and are synced back to `AppState` on explicit actions (submit, remove, disappear). This prevents premature sanitization during editing.

The local state is loaded on view appear and when the view becomes active, ensuring it stays in sync with `AppState`.
2026-01-23 20:08:12 +00:00
Paul van Oorschot
7d0a0ae3ba fix(discord): autoThread ack reactions + exec approval null handling (#1511)
* fix(discord): gate autoThread by thread owner

* fix(discord): ack bot-owned autoThreads

* fix(discord): ack mentions in open channels

- Ack reactions in bot-owned autoThreads
- Ack reactions in open channels (no mention required)
- DRY: Pass pre-computed isAutoThreadOwnedByBot to avoid redundant checks
- Consolidate ack logic with explanatory comment

* fix: allow null values in exec.approval.request schema

The ExecApprovalRequestParamsSchema was rejecting null values for optional
fields like resolvedPath, but the calling code in bash-tools.exec.ts passes
null. This caused intermittent 'invalid exec.approval.request params'
validation errors.

Fix: Accept Type.Union([Type.String(), Type.Null()]) for all optional string
fields in the schema. Update test to reflect new behavior.

* fix: align discord ack reactions with mention gating (#1511) (thanks @pvoo)

---------

Co-authored-by: Wimmie <wimmie@tameson.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-23 20:01:15 +00:00
Peter Steinberger
242add587f fix: quiet auth probe diagnostics 2026-01-23 19:53:01 +00:00
Peter Steinberger
6fba598eaf fix: handle gateway slash command replies in TUI 2026-01-23 19:48:22 +00:00
Peter Steinberger
75a54f0259 docs: note models usage suppression 2026-01-23 19:43:26 +00:00
Peter Steinberger
c63144ab14 fix: hide usage errors in status 2026-01-23 19:43:26 +00:00
Peter Steinberger
f07c39b265 docs: handle lint/format churn 2026-01-23 19:37:33 +00:00
Peter Steinberger
40181afded feat: add models status auth probes 2026-01-23 19:28:55 +00:00
Peter Steinberger
2f1b9efe9a style: wrap service path helpers 2026-01-23 19:17:57 +00:00
Peter Steinberger
ff30cef8a4 fix: expand linux service PATH handling 2026-01-23 19:16:41 +00:00
Robby
3d958d5466 fix(linux): add user bin directories to systemd service PATH for skill installation (#1512)
* fix(linux): add user bin directories to systemd service PATH

Fixes #1503

On Linux, the systemd service PATH was hardcoded to only include system
directories (/usr/local/bin, /usr/bin, /bin), causing binaries installed
via npm global with custom prefix or node version managers to not be found.

This adds common Linux user bin directories to the PATH:
- ~/.local/bin (XDG standard, pip, etc.)
- ~/.npm-global/bin (npm custom prefix)
- ~/bin (user's personal bin)
- Node version manager paths (nvm, fnm, volta, asdf)
- ~/.local/share/pnpm (pnpm global)
- ~/.bun/bin (Bun)

User directories are added before system directories so user-installed
binaries take precedence.

🤖 AI-assisted (Claude Opus 4.5 via Clawdbot)
📋 Testing: Existing unit tests pass (7/7)

* test: add comprehensive tests for Linux user bin directory resolution

- Add dedicated tests for resolveLinuxUserBinDirs() function
- Test path ordering (extraDirs > user dirs > system dirs)
- Test buildMinimalServicePath() with HOME set/unset
- Test platform-specific behavior (Linux vs macOS vs Windows)

Test count: 7 → 20 (+13 tests)

* test: add comprehensive tests for Linux user bin directory handling

- Test Linux user directories included when HOME is set
- Test Linux user directories excluded when HOME is missing
- Test path ordering (extraDirs > user dirs > system dirs)
- Test platform-specific behavior (Linux vs macOS vs Windows)
- Test buildMinimalServicePath() with HOME in env

Covers getMinimalServicePathParts() and buildMinimalServicePath()
for all Linux user bin directory edge cases.

Test count: 7 → 16 (+9 tests)
2026-01-23 19:06:14 +00:00
Peter Steinberger
cad7ed1cb8 fix(exec-approvals): stabilize allowlist ids (#1521) 2026-01-23 19:00:45 +00:00
Peter Steinberger
8195497cec fix: surface gateway slash commands in TUI 2026-01-23 18:58:41 +00:00
Peter Steinberger
1af227b619 fix: forward unknown TUI slash commands 2026-01-23 18:41:02 +00:00
Peter Steinberger
b77e730657 fix: add per-channel markdown table conversion (#1495) (thanks @odysseus0) 2026-01-23 18:39:25 +00:00
Peter Steinberger
37e5f077b8 test: move gateway server coverage to e2e 2026-01-23 18:34:33 +00:00
Peter Steinberger
0eb7e1864c test: move auto-reply directive coverage to e2e 2026-01-23 18:34:33 +00:00
Peter Steinberger
0d336272f9 test: consolidate auto-reply unit coverage 2026-01-23 18:34:33 +00:00
Peter Steinberger
ace6a42ea6 test: dedupe CLI onboard auth cases 2026-01-23 18:34:33 +00:00
Peter Steinberger
6d2a1ce217 test: trim async waits in webhook tests 2026-01-23 18:34:33 +00:00
Peter Steinberger
c9d73469c3 test: stub heavy tools in agent tests 2026-01-23 18:34:33 +00:00
Peter Steinberger
29353e2e81 test: speed up default test env 2026-01-23 18:34:33 +00:00
Peter Steinberger
fdc50a0feb fix: normalize session lock path 2026-01-23 18:34:33 +00:00
George Zhang
a1413a011e feat(telegram): convert markdown tables to bullet points (#1495)
Tables render poorly in Telegram (pipes stripped, whitespace collapses).
This adds a 'tableMode' option to markdownToIR that converts tables to
nested bullet points, which render cleanly on mobile.

- Add tableMode: 'flat' | 'bullets' to MarkdownParseOptions
- Track table state during token rendering
- Render tables as bullet points with first column as row labels
- Apply bold styling to row labels for visual hierarchy
- Enable tableMode: 'bullets' for Telegram formatter

Closes #TBD
2026-01-23 18:00:51 +00:00
George Zhang
bfbeea0f20 daemon: prefer symlinked paths over realpath for stable service configs (#1505)
When installing the LaunchAgent/systemd service, the CLI was using
fs.realpath() to resolve the entry.js path, which converted stable
symlinked paths (e.g. node_modules/clawdbot) into version-specific
paths (e.g. .pnpm/clawdbot@X.Y.Z/...).

This caused the service to break after pnpm updates because the old
versioned path no longer exists, even though the symlink still works.

Now we prefer the original (symlinked) path when it's valid, keeping
service configs stable across package version updates.
2026-01-23 11:52:26 +00:00
Peter Steinberger
2c85b1b409 fix: restart gateway after update by default 2026-01-23 11:50:19 +00:00
Peter Steinberger
8b7b7e154f chore: speed up tests and update opencode models 2026-01-23 11:36:32 +00:00
Peter Steinberger
bb9bddebb4 fix: stabilize ci tests 2026-01-23 09:52:22 +00:00
Peter Steinberger
6e570561b6 docs: prefer fast install smoke for release 2026-01-23 09:18:15 +00:00
Peter Steinberger
fb6363ae58 Merge pull request #1492 from svkozak/fix-discord-accountId
Discord: preserve accountId in message actions (refs #1489)
2026-01-23 09:15:54 +00:00
Peter Steinberger
1b77e086d4 Merge origin/main into fix-discord-accountId 2026-01-23 09:15:44 +00:00
Sergii Kozak
d371a4c8c3 Discord Actions: Update tests for optional config parameter 2026-01-23 01:11:54 -08:00
Peter Steinberger
03e8b7c4ba fix: always offer TUI hatch 2026-01-23 09:07:43 +00:00
Peter Steinberger
8aadcaa1bd test: fix discord action mocks 2026-01-23 09:06:04 +00:00
Peter Steinberger
96800c27ec docs: update changelog for #1492 2026-01-23 09:06:04 +00:00
Peter Steinberger
13d1712850 fix: honor accountId in message actions 2026-01-23 09:06:04 +00:00
Sergii Kozak
c5546f0d5b Discord: preserve accountId in message actions (refs #1489) 2026-01-23 09:06:04 +00:00
Peter Steinberger
3de5ea818d ci: speed up install smoke on PRs 2026-01-23 09:05:15 +00:00
Peter Steinberger
dc07f1e021 fix: keep core tools when allowlist is plugin-only 2026-01-23 09:02:17 +00:00
Peter Steinberger
310a248a44 docs: add exe.dev ops note 2026-01-23 09:01:02 +00:00
Peter Steinberger
88e7684258 chore: update appcast for 2026.1.22 2026-01-23 08:59:04 +00:00
Sergii Kozak
716f901504 Discord: honor accountId across channel actions (refs #1489) 2026-01-23 00:50:50 -08:00
Peter Steinberger
e817c0cee5 fix: preserve PNG alpha fallback (#1491) (thanks @robbyczgw-cla) 2026-01-23 08:45:50 +00:00
Robby
e634791585 fix(media): preserve alpha channel for transparent PNGs (#1473) 2026-01-23 08:43:01 +00:00
Peter Steinberger
78071f8ec4 docs: note SPARKLE_PRIVATE_KEY_FILE in profile 2026-01-23 08:25:20 +00:00
Peter Steinberger
c48751a99c chore: sync plugin versions for 2026.1.22 2026-01-23 08:18:55 +00:00
Peter Steinberger
86e0916fa3 fix: allow windows spawn in test parallel 2026-01-23 07:52:04 +00:00
Sergii Kozak
dc89bc4004 Discord: preserve accountId in message actions (refs #1489) 2026-01-22 23:51:58 -08:00
Peter Steinberger
0c7e649676 docs: fix 2026.1.21 changelog placement 2026-01-23 07:51:40 +00:00
Peter Steinberger
45ce07a098 test: split vitest into unit and gateway 2026-01-23 07:34:57 +00:00
Peter Steinberger
aed8dc1ade test: consolidate pi-tools shards 2026-01-23 07:34:57 +00:00
Peter Steinberger
86a341be62 test: speed up history and cron suites 2026-01-23 07:34:57 +00:00
Ian Hildebrand
ff78e9a564 fix: support direct token and provider in auth apply commands (#1485) 2026-01-23 07:27:52 +00:00
Peter Steinberger
60a60779d7 test: streamline slow suites 2026-01-23 07:26:19 +00:00
Peter Steinberger
32da00cb2f docs: note vitest worker cap 2026-01-23 07:26:19 +00:00
Peter Steinberger
0420f2804c fix: log config update in copilot auth 2026-01-23 07:23:52 +00:00
Hiren Patel
4de660bec6 [AI Assisted] Usage: add Google Antigravity usage tracking (#1490)
* Usage: add Google Antigravity usage tracking

- Add dedicated fetcher for google-antigravity provider
- Fetch credits and per-model quotas from Cloud Code API
- Report individual model IDs sorted by usage (top 10)
- Include comprehensive debug logging with [antigravity] prefix

* fix: refine antigravity usage tracking (#1490) (thanks @patelhiren)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-23 07:17:59 +00:00
Peter Steinberger
58f638463f fix: stop gateway before uninstall 2026-01-23 07:17:42 +00:00
Peter Steinberger
f1afc722da Revert "fix: improve GitHub Copilot integration"
This reverts commit 21a9b3b66f.
2026-01-23 07:14:00 +00:00
Peter Steinberger
bc75d58e9e Revert "fix: set Copilot user agent header"
This reverts commit cfcc4548bb.
2026-01-23 07:14:00 +00:00
Peter Steinberger
2efd265697 Revert "fix: treat copilot oauth tokens as non-expiring"
This reverts commit 35228ecae9.
2026-01-23 07:14:00 +00:00
Peter Steinberger
9c1f1476bc docs: fix Lobster changelog placement 2026-01-23 07:12:13 +00:00
Peter Steinberger
e8352c8d21 fix: stabilize cron log wait 2026-01-23 07:11:01 +00:00
Peter Steinberger
551685351f fix: sanitize assistant session text (#1456) (thanks @zerone0x) 2026-01-23 07:05:31 +00:00
Peter Steinberger
3fbbac07fe fix: prioritize Anthropic token auth option 2026-01-23 07:04:18 +00:00
zerone0x
03bec49299 fix: sanitize tool call text in sessions-helpers extractAssistantText
Adds sanitization to extractAssistantText in sessions-helpers.ts to
prevent tool call text from leaking to users. Previously, messages
retrieved from chat history via sessions-helpers.ts could expose:

- Minimax XML tool calls (<invoke>...</invoke>)
- Downgraded tool call markers ([Tool Call: name (ID: ...)])
- Thinking tags (<think>...</think>)

This fix:
- Exports the stripping functions from pi-embedded-utils.ts
- Adds a new sanitizeTextContent helper in sessions-helpers.ts
- Updates extractAssistantText to sanitize before returning
- Updates extractMessageText in commands-subagents.ts to sanitize

Fixes #1269

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 07:03:26 +00:00
Peter Steinberger
6779ba2367 fix(tui): hide off think/verbose in footer 2026-01-23 07:02:56 +00:00
Peter Steinberger
8598e906ef docs: highlight compaction safeguards in changelog 2026-01-23 06:41:23 +00:00
Peter Steinberger
300fc486a4 test: avoid double cron finish wait 2026-01-23 06:40:14 +00:00
Peter Steinberger
f014b46b56 test: harden onboarding/discord/telegram test setup 2026-01-23 06:38:16 +00:00
Peter Steinberger
833f5acda1 test: stabilize cron + async search timings 2026-01-23 06:38:16 +00:00
Dave Lauer
d03c404cb4 feat(compaction): add adaptive chunk sizing, progressive fallback, and UI indicator (#1466)
* fix(ui): allow relative URLs in avatar validation

The isAvatarUrl check only accepted http://, https://, or data: URLs,
but the /avatar/{agentId} endpoint returns relative paths like /avatar/main.
This caused local file avatars to display as text instead of images.

Fixes avatar display for locally configured avatar files.

* fix(gateway): resolve local avatars to URL in HTML injection and RPC

The frontend fix alone wasn't enough because:
1. serveIndexHtml() was injecting the raw avatar filename into HTML
2. agent.identity.get RPC was returning raw filename, overwriting the
   HTML-injected value

Now both paths resolve local file avatars (*.png, *.jpg, etc.) to the
/avatar/{agentId} endpoint URL.

* feat(compaction): add adaptive chunk sizing and progressive fallback

- Add computeAdaptiveChunkRatio() to reduce chunk size for large messages
- Add isOversizedForSummary() to detect messages too large to summarize
- Add summarizeWithFallback() with progressive fallback:
  - Tries full summarization first
  - Falls back to partial summarization excluding oversized messages
  - Notes oversized messages in the summary output
- Add SAFETY_MARGIN (1.2x) buffer for token estimation inaccuracy
- Reduce MIN_CHUNK_RATIO to 0.15 for very large messages

This prevents compaction failures when conversations contain
unusually large tool outputs or responses that exceed the
summarization model's context window.

* feat(ui): add compaction indicator and improve event error handling

Compaction indicator:
- Add CompactionStatus type and handleCompactionEvent() in app-tool-stream.ts
- Show '🧹 Compacting context...' toast while active (with pulse animation)
- Show '🧹 Context compacted' briefly after completion
- Auto-clear toast after 5 seconds
- Add CSS styles for .callout.info, .callout.success, .compaction-indicator

Error handling improvements:
- Wrap onEvent callback in try/catch in gateway.ts to prevent errors
  from breaking the WebSocket message handler
- Wrap handleGatewayEvent in try/catch with console.error logging
  to isolate errors and make them visible in devtools

These changes address UI freezes during heavy agent activity by:
1. Showing users when compaction is happening
2. Preventing uncaught errors from silently breaking the event loop

* fix(control-ui): add agentId to DEFAULT_ASSISTANT_IDENTITY

TypeScript inferred the union type without agentId when falling back to
DEFAULT_ASSISTANT_IDENTITY, causing build errors at control-ui.ts:222-223.
2026-01-23 06:32:30 +00:00
Peter Steinberger
68ea6e521b fix: reduce Slack WebClient retries 2026-01-23 06:31:53 +00:00
Peter Steinberger
4912e85ac8 fix: fall back to non-PTY exec 2026-01-23 06:27:26 +00:00
Peter Steinberger
39d8ff59aa test: trim plugin + telegram test setup 2026-01-23 06:22:09 +00:00
Peter Steinberger
070944f64f test(memory): speed up batch coverage 2026-01-23 06:22:09 +00:00
Peter Steinberger
d4db45e8a9 test(agents): merge sessions_spawn group announce coverage 2026-01-23 06:22:09 +00:00
Peter Steinberger
451792d326 test(commands): streamline onboarding tests 2026-01-23 06:22:09 +00:00
Peter Steinberger
c7ca312f97 test(gateway): consolidate server suites for speed 2026-01-23 06:22:09 +00:00
ganghyun kim
1e6e58b23b fix: clarify Discord onboarding hint (#1487)
Thanks @kyleok.

Co-authored-by: Ganghyun Kim <58307870+kyleok@users.noreply.github.com>
2026-01-23 06:11:41 +00:00
Peter Steinberger
e98e71401a fix: always skip browser opens in tests 2026-01-23 06:00:21 +00:00
Peter Steinberger
bec1d0d3d4 fix: extend gateway chat test timeout on windows 2026-01-23 05:55:35 +00:00
Peter Steinberger
9f6ea67415 fix: gateway summary lookup + test browser opens 2026-01-23 05:54:51 +00:00
Peter Steinberger
bd7443b39b docs: update media auto-detect 2026-01-23 05:47:16 +00:00
Peter Steinberger
93bef830ce test: add media auto-detect coverage 2026-01-23 05:47:13 +00:00
Peter Steinberger
2dfbd1c1f6 feat: improve media auto-detect 2026-01-23 05:47:09 +00:00
Peter Steinberger
1d9f230be4 docs: expand slack replyToModeByChatType examples 2026-01-23 05:38:28 +00:00
Peter Steinberger
9bf295da48 feat: add slack replyToModeByChatType overrides 2026-01-23 05:38:28 +00:00
Peter Steinberger
eebd750781 fix: improve matrix direct room resolution (#1436) (thanks @sibbl) (#1486)
* fix: improve matrix direct room resolution (#1436) (thanks @sibbl)

* docs: update changelog for matrix fix (#1486) (thanks @sibbl)
2026-01-23 05:38:04 +00:00
Sebastian Schubotz
aa11300175 fix(matrix): broken import and enhance direct room resolve logic (#1436)
* fix(matrix): fix broken import again

* fix(matrix): improve error handling and fallback logic in resolveDirectRoomId
2026-01-23 05:35:01 +00:00
Stefan Galescu
7b40d1b261 feat(slack): add dm-specific replyToMode configuration (#1442)
Adds support for separate replyToMode settings for DMs vs channels:

- Add channels.slack.dm.replyToMode for DM-specific threading
- Keep channels.slack.replyToMode as default for channels
- Add resolveSlackReplyToMode helper to centralize logic
- Pass chatType through threading resolution chain

Usage:
```json5
{
  channels: {
    slack: {
      replyToMode: "off",     // channels
      dm: {
        replyToMode: "all"    // DMs always thread
      }
    }
  }
}
```

When dm.replyToMode is set, DMs use that mode; channels use the
top-level replyToMode. Backward compatible when not configured.
2026-01-23 05:13:23 +00:00
Peter Steinberger
2c10c601a8 test: harden docker onboarding waits 2026-01-23 05:10:59 +00:00
Travis Irby
578ac9f1a9 hydrate files from thread root message on replies
When replying to a Slack thread, files attached to the root message were
  not being fetched. The existing `resolveSlackThreadStarter()` fetched the
  root message text via `conversations.replies` but ignored the `files[]`
  array in the response.

  Changes:
  - Add `files` to `SlackThreadStarter` type and extract from API response
  - Download thread starter files when the reply message has no attachments
  - Add verbose log for thread starter file hydration

  Fixes issue where asking about a PDF in a thread reply would fail because
  the model never received the file content from the root message.
2026-01-23 05:10:36 +00:00
Neo
2accb47e4d fix: follow soul.md more closely (#1434)
* Agents: honor SOUL.md persona guidance

* fix: harden SOUL.md detection (#1434) (thanks @neooriginal)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-23 05:00:13 +00:00
Tak hoffman
b65916e0d1 CLI: fix Windows gateway startup 2026-01-23 04:47:01 +00:00
Peter Steinberger
9207840db4 docs: note #1482 in changelog 2026-01-23 04:38:08 +00:00
Peter Steinberger
784468d6c3 fix: harden BlueBubbles voice memos (#1477) (thanks @Nicell) 2026-01-23 04:38:08 +00:00
Clawd
02b5f403db feat(bluebubbles): add asVoice support for voice memos
Add asVoice parameter to sendBlueBubblesAttachment that converts audio
to iMessage voice memo format (Opus CAF at 48kHz) and sets isAudioMessage
flag in the BlueBubbles API.

This follows the existing asVoice pattern used by Telegram.

- Convert audio to Opus CAF format using ffmpeg when asVoice=true
- Set isAudioMessage=true in BlueBubbles attachment API
- Pass asVoice through action handler and media-send
2026-01-23 04:34:19 +00:00
Peter Steinberger
5d0d9e6323 feat: refine onboarding hatch flow 2026-01-23 04:32:23 +00:00
Peter Steinberger
64be2b2cd1 test: speed up gateway suite setup 2026-01-23 04:28:02 +00:00
Rodrigo Uroz
dd2400fb2a fix: read Slack thread replies for message reads (#1450) (#1450)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Rodrigo Uroz <rodrigouroz@users.noreply.github.com>
2026-01-23 04:17:45 +00:00
Peter Steinberger
5d001cb953 refactor: add config logging helpers 2026-01-23 04:16:39 +00:00
Peter Steinberger
d23c4a3f10 fix: put plugin descriptions under source 2026-01-23 04:02:42 +00:00
Peter Steinberger
e750ad5e75 refactor: centralize config update logging 2026-01-23 04:01:26 +00:00
Paulo Portella
246ee490f6 docs: add pauloportella to clawtributors 2026-01-23 03:58:57 +00:00
Peter Steinberger
d62a20fba9 chore: add open-prose license 2026-01-23 03:53:03 +00:00
Peter Steinberger
7f68bf79b6 fix: prefer ~ for home paths in output 2026-01-23 03:44:31 +00:00
Peter Steinberger
34bb7250f8 fix: resolve changelog merge markers 2026-01-23 03:44:14 +00:00
Peter Steinberger
34696dc8b9 Merge pull request #1432 from tobiasbischoff/main
fix(auth): skip auth profiles in cooldown during selection and rotation
2026-01-23 03:35:25 +00:00
Peter Steinberger
9a9afb389a Merge origin/main into pr-1432 2026-01-23 03:35:16 +00:00
Peter Steinberger
1e9ae7649d docs: add changelog entry for #1432 2026-01-23 03:31:42 +00:00
Peter Steinberger
5cb9026541 fix: honor user-pinned profiles and search ranking 2026-01-23 03:28:47 +00:00
Tobias Bischoff
81e78dced5 perf(tui): optimize searchable select list filtering
- Add regex caching to avoid creating new RegExp objects on each render
- Optimize smartFilter to use single array with tier-based scoring
- Replace non-existent fuzzyFilter import with local fuzzyFilterLower
- Reduces from 4 array allocations and 4 sorts to 1 array and 1 sort

Fixes pre-existing bug where fuzzyFilter was imported from pi-tui but not exported.
2026-01-23 03:28:18 +00:00
Tobias Bischoff
565944ec71 fix(auth): skip auth profiles in cooldown during selection and rotation
Auth profiles in cooldown (due to rate limiting) were being attempted,
causing unnecessary retries and delays. This fix ensures:

1. Initial profile selection skips profiles in cooldown
2. Profile rotation (after failures) skips cooldown profiles
3. Clear error message when all profiles are unavailable

Tests added:
- Skips profiles in cooldown during initial selection
- Skips profiles in cooldown when rotating after failure

Fixes #1316
2026-01-23 03:28:18 +00:00
Peter Steinberger
ec2c69c230 fix: honor gateway env token for doctor/security
Co-authored-by: azade-c <azade-c@users.noreply.github.com>
2026-01-23 03:16:52 +00:00
Peter Steinberger
f1deffa681 fix: repair docs redirects 2026-01-23 03:13:12 +00:00
Peter Steinberger
4b19066cc1 fix: normalize Windows exec allowlist paths 2026-01-23 03:11:41 +00:00
Peter Steinberger
ea79b26b79 feat: extend lobster tool run args 2026-01-23 03:09:59 +00:00
Peter Steinberger
6eb355954c docs: add changelog entry for #1432 2026-01-23 03:06:10 +00:00
Peter Steinberger
91ca52d3c5 fix: honor user-pinned profiles and search ranking 2026-01-23 03:05:01 +00:00
Peter Steinberger
0149d2b678 test: speed up test suite 2026-01-23 02:55:38 +00:00
Peter Steinberger
ecfddb7807 docs: fix lobster links 2026-01-23 02:51:33 +00:00
Peter Steinberger
35228ecae9 fix: treat copilot oauth tokens as non-expiring 2026-01-23 02:51:33 +00:00
Peter Steinberger
cfcc4548bb fix: set Copilot user agent header 2026-01-23 02:51:33 +00:00
Peter Steinberger
21a9b3b66f fix: improve GitHub Copilot integration 2026-01-23 02:51:33 +00:00
Peter Steinberger
837749dced fix: honor send path/filePath inputs (#1444) (thanks @hopyky) 2026-01-23 02:27:47 +00:00
Peter Steinberger
59a8eecd7e test: speed up test suite 2026-01-23 02:22:02 +00:00
Peter Steinberger
542cf011a0 Merge pull request #1444 from hopyky/fix-message-path-parameter
Fix: Support path and filePath parameters in message send action
2026-01-23 02:10:54 +00:00
Peter Steinberger
4355d9acca fix: resolve heartbeat sender and Slack thread_ts 2026-01-23 02:05:34 +00:00
Peter Steinberger
712bc74c30 docs: highlight mattermost plugin 2026-01-23 01:39:36 +00:00
Peter Steinberger
0396b678fa docs: note transcript hygiene sync 2026-01-23 01:38:05 +00:00
Peter Steinberger
eaf1b6bfee docs: simplify OpenProse install 2026-01-23 01:37:54 +00:00
Peter Steinberger
06cb2bf58d docs: expand mattermost intro 2026-01-23 01:35:50 +00:00
Peter Steinberger
8fdb3b38eb docs: add mattermost redirect 2026-01-23 01:35:15 +00:00
Peter Steinberger
5689d7fb98 refactor: remove transcript sanitize extension 2026-01-23 01:34:33 +00:00
Peter Steinberger
2424404fb4 docs: add transcript hygiene reference 2026-01-23 01:34:21 +00:00
Peter Steinberger
17a09cc721 Merge pull request #1472 from czekaj/fix/logs-follow-spinner
fix: suppress spinner in logs --follow mode
2026-01-23 01:29:30 +00:00
Peter Steinberger
bc4d8ce398 docs: link Lobster and OpenProse 2026-01-23 01:29:17 +00:00
Peter Steinberger
279f799388 fix: harden Mattermost plugin gating (#1428) (thanks @damoahdominic) 2026-01-23 01:23:23 +00:00
Peter Steinberger
1d658109a8 docs: remove OpenProse telemetry mentions 2026-01-23 01:20:30 +00:00
Peter Steinberger
5a446f3a21 docs: expand OpenProse guide 2026-01-23 01:08:55 +00:00
Peter Steinberger
52b6bf04af fix: improve tool summaries 2026-01-23 01:00:24 +00:00
Lucas Czekaj
76a42da676 fix: suppress spinner in logs --follow mode
The progress spinner was being shown for each gateway RPC call during
log tailing, causing repeated spinner frames (◇ │) to appear every
polling interval.

Add a `progress` option to `callGatewayFromCli` and disable the spinner
during follow mode polling to keep output clean.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 16:58:42 -08:00
Peter Steinberger
51a9053387 feat: add OpenProse plugin skills 2026-01-23 00:49:40 +00:00
Peter Steinberger
db0235a26a fix: gate transcript sanitization by provider 2026-01-23 00:42:45 +00:00
Peter Steinberger
fac21e6eb4 Merge pull request #1428 from bestparents/feat/mattermost-channel
feat: add Mattermost channel support
2026-01-23 00:24:47 +00:00
Peter Steinberger
e872f5335b fix: allow chained exec allowlists
Co-authored-by: Lucas Czekaj <1464539+czekaj@users.noreply.github.com>
2026-01-23 00:11:58 +00:00
Peter Steinberger
a23e272877 Merge pull request #1440 from robbyczgw-cla/fix/token-count-after-compaction
fix: update token count display after compaction
2026-01-23 00:10:46 +00:00
Peter Steinberger
870bfa94ed fix: skip tool id sanitization for openai responses 2026-01-22 23:51:59 +00:00
Peter Steinberger
d297e17958 refactor: centralize control ui avatar helpers 2026-01-22 23:41:36 +00:00
Peter Steinberger
6a25e23909 fix: tui local shell consent UX (#1463)
- add local shell runner + denial notice + tests
- docs: describe ! local shell usage
- lint: drop unused Slack upload contentType
- cleanup: remove stray Swabble pins

Thanks @vignesh07.
Co-authored-by: Vignesh Natarajan <vigneshnatarajan92@gmail.com>
2026-01-22 23:38:44 +00:00
Vignesh Natarajan
dc66527114 tui: clarify local shell exec consent prompt 2026-01-22 23:26:01 +00:00
Vignesh Natarajan
110b5dafee tui: keep trimming for normal submits; only raw ! triggers bash 2026-01-22 23:26:01 +00:00
Vignesh Natarajan
5fd699d0bf tui: add local shell execution for !-prefixed lines 2026-01-22 23:26:01 +00:00
Peter Steinberger
c1e50b7184 docs: clarify node service commands 2026-01-22 23:22:56 +00:00
Peter Steinberger
c7e0dc10fc docs: fix remaining node ws references 2026-01-22 23:22:56 +00:00
Dominic Damoah
01579aa7d7 Merge branch 'main' into feat/mattermost-channel 2026-01-22 18:17:40 -05:00
Peter Steinberger
42cd8a02bb Merge pull request #1447 from jdrhyne/fix/slack-filetype-deprecation
fix(slack): remove deprecated filetype field from files.uploadV2 [AI]
2026-01-22 23:16:26 +00:00
Peter Steinberger
96f1846c2c docs: align node transport with gateway ws 2026-01-22 23:10:09 +00:00
Peter Steinberger
7c336588ea chore: drop tty from install e2e docker 2026-01-22 23:09:28 +00:00
Peter Steinberger
814e9a500e feat: add manual onboarding flow alias 2026-01-22 23:09:28 +00:00
Peter Steinberger
370896e994 fix(macos): prefer linked channel in health summaries 2026-01-22 23:09:28 +00:00
Peter Steinberger
573354f5e4 chore(dev): default restart-mac to attach-only 2026-01-22 23:08:56 +00:00
Peter Steinberger
c721947346 feat(macos): add attach-only launchd override 2026-01-22 23:08:56 +00:00
Peter Steinberger
56339a17cc fix: correct gog auth services example (#1454) (thanks @zerone0x) 2026-01-22 22:51:59 +00:00
Peter Steinberger
567d8e5aa4 Merge pull request #1454 from zerone0x/docs/fix-gog-auth-services-example
docs(gog): fix invalid service name in auth example
2026-01-22 22:50:48 +00:00
Peter Steinberger
da3a141c58 refactor: require session state for directive handling 2026-01-22 22:42:46 +00:00
Peter Steinberger
c0c8ee217f fix: clarify session_status model-use guidance 2026-01-22 22:42:37 +00:00
Peter Steinberger
411ce7e231 fix: surface concrete ai error details 2026-01-22 22:24:25 +00:00
Peter Steinberger
b709898fb3 Merge pull request #1461 from ameno-/fix/node-daemon-run
Fix node daemon command
2026-01-22 22:02:19 +00:00
Peter Steinberger
826013c990 docs: refresh nodes + pairing docs 2026-01-22 22:02:06 +00:00
Peter Steinberger
482fcd2f2c fix: resolve control UI avatar URLs (#1457) (thanks @dlauer) 2026-01-22 21:58:46 +00:00
Peter Steinberger
6c7f224ce1 Merge pull request #1457 from dlauer/fix/avatar-relative-url-validation
fix(ui): allow relative URLs in avatar validation
2026-01-22 21:57:27 +00:00
Peter Steinberger
db146837a1 fix: move session-memory changelog entry 2026-01-22 21:55:10 +00:00
Peter Steinberger
1ef2de1276 fix: cover missing session key model switch persist (#1465) (thanks @robbyczgw-cla) 2026-01-22 21:41:05 +00:00
Peter Steinberger
60cbf97079 Merge pull request #1464 from alfranli123/fix/session-memory-suppress-confirmation
fix(session-memory): suppress user-visible confirmation message
2026-01-22 21:40:15 +00:00
Peter Steinberger
13a62d1a6f Merge pull request #1465 from robbyczgw-cla/fix/model-switch-persist-1435
fix: only show model switch success when persist succeeds (fixes #1435)
2026-01-22 21:37:51 +00:00
Peter Steinberger
534f28a78f Merge pull request #1439 from Nicell/fix/bluebubbles-typing-stop
fix(bluebubbles): call stop typing on idle and NO_REPLY
2026-01-22 21:33:49 +00:00
Peter Steinberger
3993c9a3b4 fix: stop BlueBubbles typing on idle/no-reply (#1439) (thanks @Nicell) 2026-01-22 21:33:19 +00:00
Clawd
f552820a75 fix(bluebubbles): call stop typing on idle and NO_REPLY
Previously, typing stop was intentionally skipped because the
BlueBubbles Server DELETE endpoint was bugged (called startTyping
instead of stopTyping). Now that the server bug is fixed, we can
properly stop typing indicators.

- onIdle: now calls sendBlueBubblesTyping(false) to stop typing
- finally block: stops typing when no message sent (NO_REPLY case)
2026-01-22 21:20:35 +00:00
Robby
784ea4f7d5 test: add unit tests for model switch persist behavior
Tests verify:
- Success message shown when session state available
- Error message shown when sessionEntry missing
- Error message shown when sessionStore missing
- No model message when no /model directive

Covers edge cases for #1435 fix.
2026-01-22 20:40:41 +00:00
Robby
f07a58965e fix: only show model switch success when persist succeeds (fixes #1435)
Previously, the /model command would display 'Model set to X' even when
the session state wasn't actually persisted (when sessionEntry, sessionStore,
or sessionKey were missing). This caused confusion as users saw success
messages but the model didn't actually change.

This fix:
- Tracks whether the model override was actually persisted
- Only shows success message when persist happened
- Shows a clear error message when persist fails

AI-assisted: Claude Opus 4.5 via Clawdbot
Testing: lightly tested (code review, no runtime test)
2026-01-22 20:31:06 +00:00
Al
773dad256e fix(session-memory): suppress user-visible confirmation message
The session-memory hook saves session context to memory files when /new is run,
which is useful internal housekeeping. However, the confirmation message that
was displayed to users (showing the file path) leaked implementation details.

This change removes the user-visible message while keeping the console.log
for debugging purposes. The hook continues to save session context silently.
2026-01-22 15:22:20 -05:00
Dave Lauer
ffca65d15f fix(gateway): resolve local avatars to URL in HTML injection and RPC
The frontend fix alone wasn't enough because:
1. serveIndexHtml() was injecting the raw avatar filename into HTML
2. agent.identity.get RPC was returning raw filename, overwriting the
   HTML-injected value

Now both paths resolve local file avatars (*.png, *.jpg, etc.) to the
/avatar/{agentId} endpoint URL.
2026-01-22 15:16:31 -05:00
Ameno Osman
654b6a943b fix(node): use node run for node daemon 2026-01-22 11:15:51 -08:00
Robby
768d5ccafe style: fix formatting 2026-01-22 17:47:52 +00:00
Dominic Damoah
8b3cb373d4 fix: remove unused originatingChannel variable
Remove unused originatingChannel variable from runPreparedReply function that was assigned but never referenced.
2026-01-22 12:11:05 -05:00
Dave Lauer
9d09a7879c fix(ui): allow relative URLs in avatar validation
The isAvatarUrl check only accepted http://, https://, or data: URLs,
but the /avatar/{agentId} endpoint returns relative paths like /avatar/main.
This caused local file avatars to display as text instead of images.

Fixes avatar display for locally configured avatar files.
2026-01-22 12:09:27 -05:00
Dominic Damoah
495a39b5a9 refactor: extract mattermost channel plugin to extension
Move mattermost channel implementation from core to extensions/mattermost plugin. Extract config schema, group mentions, normalize utilities, and all mattermost-specific logic (accounts, client, monitor, probe, send) into the extension. Update imports to use plugin SDK and local modules. Add channel metadata directly in plugin definition instead of using getChatChannelMeta. Update package.json with channel and install configuration.
2026-01-22 12:02:30 -05:00
zerone0x
ba824a4b2d docs(gog): fix invalid service name in auth example
Replace invalid "docs" service with the correct "tasks,people" services
in the setup example. The gog CLI does not have a "docs" service - docs
commands (export/cat) use Drive authentication instead.

Fixes #1433

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 00:45:20 +08:00
Jonathan Rhyne
8b6b97c3f6 docs: add changelog entry for PR #1447 2026-01-22 08:39:54 -05:00
Jonathan Rhyne
47e440f73a fix(slack): remove deprecated filetype field from files.uploadV2
Slack's files.uploadV2 API no longer supports the filetype field and logs
deprecation warnings when it's included. Slack auto-detects the file type
from the file content, so this field is unnecessary.

This removes the warning:
[WARN] web-api:WebClient filetype is no longer a supported field in files.uploadV2.
2026-01-22 08:33:13 -05:00
Peter Steinberger
80c1edc3ff chore: update appcast for v2026.1.21 2026-01-22 12:24:06 +00:00
Peter Steinberger
1d55dc0fe3 fix: export sessions preview payload init 2026-01-22 12:23:59 +00:00
Matt mini
57e81d3c24 Fix: Support path and filePath parameters in message send action
The message tool accepts path and filePath parameters in its schema,
but these were never converted to mediaUrl, causing local files to
be ignored when sending messages.

Changes:
- src/agents/tools/message-tool.ts: Convert path/filePath to media with file:// URL
- src/infra/outbound/message-action-runner.ts: Allow hydrateSendAttachmentParams for "send" action

Fixes issue where local audio files (and other media) couldn't be sent
via the message tool with the path parameter.

Users can now use:
  message({ path: "/tmp/file.ogg" })
  message({ filePath: "/tmp/file.ogg" })
2026-01-22 13:15:48 +01:00
Peter Steinberger
cd6bacae23 chore: release 2026.1.21-2 2026-01-22 11:42:42 +00:00
Peter Steinberger
447db67b18 ui: add onboarding mode for control ui 2026-01-22 11:40:33 +00:00
Peter Steinberger
019726f2d1 fix: guard invalid avatar bootstrap text 2026-01-22 11:37:29 +00:00
Peter Steinberger
3be7ac8524 fix: build control ui during prepack 2026-01-22 11:11:15 +00:00
Peter Steinberger
058f00ba0b chore: update protocol Swift models 2026-01-22 11:02:15 +00:00
Peter Steinberger
fb85cb3271 docs: clarify bootstrap memory absence 2026-01-22 10:48:07 +00:00
Peter Steinberger
d47db55106 chore: sync plugin versions 2026-01-22 10:32:53 +00:00
Peter Steinberger
5045a9a00d test: relax Windows vitest limits 2026-01-22 10:29:44 +00:00
Peter Steinberger
36a2584ac7 fix: allowlist match without local exec resolution 2026-01-22 10:29:36 +00:00
Peter Steinberger
cadaf2c835 feat: add sessions preview rpc and menu prewarm 2026-01-22 10:21:50 +00:00
Peter Steinberger
72455b902f test: cover exec approval prompt gating 2026-01-22 10:00:55 +00:00
Peter Steinberger
e389bd478b fix: keep backslashes in quoted exec paths 2026-01-22 09:58:24 +00:00
Robby
0873351401 fix: update token count display after compaction (#1299) 2026-01-22 09:58:07 +00:00
Peter Steinberger
ced9efd964 fix: avoid duplicate exec approval prompts 2026-01-22 09:53:36 +00:00
Peter Steinberger
6822d509d7 docs: explain unpinning model auth profiles 2026-01-22 09:38:47 +00:00
Peter Steinberger
9f588d91f4 docs: add cache optimization highlight 2026-01-22 09:35:12 +00:00
Peter Steinberger
486af3f453 docs: consolidate 2026.1.21 changelog 2026-01-22 09:35:12 +00:00
Peter Steinberger
7a283f86a8 fix: omit skills section in minimal prompt 2026-01-22 09:32:49 +00:00
Tobias Bischoff
917bcb714e perf(tui): optimize searchable select list filtering
- Add regex caching to avoid creating new RegExp objects on each render
- Optimize smartFilter to use single array with tier-based scoring
- Replace non-existent fuzzyFilter import with local fuzzyFilterLower
- Reduces from 4 array allocations and 4 sorts to 1 array and 1 sort

Fixes pre-existing bug where fuzzyFilter was imported from pi-tui but not exported.
2026-01-22 10:29:37 +01:00
Peter Steinberger
646ea6ef0b test: use absolute exec path for allowlist 2026-01-22 09:20:38 +00:00
Tobias Bischoff
3d8a759eba fix(auth): skip auth profiles in cooldown during selection and rotation
Auth profiles in cooldown (due to rate limiting) were being attempted,
causing unnecessary retries and delays. This fix ensures:

1. Initial profile selection skips profiles in cooldown
2. Profile rotation (after failures) skips cooldown profiles
3. Clear error message when all profiles are unavailable

Tests added:
- Skips profiles in cooldown during initial selection
- Skips profiles in cooldown when rotating after failure

Fixes #1316
2026-01-22 10:04:56 +01:00
Peter Steinberger
4c8806ad38 Merge pull request #1431 from robbyczgw-cla/fix/subagent-skills-inheritance
fix: include skills in minimal prompt mode for subagents
2026-01-22 09:02:28 +00:00
Peter Steinberger
0824bc0236 test: isolate exec allowlist env 2026-01-22 08:58:55 +00:00
Peter Steinberger
0e17e55be9 fix: cache usage cost summary 2026-01-22 08:51:22 +00:00
Peter Steinberger
54e0fc342e fix: wrap cli banner tagline 2026-01-22 08:50:06 +00:00
Peter Steinberger
cc8506ae79 fix: refresh menu sessions on reconnect 2026-01-22 08:48:13 +00:00
Peter Steinberger
f2606a17ba chore: update a2ui bundle hash 2026-01-22 08:48:09 +00:00
Peter Steinberger
1a4fade2f7 fix: honor Windows Path casing 2026-01-22 08:33:52 +00:00
Peter Steinberger
e344b7df9c fix: preserve antigravity thinking block types 2026-01-22 08:31:07 +00:00
Robby
256fdcb3cf fix: include skills in minimal prompt mode for subagents 2026-01-22 08:28:55 +00:00
Peter Steinberger
acdfbee4f9 fix: detect antigravity claude by provider 2026-01-22 08:26:08 +00:00
Peter Steinberger
ff69a9bd9c fix: sanitize antigravity thinking signatures 2026-01-22 08:17:49 +00:00
Dominic Damoah
91278d8b4e Merge branch 'main' into feat/mattermost-channel 2026-01-22 03:11:53 -05:00
Peter Steinberger
b748b86b23 fix: canonicalize allowlist paths on Windows 2026-01-22 08:07:55 +00:00
Peter Steinberger
1a8b106f34 style: format agent workspace and prompts 2026-01-22 08:05:55 +00:00
Peter Steinberger
87baca82db style: polish exec approvals prompt 2026-01-22 08:05:55 +00:00
Peter Steinberger
388d302472 fix: carry reply tags across streamed chunks 2026-01-22 08:01:34 +00:00
Peter Steinberger
e0c19607b7 fix: allow MEDIA local paths with spaces 2026-01-22 07:51:09 +00:00
Dominic Damoah
fe77d3eb56 Merge branch 'main' into feat/mattermost-channel 2026-01-22 02:49:17 -05:00
Peter Steinberger
230211fe26 fix: resolve Windows exec paths with extensions 2026-01-22 07:46:50 +00:00
Peter Steinberger
0f4e0cbe5f test: cover unpaired telegram dm native commands 2026-01-22 07:44:35 +00:00
Peter Steinberger
40b7447a80 docs: update clawtributors 2026-01-22 07:36:40 +00:00
Peter Steinberger
d30e9b7d56 fix: keep chat pinned on stream 2026-01-22 07:35:50 +00:00
Peter Steinberger
bc8e5ad6b3 fix: stabilize avatar tests on Windows 2026-01-22 07:24:12 +00:00
Lucas Czekaj
4b3e9c0f33 fix(exec): align node exec approvals (#1425)
Thanks @czekaj.

Co-authored-by: Lucas Czekaj <lukasz@czekaj.us>
2026-01-22 07:22:43 +00:00
Peter Steinberger
d83ea7f2da fix: stabilize session previews 2026-01-22 07:15:16 +00:00
Peter Steinberger
7004616e03 docs: note node-gyp workaround for sharp 2026-01-22 07:09:20 +00:00
Peter Steinberger
0d37a92c16 fix: remove duplicate loadConfig import 2026-01-22 07:08:13 +00:00
Peter Steinberger
37cbe387bf chore: update clawtributors 2026-01-22 07:08:13 +00:00
Peter Steinberger
8544df36b8 feat: extend Control UI assistant identity 2026-01-22 07:08:13 +00:00
Robby
3125637ad6 feat(webui): add custom assistant identity support
Adds the ability to customize the assistant's name and avatar in the Web UI.

Configuration options:
- config.ui.assistant.name: Custom name (replaces 'Assistant')
- config.ui.assistant.avatar: Emoji or letter for avatar (replaces 'A')

Also reads from workspace IDENTITY.md as fallback:
- Name: field sets the assistant name
- Emoji: field sets the avatar

Priority: config > IDENTITY.md > defaults

Closes #1383
2026-01-22 07:07:53 +00:00
Vignesh
aadb66e956 Merge pull request #1427 from vignesh07/docs/lobster-org-url 2026-01-21 23:07:39 -08:00
Peter Steinberger
ad6d048934 feat: add update wizard and guard elevated defaults 2026-01-22 07:06:19 +00:00
Peter Steinberger
d19a0249f8 fix: align rolling logs to local time 2026-01-22 07:02:52 +00:00
Peter Steinberger
b91e72824f chore: land PR #1422 (thanks @aj47)
Co-authored-by: AJ <yspdev@gmail.com>
2026-01-22 07:01:27 +00:00
Peter Steinberger
b573231cd1 fix: prevent exec approval resolve race 2026-01-22 07:01:27 +00:00
AJ
862f34ade7 fix: read account_id from Codex CLI auth for workspace billing 2026-01-22 07:01:10 +00:00
Vignesh Natarajan
d8ad865cf5 docs: update lobster repo url 2026-01-21 22:55:49 -08:00
Peter Steinberger
8a20f44228 fix: improve gateway ssh auth handling 2026-01-22 06:54:08 +00:00
Peter Steinberger
a056042caa chore: refresh macOS package pins 2026-01-22 06:40:02 +00:00
Peter Steinberger
d430a3a5c7 chore: update a2ui bundle hash 2026-01-22 06:40:02 +00:00
Peter Steinberger
319b4d02a0 fix: load workspace templates from docs 2026-01-22 06:39:28 +00:00
Peter Steinberger
30ca87094d fix: macOS auto bind loopback-first 2026-01-22 06:35:59 +00:00
Peter Steinberger
98ab2b4eae Merge pull request #1424 from clawdbot/feature/agent-avatar-support
fix: complete agent identity avatar support
2026-01-22 06:28:17 +00:00
Peter Steinberger
b63175d822 Merge remote-tracking branch 'origin/main' into feature/agent-avatar-support 2026-01-22 06:27:45 +00:00
Peter Steinberger
6539c09a93 Merge remote-tracking branch 'origin/main' into feature/agent-avatar-support 2026-01-22 06:03:56 +00:00
Peter Steinberger
23ea4a21e0 fix: skip elevated defaults when not allowed 2026-01-22 06:03:23 +00:00
Peter Steinberger
34686027b1 fix: inherit model overrides for thread sessions 2026-01-22 06:03:23 +00:00
Peter Steinberger
7b7c107ffe docs: update changelog for avatar follow-up (#1424) (thanks @dlauer) 2026-01-22 05:58:46 +00:00
Peter Steinberger
36cfe75a0b test: relax canvas host reload timing 2026-01-22 05:54:00 +00:00
Peter Steinberger
d425f1ebea test: align envelope timestamp expectations (#1329) (thanks @dlauer) 2026-01-22 05:51:42 +00:00
Peter Steinberger
8580b85f0b fix: subagents list uses command session 2026-01-22 05:43:50 +00:00
Peter Steinberger
5ff4ac7fb7 fix: use gateway subcommand for launchd 2026-01-22 05:43:02 +00:00
Peter Steinberger
a2981c5a2c feat: add elevated ask/full modes 2026-01-22 05:41:11 +00:00
Peter Steinberger
a59ac5cf6f feat: add agent identity avatars (#1329) (thanks @dlauer) 2026-01-22 05:37:15 +00:00
Peter Steinberger
5567bceb66 fix: restore daemon subcommand alias 2026-01-22 05:33:47 +00:00
Peter Steinberger
f98d31cdd3 style: format system prompt params test 2026-01-22 05:20:42 +00:00
Peter Steinberger
e0896de2bf feat: surface repo root in runtime prompt 2026-01-22 05:20:42 +00:00
Peter Steinberger
8d73c16488 fix: add changelog for Chrome restore prompt (#1419) (thanks @jamesgroat) 2026-01-22 05:17:45 +00:00
Peter Steinberger
0f8d0f37fd Merge pull request #1419 from jamesgroat/fix/chrome-restore-prompt
Browser: suppress Chrome restore prompt
2026-01-22 05:17:01 +00:00
Peter Steinberger
d912b02a43 docs: add control ui dev gatewayUrl note 2026-01-22 05:05:30 +00:00
Peter Steinberger
4dca662a5d chore(canvas): update a2ui bundle hash 2026-01-22 04:51:39 +00:00
Peter Steinberger
9063b9e61d chore(pnpm): update lockfile 2026-01-22 04:51:36 +00:00
Peter Steinberger
50049fd220 chore(macos): drop time-sensitive notification entitlement toggle 2026-01-22 04:50:03 +00:00
Peter Steinberger
9ead312118 feat(macos): move location access to permissions tab 2026-01-22 04:50:03 +00:00
Peter Steinberger
f02960df26 fix: avoid whatsapp config resurrection 2026-01-22 04:49:56 +00:00
Peter Steinberger
b60db040e2 test: align envelope timestamps with local tz 2026-01-22 04:49:41 +00:00
Peter Steinberger
af42cb3ded Merge pull request #1418 from MaudeBot/fix/export-section-meta
fix(ui): export SECTION_META from config-form module
2026-01-22 04:34:13 +00:00
Peter Steinberger
13dab38a26 fix: retry lobster spawn on windows 2026-01-22 04:31:25 +00:00
Peter Steinberger
351c73be01 docs: fix npm prefix guidance 2026-01-22 04:31:25 +00:00
Peter Steinberger
55ead9636c docs: add /model allowlist troubleshooting note 2026-01-22 04:28:57 +00:00
James Groat
fd597a796b Browser: suppress Chrome restore prompt 2026-01-21 21:27:34 -07:00
Peter Steinberger
ff3d8cab2b feat: preflight update runner before rebase 2026-01-22 04:19:33 +00:00
Peter Steinberger
9ae03b92bb docs: clarify prompt injection guidance 2026-01-22 04:19:33 +00:00
Peter Steinberger
5424b4173c fix: localize system event timestamps 2026-01-22 04:15:39 +00:00
Peter Steinberger
30a8478e1a fix: default envelope timestamps to local 2026-01-22 04:10:06 +00:00
Peter Steinberger
2fc926ab1c Merge pull request #1329 from dlauer/feature/agent-avatar-support
feat: add avatar support for agent identity
2026-01-22 04:09:00 +00:00
Peter Steinberger
1ac1e72a47 Merge pull request #1204 from cpojer/reminders
Improve `cron` reminder tool description.
2026-01-22 04:06:50 +00:00
Peter Steinberger
9450873c1b fix: align exec approvals default agent 2026-01-22 04:05:54 +00:00
Maude Bot
f40f16608c fix(ui): export SECTION_META from config-form module
Export the SECTION_META constant from config-form.render.ts and
re-export it through config-form.ts so it can be imported by config.ts.

This fixes a runtime error where SECTION_META was being referenced
but not properly exported from its source module.
2026-01-21 23:03:08 -05:00
Peter Steinberger
5fb6a0fd32 fix: map OpenCode Zen models to correct APIs 2026-01-22 04:02:53 +00:00
Peter Steinberger
3b2aff0d6f Merge pull request #1417 from czekaj/fix/exec-allowlist-agentid-derivation
fix(exec): derive agentId from sessionKey for allowlist lookup
2026-01-22 04:01:01 +00:00
Peter Steinberger
a2bea8e366 feat: add agent avatar support (#1329) (thanks @dlauer) 2026-01-22 04:00:07 +00:00
Peter Steinberger
2d583e877b fix: default exec approvals to main agent (#1417) (thanks @czekaj) 2026-01-22 03:58:53 +00:00
Lucas Czekaj
0c55b1e9ce fix(exec): derive agentId from sessionKey for allowlist lookup
When creating exec tools via chat/Discord, agentId was not passed,
causing allowlist lookup to use 'default' key instead of 'main'.
User's allowlist entries in agents.main were never matched.

Now derives agentId from sessionKey if not explicitly provided,
ensuring correct allowlist lookup for all exec paths.
2026-01-22 03:58:53 +00:00
Peter Steinberger
51cd9c7ff4 fix: make lobster tool tests windows-safe 2026-01-22 03:58:05 +00:00
Dave Lauer
7edc464b82 chore: fix formatting 2026-01-22 03:56:54 +00:00
Dave Lauer
754481716e feat: add avatar support for agent identity
- Add avatar field to IdentityConfig type
- Add avatar parsing in AgentIdentity from IDENTITY.md
- Add renderAvatar support for image avatars in webchat
- Add CSS styling for image avatars

Users can now configure a custom avatar for the assistant in the webchat
by setting 'identity.avatar' in the agent config or adding 'Avatar: path'
to IDENTITY.md. The avatar can be served from the assets folder.

Closes #TBD
2026-01-22 03:56:54 +00:00
Peter Steinberger
0c3d46cb72 Merge pull request #1103 from mkbehr/feat/cron-context-messages
feat(cron): Add parameter to control context messages
2026-01-22 03:52:34 +00:00
Peter Steinberger
654f9e5053 fix: cap cron context messages (#1103) (thanks @mkbehr) 2026-01-22 03:52:03 +00:00
Peter Steinberger
17fad54ca0 docs: update clawtributors 2026-01-22 03:37:29 +00:00
Peter Steinberger
0f7f7bb95f fix: msteams attachments + plugin prompt hints
Co-authored-by: Christof <10854026+Evizero@users.noreply.github.com>
2026-01-22 03:37:29 +00:00
Michael Behr
ffbf75d740 update description 2026-01-22 03:37:20 +00:00
Michael Behr
4642fae193 feat(cron): add contextMessages param to control reminder context 2026-01-22 03:37:20 +00:00
Peter Steinberger
5fe8c4ab8c docs: add gog gmail messages search note (#1220) (thanks @mbelinky)
Co-authored-by: Mariano <mbelinky@users.noreply.github.com>
2026-01-22 03:36:28 +00:00
Mariano Belinky
7b8405cbfb docs(gog): sanitize gmail messages example 2026-01-22 03:31:00 +00:00
Mariano Belinky
a96e7f59c0 docs(gog): add gmail messages search usage 2026-01-22 03:31:00 +00:00
Peter Steinberger
57f3d209de docs: expand lobster guides 2026-01-22 03:25:13 +00:00
Peter Steinberger
40757a8c18 fix: stabilize lobster tool subprocess 2026-01-22 03:20:23 +00:00
Peter Steinberger
472b8fe15d fix: prevent memory CLI hangs 2026-01-22 03:14:59 +00:00
Peter Steinberger
721737cc77 Merge pull request #1414 from czekaj/fix/discord-exec-resolvedpath-validation
fix(exec): pass undefined instead of null for optional approval params
2026-01-22 03:11:26 +00:00
Peter Steinberger
464de2978b docs: add special thanks 2026-01-22 02:48:17 +00:00
Peter Steinberger
9d22646120 fix: reduce invalid config log noise 2026-01-22 02:48:01 +00:00
Peter Steinberger
f1aa260b0e test: avoid downgrade prompt in update fallback 2026-01-22 02:44:13 +00:00
Peter Steinberger
b5c307d07f docs: highlight lobster in changelog 2026-01-22 02:37:26 +00:00
Peter Steinberger
2e1514095d fix: package Textual resources for mac app 2026-01-22 02:34:27 +00:00
Peter Steinberger
f4b3f33c8e Merge pull request #1152 from vignesh07/feat/lobster-plugin
feat: Add optional lobster plugin tool (typed workflows, approvals/resume)
2026-01-22 02:34:05 +00:00
Peter Steinberger
2d1d793651 Merge pull request #1373 from yazinsai/main
Add auto-refresh polling for debug view
2026-01-22 02:25:24 +00:00
Peter Steinberger
2f47b3f6bd fix: sync debug polling with route changes (#1373) (thanks @yazinsai) 2026-01-22 02:24:19 +00:00
Peter Steinberger
302bb64457 test: fix await-thenable in signal typing test 2026-01-22 02:20:42 +00:00
Lucas Czekaj
de898c423b fix(exec): pass undefined instead of null for optional approval params
TypeBox Type.Optional(Type.String()) accepts string|undefined but NOT null.
Discord exec was failing with 'resolvedPath must be string' because callers
passed null explicitly. Web UI worked because it skipped the approval request.

Fixes exec approval validation error in Discord-triggered sessions.
2026-01-21 18:14:51 -08:00
Peter Steinberger
47ebe29195 test: stabilize exec approvals path resolution 2026-01-22 02:07:40 +00:00
Peter Steinberger
cc74e0d188 feat(signal): add typing + read receipts 2026-01-22 02:04:59 +00:00
Yazin
d7d98c3971 Add auto-refresh polling for debug view
The debug view now automatically refreshes every 3 seconds when active,
similar to the logs view. This removes the need to manually click the
refresh button to see updated debug messages and status information.
2026-01-22 02:03:40 +00:00
Peter Steinberger
5bf7a9d0db test: avoid hardcoded version strings 2026-01-22 02:01:11 +00:00
Peter Steinberger
3ad0d2fe23 chore: bump version to 2026.1.21 2026-01-22 01:59:16 +00:00
Peter Steinberger
da98528651 Merge pull request #1256 from zknicker/feat/heartbeat-session-target
feat: configurable heartbeat session
2026-01-22 01:50:53 +00:00
Peter Steinberger
75dd1781b7 fix(macos): clear stale gateway failures 2026-01-22 01:48:41 +00:00
Peter Steinberger
1b947dcdf9 chore: update dependencies 2026-01-22 01:47:43 +00:00
Peter Steinberger
39073d5196 fix: finish model list alias + heartbeat session (#1256) (thanks @zknicker) 2026-01-22 01:36:58 +00:00
Zach Knickerbocker
7725dd6795 feat: configurable heartbeat session 2026-01-22 01:36:28 +00:00
Peter Steinberger
db61451c67 fix: handle Windows safe-bin exe names 2026-01-22 01:30:06 +00:00
Peter Steinberger
9780748bbb Merge pull request #1372 from zerone0x/fix/openrouter-tool-call-id-alphanumeric
fix(agents): use alphanumeric-only tool call IDs for OpenRouter compatibility
2026-01-22 01:17:16 +00:00
Peter Steinberger
f5cec1dd8b test: update fuzzy model selection expectations (#1372) (thanks @zerone0x) 2026-01-22 01:16:59 +00:00
Peter Steinberger
758f30eb7d refactor: satisfy swiftlint 2026-01-22 00:59:41 +00:00
Peter Steinberger
7e1a17e5e6 fix: unify exec approval ids 2026-01-22 00:59:29 +00:00
Peter Steinberger
4997a5b93f fix: improve macOS exec approvals 2026-01-22 00:46:31 +00:00
Nimrod Gutman
1092b30531 fix(node): handle invoke approvals and errors 2026-01-22 00:46:31 +00:00
Peter Steinberger
0704fe7dbb fix: enforce Mistral tool call ids (#1372) (thanks @zerone0x) 2026-01-22 00:43:15 +00:00
Peter Steinberger
7d93de710e fix: remove setup-token run option in onboarding 2026-01-22 00:42:04 +00:00
zerone0x
d51eca64cc fix(agents): make tool call ID sanitization conditional with standard/strict modes
- Add ToolCallIdMode type ('standard' | 'strict') for provider compatibility
- Standard mode (default): allows [a-zA-Z0-9_-] for readable session logs
- Strict mode: only [a-zA-Z0-9] for Mistral via OpenRouter
- Update sanitizeSessionMessagesImages to accept toolCallIdMode option
- Export ToolCallIdMode from pi-embedded-helpers barrel

Addresses review feedback on PR #1372 about readability.
2026-01-22 00:41:22 +00:00
zerone0x
d0f9e22a4b fix(agents): use alphanumeric-only tool call IDs for OpenRouter compatibility
Some providers like Mistral via OpenRouter require strictly alphanumeric
tool call IDs. The error message indicates: "Tool call id was
whatsapp_login_1768799841527_1 but must be a-z, A-Z, 0-9, with a length
of 9."

Changes:
- Update sanitizeToolCallId to strip all non-alphanumeric characters
  (previously allowed underscores and hyphens)
- Update makeUniqueToolId to use alphanumeric suffixes (x2, x3, etc.)
  instead of underscores
- Update isValidCloudCodeAssistToolId to validate alphanumeric-only IDs
- Update tests to reflect stricter sanitization

Fixes #1359

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 00:41:22 +00:00
Peter Steinberger
39b375e32b Merge pull request #1396 from JustYannicc/fix/macos-x86-universal-build
fix(mac): default to universal binary for distribution builds
2026-01-22 00:32:06 +00:00
Peter Steinberger
3b6ec501aa Merge origin/main into fix/macos-x86-universal-build 2026-01-22 00:31:54 +00:00
Peter Steinberger
2b254a9b39 fix: refine model directive handling 2026-01-22 00:29:27 +00:00
Clawd
429a2d7849 fix(mac): default to universal binary for distribution builds
Closes #1393

The distribution script (package-mac-dist.sh) now defaults BUILD_ARCHS to 'all',
producing universal binaries that run natively on both Apple Silicon and Intel Macs.

Previously, the script inherited the host architecture default from package-mac-app.sh,
which meant release builds done on ARM Macs only included ARM binaries.
2026-01-22 00:29:27 +00:00
Peter Steinberger
1cce83b21e fix: refine model directive handling 2026-01-22 00:28:49 +00:00
Clawd
8255e4649c fix(mac): default to universal binary for distribution builds
Closes #1393

The distribution script (package-mac-dist.sh) now defaults BUILD_ARCHS to 'all',
producing universal binaries that run natively on both Apple Silicon and Intel Macs.

Previously, the script inherited the host architecture default from package-mac-app.sh,
which meant release builds done on ARM Macs only included ARM binaries.
2026-01-22 00:28:49 +00:00
Peter Steinberger
7eef176afc fix: warn on unset gateway.mode 2026-01-22 00:21:08 +00:00
Peter Steinberger
06e496540f Merge pull request #1379 from ptn1411/feature/1378-zalouser-extension
Refs #1378: scaffold zalouser extension
2026-01-22 00:00:29 +00:00
Peter Steinberger
f76e3c1419 fix: enforce secure control ui auth 2026-01-21 23:58:42 +00:00
Dominic Damoah
bf6df6d6b7 feat: add Mattermost channel support
Add Mattermost as a supported messaging channel with bot API and WebSocket integration. Includes channel state tracking (tint, summary, details), multi-account support, and delivery target routing. Update documentation and tests to include Mattermost alongside existing channels.
2026-01-21 18:40:56 -05:00
Peter Steinberger
b4776af38c docs: clarify mac packaging guidance 2026-01-21 23:27:40 +00:00
Peter Steinberger
cd65e8e755 fix: type gateway lock handle 2026-01-21 23:05:11 +00:00
Peter Steinberger
28e547f120 fix: stabilize ci 2026-01-21 22:59:11 +00:00
Peter Steinberger
05a254746e fix(gateway): enforce singleton lock 2026-01-21 22:47:18 +00:00
Peter Steinberger
529372f762 Merge pull request #1398 from vignesh07/feat/models-command
fix(chat): add /models and stop /model from dumping full model list
2026-01-21 21:54:16 +00:00
Peter Steinberger
3b18efdd25 feat: tighten exec allowlist gating 2026-01-21 21:45:50 +00:00
Vignesh Natarajan
6e044b5f2f fix(models): include configured providers/models + ignore page with all 2026-01-21 13:14:18 -08:00
Vignesh Natarajan
310f916675 fix(models): handle out-of-range pages 2026-01-21 12:54:02 -08:00
Peter Steinberger
acd40e1780 docs: add showcase video 2026-01-21 20:53:55 +00:00
Peter Steinberger
b5fd66c92d fix: add explicit tailnet gateway bind 2026-01-21 20:36:09 +00:00
Peter Steinberger
45c1ccdfcf refactor: unify threading contexts 2026-01-21 20:35:12 +00:00
Peter Steinberger
76600e80ba docs: simplify heartbeat active hours example 2026-01-21 20:33:23 +00:00
Peter Steinberger
483a50f107 fix: correct nodes exec config typing 2026-01-21 20:32:43 +00:00
Peter Steinberger
31943dcecb feat: add heartbeat active hours 2026-01-21 20:30:37 +00:00
Peter Steinberger
717fb9e413 refactor(macos): drop CLI install UI 2026-01-21 20:26:25 +00:00
Peter Steinberger
ad7ef27f66 refactor(macos): tidy settings layout 2026-01-21 20:26:25 +00:00
Peter Steinberger
0d3b8f6ac3 feat: make nodes run exec-style 2026-01-21 20:25:12 +00:00
Peter Steinberger
6492e90c1b feat: add auth-aware cache defaults 2026-01-21 20:23:39 +00:00
Peter Steinberger
e4b3c8b98d fix(macos): switch connection mode to menu 2026-01-21 20:10:38 +00:00
Peter Steinberger
8b8e078ef8 chore(canvas): update a2ui bundle 2026-01-21 20:10:38 +00:00
Peter Steinberger
44a3539ffa tmp 2026-01-21 20:10:37 +00:00
Peter Steinberger
0daaa5b592 fix: restore 1h cache ttl option 2026-01-21 20:00:32 +00:00
Peter Steinberger
6866cca6d7 docs: clarify cache-ttl pruning window 2026-01-21 20:00:32 +00:00
Peter Steinberger
c145a0d116 docs: update changelog 2026-01-21 19:58:20 +00:00
Peter Steinberger
6c0a01dc90 fix: bundle mac model catalog 2026-01-21 19:58:19 +00:00
Peter Steinberger
41c9c214fc fix: drop obsolete pi-mono workarounds 2026-01-21 19:58:19 +00:00
Vignesh Natarajan
41d56c06b9 feat(commands): add /models and fix /model listing UX 2026-01-21 11:53:29 -08:00
Peter Steinberger
9f999f6554 fix: reset cache-ttl pruning window 2026-01-21 19:53:00 +00:00
Peter Steinberger
9f59ff325b feat: add cache-ttl pruning mode 2026-01-21 19:46:24 +00:00
Echo
c415ccaed5 feat(sessions): add channelIdleMinutes config for per-channel session idle durations (#1353)
* feat(sessions): add channelIdleMinutes config for per-channel session idle durations

Add new `channelIdleMinutes` config option to allow different session idle
timeouts per channel. For example, Discord sessions can now be configured
to last 7 days (10080 minutes) while other channels use shorter defaults.

Config example:
  sessions:
    channelIdleMinutes:
      discord: 10080  # 7 days

The channel-specific idle is passed as idleMinutesOverride to the existing
resolveSessionResetPolicy, integrating cleanly with the new reset policy
architecture.

* fix

* feat: add per-channel session reset overrides (#1353) (thanks @cash-echo-bot)

---------

Co-authored-by: Cash Williams <cashwilliams@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-21 19:10:31 +00:00
Peter Steinberger
403904ecd1 fix: harden port listener detection 2026-01-21 18:52:55 +00:00
Peter Steinberger
32550154f9 feat(queue): add per-channel debounce overrides 2026-01-21 18:50:55 +00:00
Peter Steinberger
6996c0f330 test: cover history image injection 2026-01-21 18:45:23 +00:00
Peter Steinberger
cf4f1ed03a fix: persist history image injections 2026-01-21 18:45:23 +00:00
Peter Steinberger
c913f05fb5 docs(discord): mention wildcard channel defaults 2026-01-21 18:21:24 +00:00
Peter Steinberger
88d76d4be5 refactor(channels): centralize match metadata 2026-01-21 18:21:19 +00:00
Peter Steinberger
b52ab96e2c docs(changelog): note discord wildcard fix 2026-01-21 17:56:01 +00:00
Peter Steinberger
f0a8b34198 fix(discord): align wildcard channel matching 2026-01-21 17:56:01 +00:00
Wimmie
64d29b0c31 feat(discord): add wildcard channel config support
Add support for '*' wildcard in Discord channel configuration,
matching the existing guild-level wildcard behavior.

This allows applying default channel settings (like autoThread)
to all channels without listing each one explicitly:

  guilds:
    '*':
      channels:
        '*': { autoThread: true }

Specific channel configs still take precedence over the wildcard.
2026-01-21 17:56:01 +00:00
Peter Steinberger
9b47f463b7 chore: rename gateway daemon prompts 2026-01-21 17:46:30 +00:00
Peter Steinberger
9605ad76c5 fix: preserve fetch preconnect in abort wrapper 2026-01-21 17:45:58 +00:00
Peter Steinberger
c129f0bbaa docs: align gateway service naming 2026-01-21 17:45:26 +00:00
Peter Steinberger
9e22f019db feat: fold gateway service commands into gateway 2026-01-21 17:45:26 +00:00
Shadow
6f58d508b8 chore: update carbon to v0.14.0 2026-01-21 11:36:56 -06:00
Peter Steinberger
84eadd92a1 Merge pull request #1384 from ameno-/fix/ssh-identity-agent
macOS: allow SSH agents without identity file
2026-01-21 17:32:44 +00:00
Peter Steinberger
fd918bf6bf fix: allow SSH agent auth without identity file (#1384) (thanks @ameno-) 2026-01-21 17:32:00 +00:00
Peter Steinberger
4e1806947d fix: normalize abort signals for fetch 2026-01-21 17:29:46 +00:00
Peter Steinberger
8aca606a6f docs: clarify bluebubbles message ids 2026-01-21 17:20:03 +00:00
Ameno Osman
56799a21be macOS: allow SSH agents without identity file 2026-01-21 17:19:51 +00:00
Peter Steinberger
d2a0e416ea test: align NO_REPLY typing expectations 2026-01-21 17:12:50 +00:00
Peter Steinberger
43afad9f51 fix: start instant typing at run start 2026-01-21 17:12:50 +00:00
Peter Steinberger
5d73a412c6 Merge pull request #1387 from clawdbot/temp/landpr-1369-followup
BlueBubbles: enforce short id resolution
2026-01-21 17:10:06 +00:00
Peter Steinberger
d0e8faea97 docs: update changelog for bluebubbles follow-up (#1387) 2026-01-21 17:09:51 +00:00
Peter Steinberger
cd25d69b4d fix: harden bluebubbles short ids and fetch wrapper (#1369) (thanks @tyler6204) 2026-01-21 17:09:15 +00:00
Peter Steinberger
c3adc50cb2 Merge pull request #1369 from tyler6204/fix/bluebubbles-gc-guid-resolution
BlueBubbles: short ID mapping, action resolution, and threading/typing fixes
2026-01-21 17:06:09 +00:00
Peter Steinberger
cbb9872478 docs: add FAQ entry for tool_use input error 2026-01-21 16:56:26 +00:00
Peter Steinberger
39e24c9937 docs: update node CLI references 2026-01-21 16:48:42 +00:00
Peter Steinberger
fa1bc589e4 feat: flatten node CLI commands 2026-01-21 16:48:42 +00:00
Peter Steinberger
0e003cb7f1 fix: normalize abort signals for telegram fetch 2026-01-21 16:46:58 +00:00
Pham Nam
a90fe1b245 Refs #1378: scaffold zalouser extension 2026-01-21 19:48:21 +07:00
Peter Steinberger
fb164b321e fix: model picker allowlist fallbacks 2026-01-21 11:22:33 +00:00
Peter Steinberger
884211a924 feat: render approvals tables on write 2026-01-21 11:10:03 +00:00
Peter Steinberger
9bd6b3fd54 feat: show node PATH and bootstrap node host env 2026-01-21 11:06:56 +00:00
Peter Steinberger
dc06b225cd fix: narrow configure model allowlist for Anthropic OAuth 2026-01-21 11:00:28 +00:00
Peter Steinberger
cdb35c3aae test: stabilize exec approvals homedir 2026-01-21 10:49:12 +00:00
Peter Steinberger
4e4f5558fc fix: limit /model list output 2026-01-21 10:47:37 +00:00
Peter Steinberger
8479dc97da fix: make session memory indexing async 2026-01-21 10:39:00 +00:00
Peter Steinberger
86ddd3c69c Merge pull request #1370 from parubets/fix-debug-ttl-cache
WIP: cache trace mvp for Anthropic
2026-01-21 10:35:31 +00:00
Peter Steinberger
49d53ff0bb fix: honor wildcard exec approvals on macOS 2026-01-21 10:27:19 +00:00
Peter Steinberger
97e8f9d619 fix: add diagnostics cache trace config (#1370) (thanks @parubets) 2026-01-21 10:23:30 +00:00
Andrii
5392fa0dfa cache trace mvp
Added a standalone cache tracing module and wired it into the embedded
runner so you can capture message flow and the exact context sent to
  Anthropic in a separate JSONL file.

  What changed

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

  How to enable

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

  What you’ll see

  - One JSON object per line with stage, messagesDigest, per‑message
messageFingerprints, and the actual messages if enabled.
  - The most important line is stage: "stream:context" — that is the
exact payload pi‑mono is sending. If this diverges from earlier stages,
you’ve
    found the mutation point.
2026-01-21 10:23:30 +00:00
Peter Steinberger
63d017c3af fix: add node tool failure context 2026-01-21 09:55:10 +00:00
Peter Steinberger
40646c73af feat: improve exec approvals defaults and wildcard 2026-01-21 09:55:10 +00:00
Peter Steinberger
43ea7665ef chore: bump bluebubbles to 2026.1.21-1 2026-01-21 09:43:19 +00:00
Peter Steinberger
ba131b0164 Update README to remove contributor acknowledgments
Removed special thanks and core contributors section.
2026-01-21 09:21:50 +00:00
Peter Steinberger
0693c7804f test: skip plugin tools in sessions tools test 2026-01-21 09:17:27 +00:00
Peter Steinberger
6c69ea2c91 refactor: centralize sandbox runtime label 2026-01-21 09:07:21 +00:00
Peter Steinberger
1e10dc1d3b fix: use plugin-sdk exports in bluebubbles 2026-01-21 09:03:09 +00:00
Peter Steinberger
c22a37976d fix: report sandboxed runtime in status 2026-01-21 08:59:32 +00:00
Tyler Yust
9b9bbae501 feat: enhance message context with full ID support for replies and caching
- Updated message processing to include full message IDs alongside short IDs for better context resolution.
- Improved reply handling by caching inbound messages, allowing for accurate sender and body resolution without exposing dropped content.
- Adjusted tests to validate the new full ID properties and their integration into the message handling workflow.
2026-01-21 00:45:01 -08:00
Tyler Yust
7bfc32fe33 feat: enhance message handling with short ID resolution and reply context improvements
- Implemented resolution of short message IDs to full UUIDs in both text and media sending functions.
- Updated reply context formatting to optimize token usage by including only necessary information.
- Introduced truncation for long reply bodies to further reduce token consumption.
- Adjusted tests to reflect changes in reply context handling and message ID resolution.
2026-01-21 00:45:01 -08:00
Tyler Yust
b073deee20 feat: implement short ID mapping for BlueBubbles messages and enhance reply context caching
- Added functionality to resolve short message IDs to full UUIDs and vice versa, optimizing token usage.
- Introduced a reply cache to store message context for replies when metadata is omitted in webhook payloads.
- Updated message handling to utilize short IDs for outbound messages and replies, improving efficiency.
- Enhanced error messages to clarify required parameters for actions like react, edit, and unsend.
- Added tests to ensure correct behavior of new features and maintain existing functionality.
2026-01-21 00:45:01 -08:00
Peter Steinberger
89c5035aa2 docs: restore 2026.1.20 release notes 2026-01-21 08:43:05 +00:00
Peter Steinberger
cb7791c8a4 chore: release 2026.1.20-2 2026-01-21 08:30:33 +00:00
Peter Steinberger
9a14267dfa chore: update appcast 2026-01-21 08:25:20 +00:00
Peter Steinberger
010d305401 chore: tidy package files list 2026-01-21 08:25:01 +00:00
Peter Steinberger
3210c91f6b chore: release 2026.1.20 2026-01-21 08:23:49 +00:00
Peter Steinberger
e3cea55d72 docs: add npm files check to release checklist 2026-01-21 08:10:53 +00:00
Peter Steinberger
687a902f3e fix: align chat composer 2026-01-21 07:48:00 +00:00
Peter Steinberger
fe860de148 fix: quiet update banner and skip duplicate plugin CLI 2026-01-21 07:37:22 +00:00
Peter Steinberger
bc8a59faa4 chore: release 2026.1.20-1 2026-01-21 07:37:22 +00:00
Peter Steinberger
91bcdad503 fix: guard anthropic refusal trigger 2026-01-21 07:28:49 +00:00
Peter Steinberger
ab97c6880b Merge pull request #1360 from SocialNerd42069/fix/duplicate-assistant-texts
fix: prevent duplicate assistant texts from whitespace differences
2026-01-21 06:31:01 +00:00
Peter Steinberger
65dd73b4c3 fix: clean up slack threading landings (#1360) (thanks @SocialNerd42069) 2026-01-21 06:29:36 +00:00
SocialNerd42069
b69aa011fe Add auto-notify on completion to coding-agent skill 2026-01-21 06:29:36 +00:00
SocialNerd42069
e3a44b10bc fix: prevent duplicate assistant texts from whitespace differences
- Add per-message dedup tracking in subscribeEmbeddedPiSession
- Compare both trimmed and normalized text to catch near-duplicates
- Reset dedup state on each new assistant message
- Add test for trailing whitespace edge case

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

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

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

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

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

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

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

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

Tests:
- swift test --filter GatewayNodeSessionTests
2026-01-20 20:50:20 -08:00
Dave Lauer
2f0dd9c4ee chore: fix swift formatting 2026-01-20 16:38:37 -05:00
Dave Lauer
2af497495f chore: regenerate protocol files 2026-01-20 16:21:15 -05:00
Dave Lauer
056b3e40d6 chore: fix formatting 2026-01-20 16:21:14 -05:00
Dave Lauer
6402a48482 feat: add avatar support for agent identity
- Add avatar field to IdentityConfig type
- Add avatar parsing in AgentIdentity from IDENTITY.md
- Add renderAvatar support for image avatars in webchat
- Add CSS styling for image avatars

Users can now configure a custom avatar for the assistant in the webchat
by setting 'identity.avatar' in the agent config or adding 'Avatar: path'
to IDENTITY.md. The avatar can be served from the assets folder.

Closes #TBD
2026-01-20 16:21:14 -05:00
cpojer
ed909d6013 Improve cron reminder tool description. 2026-01-19 10:42:21 +09:00
Vignesh Natarajan
9497ffcc50 Add SKILL.md to teach Clawdbot when/how to use Lobster 2026-01-18 12:11:25 -08:00
Vignesh Natarajan
032c780a79 Add lobster.md documentation 2026-01-18 11:07:47 -08:00
Vignesh Natarajan
e011c764a7 Gate lobster plugin tool in sandboxed contexts 2026-01-17 20:33:31 -08:00
Vignesh Natarajan
b2650ba672 Move lobster integration to optional plugin tool 2026-01-17 20:18:54 -08:00
Vignesh Natarajan
147fccd967 Add lobster tool for running local Lobster pipelines 2026-01-17 20:13:00 -08:00
1535 changed files with 127405 additions and 39841 deletions

143
.github/workflows/docker-release.yml vendored Normal file
View File

@@ -0,0 +1,143 @@
name: Docker Release
on:
push:
branches:
- main
tags:
- "v*"
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# Build amd64 image
build-amd64:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
outputs:
image-digest: ${{ steps.build.outputs.digest }}
image-metadata: ${{ steps.meta.outputs.json }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{version}},suffix=-amd64
type=semver,pattern={{version}},suffix=-arm64
type=ref,event=branch,suffix=-amd64
type=ref,event=branch,suffix=-arm64
- name: Build and push amd64 image
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
push: true
# Build arm64 image
build-arm64:
runs-on: ubuntu-24.04-arm
permissions:
packages: write
contents: read
outputs:
image-digest: ${{ steps.build.outputs.digest }}
image-metadata: ${{ steps.meta.outputs.json }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{version}},suffix=-amd64
type=semver,pattern={{version}},suffix=-arm64
type=ref,event=branch,suffix=-amd64
type=ref,event=branch,suffix=-arm64
- name: Build and push arm64 image
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/arm64
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
push: true
# Create multi-platform manifest
create-manifest:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
needs: [build-amd64, build-arm64]
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for manifest
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
- name: Create and push manifest
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
${{ needs.build-amd64.outputs.image-digest }} \
${{ needs.build-arm64.outputs.image-digest }}
env:
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}

View File

@@ -29,5 +29,6 @@ jobs:
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
CLAWDBOT_NO_ONBOARD: "1"
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
run: pnpm test:install:smoke

View File

@@ -7,6 +7,7 @@
- Tests: colocated `*.test.ts`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `clawdbot` in `devDependencies` or `peerDependencies` instead (runtime resolves `clawdbot/plugin-sdk` via jiti alias).
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
- Core channel docs: `docs/channels/`
@@ -17,11 +18,22 @@
- Docs are hosted on Mintlify (docs.clawd.bot).
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
- Doc headings and anchors: avoid em dashes and apostrophes in headings because they break Mintlify anchor links.
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
## exe.dev VM ops (general)
- Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set).
- SSH flaky: use exe.dev web terminal or Shelley (web agent); keep a tmux session for long ops.
- Update: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`).
- Config: use `clawdbot config set ...`; ensure `gateway.mode=local` is set.
- Discord: store raw token only (no `DISCORD_BOT_TOKEN=` prefix).
- Restart: stop old gateway and run:
`pkill -9 -f clawdbot-gateway || true; nohup clawdbot gateway run --bind loopback --port 18789 --force > /tmp/clawdbot-gateway.log 2>&1 &`
- Verify: `clawdbot channels status --probe`, `ss -ltnp | rg 18789`, `tail -n 120 /tmp/clawdbot-gateway.log`.
## Build, Test, and Development Commands
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
- Install deps: `pnpm install`
@@ -29,6 +41,7 @@
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
- Node remains supported for running built output (`dist/*`) and production installs.
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. Release checklist: `docs/platforms/mac/release.md`.
- Type-check/build: `pnpm build` (tsc)
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
@@ -50,6 +63,7 @@
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
- Do not set test workers above 16; tried already.
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (Clawdbot-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
- Full kit + whats covered: `docs/testing.md`.
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
@@ -64,6 +78,7 @@
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
- Goal: merge PRs. Prefer **rebase** when commits are clean; **squash** when history is messy.
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless its truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
@@ -92,6 +107,7 @@
## Agent-Specific Notes
- Vocabulary: "makeup" = "mac app".
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/clawdbot && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency.
@@ -100,7 +116,7 @@
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); dont hand-roll spinners/bars.
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- macOS logs: use `./scripts/clawlog.sh` to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; dont 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.
@@ -117,6 +133,10 @@
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
- Lint/format churn:
- If staged+unstaged diffs are formatting-only, auto-resolve without asking.
- If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
- Only ask when changes are semantic (logic/data/behavior).
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.

View File

@@ -2,291 +2,391 @@
Docs: https://docs.clawd.bot
## 2026.1.24
### Highlights
- Ollama: provider discovery + docs. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama
- Venius (Venice AI): highlight provider guide + cross-links + expanded guidance. https://docs.clawd.bot/providers/venice
### Changes
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
- Docs: add verbose installer troubleshooting guidance.
- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
- Docs: update Fly.io guide notes.
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
### Fixes
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
- Web UI: hide internal `message_id` hints in chat bubbles.
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.
- Auto-reply: don't treat `/models` as a `/model` directive. (#1753) Thanks @uos-status.
- Heartbeat: normalize target identifiers for consistent routing.
- TUI: reload history after gateway reconnect to restore session state. (#1663)
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
- Agents: use the active auth profile for auto-compaction recovery.
- Models: default missing custom provider fields so minimal configs are accepted.
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
- Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)
- Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work.
- Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
- Google Chat: normalize space targets without double `spaces/` prefix.
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
- Tests: cap Vitest workers on CI macOS to reduce timeouts. (#1597) Thanks @rohannagpal.
- Tests: avoid fake-timer dependency in embedded runner stream mock to reduce CI flakes. (#1597) Thanks @rohannagpal.
- Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal.
## 2026.1.23-1
### Fixes
- Packaging: include dist/tts output in npm tarball (fixes missing dist/tts/tts.js).
## 2026.1.23
### Highlights
- TTS: move Telegram TTS into core + enable model-driven TTS tags by default for expressive audio replies. (#1559) Thanks @Glucksberg. https://docs.clawd.bot/tts
- Gateway: add `/tools/invoke` HTTP endpoint for direct tool calls (auth + tool policy enforced). (#1575) Thanks @vignesh07. https://docs.clawd.bot/gateway/tools-invoke-http-api
- Heartbeat: per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer. https://docs.clawd.bot/gateway/heartbeat
- Deploy: add Fly.io deployment support + guide. (#1570) https://docs.clawd.bot/platforms/fly
- Channels: add Tlon/Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a. https://docs.clawd.bot/channels/tlon
### Changes
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt. https://docs.clawd.bot/multi-agent-sandbox-tools
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3. https://docs.clawd.bot/bedrock
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`. (commit 71203829d) https://docs.clawd.bot/cli/system
- CLI: add live auth probes to `clawdbot models status` for per-profile verification. (commit 40181afde) https://docs.clawd.bot/cli/models
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it. (commit 2c85b1b40)
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node). (commit c3cb26f7c)
- Plugins: add optional `llm-task` JSON-only tool for workflows. (#1498) Thanks @vignesh07. https://docs.clawd.bot/tools/llm-task
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
- Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits. (commit 66eec295b)
- Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc. https://docs.clawd.bot/automation/cron-vs-heartbeat
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc. https://docs.clawd.bot/gateway/heartbeat
### Fixes
- Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518)
- Heartbeat: accept plugin channel ids for heartbeat target validation + UI hints.
- Messaging/Sessions: mirror outbound sends into target session keys (threads + dmScope), create session entries on send, and normalize session key casing. (#1520, commit 4b6cdd1d3)
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
- Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts). (commit 5662a9cdf)
- Daemon: use platform PATH delimiters when building minimal service paths. (commit a4e57d3ac)
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
- Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops). (commit 8ea8801d0)
- Agents: ignore IDENTITY.md template placeholders when parsing identity. (#1556)
- Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
- Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests. (commit 084002998)
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
- Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes. (commit f70ac0c7c)
- Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp). (commit d905ca0e0)
- Telegram: render markdown in media captions. (#1478)
- MS Teams: remove `.default` suffix from Graph scopes and Bot Framework probe scopes. (#1507, #1574) Thanks @Evizero.
- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS. (commit 69f645c66)
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank. (commit d57cb2e1a)
- TUI: forward unknown slash commands, include Gateway commands in autocomplete, and render slash replies as system output. (commit 1af227b61, commit 8195497ce, commit 6fba598ea)
- CLI: auth probe output polish (table output, inline errors, reduced noise, and wrap fixes in `clawdbot models status`). (commit da3f2b489, commit 00ae21bed, commit 31e59cd58, commit f7dc27f2d, commit 438e782f8, commit 886752217, commit aabe0bed3, commit 81535d512, commit c63144ab1)
- Media: only parse `MEDIA:` tags when they start the line to avoid stripping prose mentions. (#1206)
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
- Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.
## 2026.1.22
### Changes
- Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.
- Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren.
- Slack: add chat-type reply threading overrides via `replyToModeByChatType`. (#1442) Thanks @stefangalescu.
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
### Fixes
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
- Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
- Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.
- Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.
- Gateway: stop the service before uninstalling and fail if it remains loaded.
- Agents: surface concrete API error details instead of generic AI service errors.
- Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
- Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.
- Agents: make tool summaries more readable and only show optional params when set.
- Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.
- Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl.
- CLI: prefer `~` for home paths in output.
- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.
- Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.
- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.
- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
- Slack: reduce WebClient retries to avoid duplicate sends. (#1481)
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
- Discord: honor accountId across message actions and cron deliveries. (#1492) Thanks @svkozak.
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
- macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)
## 2026.1.21-2
### Fixes
- Control UI: ignore bootstrap identity placeholder text for avatar values and fall back to the default avatar. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui
- Slack: remove deprecated `filetype` field from `files.uploadV2` to eliminate API warnings. (#1447)
## 2026.1.21
### Changes
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
- 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.
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing.
- 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.
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
- Signal: add typing indicators and DM read receipts via signal-cli.
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
- Docs: add /model allowlist troubleshooting note. (#1405)
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
### Breaking
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents dont have to constantly convert.
### Fixes
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
- Embedded runner: persist injected history images so attachments arent reloaded each turn. (#1374) Thanks @Nicell.
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
- macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-.
- Gateway: prevent multiple gateways from sharing the same config/state at once (singleton lock).
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
- Typing: start instant typing indicators at run start so DMs and mentions show immediately.
- 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.
- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo.
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204.
- Infra: preserve fetch helper methods when wrapping abort signals. (#1387)
- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc.
## 2026.1.20
### Changes
- Deps: update workspace + memory-lancedb dependencies.
- Dev: use tsgo for dev/watch builds by default; set `CLAWDBOT_TS_COMPILER=tsc` to opt out.
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
- Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui
- Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui
- TUI: add syntax highlighting for code blocks. (#1200) https://docs.clawd.bot/tui
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) https://docs.clawd.bot/tui
- TUI: add a searchable model picker for quicker model selection. (#1198) https://docs.clawd.bot/tui
- TUI: add input history (up/down) for submitted messages. (#1348) https://docs.clawd.bot/tui
- ACP: add `clawdbot acp` for IDE integrations. https://docs.clawd.bot/cli/acp
- ACP: add `clawdbot acp client` interactive harness for debugging. https://docs.clawd.bot/cli/acp
- Skills: add download installs with OS-filtered options. https://docs.clawd.bot/tools/skills
- Skills: add the local sherpa-onnx-tts skill. https://docs.clawd.bot/tools/skills
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. https://docs.clawd.bot/concepts/memory
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates. https://docs.clawd.bot/concepts/memory
- Memory: add OpenAI batch indexing for embeddings when configured. https://docs.clawd.bot/concepts/memory
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings. https://docs.clawd.bot/concepts/memory
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). https://docs.clawd.bot/concepts/memory
- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default. https://docs.clawd.bot/concepts/memory
- Memory: add `--verbose` logging for memory status + batch indexing details. https://docs.clawd.bot/concepts/memory
- Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.clawd.bot/concepts/memory
- Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) https://docs.clawd.bot/tools/browser
- Nostr: add the Nostr channel plugin with profile management + onboarding defaults. (#1323) https://docs.clawd.bot/channels/nostr
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) https://docs.clawd.bot/channels/matrix
- Slack: add HTTP webhook mode via Bolt HTTP receiver. (#1143) https://docs.clawd.bot/channels/slack
- Telegram: enrich forwarded-message context with normalized origin details + legacy fallback. (#1090) https://docs.clawd.bot/channels/telegram
- Discord: fall back to `/skill` when native command limits are exceeded. (#1287)
- Discord: expose `/skill` globally. (#1287)
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) https://docs.clawd.bot/plugins/zalouser
- Plugins: require manifest-embedded config schemas with preflight validation warnings. (#1272) https://docs.clawd.bot/plugins/manifest
- Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest
- Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui
- Agents/UI: add agent avatar support in identity config, IDENTITY.md, and the Control UI. (#1329) https://docs.clawd.bot/gateway/configuration
- Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools
- Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles
- Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.
- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime. https://docs.clawd.bot/channels/zalo
- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime. https://docs.clawd.bot/plugins/zalouser
- Plugins: allow optional agent tools with explicit allowlists and add the plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
- Plugins: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
- Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) — thanks @sebslight.
- Channels: add the Nostr plugin channel with profile management + onboarding install defaults. (#1323) — thanks @joelklabo.
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
- Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow.
- Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0.
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
- Security: warn when <=300B models run without sandboxing and with web tools enabled.
- Skills: add download installs with OS-filtered install options; add local sherpa-onnx-tts skill.
- Docs: clarify WhatsApp voice notes and Windows WSL portproxy LAN access notes.
- UI: add copy-as-markdown with error feedback and drop legacy list view. (#1345) — thanks @bradleypriest.
- TUI: add input history (up/down) for submitted messages. (#1348) — thanks @vignesh07.
### Fixes
- iOS: explain Talk mode is unavailable on the simulator to avoid Speech live-audio crashes. (#1358) — thanks @vignesh07.
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch; gate heartbeat/webhook logging. (#1244) — thanks @oscargavin.
- CLI: preserve cron delivery settings when editing message payloads. (#1322) — thanks @KrauseFx.
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output.
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
- Doctor: clarify plugin auto-enable hint text in the startup banner.
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
- Gateway: clarify connect/validation errors for gateway params. (#1347) — thanks @vignesh07.
- Gateway: preserve restart wake routing + thread replies across restarts. (#1337) — thanks @John-Rood.
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
- Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
- UI: preserve ordered list numbering in chat markdown. (#1341) — thanks @bradleypriest.
- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342) — thanks @ameno-.
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346) — thanks @fogboots.
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
- Anthropic: default API prompt caching to 1h with configurable TTL override; ignore TTL for OAuth.
- Discord: make resolve warnings avoid raw JSON payloads on rate limits.
- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)
- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
## 2026.1.19-3
### Changes
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
- Gateway: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229) — thanks @RyanLisse.
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) — thanks @steipete.
### Fixes
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
- Browser: register AI snapshot refs for act commands. (#1282) — thanks @John-Rood.
## 2026.1.19-2
### Changes
- Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.
- Android: bump okhttp + dnsjava to satisfy lint dependency checks.
- Docs: refresh Android node discovery docs for the Gateway WS service type.
### Fixes
- Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.
- CLI: skip runner rebuilds when dist is fresh. (#1231) — thanks @mukhtharcm, @thewilloftheshadow.
## 2026.1.19-1
### Breaking
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
### Changes
- Gateway: add `/v1/responses` endpoint (OpenResponses API) for agentic workflows with item-based input and semantic streaming events. Enable via `gateway.http.endpoints.responses.enabled: true`.
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
- Gateway/API: add `/v1/responses` (OpenResponses) with item-based input + semantic streaming events. (#1229)
- Gateway/API: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229)
- Usage: add `/usage cost` summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs
- Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.clawd.bot/cli/security
- Exec: add host/security/ask routing for gateway + node exec. https://docs.clawd.bot/tools/exec
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node). https://docs.clawd.bot/tools/exec
- Exec approvals: migrate approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists + skill auto-allow toggle, and add approvals UI + node exec lifecycle events. https://docs.clawd.bot/tools/exec-approvals
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`. https://docs.clawd.bot/cli/node
- Nodes: add node daemon service install/status/start/stop/restart. https://docs.clawd.bot/cli/node
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) https://docs.clawd.bot/concepts/session
- Sessions: allow `sessions_spawn` to override thinking level for sub-agent runs. https://docs.clawd.bot/tools/subagents
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers. https://docs.clawd.bot/concepts/groups
- Models: add Qwen Portal OAuth provider support. (#1120) https://docs.clawd.bot/providers/qwen
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. https://docs.clawd.bot/start/onboarding
- Docs: clarify allowlist input types and onboarding behavior for messaging channels. https://docs.clawd.bot/start/onboarding
- Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.clawd.bot/platforms/android
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.clawd.bot/bedrock
- Docs: clarify WhatsApp voice notes. https://docs.clawd.bot/channels/whatsapp
- Docs: clarify Windows WSL portproxy LAN access notes. https://docs.clawd.bot/platforms/windows
- Docs: refresh bird skill install metadata and usage notes. (#1302) https://docs.clawd.bot/tools/browser-login
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
- Agents: clarify node_modules read-only guidance in agent instructions.
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) — thanks @Whoaa512.
### Fixes
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
- Agents: add `clawdbot agents set-identity` helper and update bootstrap guidance for multi-agent setups. (#1222) — thanks @ThePickle31.
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214) — thanks @ameno-.
- Memory: show total file counts + scan issues in `clawdbot memory status`; fall back to non-batch embeddings after repeated batch failures.
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226) — thanks @VACInc. (fixes #1225)
## 2026.1.18-5
### Changes
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels.
- TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07.
- Docs: clarify allowlist input types and onboarding behavior for messaging channels.
### Fixes
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
- Docs: make docs:list fail fast with a clear error if the docs directory is missing.
- macOS: load menu session previews asynchronously so items populate while the menu is open.
- macOS: use label colors for session preview text so previews render in menu subviews.
- macOS: suppress usage error text in the menubar cost view.
- Telegram: honor pairing allowlists for native slash commands.
- TUI: highlight model search matches and stabilize search ordering.
- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195) — thanks @gumadeiras.
- Slack: resolve Bolt import interop for Bun + Node. (#1191) — thanks @CoreyH.
- Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.
- Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.
## 2026.1.18-4
### Changes
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
- Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
- Android: bump okhttp + dnsjava to satisfy lint dependency checks.
- Build: update workspace + core/plugin deps.
- Build: use tsgo for dev/watch builds by default (opt out with `CLAWDBOT_TS_COMPILER=tsc`).
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.
- macOS: stop syncing Peekaboo in postinstall.
- Swabble: use the tagged Commander Swift package release.
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
- Memory: add native Gemini embeddings provider for memory search. (#1151)
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
### Breaking
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety. Run `clawdbot doctor --fix` to repair, then update plugins (`clawdbot plugins update`) if you use any.
### Fixes
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244)
- Diagnostics: gate heartbeat/webhook logging. (#1244)
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
- Gateway: allow mobile node client ids for iOS + Android handshake validation. (#1354)
- Gateway: clarify connect/validation errors for gateway params. (#1347)
- Gateway: preserve restart wake routing + thread replies across restarts. (#1337)
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
- Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.
- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241)
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. (#1137)
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166) — thanks @AlexMikhalev.
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
- Sessions: fall back to session labels when listing display names. (#1124)
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226)
- Doctor: clarify plugin auto-enable hint text in the startup banner.
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
- Docs: make docs:list fail fast with a clear error if the docs directory is missing.
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297)
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
- CLI: preserve cron delivery settings when editing message payloads. (#1322)
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output.
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207)
- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195)
- CLI: skip runner rebuilds when dist is fresh. (#1231)
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
- Status: show both usage windows with reset hints when usage data is available. (#1101)
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315)
- UI: preserve ordered list numbering in chat markdown. (#1341)
- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342)
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283)
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212)
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202)
- TUI: align custom editor initialization with the latest pi-tui API. (#1298)
- TUI: show generic empty-state text for searchable pickers. (#1201)
- TUI: highlight model search matches and stabilize search ordering.
- Configure: hide OpenRouter auto routing model from the model picker. (#1182)
- Memory: show total file counts + scan issues in `clawdbot memory status`.
- Memory: fall back to non-batch embeddings after repeated batch failures.
- Memory: apply OpenAI batch defaults even without explicit remote config.
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
## 2026.1.18-3
### Changes
- Exec: add host/security/ask routing for gateway + node exec.
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
- macOS: add approvals socket UI server + node exec lifecycle events.
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
- Nodes: add node daemon service install/status/start/stop/restart.
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
- Agents: auto-inject local image references for vision models and avoid reloading history images. (#1098) — thanks @tyler6204.
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
- ACP: add experimental ACP support for IDE integrations (`clawdbot acp`). Thanks @visionik.
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
- macOS: add exec-host IPC for node service `system.run` with HMAC + peer UID checks.
### Fixes
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
- Tools: return a companion-app-required message when node exec is requested with no paired node.
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
## 2026.1.18-2
### Fixes
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
## 2026.1.18-1
### Changes
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
### Fixes
- Memory: apply OpenAI batch defaults even without explicit remote config.
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
- Discord: only emit slow listener warnings after 30s.
## 2026.1.17-6
### Changes
- Plugins: add exclusive plugin slots with a dedicated memory slot selector.
- Memory: ship core memory tools + CLI as the bundled `memory-core` plugin.
- Docs: document plugin slots and memory plugin behavior.
- Plugins: add the bundled BlueBubbles channel plugin (disabled by default).
- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime.
- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime.
## 2026.1.17-5
### Changes
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback.
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates.
- CLI: surface FTS + embedding cache state in `clawdbot memory status`.
- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default.
- Plugins: allow optional agent tools with explicit allowlists and add plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
- Tools: centralize plugin tool policy helpers.
- Commands: add `/subagents info` and show sub-agent counts in `/status`.
- Docs: clarify plugin agent tool configuration. https://docs.clawd.bot/plugins/agent-tools
### Fixes
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
## 2026.1.18-1
### Changes
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
### Fixes
- Memory: apply OpenAI batch defaults even without explicit remote config.
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
- Discord: only emit slow listener warnings after 30s.
## 2026.1.17-3
### Changes
- Memory: add OpenAI Batch API indexing for embeddings when configured.
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings.
### Fixes
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
## 2026.1.17-2
### Changes
### Fixes
- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.
- Memory: parallelize embedding indexing with rate-limit retries.
- Memory: split overly long lines to keep embeddings under token limits.
- Memory: skip empty chunks to avoid invalid embedding inputs.
- Sessions: fall back to session labels when listing display names. (#1124) — thanks @abdaraxus.
- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123) — thanks @thewilloftheshadow.
## 2026.1.17-1
### Changes
- Telegram: enrich forwarded message context with normalized origin details + legacy fallback. (#1090) — thanks @sleontenko.
- macOS: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x.
- macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg.
- CLI: surface update availability in `clawdbot status`.
- CLI: add `clawdbot memory status --deep/--index` probes.
- CLI: add playful update completion quips.
### Fixes
- Doctor: avoid re-adding WhatsApp ack reaction config when only legacy auth files exist. (#1087) — thanks @YuriNachos.
- Hooks: parse multi-line/YAML frontmatter metadata blocks (JSON5-friendly). (#1114) — thanks @sebslight.
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
- Windows: install gateway scheduled task as the current user; show friendly guidance instead of failing on access denied.
- Status: show both usage windows with reset hints when usage data is available. (#1101) — thanks @rhjoh.
- Memory: probe sqlite-vec availability in `clawdbot memory status`.
- Memory: split embedding batches to avoid OpenAI token limits during indexing.
- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118) — thanks @sleontenko.
- Memory: probe sqlite-vec availability in `clawdbot memory status`.
- Exec approvals: enforce allowlist when ask is off.
- Exec approvals: prefer raw command for node approvals/events.
- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.
- Tools: return a companion-app-required message when node exec is requested with no paired node.
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
- Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297)
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147)
- Discord: make resolve warnings avoid raw JSON payloads on rate limits.
- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)
- Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.
- Discord: only emit slow listener warnings after 30s.
- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123)
- Telegram: honor pairing allowlists for native slash commands.
- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118)
- Slack: resolve Bolt import interop for Bun + Node. (#1191)
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346)
- Browser: register AI snapshot refs for act commands. (#1282)
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
- Anthropic: default API prompt caching to 1h with configurable TTL override.
- Anthropic: ignore TTL for OAuth.
- Auth profiles: keep auto-pinned preference while allowing rotation on failover. (#1138)
- Auth profiles: user pins stay locked. (#1138)
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332)
- Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
- Windows: install gateway scheduled task as the current user.
- Windows: show friendly guidance instead of failing on access denied.
- macOS: load menu session previews asynchronously so items populate while the menu is open.
- macOS: use label colors for session preview text so previews render in menu subviews.
- macOS: suppress usage error text in the menubar cost view.
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166)
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214)
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
## 2026.1.16-2

View File

@@ -17,7 +17,7 @@
</p>
**Clawdbot** is a *personal AI assistant* you run on your own devices.
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
@@ -65,7 +65,7 @@ clawdbot gateway --port 18789 --verbose
# Send a message
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
clawdbot agent --message "Ship checklist" --thinking high
```
@@ -106,7 +106,7 @@ Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted i
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Slack:
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Google Chat/Slack:
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
- Approve with: `clawdbot pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`).
@@ -116,7 +116,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## Highlights
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
@@ -138,7 +138,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
### Channels
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.clawd.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (extension), [Matrix](https://docs.clawd.bot/channels/matrix) (extension), [Zalo](https://docs.clawd.bot/channels/zalo) (extension), [Zalo Personal](https://docs.clawd.bot/channels/zalouser) (extension), [WebChat](https://docs.clawd.bot/web/webchat).
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Google Chat](https://docs.clawd.bot/channels/googlechat) (Chat API), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.clawd.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (extension), [Matrix](https://docs.clawd.bot/channels/matrix) (extension), [Zalo](https://docs.clawd.bot/channels/zalo) (extension), [Zalo Personal](https://docs.clawd.bot/channels/zalouser) (extension), [WebChat](https://docs.clawd.bot/web/webchat).
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
### Apps + nodes
@@ -169,7 +169,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## How it works (short)
```
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
┌───────────────────────────────┐
@@ -252,7 +252,7 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc
## Chat commands
Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands are owner-only):
Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only):
- `/status` — compact session status (model + tokens, cost when available)
- `/new` or `/reset` — reset the session
@@ -471,35 +471,38 @@ by Peter Steinberger and the community.
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
AI/vibe-coded PRs welcome! 🤖
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
Core contributors:
- @cpojer — Telegram onboarding UX + docs
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
[pi-mono](https://github.com/badlogic/pi-mono).
Thanks to all clawtributors:
<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/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/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/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/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/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/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/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/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/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/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/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=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/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/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/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/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/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/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/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=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/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/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/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>
<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/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></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/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></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/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/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/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></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/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/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/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></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/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/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></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/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/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/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/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></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/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></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/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></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/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/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/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/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/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/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/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/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/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></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/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></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/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/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/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/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></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/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/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/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></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/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></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/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></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/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></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/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></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/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/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/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/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></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/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></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/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></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/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/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/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/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></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/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/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=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/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/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/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/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/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/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/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></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/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></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/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/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/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/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/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/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/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></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/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/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></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/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></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/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></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/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></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/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/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></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/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></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/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></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/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></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/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a>
<a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></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/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></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/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></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/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=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></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/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/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></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=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/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></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/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></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=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></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/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></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/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>

View File

@@ -3,273 +3,213 @@
<channel>
<title>Clawdbot</title>
<item>
<title>2026.1.16-2</title>
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
<title>2026.1.23</title>
<pubDate>Sat, 24 Jan 2026 13:02:18 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>6273</sparkle:version>
<sparkle:shortVersionString>2026.1.16-2</sparkle:shortVersionString>
<sparkle:version>7750</sparkle:version>
<sparkle:shortVersionString>2026.1.23</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
<description><![CDATA[<h2>Clawdbot 2026.1.23</h2>
<h3>Highlights</h3>
<ul>
<li>TTS: allow model-driven TTS tags by default for expressive audio replies (laughter, singing cues, etc.).</li>
</ul>
<h3>Changes</h3>
<ul>
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</li>
<li>Gateway: add /tools/invoke HTTP endpoint for direct tool calls and document it. (#1575) Thanks @vignesh07.</li>
<li>Agents: keep system prompt time zone-only and move current time to <code>session_status</code> for better cache hits.</li>
<li>Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.</li>
<li>Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).</li>
<li>Heartbeat: add per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer.</li>
<li>Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.</li>
<li>CLI: restart the gateway by default after <code>clawdbot update</code>; add <code>--no-restart</code> to skip it.</li>
<li>CLI: add live auth probes to <code>clawdbot models status</code> for per-profile verification.</li>
<li>CLI: add <code>clawdbot system</code> for system events + heartbeat controls; remove standalone <code>wake</code>.</li>
<li>Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.</li>
<li>Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.</li>
<li>Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.</li>
<li>Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.</li>
<li>Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.</li>
<li>Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.</li>
<li>TTS: move Telegram TTS into core with auto-replies, commands, and gateway methods. (#1559) Thanks @Glucksberg.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518)</li>
<li>Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.</li>
<li>Messaging: mirror outbound sends into target session keys (threads + dmScope) and create session entries on send. (#1520)</li>
<li>Sessions: normalize session key casing to lowercase for consistent routing.</li>
<li>BlueBubbles: normalize group session keys for outbound mirroring. (#1520)</li>
<li>Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.</li>
<li>Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.</li>
<li>Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops).</li>
<li>Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)</li>
<li>Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.</li>
<li>Docker: update gateway command in docker-compose and Hetzner guide. (#1514)</li>
<li>Sessions: reject array-backed session stores to prevent silent wipes. (#1469)</li>
<li>Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.</li>
<li>UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.</li>
<li>UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.</li>
<li>Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.</li>
<li>Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.</li>
<li>Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.</li>
<li>Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes.</li>
<li>Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp).</li>
<li>Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.</li>
<li>Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts).</li>
<li>TUI: forward unknown slash commands (for example, <code>/context</code>) to the Gateway.</li>
<li>TUI: include Gateway slash commands in autocomplete and <code>/help</code>.</li>
<li>CLI: skip usage lines in <code>clawdbot models status</code> when provider usage is unavailable.</li>
<li>CLI: suppress diagnostic session/run noise during auth probes.</li>
<li>CLI: hide auth probe timeout warnings from embedded runs.</li>
<li>CLI: render auth probe results as a table in <code>clawdbot models status</code>.</li>
<li>CLI: suppress probe-only embedded logs unless <code>--verbose</code> is set.</li>
<li>CLI: move auth probe errors below the table to reduce wrapping.</li>
<li>CLI: prevent ANSI color bleed when table cells wrap.</li>
<li>CLI: explain when auth profiles are excluded by auth.order in probe details.</li>
<li>CLI: drop the em dash when the banner tagline wraps to a second line.</li>
<li>CLI: inline auth probe errors in status rows to reduce wrapping.</li>
<li>Telegram: render markdown in media captions. (#1478)</li>
<li>Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests.</li>
<li>Agents: trigger model fallback when auth profiles are all in cooldown or unavailable. (#1522)</li>
<li>Daemon: use platform PATH delimiters when building minimal service paths.</li>
<li>Tests: skip embedded runner ordering assertion on Windows to avoid CI timeouts.</li>
<li>Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.</li>
<li>TUI: render Gateway slash-command replies as system output (for example, <code>/context</code>).</li>
<li>Media: only parse <code>MEDIA:</code> tags when they start the line to avoid stripping prose mentions. (#1206)</li>
<li>Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.</li>
<li>Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)</li>
<li>Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.</li>
<li>MS Teams (plugin): remove <code>.default</code> suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.</li>
<li>MS Teams (plugin): remove <code>.default</code> suffix from Bot Framework probe scope to avoid double-appending. (#1574) Thanks @Evizero.</li>
<li>Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)</li>
<li>Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)</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.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.23/Clawdbot-2026.1.23.zip" length="22326233" type="application/octet-stream" sparkle:edSignature="p40dFczUfmMpsif4BrEUYVqUPG2WiBXleWgefwu4WiqjuyXbw7CAaH5CpQKig/k2qRLlE59kX7AR/qJqmy+yCA=="/>
</item>
<item>
<title>2026.1.15</title>
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
<title>2026.1.22</title>
<pubDate>Fri, 23 Jan 2026 08:58:14 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5998</sparkle:version>
<sparkle:shortVersionString>2026.1.15</sparkle:shortVersionString>
<sparkle:version>7530</sparkle:version>
<sparkle:shortVersionString>2026.1.22</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.15</h2>
<description><![CDATA[<h2>Clawdbot 2026.1.22</h2>
<h3>Changes</h3>
<ul>
<li>Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.</li>
<li>Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren.</li>
<li>Slack: add chat-type reply threading overrides via <code>replyToModeByChatType</code>. (#1442) Thanks @stefangalescu.</li>
<li>BlueBubbles: add <code>asVoice</code> support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.</li>
<li>Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.</li>
<li>Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.</li>
<li>Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.</li>
<li>Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.</li>
<li>Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.</li>
<li>Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.</li>
<li>Gateway: stop the service before uninstalling and fail if it remains loaded.</li>
<li>Agents: surface concrete API error details instead of generic AI service errors.</li>
<li>Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)</li>
<li>Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.</li>
<li>Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.</li>
<li>Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.</li>
<li>Agents: make tool summaries more readable and only show optional params when set.</li>
<li>Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.</li>
<li>Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl.</li>
<li>CLI: prefer <code>~</code> for home paths in output.</li>
<li>Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.</li>
<li>Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.</li>
<li>Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.</li>
<li>Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.</li>
<li>Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.</li>
<li>Slack: reduce WebClient retries to avoid duplicate sends. (#1481)</li>
<li>Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.</li>
<li>macOS: prefer linked channels in gateway summary to avoid false “not linked” status.</li>
<li>macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)</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.22/Clawdbot-2026.1.22.zip" length="22302446" type="application/octet-stream" sparkle:edSignature="w/EzfwGBCRRuCg5vz8enIfYujxOZJWRw9PaunQ7gIafKwnBJSTtxcnkvMVwQsnBwB6VN5Tu2MPij7PjDFFX+CA=="/>
</item>
<item>
<title>2026.1.21</title>
<pubDate>Thu, 22 Jan 2026 12:22:35 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>7374</sparkle:version>
<sparkle:shortVersionString>2026.1.21</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.21</h2>
<h3>Highlights</h3>
<ul>
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
<li>Browser: improve remote CDP/Browserless support (auth passthrough, <code>wss</code> upgrade, timeouts, clearer errors).</li>
<li>Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.</li>
<li>Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).</li>
<li>Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster</li>
<li>Custom assistant identity + avatars in the Control UI. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui</li>
<li>Cache optimizations: cache-ttl pruning + defaults reduce token spend on cold requests. https://docs.clawd.bot/concepts/session-pruning</li>
<li>Exec approvals + elevated ask/full modes. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/elevated</li>
<li>Signal typing/read receipts + MSTeams attachments. https://docs.clawd.bot/channels/signal https://docs.clawd.bot/channels/msteams</li>
<li><code>/models</code> UX refresh + <code>clawdbot update wizard</code>. https://docs.clawd.bot/cli/models https://docs.clawd.bot/cli/update</li>
</ul>
<h3>Changes</h3>
<ul>
<li>Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07.</li>
<li>Agents/UI: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. https://docs.clawd.bot/gateway/configuration https://docs.clawd.bot/cli/agents</li>
<li>Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui</li>
<li>CLI: add <code>clawdbot update wizard</code> with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update</li>
<li>Models/Commands: add <code>/models</code>, improve <code>/model</code> listing UX, and expand <code>clawdbot models</code> paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models</li>
<li>CLI: move gateway service commands under <code>clawdbot gateway</code>, flatten node service commands under <code>clawdbot node</code>, and add <code>gateway probe</code> for reachability. https://docs.clawd.bot/cli/gateway https://docs.clawd.bot/cli/node</li>
<li>Exec: add elevated ask/full modes, tighten allowlist gating, and render approvals tables on write. https://docs.clawd.bot/tools/elevated https://docs.clawd.bot/tools/exec-approvals</li>
<li>Exec approvals: default to local host, add gateway/node targeting + target details, support wildcard agent allowlists, and tighten allowlist parsing/safe bins. https://docs.clawd.bot/cli/approvals https://docs.clawd.bot/tools/exec-approvals</li>
<li>Heartbeat: allow explicit session keys and active hours. (#1256) Thanks @zknicker. https://docs.clawd.bot/gateway/heartbeat</li>
<li>Sessions: add per-channel idle durations via <code>sessions.channelIdleMinutes</code>. (#1353) Thanks @cash-echo-bot.</li>
<li>Nodes: run exec-style, expose PATH in status/describe, and bootstrap PATH for node-host execution. https://docs.clawd.bot/cli/node</li>
<li>Cache: add <code>cache.ttlPrune</code> mode and auth-aware defaults for cache TTL behavior.</li>
<li>Queue: add per-channel debounce overrides for auto-reply. https://docs.clawd.bot/concepts/queue</li>
<li>Discord: add wildcard channel config support. (#1334) Thanks @pvoo. https://docs.clawd.bot/channels/discord</li>
<li>Signal: add typing indicators and DM read receipts via signal-cli. https://docs.clawd.bot/channels/signal</li>
<li>MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. https://docs.clawd.bot/channels/msteams</li>
<li>Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).</li>
<li>macOS: refresh Settings (location access in Permissions, connection mode in menu, remove CLI install UI).</li>
<li>Diagnostics: add cache trace config for debugging. (#1370) Thanks @parubets.</li>
<li>Docs: Lobster guides + org URL updates, /model allowlist troubleshooting, Gmail message search examples, gateway.mode troubleshooting, prompt injection guidance, npm prefix/node CLI notes, control UI dev gatewayUrl note, tool_use FAQ, showcase video, and sharp/node-gyp workaround. (#1427, #1220, #1405) Thanks @vignesh07, @mbelinky.</li>
</ul>
<h3>Breaking</h3>
<ul>
<li><strong>BREAKING:</strong> iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)</li>
<li><strong>BREAKING:</strong> Microsoft Teams is now a plugin; install <code>@clawdbot/msteams</code> via <code>clawdbot plugins install @clawdbot/msteams</code>.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>CLI: set process titles to <code>clawdbot-<command></code> for clearer process listings.</li>
<li>CLI/macOS: sync remote SSH target/identity to config and let <code>gateway status</code> auto-infer SSH targets (ssh-config aware).</li>
<li>Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.</li>
<li>Sessions/Security: add <code>session.dmScope</code> for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.</li>
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
<li>Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.</li>
<li>TUI: show provider/model labels for the active session and default model.</li>
<li>Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.</li>
<li>UI: show gateway auth guidance + doc link on unauthorized Control UI connections.</li>
<li>Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in <code>clawdbot security audit</code>.</li>
<li>Apps: store node auth tokens encrypted (Keychain/SecurePrefs).</li>
<li>Daemon: share profile/state-dir resolution across service helpers and honor <code>CLAWDBOT_STATE_DIR</code> for Windows task scripts.</li>
<li>Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.</li>
<li>Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).</li>
<li>Tools: normalize Slack/Discord message timestamps with <code>timestampMs</code>/<code>timestampUtc</code> while keeping raw provider fields.</li>
<li>macOS: add <code>system.which</code> for prompt-free remote skill discovery (with gateway fallback to <code>system.run</code>).</li>
<li>Docs: add Date & Time guide and update prompt/timezone configuration docs.</li>
<li>Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.</li>
<li>Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.</li>
<li>Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in <code>/status</code> and <code>clawdbot models status</code>, and update docs.</li>
<li>CLI: add <code>--json</code> output for <code>clawdbot daemon</code> lifecycle/install commands.</li>
<li>Memory: make <code>node-llama-cpp</code> an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.</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: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.</li>
<li>Browser: increase remote CDP reachability timeouts + add <code>remoteCdpTimeoutMs</code>/<code>remoteCdpHandshakeTimeoutMs</code>.</li>
<li>Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.</li>
<li>Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.</li>
<li>Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.</li>
<li>Discord: allow allowlisted guilds without channel lists to receive messages when <code>groupPolicy="allowlist"</code>. — thanks @thewilloftheshadow.</li>
<li>Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.</li>
<li><strong>BREAKING:</strong> Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set <code>gateway.controlUi.allowInsecureAuth: true</code> to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http</li>
<li><strong>BREAKING:</strong> Envelope and system event timestamps now default to host-local time (was UTC) so agents dont have to constantly convert.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.</li>
<li>Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.</li>
<li>Fix: persist <code>gateway.mode=local</code> after selecting Local run mode in <code>clawdbot configure</code>, even if no other sections are chosen.</li>
<li>Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.</li>
<li>Agents: avoid false positives when logging unsupported Google tool schema keywords.</li>
<li>Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.</li>
<li>Status: restore usage summary line for current provider when no OAuth profiles exist.</li>
<li>Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.</li>
<li>Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.</li>
<li>Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.</li>
<li>Fix: support MiniMax coding plan usage responses with <code>model_remains</code>/<code>current_interval_*</code> payloads.</li>
<li>Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)</li>
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
<li>Browser: fix <code>tab not found</code> for extension relay snapshots/actions when Playwright blocks <code>newCDPSession</code> (use the single available Page).</li>
<li>Browser: upgrade <code>ws</code> → <code>wss</code> when remote CDP uses <code>https</code> (fixes Browserless handshake).</li>
<li>Telegram: skip <code>message_thread_id=1</code> for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.</li>
<li>Fix: sanitize user-facing error text + strip <code><final></code> tags across reply pipelines. (#975) — thanks @ThomsenDrake.</li>
<li>Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.</li>
<li>Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.</li>
<li>Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)</li>
<li>Streaming/Typing/Media: keep reply tags across streamed chunks, start typing indicators at run start, and accept MEDIA paths with spaces/tilde while preferring the message tool hint for image replies.</li>
<li>Agents/Providers: drop unsigned thinking blocks for Claude models (Google Antigravity) and enforce alphanumeric tool call ids for strict providers (Mistral/OpenRouter). (#1372) Thanks @zerone0x.</li>
<li>Exec approvals: treat main as the default agent, align node/gateway allowlist prechecks, validate resolved paths, avoid allowlist resolve races, and avoid null optional params. (#1417, #1414, #1425) Thanks @czekaj.</li>
<li>Exec/Windows: resolve Windows exec paths with extensions and handle safe-bin exe names.</li>
<li>Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.</li>
<li>Gateway: prevent multiple gateways from sharing the same config/state (singleton lock), keep auto bind loopback-first with explicit tailnet binding, and improve SSH auth handling. (#1380)</li>
<li>Control UI: remove the chat stop button, keep the composer aligned to the bottom edge, stabilize session previews, and refresh the debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.</li>
<li>UI/config: export <code>SECTION_META</code> for config form modules. (#1418) Thanks @MaudeBot.</li>
<li>macOS: keep chat pinned during streaming replies, include Textual resources, respect wildcard exec approvals, allow SSH agent auth, and default distribution builds to universal binaries. (#1279, #1362, #1384, #1396) Thanks @ameno-, @JustYannicc.</li>
<li>BlueBubbles: resolve short message IDs safely, expose full IDs in templates, and harden short-id fetch wrappers. (#1369, #1387) Thanks @tyler6204.</li>
<li>Models/Configure: inherit session model overrides in threads/topics, map OpenCode Zen models to the correct APIs, narrow Anthropic OAuth allowlist handling, seed allowlist fallbacks, list the full catalog when no allowlist is set, and limit <code>/model</code> list output. (#1376, #1416)</li>
<li>Memory: prevent CLI hangs by deferring vector probes, add sqlite-vec/embedding timeouts, and make session memory indexing async.</li>
<li>Cron: cap reminder context history to 10 messages and honor <code>contextMessages</code>. (#1103) Thanks @mkbehr.</li>
<li>Cache: restore the 1h cache TTL option and reset the pruning window.</li>
<li>Zalo Personal: tolerate ANSI/log-prefixed JSON output from <code>zca</code>. (#1379) Thanks @ptn1411.</li>
<li>Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.</li>
<li>Infra: preserve fetch helper methods/preconnect when wrapping abort signals and normalize Telegram fetch aborts.</li>
<li>Config/Doctor: avoid stack traces for invalid configs, log the config path, avoid WhatsApp config resurrection, and warn when <code>gateway.mode</code> is unset. (#900)</li>
<li>CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47.</li>
<li>Logs/Status: align rolling log filenames with local time and report sandboxed runtime in <code>clawdbot status</code>. (#1343)</li>
<li>Embedded runner: persist injected history images so attachments arent reloaded each turn. (#1374) Thanks @Nicell.</li>
<li>Nodes/Subagents: include agent/node/gateway context in tool failure logs and ensure subagent list uses the command session.</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.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
</item>
<item>
<title>2026.1.14-1</title>
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5825</sparkle:version>
<sparkle:shortVersionString>2026.1.14-1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.14-1</h2>
<h3>Highlights</h3>
<ul>
<li>Web search: <code>web_search</code>/<code>web_fetch</code> tools (Brave API) + first-time setup in onboarding/configure.</li>
<li>Browser control: Chrome extension relay takeover mode + remote browser control via <code>clawdbot browser serve</code>.</li>
<li>Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.</li>
<li>Security: expanded <code>clawdbot security audit</code> (+ <code>--fix</code>), detect-secrets CI scan, and a <code>SECURITY.md</code> reporting policy.</li>
</ul>
<h3>Changes</h3>
<h4>Web Tools</h4>
<ul>
<li>Tools: add <code>web_search</code>/<code>web_fetch</code> (Brave API), including helpful setup hints when the key is missing.</li>
<li>Tools: enable <code>web_fetch</code> by default (unless explicitly disabled in config).</li>
<li>CLI/Docs: add <code>clawdbot configure --section web</code> for storing Brave API keys and update onboarding tips.</li>
</ul>
<h4>Browser / Control UI</h4>
<ul>
<li>Browser: add Chrome extension relay takeover mode (toolbar button) + <code>clawdbot browser serve</code> remote control + <code>browser.controlToken</code>.</li>
<li>Browser: ship a built-in <code>chrome</code> profile for extension relay and start the relay automatically when running locally.</li>
<li>Browser: default <code>browser.defaultProfile</code> to <code>chrome</code> (existing Chrome takeover mode).</li>
<li>Browser: add <code>clawdbot browser extension install/path</code> and copy extension path to clipboard.</li>
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
<li>Control UI: show raw any-map entries in config views; move Docs link into the left nav.</li>
</ul>
<h4>Plugins</h4>
<ul>
<li>Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.</li>
<li>Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.</li>
<li>Channels: add Matrix plugin (external) with docs + onboarding hooks.</li>
<li>Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.</li>
</ul>
<h4>Security</h4>
<ul>
<li>Security: expand <code>clawdbot security audit</code> checks and publish a <code>SECURITY.md</code> reporting policy.</li>
<li>Security: extend <code>clawdbot security audit --fix</code> to tighten more sensitive state paths.</li>
<li>Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.</li>
</ul>
<h4>Onboarding / Daemon</h4>
<ul>
<li>Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require <code>--accept-risk</code> for <code>--non-interactive</code>.</li>
<li>Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.</li>
</ul>
<h4>Auth / Usage / Config</h4>
<ul>
<li>Usage: add MiniMax coding plan usage tracking.</li>
<li>Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.</li>
<li>Agents: add optional auth-profile copy prompt on <code>agents add</code> and improve auth error messaging.</li>
<li>Auth: add dynamic template variables to <code>messages.responsePrefix</code>. (#928) — thanks @sebslight.</li>
<li>Config: add <code>channels.<provider>.configWrites</code> gating for channel-initiated config writes; migrate Slack channel IDs.</li>
</ul>
<h4>Channels</h4>
<ul>
<li>Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.</li>
<li>WhatsApp: add <code>channels.whatsapp.sendReadReceipts</code> to disable auto read receipts. (#882) — thanks @chrisrodz.</li>
</ul>
<h4>Docs</h4>
<ul>
<li>Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.</li>
<li>Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.</li>
<li>Docs: expand gateway security hardening guidance and incident response checklist.</li>
<li>Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.</li>
<li>Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)</li>
<li>Docs: add per-command CLI doc pages and link them from <code>clawdbot <command> --help</code>.</li>
<li>Docs: add multi-gateway guide (sidebar + nav).</li>
</ul>
<h3>Fixes</h3>
<h4>Gateway / Daemon / Sessions</h4>
<ul>
<li>Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.</li>
<li>Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare <code>main</code> alias).</li>
<li>Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.</li>
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
<li>Browser: persist role snapshot refs per CDP target so <code>snapshot</code> → <code>act</code> clicks work even if Playwright returns a different Page instance.</li>
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
<li>Daemon: clear persisted launchd disabled state before bootstrap (fixes <code>daemon install</code> after uninstall). (#849) — thanks @ndraiman.</li>
<li>Sessions: return deep clones (<code>structuredClone</code>) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.</li>
<li>Heartbeat: keep <code>updatedAt</code> monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.</li>
<li>Agent: clear run context after CLI runs (<code>clearAgentRunContext</code>) to avoid runaway contexts. (#934) — thanks @ronak-guliani.</li>
<li>Gateway/Dev: ensure <code>pnpm gateway:dev</code> always uses the dev profile config + state (<code>~/.clawdbot-dev</code>).</li>
</ul>
<h4>CLI / Onboarding</h4>
<ul>
<li>Onboarding: show web search setup at the end (not the beginning).</li>
<li>Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.</li>
<li>Health: colorize “not configured” provider lines for easier scanning.</li>
</ul>
<h4>Control UI / TUI</h4>
<ul>
<li>Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)</li>
<li>UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.</li>
<li>UI: always apply <code>?token=</code> from URL (fixes unauthorized after re-onboard).</li>
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
<li>TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.</li>
<li>TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.</li>
<li>TUI: show LLM error messages (rate limits, auth, etc.) instead of <code>(no output)</code>.</li>
</ul>
<h4>Agents / Auth / Tools / Sandbox</h4>
<ul>
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
<li>Logging: tolerate <code>EIO</code> from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.</li>
<li>Sandbox: restore <code>docker.binds</code> config validation and preserve configured PATH for <code>docker exec</code>. (#873) — thanks @akonyer.</li>
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
</ul>
<h4>macOS / Apps</h4>
<ul>
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
<li>macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.</li>
<li>macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)</li>
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
<li>macOS: fix cron preview/testing payload to use <code>channel</code> key. (#867) — thanks @wes-davis.</li>
<li>macOS: update cron testing channel arg. (#896) — thanks @ngutman.</li>
</ul>
<h4>Channels / Messaging</h4>
<ul>
<li>Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)</li>
<li>Slack: respect <code>channels.slack.requireMention</code> default when resolving channel mention gating. (#850) — thanks @evalexpr.</li>
<li>Slack: drop Socket Mode events with mismatched <code>api_app_id</code>/<code>team_id</code>. (#889) — thanks @roshanasingh4.</li>
<li>Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.</li>
<li>Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.</li>
<li>Telegram: honor <code>channels.telegram.timeoutSeconds</code> for grammY API requests. (#863) — thanks @Snaver.</li>
<li>Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).</li>
<li>Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.</li>
<li>Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.</li>
<li>Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.</li>
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
<li>Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.</li>
<li>iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)</li>
<li>WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.</li>
<li>WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.</li>
<li>WhatsApp: harden owner command auth.</li>
<li>Auto-reply: treat trailing <code>NO_REPLY</code> tokens as silent replies.</li>
</ul>
<h4>Config / Doctor / Packaging</h4>
<ul>
<li>Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).</li>
<li>Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).</li>
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="22284796" type="application/octet-stream" sparkle:edSignature="pXji4NMA/cu35iMxln385d6LnsT4yIZtFtFiR7sIimKeSC2CsyeWzzSD0EhJsN98PdSoy69iEFZt4I2ZtNCECg=="/>
</item>
</channel>
</rss>

View File

@@ -21,8 +21,8 @@ android {
applicationId = "com.clawdbot.android"
minSdk = 31
targetSdk = 36
versionCode = 202601200
versionName = "2026.1.20"
versionCode = 202601240
versionName = "2026.1.24"
}
buildTypes {

View File

@@ -529,7 +529,7 @@ class NodeRuntime(context: Context) {
caps = buildCapabilities(),
commands = buildInvokeCommands(),
permissions = emptyMap(),
client = buildClientInfo(clientId = "node-host", clientMode = "node"),
client = buildClientInfo(clientId = "clawdbot-android", clientMode = "node"),
userAgent = buildUserAgent(),
)
}

View File

@@ -8,10 +8,14 @@ object WakeWords {
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
}
fun parseIfChanged(input: String, current: List<String>): List<String>? {
val parsed = parseCommaSeparated(input)
return if (parsed == current) null else parsed
}
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
val cleaned =
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
return cleaned.ifEmpty { defaults }
}
}

View File

@@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
@@ -49,7 +51,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@@ -58,6 +63,7 @@ import com.clawdbot.android.LocationMode
import com.clawdbot.android.MainViewModel
import com.clawdbot.android.NodeForegroundService
import com.clawdbot.android.VoiceWakeMode
import com.clawdbot.android.WakeWords
@Composable
fun SettingsSheet(viewModel: MainViewModel) {
@@ -86,6 +92,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
val listState = rememberLazyListState()
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
var wakeWordsHadFocus by remember { mutableStateOf(false) }
val deviceModel =
remember {
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
@@ -104,6 +112,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
val commitWakeWords = {
val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords)
if (parsed != null) {
viewModel.setWakeWords(parsed)
}
}
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
@@ -481,25 +495,27 @@ fun SettingsSheet(viewModel: MainViewModel) {
value = wakeWordsText,
onValueChange = setWakeWordsText,
label = { Text("Wake Words (comma-separated)") },
modifier = Modifier.fillMaxWidth(),
modifier =
Modifier.fillMaxWidth().onFocusChanged { focusState ->
if (focusState.isFocused) {
wakeWordsHadFocus = true
} else if (wakeWordsHadFocus) {
wakeWordsHadFocus = false
commitWakeWords()
}
},
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(
onDone = {
commitWakeWords()
focusManager.clearFocus()
},
),
)
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = {
val parsed = com.clawdbot.android.WakeWords.parseCommaSeparated(wakeWordsText)
viewModel.setWakeWords(parsed)
},
enabled = isConnected,
) {
Text("Save + Sync")
}
Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") }
}
}
item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } }
item {
Text(
if (isConnected) {

View File

@@ -1,6 +1,7 @@
package com.clawdbot.android
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class WakeWordsTest {
@@ -32,5 +33,18 @@ class WakeWordsTest {
assertEquals("w1", sanitized.first())
assertEquals("w${WakeWords.maxWords}", sanitized.last())
}
}
@Test
fun parseIfChangedSkipsWhenUnchanged() {
val current = listOf("clawd", "claude")
val parsed = WakeWords.parseIfChanged(" clawd , claude ", current)
assertNull(parsed)
}
@Test
fun parseIfChangedReturnsUpdatedList() {
val current = listOf("clawd")
val parsed = WakeWords.parseIfChanged(" clawd , jarvis ", current)
assertEquals(listOf("clawd", "jarvis"), parsed)
}
}

View File

@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.20</string>
<string>2026.1.24</string>
<key>CFBundleVersion</key>
<string>20260120</string>
<string>20260124</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>

View File

@@ -1,8 +1,10 @@
import SwiftUI
import Combine
struct VoiceWakeWordsSettingsView: View {
@Environment(NodeAppModel.self) private var appModel
@State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
@FocusState private var focusedTriggerIndex: Int?
@State private var syncTask: Task<Void, Never>?
var body: some View {
@@ -12,6 +14,10 @@ struct VoiceWakeWordsSettingsView: View {
TextField("Wake word", text: self.binding(for: index))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused(self.$focusedTriggerIndex, equals: index)
.onSubmit {
self.commitTriggerWords()
}
}
.onDelete(perform: self.removeWords)
@@ -39,17 +45,18 @@ struct VoiceWakeWordsSettingsView: View {
.onAppear {
if self.triggerWords.isEmpty {
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
self.commitTriggerWords()
}
}
.onChange(of: self.triggerWords) { _, newValue in
// Keep local voice wake responsive even if the gateway isn't connected yet.
VoiceWakePreferences.saveTriggerWords(newValue)
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue)
self.syncTask?.cancel()
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
try? await Task.sleep(nanoseconds: 650_000_000)
await appModel?.setGlobalWakeWords(snapshot)
.onChange(of: self.focusedTriggerIndex) { oldValue, newValue in
guard oldValue != nil, oldValue != newValue else { return }
self.commitTriggerWords()
}
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
guard self.focusedTriggerIndex == nil else { return }
let updated = VoiceWakePreferences.loadTriggerWords()
if updated != self.triggerWords {
self.triggerWords = updated
}
}
}
@@ -63,6 +70,7 @@ struct VoiceWakeWordsSettingsView: View {
if self.triggerWords.isEmpty {
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
}
self.commitTriggerWords()
}
private func binding(for index: Int) -> Binding<String> {
@@ -76,4 +84,15 @@ struct VoiceWakeWordsSettingsView: View {
self.triggerWords[index] = newValue
})
}
private func commitTriggerWords() {
VoiceWakePreferences.saveTriggerWords(self.triggerWords)
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(self.triggerWords)
self.syncTask?.cancel()
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
try? await Task.sleep(nanoseconds: 650_000_000)
await appModel?.setGlobalWakeWords(snapshot)
}
}
}

View File

@@ -133,9 +133,8 @@ final class TalkModeManager: NSObject {
private func startRecognition() throws {
#if targetEnvironment(simulator)
// Apple Speech live-audio recognition is not supported on Simulator.
throw NSError(domain: "TalkMode", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator (Speech live audio requires a device).",
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
])
#endif

View File

@@ -6,6 +6,8 @@ enum VoiceWakePreferences {
// Keep defaults aligned with the mac app.
static let defaultTriggerWords: [String] = ["clawd", "claude"]
static let maxWords = 32
static let maxWordLength = 64
static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? {
guard let data = payloadJSON.data(using: .utf8) else { return nil }
@@ -30,6 +32,8 @@ enum VoiceWakePreferences {
let cleaned = words
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.prefix(Self.maxWords)
.map { String($0.prefix(Self.maxWordLength)) }
return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned
}

View File

@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.20</string>
<string>2026.1.24</string>
<key>CFBundleVersion</key>
<string>20260120</string>
<string>20260124</string>
</dict>
</plist>

View File

@@ -11,6 +11,18 @@ import Testing
#expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords)
}
@Test func sanitizeTriggerWordsLimitsWordLength() {
let long = String(repeating: "x", count: VoiceWakePreferences.maxWordLength + 5)
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(["ok", long])
#expect(cleaned[1].count == VoiceWakePreferences.maxWordLength)
}
@Test func sanitizeTriggerWordsLimitsWordCount() {
let words = (1...VoiceWakePreferences.maxWords + 3).map { "w\($0)" }
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(words)
#expect(cleaned.count == VoiceWakePreferences.maxWords)
}
@Test func displayStringUsesSanitizedWords() {
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude")
}

View File

@@ -81,8 +81,8 @@ targets:
properties:
CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.1.20"
CFBundleVersion: "20260120"
CFBundleShortVersionString: "2026.1.24"
CFBundleVersion: "20260124"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: ClawdbotTests
CFBundleShortVersionString: "2026.1.20"
CFBundleVersion: "20260120"
CFBundleShortVersionString: "2026.1.24"
CFBundleVersion: "20260124"

View File

@@ -1,5 +1,5 @@
{
"originHash" : "550d4ea41d4bb2546b99a7bfa1c5cba7e28a13862bc226727ea7426c61555a33",
"originHash" : "f847d54db16b371dbb1a79271d50436cdec572179b0f0cf14cfe1b75df8dfbc2",
"pins" : [
{
"identity" : "axorcist",
@@ -24,7 +24,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit",
"state" : {
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
"version" : "0.1.0"
}
},

View File

@@ -24,6 +24,11 @@ final class AppState {
case remote
}
enum RemoteTransport: String {
case ssh
case direct
}
var isPaused: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
}
@@ -166,6 +171,10 @@ final class AppState {
}
}
var remoteTransport: RemoteTransport {
didSet { self.syncGatewayConfigIfNeeded() }
}
var canvasEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
}
@@ -200,6 +209,10 @@ final class AppState {
}
}
var remoteUrl: String {
didSet { self.syncGatewayConfigIfNeeded() }
}
var remoteIdentity: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
}
@@ -263,13 +276,15 @@ final class AppState {
}
let configRoot = ClawdbotConfigFile.loadDict()
let configGateway = configRoot["gateway"] as? [String: Any]
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot)
let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot)
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
self.remoteTransport = configRemoteTransport
self.connectionMode = resolvedConnectionMode
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
if resolvedConnectionMode == .remote,
configRemoteTransport != .direct,
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let host = AppState.remoteHost(from: configRemoteUrl)
{
@@ -277,6 +292,7 @@ final class AppState {
} else {
self.remoteTarget = storedRemoteTarget
}
self.remoteUrl = configRemoteUrl ?? ""
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
@@ -354,10 +370,11 @@ final class AppState {
private func applyConfigOverrides(_ root: [String: Any]) {
let gateway = root["gateway"] as? [String: Any]
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String
let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root)
let hasRemoteUrl = !(remoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
let remoteTransport = GatewayRemoteConfig.resolveTransport(root: root)
let desiredMode: ConnectionMode? = switch modeRaw {
case "local":
@@ -378,8 +395,17 @@ final class AppState {
self.connectionMode = .remote
}
if remoteTransport != self.remoteTransport {
self.remoteTransport = remoteTransport
}
let remoteUrlText = remoteUrl ?? ""
if remoteUrlText != self.remoteUrl {
self.remoteUrl = remoteUrlText
}
let targetMode = desiredMode ?? self.connectionMode
if targetMode == .remote,
remoteTransport != .direct,
let host = AppState.remoteHost(from: remoteUrl)
{
self.updateRemoteTarget(host: host)
@@ -402,6 +428,8 @@ final class AppState {
let connectionMode = self.connectionMode
let remoteTarget = self.remoteTarget
let remoteIdentity = self.remoteIdentity
let remoteTransport = self.remoteTransport
let remoteUrl = self.remoteUrl
let desiredMode: String? = switch connectionMode {
case .local:
"local"
@@ -435,39 +463,63 @@ final class AppState {
var remote = gateway["remote"] as? [String: Any] ?? [:]
var remoteChanged = false
if let host = remoteHost {
let existingUrl = (remote["url"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
if remoteTransport == .direct {
let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedUrl.isEmpty {
if remote["url"] != nil {
remote.removeValue(forKey: "url")
remoteChanged = true
}
} else {
let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) ?? trimmedUrl
if (remote["url"] as? String) != normalizedUrl {
remote["url"] = normalizedUrl
remoteChanged = true
}
}
if (remote["transport"] as? String) != RemoteTransport.direct.rawValue {
remote["transport"] = RemoteTransport.direct.rawValue
remoteChanged = true
}
}
} else {
if remote["transport"] != nil {
remote.removeValue(forKey: "transport")
remoteChanged = true
}
if let host = remoteHost {
let existingUrl = (remote["url"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
remoteChanged = true
}
}
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
if !sanitizedTarget.isEmpty {
if (remote["sshTarget"] as? String) != sanitizedTarget {
remote["sshTarget"] = sanitizedTarget
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
if !sanitizedTarget.isEmpty {
if (remote["sshTarget"] as? String) != sanitizedTarget {
remote["sshTarget"] = sanitizedTarget
remoteChanged = true
}
} else if remote["sshTarget"] != nil {
remote.removeValue(forKey: "sshTarget")
remoteChanged = true
}
} else if remote["sshTarget"] != nil {
remote.removeValue(forKey: "sshTarget")
remoteChanged = true
}
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedIdentity.isEmpty {
if (remote["sshIdentity"] as? String) != trimmedIdentity {
remote["sshIdentity"] = trimmedIdentity
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedIdentity.isEmpty {
if (remote["sshIdentity"] as? String) != trimmedIdentity {
remote["sshIdentity"] = trimmedIdentity
remoteChanged = true
}
} else if remote["sshIdentity"] != nil {
remote.removeValue(forKey: "sshIdentity")
remoteChanged = true
}
} else if remote["sshIdentity"] != nil {
remote.removeValue(forKey: "sshIdentity")
remoteChanged = true
}
if remoteChanged {
@@ -621,8 +673,10 @@ extension AppState {
state.iconOverride = .system
state.heartbeatsEnabled = true
state.connectionMode = .local
state.remoteTransport = .ssh
state.canvasEnabled = true
state.remoteTarget = "user@example.com"
state.remoteUrl = "wss://gateway.example.ts.net"
state.remoteIdentity = "~/.ssh/id_ed25519"
state.remoteProjectRoot = "~/Projects/clawdbot"
state.remoteCliPath = ""

View File

@@ -40,6 +40,16 @@ extension ChannelsSettings {
return .orange
}
var googlechatTint: Color {
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
else { return .secondary }
if !status.configured { return .secondary }
if status.lastError != nil { return .orange }
if status.probe?.ok == false { return .orange }
if status.running { return .green }
return .orange
}
var signalTint: Color {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return .secondary }
@@ -85,6 +95,14 @@ extension ChannelsSettings {
return "Configured"
}
var googlechatSummary: String {
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
else { return "Checking…" }
if !status.configured { return "Not configured" }
if status.running { return "Running" }
return "Configured"
}
var signalSummary: String {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return "Checking…" }
@@ -193,6 +211,37 @@ extension ChannelsSettings {
return lines.isEmpty ? nil : lines.joined(separator: " · ")
}
var googlechatDetails: String? {
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
else { return nil }
var lines: [String] = []
if let source = status.credentialSource {
lines.append("Credential: \(source)")
}
if let audienceType = status.audienceType {
let audience = status.audience ?? ""
let label = audience.isEmpty ? audienceType : "\(audienceType) \(audience)"
lines.append("Audience: \(label)")
}
if let probe = status.probe {
if probe.ok {
if let elapsed = probe.elapsedMs {
lines.append("Probe \(Int(elapsed))ms")
}
} else {
let code = probe.status.map { String($0) } ?? "unknown"
lines.append("Probe failed (\(code))")
}
}
if let last = self.date(fromMs: status.lastProbeAt) {
lines.append("Last probe \(relativeAge(from: last))")
}
if let err = status.lastError, !err.isEmpty {
lines.append("Error: \(err)")
}
return lines.isEmpty ? nil : lines.joined(separator: " · ")
}
var signalDetails: String? {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return nil }
@@ -244,7 +293,7 @@ extension ChannelsSettings {
}
var orderedChannels: [ChannelItem] {
let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]
let fallback = ["whatsapp", "telegram", "discord", "googlechat", "slack", "signal", "imessage"]
let order = self.store.snapshot?.channelOrder ?? fallback
let channels = order.enumerated().map { index, id in
ChannelItem(
@@ -307,6 +356,8 @@ extension ChannelsSettings {
return self.telegramTint
case "discord":
return self.discordTint
case "googlechat":
return self.googlechatTint
case "signal":
return self.signalTint
case "imessage":
@@ -326,6 +377,8 @@ extension ChannelsSettings {
return self.telegramSummary
case "discord":
return self.discordSummary
case "googlechat":
return self.googlechatSummary
case "signal":
return self.signalSummary
case "imessage":
@@ -345,6 +398,8 @@ extension ChannelsSettings {
return self.telegramDetails
case "discord":
return self.discordDetails
case "googlechat":
return self.googlechatDetails
case "signal":
return self.signalDetails
case "imessage":
@@ -377,6 +432,10 @@ extension ChannelsSettings {
return self
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.lastProbeAt)
case "googlechat":
return self
.date(fromMs: self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)?
.lastProbeAt)
case "signal":
return self
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
@@ -411,6 +470,10 @@ extension ChannelsSettings {
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case "googlechat":
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case "signal":
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return false }

View File

@@ -85,6 +85,28 @@ struct ChannelsStatusSnapshot: Codable {
let lastProbeAt: Double?
}
struct GoogleChatProbe: Codable {
let ok: Bool
let status: Int?
let error: String?
let elapsedMs: Double?
}
struct GoogleChatStatus: Codable {
let configured: Bool
let credentialSource: String?
let audienceType: String?
let audience: String?
let webhookPath: String?
let webhookUrl: String?
let running: Bool
let lastStartAt: Double?
let lastStopAt: Double?
let lastError: String?
let probe: GoogleChatProbe?
let lastProbeAt: Double?
}
struct SignalProbe: Codable {
let ok: Bool
let status: Int?

View File

@@ -284,13 +284,16 @@ enum CommandResolver {
var args: [String] = [
"-o", "BatchMode=yes",
"-o", "IdentitiesOnly=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
]
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
if !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
args.append(contentsOf: ["-i", settings.identity])
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
if !identity.isEmpty {
// Only use IdentitiesOnly when an explicit identity file is provided.
// This allows 1Password SSH agent and other SSH agents to provide keys.
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
args.append(contentsOf: ["-i", identity])
}
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
args.append(userHost)

View File

@@ -6,15 +6,20 @@ final class ConnectionModeCoordinator {
static let shared = ConnectionModeCoordinator()
private let logger = Logger(subsystem: "com.clawdbot", category: "connection")
private var lastMode: AppState.ConnectionMode?
/// Apply the requested connection mode by starting/stopping local gateway,
/// managing the control-channel SSH tunnel, and cleaning up chat windows/panels.
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
if let lastMode = self.lastMode, lastMode != mode {
GatewayProcessManager.shared.clearLastFailure()
NodesStore.shared.lastError = nil
}
self.lastMode = mode
switch mode {
case .unconfigured:
if let error = await NodeServiceManager.stop() {
NodesStore.shared.lastError = "Node service stop failed: \(error)"
}
_ = await NodeServiceManager.stop()
NodesStore.shared.lastError = nil
await RemoteTunnelManager.shared.stopAll()
WebChatManager.shared.resetTunnels()
GatewayProcessManager.shared.stop()
@@ -23,9 +28,8 @@ final class ConnectionModeCoordinator {
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
case .local:
if let error = await NodeServiceManager.stop() {
NodesStore.shared.lastError = "Node service stop failed: \(error)"
}
_ = await NodeServiceManager.stop()
NodesStore.shared.lastError = nil
await RemoteTunnelManager.shared.stopAll()
WebChatManager.shared.resetTunnels()
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
@@ -56,6 +60,7 @@ final class ConnectionModeCoordinator {
WebChatManager.shared.resetTunnels()
do {
NodesStore.shared.lastError = nil
if let error = await NodeServiceManager.start() {
NodesStore.shared.lastError = "Node service start failed: \(error)"
}

View File

@@ -12,6 +12,8 @@ let voiceWakeTriggerChimeKey = "clawdbot.voiceWakeTriggerChime"
let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
let showDockIconKey = "clawdbot.showDockIcon"
let defaultVoiceWakeTriggers = ["clawd", "claude"]
let voiceWakeMaxWords = 32
let voiceWakeMaxWordLength = 64
let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName"
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"

View File

@@ -74,6 +74,7 @@ final class ControlChannel {
}
private(set) var lastPingMs: Double?
private(set) var authSourceLabel: String?
private let logger = Logger(subsystem: "com.clawdbot", category: "control")
@@ -87,15 +88,7 @@ final class ControlChannel {
func configure() async {
self.logger.info("control channel configure mode=local")
self.state = .connecting
do {
try await GatewayConnection.shared.refresh()
self.state = .connected
PresenceReporter.shared.sendImmediate(reason: "connect")
} catch {
let message = self.friendlyGatewayMessage(error)
self.state = .degraded(message)
}
await self.refreshEndpoint(reason: "configure")
}
func configure(mode: Mode = .local) async throws {
@@ -111,7 +104,7 @@ final class ControlChannel {
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
self.state = .connecting
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
await self.configure()
await self.refreshEndpoint(reason: "configure")
} catch {
self.state = .degraded(error.localizedDescription)
throw error
@@ -119,10 +112,24 @@ final class ControlChannel {
}
}
func refreshEndpoint(reason: String) async {
self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)")
self.state = .connecting
do {
try await self.establishGatewayConnection()
self.state = .connected
PresenceReporter.shared.sendImmediate(reason: "connect")
} catch {
let message = self.friendlyGatewayMessage(error)
self.state = .degraded(message)
}
}
func disconnect() async {
await GatewayConnection.shared.shutdown()
self.state = .disconnected
self.lastPingMs = nil
self.authSourceLabel = nil
}
func health(timeout: TimeInterval? = nil) async throws -> Data {
@@ -183,8 +190,11 @@ final class ControlChannel {
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
{
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
let tokenKey = CommandResolver.connectionModeIsRemote()
? "gateway.remote.token"
: "gateway.auth.token"
return
"Gateway rejected token; set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) " +
"Gateway rejected token; set \(tokenKey) (or CLAWDBOT_GATEWAY_TOKEN) " +
"or clear it on the gateway. " +
"Reason: \(reason)"
}
@@ -275,18 +285,49 @@ final class ControlChannel {
}
}
do {
try await GatewayConnection.shared.refresh()
await self.refreshEndpoint(reason: "recovery:\(reasonText)")
if case .connected = self.state {
self.logger.info("control channel recovery finished")
} catch {
self.logger.error(
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
} else if case let .degraded(message) = self.state {
self.logger.error("control channel recovery failed \(message, privacy: .public)")
}
self.recoveryTask = nil
}
}
private func establishGatewayConnection(timeoutMs: Int = 5000) async throws {
try await GatewayConnection.shared.refresh()
let ok = try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
if ok == false {
throw NSError(
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
}
await self.refreshAuthSourceLabel()
}
private func refreshAuthSourceLabel() async {
let isRemote = CommandResolver.connectionModeIsRemote()
let authSource = await GatewayConnection.shared.authSource()
self.authSourceLabel = Self.formatAuthSource(authSource, isRemote: isRemote)
}
private static func formatAuthSource(_ source: GatewayAuthSource?, isRemote: Bool) -> String? {
guard let source else { return nil }
switch source {
case .deviceToken:
return "Auth: device token (paired device)"
case .sharedToken:
return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))"
case .password:
return "Auth: password (\(isRemote ? "gateway.remote.password" : "gateway.auth.password"))"
case .none:
return "Auth: none"
}
}
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
var merged = params
merged["text"] = AnyHashable(text)

View File

@@ -16,6 +16,8 @@ struct DebugSettings: View {
@State private var modelsError: String?
private let gatewayManager = GatewayProcessManager.shared
private let healthStore = HealthStore.shared
@State private var launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()
@State private var launchAgentWriteError: String?
@State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
@State private var sessionStoreSaveError: String?
@@ -47,6 +49,7 @@ struct DebugSettings: View {
VStack(alignment: .leading, spacing: 14) {
self.header
self.launchdSection
self.appInfoSection
self.gatewaySection
self.logsSection
@@ -79,6 +82,41 @@ struct DebugSettings: View {
}
}
private var launchdSection: some View {
GroupBox("Gateway startup") {
VStack(alignment: .leading, spacing: 8) {
Toggle("Attach only (skip launchd install)", isOn: self.$launchAgentWriteDisabled)
.onChange(of: self.launchAgentWriteDisabled) { _, newValue in
self.launchAgentWriteError = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(newValue)
if self.launchAgentWriteError != nil {
self.launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()
return
}
if newValue {
Task {
_ = await GatewayLaunchAgentManager.set(
enabled: false,
bundlePath: Bundle.main.bundlePath,
port: GatewayEnvironment.gatewayPort())
}
}
}
Text(
"When enabled, Clawdbot won't install or manage \(gatewayLaunchdLabel). " +
"It will only attach to an existing Gateway.")
.font(.caption)
.foregroundStyle(.secondary)
if let launchAgentWriteError {
Text(launchAgentWriteError)
.font(.caption)
.foregroundStyle(.red)
}
}
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Debug")
@@ -484,6 +522,22 @@ struct DebugSettings: View {
}
}
VStack(alignment: .leading, spacing: 6) {
Text(
"Note: macOS may require restarting Clawdbot after enabling Accessibility or Screen Recording.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button {
LaunchdManager.startClawdbot()
} label: {
Label("Restart Clawdbot", systemImage: "arrow.counterclockwise")
}
.buttonStyle(.bordered)
.controlSize(.small)
}
HStack(spacing: 8) {
Button("Restart app") { DebugActions.restartApp() }
Button("Restart onboarding") { DebugActions.restartOnboarding() }

View File

@@ -84,11 +84,52 @@ enum ExecApprovalDecision: String, Codable, Sendable {
case deny
}
struct ExecAllowlistEntry: Codable, Hashable {
struct ExecAllowlistEntry: Codable, Hashable, Identifiable {
var id: UUID
var pattern: String
var lastUsedAt: Double?
var lastUsedCommand: String?
var lastResolvedPath: String?
init(
id: UUID = UUID(),
pattern: String,
lastUsedAt: Double? = nil,
lastUsedCommand: String? = nil,
lastResolvedPath: String? = nil)
{
self.id = id
self.pattern = pattern
self.lastUsedAt = lastUsedAt
self.lastUsedCommand = lastUsedCommand
self.lastResolvedPath = lastResolvedPath
}
private enum CodingKeys: String, CodingKey {
case id
case pattern
case lastUsedAt
case lastUsedCommand
case lastResolvedPath
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
self.pattern = try container.decode(String.self, forKey: .pattern)
self.lastUsedAt = try container.decodeIfPresent(Double.self, forKey: .lastUsedAt)
self.lastUsedCommand = try container.decodeIfPresent(String.self, forKey: .lastUsedCommand)
self.lastResolvedPath = try container.decodeIfPresent(String.self, forKey: .lastResolvedPath)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.id, forKey: .id)
try container.encode(self.pattern, forKey: .pattern)
try container.encodeIfPresent(self.lastUsedAt, forKey: .lastUsedAt)
try container.encodeIfPresent(self.lastUsedCommand, forKey: .lastUsedCommand)
try container.encodeIfPresent(self.lastResolvedPath, forKey: .lastResolvedPath)
}
}
struct ExecApprovalsDefaults: Codable {
@@ -149,6 +190,7 @@ struct ExecApprovalsResolvedDefaults {
enum ExecApprovalsStore {
private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals")
private static let defaultAgentId = "main"
private static let defaultSecurity: ExecSecurity = .deny
private static let defaultAsk: ExecAsk = .onMiss
private static let defaultAskFallback: ExecSecurity = .deny
@@ -165,13 +207,22 @@ enum ExecApprovalsStore {
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
var agents = file.agents ?? [:]
if let legacyDefault = agents["default"] {
if let main = agents[self.defaultAgentId] {
agents[self.defaultAgentId] = self.mergeAgents(current: main, legacy: legacyDefault)
} else {
agents[self.defaultAgentId] = legacyDefault
}
agents.removeValue(forKey: "default")
}
return ExecApprovalsFile(
version: 1,
socket: ExecApprovalsSocketConfig(
path: socketPath.isEmpty ? nil : socketPath,
token: token.isEmpty ? nil : token),
defaults: file.defaults,
agents: file.agents)
agents: agents)
}
static func readSnapshot() -> ExecApprovalsSnapshot {
@@ -272,18 +323,20 @@ enum ExecApprovalsStore {
ask: defaults.ask ?? self.defaultAsk,
askFallback: defaults.askFallback ?? self.defaultAskFallback,
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
: "default"
let key = self.agentKey(agentId)
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
let resolvedAgent = ExecApprovalsResolvedDefaults(
security: agentEntry.security ?? resolvedDefaults.security,
ask: agentEntry.ask ?? resolvedDefaults.ask,
askFallback: agentEntry.askFallback ?? resolvedDefaults.askFallback,
autoAllowSkills: agentEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills)
let allowlist = (agentEntry.allowlist ?? [])
security: agentEntry.security ?? wildcardEntry.security ?? resolvedDefaults.security,
ask: agentEntry.ask ?? wildcardEntry.ask ?? resolvedDefaults.ask,
askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback
?? resolvedDefaults.askFallback,
autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills
?? resolvedDefaults.autoAllowSkills)
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
.map { entry in
ExecAllowlistEntry(
id: entry.id,
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: entry.lastUsedAt,
lastUsedCommand: entry.lastUsedCommand,
@@ -368,6 +421,7 @@ enum ExecApprovalsStore {
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
guard item.pattern == pattern else { return item }
return ExecAllowlistEntry(
id: item.id,
pattern: item.pattern,
lastUsedAt: Date().timeIntervalSince1970 * 1000,
lastUsedCommand: command,
@@ -387,6 +441,7 @@ enum ExecApprovalsStore {
let cleaned = allowlist
.map { item in
ExecAllowlistEntry(
id: item.id,
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: item.lastUsedAt,
lastUsedCommand: item.lastUsedCommand,
@@ -454,7 +509,40 @@ enum ExecApprovalsStore {
private static func agentKey(_ agentId: String?) -> String {
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "default" : trimmed
return trimmed.isEmpty ? self.defaultAgentId : trimmed
}
private static func normalizedPattern(_ pattern: String?) -> String? {
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed.lowercased()
}
private static func mergeAgents(
current: ExecApprovalsAgent,
legacy: ExecApprovalsAgent) -> ExecApprovalsAgent
{
var seen = Set<String>()
var allowlist: [ExecAllowlistEntry] = []
func append(_ entry: ExecAllowlistEntry) {
guard let key = self.normalizedPattern(entry.pattern), !seen.contains(key) else {
return
}
seen.insert(key)
allowlist.append(entry)
}
for entry in current.allowlist ?? [] {
append(entry)
}
for entry in legacy.allowlist ?? [] {
append(entry)
}
return ExecApprovalsAgent(
security: current.security ?? legacy.security,
ask: current.ask ?? legacy.ask,
askFallback: current.askFallback ?? legacy.askFallback,
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
allowlist: allowlist.isEmpty ? nil : allowlist)
}
}
@@ -553,6 +641,30 @@ enum ExecCommandFormatter {
}
}
enum ExecApprovalHelpers {
static func parseDecision(_ raw: String?) -> ExecApprovalDecision? {
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty else { return nil }
return ExecApprovalDecision(rawValue: trimmed)
}
static func requiresAsk(
ask: ExecAsk,
security: ExecSecurity,
allowlistMatch: ExecAllowlistEntry?,
skillAllow: Bool) -> Bool
{
if ask == .always { return true }
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
return false
}
static func allowlistPattern(command: [String], resolution: ExecCommandResolution?) -> String? {
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
return pattern.isEmpty ? nil : pattern
}
}
enum ExecAllowlistMatcher {
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
guard let resolution, !entries.isEmpty else { return nil }

View File

@@ -1,5 +1,6 @@
import ClawdbotKit
import ClawdbotProtocol
import CoreGraphics
import Foundation
import OSLog
@@ -44,6 +45,7 @@ final class ExecApprovalsGatewayPrompter {
do {
let data = try JSONEncoder().encode(payload)
let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data)
guard self.shouldPresent(request: request) else { return }
let decision = ExecApprovalsPromptPresenter.prompt(request.request)
try await GatewayConnection.shared.requestVoid(
method: .execApprovalResolve,
@@ -56,4 +58,66 @@ final class ExecApprovalsGatewayPrompter {
self.logger.error("exec approval handling failed \(error.localizedDescription, privacy: .public)")
}
}
private func shouldPresent(request: GatewayApprovalRequest) -> Bool {
let mode = AppStateStore.shared.connectionMode
let activeSession = WebChatManager.shared.activeSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
let requestSession = request.request.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
return Self.shouldPresent(
mode: mode,
activeSession: activeSession,
requestSession: requestSession,
lastInputSeconds: Self.lastInputSeconds(),
thresholdSeconds: 120)
}
private static func shouldPresent(
mode: AppState.ConnectionMode,
activeSession: String?,
requestSession: String?,
lastInputSeconds: Int?,
thresholdSeconds: Int) -> Bool
{
let active = activeSession?.trimmingCharacters(in: .whitespacesAndNewlines)
let requested = requestSession?.trimmingCharacters(in: .whitespacesAndNewlines)
let recentlyActive = lastInputSeconds.map { $0 <= thresholdSeconds } ?? (mode == .local)
if let session = requested, !session.isEmpty {
if let active, !active.isEmpty {
return active == session
}
return recentlyActive
}
if let active, !active.isEmpty {
return true
}
return mode == .local
}
private static func lastInputSeconds() -> Int? {
let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil }
return Int(seconds.rounded())
}
}
#if DEBUG
extension ExecApprovalsGatewayPrompter {
static func _testShouldPresent(
mode: AppState.ConnectionMode,
activeSession: String?,
requestSession: String?,
lastInputSeconds: Int?,
thresholdSeconds: Int = 120) -> Bool
{
self.shouldPresent(
mode: mode,
activeSession: activeSession,
requestSession: requestSession,
lastInputSeconds: lastInputSeconds,
thresholdSeconds: thresholdSeconds)
}
}
#endif

View File

@@ -13,6 +13,7 @@ struct ExecApprovalPromptRequest: Codable, Sendable {
var ask: String?
var agentId: String?
var resolvedPath: String?
var sessionKey: String?
}
private struct ExecApprovalSocketRequest: Codable {
@@ -215,36 +216,15 @@ enum ExecApprovalsPromptPresenter {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Allow this command?"
var details = "Clawdbot wants to run:\n\n\(request.command)"
let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedCwd.isEmpty {
details += "\n\nWorking directory:\n\(trimmedCwd)"
}
let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedAgent.isEmpty {
details += "\n\nAgent:\n\(trimmedAgent)"
}
let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedPath.isEmpty {
details += "\n\nExecutable:\n\(trimmedPath)"
}
let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedHost.isEmpty {
details += "\n\nHost:\n\(trimmedHost)"
}
if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty {
details += "\n\nSecurity:\n\(security)"
}
if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty {
details += "\nAsk mode:\n\(ask)"
}
details += "\n\nThis runs on this machine."
alert.informativeText = details
alert.informativeText = "Review the command details before allowing."
alert.accessoryView = self.buildAccessoryView(request)
alert.addButton(withTitle: "Allow Once")
alert.addButton(withTitle: "Always Allow")
alert.addButton(withTitle: "Don't Allow")
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
alert.buttons[2].hasDestructiveAction = true
}
switch alert.runModal() {
case .alertFirstButtonReturn:
@@ -255,6 +235,110 @@ enum ExecApprovalsPromptPresenter {
return .deny
}
}
@MainActor
private static func buildAccessoryView(_ request: ExecApprovalPromptRequest) -> NSView {
let stack = NSStackView()
stack.orientation = .vertical
stack.spacing = 8
stack.alignment = .leading
let commandTitle = NSTextField(labelWithString: "Command")
commandTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
stack.addArrangedSubview(commandTitle)
let commandText = NSTextView()
commandText.isEditable = false
commandText.isSelectable = true
commandText.drawsBackground = true
commandText.backgroundColor = NSColor.textBackgroundColor
commandText.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
commandText.string = request.command
commandText.textContainerInset = NSSize(width: 6, height: 6)
commandText.textContainer?.lineFragmentPadding = 0
commandText.textContainer?.widthTracksTextView = true
commandText.isHorizontallyResizable = false
commandText.isVerticallyResizable = false
let commandScroll = NSScrollView()
commandScroll.borderType = .lineBorder
commandScroll.hasVerticalScroller = false
commandScroll.hasHorizontalScroller = false
commandScroll.documentView = commandText
commandScroll.translatesAutoresizingMaskIntoConstraints = false
commandScroll.widthAnchor.constraint(lessThanOrEqualToConstant: 440).isActive = true
commandScroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 56).isActive = true
stack.addArrangedSubview(commandScroll)
let contextTitle = NSTextField(labelWithString: "Context")
contextTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
stack.addArrangedSubview(contextTitle)
let contextStack = NSStackView()
contextStack.orientation = .vertical
contextStack.spacing = 4
contextStack.alignment = .leading
let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedCwd.isEmpty {
self.addDetailRow(title: "Working directory", value: trimmedCwd, to: contextStack)
}
let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedAgent.isEmpty {
self.addDetailRow(title: "Agent", value: trimmedAgent, to: contextStack)
}
let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedPath.isEmpty {
self.addDetailRow(title: "Executable", value: trimmedPath, to: contextStack)
}
let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedHost.isEmpty {
self.addDetailRow(title: "Host", value: trimmedHost, to: contextStack)
}
if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty {
self.addDetailRow(title: "Security", value: security, to: contextStack)
}
if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty {
self.addDetailRow(title: "Ask mode", value: ask, to: contextStack)
}
if contextStack.arrangedSubviews.isEmpty {
let empty = NSTextField(labelWithString: "No additional context provided.")
empty.textColor = NSColor.secondaryLabelColor
empty.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
contextStack.addArrangedSubview(empty)
}
stack.addArrangedSubview(contextStack)
let footer = NSTextField(labelWithString: "This runs on this machine.")
footer.textColor = NSColor.secondaryLabelColor
footer.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
stack.addArrangedSubview(footer)
return stack
}
@MainActor
private static func addDetailRow(title: String, value: String, to stack: NSStackView) {
let row = NSStackView()
row.orientation = .horizontal
row.spacing = 6
row.alignment = .firstBaseline
let titleLabel = NSTextField(labelWithString: "\(title):")
titleLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold)
titleLabel.textColor = NSColor.secondaryLabelColor
let valueLabel = NSTextField(labelWithString: value)
valueLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
valueLabel.lineBreakMode = .byTruncatingMiddle
valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
row.addArrangedSubview(titleLabel)
row.addArrangedSubview(valueLabel)
stack.addArrangedSubview(row)
}
}
@MainActor
@@ -314,7 +398,7 @@ private enum ExecHostExecutor {
}
var approvedByAsk = approvalDecision != nil
if self.requiresAsk(
if ExecApprovalHelpers.requiresAsk(
ask: context.ask,
security: context.security,
allowlistMatch: context.allowlistMatch,
@@ -329,7 +413,8 @@ private enum ExecHostExecutor {
security: context.security.rawValue,
ask: context.ask.rawValue,
agentId: context.trimmedAgent,
resolvedPath: context.resolution?.resolvedPath))
resolvedPath: context.resolution?.resolvedPath,
sessionKey: request.sessionKey))
switch decision {
case .deny:
@@ -417,36 +502,20 @@ private enum ExecHostExecutor {
skillAllow: skillAllow)
}
private static func requiresAsk(
ask: ExecAsk,
security: ExecSecurity,
allowlistMatch: ExecAllowlistEntry?,
skillAllow: Bool) -> Bool
{
if ask == .always { return true }
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
return false
}
private static func persistAllowlistEntry(
decision: ExecApprovalDecision?,
context: ExecApprovalContext)
{
guard decision == .allowAlways, context.security == .allowlist else { return }
guard let pattern = self.allowlistPattern(command: context.command, resolution: context.resolution) else {
guard let pattern = ExecApprovalHelpers.allowlistPattern(
command: context.command,
resolution: context.resolution)
else {
return
}
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
}
private static func allowlistPattern(
command: [String],
resolution: ExecCommandResolution?) -> String?
{
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
return pattern.isEmpty ? nil : pattern
}
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
guard needsScreenRecording == true else { return nil }
let authorized = await PermissionManager

View File

@@ -11,6 +11,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
case whatsapp
case telegram
case discord
case googlechat
case slack
case signal
case imessage
@@ -69,6 +70,7 @@ actor GatewayConnection {
case channelsLogout = "channels.logout"
case modelsList = "models.list"
case chatHistory = "chat.history"
case sessionsPreview = "sessions.preview"
case chatSend = "chat.send"
case chatAbort = "chat.abort"
case skillsStatus = "skills.status"
@@ -249,6 +251,11 @@ actor GatewayConnection {
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
}
func authSource() async -> GatewayAuthSource? {
guard let client else { return nil }
return await client.authSource()
}
func shutdown() async {
if let client {
await client.shutdown()
@@ -535,6 +542,30 @@ extension GatewayConnection {
return try await self.requestDecoded(method: .skillsUpdate, params: params)
}
// MARK: - Sessions
func sessionsPreview(
keys: [String],
limit: Int? = nil,
maxChars: Int? = nil,
timeoutMs: Int? = nil) async throws -> ClawdbotSessionsPreviewPayload
{
let resolvedKeys = keys
.map { self.canonicalizeSessionKey($0) }
.filter { !$0.isEmpty }
if resolvedKeys.isEmpty {
return ClawdbotSessionsPreviewPayload(ts: 0, previews: [])
}
var params: [String: AnyCodable] = ["keys": AnyCodable(resolvedKeys)]
if let limit { params["limit"] = AnyCodable(limit) }
if let maxChars { params["maxChars"] = AnyCodable(maxChars) }
let timeout = timeoutMs.map { Double($0) }
return try await self.requestDecoded(
method: .sessionsPreview,
params: params,
timeoutMs: timeout)
}
// MARK: - Chat
func chatHistory(

View File

@@ -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
}
}

View File

@@ -0,0 +1,47 @@
import ClawdbotDiscovery
import Foundation
enum GatewayDiscoveryHelpers {
static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
let user = NSUserName()
var target = "\(user)@\(host)"
if gateway.sshPort != 22 {
target += ":\(gateway.sshPort)"
}
return target
}
static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
self.directGatewayUrl(
tailnetDns: gateway.tailnetDns,
lanHost: gateway.lanHost,
gatewayPort: gateway.gatewayPort)
}
static func directGatewayUrl(
tailnetDns: String?,
lanHost: String?,
gatewayPort: Int?) -> String?
{
if let tailnetDns = self.sanitizedTailnetHost(tailnetDns) {
return "wss://\(tailnetDns)"
}
guard let lanHost = self.trimmed(lanHost), !lanHost.isEmpty else { return nil }
let port = gatewayPort ?? 18789
return "ws://\(lanHost):\(port)"
}
static func sanitizedTailnetHost(_ host: String?) -> String? {
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
if host.hasSuffix(".internal.") || host.hasSuffix(".internal") {
return nil
}
return host
}
private static func trimmed(_ value: String?) -> String? {
value?.trimmingCharacters(in: .whitespacesAndNewlines)
}
}

View File

@@ -4,6 +4,8 @@ import SwiftUI
struct GatewayDiscoveryInlineList: View {
var discovery: GatewayDiscoveryModel
var currentTarget: String?
var currentUrl: String?
var transport: AppState.RemoteTransport
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
@State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID?
@@ -25,9 +27,8 @@ struct GatewayDiscoveryInlineList: View {
} else {
VStack(alignment: .leading, spacing: 6) {
ForEach(self.discovery.gateways.prefix(6)) { gateway in
let target = self.suggestedSSHTarget(gateway)
let selected = (target != nil && self.currentTarget?
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
let display = self.displayInfo(for: gateway)
let selected = display.selected
Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
@@ -40,7 +41,7 @@ struct GatewayDiscoveryInlineList: View {
.font(.callout.weight(.semibold))
.lineLimit(1)
.truncationMode(.tail)
Text(target ?? "Gateway pairing only")
Text(display.label)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -83,27 +84,26 @@ struct GatewayDiscoveryInlineList: View {
.fill(Color(NSColor.controlBackgroundColor)))
}
}
.help("Click a discovered gateway to fill the SSH target.")
.help(self.transport == .direct
? "Click a discovered gateway to fill the gateway URL."
: "Click a discovered gateway to fill the SSH target.")
}
private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
guard let host else { return nil }
let user = NSUserName()
return GatewayDiscoveryModel.buildSSHTarget(
user: user,
host: host,
port: gateway.sshPort)
}
private func sanitizedTailnetHost(_ host: String?) -> String? {
guard let host else { return nil }
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
if trimmed.hasSuffix(".internal.") || trimmed.hasSuffix(".internal") {
return nil
private func displayInfo(
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (label: String, selected: Bool)
{
switch self.transport {
case .direct:
let url = GatewayDiscoveryHelpers.directUrl(for: gateway)
let label = url ?? "Gateway pairing only"
let selected = url != nil && self.trimmed(self.currentUrl) == url
return (label, selected)
case .ssh:
let target = GatewayDiscoveryHelpers.sshTarget(for: gateway)
let label = target ?? "Gateway pairing only"
let selected = target != nil && self.trimmed(self.currentTarget) == target
return (label, selected)
}
return trimmed
}
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
@@ -111,6 +111,10 @@ struct GatewayDiscoveryInlineList: View {
if hovered { return Color.secondary.opacity(0.08) }
return Color.clear
}
private func trimmed(_ value: String?) -> String {
value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
}
struct GatewayDiscoveryMenu: View {

View File

@@ -68,6 +68,7 @@ actor GatewayEndpointStore {
env: ProcessInfo.processInfo.environment)
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
?? TailscaleService.fallbackTailnetIPv4()
return GatewayEndpointStore.resolveLocalGatewayHost(
bindMode: bind,
customBindHost: customBindHost,
@@ -310,6 +311,19 @@ actor GatewayEndpointStore {
token: token,
password: password))
case .remote:
let root = ClawdbotConfigFile.loadDict()
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
self.cancelRemoteEnsure()
self.setState(.unavailable(
mode: .remote,
reason: "gateway.remote.url missing or invalid for direct transport"))
return
}
self.cancelRemoteEnsure()
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
return
}
let port = await self.deps.remotePortIfRunning()
guard let port else {
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
@@ -340,6 +354,25 @@ actor GatewayEndpointStore {
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
let root = ClawdbotConfigFile.loadDict()
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
}
guard let port = GatewayRemoteConfig.defaultPort(for: url),
let portInt = UInt16(exactly: port)
else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Invalid gateway.remote.url port"])
}
self.logger.info("remote transport direct; skipping SSH tunnel")
return portInt
}
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
throw NSError(
@@ -400,6 +433,21 @@ actor GatewayEndpointStore {
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
let root = ClawdbotConfigFile.loadDict()
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
}
let token = self.deps.token()
let password = self.deps.password()
self.cancelRemoteEnsure()
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
return (url, token, password)
}
self.kickRemoteEnsureIfNeeded(detail: detail)
guard let ensure = self.remoteEnsure else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
@@ -481,12 +529,13 @@ actor GatewayEndpointStore {
let bind = GatewayEndpointStore.resolveGatewayBindMode(
root: root,
env: ProcessInfo.processInfo.environment)
guard bind == "auto" else { return nil }
guard bind == "tailnet" else { return nil }
let currentHost = currentURL.host?.lowercased() ?? ""
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
?? TailscaleService.fallbackTailnetIPv4()
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
let scheme = GatewayEndpointStore.resolveGatewayScheme(
@@ -558,16 +607,13 @@ actor GatewayEndpointStore {
{
switch bindMode {
case "tailnet":
return tailscaleIP ?? "127.0.0.1"
tailscaleIP ?? "127.0.0.1"
case "auto":
if let tailscaleIP, !tailscaleIP.isEmpty {
return tailscaleIP
}
return "127.0.0.1"
"127.0.0.1"
case "custom":
return customBindHost ?? "127.0.0.1"
customBindHost ?? "127.0.0.1"
default:
return "127.0.0.1"
"127.0.0.1"
}
}
}

View File

@@ -4,11 +4,46 @@ enum GatewayLaunchAgentManager {
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
private static var disableLaunchAgentMarkerURL: URL {
FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(self.disableLaunchAgentMarker)
}
private static var plistURL: URL {
FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
}
static func isLaunchAgentWriteDisabled() -> Bool {
FileManager().fileExists(atPath: self.disableLaunchAgentMarkerURL.path)
}
static func setLaunchAgentWriteDisabled(_ disabled: Bool) -> String? {
let marker = self.disableLaunchAgentMarkerURL
if disabled {
do {
try FileManager().createDirectory(
at: marker.deletingLastPathComponent(),
withIntermediateDirectories: true)
if !FileManager().fileExists(atPath: marker.path) {
FileManager().createFile(atPath: marker.path, contents: nil)
}
} catch {
return error.localizedDescription
}
return nil
}
if FileManager().fileExists(atPath: marker.path) {
do {
try FileManager().removeItem(at: marker)
} catch {
return error.localizedDescription
}
}
return nil
}
static func isLoaded() async -> Bool {
guard let loaded = await self.readDaemonLoaded() else { return false }
return loaded
@@ -66,12 +101,6 @@ enum GatewayLaunchAgentManager {
}
extension GatewayLaunchAgentManager {
private static func isLaunchAgentWriteDisabled() -> Bool {
let marker = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(self.disableLaunchAgentMarker)
return FileManager().fileExists(atPath: marker.path)
}
private static func readDaemonLoaded() async -> Bool? {
let result = await self.runDaemonCommandResult(
["status", "--json", "--no-probe"],
@@ -115,7 +144,7 @@ extension GatewayLaunchAgentManager {
quiet: Bool) async -> CommandResult
{
let command = CommandResolver.clawdbotCommand(
subcommand: "daemon",
subcommand: "gateway",
extraArgs: self.withJsonFlag(args),
// Launchd management must always run locally, even if remote mode is configured.
configRoot: ["gateway": ["mode": "local"]])

View File

@@ -42,10 +42,20 @@ final class GatewayProcessManager {
private var environmentRefreshTask: Task<Void, Never>?
private var lastEnvironmentRefresh: Date?
private var logRefreshTask: Task<Void, Never>?
#if DEBUG
private var testingConnection: GatewayConnection?
#endif
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.process")
private let logLimit = 20000 // characters to keep in-memory
private let environmentRefreshMinInterval: TimeInterval = 30
private var connection: GatewayConnection {
#if DEBUG
return self.testingConnection ?? .shared
#else
return .shared
#endif
}
func setActive(_ active: Bool) {
// Remote mode should never spawn a local gateway; treat as stopped.
@@ -69,6 +79,11 @@ final class GatewayProcessManager {
func ensureLaunchAgentEnabledIfNeeded() async {
guard !CommandResolver.connectionModeIsRemote() else { return }
if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() {
self.appendLog("[gateway] launchd auto-enable skipped (attach-only)\n")
self.logger.info("gateway launchd auto-enable skipped (disable marker set)")
return
}
let enabled = await GatewayLaunchAgentManager.isLoaded()
guard !enabled else { return }
let bundlePath = Bundle.main.bundleURL.path
@@ -126,6 +141,10 @@ final class GatewayProcessManager {
}
}
func clearLastFailure() {
self.lastFailureReason = nil
}
func refreshEnvironmentStatus(force: Bool = false) {
let now = Date()
if !force {
@@ -178,7 +197,7 @@ final class GatewayProcessManager {
let hasListener = instance != nil
let attemptAttach = {
try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 2000)
try await self.connection.requestRaw(method: .health, timeoutMs: 2000)
}
for attempt in 0..<(hasListener ? 3 : 1) {
@@ -187,6 +206,7 @@ final class GatewayProcessManager {
let snap = decodeHealthSnapshot(from: data)
let details = self.describe(details: instanceText, port: port, snap: snap)
self.existingGatewayDetails = details
self.clearLastFailure()
self.status = .attachedExisting(details: details)
self.appendLog("[gateway] using existing instance: \(details)\n")
self.logger.info("gateway using existing instance details=\(details)")
@@ -222,19 +242,17 @@ final class GatewayProcessManager {
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
let instanceText = instance ?? "pid unknown"
if let snap {
let linkId = snap.channelOrder?.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
return false
}) ?? snap.channels.keys.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
return false
})
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
let order = snap.channelOrder ?? Array(snap.channels.keys)
let linkId = order.first(where: { snap.channels[$0]?.linked == true })
?? order.first(where: { snap.channels[$0]?.linked != nil })
guard let linkId else {
return "port \(port), health probe succeeded, \(instanceText)"
}
let linked = snap.channels[linkId]?.linked ?? false
let authAge = snap.channels[linkId]?.authAgeMs.flatMap(msToAge) ?? "unknown age"
let label =
linkId.flatMap { snap.channelLabels?[$0] } ??
linkId?.capitalized ??
"channel"
snap.channelLabels?[linkId] ??
linkId.capitalized
let linkText = linked ? "linked" : "not linked"
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
}
@@ -293,6 +311,15 @@ final class GatewayProcessManager {
return
}
if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() {
let message = "Launchd disabled; start the Gateway manually or disable attach-only."
self.status = .failed(message)
self.lastFailureReason = "launchd disabled"
self.appendLog("[gateway] launchd disabled; skipping auto-start\n")
self.logger.info("gateway launchd enable skipped (disable marker set)")
return
}
let bundlePath = Bundle.main.bundleURL.path
let port = GatewayEnvironment.gatewayPort()
self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n")
@@ -310,9 +337,10 @@ final class GatewayProcessManager {
while Date() < deadline {
if !self.desiredActive { return }
do {
_ = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 1500)
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500)
let instance = await PortGuardian.shared.describe(port: port)
let details = instance.map { "pid \($0.pid)" }
self.clearLastFailure()
self.status = .running(details: details)
self.logger.info("gateway started details=\(details ?? "ok")")
self.refreshControlChannelIfNeeded(reason: "gateway started")
@@ -352,7 +380,8 @@ final class GatewayProcessManager {
while Date() < deadline {
if !self.desiredActive { return false }
do {
_ = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 1500)
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500)
self.clearLastFailure()
return true
} catch {
try? await Task.sleep(nanoseconds: 300_000_000)
@@ -385,3 +414,19 @@ final class GatewayProcessManager {
return String(text.suffix(limit))
}
}
#if DEBUG
extension GatewayProcessManager {
func setTestingConnection(_ connection: GatewayConnection?) {
self.testingConnection = connection
}
func setTestingDesiredActive(_ active: Bool) {
self.desiredActive = active
}
func setTestingLastFailureReason(_ reason: String?) {
self.lastFailureReason = reason
}
}
#endif

View File

@@ -0,0 +1,64 @@
import Foundation
enum GatewayRemoteConfig {
static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport {
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let raw = remote["transport"] as? String
else {
return .ssh
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed == AppState.RemoteTransport.direct.rawValue ? .direct : .ssh
}
static func resolveUrlString(root: [String: Any]) -> String? {
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let urlRaw = remote["url"] as? String
else {
return nil
}
let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
static func resolveGatewayUrl(root: [String: Any]) -> URL? {
guard let raw = self.resolveUrlString(root: root) else { return nil }
return self.normalizeGatewayUrl(raw)
}
static func normalizeGatewayUrlString(_ raw: String) -> String? {
self.normalizeGatewayUrl(raw)?.absoluteString
}
static func normalizeGatewayUrl(_ raw: String) -> URL? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil }
let scheme = url.scheme?.lowercased() ?? ""
guard scheme == "ws" || scheme == "wss" else { return nil }
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !host.isEmpty else { return nil }
if scheme == "ws", url.port == nil {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return url
}
components.port = 18789
return components.url
}
return url
}
static func defaultPort(for url: URL) -> Int? {
if let port = url.port { return port }
let scheme = url.scheme?.lowercased() ?? ""
switch scheme {
case "wss":
return 443
case "ws":
return 18789
default:
return nil
}
}
}

View File

@@ -2,52 +2,26 @@ import AppKit
import ClawdbotDiscovery
import ClawdbotIPC
import ClawdbotKit
import CoreLocation
import Observation
import SwiftUI
struct GeneralSettings: View {
@Bindable var state: AppState
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdbotLocationMode.off.rawValue
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
private let healthStore = HealthStore.shared
private let gatewayManager = GatewayProcessManager.shared
@State private var gatewayDiscovery = GatewayDiscoveryModel(
localDisplayName: InstanceIdentity.displayName)
@State private var isInstallingCLI = false
@State private var cliStatus: String?
@State private var cliInstalled = false
@State private var cliInstallLocation: String?
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
@State private var remoteStatus: RemoteStatus = .idle
@State private var showRemoteAdvanced = false
private let isPreview = ProcessInfo.processInfo.isPreview
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
private var remoteLabelWidth: CGFloat { 88 }
var body: some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 18) {
if !self.state.onboardingSeen {
Button {
DebugActions.restartOnboarding()
} label: {
HStack(spacing: 8) {
Label("Complete onboarding to finish setup", systemImage: "arrow.counterclockwise")
.font(.callout.weight(.semibold))
.foregroundStyle(Color.accentColor)
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.bottom, 2)
}
VStack(alignment: .leading, spacing: 12) {
SettingsToggleRow(
title: "Clawdbot active",
@@ -83,29 +57,6 @@ struct GeneralSettings: View {
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
binding: self.$cameraEnabled)
SystemRunSettingsView()
VStack(alignment: .leading, spacing: 6) {
Text("Location Access")
.font(.body)
Picker("", selection: self.$locationModeRaw) {
Text("Off").tag(ClawdbotLocationMode.off.rawValue)
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
}
.labelsHidden()
.pickerStyle(.menu)
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
.disabled(self.locationMode == .off)
Text("Always may require System Settings to approve background location.")
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
SettingsToggleRow(
title: "Enable Peekaboo Bridge",
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
@@ -130,29 +81,13 @@ struct GeneralSettings: View {
}
.onAppear {
guard !self.isPreview else { return }
self.refreshCLIStatus()
self.refreshGatewayStatus()
self.lastLocationModeRaw = self.locationModeRaw
}
.onChange(of: self.state.canvasEnabled) { _, enabled in
if !enabled {
CanvasManager.shared.hideAll()
}
}
.onChange(of: self.locationModeRaw) { _, newValue in
let previous = self.lastLocationModeRaw
self.lastLocationModeRaw = newValue
guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return }
Task {
let granted = await self.requestLocationAuthorization(mode: mode)
if !granted {
await MainActor.run {
self.locationModeRaw = previous
self.lastLocationModeRaw = previous
}
}
}
}
}
private var activeBinding: Binding<Bool> {
@@ -161,39 +96,20 @@ struct GeneralSettings: View {
set: { self.state.isPaused = !$0 })
}
private var locationMode: ClawdbotLocationMode {
ClawdbotLocationMode(rawValue: self.locationModeRaw) ?? .off
}
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
guard mode != .off else { return true }
guard CLLocationManager.locationServicesEnabled() else {
await MainActor.run { LocationPermissionHelper.openSettings() }
return false
}
let status = CLLocationManager().authorizationStatus
let requireAlways = mode == .always
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
return true
}
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
}
private var connectionSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Clawdbot runs")
.font(.title3.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
Picker("", selection: self.$state.connectionMode) {
Picker("Mode", selection: self.$state.connectionMode) {
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
Text("Remote (another host)").tag(AppState.ConnectionMode.remote)
}
.pickerStyle(.segmented)
.frame(width: 380, alignment: .leading)
.pickerStyle(.menu)
.labelsHidden()
.frame(width: 260, alignment: .leading)
if self.state.connectionMode == .unconfigured {
Text("Pick Local or Remote to start the Gateway.")
@@ -216,67 +132,56 @@ struct GeneralSettings: View {
if self.state.connectionMode == .remote {
self.remoteCard
}
self.cliInstaller
}
}
private var remoteCard: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center, spacing: 10) {
Text("SSH")
.font(.callout.weight(.semibold))
.frame(width: 48, alignment: .leading)
TextField("user@host[:22]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
Button {
Task { await self.testRemote() }
} label: {
if self.remoteStatus == .checking {
ProgressView().controlSize(.small)
} else {
Text("Test remote")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
self.remoteTransportRow
if self.state.remoteTransport == .ssh {
self.remoteSshRow
} else {
self.remoteDirectRow
}
GatewayDiscoveryInlineList(
discovery: self.gatewayDiscovery,
currentTarget: self.state.remoteTarget)
currentTarget: self.state.remoteTarget,
currentUrl: self.state.remoteUrl,
transport: self.state.remoteTransport)
{ gateway in
self.applyDiscoveredGateway(gateway)
}
.padding(.leading, 58)
.padding(.leading, self.remoteLabelWidth + 10)
self.remoteStatusView
.padding(.leading, 58)
.padding(.leading, self.remoteLabelWidth + 10)
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
VStack(alignment: .leading, spacing: 8) {
LabeledContent("Identity file") {
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
LabeledContent("Project root") {
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
LabeledContent("CLI path") {
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
if self.state.remoteTransport == .ssh {
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
VStack(alignment: .leading, spacing: 8) {
LabeledContent("Identity file") {
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
LabeledContent("Project root") {
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
LabeledContent("CLI path") {
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
}
.padding(.top, 4)
} label: {
Text("Advanced")
.font(.callout.weight(.semibold))
}
.padding(.top, 4)
} label: {
Text("Advanced")
.font(.callout.weight(.semibold))
}
// Diagnostics
@@ -299,18 +204,96 @@ struct GeneralSettings: View {
.font(.caption)
.foregroundStyle(.secondary)
}
if let authLabel = ControlChannel.shared.authSourceLabel {
Text(authLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Text("Tip: enable Tailscale for stable remote access.")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
if self.state.remoteTransport == .ssh {
Text("Tip: enable Tailscale for stable remote access.")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
} else {
Text("Tip: use Tailscale Serve so the gateway has a valid HTTPS cert.")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.transition(.opacity)
.onAppear { self.gatewayDiscovery.start() }
.onDisappear { self.gatewayDiscovery.stop() }
}
private var remoteTransportRow: some View {
HStack(alignment: .center, spacing: 10) {
Text("Transport")
.font(.callout.weight(.semibold))
.frame(width: self.remoteLabelWidth, alignment: .leading)
Picker("Transport", selection: self.$state.remoteTransport) {
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
}
.pickerStyle(.segmented)
.frame(maxWidth: 320)
}
}
private var remoteSshRow: some View {
HStack(alignment: .center, spacing: 10) {
Text("SSH target")
.font(.callout.weight(.semibold))
.frame(width: self.remoteLabelWidth, alignment: .leading)
TextField("user@host[:22]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
Button {
Task { await self.testRemote() }
} label: {
if self.remoteStatus == .checking {
ProgressView().controlSize(.small)
} else {
Text("Test remote")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
private var remoteDirectRow: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .center, spacing: 10) {
Text("Gateway")
.font(.callout.weight(.semibold))
.frame(width: self.remoteLabelWidth, alignment: .leading)
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
Button {
Task { await self.testRemote() }
} label: {
if self.remoteStatus == .checking {
ProgressView().controlSize(.small)
} else {
Text("Test remote")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || self.state.remoteUrl
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
Text("Direct mode requires a ws:// or wss:// URL (Tailscale Serve uses wss://<magicdns>).")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, self.remoteLabelWidth + 10)
}
}
private var controlStatusLine: String {
switch ControlChannel.shared.state {
case .connected: "Connected"
@@ -346,59 +329,6 @@ struct GeneralSettings: View {
return message == self.controlStatusLine
}
private var cliInstaller: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Button {
Task { await self.installCLI() }
} label: {
let title = self.cliInstalled ? "Reinstall CLI" : "Install CLI"
ZStack {
Text(title)
.opacity(self.isInstallingCLI ? 0 : 1)
if self.isInstallingCLI {
ProgressView()
.controlSize(.mini)
}
}
.frame(minWidth: 150)
}
.disabled(self.isInstallingCLI)
if self.isInstallingCLI {
Text("Working...")
.font(.callout)
.foregroundStyle(.secondary)
} else if self.cliInstalled {
Label("Installed", systemImage: "checkmark.circle.fill")
.font(.callout)
.foregroundStyle(.secondary)
} else {
Text("Not installed")
.font(.callout)
.foregroundStyle(.secondary)
}
}
if let status = cliStatus {
Text(status)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
} else if let installLocation = self.cliInstallLocation {
Text("Found at \(installLocation)")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
} else {
Text("Installs a user-space Node 22+ runtime and the CLI (no Homebrew).")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
}
private var gatewayInstallerCard: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
@@ -454,22 +384,6 @@ struct GeneralSettings: View {
.cornerRadius(10)
}
private func installCLI() async {
guard !self.isInstallingCLI else { return }
self.isInstallingCLI = true
defer { isInstallingCLI = false }
await CLIInstaller.install { status in
self.cliStatus = status
self.refreshCLIStatus()
}
}
private func refreshCLIStatus() {
let installLocation = CLIInstaller.installedLocation()
self.cliInstallLocation = installLocation
self.cliInstalled = installLocation != nil
}
private func refreshGatewayStatus() {
Task {
let status = await Task.detached(priority: .utility) {
@@ -609,24 +523,36 @@ extension GeneralSettings {
func testRemote() async {
self.remoteStatus = .checking
let settings = CommandResolver.connectionSettings()
guard !settings.target.isEmpty else {
self.remoteStatus = .failed("Set an SSH target first")
return
if self.state.remoteTransport == .direct {
let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedUrl.isEmpty else {
self.remoteStatus = .failed("Set a gateway URL first")
return
}
guard Self.isValidWsUrl(trimmedUrl) else {
self.remoteStatus = .failed("Gateway URL must start with ws:// or wss://")
return
}
} else {
guard !settings.target.isEmpty else {
self.remoteStatus = .failed("Set an SSH target first")
return
}
// Step 1: basic SSH reachability check
let sshResult = await ShellExecutor.run(
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
cwd: nil,
env: nil,
timeout: 8)
guard sshResult.ok else {
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
return
}
}
// Step 1: basic SSH reachability check
let sshResult = await ShellExecutor.run(
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
cwd: nil,
env: nil,
timeout: 8)
guard sshResult.ok else {
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
return
}
// Step 2: control channel health over tunnel
// Step 2: control channel health check
let originalMode = AppStateStore.shared.connectionMode
do {
try await ControlChannel.shared.configure(mode: .remote(
@@ -653,6 +579,14 @@ extension GeneralSettings {
}
}
private static func isValidWsUrl(_ raw: String) -> Bool {
guard let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false }
let scheme = url.scheme?.lowercased() ?? ""
guard scheme == "ws" || scheme == "wss" else { return false }
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return !host.isEmpty
}
private static func sshCheckCommand(target: String, identity: String) -> [String] {
var args: [String] = [
"/usr/bin/ssh",
@@ -721,12 +655,18 @@ extension GeneralSettings {
let host = gateway.tailnetDns ?? gateway.lanHost
guard let host else { return }
let user = NSUserName()
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
user: user,
host: host,
port: gateway.sshPort)
self.state.remoteCliPath = gateway.cliPath ?? ""
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
if self.state.remoteTransport == .direct {
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
self.state.remoteUrl = url
}
} else {
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
user: user,
host: host,
port: gateway.sshPort)
self.state.remoteCliPath = gateway.cliPath ?? ""
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
}
}
}
@@ -749,7 +689,9 @@ extension GeneralSettings {
static func exerciseForTesting() {
let state = AppState(preview: true)
state.connectionMode = .remote
state.remoteTransport = .ssh
state.remoteTarget = "user@host:2222"
state.remoteUrl = "wss://gateway.example.ts.net"
state.remoteIdentity = "/tmp/id_ed25519"
state.remoteProjectRoot = "/tmp/clawdbot"
state.remoteCliPath = "/tmp/clawdbot"
@@ -763,9 +705,6 @@ extension GeneralSettings {
message: "Gateway ready")
view.remoteStatus = .failed("SSH failed")
view.showRemoteAdvanced = true
view.cliInstalled = true
view.cliInstallLocation = "/usr/local/bin/clawdbot"
view.cliStatus = "Installed"
_ = view.body
state.connectionMode = .unconfigured

View File

@@ -166,6 +166,11 @@ final class HealthStore {
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
{
let order = snap.channelOrder ?? Array(snap.channels.keys)
for id in order {
if let summary = snap.channels[id], summary.linked == true {
return (id: id, summary: summary)
}
}
for id in order {
if let summary = snap.channels[id], summary.linked != nil {
return (id: id, summary: summary)
@@ -235,8 +240,8 @@ final class HealthStore {
let lower = error.lowercased()
if lower.contains("connection refused") {
let port = GatewayEnvironment.gatewayPort()
return "The gateway control port (127.0.0.1:\(port)) isnt listening — " +
"restart Clawdbot to bring it back."
let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
return "The gateway control port (\(host)) isnt listening — restart Clawdbot to bring it back."
}
if lower.contains("timeout") {
return "Timed out waiting for the control server; the gateway may be crashed or still starting."

View File

@@ -3,6 +3,7 @@ import Darwin
import Foundation
import MenuBarExtraAccess
import Observation
import OSLog
import Security
import SwiftUI
@@ -10,9 +11,11 @@ import SwiftUI
struct ClawdbotApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@State private var state: AppState
private static let logger = Logger(subsystem: "com.clawdbot", category: "app")
private let gatewayManager = GatewayProcessManager.shared
private let controlChannel = ControlChannel.shared
private let activityStore = WorkActivityStore.shared
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
@State private var statusItem: NSStatusItem?
@State private var isMenuPresented = false
@State private var isPanelVisible = false
@@ -30,6 +33,7 @@ struct ClawdbotApp: App {
init() {
ClawdbotLogging.bootstrapIfNeeded()
Self.applyAttachOnlyOverrideIfNeeded()
_state = State(initialValue: AppStateStore.shared)
}
@@ -90,6 +94,22 @@ struct ClawdbotApp: App {
self.statusItem?.button?.appearsDisabled = paused || sleeping
}
private static func applyAttachOnlyOverrideIfNeeded() {
let args = CommandLine.arguments
guard args.contains("--attach-only") || args.contains("--no-launchd") else { return }
if let error = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(true) {
Self.logger.error("attach-only flag failed: \(error, privacy: .public)")
return
}
Task {
_ = await GatewayLaunchAgentManager.set(
enabled: false,
bundlePath: Bundle.main.bundlePath,
port: GatewayEnvironment.gatewayPort())
}
Self.logger.info("attach-only flag enabled")
}
private var isGatewaySleeping: Bool {
if self.state.isPaused { return false }
switch self.state.connectionMode {

View File

@@ -1,4 +1,6 @@
import AppKit
import Foundation
import Observation
import SwiftUI
@MainActor
@@ -18,6 +20,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
private var isMenuOpen = false
private var lastKnownMenuWidth: CGFloat?
private var menuOpenWidth: CGFloat?
private var isObservingControlChannel = false
private var cachedSnapshot: SessionStoreSnapshot?
private var cachedErrorText: String?
@@ -50,6 +53,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
self.loadTask = Task { await self.refreshCache(force: true) }
}
self.startControlChannelObservation()
self.nodesStore.start()
}
@@ -96,6 +100,50 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
self.cancelPreviewTasks()
}
private func startControlChannelObservation() {
guard !self.isObservingControlChannel else { return }
self.isObservingControlChannel = true
self.observeControlChannelState()
}
private func observeControlChannelState() {
withObservationTracking {
_ = ControlChannel.shared.state
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
self.handleControlChannelStateChange()
self.observeControlChannelState()
}
}
}
private func handleControlChannelStateChange() {
guard self.isMenuOpen, let menu = self.statusItem?.menu else { return }
self.loadTask?.cancel()
self.loadTask = Task { [weak self, weak menu] in
guard let self, let menu else { return }
await self.refreshCache(force: true)
await self.refreshUsageCache(force: true)
await self.refreshCostUsageCache(force: true)
await MainActor.run {
guard self.isMenuOpen else { return }
self.inject(into: menu)
self.injectNodes(into: menu)
}
}
self.nodesLoadTask?.cancel()
self.nodesLoadTask = Task { [weak self, weak menu] in
guard let self, let menu else { return }
await self.nodesStore.refresh()
await MainActor.run {
guard self.isMenuOpen else { return }
self.injectNodes(into: menu)
}
}
}
func menuNeedsUpdate(_ menu: NSMenu) {
self.originalDelegate?.menuNeedsUpdate?(menu)
}
@@ -141,14 +189,23 @@ extension MenuSessionsInjector {
if rhs.key == mainKey { return false }
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
}
if !rows.isEmpty {
let previewKeys = rows.prefix(20).map(\.key)
let task = Task {
await SessionMenuPreviewLoader.prewarm(sessionKeys: previewKeys, maxItems: 10)
}
self.previewTasks.append(task)
}
let headerItem = NSMenuItem()
headerItem.tag = self.tag
headerItem.isEnabled = false
let statusText = self
.cachedErrorText ?? (isConnected ? nil : self.controlChannelStatusText(for: channelState))
let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(
count: rows.count,
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
statusText: statusText)),
width: width,
highlighted: false)
headerItem.view = hosted
@@ -461,15 +518,29 @@ extension MenuSessionsInjector {
switch mode {
case .remote:
platform = "remote"
let target = AppStateStore.shared.remoteTarget
if let parsed = CommandResolver.parseSSHTarget(target) {
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
if AppStateStore.shared.remoteTransport == .direct {
let trimmedUrl = AppStateStore.shared.remoteUrl
.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = URL(string: trimmedUrl), let urlHost = url.host, !urlHost.isEmpty {
if let port = url.port {
host = "\(urlHost):\(port)"
} else {
host = urlHost
}
} else {
host = trimmedUrl.nonEmpty
}
} else {
host = target.nonEmpty
let target = AppStateStore.shared.remoteTarget
if let parsed = CommandResolver.parseSSHTarget(target) {
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
} else {
host = target.nonEmpty
}
}
case .local:
platform = "local"
host = "127.0.0.1:\(port)"
host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
case .unconfigured:
platform = nil
host = nil
@@ -598,8 +669,11 @@ extension MenuSessionsInjector {
}
guard self.isControlChannelConnected else {
self.cachedSnapshot = nil
self.cachedErrorText = nil
if self.cachedSnapshot != nil {
self.cachedErrorText = "Gateway disconnected (showing cached)"
} else {
self.cachedErrorText = nil
}
self.cacheUpdatedAt = Date()
return
}
@@ -624,8 +698,6 @@ extension MenuSessionsInjector {
}
guard self.isControlChannelConnected else {
self.cachedUsageSummary = nil
self.cachedUsageErrorText = nil
self.usageCacheUpdatedAt = Date()
return
}
@@ -648,8 +720,6 @@ extension MenuSessionsInjector {
}
guard self.isControlChannelConnected else {
self.cachedCostSummary = nil
self.cachedCostErrorText = nil
self.costCacheUpdatedAt = Date()
return
}

View File

@@ -2,14 +2,28 @@ import Foundation
import JavaScriptCore
enum ModelCatalogLoader {
static let defaultPath: String = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path
static var defaultPath: String { self.resolveDefaultPath() }
private static let logger = Logger(subsystem: "com.clawdbot", category: "models")
private nonisolated static let appSupportDir: URL = {
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("Clawdbot", isDirectory: true)
}()
private static var cachePath: URL {
self.appSupportDir.appendingPathComponent("model-catalog/models.generated.js", isDirectory: false)
}
static func load(from path: String) async throws -> [ModelChoice] {
let expanded = (path as NSString).expandingTildeInPath
self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: expanded).lastPathComponent)")
let source = try String(contentsOfFile: expanded, encoding: .utf8)
guard let resolved = self.resolvePath(preferred: expanded) else {
self.logger.error("model catalog load failed: file not found")
throw NSError(
domain: "ModelCatalogLoader",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Model catalog file not found"])
}
self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: resolved.path).lastPathComponent)")
let source = try String(contentsOfFile: resolved.path, encoding: .utf8)
let sanitized = self.sanitize(source: source)
let ctx = JSContext()
@@ -45,9 +59,82 @@ enum ModelCatalogLoader {
return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending
}
self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)")
if resolved.shouldCache {
self.cacheCatalog(sourcePath: resolved.path)
}
return sorted
}
private static func resolveDefaultPath() -> String {
let cache = self.cachePath.path
if FileManager().isReadableFile(atPath: cache) { return cache }
if let bundlePath = self.bundleCatalogPath() { return bundlePath }
if let nodePath = self.nodeModulesCatalogPath() { return nodePath }
return cache
}
private static func resolvePath(preferred: String) -> (path: String, shouldCache: Bool)? {
if FileManager().isReadableFile(atPath: preferred) {
return (preferred, preferred != self.cachePath.path)
}
if let bundlePath = self.bundleCatalogPath(), bundlePath != preferred {
self.logger.warning("model catalog path missing; falling back to bundled catalog")
return (bundlePath, true)
}
let cache = self.cachePath.path
if cache != preferred, FileManager().isReadableFile(atPath: cache) {
self.logger.warning("model catalog path missing; falling back to cached catalog")
return (cache, false)
}
if let nodePath = self.nodeModulesCatalogPath(), nodePath != preferred {
self.logger.warning("model catalog path missing; falling back to node_modules catalog")
return (nodePath, true)
}
return nil
}
private static func bundleCatalogPath() -> String? {
guard let url = Bundle.main.url(forResource: "models.generated", withExtension: "js") else {
return nil
}
return url.path
}
private static func nodeModulesCatalogPath() -> String? {
let roots = [
URL(fileURLWithPath: CommandResolver.projectRootPath()),
URL(fileURLWithPath: FileManager().currentDirectoryPath),
]
for root in roots {
let candidate = root
.appendingPathComponent("node_modules/@mariozechner/pi-ai/dist/models.generated.js")
if FileManager().isReadableFile(atPath: candidate.path) {
return candidate.path
}
}
return nil
}
private static func cacheCatalog(sourcePath: String) {
let destination = self.cachePath
do {
try FileManager().createDirectory(
at: destination.deletingLastPathComponent(),
withIntermediateDirectories: true)
if FileManager().fileExists(atPath: destination.path) {
try FileManager().removeItem(at: destination)
}
try FileManager().copyItem(atPath: sourcePath, toPath: destination.path)
self.logger.debug("model catalog cached file=\(destination.lastPathComponent)")
} catch {
self.logger.warning("model catalog cache failed: \(error.localizedDescription)")
}
}
private static func sanitize(source: String) -> String {
guard let exportRange = source.range(of: "export const MODELS"),
let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"),

View File

@@ -480,26 +480,26 @@ actor MacNodeRuntime {
message: "SYSTEM_RUN_DISABLED: security=deny")
}
let requiresAsk: Bool = {
if ask == .always { return true }
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
return false
}()
let approvedByAsk = params.approved == true
if requiresAsk, !approvedByAsk {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
reason: "approval-required"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: approval required")
let approval = await self.resolveSystemRunApproval(
req: req,
params: params,
context: ExecRunContext(
displayCommand: displayCommand,
security: security,
ask: ask,
agentId: agentId,
resolution: resolution,
allowlistMatch: allowlistMatch,
skillAllow: skillAllow,
sessionKey: sessionKey,
runId: runId))
if let response = approval.response { return response }
let approvedByAsk = approval.approvedByAsk
let persistAllowlist = approval.persistAllowlist
if persistAllowlist, security == .allowlist,
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution)
{
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
}
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
@@ -619,6 +619,100 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private struct ExecApprovalOutcome {
var approvedByAsk: Bool
var persistAllowlist: Bool
var response: BridgeInvokeResponse?
}
private struct ExecRunContext {
var displayCommand: String
var security: ExecSecurity
var ask: ExecAsk
var agentId: String?
var resolution: ExecCommandResolution?
var allowlistMatch: ExecAllowlistEntry?
var skillAllow: Bool
var sessionKey: String
var runId: String
}
private func resolveSystemRunApproval(
req: BridgeInvokeRequest,
params: ClawdbotSystemRunParams,
context: ExecRunContext) async -> ExecApprovalOutcome
{
let requiresAsk = ExecApprovalHelpers.requiresAsk(
ask: context.ask,
security: context.security,
allowlistMatch: context.allowlistMatch,
skillAllow: context.skillAllow)
let decisionFromParams = ExecApprovalHelpers.parseDecision(params.approvalDecision)
var approvedByAsk = params.approved == true || decisionFromParams != nil
var persistAllowlist = decisionFromParams == .allowAlways
if decisionFromParams == .deny {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: context.sessionKey,
runId: context.runId,
host: "node",
command: context.displayCommand,
reason: "user-denied"))
return ExecApprovalOutcome(
approvedByAsk: approvedByAsk,
persistAllowlist: persistAllowlist,
response: Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: user denied"))
}
if requiresAsk, !approvedByAsk {
let decision = await MainActor.run {
ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: context.displayCommand,
cwd: params.cwd,
host: "node",
security: context.security.rawValue,
ask: context.ask.rawValue,
agentId: context.agentId,
resolvedPath: context.resolution?.resolvedPath,
sessionKey: context.sessionKey))
}
switch decision {
case .deny:
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: context.sessionKey,
runId: context.runId,
host: "node",
command: context.displayCommand,
reason: "user-denied"))
return ExecApprovalOutcome(
approvedByAsk: approvedByAsk,
persistAllowlist: persistAllowlist,
response: Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: user denied"))
case .allowAlways:
approvedByAsk = true
persistAllowlist = true
case .allowOnce:
approvedByAsk = true
}
}
return ExecApprovalOutcome(
approvedByAsk: approvedByAsk,
persistAllowlist: persistAllowlist,
response: nil)
}
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
_ = ExecApprovalsStore.ensureFile()
let snapshot = ExecApprovalsStore.readSnapshot()

View File

@@ -25,7 +25,11 @@ extension OnboardingView {
self.preferredGatewayID = gateway.stableID
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
if let host = gateway.tailnetDns ?? gateway.lanHost {
if self.state.remoteTransport == .direct {
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
self.state.remoteUrl = url
}
} else if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
let user = NSUserName()
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
user: user,

View File

@@ -177,42 +177,67 @@ extension OnboardingView {
VStack(alignment: .leading, spacing: 10) {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow {
Text("SSH target")
Text("Transport")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("user@host[:port]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
Picker("Transport", selection: self.$state.remoteTransport) {
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
}
.pickerStyle(.segmented)
.frame(width: fieldWidth)
}
GridRow {
Text("Identity file")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
if self.state.remoteTransport == .direct {
GridRow {
Text("Gateway URL")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
}
GridRow {
Text("Project root")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
GridRow {
Text("CLI path")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField(
"/Applications/Clawdbot.app/.../clawdbot",
text: self.$state.remoteCliPath)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
if self.state.remoteTransport == .ssh {
GridRow {
Text("SSH target")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("user@host[:port]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
GridRow {
Text("Identity file")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
GridRow {
Text("Project root")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
GridRow {
Text("CLI path")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField(
"/Applications/Clawdbot.app/.../clawdbot",
text: self.$state.remoteCliPath)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
}
}
Text("Tip: keep Tailscale enabled so your gateway stays reachable.")
Text(self.state.remoteTransport == .direct
? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert."
: "Tip: keep Tailscale enabled so your gateway stays reachable.")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -225,7 +250,10 @@ extension OnboardingView {
}
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
if let host = gateway.tailnetDns ?? gateway.lanHost {
if self.state.remoteTransport == .direct {
return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only"
}
if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
return "\(host)\(portSuffix)"
}

View File

@@ -1,4 +1,6 @@
import ClawdbotIPC
import ClawdbotKit
import CoreLocation
import SwiftUI
struct PermissionsSettings: View {
@@ -8,6 +10,8 @@ struct PermissionsSettings: View {
var body: some View {
VStack(alignment: .leading, spacing: 14) {
SystemRunSettingsView()
Text("Allow these so Clawdbot can notify and capture when needed.")
.padding(.top, 4)
@@ -15,6 +19,8 @@ struct PermissionsSettings: View {
.padding(.horizontal, 2)
.padding(.vertical, 6)
LocationAccessSettings()
Button("Restart onboarding") { self.showOnboarding() }
.buttonStyle(.bordered)
Spacer()
@@ -24,6 +30,72 @@ struct PermissionsSettings: View {
}
}
private struct LocationAccessSettings: View {
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdbotLocationMode.off.rawValue
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Location Access")
.font(.body)
Picker("", selection: self.$locationModeRaw) {
Text("Off").tag(ClawdbotLocationMode.off.rawValue)
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
}
.labelsHidden()
.pickerStyle(.menu)
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
.disabled(self.locationMode == .off)
Text("Always may require System Settings to approve background location.")
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
.onAppear {
self.lastLocationModeRaw = self.locationModeRaw
}
.onChange(of: self.locationModeRaw) { _, newValue in
let previous = self.lastLocationModeRaw
self.lastLocationModeRaw = newValue
guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return }
Task {
let granted = await self.requestLocationAuthorization(mode: mode)
if !granted {
await MainActor.run {
self.locationModeRaw = previous
self.lastLocationModeRaw = previous
}
}
}
}
}
private var locationMode: ClawdbotLocationMode {
ClawdbotLocationMode(rawValue: self.locationModeRaw) ?? .off
}
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
guard mode != .off else { return true }
guard CLLocationManager.locationServicesEnabled() else {
await MainActor.run { LocationPermissionHelper.openSettings() }
return false
}
let status = CLLocationManager().authorizationStatus
let requireAlways = mode == .always
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
return true
}
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
}
}
struct PermissionStatusList: View {
let status: [Capability: Bool]
let refresh: () async -> Void
@@ -45,25 +117,6 @@ struct PermissionStatusList: View {
.font(.footnote)
.padding(.top, 2)
.help("Refresh status")
if (self.status[.accessibility] ?? false) == false || (self.status[.screenRecording] ?? false) == false {
VStack(alignment: .leading, spacing: 8) {
Text(
"Note: macOS may require restarting Clawdbot after enabling Accessibility or Screen Recording.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button {
LaunchdManager.startClawdbot()
} label: {
Label("Restart Clawdbot", systemImage: "arrow.counterclockwise")
}
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.top, 4)
}
}
}

View File

@@ -184,6 +184,14 @@ actor PortGuardian {
}
}
func isListening(port: Int, pid: Int32? = nil) async -> Bool {
let listeners = await self.listeners(on: port)
if let pid {
return listeners.contains(where: { $0.pid == pid })
}
return !listeners.isEmpty
}
private func listeners(on port: Int) async -> [Listener] {
let res = await ShellExecutor.run(
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],

View File

@@ -72,7 +72,6 @@ final class RemotePortTunnel {
}
var args: [String] = [
"-o", "BatchMode=yes",
"-o", "IdentitiesOnly=yes",
"-o", "ExitOnForwardFailure=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
@@ -84,7 +83,12 @@ final class RemotePortTunnel {
]
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
if !identity.isEmpty { args.append(contentsOf: ["-i", identity]) }
if !identity.isEmpty {
// Only use IdentitiesOnly when an explicit identity file is provided.
// This allows 1Password SSH agent and other SSH agents to provide keys.
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
args.append(contentsOf: ["-i", identity])
}
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
args.append(userHost)

View File

@@ -20,11 +20,13 @@ actor RemoteTunnelManager {
tunnel.process.isRunning,
let local = tunnel.localPort
{
if await self.isTunnelHealthy(port: local) {
let pid = tunnel.process.processIdentifier
if await PortGuardian.shared.isListening(port: Int(local), pid: pid) {
self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)")
return local
}
self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting")
self.logger.error(
"active SSH tunnel on port \(local, privacy: .public) is not listening; restarting")
await self.beginRestart()
tunnel.terminate()
self.controlTunnel = nil
@@ -35,19 +37,11 @@ actor RemoteTunnelManager {
if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)),
self.isSshProcess(desc)
{
if await self.isTunnelHealthy(port: desiredPort) {
self.logger.info(
"reusing existing SSH tunnel listener " +
"localPort=\(desiredPort, privacy: .public) " +
"pid=\(desc.pid, privacy: .public)")
return desiredPort
}
if self.restartInFlight {
self.logger.info("control tunnel restart in flight; skip stale tunnel cleanup")
return nil
}
await self.beginRestart()
await self.cleanupStaleTunnel(desc: desc, port: desiredPort)
self.logger.info(
"reusing existing SSH tunnel listener " +
"localPort=\(desiredPort, privacy: .public) " +
"pid=\(desc.pid, privacy: .public)")
return desiredPort
}
return nil
}
@@ -88,10 +82,6 @@ actor RemoteTunnelManager {
self.controlTunnel = nil
}
private func isTunnelHealthy(port: UInt16) async -> Bool {
await PortGuardian.shared.probeGatewayHealth(port: Int(port))
}
private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool {
let cmd = desc.command.lowercased()
if cmd.contains("ssh") { return true }
@@ -128,21 +118,5 @@ actor RemoteTunnelManager {
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
}
private func cleanupStaleTunnel(desc: PortGuardian.Descriptor, port: UInt16) async {
let pid = desc.pid
self.logger.error(
"stale SSH tunnel detected on port \(port, privacy: .public) pid \(pid, privacy: .public)")
let killed = await self.kill(pid: pid)
if !killed {
self.logger.error("failed to terminate stale SSH tunnel pid \(pid, privacy: .public)")
}
await PortGuardian.shared.removeRecord(pid: pid)
}
private func kill(pid: Int32) async -> Bool {
let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2)
if term.ok { return true }
let sigkill = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2)
return sigkill.ok
}
// Keep tunnel reuse lightweight; restart only when the listener disappears.
}

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.20</string>
<string>2026.1.24</string>
<key>CFBundleVersion</key>
<string>202601200</string>
<string>202601240</string>
<key>CFBundleIconFile</key>
<string>Clawdbot</string>
<key>CFBundleURLTypes</key>

View File

@@ -1,5 +1,6 @@
import ClawdbotChatUI
import ClawdbotKit
import ClawdbotProtocol
import OSLog
import SwiftUI
@@ -31,31 +32,80 @@ actor SessionPreviewCache {
static let shared = SessionPreviewCache()
private struct CacheEntry {
let items: [SessionPreviewItem]
let snapshot: SessionMenuPreviewSnapshot
let updatedAt: Date
}
private var entries: [String: CacheEntry] = [:]
func cachedItems(for sessionKey: String, maxAge: TimeInterval) -> [SessionPreviewItem]? {
func cachedSnapshot(for sessionKey: String, maxAge: TimeInterval) -> SessionMenuPreviewSnapshot? {
guard let entry = self.entries[sessionKey] else { return nil }
guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil }
return entry.items
return entry.snapshot
}
func store(items: [SessionPreviewItem], for sessionKey: String) {
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: Date())
func store(snapshot: SessionMenuPreviewSnapshot, for sessionKey: String) {
self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: Date())
}
func lastItems(for sessionKey: String) -> [SessionPreviewItem]? {
self.entries[sessionKey]?.items
func lastSnapshot(for sessionKey: String) -> SessionMenuPreviewSnapshot? {
self.entries[sessionKey]?.snapshot
}
}
actor SessionPreviewLimiter {
static let shared = SessionPreviewLimiter(maxConcurrent: 2)
private let maxConcurrent: Int
private var available: Int
private var waitQueue: [UUID] = []
private var waiters: [UUID: CheckedContinuation<Void, Never>] = [:]
init(maxConcurrent: Int) {
let normalized = max(1, maxConcurrent)
self.maxConcurrent = normalized
self.available = normalized
}
func withPermit<T>(_ operation: () async throws -> T) async throws -> T {
await self.acquire()
defer { self.release() }
if Task.isCancelled { throw CancellationError() }
return try await operation()
}
private func acquire() async {
if self.available > 0 {
self.available -= 1
return
}
let id = UUID()
await withCheckedContinuation { cont in
self.waitQueue.append(id)
self.waiters[id] = cont
}
}
private func release() {
if let id = self.waitQueue.first {
self.waitQueue.removeFirst()
if let cont = self.waiters.removeValue(forKey: id) {
cont.resume()
}
return
}
self.available = min(self.available + 1, self.maxConcurrent)
}
}
#if DEBUG
extension SessionPreviewCache {
func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt)
func _testSet(
snapshot: SessionMenuPreviewSnapshot,
for sessionKey: String,
updatedAt: Date = Date())
{
self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: updatedAt)
}
func _testReset() {
@@ -174,36 +224,44 @@ enum SessionMenuPreviewLoader {
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
private static let previewTimeoutSeconds: Double = 4
private static let cacheMaxAgeSeconds: TimeInterval = 30
private static let previewMaxChars = 240
private struct PreviewTimeoutError: LocalizedError {
var errorDescription: String? { "preview timeout" }
}
static func prewarm(sessionKeys: [String], maxItems: Int) async {
let keys = self.uniqueKeys(sessionKeys)
guard !keys.isEmpty else { return }
do {
let payload = try await self.requestPreview(keys: keys, maxItems: maxItems)
await self.cache(payload: payload, maxItems: maxItems)
} catch {
if self.isUnknownMethodError(error) { return }
let errorDescription = String(describing: error)
Self.logger.debug(
"Session preview prewarm failed count=\(keys.count, privacy: .public) " +
"error=\(errorDescription, privacy: .public)")
}
}
static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot {
if let cached = await SessionPreviewCache.shared.cachedItems(for: sessionKey, maxAge: cacheMaxAgeSeconds) {
return self.snapshot(from: cached)
if let cached = await SessionPreviewCache.shared.cachedSnapshot(
for: sessionKey,
maxAge: cacheMaxAgeSeconds)
{
return cached
}
do {
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
let payload = try await AsyncTimeout.withTimeout(
seconds: self.previewTimeoutSeconds,
onTimeout: { PreviewTimeoutError() },
operation: {
try await GatewayConnection.shared.chatHistory(
sessionKey: sessionKey,
limit: self.previewLimit(for: maxItems),
timeoutMs: timeoutMs)
})
let built = Self.previewItems(from: payload, maxItems: maxItems)
await SessionPreviewCache.shared.store(items: built, for: sessionKey)
return Self.snapshot(from: built)
let snapshot = try await self.fetchSnapshot(sessionKey: sessionKey, maxItems: maxItems)
await SessionPreviewCache.shared.store(snapshot: snapshot, for: sessionKey)
return snapshot
} catch is CancellationError {
return SessionMenuPreviewSnapshot(items: [], status: .loading)
} catch {
let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey)
if let fallback {
return Self.snapshot(from: fallback)
if let fallback = await SessionPreviewCache.shared.lastSnapshot(for: sessionKey) {
return fallback
}
let errorDescription = String(describing: error)
Self.logger.warning(
@@ -213,18 +271,120 @@ enum SessionMenuPreviewLoader {
}
}
private static func fetchSnapshot(sessionKey: String, maxItems: Int) async throws -> SessionMenuPreviewSnapshot {
do {
let payload = try await self.requestPreview(keys: [sessionKey], maxItems: maxItems)
if let entry = payload.previews.first(where: { $0.key == sessionKey }) ?? payload.previews.first {
return self.snapshot(from: entry, maxItems: maxItems)
}
return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable"))
} catch {
if self.isUnknownMethodError(error) {
return try await self.fetchHistorySnapshot(sessionKey: sessionKey, maxItems: maxItems)
}
throw error
}
}
private static func requestPreview(
keys: [String],
maxItems: Int) async throws -> ClawdbotSessionsPreviewPayload
{
let boundedItems = self.normalizeMaxItems(maxItems)
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
return try await SessionPreviewLimiter.shared.withPermit {
try await AsyncTimeout.withTimeout(
seconds: self.previewTimeoutSeconds,
onTimeout: { PreviewTimeoutError() },
operation: {
try await GatewayConnection.shared.sessionsPreview(
keys: keys,
limit: boundedItems,
maxChars: self.previewMaxChars,
timeoutMs: timeoutMs)
})
}
}
private static func fetchHistorySnapshot(
sessionKey: String,
maxItems: Int) async throws -> SessionMenuPreviewSnapshot
{
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
let payload = try await SessionPreviewLimiter.shared.withPermit {
try await AsyncTimeout.withTimeout(
seconds: self.previewTimeoutSeconds,
onTimeout: { PreviewTimeoutError() },
operation: {
try await GatewayConnection.shared.chatHistory(
sessionKey: sessionKey,
limit: self.previewLimit(for: maxItems),
timeoutMs: timeoutMs)
})
}
let built = Self.previewItems(from: payload, maxItems: maxItems)
return Self.snapshot(from: built)
}
private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot {
SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
}
private static func snapshot(
from entry: ClawdbotSessionPreviewEntry,
maxItems: Int) -> SessionMenuPreviewSnapshot
{
let items = self.previewItems(from: entry, maxItems: maxItems)
let normalized = entry.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
switch normalized {
case "ok":
return SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
case "empty":
return SessionMenuPreviewSnapshot(items: items, status: .empty)
case "missing":
return SessionMenuPreviewSnapshot(items: items, status: .error("Session missing"))
default:
return SessionMenuPreviewSnapshot(items: items, status: .error("Preview unavailable"))
}
}
private static func cache(payload: ClawdbotSessionsPreviewPayload, maxItems: Int) async {
for entry in payload.previews {
let snapshot = self.snapshot(from: entry, maxItems: maxItems)
await SessionPreviewCache.shared.store(snapshot: snapshot, for: entry.key)
}
}
private static func previewLimit(for maxItems: Int) -> Int {
min(max(maxItems * 3, 20), 120)
let boundedItems = self.normalizeMaxItems(maxItems)
return min(max(boundedItems * 3, 20), 120)
}
private static func normalizeMaxItems(_ maxItems: Int) -> Int {
max(1, min(maxItems, 50))
}
private static func previewItems(
from entry: ClawdbotSessionPreviewEntry,
maxItems: Int) -> [SessionPreviewItem]
{
let boundedItems = self.normalizeMaxItems(maxItems)
let built: [SessionPreviewItem] = entry.items.enumerated().compactMap { index, item in
let text = item.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
let role = self.previewRoleFromRaw(item.role)
return SessionPreviewItem(id: "\(entry.key)-\(index)", role: role, text: text)
}
let trimmed = built.suffix(boundedItems)
return Array(trimmed.reversed())
}
private static func previewItems(
from payload: ClawdbotChatHistoryPayload,
maxItems: Int) -> [SessionPreviewItem]
{
let boundedItems = self.normalizeMaxItems(maxItems)
let raw: [ClawdbotKit.AnyCodable] = payload.messages ?? []
let messages = self.decodeMessages(raw)
let built = messages.compactMap { message -> SessionPreviewItem? in
@@ -235,7 +395,7 @@ enum SessionMenuPreviewLoader {
return SessionPreviewItem(id: id, role: role, text: text)
}
let trimmed = built.suffix(maxItems)
let trimmed = built.suffix(boundedItems)
return Array(trimmed.reversed())
}
@@ -248,12 +408,16 @@ enum SessionMenuPreviewLoader {
private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole {
if isTool { return .tool }
return self.previewRoleFromRaw(raw)
}
private static func previewRoleFromRaw(_ raw: String) -> PreviewRole {
switch raw.lowercased() {
case "user": return .user
case "assistant": return .assistant
case "system": return .system
case "tool": return .tool
default: return .other
case "user": .user
case "assistant": .assistant
case "system": .system
case "tool": .tool
default: .other
}
}
@@ -316,4 +480,16 @@ enum SessionMenuPreviewLoader {
}
return result
}
private static func uniqueKeys(_ keys: [String]) -> [String] {
let trimmed = keys.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
return self.dedupePreservingOrder(trimmed.filter { !$0.isEmpty })
}
private static func isUnknownMethodError(_ error: Error) -> Bool {
guard let response = error as? GatewayResponseError else { return false }
guard response.code == ErrorCode.invalidRequest.rawValue else { return false }
let message = response.message.lowercased()
return message.contains("unknown method")
}
}

View File

@@ -123,12 +123,12 @@ struct SystemRunSettingsView: View {
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
ForEach(self.model.entries, id: \.id) { entry in
ExecAllowlistRow(
entry: Binding(
get: { self.model.entries[index] },
set: { self.model.updateEntry($0, at: index) }),
onRemove: { self.model.removeEntry(at: index) })
get: { self.model.entry(for: entry.id) ?? entry },
set: { self.model.updateEntry($0, id: entry.id) }),
onRemove: { self.model.removeEntry(id: entry.id) })
}
}
}
@@ -373,20 +373,24 @@ final class ExecApprovalsSettingsModel {
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) {
guard !self.isDefaultsScope else { return }
guard self.entries.indices.contains(index) else { return }
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
self.entries[index] = entry
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func removeEntry(at index: Int) {
func removeEntry(id: UUID) {
guard !self.isDefaultsScope else { return }
guard self.entries.indices.contains(index) else { return }
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
self.entries.remove(at: index)
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func entry(for id: UUID) -> ExecAllowlistEntry? {
self.entries.first(where: { $0.id == id })
}
func refreshSkillBins(force: Bool = false) async {
guard self.autoAllowSkills else {
self.skillBins = []

View File

@@ -103,6 +103,7 @@ final class TailscaleService {
}
func checkTailscaleStatus() async {
let previousIP = self.tailscaleIP
self.isInstalled = self.checkAppInstallation()
if !self.isInstalled {
self.isRunning = false
@@ -147,6 +148,10 @@ final class TailscaleService {
self.statusError = nil
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
}
if previousIP != self.tailscaleIP {
await GatewayEndpointStore.shared.refresh()
}
}
func openTailscaleApp() {
@@ -173,7 +178,7 @@ final class TailscaleService {
}
}
private static func isTailnetIPv4(_ address: String) -> Bool {
private nonisolated static func isTailnetIPv4(_ address: String) -> Bool {
let parts = address.split(separator: ".")
guard parts.count == 4 else { return false }
let octets = parts.compactMap { Int($0) }
@@ -183,7 +188,7 @@ final class TailscaleService {
return a == 100 && b >= 64 && b <= 127
}
private static func detectTailnetIPv4() -> String? {
private nonisolated static func detectTailnetIPv4() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
defer { freeifaddrs(addrList) }
@@ -214,4 +219,8 @@ final class TailscaleService {
return nil
}
nonisolated static func fallbackTailnetIPv4() -> String? {
self.detectTailnetIPv4()
}
}

View File

@@ -4,6 +4,8 @@ func sanitizeVoiceWakeTriggers(_ words: [String]) -> [String] {
let cleaned = words
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.prefix(voiceWakeMaxWords)
.map { String($0.prefix(voiceWakeMaxWordLength)) }
return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned
}

View File

@@ -21,6 +21,7 @@ struct VoiceWakeSettings: View {
@State private var micObserver = AudioInputDeviceObserver()
@State private var micRefreshTask: Task<Void, Never>?
@State private var availableLocales: [Locale] = []
@State private var triggerEntries: [TriggerEntry] = []
private let fieldLabelWidth: CGFloat = 140
private let controlWidth: CGFloat = 240
private let isPreview = ProcessInfo.processInfo.isPreview
@@ -31,9 +32,9 @@ struct VoiceWakeSettings: View {
var id: String { self.uid }
}
private struct IndexedWord: Identifiable {
let id: Int
let value: String
private struct TriggerEntry: Identifiable {
let id: UUID
var value: String
}
private var voiceWakeBinding: Binding<Bool> {
@@ -105,6 +106,7 @@ struct VoiceWakeSettings: View {
.onAppear {
guard !self.isPreview else { return }
self.startMicObserver()
self.loadTriggerEntries()
}
.onChange(of: self.state.voiceWakeMicID) { _, _ in
guard !self.isPreview else { return }
@@ -122,8 +124,10 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil
Task { await self.meter.stop() }
self.micObserver.stop()
self.syncTriggerEntriesToState()
} else {
self.startMicObserver()
self.loadTriggerEntries()
}
}
.onDisappear {
@@ -136,11 +140,16 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil
self.micObserver.stop()
Task { await self.meter.stop() }
self.syncTriggerEntriesToState()
}
}
private var indexedWords: [IndexedWord] {
self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) }
private func loadTriggerEntries() {
self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) }
}
private func syncTriggerEntriesToState() {
self.state.swabbleTriggerWords = self.triggerEntries.map(\.value)
}
private var triggerTable: some View {
@@ -154,29 +163,42 @@ struct VoiceWakeSettings: View {
} label: {
Label("Add word", systemImage: "plus")
}
.disabled(self.state.swabbleTriggerWords
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
.disabled(self.triggerEntries
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers }
Button("Reset defaults") {
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
self.syncTriggerEntriesToState()
}
}
Table(self.indexedWords) {
TableColumn("Word") { row in
TextField("Wake word", text: self.binding(for: row.id))
.textFieldStyle(.roundedBorder)
}
TableColumn("") { row in
Button {
self.removeWord(at: row.id)
} label: {
Image(systemName: "trash")
VStack(spacing: 0) {
ForEach(self.$triggerEntries) { $entry in
HStack(spacing: 8) {
TextField("Wake word", text: $entry.value)
.textFieldStyle(.roundedBorder)
.onSubmit {
self.syncTriggerEntriesToState()
}
Button {
self.removeWord(id: entry.id)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.help("Remove trigger word")
.frame(width: 24)
}
.padding(8)
if entry.id != self.triggerEntries.last?.id {
Divider()
}
.buttonStyle(.borderless)
.help("Remove trigger word")
}
.width(36)
}
.frame(minHeight: 180)
.frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
.background(Color(nsColor: .textBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
@@ -211,24 +233,12 @@ struct VoiceWakeSettings: View {
}
private func addWord() {
self.state.swabbleTriggerWords.append("")
self.triggerEntries.append(TriggerEntry(id: UUID(), value: ""))
}
private func removeWord(at index: Int) {
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
self.state.swabbleTriggerWords.remove(at: index)
}
private func binding(for index: Int) -> Binding<String> {
Binding(
get: {
guard self.state.swabbleTriggerWords.indices.contains(index) else { return "" }
return self.state.swabbleTriggerWords[index]
},
set: { newValue in
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
self.state.swabbleTriggerWords[index] = newValue
})
private func removeWord(id: UUID) {
self.triggerEntries.removeAll { $0.id == id }
self.syncTriggerEntriesToState()
}
private func toggleTest() {
@@ -638,13 +648,14 @@ extension VoiceWakeSettings {
state.voicePushToTalkEnabled = true
state.swabbleTriggerWords = ["Claude", "Hey"]
let view = VoiceWakeSettings(state: state, isActive: true)
var view = VoiceWakeSettings(state: state, isActive: true)
view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")]
view.availableLocales = [Locale(identifier: "en_US")]
view.meterLevel = 0.42
view.meterError = "No input"
view.testState = .detected("ok")
view.isTesting = true
view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")]
_ = view.body
_ = view.localePicker
@@ -654,8 +665,9 @@ extension VoiceWakeSettings {
_ = view.chimeSection
view.addWord()
_ = view.binding(for: 0).wrappedValue
view.removeWord(at: 0)
if let entryId = view.triggerEntries.first?.id {
view.removeWord(id: entryId)
}
}
}
#endif

View File

@@ -309,7 +309,7 @@ private func resolveLocalHost(bind: String?) -> String {
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let tailnetIP = detectTailnetIPv4()
switch normalized {
case "tailnet", "auto":
case "tailnet":
return tailnetIP ?? "127.0.0.1"
default:
return "127.0.0.1"

View File

@@ -18,6 +18,7 @@ public struct ConnectParams: Codable, Sendable {
public let caps: [String]?
public let commands: [String]?
public let permissions: [String: AnyCodable]?
public let pathenv: String?
public let role: String?
public let scopes: [String]?
public let device: [String: AnyCodable]?
@@ -32,6 +33,7 @@ public struct ConnectParams: Codable, Sendable {
caps: [String]?,
commands: [String]?,
permissions: [String: AnyCodable]?,
pathenv: String?,
role: String?,
scopes: [String]?,
device: [String: AnyCodable]?,
@@ -45,6 +47,7 @@ public struct ConnectParams: Codable, Sendable {
self.caps = caps
self.commands = commands
self.permissions = permissions
self.pathenv = pathenv
self.role = role
self.scopes = scopes
self.device = device
@@ -59,6 +62,7 @@ public struct ConnectParams: Codable, Sendable {
case caps
case commands
case permissions
case pathenv = "pathEnv"
case role
case scopes
case device
@@ -381,6 +385,7 @@ public struct SendParams: Codable, Sendable {
public let to: String
public let message: String
public let mediaurl: String?
public let mediaurls: [String]?
public let gifplayback: Bool?
public let channel: String?
public let accountid: String?
@@ -391,6 +396,7 @@ public struct SendParams: Codable, Sendable {
to: String,
message: String,
mediaurl: String?,
mediaurls: [String]?,
gifplayback: Bool?,
channel: String?,
accountid: String?,
@@ -400,6 +406,7 @@ public struct SendParams: Codable, Sendable {
self.to = to
self.message = message
self.mediaurl = mediaurl
self.mediaurls = mediaurls
self.gifplayback = gifplayback
self.channel = channel
self.accountid = accountid
@@ -410,6 +417,7 @@ public struct SendParams: Codable, Sendable {
case to
case message
case mediaurl = "mediaUrl"
case mediaurls = "mediaUrls"
case gifplayback = "gifPlayback"
case channel
case accountid = "accountId"
@@ -474,6 +482,9 @@ public struct AgentParams: Codable, Sendable {
public let accountid: String?
public let replyaccountid: String?
public let threadid: String?
public let groupid: String?
public let groupchannel: String?
public let groupspace: String?
public let timeout: Int?
public let lane: String?
public let extrasystemprompt: String?
@@ -496,6 +507,9 @@ public struct AgentParams: Codable, Sendable {
accountid: String?,
replyaccountid: String?,
threadid: String?,
groupid: String?,
groupchannel: String?,
groupspace: String?,
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
@@ -517,6 +531,9 @@ public struct AgentParams: Codable, Sendable {
self.accountid = accountid
self.replyaccountid = replyaccountid
self.threadid = threadid
self.groupid = groupid
self.groupchannel = groupchannel
self.groupspace = groupspace
self.timeout = timeout
self.lane = lane
self.extrasystemprompt = extrasystemprompt
@@ -539,6 +556,9 @@ public struct AgentParams: Codable, Sendable {
case accountid = "accountId"
case replyaccountid = "replyAccountId"
case threadid = "threadId"
case groupid = "groupId"
case groupchannel = "groupChannel"
case groupspace = "groupSpace"
case timeout
case lane
case extrasystemprompt = "extraSystemPrompt"
@@ -548,6 +568,44 @@ public struct AgentParams: Codable, Sendable {
}
}
public struct AgentIdentityParams: Codable, Sendable {
public let agentid: String?
public let sessionkey: String?
public init(
agentid: String?,
sessionkey: String?
) {
self.agentid = agentid
self.sessionkey = sessionkey
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case sessionkey = "sessionKey"
}
}
public struct AgentIdentityResult: Codable, Sendable {
public let agentid: String
public let name: String?
public let avatar: String?
public init(
agentid: String,
name: String?,
avatar: String?
) {
self.agentid = agentid
self.name = name
self.avatar = avatar
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case name
case avatar
}
}
public struct AgentWaitParams: Codable, Sendable {
public let runid: String
public let timeoutms: Int?
@@ -883,8 +941,30 @@ public struct SessionsListParams: Codable, Sendable {
}
}
public struct SessionsPreviewParams: Codable, Sendable {
public let keys: [String]
public let limit: Int?
public let maxchars: Int?
public init(
keys: [String],
limit: Int?,
maxchars: Int?
) {
self.keys = keys
self.limit = limit
self.maxchars = maxchars
}
private enum CodingKeys: String, CodingKey {
case keys
case limit
case maxchars = "maxChars"
}
}
public struct SessionsResolveParams: Codable, Sendable {
public let key: String?
public let sessionid: String?
public let label: String?
public let agentid: String?
public let spawnedby: String?
@@ -893,6 +973,7 @@ public struct SessionsResolveParams: Codable, Sendable {
public init(
key: String?,
sessionid: String?,
label: String?,
agentid: String?,
spawnedby: String?,
@@ -900,6 +981,7 @@ public struct SessionsResolveParams: Codable, Sendable {
includeunknown: Bool?
) {
self.key = key
self.sessionid = sessionid
self.label = label
self.agentid = agentid
self.spawnedby = spawnedby
@@ -908,6 +990,7 @@ public struct SessionsResolveParams: Codable, Sendable {
}
private enum CodingKeys: String, CodingKey {
case key
case sessionid = "sessionId"
case label
case agentid = "agentId"
case spawnedby = "spawnedBy"
@@ -1084,17 +1167,29 @@ public struct ConfigApplyParams: Codable, Sendable {
public struct ConfigPatchParams: Codable, Sendable {
public let raw: String
public let basehash: String?
public let sessionkey: String?
public let note: String?
public let restartdelayms: Int?
public init(
raw: String,
basehash: String?
basehash: String?,
sessionkey: String?,
note: String?,
restartdelayms: Int?
) {
self.raw = raw
self.basehash = basehash
self.sessionkey = sessionkey
self.note = note
self.restartdelayms = restartdelayms
}
private enum CodingKeys: String, CodingKey {
case raw
case basehash = "baseHash"
case sessionkey = "sessionKey"
case note
case restartdelayms = "restartDelayMs"
}
}
@@ -1443,17 +1538,21 @@ public struct WebLoginWaitParams: Codable, Sendable {
public struct AgentSummary: Codable, Sendable {
public let id: String
public let name: String?
public let identity: [String: AnyCodable]?
public init(
id: String,
name: String?
name: String?,
identity: [String: AnyCodable]?
) {
self.id = id
self.name = name
self.identity = identity
}
private enum CodingKeys: String, CodingKey {
case id
case name
case identity
}
}
@@ -1904,27 +2003,30 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
}
public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String?
public let command: String
public let cwd: String?
public let host: String?
public let security: String?
public let ask: String?
public let agentid: String?
public let resolvedpath: String?
public let sessionkey: String?
public let cwd: AnyCodable?
public let host: AnyCodable?
public let security: AnyCodable?
public let ask: AnyCodable?
public let agentid: AnyCodable?
public let resolvedpath: AnyCodable?
public let sessionkey: AnyCodable?
public let timeoutms: Int?
public init(
id: String?,
command: String,
cwd: String?,
host: String?,
security: String?,
ask: String?,
agentid: String?,
resolvedpath: String?,
sessionkey: String?,
cwd: AnyCodable?,
host: AnyCodable?,
security: AnyCodable?,
ask: AnyCodable?,
agentid: AnyCodable?,
resolvedpath: AnyCodable?,
sessionkey: AnyCodable?,
timeoutms: Int?
) {
self.id = id
self.command = command
self.cwd = cwd
self.host = host
@@ -1936,6 +2038,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
self.timeoutms = timeoutms
}
private enum CodingKeys: String, CodingKey {
case id
case command
case cwd
case host

View File

@@ -0,0 +1,60 @@
import Foundation
import Testing
@testable import Clawdbot
@Suite struct ExecApprovalHelpersTests {
@Test func parseDecisionTrimsAndRejectsInvalid() {
#expect(ExecApprovalHelpers.parseDecision("allow-once") == .allowOnce)
#expect(ExecApprovalHelpers.parseDecision(" allow-always ") == .allowAlways)
#expect(ExecApprovalHelpers.parseDecision("deny") == .deny)
#expect(ExecApprovalHelpers.parseDecision("") == nil)
#expect(ExecApprovalHelpers.parseDecision("nope") == nil)
}
@Test func allowlistPatternPrefersResolution() {
let resolved = ExecCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
#expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: resolved) == resolved.resolvedPath)
let rawOnly = ExecCommandResolution(
rawExecutable: "rg",
resolvedPath: nil,
executableName: "rg",
cwd: nil)
#expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: rawOnly) == "rg")
#expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: nil) == "rg")
#expect(ExecApprovalHelpers.allowlistPattern(command: [], resolution: nil) == nil)
}
@Test func requiresAskMatchesPolicy() {
let entry = ExecAllowlistEntry(pattern: "/bin/ls", lastUsedAt: nil, lastUsedCommand: nil, lastResolvedPath: nil)
#expect(ExecApprovalHelpers.requiresAsk(
ask: .always,
security: .deny,
allowlistMatch: nil,
skillAllow: false))
#expect(ExecApprovalHelpers.requiresAsk(
ask: .onMiss,
security: .allowlist,
allowlistMatch: nil,
skillAllow: false))
#expect(!ExecApprovalHelpers.requiresAsk(
ask: .onMiss,
security: .allowlist,
allowlistMatch: entry,
skillAllow: false))
#expect(!ExecApprovalHelpers.requiresAsk(
ask: .onMiss,
security: .allowlist,
allowlistMatch: nil,
skillAllow: true))
#expect(!ExecApprovalHelpers.requiresAsk(
ask: .off,
security: .allowlist,
allowlistMatch: nil,
skillAllow: false))
}
}

View File

@@ -0,0 +1,56 @@
import Testing
@testable import Clawdbot
@Suite
@MainActor
struct ExecApprovalsGatewayPrompterTests {
@Test func sessionMatchPrefersActiveSession() {
let matches = ExecApprovalsGatewayPrompter._testShouldPresent(
mode: .remote,
activeSession: " main ",
requestSession: "main",
lastInputSeconds: nil)
#expect(matches)
let mismatched = ExecApprovalsGatewayPrompter._testShouldPresent(
mode: .remote,
activeSession: "other",
requestSession: "main",
lastInputSeconds: 0)
#expect(!mismatched)
}
@Test func sessionFallbackUsesRecentActivity() {
let recent = ExecApprovalsGatewayPrompter._testShouldPresent(
mode: .remote,
activeSession: nil,
requestSession: "main",
lastInputSeconds: 10,
thresholdSeconds: 120)
#expect(recent)
let stale = ExecApprovalsGatewayPrompter._testShouldPresent(
mode: .remote,
activeSession: nil,
requestSession: "main",
lastInputSeconds: 200,
thresholdSeconds: 120)
#expect(!stale)
}
@Test func defaultBehaviorMatchesMode() {
let local = ExecApprovalsGatewayPrompter._testShouldPresent(
mode: .local,
activeSession: nil,
requestSession: nil,
lastInputSeconds: 400)
#expect(local)
let remote = ExecApprovalsGatewayPrompter._testShouldPresent(
mode: .remote,
activeSession: nil,
requestSession: nil,
lastInputSeconds: 400)
#expect(!remote)
}
}

View File

@@ -11,6 +11,7 @@ import Testing
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.googlechat.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
}
@@ -19,6 +20,7 @@ import Testing
#expect(GatewayAgentChannel(raw: nil) == .last)
#expect(GatewayAgentChannel(raw: " ") == .last)
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
#expect(GatewayAgentChannel(raw: "googlechat") == .googlechat)
#expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles)
#expect(GatewayAgentChannel(raw: "unknown") == .last)
}

View File

@@ -140,14 +140,14 @@ import Testing
#expect(resolved.mode == .remote)
}
@Test func resolveLocalGatewayHostPrefersTailnetForAuto() {
@Test func resolveLocalGatewayHostUsesLoopbackForAutoEvenWithTailnet() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: "100.64.1.2")
#expect(host == "100.64.1.2")
#expect(host == "127.0.0.1")
}
@Test func resolveLocalGatewayHostFallsBackToLoopbackForAuto() {
@Test func resolveLocalGatewayHostUsesLoopbackForAutoWithoutTailnet() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: nil)
@@ -175,4 +175,10 @@ import Testing
customBindHost: "192.168.1.10")
#expect(host == "192.168.1.10")
}
@Test func normalizeGatewayUrlAddsDefaultPortForWs() {
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway")
#expect(url?.port == 18789)
#expect(url?.absoluteString == "ws://gateway:18789")
}
}

View File

@@ -48,7 +48,10 @@ import Testing
@Test func expectedGatewayVersionFromStringUsesParser() {
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11))
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(
major: 2026,
minor: 1,
patch: 11))
#expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil)
}
}

View File

@@ -0,0 +1,147 @@
import ClawdbotKit
import Foundation
import os
import Testing
@testable import Clawdbot
@Suite(.serialized)
@MainActor
struct GatewayProcessManagerTests {
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>)
-> Void)?>(initialState: nil)
private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
private let sendCount = OSAllocatedUnfairLock(initialState: 0)
var state: URLSessionTask.State = .suspended
func resume() {
self.state = .running
}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
self.state = .canceling
self.cancelCount.withLock { $0 += 1 }
let handler = self.pendingReceiveHandler.withLock { handler in
defer { handler = nil }
return handler
}
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.cancelled)))
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
let currentSendCount = self.sendCount.withLock { count in
defer { count += 1 }
return count
}
if currentSendCount == 0 {
guard case let .data(data) = message else { return }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["type"] as? String) == "req",
(obj["method"] as? String) == "connect",
let id = obj["id"] as? String
{
self.connectRequestID.withLock { $0 = id }
}
return
}
guard case let .data(data) = message else { return }
guard
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["type"] as? String) == "req",
let id = obj["id"] as? String
else {
return
}
let response = Self.responseData(id: id)
let handler = self.pendingReceiveHandler.withLock { $0 }
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(response)))
}
func receive() async throws -> URLSessionWebSocketTask.Message {
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(Self.connectOkData(id: id))
}
func receive(
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
{
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
private static func connectOkData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 2,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
}
private static func responseData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": { "ok": true }
}
"""
return Data(json.utf8)
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]())
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
_ = url
let task = FakeWebSocketTask()
self.tasks.withLock { $0.append(task) }
return WebSocketTaskBox(task: task)
}
}
@Test func clearsLastFailureWhenHealthSucceeds() async {
let session = FakeWebSocketSession()
let url = URL(string: "ws://example.invalid")!
let connection = GatewayConnection(
configProvider: { (url: url, token: nil, password: nil) },
sessionBox: WebSocketSessionBox(session: session))
let manager = GatewayProcessManager.shared
manager.setTestingConnection(connection)
manager.setTestingDesiredActive(true)
manager.setTestingLastFailureReason("health failed")
defer {
manager.setTestingConnection(nil)
manager.setTestingDesiredActive(false)
manager.setTestingLastFailureReason(nil)
}
let ready = await manager.waitForGatewayReady(timeout: 0.5)
#expect(ready)
#expect(manager.lastFailureReason == nil)
}
}

View File

@@ -7,20 +7,22 @@ struct SessionMenuPreviewTests {
@Test func loaderReturnsCachedItems() async {
await SessionPreviewCache.shared._testReset()
let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")]
await SessionPreviewCache.shared._testSet(items: items, for: "main")
let snapshot = SessionMenuPreviewSnapshot(items: items, status: .ready)
await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main")
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
#expect(snapshot.status == .ready)
#expect(snapshot.items.count == 1)
#expect(snapshot.items.first?.text == "Hi")
let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
#expect(loaded.status == .ready)
#expect(loaded.items.count == 1)
#expect(loaded.items.first?.text == "Hi")
}
@Test func loaderReturnsEmptyWhenCachedEmpty() async {
await SessionPreviewCache.shared._testReset()
await SessionPreviewCache.shared._testSet(items: [], for: "main")
let snapshot = SessionMenuPreviewSnapshot(items: [], status: .empty)
await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main")
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
#expect(snapshot.status == .empty)
#expect(snapshot.items.isEmpty)
let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
#expect(loaded.status == .empty)
#expect(loaded.items.isEmpty)
}
}

View File

@@ -12,6 +12,18 @@ struct VoiceWakeHelpersTests {
#expect(cleaned == defaultVoiceWakeTriggers)
}
@Test func sanitizeTriggersLimitsWordLength() {
let long = String(repeating: "x", count: voiceWakeMaxWordLength + 5)
let cleaned = sanitizeVoiceWakeTriggers(["ok", long])
#expect(cleaned[1].count == voiceWakeMaxWordLength)
}
@Test func sanitizeTriggersLimitsWordCount() {
let words = (1...voiceWakeMaxWords + 3).map { "w\($0)" }
let cleaned = sanitizeVoiceWakeTriggers(words)
#expect(cleaned.count == voiceWakeMaxWords)
}
@Test func normalizeLocaleStripsCollation() {
#expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US")
}

View File

@@ -235,6 +235,27 @@ public struct ClawdbotChatHistoryPayload: Codable, Sendable {
public let thinkingLevel: String?
}
public struct ClawdbotSessionPreviewItem: Codable, Hashable, Sendable {
public let role: String
public let text: String
}
public struct ClawdbotSessionPreviewEntry: Codable, Sendable {
public let key: String
public let status: String
public let items: [ClawdbotSessionPreviewItem]
}
public struct ClawdbotSessionsPreviewPayload: Codable, Sendable {
public let ts: Int
public let previews: [ClawdbotSessionPreviewEntry]
public init(ts: Int, previews: [ClawdbotSessionPreviewEntry]) {
self.ts = ts
self.previews = previews
}
}
public struct ClawdbotChatSendResponse: Codable, Sendable {
public let runId: String
public let status: String

View File

@@ -12,6 +12,7 @@ public struct ClawdbotChatView: View {
@State private var scrollPosition: UUID?
@State private var showSessions = false
@State private var hasPerformedInitialScroll = false
@State private var isPinnedToBottom = true
private let showsSessionSwitcher: Bool
private let style: Style
private let markdownVariant: ChatMarkdownVariant
@@ -87,36 +88,28 @@ public struct ClawdbotChatView: View {
private var messageList: some View {
ZStack {
ScrollView {
#if os(macOS)
VStack(spacing: 0) {
LazyVStack(spacing: Layout.messageSpacing) {
self.messageListRows
}
Color.clear
.frame(height: Layout.messageListPaddingBottom)
.id(self.scrollerBottomID)
}
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
.scrollTargetLayout()
.padding(.top, Layout.messageListPaddingTop)
.padding(.horizontal, Layout.messageListPaddingHorizontal)
#else
LazyVStack(spacing: Layout.messageSpacing) {
self.messageListRows
Color.clear
#if os(macOS)
.frame(height: Layout.messageListPaddingBottom)
#else
.frame(height: Layout.messageListPaddingBottom + 1)
#endif
.id(self.scrollerBottomID)
}
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
.scrollTargetLayout()
.padding(.top, Layout.messageListPaddingTop)
.padding(.horizontal, Layout.messageListPaddingHorizontal)
#endif
}
// Keep the scroll pinned to the bottom for new messages.
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
.onChange(of: self.scrollPosition) { _, position in
guard let position else { return }
self.isPinnedToBottom = position == self.scrollerBottomID
}
if self.viewModel.isLoading {
ProgressView()
@@ -133,18 +126,26 @@ public struct ClawdbotChatView: View {
guard !isLoading, !self.hasPerformedInitialScroll else { return }
self.scrollPosition = self.scrollerBottomID
self.hasPerformedInitialScroll = true
self.isPinnedToBottom = true
}
.onChange(of: self.viewModel.sessionKey) { _, _ in
self.hasPerformedInitialScroll = false
self.isPinnedToBottom = true
}
.onChange(of: self.viewModel.messages.count) { _, _ in
guard self.hasPerformedInitialScroll else { return }
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
withAnimation(.snappy(duration: 0.22)) {
self.scrollPosition = self.scrollerBottomID
}
}
.onChange(of: self.viewModel.pendingRunCount) { _, _ in
guard self.hasPerformedInitialScroll else { return }
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
withAnimation(.snappy(duration: 0.22)) {
self.scrollPosition = self.scrollerBottomID
}
}
.onChange(of: self.viewModel.streamingAssistantText) { _, _ in
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
withAnimation(.snappy(duration: 0.22)) {
self.scrollPosition = self.scrollerBottomID
}

View File

@@ -94,6 +94,13 @@ public struct GatewayConnectOptions: Sendable {
}
}
public enum GatewayAuthSource: String, Sendable {
case deviceToken = "device-token"
case sharedToken = "shared-token"
case password = "password"
case none = "none"
}
// Avoid ambiguity with the app's own AnyCodable type.
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
@@ -117,6 +124,7 @@ public actor GatewayChannelActor {
private var lastSeq: Int?
private var lastTick: Date?
private var tickIntervalMs: Double = 30000
private var lastAuthSource: GatewayAuthSource = .none
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private let connectTimeoutSeconds: Double = 6
@@ -149,6 +157,8 @@ public actor GatewayChannelActor {
}
}
public func authSource() -> GatewayAuthSource { self.lastAuthSource }
public func shutdown() async {
self.shouldReconnect = false
self.connected = false
@@ -300,6 +310,18 @@ public actor GatewayChannelActor {
let identity = DeviceIdentityStore.loadOrCreate()
let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token
let authToken = storedToken ?? self.token
let authSource: GatewayAuthSource
if storedToken != nil {
authSource = .deviceToken
} else if authToken != nil {
authSource = .sharedToken
} else if self.password != nil {
authSource = .password
} else {
authSource = .none
}
self.lastAuthSource = authSource
self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)")
let canFallbackToShared = storedToken != nil && self.token != nil
if let authToken {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
@@ -571,7 +593,14 @@ public actor GatewayChannelActor {
id: id,
method: method,
params: paramsObject)
let data = try self.encoder.encode(frame)
let data: Data
do {
data = try self.encoder.encode(frame)
} catch {
self.logger.error(
"gateway request encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
throw error
}
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
self.pending[id] = cont
Task { [weak self] in

View File

@@ -23,6 +23,35 @@ public actor GatewayNodeSession {
private var onConnected: (@Sendable () async -> Void)?
private var onDisconnected: (@Sendable (String) async -> Void)?
private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)?
static func invokeWithTimeout(
request: BridgeInvokeRequest,
timeoutMs: Int?,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
) async -> BridgeInvokeResponse {
let timeout = max(0, timeoutMs ?? 0)
guard timeout > 0 else {
return await onInvoke(request)
}
return await withTaskGroup(of: BridgeInvokeResponse.self) { group in
group.addTask { await onInvoke(request) }
group.addTask {
try? await Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000)
return BridgeInvokeResponse(
id: request.id,
ok: false,
error: ClawdbotNodeError(
code: .unavailable,
message: "node invoke timed out")
)
}
let first = await group.next()!
group.cancelAll()
return first
}
}
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
private var canvasHostUrl: String?
@@ -167,7 +196,11 @@ public actor GatewayNodeSession {
let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
guard let onInvoke else { return }
let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON)
let response = await onInvoke(req)
let response = await Self.invokeWithTimeout(
request: req,
timeoutMs: request.timeoutMs,
onInvoke: onInvoke
)
await self.sendInvokeResult(request: request, response: response)
} catch {
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
@@ -186,8 +219,8 @@ public actor GatewayNodeSession {
}
if let error = response.error {
params["error"] = AnyCodable([
"code": AnyCodable(error.code.rawValue),
"message": AnyCodable(error.message),
"code": error.code.rawValue,
"message": error.message,
])
}
do {

View File

@@ -30,6 +30,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
public var agentId: String?
public var sessionKey: String?
public var approved: Bool?
public var approvalDecision: String?
public init(
command: [String],
@@ -40,7 +41,8 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
needsScreenRecording: Bool? = nil,
agentId: String? = nil,
sessionKey: String? = nil,
approved: Bool? = nil)
approved: Bool? = nil,
approvalDecision: String? = nil)
{
self.command = command
self.rawCommand = rawCommand
@@ -51,6 +53,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
self.agentId = agentId
self.sessionKey = sessionKey
self.approved = approved
self.approvalDecision = approvalDecision
}
}

View File

@@ -18,6 +18,7 @@ public struct ConnectParams: Codable, Sendable {
public let caps: [String]?
public let commands: [String]?
public let permissions: [String: AnyCodable]?
public let pathenv: String?
public let role: String?
public let scopes: [String]?
public let device: [String: AnyCodable]?
@@ -32,6 +33,7 @@ public struct ConnectParams: Codable, Sendable {
caps: [String]?,
commands: [String]?,
permissions: [String: AnyCodable]?,
pathenv: String?,
role: String?,
scopes: [String]?,
device: [String: AnyCodable]?,
@@ -45,6 +47,7 @@ public struct ConnectParams: Codable, Sendable {
self.caps = caps
self.commands = commands
self.permissions = permissions
self.pathenv = pathenv
self.role = role
self.scopes = scopes
self.device = device
@@ -59,6 +62,7 @@ public struct ConnectParams: Codable, Sendable {
case caps
case commands
case permissions
case pathenv = "pathEnv"
case role
case scopes
case device
@@ -381,6 +385,7 @@ public struct SendParams: Codable, Sendable {
public let to: String
public let message: String
public let mediaurl: String?
public let mediaurls: [String]?
public let gifplayback: Bool?
public let channel: String?
public let accountid: String?
@@ -391,6 +396,7 @@ public struct SendParams: Codable, Sendable {
to: String,
message: String,
mediaurl: String?,
mediaurls: [String]?,
gifplayback: Bool?,
channel: String?,
accountid: String?,
@@ -400,6 +406,7 @@ public struct SendParams: Codable, Sendable {
self.to = to
self.message = message
self.mediaurl = mediaurl
self.mediaurls = mediaurls
self.gifplayback = gifplayback
self.channel = channel
self.accountid = accountid
@@ -410,6 +417,7 @@ public struct SendParams: Codable, Sendable {
case to
case message
case mediaurl = "mediaUrl"
case mediaurls = "mediaUrls"
case gifplayback = "gifPlayback"
case channel
case accountid = "accountId"
@@ -474,6 +482,9 @@ public struct AgentParams: Codable, Sendable {
public let accountid: String?
public let replyaccountid: String?
public let threadid: String?
public let groupid: String?
public let groupchannel: String?
public let groupspace: String?
public let timeout: Int?
public let lane: String?
public let extrasystemprompt: String?
@@ -496,6 +507,9 @@ public struct AgentParams: Codable, Sendable {
accountid: String?,
replyaccountid: String?,
threadid: String?,
groupid: String?,
groupchannel: String?,
groupspace: String?,
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
@@ -517,6 +531,9 @@ public struct AgentParams: Codable, Sendable {
self.accountid = accountid
self.replyaccountid = replyaccountid
self.threadid = threadid
self.groupid = groupid
self.groupchannel = groupchannel
self.groupspace = groupspace
self.timeout = timeout
self.lane = lane
self.extrasystemprompt = extrasystemprompt
@@ -539,6 +556,9 @@ public struct AgentParams: Codable, Sendable {
case accountid = "accountId"
case replyaccountid = "replyAccountId"
case threadid = "threadId"
case groupid = "groupId"
case groupchannel = "groupChannel"
case groupspace = "groupSpace"
case timeout
case lane
case extrasystemprompt = "extraSystemPrompt"
@@ -548,6 +568,44 @@ public struct AgentParams: Codable, Sendable {
}
}
public struct AgentIdentityParams: Codable, Sendable {
public let agentid: String?
public let sessionkey: String?
public init(
agentid: String?,
sessionkey: String?
) {
self.agentid = agentid
self.sessionkey = sessionkey
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case sessionkey = "sessionKey"
}
}
public struct AgentIdentityResult: Codable, Sendable {
public let agentid: String
public let name: String?
public let avatar: String?
public init(
agentid: String,
name: String?,
avatar: String?
) {
self.agentid = agentid
self.name = name
self.avatar = avatar
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case name
case avatar
}
}
public struct AgentWaitParams: Codable, Sendable {
public let runid: String
public let timeoutms: Int?
@@ -883,8 +941,30 @@ public struct SessionsListParams: Codable, Sendable {
}
}
public struct SessionsPreviewParams: Codable, Sendable {
public let keys: [String]
public let limit: Int?
public let maxchars: Int?
public init(
keys: [String],
limit: Int?,
maxchars: Int?
) {
self.keys = keys
self.limit = limit
self.maxchars = maxchars
}
private enum CodingKeys: String, CodingKey {
case keys
case limit
case maxchars = "maxChars"
}
}
public struct SessionsResolveParams: Codable, Sendable {
public let key: String?
public let sessionid: String?
public let label: String?
public let agentid: String?
public let spawnedby: String?
@@ -893,6 +973,7 @@ public struct SessionsResolveParams: Codable, Sendable {
public init(
key: String?,
sessionid: String?,
label: String?,
agentid: String?,
spawnedby: String?,
@@ -900,6 +981,7 @@ public struct SessionsResolveParams: Codable, Sendable {
includeunknown: Bool?
) {
self.key = key
self.sessionid = sessionid
self.label = label
self.agentid = agentid
self.spawnedby = spawnedby
@@ -908,6 +990,7 @@ public struct SessionsResolveParams: Codable, Sendable {
}
private enum CodingKeys: String, CodingKey {
case key
case sessionid = "sessionId"
case label
case agentid = "agentId"
case spawnedby = "spawnedBy"
@@ -1084,17 +1167,29 @@ public struct ConfigApplyParams: Codable, Sendable {
public struct ConfigPatchParams: Codable, Sendable {
public let raw: String
public let basehash: String?
public let sessionkey: String?
public let note: String?
public let restartdelayms: Int?
public init(
raw: String,
basehash: String?
basehash: String?,
sessionkey: String?,
note: String?,
restartdelayms: Int?
) {
self.raw = raw
self.basehash = basehash
self.sessionkey = sessionkey
self.note = note
self.restartdelayms = restartdelayms
}
private enum CodingKeys: String, CodingKey {
case raw
case basehash = "baseHash"
case sessionkey = "sessionKey"
case note
case restartdelayms = "restartDelayMs"
}
}
@@ -1443,17 +1538,21 @@ public struct WebLoginWaitParams: Codable, Sendable {
public struct AgentSummary: Codable, Sendable {
public let id: String
public let name: String?
public let identity: [String: AnyCodable]?
public init(
id: String,
name: String?
name: String?,
identity: [String: AnyCodable]?
) {
self.id = id
self.name = name
self.identity = identity
}
private enum CodingKeys: String, CodingKey {
case id
case name
case identity
}
}
@@ -1904,27 +2003,30 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
}
public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String?
public let command: String
public let cwd: String?
public let host: String?
public let security: String?
public let ask: String?
public let agentid: String?
public let resolvedpath: String?
public let sessionkey: String?
public let cwd: AnyCodable?
public let host: AnyCodable?
public let security: AnyCodable?
public let ask: AnyCodable?
public let agentid: AnyCodable?
public let resolvedpath: AnyCodable?
public let sessionkey: AnyCodable?
public let timeoutms: Int?
public init(
id: String?,
command: String,
cwd: String?,
host: String?,
security: String?,
ask: String?,
agentid: String?,
resolvedpath: String?,
sessionkey: String?,
cwd: AnyCodable?,
host: AnyCodable?,
security: AnyCodable?,
ask: AnyCodable?,
agentid: AnyCodable?,
resolvedpath: AnyCodable?,
sessionkey: AnyCodable?,
timeoutms: Int?
) {
self.id = id
self.command = command
self.cwd = cwd
self.host = host
@@ -1936,6 +2038,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
self.timeoutms = timeoutms
}
private enum CodingKeys: String, CodingKey {
case id
case command
case cwd
case host

View File

@@ -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)
}
}

File diff suppressed because one or more lines are too long

3059
dist/control-ui/assets/index-DsXRcnEw.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3047
dist/control-ui/assets/index-bYQnHP3a.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

15
dist/control-ui/index.html vendored Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Clawdbot Control</title>
<meta name="color-scheme" content="dark light" />
<link rel="icon" href="./favicon.ico" sizes="any" />
<script type="module" crossorigin src="./assets/index-DsXRcnEw.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BvhR9FCb.css">
</head>
<body>
<clawdbot-app></clawdbot-app>
</body>
</html>

View File

@@ -20,7 +20,7 @@ services:
[
"node",
"dist/index.js",
"gateway-daemon",
"gateway",
"--bind",
"${CLAWDBOT_GATEWAY_BIND:-lan}",
"--port",

View File

@@ -3,9 +3,12 @@ summary: "Cron jobs + wakeups for the Gateway scheduler"
read_when:
- Scheduling background jobs or wakeups
- Wiring automation that should run with or alongside heartbeats
- Deciding between heartbeat and cron for scheduled tasks
---
# Cron jobs (Gateway scheduler)
> **Cron vs Heartbeat?** See [Cron vs Heartbeat](/automation/cron-vs-heartbeat) for guidance on when to use each.
Cron is the Gateways built-in scheduler. It persists jobs, wakes the agent at
the right time, and can optionally deliver output back to a chat.
@@ -121,7 +124,7 @@ Resolution priority:
### Delivery (channel + target)
Isolated jobs can deliver output to a channel. The job payload can specify:
- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `signal` / `imessage` / `last`
- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last`
- `to`: channel-specific recipient target
If `channel` or `to` is omitted, cron can fall back to the main sessions “last route”
@@ -133,7 +136,7 @@ Delivery notes:
- Use `deliver: false` to keep output internal even if a `to` is present.
Target format reminders:
- Slack/Discord targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
- Slack/Discord/Mattermost (plugin) targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
- Telegram topics should use the `:topic:` form (see below).
#### Telegram delivery targets (topics / forum threads)
@@ -260,15 +263,15 @@ Run history:
clawdbot cron runs --id <jobId> --limit 50
```
Immediate wake without creating a job:
Immediate system event without creating a job:
```bash
clawdbot wake --mode now --text "Next heartbeat: check battery."
clawdbot system event --mode now --text "Next heartbeat: check battery."
```
## Gateway API surface
- `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`
- `cron.run` (force or due), `cron.runs`
- `wake` (enqueue system event + optional heartbeat)
For immediate system events without a job, use [`clawdbot system event`](/cli/system).
## Troubleshooting

View File

@@ -0,0 +1,274 @@
---
summary: "Guidance for choosing between heartbeat and cron jobs for automation"
read_when:
- Deciding how to schedule recurring tasks
- Setting up background monitoring or notifications
- Optimizing token usage for periodic checks
---
# Cron vs Heartbeat: When to Use Each
Both heartbeats and cron jobs let you run tasks on a schedule. This guide helps you choose the right mechanism for your use case.
## Quick Decision Guide
| Use Case | Recommended | Why |
|----------|-------------|-----|
| Check inbox every 30 min | Heartbeat | Batches with other checks, context-aware |
| Send daily report at 9am sharp | Cron (isolated) | Exact timing needed |
| Monitor calendar for upcoming events | Heartbeat | Natural fit for periodic awareness |
| Run weekly deep analysis | Cron (isolated) | Standalone task, can use different model |
| Remind me in 20 minutes | Cron (main, `--at`) | One-shot with precise timing |
| Background project health check | Heartbeat | Piggybacks on existing cycle |
## Heartbeat: Periodic Awareness
Heartbeats run in the **main session** at a regular interval (default: 30 min). They're designed for the agent to check on things and surface anything important.
### When to use heartbeat
- **Multiple periodic checks**: Instead of 5 separate cron jobs checking inbox, calendar, weather, notifications, and project status, a single heartbeat can batch all of these.
- **Context-aware decisions**: The agent has full main-session context, so it can make smart decisions about what's urgent vs. what can wait.
- **Conversational continuity**: Heartbeat runs share the same session, so the agent remembers recent conversations and can follow up naturally.
- **Low-overhead monitoring**: One heartbeat replaces many small polling tasks.
### Heartbeat advantages
- **Batches multiple checks**: One agent turn can review inbox, calendar, and notifications together.
- **Reduces API calls**: A single heartbeat is cheaper than 5 isolated cron jobs.
- **Context-aware**: The agent knows what you've been working on and can prioritize accordingly.
- **Smart suppression**: If nothing needs attention, the agent replies `HEARTBEAT_OK` and no message is delivered.
- **Natural timing**: Drifts slightly based on queue load, which is fine for most monitoring.
### Heartbeat example: HEARTBEAT.md checklist
```md
# Heartbeat checklist
- Check email for urgent messages
- Review calendar for events in next 2 hours
- If a background task finished, summarize results
- If idle for 8+ hours, send a brief check-in
```
The agent reads this on each heartbeat and handles all items in one turn.
### Configuring heartbeat
```json5
{
agents: {
defaults: {
heartbeat: {
every: "30m", // interval
target: "last", // where to deliver alerts
activeHours: { start: "08:00", end: "22:00" } // optional
}
}
}
}
```
See [Heartbeat](/gateway/heartbeat) for full configuration.
## Cron: Precise Scheduling
Cron jobs run at **exact times** and can run in isolated sessions without affecting main context.
### When to use cron
- **Exact timing required**: "Send this at 9:00 AM every Monday" (not "sometime around 9").
- **Standalone tasks**: Tasks that don't need conversational context.
- **Different model/thinking**: Heavy analysis that warrants a more powerful model.
- **One-shot reminders**: "Remind me in 20 minutes" with `--at`.
- **Noisy/frequent tasks**: Tasks that would clutter main session history.
- **External triggers**: Tasks that should run independently of whether the agent is otherwise active.
### Cron advantages
- **Exact timing**: 5-field cron expressions with timezone support.
- **Session isolation**: Runs in `cron:<jobId>` without polluting main history.
- **Model overrides**: Use a cheaper or more powerful model per job.
- **Delivery control**: Can deliver directly to a channel; still posts a summary to main by default (configurable).
- **No agent context needed**: Runs even if main session is idle or compacted.
- **One-shot support**: `--at` for precise future timestamps.
### Cron example: Daily morning briefing
```bash
clawdbot cron add \
--name "Morning briefing" \
--cron "0 7 * * *" \
--tz "America/New_York" \
--session isolated \
--message "Generate today's briefing: weather, calendar, top emails, news summary." \
--model opus \
--deliver \
--channel whatsapp \
--to "+15551234567"
```
This runs at exactly 7:00 AM New York time, uses Opus for quality, and delivers directly to WhatsApp.
### Cron example: One-shot reminder
```bash
clawdbot cron add \
--name "Meeting reminder" \
--at "20m" \
--session main \
--system-event "Reminder: standup meeting starts in 10 minutes." \
--wake now \
--delete-after-run
```
See [Cron jobs](/automation/cron-jobs) for full CLI reference.
## Decision Flowchart
```
Does the task need to run at an EXACT time?
YES -> Use cron
NO -> Continue...
Does the task need isolation from main session?
YES -> Use cron (isolated)
NO -> Continue...
Can this task be batched with other periodic checks?
YES -> Use heartbeat (add to HEARTBEAT.md)
NO -> Use cron
Is this a one-shot reminder?
YES -> Use cron with --at
NO -> Continue...
Does it need a different model or thinking level?
YES -> Use cron (isolated) with --model/--thinking
NO -> Use heartbeat
```
## Combining Both
The most efficient setup uses **both**:
1. **Heartbeat** handles routine monitoring (inbox, calendar, notifications) in one batched turn every 30 minutes.
2. **Cron** handles precise schedules (daily reports, weekly reviews) and one-shot reminders.
### Example: Efficient automation setup
**HEARTBEAT.md** (checked every 30 min):
```md
# Heartbeat checklist
- Scan inbox for urgent emails
- Check calendar for events in next 2h
- Review any pending tasks
- Light check-in if quiet for 8+ hours
```
**Cron jobs** (precise timing):
```bash
# Daily morning briefing at 7am
clawdbot cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --deliver
# Weekly project review on Mondays at 9am
clawdbot cron add --name "Weekly review" --cron "0 9 * * 1" --session isolated --message "..." --model opus
# One-shot reminder
clawdbot cron add --name "Call back" --at "2h" --session main --system-event "Call back the client" --wake now
```
## Lobster: Deterministic workflows with approvals
Lobster is the workflow runtime for **multi-step tool pipelines** that need deterministic execution and explicit approvals.
Use it when the task is more than a single agent turn, and you want a resumable workflow with human checkpoints.
### When Lobster fits
- **Multi-step automation**: You need a fixed pipeline of tool calls, not a one-off prompt.
- **Approval gates**: Side effects should pause until you approve, then resume.
- **Resumable runs**: Continue a paused workflow without re-running earlier steps.
### How it pairs with heartbeat and cron
- **Heartbeat/cron** decide *when* a run happens.
- **Lobster** defines *what steps* happen once the run starts.
For scheduled workflows, use cron or heartbeat to trigger an agent turn that calls Lobster.
For ad-hoc workflows, call Lobster directly.
### Operational notes (from the code)
- Lobster runs as a **local subprocess** (`lobster` CLI) in tool mode and returns a **JSON envelope**.
- If the tool returns `needs_approval`, you resume with a `resumeToken` and `approve` flag.
- The tool is an **optional plugin**; you must allowlist `lobster` in `tools.allow`.
- If you pass `lobsterPath`, it must be an **absolute path**.
See [Lobster](/tools/lobster) for full usage and examples.
## Main Session vs Isolated Session
Both heartbeat and cron can interact with the main session, but differently:
| | Heartbeat | Cron (main) | Cron (isolated) |
|---|---|---|---|
| Session | Main | Main (via system event) | `cron:<jobId>` |
| History | Shared | Shared | Fresh each run |
| Context | Full | Full | None (starts clean) |
| Model | Main session model | Main session model | Can override |
| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Summary posted to main |
### When to use main session cron
Use `--session main` with `--system-event` when you want:
- The reminder/event to appear in main session context
- The agent to handle it during the next heartbeat with full context
- No separate isolated run
```bash
clawdbot cron add \
--name "Check project" \
--every "4h" \
--session main \
--system-event "Time for a project health check" \
--wake now
```
### When to use isolated cron
Use `--session isolated` when you want:
- A clean slate without prior context
- Different model or thinking settings
- Output delivered directly to a channel (summary still posts to main by default)
- History that doesn't clutter main session
```bash
clawdbot cron add \
--name "Deep analysis" \
--cron "0 6 * * 0" \
--session isolated \
--message "Weekly codebase analysis..." \
--model opus \
--thinking high \
--deliver
```
## Cost Considerations
| Mechanism | Cost Profile |
|-----------|--------------|
| Heartbeat | One turn every N minutes; scales with HEARTBEAT.md size |
| Cron (main) | Adds event to next heartbeat (no isolated turn) |
| Cron (isolated) | Full agent turn per job; can use cheaper model |
**Tips**:
- Keep `HEARTBEAT.md` small to minimize token overhead.
- Batch similar checks into heartbeat instead of multiple cron jobs.
- Use `target: "none"` on heartbeat if you only want internal processing.
- Use isolated cron with a cheaper model for routine tasks.
## Related
- [Heartbeat](/gateway/heartbeat) - full heartbeat configuration
- [Cron jobs](/automation/cron-jobs) - full cron CLI and API reference
- [System](/cli/system) - system events + heartbeat controls

View File

@@ -71,8 +71,8 @@ Payload:
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context.
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `msteams`. Defaults to `last`.
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack, conversation ID for MS Teams). Defaults to the last recipient in the main session.
- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `mattermost` (plugin), `signal`, `imessage`, `msteams`. Defaults to `last`.
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost (plugin), conversation ID for MS Teams). Defaults to the last recipient in the main session.
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
- `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds.

View File

@@ -17,6 +17,37 @@ not an API key.
- Auth: AWS credentials (env vars, shared config, or instance role)
- Region: `AWS_REGION` or `AWS_DEFAULT_REGION` (default: `us-east-1`)
## Automatic model discovery
If AWS credentials are detected, Clawdbot can automatically discover Bedrock
models that support **streaming** and **text output**. Discovery uses
`bedrock:ListFoundationModels` and is cached (default: 1 hour).
Config options live under `models.bedrockDiscovery`:
```json5
{
models: {
bedrockDiscovery: {
enabled: true,
region: "us-east-1",
providerFilter: ["anthropic", "amazon"],
refreshInterval: 3600,
defaultContextWindow: 32000,
defaultMaxTokens: 4096
}
}
}
```
Notes:
- `enabled` defaults to `true` when AWS credentials are present.
- `region` defaults to `AWS_REGION` or `AWS_DEFAULT_REGION`, then `us-east-1`.
- `providerFilter` matches Bedrock provider names (for example `anthropic`).
- `refreshInterval` is seconds; set to `0` to disable caching.
- `defaultContextWindow` (default: `32000`) and `defaultMaxTokens` (default: `4096`)
are used for discovered models (override if you know your model limits).
## Setup (manual)
1) Ensure AWS credentials are available on the **gateway host**:
@@ -44,10 +75,10 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
auth: "aws-sdk",
models: [
{
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
name: "Claude 3.7 Sonnet (Bedrock)",
id: "anthropic.claude-opus-4-5-20251101-v1:0",
name: "Claude Opus 4.5 (Bedrock)",
reasoning: true,
input: ["text"],
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192
@@ -58,15 +89,79 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
},
agents: {
defaults: {
model: { primary: "amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0" }
model: { primary: "amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0" }
}
}
}
```
## EC2 Instance Roles
When running Clawdbot on an EC2 instance with an IAM role attached, the AWS SDK
will automatically use the instance metadata service (IMDS) for authentication.
However, Clawdbot's credential detection currently only checks for environment
variables, not IMDS credentials.
**Workaround:** Set `AWS_PROFILE=default` to signal that AWS credentials are
available. The actual authentication still uses the instance role via IMDS.
```bash
# Add to ~/.bashrc or your shell profile
export AWS_PROFILE=default
export AWS_REGION=us-east-1
```
**Required IAM permissions** for the EC2 instance role:
- `bedrock:InvokeModel`
- `bedrock:InvokeModelWithResponseStream`
- `bedrock:ListFoundationModels` (for automatic discovery)
Or attach the managed policy `AmazonBedrockFullAccess`.
**Quick setup:**
```bash
# 1. Create IAM role and instance profile
aws iam create-role --role-name EC2-Bedrock-Access \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "ec2.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}'
aws iam attach-role-policy --role-name EC2-Bedrock-Access \
--policy-arn arn:aws:iam::aws:policy/AmazonBedrockFullAccess
aws iam create-instance-profile --instance-profile-name EC2-Bedrock-Access
aws iam add-role-to-instance-profile \
--instance-profile-name EC2-Bedrock-Access \
--role-name EC2-Bedrock-Access
# 2. Attach to your EC2 instance
aws ec2 associate-iam-instance-profile \
--instance-id i-xxxxx \
--iam-instance-profile Name=EC2-Bedrock-Access
# 3. On the EC2 instance, enable discovery
clawdbot config set models.bedrockDiscovery.enabled true
clawdbot config set models.bedrockDiscovery.region us-east-1
# 4. Set the workaround env vars
echo 'export AWS_PROFILE=default' >> ~/.bashrc
echo 'export AWS_REGION=us-east-1' >> ~/.bashrc
source ~/.bashrc
# 5. Verify models are discovered
clawdbot models list
```
## Notes
- Bedrock requires **model access** enabled in your AWS account/region.
- Automatic discovery needs the `bedrock:ListFoundationModels` permission.
- If you use profiles, set `AWS_PROFILE` on the gateway host.
- Clawdbot surfaces the credential source in this order: `AWS_BEARER_TOKEN_BEDROCK`,
then `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`, then `AWS_PROFILE`, then the

View File

@@ -147,7 +147,21 @@ Available actions:
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
- **leaveGroup**: Leave a group chat (`chatGuid`)
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`)
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`, `asVoice`)
- Voice memos: set `asVoice: true` with **MP3** or **CAF** audio to send as an iMessage voice message. BlueBubbles converts MP3 → CAF when sending voice memos.
### Message IDs (short vs full)
Clawdbot may surface *short* message IDs (e.g., `1`, `2`) to save tokens.
- `MessageSid` / `ReplyToId` can be short IDs.
- `MessageSidFull` / `ReplyToIdFull` contain the provider full IDs.
- Short IDs are in-memory; they can expire on restart or cache eviction.
- Actions accept short or full `messageId`, but short IDs will error if no longer available.
Use full IDs for durable automations and storage:
- Templates: `{{MessageSidFull}}`, `{{ReplyToIdFull}}`
- Context: `MessageSidFull` / `ReplyToIdFull` in inbound payloads
See [Configuration](/gateway/configuration) for template variables.
## Block streaming
Control whether responses are sent as a single message or streamed in blocks:
@@ -182,6 +196,7 @@ Provider options:
- `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`).
- `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `true`).
- `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000).
- `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on every newline and sends each line immediately during streaming.
- `channels.bluebubbles.mediaMaxMb`: Inbound media cap in MB (default: 8).
- `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables).
- `channels.bluebubbles.dmHistoryLimit`: DM history limit.

View File

@@ -175,6 +175,7 @@ Notes:
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages.
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
- If `channels` is present, any channel not listed is denied by default.
- Use a `"*"` channel entry to apply defaults across all channels; explicit channel entries override the wildcard.
- Threads inherit parent channel config (allowlist, `requireMention`, skills, prompts, etc.) unless you add the thread channel id explicitly.
- Bot-authored messages are ignored by default; set `channels.discord.allowBots=true` to allow them (own messages remain filtered).
- Warning: If you allow replies to other bots (`channels.discord.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.discord.guilds.*.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
@@ -204,6 +205,7 @@ Notes:
## Capabilities & limits
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
- Typing indicators sent best-effort; message chunking uses `channels.discord.textChunkLimit` (default 2000) and splits tall replies by line count (`channels.discord.maxLinesPerMessage`, default 17).
- Optional newline chunking: set `channels.discord.chunkMode="newline"` to split on each line before length chunking.
- File uploads supported up to the configured `channels.discord.mediaMaxMb` (default 8 MB).
- Mention-gated guild replies by default to avoid noisy bots.
- Reply context is injected when a message references another message (quoted content + ids).
@@ -305,6 +307,7 @@ ack reaction after the bot replies.
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
- `textChunkLimit`: outbound text chunk size (chars). Default: 2000.
- `chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on every newline before length chunking.
- `maxLinesPerMessage`: soft max line count per message. Default: 17.
- `mediaMaxMb`: clamp inbound media saved to disk.
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables).

220
docs/channels/googlechat.md Normal file
View File

@@ -0,0 +1,220 @@
---
summary: "Google Chat app support status, capabilities, and configuration"
read_when:
- Working on Google Chat channel features
---
# Google Chat (Chat API)
Status: ready for DMs + spaces via Google Chat API webhooks (HTTP only).
## Quick setup (beginner)
1) Create a Google Cloud project and enable the **Google Chat API**.
- Go to: [Google Chat API Credentials](https://console.cloud.google.com/apis/api/chat.googleapis.com/credentials)
- Enable the API if it is not already enabled.
2) Create a **Service Account**:
- Press **Create Credentials** > **Service Account**.
- Name it whatever you want (e.g., `clawdbot-chat`).
- Leave permissions blank (press **Continue**).
- Leave principals with access blank (press **Done**).
3) Create and download the **JSON Key**:
- In the list of service accounts, click on the one you just created.
- Go to the **Keys** tab.
- Click **Add Key** > **Create new key**.
- Select **JSON** and press **Create**.
4) Store the downloaded JSON file on your gateway host (e.g., `~/.clawdbot/googlechat-service-account.json`).
5) Create a Google Chat app in the [Google Cloud Console Chat Configuration](https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat):
- Fill in the **Application info**:
- **App name**: (e.g. `Clawdbot`)
- **Avatar URL**: (e.g. `https://clawd.bot/logo.png`)
- **Description**: (e.g. `Personal AI Assistant`)
- Enable **Interactive features**.
- Under **Functionality**, check **Join spaces and group conversations**.
- Under **Connection settings**, select **HTTP endpoint URL**.
- Under **Triggers**, select **Use a common HTTP endpoint URL for all triggers** and set it to your gateway's public URL followed by `/googlechat`.
- *Tip: Run `clawdbot status` to find your gateway's public URL.*
- Under **Visibility**, check **Make this Chat app available to specific people and groups in &lt;Your Domain&gt;**.
- Enter your email address (e.g. `user@example.com`) in the text box.
- Click **Save** at the bottom.
6) **Enable the app status**:
- After saving, **refresh the page**.
- Look for the **App status** section (usually near the top or bottom after saving).
- Change the status to **Live - available to users**.
- Click **Save** again.
7) Configure Clawdbot with the service account path + webhook audience:
- Env: `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE=/path/to/service-account.json`
- Or config: `channels.googlechat.serviceAccountFile: "/path/to/service-account.json"`.
8) Set the webhook audience type + value (matches your Chat app config).
9) Start the gateway. Google Chat will POST to your webhook path.
## Add to Google Chat
Once the gateway is running and your email is added to the visibility list:
1) Go to [Google Chat](https://chat.google.com/).
2) Click the **+** (plus) icon next to **Direct Messages**.
3) In the search bar (where you usually add people), type the **App name** you configured in the Google Cloud Console.
- **Note**: The bot will *not* appear in the "Marketplace" browse list because it is a private app. You must search for it by name.
4) Select your bot from the results.
5) Click **Add** or **Chat** to start a 1:1 conversation.
6) Send "Hello" to trigger the assistant!
## Public URL (Webhook-only)
Google Chat webhooks require a public HTTPS endpoint. For security, **only expose the `/googlechat` path** to the internet. Keep the Clawdbot dashboard and other sensitive endpoints on your private network.
### Option A: Tailscale Funnel (Recommended)
Use Tailscale Serve for the private dashboard and Funnel for the public webhook path. This keeps `/` private while exposing only `/googlechat`.
1. **Check what address your gateway is bound to:**
```bash
ss -tlnp | grep 18789
```
Note the IP address (e.g., `127.0.0.1`, `0.0.0.0`, or your Tailscale IP like `100.x.x.x`).
2. **Expose the dashboard to the tailnet only (port 8443):**
```bash
# If bound to localhost (127.0.0.1 or 0.0.0.0):
tailscale serve --bg --https 8443 http://127.0.0.1:18789
# If bound to Tailscale IP only (e.g., 100.106.161.80):
tailscale serve --bg --https 8443 http://100.106.161.80:18789
```
3. **Expose only the webhook path publicly:**
```bash
# If bound to localhost (127.0.0.1 or 0.0.0.0):
tailscale funnel --bg --set-path /googlechat http://127.0.0.1:18789/googlechat
# If bound to Tailscale IP only (e.g., 100.106.161.80):
tailscale funnel --bg --set-path /googlechat http://100.106.161.80:18789/googlechat
```
4. **Authorize the node for Funnel access:**
If prompted, visit the authorization URL shown in the output to enable Funnel for this node in your tailnet policy.
5. **Verify the configuration:**
```bash
tailscale serve status
tailscale funnel status
```
Your public webhook URL will be:
`https://<node-name>.<tailnet>.ts.net/googlechat`
Your private dashboard stays tailnet-only:
`https://<node-name>.<tailnet>.ts.net:8443/`
Use the public URL (without `:8443`) in the Google Chat app config.
> Note: This configuration persists across reboots. To remove it later, run `tailscale funnel reset` and `tailscale serve reset`.
### Option B: Reverse Proxy (Caddy)
If you use a reverse proxy like Caddy, only proxy the specific path:
```caddy
your-domain.com {
reverse_proxy /googlechat* localhost:18789
}
```
With this config, any request to `your-domain.com/` will be ignored or returned as 404, while `your-domain.com/googlechat` is safely routed to Clawdbot.
### Option C: Cloudflare Tunnel
Configure your tunnel's ingress rules to only route the webhook path:
- **Path**: `/googlechat` -> `http://localhost:18789/googlechat`
- **Default Rule**: HTTP 404 (Not Found)
## How it works
1. Google Chat sends webhook POSTs to the gateway. Each request includes an `Authorization: Bearer <token>` header.
2. Clawdbot verifies the token against the configured `audienceType` + `audience`:
- `audienceType: "app-url"` → audience is your HTTPS webhook URL.
- `audienceType: "project-number"` → audience is the Cloud project number.
3. Messages are routed by space:
- DMs use session key `agent:<agentId>:googlechat:dm:<spaceId>`.
- Spaces use session key `agent:<agentId>:googlechat:group:<spaceId>`.
4. DM access is pairing by default. Unknown senders receive a pairing code; approve with:
- `clawdbot pairing approve googlechat <code>`
5. Group spaces require @-mention by default. Use `botUser` if mention detection needs the apps user name.
## Targets
Use these identifiers for delivery and allowlists:
- Direct messages: `users/<userId>` or `users/<email>` (email addresses are accepted).
- Spaces: `spaces/<spaceId>`.
## Config highlights
```json5
{
channels: {
"googlechat": {
enabled: true,
serviceAccountFile: "/path/to/service-account.json",
audienceType: "app-url",
audience: "https://gateway.example.com/googlechat",
webhookPath: "/googlechat",
botUser: "users/1234567890", // optional; helps mention detection
dm: {
policy: "pairing",
allowFrom: ["users/1234567890", "name@example.com"]
},
groupPolicy: "allowlist",
groups: {
"spaces/AAAA": {
allow: true,
requireMention: true,
users: ["users/1234567890"],
systemPrompt: "Short answers only."
}
},
actions: { reactions: true },
typingIndicator: "message",
mediaMaxMb: 20
}
}
}
```
Notes:
- Service account credentials can also be passed inline with `serviceAccount` (JSON string).
- Default webhook path is `/googlechat` if `webhookPath` isnt set.
- Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled.
- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth).
- Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`).
## Troubleshooting
### 405 Method Not Allowed
If Google Cloud Logs Explorer shows errors like:
```
status code: 405, reason phrase: HTTP error response: HTTP/1.1 405 Method Not Allowed
```
This means the webhook handler isn't registered. Common causes:
1. **Channel not configured**: The `channels.googlechat` section is missing from your config. Verify with:
```bash
clawdbot config get channels.googlechat
```
If it returns "Config path not found", add the configuration (see [Config highlights](#config-highlights)).
2. **Plugin not enabled**: Check plugin status:
```bash
clawdbot plugins list | grep googlechat
```
If it shows "disabled", add `plugins.entries.googlechat.enabled: true` to your config.
3. **Gateway not restarted**: After adding config, restart the gateway:
```bash
clawdbot gateway restart
```
Verify the channel is running:
```bash
clawdbot channels status
# Should show: Google Chat default: enabled, configured, ...
```
### Other issues
- Check `clawdbot channels status --probe` for auth errors or missing audience config.
- If no messages arrive, confirm the Chat app's webhook URL + event subscriptions.
- If mention gating blocks replies, set `botUser` to the app's user resource name and verify `requireMention`.
- Use `clawdbot logs --follow` while sending a test message to see if requests reach the gateway.
Related docs:
- [Gateway configuration](/gateway/configuration)
- [Security](/gateway/security)
- [Reactions](/tools/reactions)

View File

@@ -17,7 +17,7 @@ read_when:
- **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammYs `client.baseFetch`.
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` is set (otherwise it long-polls).
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`.
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`.
- **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming.
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.

View File

@@ -219,6 +219,7 @@ This is useful when you want an isolated personality/model for a specific thread
## Limits
- Outbound text is chunked to `channels.imessage.textChunkLimit` (default 4000).
- Optional newline chunking: set `channels.imessage.chunkMode="newline"` to split on each line before length chunking.
- Media uploads are capped by `channels.imessage.mediaMaxMb` (default 16).
## Addressing / delivery targets
@@ -253,6 +254,7 @@ Provider options:
- `channels.imessage.includeAttachments`: ingest attachments into context.
- `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB).
- `channels.imessage.textChunkLimit`: outbound chunk size (chars).
- `channels.imessage.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
Related global options:
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`).

View File

@@ -15,6 +15,8 @@ Text is supported everywhere; media and reactions vary by channel.
- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
- [Google Chat](/channels/googlechat) — Google Chat API app via HTTP webhook.
- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs (plugin, installed separately).
- [Signal](/channels/signal) — signal-cli; privacy-focused.
- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe).
- [iMessage](/channels/imessage) — macOS only; native integration via imsg (legacy, consider BlueBubbles for new setups).
@@ -22,6 +24,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately).
- [Tlon](/channels/tlon) — Urbit-based messenger (plugin, installed separately).
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
@@ -29,6 +32,8 @@ Text is supported everywhere; media and reactions vary by channel.
## Notes
- Channels can run simultaneously; configure multiple and Clawdbot will route per chat.
- Fastest setup is usually **Telegram** (simple bot token). WhatsApp requires QR pairing and
stores more state on disk.
- Group behavior varies by channel; see [Groups](/concepts/groups).
- DM pairing and allowlists are enforced for safety; see [Security](/gateway/security).
- Telegram internals: [grammY notes](/channels/grammy).

View File

@@ -215,6 +215,7 @@ Provider options:
- `channels.matrix.initialSyncLimit`: initial sync limit.
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
- `channels.matrix.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible.
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).

123
docs/channels/mattermost.md Normal file
View File

@@ -0,0 +1,123 @@
---
summary: "Mattermost bot setup and Clawdbot config"
read_when:
- Setting up Mattermost
- Debugging Mattermost routing
---
# Mattermost (plugin)
Status: supported via plugin (bot token + WebSocket events). Channels, groups, and DMs are supported.
Mattermost is a self-hostable team messaging platform; see the official site at
[mattermost.com](https://mattermost.com) for product details and downloads.
## Plugin required
Mattermost ships as a plugin and is not bundled with the core install.
Install via CLI (npm registry):
```bash
clawdbot plugins install @clawdbot/mattermost
```
Local checkout (when running from a git repo):
```bash
clawdbot plugins install ./extensions/mattermost
```
If you choose Mattermost during configure/onboarding and a git checkout is detected,
Clawdbot will offer the local install path automatically.
Details: [Plugins](/plugin)
## Quick setup
1) Install the Mattermost plugin.
2) Create a Mattermost bot account and copy the **bot token**.
3) Copy the Mattermost **base URL** (e.g., `https://chat.example.com`).
4) Configure Clawdbot and start the gateway.
Minimal config:
```json5
{
channels: {
mattermost: {
enabled: true,
botToken: "mm-token",
baseUrl: "https://chat.example.com",
dmPolicy: "pairing"
}
}
}
```
## Environment variables (default account)
Set these on the gateway host if you prefer env vars:
- `MATTERMOST_BOT_TOKEN=...`
- `MATTERMOST_URL=https://chat.example.com`
Env vars apply only to the **default** account (`default`). Other accounts must use config values.
## Chat modes
Mattermost responds to DMs automatically. Channel behavior is controlled by `chatmode`:
- `oncall` (default): respond only when @mentioned in channels.
- `onmessage`: respond to every channel message.
- `onchar`: respond when a message starts with a trigger prefix.
Config example:
```json5
{
channels: {
mattermost: {
chatmode: "onchar",
oncharPrefixes: [">", "!"]
}
}
}
```
Notes:
- `onchar` still responds to explicit @mentions.
- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred.
## Access control (DMs)
- Default: `channels.mattermost.dmPolicy = "pairing"` (unknown senders get a pairing code).
- Approve via:
- `clawdbot pairing list mattermost`
- `clawdbot pairing approve mattermost <CODE>`
- Public DMs: `channels.mattermost.dmPolicy="open"` plus `channels.mattermost.allowFrom=["*"]`.
## Channels (groups)
- Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated).
- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs or `@username`).
- Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated).
## Targets for outbound delivery
Use these target formats with `clawdbot message send` or cron/webhooks:
- `channel:<id>` for a channel
- `user:<id>` for a DM
- `@username` for a DM (resolved via the Mattermost API)
Bare IDs are treated as channels.
## Multi-account
Mattermost supports multiple accounts under `channels.mattermost.accounts`:
```json5
{
channels: {
mattermost: {
accounts: {
default: { name: "Primary", botToken: "mm-token", baseUrl: "https://chat.example.com" },
alerts: { name: "Alerts", botToken: "mm-token-2", baseUrl: "https://alerts.example.com" }
}
}
}
}
```
## Troubleshooting
- No replies in channels: ensure the bot is in the channel and mention it (oncall), use a trigger prefix (onchar), or set `chatmode: "onmessage"`.
- Auth errors: check the bot token, base URL, and whether the account is enabled.
- Multi-account issues: env vars only apply to the `default` account.

View File

@@ -8,9 +8,9 @@ read_when:
> "Abandon all hope, ye who enter here."
Updated: 2026-01-16
Updated: 2026-01-21
Status: text + DM attachments are supported; channel/group attachments require Microsoft Graph permissions. Polls are sent via Adaptive Cards.
Status: text + DM attachments are supported; channel/group file sending requires `sharePointSiteId` + Graph permissions (see [Sending files in group chats](#sending-files-in-group-chats)). Polls are sent via Adaptive Cards.
## Plugin required
Microsoft Teams ships as a plugin and is not bundled with the core install.
@@ -403,7 +403,7 @@ Clawdbot handles this by returning quickly and sending replies proactively, but
Teams markdown is more limited than Slack or Discord:
- Basic formatting works: **bold**, *italic*, `code`, links
- Complex markdown (tables, nested lists) may not render correctly
- Adaptive Cards are used for polls; other card types are not yet supported
- Adaptive Cards are supported for polls and arbitrary card sends (see below)
## Configuration
Key settings (see `/gateway/configuration` for shared channel patterns):
@@ -415,6 +415,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available.
- `channels.msteams.textChunkLimit`: outbound text chunk size.
- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
- `channels.msteams.requireMention`: require @mention in channels/groups (default true).
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
@@ -422,6 +423,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
- `channels.msteams.teams.<teamId>.requireMention`: per-team override.
- `channels.msteams.teams.<teamId>.channels.<conversationId>.replyStyle`: per-channel override.
- `channels.msteams.teams.<teamId>.channels.<conversationId>.requireMention`: per-channel override.
- `channels.msteams.sharePointSiteId`: SharePoint site ID for file uploads in group chats/channels (see [Sending files in group chats](#sending-files-in-group-chats)).
## Routing & Sessions
- Session keys follow the standard agent format (see [/concepts/session](/concepts/session)):
@@ -471,6 +473,75 @@ Teams recently introduced two channel UI styles over the same underlying data mo
Without Graph permissions, channel messages with images will be received as text-only (the image content is not accessible to the bot).
By default, Clawdbot only downloads media from Microsoft/Teams hostnames. Override with `channels.msteams.mediaAllowHosts` (use `["*"]` to allow any host).
## Sending files in group chats
Bots can send files in DMs using the FileConsentCard flow (built-in). However, **sending files in group chats/channels** requires additional setup:
| Context | How files are sent | Setup needed |
|---------|-------------------|--------------|
| **DMs** | FileConsentCard → user accepts → bot uploads | Works out of the box |
| **Group chats/channels** | Upload to SharePoint → share link | Requires `sharePointSiteId` + Graph permissions |
| **Images (any context)** | Base64-encoded inline | Works out of the box |
### Why group chats need SharePoint
Bots don't have a personal OneDrive drive (the `/me/drive` Graph API endpoint doesn't work for application identities). To send files in group chats/channels, the bot uploads to a **SharePoint site** and creates a sharing link.
### Setup
1. **Add Graph API permissions** in Entra ID (Azure AD) → App Registration:
- `Sites.ReadWrite.All` (Application) - upload files to SharePoint
- `Chat.Read.All` (Application) - optional, enables per-user sharing links
2. **Grant admin consent** for the tenant.
3. **Get your SharePoint site ID:**
```bash
# Via Graph Explorer or curl with a valid token:
curl -H "Authorization: Bearer $TOKEN" \
"https://graph.microsoft.com/v1.0/sites/{hostname}:/{site-path}"
# Example: for a site at "contoso.sharepoint.com/sites/BotFiles"
curl -H "Authorization: Bearer $TOKEN" \
"https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/BotFiles"
# Response includes: "id": "contoso.sharepoint.com,guid1,guid2"
```
4. **Configure Clawdbot:**
```json5
{
channels: {
msteams: {
// ... other config ...
sharePointSiteId: "contoso.sharepoint.com,guid1,guid2"
}
}
}
```
### Sharing behavior
| Permission | Sharing behavior |
|------------|------------------|
| `Sites.ReadWrite.All` only | Organization-wide sharing link (anyone in org can access) |
| `Sites.ReadWrite.All` + `Chat.Read.All` | Per-user sharing link (only chat members can access) |
Per-user sharing is more secure as only the chat participants can access the file. If `Chat.Read.All` permission is missing, the bot falls back to organization-wide sharing.
### Fallback behavior
| Scenario | Result |
|----------|--------|
| Group chat + file + `sharePointSiteId` configured | Upload to SharePoint, send sharing link |
| Group chat + file + no `sharePointSiteId` | Attempt OneDrive upload (may fail), send text only |
| Personal chat + file | FileConsentCard flow (works without SharePoint) |
| Any context + image | Base64-encoded inline (works without SharePoint) |
### Files stored location
Uploaded files are stored in a `/ClawdbotShared/` folder in the configured SharePoint site's default document library.
## Polls (Adaptive Cards)
Clawdbot sends Teams polls as Adaptive Cards (there is no native Teams poll API).
@@ -479,6 +550,82 @@ Clawdbot sends Teams polls as Adaptive Cards (there is no native Teams poll API)
- The gateway must stay online to record votes.
- Polls do not auto-post result summaries yet (inspect the store file if needed).
## Adaptive Cards (arbitrary)
Send any Adaptive Card JSON to Teams users or conversations using the `message` tool or CLI.
The `card` parameter accepts an Adaptive Card JSON object. When `card` is provided, the message text is optional.
**Agent tool:**
```json
{
"action": "send",
"channel": "msteams",
"target": "user:<id>",
"card": {
"type": "AdaptiveCard",
"version": "1.5",
"body": [{"type": "TextBlock", "text": "Hello!"}]
}
}
```
**CLI:**
```bash
clawdbot message send --channel msteams \
--target "conversation:19:abc...@thread.tacv2" \
--card '{"type":"AdaptiveCard","version":"1.5","body":[{"type":"TextBlock","text":"Hello!"}]}'
```
See [Adaptive Cards documentation](https://adaptivecards.io/) for card schema and examples. For target format details, see [Target formats](#target-formats) below.
## Target formats
MSTeams targets use prefixes to distinguish between users and conversations:
| Target type | Format | Example |
|-------------|--------|---------|
| User (by ID) | `user:<aad-object-id>` | `user:40a1a0ed-4ff2-4164-a219-55518990c197` |
| User (by name) | `user:<display-name>` | `user:John Smith` (requires Graph API) |
| Group/channel | `conversation:<conversation-id>` | `conversation:19:abc123...@thread.tacv2` |
| Group/channel (raw) | `<conversation-id>` | `19:abc123...@thread.tacv2` (if contains `@thread`) |
**CLI examples:**
```bash
# Send to a user by ID
clawdbot message send --channel msteams --target "user:40a1a0ed-..." --message "Hello"
# Send to a user by display name (triggers Graph API lookup)
clawdbot message send --channel msteams --target "user:John Smith" --message "Hello"
# Send to a group chat or channel
clawdbot message send --channel msteams --target "conversation:19:abc...@thread.tacv2" --message "Hello"
# Send an Adaptive Card to a conversation
clawdbot message send --channel msteams --target "conversation:19:abc...@thread.tacv2" \
--card '{"type":"AdaptiveCard","version":"1.5","body":[{"type":"TextBlock","text":"Hello"}]}'
```
**Agent tool examples:**
```json
{
"action": "send",
"channel": "msteams",
"target": "user:John Smith",
"message": "Hello!"
}
```
```json
{
"action": "send",
"channel": "msteams",
"target": "conversation:19:abc...@thread.tacv2",
"card": {"type": "AdaptiveCard", "version": "1.5", "body": [{"type": "TextBlock", "text": "Hello"}]}
}
```
Note: Without the `user:` prefix, names default to group/team resolution. Always use `user:` when targeting people by display name.
## Proactive messaging
- Proactive messages are only possible **after** a user has interacted, because we store conversation references at that point.
- See `/gateway/configuration` for `dmPolicy` and allowlist gating.

View File

@@ -114,6 +114,7 @@ Provider options:
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit).
- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars).
- `channels.nextcloud-talk.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
- `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel.
- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning.
- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB).

View File

@@ -74,6 +74,22 @@ Example:
Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
## External daemon mode (httpUrl)
If you want to manage `signal-cli` yourself (slow JVM cold starts, container init, or shared CPUs), run the daemon separately and point Clawdbot at it:
```json5
{
channels: {
signal: {
httpUrl: "http://127.0.0.1:8080",
autoStart: false
}
}
}
```
This skips auto-spawn and the startup wait inside Clawdbot. For slow starts when auto-spawning, set `channels.signal.startupTimeoutMs`.
## Access control (DMs + groups)
DMs:
- Default: `channels.signal.dmPolicy = "pairing"`.
@@ -95,13 +111,40 @@ Groups:
## Media + limits
- Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000).
- Optional newline chunking: set `channels.signal.chunkMode="newline"` to split on each line before length chunking.
- Attachments supported (base64 fetched from `signal-cli`).
- Default media cap: `channels.signal.mediaMaxMb` (default 8).
- Use `channels.signal.ignoreAttachments` to skip downloading media.
- Group history context uses `channels.signal.historyLimit` (or `channels.signal.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
## Typing + read receipts
- **Typing indicators**: Clawdbot sends typing signals via `signal-cli sendTyping` and refreshes them while a reply is running.
- **Read receipts**: when `channels.signal.sendReadReceipts` is true, Clawdbot forwards read receipts for allowed DMs.
- Signal-cli does not expose read receipts for groups.
## Reactions (message tool)
- Use `message action=react` with `channel=signal`.
- Targets: sender E.164 or UUID (use `uuid:<id>` from pairing output; bare UUID works too).
- `messageId` is the Signal timestamp for the message youre reacting to.
- Group reactions require `targetAuthor` or `targetAuthorUuid`.
Examples:
```
message action=react channel=signal target=uuid:123e4567-e89b-12d3-a456-426614174000 messageId=1737630212345 emoji=🔥
message action=react channel=signal target=+15551234567 messageId=1737630212345 emoji=🔥 remove=true
message action=react channel=signal target=signal:group:<groupId> targetAuthor=uuid:<sender-uuid> messageId=1737630212345 emoji=✅
```
Config:
- `channels.signal.actions.reactions`: enable/disable reaction actions (default true).
- `channels.signal.reactionLevel`: `off | ack | minimal | extensive`.
- `off`/`ack` disables agent reactions (message tool `react` will error).
- `minimal`/`extensive` enables agent reactions and sets the guidance level.
- Per-account overrides: `channels.signal.accounts.<id>.actions.reactions`, `channels.signal.accounts.<id>.reactionLevel`.
## Delivery targets (CLI/cron)
- DMs: `signal:+15551234567` (or plain E.164).
- UUID DMs: `uuid:<id>` (or bare UUID).
- Groups: `signal:group:<groupId>`.
- Usernames: `username:<name>` (if supported by your Signal account).
@@ -115,6 +158,7 @@ Provider options:
- `channels.signal.httpUrl`: full daemon URL (overrides host/port).
- `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default 127.0.0.1:8080).
- `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset).
- `channels.signal.startupTimeoutMs`: startup wait timeout in ms (cap 120000).
- `channels.signal.receiveMode`: `on-start | manual`.
- `channels.signal.ignoreAttachments`: skip attachment downloads.
- `channels.signal.ignoreStories`: ignore stories from the daemon.
@@ -126,6 +170,7 @@ Provider options:
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms["<phone_or_uuid>"].historyLimit`.
- `channels.signal.textChunkLimit`: outbound chunk size (chars).
- `channels.signal.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
- `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB).
Related global options:

View File

@@ -304,7 +304,8 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens:
"policy": "pairing",
"allowFrom": ["U123", "U456", "*"],
"groupEnabled": false,
"groupChannels": ["G123"]
"groupChannels": ["G123"],
"replyToMode": "all"
},
"channels": {
"C123": { "allow": true, "requireMention": true },
@@ -348,6 +349,7 @@ ack reaction after the bot replies.
## Limits
- Outbound text is chunked to `channels.slack.textChunkLimit` (default 4000).
- Optional newline chunking: set `channels.slack.chunkMode="newline"` to split on each line before length chunking.
- Media uploads are capped by `channels.slack.mediaMaxMb` (default 20).
## Reply threading
@@ -361,6 +363,73 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
### Per-chat-type threading
You can configure different threading behavior per chat type by setting `channels.slack.replyToModeByChatType`:
```json5
{
channels: {
slack: {
replyToMode: "off", // default for channels
replyToModeByChatType: {
direct: "all", // DMs always thread
group: "first" // group DMs/MPIM thread first reply
},
}
}
}
```
Supported chat types:
- `direct`: 1:1 DMs (Slack `im`)
- `group`: group DMs / MPIMs (Slack `mpim`)
- `channel`: standard channels (public/private)
Precedence:
1) `replyToModeByChatType.<chatType>`
2) `replyToMode`
3) Provider default (`off`)
Legacy `channels.slack.dm.replyToMode` is still accepted as a fallback for `direct` when no chat-type override is set.
Examples:
Thread DMs only:
```json5
{
channels: {
slack: {
replyToMode: "off",
replyToModeByChatType: { direct: "all" }
}
}
}
```
Thread group DMs but keep channels in the root:
```json5
{
channels: {
slack: {
replyToMode: "off",
replyToModeByChatType: { group: "first" }
}
}
}
```
Make channels thread, keep DMs in the root:
```json5
{
channels: {
slack: {
replyToMode: "first",
replyToModeByChatType: { direct: "off", group: "off" }
}
}
}
```
### Manual threading tags
For fine-grained control, use these tags in agent responses:
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).

View File

@@ -120,6 +120,13 @@ You can add custom commands to the menu via config:
}
```
## Troubleshooting
- `setMyCommands failed` in logs usually means outbound HTTPS/DNS is blocked to `api.telegram.org`.
- If you see `sendMessage` or `sendChatAction` failures, check IPv6 routing and DNS.
More help: [Channel troubleshooting](/channels/troubleshooting).
Notes:
- Custom commands are **menu entries only**; Clawdbot does not implement them unless you handle them elsewhere.
- Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (132 chars).
@@ -128,6 +135,7 @@ Notes:
## Limits
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
- Optional newline chunking: set `channels.telegram.chunkMode="newline"` to split on each line before length chunking.
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5).
- Telegram Bot API requests time out after `channels.telegram.timeoutSeconds` (default 500 via grammY). Set lower to avoid long hangs.
- Group history context uses `channels.telegram.historyLimit` (or `channels.telegram.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
@@ -484,6 +492,10 @@ The agent sees reactions as **system notifications** in the conversation history
- Make sure your Telegram user ID is authorized (via pairing or `channels.telegram.allowFrom`)
- Commands require authorization even in groups with `groupPolicy: "open"`
**Long-polling aborts immediately on Node 22+ (often with proxies/custom fetch):**
- Node 22+ is stricter about `AbortSignal` instances; foreign signals can abort `fetch` calls right away.
- Upgrade to a Clawdbot build that normalizes abort signals, or run the gateway on Node 20 until you can upgrade.
**Bot starts, then silently stops responding (or logs `HttpError: Network request ... failed`):**
- Some hosts resolve `api.telegram.org` to IPv6 first. If your server does not have working IPv6 egress, grammY can get stuck on IPv6-only requests.
- Fix by enabling IPv6 egress **or** forcing IPv4 resolution for `api.telegram.org` (for example, add an `/etc/hosts` entry using the IPv4 A record, or prefer IPv4 in your OS DNS stack), then restart the gateway.
@@ -512,6 +524,8 @@ Provider options:
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).

133
docs/channels/tlon.md Normal file
View File

@@ -0,0 +1,133 @@
---
summary: "Tlon/Urbit support status, capabilities, and configuration"
read_when:
- Working on Tlon/Urbit channel features
---
# Tlon (plugin)
Tlon is a decentralized messenger built on Urbit. Clawdbot connects to your Urbit ship and can
respond to DMs and group chat messages. Group replies require an @ mention by default and can
be further restricted via allowlists.
Status: supported via plugin. DMs, group mentions, thread replies, and text-only media fallback
(URL appended to caption). Reactions, polls, and native media uploads are not supported.
## Plugin required
Tlon ships as a plugin and is not bundled with the core install.
Install via CLI (npm registry):
```bash
clawdbot plugins install @clawdbot/tlon
```
Local checkout (when running from a git repo):
```bash
clawdbot plugins install ./extensions/tlon
```
Details: [Plugins](/plugin)
## Setup
1) Install the Tlon plugin.
2) Gather your ship URL and login code.
3) Configure `channels.tlon`.
4) Restart the gateway.
5) DM the bot or mention it in a group channel.
Minimal config (single account):
```json5
{
channels: {
tlon: {
enabled: true,
ship: "~sampel-palnet",
url: "https://your-ship-host",
code: "lidlut-tabwed-pillex-ridrup"
}
}
}
```
## Group channels
Auto-discovery is enabled by default. You can also pin channels manually:
```json5
{
channels: {
tlon: {
groupChannels: [
"chat/~host-ship/general",
"chat/~host-ship/support"
]
}
}
}
```
Disable auto-discovery:
```json5
{
channels: {
tlon: {
autoDiscoverChannels: false
}
}
}
```
## Access control
DM allowlist (empty = allow all):
```json5
{
channels: {
tlon: {
dmAllowlist: ["~zod", "~nec"]
}
}
}
```
Group authorization (restricted by default):
```json5
{
channels: {
tlon: {
defaultAuthorizedShips: ["~zod"],
authorization: {
channelRules: {
"chat/~host-ship/general": {
mode: "restricted",
allowedShips: ["~zod", "~nec"]
},
"chat/~host-ship/announcements": {
mode: "open"
}
}
}
}
}
}
```
## Delivery targets (CLI/cron)
Use these with `clawdbot message send` or cron delivery:
- DM: `~sampel-palnet` or `dm/~sampel-palnet`
- Group: `chat/~host-ship/channel` or `group:~host-ship/channel`
## Notes
- Group replies require a mention (e.g. `~your-bot-ship`) to respond.
- Thread replies: if the inbound message is in a thread, Clawdbot replies in-thread.
- Media: `sendMedia` falls back to text + URL (no native upload).

View File

@@ -22,3 +22,4 @@ clawdbot channels status --probe
## Telegram quick fixes
- Logs show `HttpError: Network request for 'sendMessage' failed` or `sendChatAction` → check IPv6 DNS. If `api.telegram.org` resolves to IPv6 first and the host lacks IPv6 egress, force IPv4 or enable IPv6. See [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting).
- Logs show `setMyCommands failed` → check outbound HTTPS and DNS reachability to `api.telegram.org` (common on locked-down VPS or proxies).

View File

@@ -271,12 +271,13 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
## Limits
- Outbound text is chunked to `channels.whatsapp.textChunkLimit` (default 4000).
- Optional newline chunking: set `channels.whatsapp.chunkMode="newline"` to split on each line before length chunking.
- Inbound media saves are capped by `channels.whatsapp.mediaMaxMb` (default 50 MB).
- Outbound media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB).
## Outbound send (text + media)
- Uses active web listener; error if gateway not running.
- Text chunking: 4k max per message (configurable via `channels.whatsapp.textChunkLimit`).
- Text chunking: 4k max per message (configurable via `channels.whatsapp.textChunkLimit`, optional `channels.whatsapp.chunkMode`).
- Media:
- Image/video/audio/document supported.
- Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`.
@@ -334,6 +335,7 @@ WhatsApp sends audio as **voice notes** (PTT bubble).
- `agents.defaults.heartbeat.model` (optional override)
- `agents.defaults.heartbeat.target`
- `agents.defaults.heartbeat.to`
- `agents.defaults.heartbeat.session`
- `agents.list[].heartbeat.*` (per-agent overrides)
- `session.*` (scope, idle, store, mainKey)
- `web.enabled` (disable channel startup when false)

Some files were not shown because too many files have changed in this diff Show More