Compare commits

..

651 Commits

Author SHA1 Message Date
Peter Steinberger
5b24bf7ac1 fix: improve matrix direct room resolution (#1436) (thanks @sibbl) 2026-01-23 05:34:37 +00:00
Sebastian Schubotz
a61c8a2317 fix(matrix): improve error handling and fallback logic in resolveDirectRoomId 2026-01-23 05:25:37 +00:00
Sebastian Schubotz
c717258a8f fix(matrix): fix broken import again 2026-01-23 05:25:37 +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
Peter Steinberger
15f16de651 docs: update nodes list/status flags 2026-01-21 04:52:54 +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
Peter Steinberger
fa7df1976d feat: theme hooks/skills/plugins output 2026-01-21 04:48:34 +00:00
Peter Steinberger
2cd62f94a5 feat: tableize device/directory outputs 2026-01-21 04:48:33 +00:00
Peter Steinberger
a74c19feed docs: unify cli help examples 2026-01-21 04:48:33 +00:00
Peter Steinberger
1ad4a7194e fix: allow node exec fallback and defer node approvals 2026-01-21 04:46:50 +00:00
Peter Steinberger
beec504ebd feat: filter nodes list/status 2026-01-21 04:39:15 +00:00
Peter Steinberger
fe1133e2c5 Merge pull request #1348 from vignesh07/feat/tui-input-history
feat(tui): add input history (↑/↓) for submitted messages
2026-01-21 04:37:49 +00:00
Peter Steinberger
6f37f1d8ff fix: record tui input history (#1348) (thanks @vignesh07) 2026-01-21 04:37:22 +00:00
Peter Steinberger
57700f33a9 fix: record node last-connect by instance id 2026-01-21 04:32:53 +00:00
Vignesh Natarajan
2700794228 feat(tui): add input history for submitted messages (WIP)
Record submitted inputs in the editor history so up/down arrow
can recall previous messages.

Adds a small helper to wire submit handling and unit tests for
routing/recording behavior.

No PR yet (per request).
2026-01-21 04:31:33 +00:00
dependabot[bot]
416894c642 chore(deps): bump tar in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [tar](https://github.com/isaacs/node-tar).


Updates `tar` from 7.5.3 to 7.5.4
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.3...v7.5.4)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.4
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-21 04:22:46 +00:00
Peter Steinberger
db88378ae3 fix: normalize node invoke result payloadJSON 2026-01-21 04:17:35 +00:00
Peter Steinberger
e97b4973bb docs: clarify node host exec flow 2026-01-21 04:14:34 +00:00
Peter Steinberger
832dfb02fe fix: omit null payloadJSON in node invoke results 2026-01-21 04:13:47 +00:00
Peter Steinberger
15e3a2a395 fix: sanitize node invoke result params 2026-01-21 04:13:47 +00:00
Peter Steinberger
8c472c210f docs: colorize update help 2026-01-21 04:08:50 +00:00
Peter Steinberger
833bbcd166 fix: show subcommand help on --help 2026-01-21 04:08:50 +00:00
Peter Steinberger
d7440baef6 docs: clarify update help 2026-01-21 04:08:50 +00:00
Peter Steinberger
58b131919f feat: use tsgo for dev/watch builds 2026-01-21 04:06:09 +00:00
Peter Steinberger
186e86660a Merge pull request #1350 from Jackten/fix/fallback-authprofile-provider-scope
test(auto-reply): regression for authProfileId across provider fallback
2026-01-21 03:53:07 +00:00
Peter Steinberger
18d47b47d2 Merge pull request #1349 from siddhantjain/fix/immediate-callback-answer
fix(telegram): answer callback queries immediately to prevent retries
2026-01-21 03:51:50 +00:00
Peter Steinberger
eb1e2c7a3b fix: suppress node warnings unless verbose 2026-01-21 03:47:50 +00:00
Peter Steinberger
6ea4cb0012 fix: suppress npm fund prompts 2026-01-21 03:47:50 +00:00
Peter Steinberger
184f5a5fc3 fix: suppress update deprecation warnings 2026-01-21 03:47:50 +00:00
Peter Steinberger
4ad359ffcd feat: add non-interactive update option 2026-01-21 03:47:50 +00:00
Peter Steinberger
38cc2a3288 fix: guard media status parts filter 2026-01-21 03:43:42 +00:00
Peter Steinberger
28c49db494 fix: default exec security to allowlist 2026-01-21 03:40:27 +00:00
Peter Steinberger
026e6c4df4 fix: restore bundled plugin discovery 2026-01-21 03:40:05 +00:00
Peter Steinberger
841dfc693e style: flatten approvals allowlist age 2026-01-21 03:36:54 +00:00
Peter Steinberger
f38278d919 style: collapse approvals allowlist age 2026-01-21 03:36:54 +00:00
Peter Steinberger
9545edcb49 style: tighten approvals allowlist age 2026-01-21 03:36:54 +00:00
Peter Steinberger
f3554a3ad8 feat: render approvals get as table 2026-01-21 03:36:54 +00:00
Peter Steinberger
b30359e9cd fix(macos): ignore launchd token in remote mode 2026-01-21 03:34:51 +00:00
Peter Steinberger
d3898ee8df test(macos): cover gateway host resolution 2026-01-21 03:34:51 +00:00
Peter Steinberger
d1c2fc4bc8 fix: hide empty status rows 2026-01-21 03:32:16 +00:00
Peter Steinberger
a5a3ab958f fix: skip bundled plugin discovery in tests 2026-01-21 03:29:03 +00:00
Peter Steinberger
165861e78d feat: add approvals allowlist examples 2026-01-21 03:27:50 +00:00
Peter Steinberger
e7c355ee85 feat: group service help examples by submenu 2026-01-21 03:27:50 +00:00
Peter Steinberger
052a58f2f7 style: tighten nodes pending row 2026-01-21 03:27:50 +00:00
Peter Steinberger
5ff56ffb4e style: compress nodes pending age 2026-01-21 03:27:50 +00:00
Peter Steinberger
9a3dd626a1 style: format nodes pending row 2026-01-21 03:27:50 +00:00
Peter Steinberger
aae4b2952f feat: polish nodes cli output 2026-01-21 03:27:50 +00:00
Peter Steinberger
aec622fe63 chore: remove fresh dist log 2026-01-21 03:13:50 +00:00
Peter Steinberger
e6287270d9 feat: render nodes status as table 2026-01-21 03:11:27 +00:00
Vultr-Clawd Admin
c05a7b5390 test(auto-reply): drop auth profile on provider fallback 2026-01-20 23:07:33 -04:00
Siddhant Jain
020fecef5c fix(telegram): answer callback queries immediately to prevent retries
Telegram retries callback queries if they aren't acknowledged quickly.
Previously, answerCallbackQuery was called in a finally block AFTER
processing, which could take several seconds for agent responses.

This change moves answerCallbackQuery to immediately after basic
validation, before any processing begins. This prevents Telegram
from sending duplicate callbacks while the agent is thinking.

Fixes duplicate callback handling when agent processing is slow.
2026-01-21 03:04:28 +00:00
Peter Steinberger
caf9dec89c feat: add nodes list table with last connect 2026-01-21 03:03:48 +00:00
Peter Steinberger
438a41f91f refactor: harden log stream writes 2026-01-21 03:03:29 +00:00
Peter Steinberger
a0cd295c0f fix: add browser snapshot default mode (#1336)
Co-authored-by: Seb Slight <sbarrios93@gmail.com>
2026-01-21 03:03:10 +00:00
Peter Steinberger
14d3d72bcc refactor(ui): reuse emoji icon helpers 2026-01-21 02:58:56 +00:00
Peter Steinberger
03916ed10e feat(ui): add copy-as-markdown in chat
Co-authored-by: Bradley Priest <bradleypriest@users.noreply.github.com>
2026-01-21 02:58:56 +00:00
Peter Steinberger
5bd55037e4 fix: harden web fetch SSRF and redirects
Co-authored-by: Eli <fogboots@users.noreply.github.com>
2026-01-21 02:54:14 +00:00
Peter Steinberger
ec51bb700c Merge pull request #1314 from dbhurley/fix/control-ui-token-auth
fix: allow token auth to bypass device identity requirement
2026-01-21 02:44:47 +00:00
Peter Steinberger
051d518078 Merge pull request #1318 from sebslight/fix/logs-follow-output
CLI: avoid logs --follow echo
2026-01-21 02:38:14 +00:00
Peter Steinberger
294886b54f fix(macos): return bind host 2026-01-21 02:29:41 +00:00
Peter Steinberger
6629e31789 Merge pull request #1322 from KrauseFx/fix/cron-edit-preserve-delivery-on-message
Fix(cli): Preserve delivery settings when updating message via cron edit
2026-01-21 02:29:20 +00:00
Peter Steinberger
9d7087168f fix(gateway): improve validation errors (#1347)
Thanks @vignesh07.

Co-authored-by: Vignesh <vignesh07@users.noreply.github.com>
2026-01-21 02:28:33 +00:00
Vignesh Natarajan
daceeaa24c fix(gateway): clarify schema validation errors
Improve validation error formatting for strict schemas.
In particular, additionalProperties errors now surface the
unexpected property name and where it occurred, which makes
handshake/connect failures easier to debug.
2026-01-21 02:28:33 +00:00
Peter Steinberger
778800be70 fix(macos): prefer tailnet ip for auto bind 2026-01-21 02:28:21 +00:00
Peter Steinberger
1b973caf7a fix: preserve cron edit delivery payloads (#1322) (thanks @KrauseFx) 2026-01-21 02:27:18 +00:00
ClawdFx
ea775025c0 Run oxfmt formatting 2026-01-21 02:18:43 +00:00
ClawdFx
0b2830470c Fix: Preserve delivery settings when updating message via cron edit
- Add failing tests for delivery field preservation
- Fix register.cron-edit to conditionally build payload object
- Only include delivery fields (deliver, channel, to, bestEffortDeliver) when explicitly provided
- Previously undefined values were included, wiping out existing delivery settings
- Now --message alone preserves existing delivery config
- Tests verify both preservation and explicit override scenarios
2026-01-21 02:18:33 +00:00
Peter Steinberger
e81ca7ab00 fix: tame invalid config logging 2026-01-21 01:58:47 +00:00
Peter Steinberger
27acfa59c5 chore: update a2ui bundle 2026-01-21 01:33:31 +00:00
Peter Steinberger
b333c4a994 fix: address config settings viewbuilder updates 2026-01-21 01:33:31 +00:00
Peter Steinberger
23f7dd8b25 fix: pass threadId in restart sentinel 2026-01-21 01:26:15 +00:00
Peter Steinberger
77d9451712 docs: fix mdx in security cli doc 2026-01-21 01:24:29 +00:00
Peter Steinberger
a4fc2b4536 fix: handle fetch streams safely 2026-01-21 01:24:29 +00:00
Peter Steinberger
20a7dd8a80 feat: add config subsections in control ui 2026-01-21 01:22:19 +00:00
Peter Steinberger
450d2d25e2 feat: add sectioned config layout in mac app 2026-01-21 01:22:19 +00:00
Peter Steinberger
df024afc97 fix: note gatewayUrl param in control ui (#1342) (thanks @ameno-) 2026-01-21 01:21:57 +00:00
Peter Steinberger
12168dc64f Merge pull request #1337 from John-Rood/fix/restart-channel-routing
fix: preserve channel routing across gateway restart
2026-01-21 01:21:35 +00:00
Peter Steinberger
4232081fcb Merge pull request #1342 from ameno-/fix-webchat-gateway-url
fix(ui): parse gatewayUrl from URL params
2026-01-21 01:20:25 +00:00
Peter Steinberger
17f3635109 fix: preserve restart routing + thread replies (#1337) (thanks @John-Rood)
Co-authored-by: John-Rood <John-Rood@users.noreply.github.com>
Co-authored-by: Outdoor <outdoor@users.noreply.github.com>
2026-01-21 01:20:25 +00:00
Peter Steinberger
9206d21c76 fix: narrow cron payload merge types 2026-01-21 01:14:24 +00:00
Peter Steinberger
96be166bd6 fix: refactor cron edit payload patches
Co-authored-by: Felix Krause <869950+KrauseFx@users.noreply.github.com>
2026-01-21 01:14:24 +00:00
Peter Steinberger
d8abd53a1d fix: reschedule heartbeat on hot reload
Co-authored-by: Seb Slight <sebslight@users.noreply.github.com>
2026-01-21 00:53:54 +00:00
Peter Steinberger
eff292eda4 Merge pull request #1338 from MaudeCode/fix/message-command-plugins
fix(cli): load plugin registry for message/channels commands
2026-01-21 00:49:04 +00:00
Ameno Osman
c74551c2ae fix(ui): parse gatewayUrl from URL params for remote gateway access
Adds support for passing `gatewayUrl` as a URL parameter to the WebChat UI,
allowing the control-ui to connect to a remote gateway (e.g., VPS) instead
of defaulting to localhost.

Usage: http://localhost:5173/?gatewayUrl=ws://<vps-ip>:18789&token=<token>

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 16:35:02 -08:00
Peter Steinberger
48b0d08493 fix: resolve ReadableStream cast in skills install 2026-01-21 00:33:22 +00:00
Peter Steinberger
dd38185e6c docs: add diagnostics changelog entry 2026-01-21 00:30:59 +00:00
Peter Steinberger
ec01e5c7e6 fix: emit diagnostics across channels 2026-01-21 00:30:34 +00:00
Peter Steinberger
e447233533 chore: sync protocol outputs 2026-01-21 00:19:53 +00:00
Peter Steinberger
00bcb01bb4 docs: clarify whatsapp voice notes and wsl portproxy 2026-01-21 00:14:36 +00:00
Peter Steinberger
458850483a feat: add sherpa-onnx-tts skill 2026-01-21 00:14:36 +00:00
Peter Steinberger
76bae8da40 feat: add download installs for skills 2026-01-21 00:14:36 +00:00
Peter Steinberger
c33c0629ec fix: treat tailnet host as local for pairing 2026-01-21 00:14:15 +00:00
Peter Steinberger
e5ea8a0d22 docs: add network hub + pairing locality 2026-01-21 00:14:14 +00:00
Peter Steinberger
e083f678fd fix(ui): preserve ordered list numbering (#1341) - thanks @bradleypriest
Co-authored-by: Bradley Priest <bradleypriest@gmail.com>
2026-01-21 00:12:21 +00:00
Peter Steinberger
51dfd6efdb fix: tighten small-model audit guardrails 2026-01-20 23:52:26 +00:00
Peter Steinberger
4fad74738a fix: prefer loopback for auto bind fallback 2026-01-20 23:48:26 +00:00
Peter Steinberger
69f0469530 Merge pull request #1332 from clawdbot/temp/landpr-model-catalog-improvements
fix(model-catalog): improve cache resilience
2026-01-20 23:07:51 +00:00
Peter Steinberger
eb1ee36f59 fix: relax diagnostic event typing (#1332) (thanks @steipete) 2026-01-20 23:07:28 +00:00
Peter Steinberger
b341512564 fix: model catalog cache + TUI editor ctor (#1326) (thanks @dougvk) 2026-01-20 22:58:41 +00:00
Peter Steinberger
6734f2d71c fix: wire OTLP logs for diagnostics 2026-01-20 22:51:47 +00:00
Peter Steinberger
e12abf3114 fix: update CustomEditor constructor 2026-01-20 22:36:06 +00:00
Maude Bot
4ad9622efb fix(cli): load plugin registry for message/channels commands
Fixes #1327 - 'clawdbot message --channel telegram' fails with
'Unknown channel: telegram' because plugins weren't loaded.

The Commander code path (non-route-first) calls ensureConfigReady() in
preAction but doesn't load the plugin registry. Channel plugins like
telegram are registered during plugin loading, so getChannelPlugin()
returns undefined without it.

This adds ensurePluginRegistryLoaded() call for commands that need
channel plugin access: message, channels, directory.
2026-01-20 16:41:15 -05: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
Peter Steinberger
2dfd3b9a81 chore: drop nostr node_modules links 2026-01-20 20:15:56 +00:00
Peter Steinberger
7b6cbf5869 feat: add Nostr channel plugin and onboarding install defaults
Co-authored-by: joelklabo <joelklabo@users.noreply.github.com>
2026-01-20 20:15:56 +00:00
Peter Steinberger
8686b3b951 Merge pull request #1326 from dougvk/fix/model-catalog-cache-poison
fix(model-catalog): avoid caching import failures
2026-01-20 20:14:52 +00:00
Peter Steinberger
2e7e135bc0 fix: config form semantics + editor ctor (#1315) (thanks @MaudeBot) 2026-01-20 20:14:22 +00:00
Peter Steinberger
c287664923 Merge pull request #1315 from MaudeCode/feat/config-ui-sections
feat(ui): config page overhaul with sidebar nav, search, and improved fields
2026-01-20 20:12:42 +00:00
Peter Steinberger
18f0051d26 fix: avoid discord gateway abort crash 2026-01-20 19:33:08 +00:00
Peter Steinberger
b012b1105e fix: unblock discord listener concurrency 2026-01-20 19:30:32 +00:00
Peter Steinberger
21370fc09b fix: allow fallback on timeout aborts
Co-authored-by: Larus Ivarsson <larusivar@gmail.com>
2026-01-20 19:23:13 +00:00
Peter Steinberger
4999f15688 refactor: consolidate mac debug CLI 2026-01-20 19:17:31 +00:00
Doug von Kohorn
e4f9555f21 fix(model-catalog): avoid caching import failures
Move dynamic import of @mariozechner/pi-coding-agent into the try/catch so transient module resolution errors don't poison the model catalog cache with a rejected promise.

This previously caused Discord/Telegram handlers and heartbeat to fail until process restart if the import failed once.
2026-01-20 20:09:55 +01:00
Peter Steinberger
243a8b019e fix: route native status to active agent 2026-01-20 19:04:31 +00:00
Peter Steinberger
5c4079f66c feat: add diagnostics events and otel exporter 2026-01-20 18:56:15 +00:00
Peter Steinberger
b1f086b536 chore(changelog): note cron auto-delivery (#1285) 2026-01-20 18:53:08 +00:00
Peter Steinberger
d298b8c16b fix(cron): auto-deliver agent output to explicit targets 2026-01-20 17:56:15 +00:00
Peter Steinberger
40968bd5e0 test: stabilize atomic reindex search mock 2026-01-20 17:50:42 +00:00
Peter Steinberger
80e6c070bf refactor: centralize discord api errors 2026-01-20 17:28:19 +00:00
Peter Steinberger
26fcca087b fix(macos): resolve AnyCodable alias conflicts 2026-01-20 17:27:45 +00:00
Peter Steinberger
02ca148583 fix: preserve subagent thread routing (#1241)
Thanks @gnarco.

Co-authored-by: gnarco <gnarco@users.noreply.github.com>
2026-01-20 17:22:07 +00:00
Peter Steinberger
ae1c6f4313 docs: update changelog for gateway auth errors 2026-01-20 17:12:26 +00:00
Peter Steinberger
9faed2226a fix: soften discord resolve warnings 2026-01-20 17:11:52 +00:00
Peter Steinberger
cf04b24632 fix: clarify gateway auth unauthorized message 2026-01-20 17:06:02 +00:00
Sebastian Slight
7e59c15496 CLI: avoid logs follow echo 2026-01-20 11:51:53 -05:00
Peter Steinberger
9f856abfe7 fix: align tui editor with pi-tui API 2026-01-20 16:51:44 +00:00
Maude Bot
e74fd9196c feat(ui): add icons for all config sections
Added SVG icons for: meta, logging, browser, ui, models, bindings,
broadcast, audio, session, cron, web, discovery, canvasHost, talk, plugins

Also added descriptions for all new sections in metadata.
2026-01-20 11:47:22 -05:00
Peter Steinberger
40e928a4c4 Merge pull request #1271 from Whoaa512/feat/session-picker-mvp
feat: session picker MVP - fuzzy search, derived titles, relative time
2026-01-20 16:46:48 +00:00
David Hurley
079af0d0b0 fix: allow token auth to bypass device identity requirement
The device identity check was rejecting connections before token
authentication could be attempted. This broke the control-ui (web UI)
which uses token-based authentication via URL parameter.

Changes:
- Skip device identity requirement when a token is provided
- Guard device token verification to only run when device is present

Fixes control-ui showing "device identity required" error when
connecting with a valid token.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 11:46:47 -05:00
Peter Steinberger
faa5838147 fix: polish session picker filtering (#1271) (thanks @Whoaa512) 2026-01-20 16:46:15 +00:00
Maude Bot
f6abe62e5f feat(ui): major config form UX overhaul
Sidebar:
- SVG icons instead of emoji (consistent rendering)
- Clean navigation with active states

Form fields completely redesigned:
- Toggle rows: full-width clickable with label + description
- Segmented controls: for enum values with ≤5 options
- Number inputs: with +/- stepper buttons
- Text inputs: with reset-to-default button
- Select dropdowns: clean styling with custom arrow
- Arrays: card-based with clear add/remove, item numbering
- Objects: collapsible sections with chevron animation
- Maps: key-value editor with inline editing

Visual improvements:
- Consistent border radius and spacing
- Better color contrast for labels vs help text
- Hover and focus states throughout
- Icons for common actions (add, remove, reset)

Mobile:
- Horizontal scrolling nav on small screens
- Stacked layouts for complex fields
2026-01-20 11:40:13 -05:00
Peter Steinberger
5c5745dee5 fix: auto-enable plugins on startup 2026-01-20 16:38:37 +00:00
Peter Steinberger
15c735de4d chore: update a2ui bundle hash 2026-01-20 16:38:08 +00:00
Peter Steinberger
8bf484bdad fix: update pi-ai/pi-tui usage 2026-01-20 16:38:08 +00:00
CJ Winslow
36719690a2 test: add coverage for readLastMessagePreviewFromTranscript
Also add CHANGELOG entry and perf docs for session list flags.
2026-01-20 16:37:09 +00:00
CJ Winslow
f2666d2092 refactor: extract shared fuzzy filter utilities for list components 2026-01-20 16:37:08 +00:00
CJ Winslow
a28c271488 TUI: optimize fuzzy filtering and consolidate time formatting
- Extract formatRelativeTime to shared utility for reuse across components
- Optimize FilterableSelectList with pre-lowercased searchTextLower field (avoids toLowerCase on every keystroke)
- Implement custom fuzzy matching with space-separated token support and word boundary scoring
- Use matchesKey utility for consistent keybinding handling (arrows, vim j/k, ctrl+p/n)
- Fix searchable-select-list to support vim keybindings consistently
- Fix system-prompt runtimeInfo null check with nullish coalescing operator
2026-01-20 16:37:08 +00:00
CJ Winslow
1d9d5b30ce feat: add last message preview to session picker
Read the final user/assistant message from session transcripts and display
it in the picker alongside the session update time. Allows quick previews
of what's in each session without opening it.
2026-01-20 16:36:51 +00:00
CJ Winslow
14f56a4e18 TUI: use editor keybindings for select cancel action
Replace hardcoded escape sequence checks with the pi-tui keybindings API to ensure consistent cancel handling across different terminal configurations.
2026-01-20 16:36:51 +00:00
CJ Winslow
687c41e838 TUI: display relative time for session updates in picker
Show "just now", "5m ago", "Yesterday" etc. instead of absolute timestamps
for better readability in the session picker list.
2026-01-20 16:36:51 +00:00
CJ Winslow
ddb7b5c6a4 feat: add search param to sessions.list RPC
Server-side filtering backup for client-side session picker search.
Case-insensitive substring match on displayName, label, subject,
sessionId, and key.

Closes #1161
2026-01-20 16:36:51 +00:00
CJ Winslow
262e35c219 refactor: clean up FilterableSelectList per code review
- Remove dead input handlers (onSubmit/onEscape never triggered)
- Store maxVisible as instance property instead of bracket access
- Remove unused filterInput theme property
2026-01-20 16:36:51 +00:00
CJ Winslow
95f0befd65 feat: add fuzzy filter to TUI session picker
Users can now type to filter sessions in real-time:
- FilterableSelectList component wraps pi-tui's fuzzyFilter
- Matches against displayName, label, subject, sessionId
- j/k navigation, Enter selects, Escape clears filter then cancels
- Uses derivedTitle from previous commit for better display

Refs #1161
2026-01-20 16:36:51 +00:00
CJ Winslow
83d5e30027 feat: add heuristic session title derivation for session picker
Enable meaningful session titles via priority-based derivation:
1. displayName (user-set)
2. subject (group name)
3. First user message (truncated to 60 chars)
4. sessionId prefix + date fallback

Opt-in via includeDerivedTitles param to avoid perf impact on
regular listing. Reads only first 10 lines of transcript files.

Closes #1161
2026-01-20 16:36:51 +00:00
Peter Steinberger
842be7b864 chore: bump version to 2026.1.20 2026-01-20 16:36:37 +00:00
Peter Steinberger
cb5d76ed3d test: cover beta fallback update logic 2026-01-20 16:36:37 +00:00
Peter Steinberger
3d5ffee07f fix: prefer stable release when beta lags 2026-01-20 16:36:04 +00:00
Maude Bot
bd8f4b052d chore: remove duplicate config styles from components.css 2026-01-20 11:29:19 -05:00
Maude Bot
929d50b7d1 feat(ui): complete config page overhaul with sidebar nav, search, toggles, and diff view
Major redesign of the config page:

Layout:
- Sidebar navigation with section list
- Search input to filter settings
- Section cards with icons and descriptions
- Responsive design for mobile (stacked layout)

Fields:
- New toggle switches for booleans (replaces checkboxes)
- Improved field-row layout with label, help text, and control
- Better fieldset and array styling

Features:
- Diff view showing pending changes before save
- Original value tracking for comparison
- Section filtering via sidebar nav
- Search across setting names, descriptions, and nested properties

Styling:
- Dedicated config.css with all new styles
- Dark and light theme support
- Smooth animations and transitions
- Mobile-first responsive breakpoints
2026-01-20 11:28:41 -05:00
Peter Steinberger
4fda10c508 refactor(macos): split exec approvals handler 2026-01-20 16:24:44 +00:00
Peter Steinberger
0b0d8b2406 fix: repair model compat + editor ctor 2026-01-20 16:19:49 +00:00
Peter Steinberger
844ff2ee8f style(macos): fix swiftformat lint 2026-01-20 16:19:37 +00:00
Peter Steinberger
8c666666ef fix(tui): update CustomEditor ctor 2026-01-20 16:06:21 +00:00
Peter Steinberger
2394703593 fix(macos): disambiguate AnyCodable usage 2026-01-20 16:05:08 +00:00
Peter Steinberger
404470853a fix: stabilize gateway tests 2026-01-20 16:02:46 +00:00
Peter Steinberger
99fc0fbac1 feat: sync plugin updates with update channel 2026-01-20 16:00:42 +00:00
Peter Steinberger
91ed00f800 fix: clarify doctor auto-enable hint 2026-01-20 15:58:30 +00:00
Peter Steinberger
76698ed296 fix: allow custom skill config bag
Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
2026-01-20 15:57:08 +00:00
Maude Bot
716546824f feat(ui): improve config page with collapsible sections
- Group config settings into logical sections (Core, Agents, Communication, etc.)
- Add collapsible accordion UI for each section group
- Add icons and labels for each config category
- Improve mobile responsiveness with better button layout
- Style improvements for nested fieldsets and arrays
2026-01-20 10:56:44 -05:00
Peter Steinberger
74f382f732 fix: default Anthropic API cache TTL to 1h 2026-01-20 15:48:53 +00:00
Peter Steinberger
a76aea1bc0 chore: update a2ui bundle hash 2026-01-20 15:48:52 +00:00
Peter Steinberger
533766207f fix: silence macos warning noise 2026-01-20 15:48:52 +00:00
Peter Steinberger
59fa002561 fix: update device identity signing 2026-01-20 15:48:52 +00:00
Peter Steinberger
48ab168df2 fix: bridge gateway anycodable payloads 2026-01-20 15:48:52 +00:00
Peter Steinberger
bef9d5bdc8 chore: refresh swift package resolved 2026-01-20 15:48:52 +00:00
Peter Steinberger
c6812c6af4 fix: harden compat and editor ctor 2026-01-20 15:16:05 +00:00
Peter Steinberger
1f7cb4b853 fix: shorten bonjour gateway service type 2026-01-20 15:10:06 +00:00
Peter Steinberger
d161f3ab0f docs: refresh development channels timestamp 2026-01-20 15:10:06 +00:00
Peter Steinberger
c65b91c841 Merge pull request #1308 from dougvk/fix/preserve-command-arg-casing
fix(session): preserve command argument casing
2026-01-20 15:04:28 +00:00
Peter Steinberger
760b1e8fc6 fix: update model compat + tui editor 2026-01-20 15:02:25 +00:00
Peter Steinberger
188893f319 docs: add WhatsApp family binding example 2026-01-20 15:00:25 +00:00
Peter Steinberger
04ee9e7765 docs: clarify sandbox env + recreate guidance 2026-01-20 15:00:25 +00:00
Peter Steinberger
390ba5f42a fix: guard closeIdleConnections typing 2026-01-20 14:58:31 +00:00
Peter Steinberger
b8593fd4fb fix: close idle gateway http connections 2026-01-20 14:56:30 +00:00
Peter Steinberger
68a467dd66 fix: guard ZAI compat on openai-completions api 2026-01-20 14:39:58 +00:00
Peter Steinberger
d18319a57d fix: align CustomEditor with pi-tui Editor API 2026-01-20 14:35:42 +00:00
Peter Steinberger
15e5bb3459 feat: improve /new model hints and reset confirmation 2026-01-20 14:35:20 +00:00
Peter Steinberger
41f6d06967 fix: align tui editor init (#1298) (thanks @sibbl) 2026-01-20 14:32:46 +00:00
Peter Steinberger
e3a99aa2ce refactor: split matrix provider modules 2026-01-20 14:32:04 +00:00
Peter Steinberger
c1d8456860 fix: clean up lint leftovers 2026-01-20 14:25:18 +00:00
Peter Steinberger
da10ca1585 fix: drain openresponses test responses 2026-01-20 14:20:04 +00:00
Peter Steinberger
5d017dae5a feat: add update channel status
Co-authored-by: Richard Poelderl <18185649+p6l-richard@users.noreply.github.com>
2026-01-20 14:19:03 +00:00
Peter Steinberger
30fd7001f2 fix: tolerate pi-tui type exports 2026-01-20 14:17:39 +00:00
Peter Steinberger
da4b124480 fix(browser): register AI snapshot refs (#1282)
thanks @John-Rood

Co-authored-by: John Rood <62669593+John-Rood@users.noreply.github.com>
2026-01-20 14:14:36 +00:00
John Rood
710c681283 fix(browser): register refs from AI snapshot for act commands
When using the default AI snapshot format without explicit options like
interactive/compact/labels, refs were not being registered because
snapshotAiViaPlaywright returns raw text without ref registration.

This caused 'Unknown ref' errors when subsequently using act commands
with refs like e12 that appeared in the snapshot text.

The fix extracts refs from the AI snapshot using buildRoleSnapshotFromAiSnapshot
and registers them via rememberRoleRefsForTarget so act commands can resolve them.

Fixes #1268
2026-01-20 14:13:48 +00:00
Peter Steinberger
e45228ac37 fix: merge login shell PATH for gateway exec 2026-01-20 14:04:13 +00:00
Peter Steinberger
a0180f364d fix: accept pi-tui editor ctor variants 2026-01-20 14:02:36 +00:00
Peter Steinberger
d69f246ba7 chore: fix lint/format 2026-01-20 13:52:59 +00:00
Peter Steinberger
a81989048d fix: update ui ed25519 + bluebubbles actions 2026-01-20 13:43:27 +00:00
Peter Steinberger
b56e9964f5 style: format update channel logic 2026-01-20 13:41:30 +00:00
Peter Steinberger
ddd7fc1513 style: format update channel logic 2026-01-20 13:41:30 +00:00
Peter Steinberger
4ebf55f1db feat: add dev update channel 2026-01-20 13:41:30 +00:00
Peter Steinberger
cc24ede586 docs: note release channels and add contributor
Co-authored-by: Richard Poelderl <richard.poelderl@gmail.com>
2026-01-20 13:41:30 +00:00
Peter Steinberger
eb3b84f3d2 chore: record zalouser PR commits 2026-01-20 13:38:38 +00:00
Peter Steinberger
304244f2be docs: add zalouser PR thanks 2026-01-20 13:33:13 +00:00
Peter Steinberger
f067ea25b4 fix: align zalouser status + schema 2026-01-20 13:32:11 +00:00
Peter Steinberger
fa51294f65 fix: sync mobile gateway auth v3 2026-01-20 13:30:40 +00:00
Peter Steinberger
a4d1c4d522 fix: run doctor config flow once 2026-01-20 13:27:51 +00:00
Peter Steinberger
6e17c463ae fix: add /skill fallback for native limits
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-20 13:20:29 +00:00
Peter Steinberger
63797e841d Merge pull request #1247 from sebslight/fix/perplexity-web-search-provider
Config: accept Perplexity for web_search
2026-01-20 13:18:18 +00:00
Peter Steinberger
fdb171cb15 refactor: centralize channel ui metadata 2026-01-20 13:11:49 +00:00
Peter Steinberger
6f9861bb9b chore: update deps 2026-01-20 13:06:16 +00:00
Peter Steinberger
759068304e fix: tighten tls fingerprints and approval events 2026-01-20 13:04:20 +00:00
Peter Steinberger
ded578b1fa docs: finalize clawnet refactor doc 2026-01-20 13:04:20 +00:00
Peter Steinberger
dcb8d16591 fix: validate ws tls fingerprint 2026-01-20 13:04:20 +00:00
Peter Steinberger
06c17a333e docs: update protocol + security notes 2026-01-20 13:04:20 +00:00
Peter Steinberger
409a16060b feat: enrich presence with roles 2026-01-20 13:04:20 +00:00
Peter Steinberger
7720106624 feat: add discovery role hints 2026-01-20 13:04:19 +00:00
Peter Steinberger
c613769d22 feat: add remote gateway tls fingerprint 2026-01-20 13:04:19 +00:00
Peter Steinberger
87343c374e feat: route exec approvals via gateway 2026-01-20 13:04:19 +00:00
Peter Steinberger
67be9aed28 chore: make @napi-rs/canvas optional 2026-01-20 13:04:19 +00:00
Peter Steinberger
b48d5d96d3 test: cover scope upgrade flow 2026-01-20 13:04:19 +00:00
Peter Steinberger
d8cc7db5e6 feat: wire role-scoped device creds 2026-01-20 13:04:19 +00:00
Peter Steinberger
dfbf6ac263 feat: enforce device-bound connect challenge 2026-01-20 13:04:19 +00:00
Peter Steinberger
121ae6036b docs: add matrix crypto setup note 2026-01-20 12:42:41 +00:00
Peter Steinberger
6e1ad31b49 build: allow matrix crypto build scripts 2026-01-20 12:23:05 +00:00
Doug von Kohorn
b8b0b3f0e7 chore: ignore serena cache 2026-01-20 13:16:49 +01:00
Peter Steinberger
0330b483ad docs: add #1306 changelog entry 2026-01-20 12:08:15 +00:00
Peter Steinberger
9a2bf57e1c refactor: extend channel plugin boundary 2026-01-20 12:07:54 +00:00
Peter Steinberger
439044068a fix: drop stray Peekaboo submodule 2026-01-20 12:07:54 +00:00
Tyler Yust
4c3b4aeb76 fix: remove unused typingSignals variable in get-reply-run 2026-01-20 12:07:54 +00:00
Tyler Yust
1e8b291374 refactor: improve error handling in embedded run payloads by clarifying conditions for user-facing error messages and updating test descriptions for typing behavior 2026-01-20 12:07:54 +00:00
Tyler Yust
95f82154f7 feat: extend BlueBubbles attachment handling by adding support for reply context, allowing users to reference previous messages in media attachments 2026-01-20 12:07:54 +00:00
Tyler Yust
7bc3998451 feat: add media size validation to BlueBubbles media handling, ensuring compliance with channel limits and improving error handling for oversized media 2026-01-20 12:07:54 +00:00
Tyler Yust
d029ceab1c feat: enhance BlueBubbles media and message handling by adding reply context support and improving outbound message ID tracking 2026-01-20 12:07:54 +00:00
Tyler Yust
c331bdc27d feat: refactor BlueBubbles media handling by introducing a dedicated media send function and optimizing message processing for media attachments 2026-01-20 12:07:54 +00:00
Tyler Yust
b0b42b4e14 feat: improve BlueBubbles message processing by adding reply context formatting and enhancing message ID extraction from responses 2026-01-20 12:07:54 +00:00
Tyler Yust
e5514d4854 feat: implement reply context handling in BlueBubbles messaging, enhancing message formatting and metadata resolution 2026-01-20 12:07:54 +00:00
Tyler Yust
20bc89d96c feat: enhance BlueBubbles messaging targets by adding support for UUID and hex chat identifiers, improving normalization and parsing functions 2026-01-20 12:07:54 +00:00
Tyler Yust
199fef2a5e feat: enhance BlueBubbles group message handling by adding account-specific logging and improving typing signal conditions 2026-01-20 12:07:54 +00:00
Tyler Yust
d9a2ac7e72 feat: enhance BlueBubbles functionality by implementing macOS version checks for message editing and improving server info caching 2026-01-20 12:07:54 +00:00
Tyler Yust
a16934b2ab feat: update BlueBubbles documentation and code to clarify group icon handling and normalize chat identifiers 2026-01-20 12:07:54 +00:00
Tyler Yust
14a072f5fa feat: add support for setting group icons in BlueBubbles, enhancing group management capabilities 2026-01-20 12:07:54 +00:00
Tyler Yust
574b848863 feat: enhance BlueBubbles message actions with support for message editing, reply metadata, and improved effect handling 2026-01-20 12:07:54 +00:00
Tyler Yust
2e6c58bf75 feat: improve BlueBubbles message action error handling and enhance channel action descriptions 2026-01-20 12:07:54 +00:00
Tyler Yust
a5d89e6eb1 feat: enhance BlueBubbles channel integration with new messaging target normalization and typing indicator improvements 2026-01-20 12:07:54 +00:00
Tyler Yust
61907ddf3e feat: add new channel capabilities for editing, unsending, replying, effects, and group management 2026-01-20 12:07:54 +00:00
Tyler Yust
1eab8fa9b0 Step 5 + Review 2026-01-20 12:07:54 +00:00
Tyler Yust
2cf444be02 Step 4 (Needs Review) 2026-01-20 12:07:54 +00:00
Tyler Yust
7870ce8177 Step 3 + Review 2026-01-20 12:07:54 +00:00
Tyler Yust
e9d691d472 (Step 2) Phase 2 & 3 Complete + Reviewed 2026-01-20 12:07:54 +00:00
Tyler Yust
ac2fcfe96a Phase 0 + Review 2026-01-20 12:07:54 +00:00
Peter Steinberger
627fa3083b Merge pull request #1298 from sibbl/matrix-with-e2ee-support
rewrite(matrix): integration with end to end encryption support
2026-01-20 12:04:45 +00:00
Peter Steinberger
e4877656ca fix: add path import for shell utils (#1298) (thanks @sibbl) 2026-01-20 11:59:36 +00:00
Peter Steinberger
d91f0ceeb3 fix: polish matrix e2ee storage (#1298) (thanks @sibbl) 2026-01-20 11:59:36 +00:00
Sebastian Schubotz
9b71382efb rewrite(matrix): use matrix-bot-sdk as base to enable e2ee encryption, strictly follow location + typing + group concepts, fix room bugs 2026-01-20 11:59:11 +00:00
Peter Steinberger
dd82d32d85 Merge pull request #1292 from bradleypriest/pr/chat-thinking-tool
ui(chat): separate tool/thinking output and add toggle
2026-01-20 11:57:21 +00:00
Peter Steinberger
5a42f7cabd fix: align bird skill metadata and flags (#1302) (thanks @odysseus0) 2026-01-20 11:55:14 +00:00
Peter Steinberger
f2c25c5f40 Merge pull request #1302 from odysseus0/docs/bird-skill-update
docs(bird): update skill for v0.5-0.8 features
2026-01-20 11:53:56 +00:00
Doug von Kohorn
528524e4c7 fix(session): preserve command argument casing 2026-01-20 12:53:45 +01:00
Peter Steinberger
7e08de4a5f fix: add nextcloud talk manifest (#1297) (thanks @ysqander) 2026-01-20 11:38:11 +00:00
Bradley Priest
c9d02f0132 ui(chat): separate tool/thinking output and add toggle
- Render assistant reasoning as a distinct block (not merged into message text).\n- Detect tool-like messages reliably and style them separately.\n- Add a "🧠" toggle to hide/show tool + thinking output, persisted in UI settings.
2026-01-20 21:07:29 +13:00
George Zhang
0bd99717be docs(bird): update skill for v0.5-0.8 features
- Add 18 missing commands (home, news, lists, engagement, etc.)
- Document pagination, media uploads, output options
- Add config file format and library usage
- Update posting advice (engagement actions now work)
- Add troubleshooting section
2026-01-20 03:49:40 +08:00
Sebastian
154c49511c Changelog: drop unrelated gateway fix 2026-01-19 13:19:09 -05:00
Sebastian
34462b3221 Config: allow Perplexity web_search provider 2026-01-19 13:03:59 -05:00
tsu
0372bdf6fe fix: add enabled property to groupConfigSchema for improved configuration 2026-01-19 20:25:17 +07:00
tsu
cd8309cc31 chore: simplify user parsing logic in probeZalouser function 2026-01-19 19:18:04 +07:00
tsu
5d9a5b7958 feat: implement zalouser channel plugin with configuration and status monitoring 2026-01-19 14:26:16 +07: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
1283 changed files with 100248 additions and 16809 deletions

3
.gitignore vendored
View File

@@ -66,3 +66,6 @@ apps/ios/*.mobileprovision
IDENTITY.md
USER.md
.tgz
# local tooling
.serena/

2
.npmrc
View File

@@ -1 +1 @@
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty,@matrix-org/matrix-sdk-crypto-nodejs

1
.serena/.gitignore vendored
View File

@@ -1 +0,0 @@
/cache

Binary file not shown.

Binary file not shown.

View File

@@ -1,87 +0,0 @@
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp csharp_omnisharp
# dart elixir elm erlang fortran fsharp
# go groovy haskell java julia kotlin
# lua markdown nix pascal perl php
# powershell python python_jedi r rego ruby
# ruby_solargraph rust scala swift terraform toml
# typescript typescript_vts yaml zig
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal / Lazarus, use pascal
# Special requirements:
# - csharp: Requires the presence of a .sln file in the project folder.
# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus.
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- typescript
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "clawdbot"
included_optional_tools: []

View File

@@ -29,6 +29,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`
@@ -41,6 +42,11 @@
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
- Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` for CLI command, package/binary, paths, and config keys.
## Release Channels (Naming)
- stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`.
- beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app).
- dev: moving head on `main` (no tag; git checkout main).
## Testing Guidelines
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
@@ -95,7 +101,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.

View File

@@ -2,244 +2,269 @@
Docs: https://docs.clawd.bot
## 2026.1.20-1
## 2026.1.22
### Changes
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
- 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.
### Fixes
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
## 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.
## 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
- 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
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.
- 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.
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
- Signal: add typing indicators and DM read receipts via signal-cli.
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
### Breaking
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents dont have to constantly convert.
### 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: surface concrete API error details instead of generic AI service errors.
- 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) 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: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
- Providers: improve GitHub Copilot integration (enterprise support, base URL, and auth flow alignment).
## 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
- 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.
- 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.
### 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
### 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)
- 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
- 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`.
- 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.
### 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.
## 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

@@ -71,6 +71,15 @@ clawdbot agent --message "Ship checklist" --thinking high
Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`).
## Development channels
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`), npm dist-tag `latest`.
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing).
- **dev**: moving head of `main`, npm dist-tag `dev` (when published).
Switch channels (git + npm): `clawdbot update --channel stable|beta|dev`.
Details: [Development channels](https://docs.clawd.bot/install/development-channels).
## From source (development)
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
@@ -462,35 +471,35 @@ 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/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/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/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/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/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/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/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/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/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></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/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/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=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/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/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/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/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/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/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

@@ -1,13 +1,13 @@
{
"originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748",
"originHash" : "c0677e232394b5f6b0191b6dbb5bae553d55264f65ae725cd03a8ffdfda9cdd3",
"pins" : [
{
"identity" : "elevenlabskit",
"identity" : "commander",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit",
"location" : "https://github.com/steipete/Commander.git",
"state" : {
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
"version" : "0.1.0"
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
"version" : "0.2.1"
}
},
{

View File

@@ -2,6 +2,270 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdbot</title>
<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>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> 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>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.21/Clawdbot-2026.1.21.zip" length="22284796" type="application/octet-stream" sparkle:edSignature="pXji4NMA/cu35iMxln385d6LnsT4yIZtFtFiR7sIimKeSC2CsyeWzzSD0EhJsN98PdSoy69iEFZt4I2ZtNCECg=="/>
</item>
<item>
<title>2026.1.21</title>
<pubDate>Wed, 21 Jan 2026 08:18:22 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>7116</sparkle:version>
<sparkle:shortVersionString>2026.1.21</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.21</h2>
<h3>Changes</h3>
<ul>
<li>Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui</li>
<li>Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui</li>
<li>TUI: add syntax highlighting for code blocks. (#1200) https://docs.clawd.bot/tui</li>
<li>TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) https://docs.clawd.bot/tui</li>
<li>TUI: add a searchable model picker for quicker model selection. (#1198) https://docs.clawd.bot/tui</li>
<li>TUI: add input history (up/down) for submitted messages. (#1348) https://docs.clawd.bot/tui</li>
<li>ACP: add <code>clawdbot acp</code> for IDE integrations. https://docs.clawd.bot/cli/acp</li>
<li>ACP: add <code>clawdbot acp client</code> interactive harness for debugging. https://docs.clawd.bot/cli/acp</li>
<li>Skills: add download installs with OS-filtered options. https://docs.clawd.bot/tools/skills</li>
<li>Skills: add the local sherpa-onnx-tts skill. https://docs.clawd.bot/tools/skills</li>
<li>Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: add SQLite embedding cache to speed up reindexing and frequent updates. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: add OpenAI batch indexing for embeddings when configured. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: enable OpenAI batch indexing by default for OpenAI embeddings. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). https://docs.clawd.bot/concepts/memory</li>
<li>Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: add <code>--verbose</code> logging for memory status + batch indexing details. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.clawd.bot/concepts/memory</li>
<li>Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) https://docs.clawd.bot/tools/browser</li>
<li>Nostr: add the Nostr channel plugin with profile management + onboarding defaults. (#1323) https://docs.clawd.bot/channels/nostr</li>
<li>Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) https://docs.clawd.bot/channels/matrix</li>
<li>Slack: add HTTP webhook mode via Bolt HTTP receiver. (#1143) https://docs.clawd.bot/channels/slack</li>
<li>Telegram: enrich forwarded-message context with normalized origin details + legacy fallback. (#1090) https://docs.clawd.bot/channels/telegram</li>
<li>Discord: fall back to <code>/skill</code> when native command limits are exceeded. (#1287)</li>
<li>Discord: expose <code>/skill</code> globally. (#1287)</li>
<li>Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) https://docs.clawd.bot/plugins/zalouser</li>
<li>Plugins: require manifest-embedded config schemas with preflight validation warnings. (#1272) https://docs.clawd.bot/plugins/manifest</li>
<li>Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest</li>
<li>Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest</li>
<li>Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui</li>
<li>Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools</li>
<li>Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles</li>
<li>Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.</li>
<li>Plugins: migrate the Zalo plugin to the shared plugin SDK runtime. https://docs.clawd.bot/channels/zalo</li>
<li>Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime. https://docs.clawd.bot/plugins/zalouser</li>
<li>Plugins: allow optional agent tools with explicit allowlists and add the plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools</li>
<li>Plugins: auto-enable bundled channel/provider plugins when configuration is present.</li>
<li>Plugins: sync plugin sources on channel switches and update npm-installed plugins during <code>clawdbot update</code>.</li>
<li>Plugins: share npm plugin update logic between <code>clawdbot update</code> and <code>clawdbot plugins update</code>.</li>
<li>Gateway/API: add <code>/v1/responses</code> (OpenResponses) with item-based input + semantic streaming events. (#1229)</li>
<li>Gateway/API: expand <code>/v1/responses</code> to support file/image inputs, tool_choice, usage, and output limits. (#1229)</li>
<li>Usage: add <code>/usage cost</code> summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs</li>
<li>Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.clawd.bot/cli/security</li>
<li>Exec: add host/security/ask routing for gateway + node exec. https://docs.clawd.bot/tools/exec</li>
<li>Exec: add <code>/exec</code> directive for per-session exec defaults (host/security/ask/node). https://docs.clawd.bot/tools/exec</li>
<li>Exec approvals: migrate approvals to <code>~/.clawdbot/exec-approvals.json</code> with per-agent allowlists + skill auto-allow toggle, and add approvals UI + node exec lifecycle events. https://docs.clawd.bot/tools/exec-approvals</li>
<li>Nodes: add headless node host (<code>clawdbot node start</code>) for <code>system.run</code>/<code>system.which</code>. https://docs.clawd.bot/cli/node</li>
<li>Nodes: add node daemon service install/status/start/stop/restart. https://docs.clawd.bot/cli/node</li>
<li>Bridge: add <code>skills.bins</code> RPC to support node host auto-allow skill bins.</li>
<li>Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) https://docs.clawd.bot/concepts/session</li>
<li>Sessions: allow <code>sessions_spawn</code> to override thinking level for sub-agent runs. https://docs.clawd.bot/tools/subagents</li>
<li>Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers. https://docs.clawd.bot/concepts/groups</li>
<li>Models: add Qwen Portal OAuth provider support. (#1120) https://docs.clawd.bot/providers/qwen</li>
<li>Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. https://docs.clawd.bot/start/onboarding</li>
<li>Docs: clarify allowlist input types and onboarding behavior for messaging channels. https://docs.clawd.bot/start/onboarding</li>
<li>Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.clawd.bot/platforms/android</li>
<li>Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.clawd.bot/bedrock</li>
<li>Docs: clarify WhatsApp voice notes. https://docs.clawd.bot/channels/whatsapp</li>
<li>Docs: clarify Windows WSL portproxy LAN access notes. https://docs.clawd.bot/platforms/windows</li>
<li>Docs: refresh bird skill install metadata and usage notes. (#1302) https://docs.clawd.bot/tools/browser-login</li>
<li>Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.</li>
<li>Agents: clarify node_modules read-only guidance in agent instructions.</li>
<li>Config: stamp last-touched metadata on write and warn if the config is newer than the running build.</li>
<li>macOS: hide usage section when usage is unavailable instead of showing provider errors.</li>
<li>Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.</li>
<li>Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.</li>
<li>Android: remove legacy bridge transport code now that nodes use the gateway protocol.</li>
<li>Android: bump okhttp + dnsjava to satisfy lint dependency checks.</li>
<li>Build: update workspace + core/plugin deps.</li>
<li>Build: use tsgo for dev/watch builds by default (opt out with <code>CLAWDBOT_TS_COMPILER=tsc</code>).</li>
<li>Repo: remove the Peekaboo git submodule now that the SPM release is used.</li>
<li>macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.</li>
<li>macOS: stop syncing Peekaboo in postinstall.</li>
<li>Swabble: use the tagged Commander Swift package release.</li>
</ul>
<h3>Breaking</h3>
<ul>
<li><strong>BREAKING:</strong> Reject invalid/unknown config entries and refuse to start the gateway for safety. Run <code>clawdbot doctor --fix</code> to repair, then update plugins (<code>clawdbot plugins update</code>) if you use any.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Discovery: shorten Bonjour DNS-SD service type to <code>_clawdbot-gw._tcp</code> and update discovery clients/docs.</li>
<li>Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.</li>
<li>Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244)</li>
<li>Diagnostics: gate heartbeat/webhook logging. (#1244)</li>
<li>Gateway: strip inbound envelope headers from chat history messages to keep clients clean.</li>
<li>Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.</li>
<li>Gateway: allow mobile node client ids for iOS + Android handshake validation. (#1354)</li>
<li>Gateway: clarify connect/validation errors for gateway params. (#1347)</li>
<li>Gateway: preserve restart wake routing + thread replies across restarts. (#1337)</li>
<li>Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.</li>
<li>Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.</li>
<li>Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)</li>
<li>Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241)</li>
<li>Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)</li>
<li>Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. (#1137)</li>
<li>Agents: sanitize oversized image payloads before send and surface image-dimension errors.</li>
<li>Sessions: fall back to session labels when listing display names. (#1124)</li>
<li>Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)</li>
<li>Config: log invalid config issues once per run and keep invalid-config errors stackless.</li>
<li>Config: allow Perplexity as a web_search provider in config validation. (#1230)</li>
<li>Config: allow custom fields under <code>skills.entries.<name>.config</code> for skill credentials/config. (#1226)</li>
<li>Doctor: clarify plugin auto-enable hint text in the startup banner.</li>
<li>Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)</li>
<li>Docs: make docs:list fail fast with a clear error if the docs directory is missing.</li>
<li>Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297)</li>
<li>Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.</li>
<li>CLI: preserve cron delivery settings when editing message payloads. (#1322)</li>
<li>CLI: keep <code>clawdbot logs</code> output resilient to broken pipes while preserving progress output.</li>
<li>CLI: avoid duplicating --profile/--dev flags when formatting commands.</li>
<li>CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207)</li>
<li>CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195)</li>
<li>CLI: skip runner rebuilds when dist is fresh. (#1231)</li>
<li>CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.</li>
<li>Status: route native <code>/status</code> to the active agent so model selection reflects the correct profile. (#1301)</li>
<li>Status: show both usage windows with reset hints when usage data is available. (#1101)</li>
<li>UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315)</li>
<li>UI: preserve ordered list numbering in chat markdown. (#1341)</li>
<li>UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342)</li>
<li>UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283)</li>
<li>UI: enable shell mode for sync Windows spawns to avoid <code>pnpm ui:build</code> EINVAL. (#1212)</li>
<li>TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202)</li>
<li>TUI: align custom editor initialization with the latest pi-tui API. (#1298)</li>
<li>TUI: show generic empty-state text for searchable pickers. (#1201)</li>
<li>TUI: highlight model search matches and stabilize search ordering.</li>
<li>Configure: hide OpenRouter auto routing model from the model picker. (#1182)</li>
<li>Memory: show total file counts + scan issues in <code>clawdbot memory status</code>.</li>
<li>Memory: fall back to non-batch embeddings after repeated batch failures.</li>
<li>Memory: apply OpenAI batch defaults even without explicit remote config.</li>
<li>Memory: index atomically so failed reindex preserves the previous memory database. (#1151)</li>
<li>Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)</li>
<li>Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.</li>
<li>Memory: parallelize embedding indexing with rate-limit retries.</li>
<li>Memory: split overly long lines to keep embeddings under token limits.</li>
<li>Memory: skip empty chunks to avoid invalid embedding inputs.</li>
<li>Memory: split embedding batches to avoid OpenAI token limits during indexing.</li>
<li>Memory: probe sqlite-vec availability in <code>clawdbot memory status</code>.</li>
<li>Exec approvals: enforce allowlist when ask is off.</li>
<li>Exec approvals: prefer raw command for node approvals/events.</li>
<li>Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.</li>
<li>Tools: return a companion-app-required message when node exec is requested with no paired node.</li>
<li>Tools: return a companion-app-required message when <code>system.run</code> is requested without a supporting node.</li>
<li>Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).</li>
<li>Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297)</li>
<li>Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)</li>
<li>Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147)</li>
<li>Discord: make resolve warnings avoid raw JSON payloads on rate limits.</li>
<li>Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)</li>
<li>Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.</li>
<li>Discord: only emit slow listener warnings after 30s.</li>
<li>Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123)</li>
<li>Telegram: honor pairing allowlists for native slash commands.</li>
<li>Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118)</li>
<li>Slack: resolve Bolt import interop for Bun + Node. (#1191)</li>
<li>Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).</li>
<li>Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346)</li>
<li>Browser: register AI snapshot refs for act commands. (#1282)</li>
<li>Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)</li>
<li>Anthropic: default API prompt caching to 1h with configurable TTL override.</li>
<li>Anthropic: ignore TTL for OAuth.</li>
<li>Auth profiles: keep auto-pinned preference while allowing rotation on failover. (#1138)</li>
<li>Auth profiles: user pins stay locked. (#1138)</li>
<li>Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332)</li>
<li>Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.</li>
<li>Tests: stabilize plugin SDK resolution and embedded agent timeouts.</li>
<li>Windows: install gateway scheduled task as the current user.</li>
<li>Windows: show friendly guidance instead of failing on access denied.</li>
<li>macOS: load menu session previews asynchronously so items populate while the menu is open.</li>
<li>macOS: use label colors for session preview text so previews render in menu subviews.</li>
<li>macOS: suppress usage error text in the menubar cost view.</li>
<li>macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166)</li>
<li>macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)</li>
<li>macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)</li>
<li>Daemon: include HOME in service environments to avoid missing HOME errors. (#1214)</li>
</ul>
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
</item>
<item>
<title>2026.1.16-2</title>
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
@@ -18,258 +282,5 @@
]]></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=="/>
</item>
<item>
<title>2026.1.15</title>
<pubDate>Fri, 16 Jan 2026 10:31:53 +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:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.15</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>
</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>
</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>
</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=="/>
</item>
</channel>
</rss>

View File

@@ -1,6 +1,6 @@
## Clawdbot Node (Android) (internal)
Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gateway._tcp`) and exposes **Canvas + Chat + Camera**.
Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gw._tcp`) and exposes **Canvas + Chat + Camera**.
Notes:
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).

View File

@@ -21,8 +21,8 @@ android {
applicationId = "com.clawdbot.android"
minSdk = 31
targetSdk = 36
versionCode = 202601114
versionName = "2026.1.11-4"
versionCode = 202601210
versionName = "2026.1.21"
}
buildTypes {

View File

@@ -12,6 +12,7 @@ import com.clawdbot.android.chat.ChatMessage
import com.clawdbot.android.chat.ChatPendingToolCall
import com.clawdbot.android.chat.ChatSessionEntry
import com.clawdbot.android.chat.OutgoingAttachment
import com.clawdbot.android.gateway.DeviceAuthStore
import com.clawdbot.android.gateway.DeviceIdentityStore
import com.clawdbot.android.gateway.GatewayClientInfo
import com.clawdbot.android.gateway.GatewayConnectOptions
@@ -62,6 +63,7 @@ class NodeRuntime(context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val prefs = SecurePrefs(appContext)
private val deviceAuthStore = DeviceAuthStore(prefs)
val canvas = CanvasController()
val camera = CameraCaptureManager(appContext)
val location = LocationCaptureManager(appContext)
@@ -153,6 +155,7 @@ class NodeRuntime(context: Context) {
GatewaySession(
scope = scope,
identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { name, remote, mainSessionKey ->
operatorConnected = true
operatorStatusText = "Connected"
@@ -188,6 +191,7 @@ class NodeRuntime(context: Context) {
GatewaySession(
scope = scope,
identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ ->
nodeConnected = true
nodeStatusText = "Connected"
@@ -525,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

@@ -189,6 +189,18 @@ class SecurePrefs(context: Context) {
prefs.edit { putString(key, fingerprint.trim()) }
}
fun getString(key: String): String? {
return prefs.getString(key, null)
}
fun putString(key: String, value: String) {
prefs.edit { putString(key, value) }
}
fun remove(key: String) {
prefs.edit { remove(key) }
}
private fun loadOrCreateInstanceId(): String {
val existing = prefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing

View File

@@ -0,0 +1,26 @@
package com.clawdbot.android.gateway
import com.clawdbot.android.SecurePrefs
class DeviceAuthStore(private val prefs: SecurePrefs) {
fun loadToken(deviceId: String, role: String): String? {
val key = tokenKey(deviceId, role)
return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() }
}
fun saveToken(deviceId: String, role: String, token: String) {
val key = tokenKey(deviceId, role)
prefs.putString(key, token.trim())
}
fun clearToken(deviceId: String, role: String) {
val key = tokenKey(deviceId, role)
prefs.remove(key)
}
private fun tokenKey(deviceId: String, role: String): String {
val normalizedDevice = deviceId.trim().lowercase()
val normalizedRole = role.trim().lowercase()
return "gateway.deviceToken.$normalizedDevice.$normalizedRole"
}
}

View File

@@ -51,7 +51,7 @@ class GatewayDiscovery(
private val nsd = context.getSystemService(NsdManager::class.java)
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
private val dns = DnsResolver.getInstance()
private val serviceType = "_clawdbot-gateway._tcp."
private val serviceType = "_clawdbot-gw._tcp."
private val wideAreaDomain = "clawdbot.internal."
private val logTag = "Clawdbot/GatewayDiscovery"

View File

@@ -55,6 +55,7 @@ data class GatewayConnectOptions(
class GatewaySession(
private val scope: CoroutineScope,
private val identityStore: DeviceIdentityStore,
private val deviceAuthStore: DeviceAuthStore,
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
private val onDisconnected: (message: String) -> Unit,
private val onEvent: (event: String, payloadJson: String?) -> Unit,
@@ -177,6 +178,7 @@ class GatewaySession(
private val connectDeferred = CompletableDeferred<Unit>()
private val closedDeferred = CompletableDeferred<Unit>()
private val isClosed = AtomicBoolean(false)
private val connectNonceDeferred = CompletableDeferred<String?>()
private val client: OkHttpClient = buildClient()
private var socket: WebSocket? = null
private val loggerTag = "ClawdbotGateway"
@@ -253,7 +255,8 @@ class GatewaySession(
override fun onOpen(webSocket: WebSocket, response: Response) {
scope.launch {
try {
sendConnect()
val nonce = awaitConnectNonce()
sendConnect(nonce)
} catch (err: Throwable) {
connectDeferred.completeExceptionally(err)
closeQuietly()
@@ -288,16 +291,30 @@ class GatewaySession(
}
}
private suspend fun sendConnect() {
val payload = buildConnectParams()
private suspend fun sendConnect(connectNonce: String?) {
val identity = identityStore.loadOrCreate()
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
val trimmedToken = token?.trim().orEmpty()
val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken
val canFallbackToShared = !storedToken.isNullOrBlank() && trimmedToken.isNotBlank()
val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim())
val res = request("connect", payload, timeoutMs = 8_000)
if (!res.ok) {
val msg = res.error?.message ?: "connect failed"
if (canFallbackToShared) {
deviceAuthStore.clearToken(identity.deviceId, options.role)
}
throw IllegalStateException(msg)
}
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
val authObj = obj["auth"].asObjectOrNull()
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
if (!deviceToken.isNullOrBlank()) {
deviceAuthStore.saveToken(identity.deviceId, authRole, deviceToken)
}
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint)
val sessionDefaults =
@@ -308,7 +325,12 @@ class GatewaySession(
connectDeferred.complete(Unit)
}
private fun buildConnectParams(): JsonObject {
private fun buildConnectParams(
identity: DeviceIdentity,
connectNonce: String?,
authToken: String,
authPassword: String?,
): JsonObject {
val client = options.client
val locale = Locale.getDefault().toLanguageTag()
val clientObj =
@@ -323,22 +345,20 @@ class GatewaySession(
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
}
val authToken = token?.trim().orEmpty()
val authPassword = password?.trim().orEmpty()
val password = authPassword?.trim().orEmpty()
val authJson =
when {
authToken.isNotEmpty() ->
buildJsonObject {
put("token", JsonPrimitive(authToken))
}
authPassword.isNotEmpty() ->
password.isNotEmpty() ->
buildJsonObject {
put("password", JsonPrimitive(authPassword))
put("password", JsonPrimitive(password))
}
else -> null
}
val identity = identityStore.loadOrCreate()
val signedAtMs = System.currentTimeMillis()
val payload =
buildDeviceAuthPayload(
@@ -349,6 +369,7 @@ class GatewaySession(
scopes = options.scopes,
signedAtMs = signedAtMs,
token = if (authToken.isNotEmpty()) authToken else null,
nonce = connectNonce,
)
val signature = identityStore.signPayload(payload, identity)
val publicKey = identityStore.publicKeyBase64Url(identity)
@@ -359,6 +380,9 @@ class GatewaySession(
put("publicKey", JsonPrimitive(publicKey))
put("signature", JsonPrimitive(signature))
put("signedAt", JsonPrimitive(signedAtMs))
if (!connectNonce.isNullOrBlank()) {
put("nonce", JsonPrimitive(connectNonce))
}
}
} else {
null
@@ -416,6 +440,13 @@ class GatewaySession(
val event = frame["event"].asStringOrNull() ?: return
val payloadJson =
frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull()
if (event == "connect.challenge") {
val nonce = extractConnectNonce(payloadJson)
if (!connectNonceDeferred.isCompleted) {
connectNonceDeferred.complete(nonce)
}
return
}
if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) {
handleInvokeEvent(payloadJson)
return
@@ -423,6 +454,21 @@ class GatewaySession(
onEvent(event, payloadJson)
}
private suspend fun awaitConnectNonce(): String? {
if (isLoopbackHost(endpoint.host)) return null
return try {
withTimeout(2_000) { connectNonceDeferred.await() }
} catch (_: Throwable) {
null
}
}
private fun extractConnectNonce(payloadJson: String?): String? {
if (payloadJson.isNullOrBlank()) return null
val obj = parseJsonOrNull(payloadJson)?.asObjectOrNull() ?: return null
return obj["nonce"].asStringOrNull()
}
private fun handleInvokeEvent(payloadJson: String) {
val payload =
try {
@@ -544,19 +590,26 @@ class GatewaySession(
scopes: List<String>,
signedAtMs: Long,
token: String?,
nonce: String?,
): String {
val scopeString = scopes.joinToString(",")
val authToken = token.orEmpty()
return listOf(
"v1",
deviceId,
clientId,
clientMode,
role,
scopeString,
signedAtMs.toString(),
authToken,
).joinToString("|")
val version = if (nonce.isNullOrBlank()) "v1" else "v2"
val parts =
mutableListOf(
version,
deviceId,
clientId,
clientMode,
role,
scopeString,
signedAtMs.toString(),
authToken,
)
if (!nonce.isNullOrBlank()) {
parts.add(nonce)
}
return parts.joinToString("|")
}
private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? {

View File

@@ -84,5 +84,7 @@ private fun sha256Hex(data: ByteArray): String {
}
private fun normalizeFingerprint(raw: String): String {
return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
val stripped = raw.trim()
.replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "")
return stripped.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
}

View File

@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.11-4</string>
<string>2026.1.21</string>
<key>CFBundleVersion</key>
<string>202601113</string>
<string>20260121</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
@@ -29,7 +29,7 @@
</dict>
<key>NSBonjourServices</key>
<array>
<string>_clawdbot-gateway._tcp</string>
<string>_clawdbot-gw._tcp</string>
</array>
<key>NSCameraUsageDescription</key>
<string>Clawdbot can capture photos or short video clips when requested via the gateway.</string>

View File

@@ -132,6 +132,12 @@ final class TalkModeManager: NSObject {
}
private func startRecognition() throws {
#if targetEnvironment(simulator)
throw NSError(domain: "TalkMode", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
])
#endif
self.stopRecognition()
self.speechRecognizer = SFSpeechRecognizer()
guard let recognizer = self.speechRecognizer else {
@@ -146,6 +152,11 @@ final class TalkModeManager: NSObject {
let input = self.audioEngine.inputNode
let format = input.outputFormat(forBus: 0)
guard format.sampleRate > 0, format.channelCount > 0 else {
throw NSError(domain: "TalkMode", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Invalid audio input format",
])
}
input.removeTap(onBus: 0)
let tapBlock = Self.makeAudioTapAppendCallback(request: request)
input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock)

View File

@@ -1,15 +1,13 @@
Sources/Bridge/BridgeClient.swift
Sources/Bridge/BridgeConnectionController.swift
Sources/Bridge/BridgeDiscoveryDebugLogView.swift
Sources/Bridge/BridgeDiscoveryModel.swift
Sources/Bridge/BridgeEndpointID.swift
Sources/Bridge/BridgeSession.swift
Sources/Bridge/BridgeSettingsStore.swift
Sources/Bridge/KeychainStore.swift
Sources/Gateway/GatewayConnectionController.swift
Sources/Gateway/GatewayDiscoveryDebugLogView.swift
Sources/Gateway/GatewayDiscoveryModel.swift
Sources/Gateway/GatewaySettingsStore.swift
Sources/Gateway/KeychainStore.swift
Sources/Camera/CameraController.swift
Sources/Chat/ChatSheet.swift
Sources/Chat/IOSBridgeChatTransport.swift
Sources/Chat/IOSGatewayChatTransport.swift
Sources/ClawdbotApp.swift
Sources/Location/LocationService.swift
Sources/Model/NodeAppModel.swift
Sources/RootCanvas.swift
Sources/RootTabs.swift
@@ -17,6 +15,7 @@ Sources/Screen/ScreenController.swift
Sources/Screen/ScreenRecordService.swift
Sources/Screen/ScreenTab.swift
Sources/Screen/ScreenWebView.swift
Sources/SessionKey.swift
Sources/Settings/SettingsNetworkingHelpers.swift
Sources/Settings/SettingsTab.swift
Sources/Settings/VoiceWakeWordsSettingsView.swift

View File

@@ -7,11 +7,11 @@ import Testing
@Test func stableIDForServiceDecodesAndNormalizesName() {
let endpoint = NWEndpoint.service(
name: "Clawdbot\\032Gateway \\032 Node\n",
type: "_clawdbot-gateway._tcp",
type: "_clawdbot-gw._tcp",
domain: "local.",
interface: nil)
#expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gateway._tcp|local.|Clawdbot Gateway Node")
#expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gw._tcp|local.|Clawdbot Gateway Node")
}
@Test func stableIDForNonServiceUsesEndpointDescription() {
@@ -22,7 +22,7 @@ import Testing
@Test func prettyDescriptionDecodesBonjourEscapes() {
let endpoint = NWEndpoint.service(
name: "Clawdbot\\032Gateway",
type: "_clawdbot-gateway._tcp",
type: "_clawdbot-gw._tcp",
domain: "local.",
interface: nil)

View File

@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.11-4</string>
<string>2026.1.21</string>
<key>CFBundleVersion</key>
<string>202601113</string>
<string>20260121</string>
</dict>
</plist>

View File

@@ -81,8 +81,8 @@ targets:
properties:
CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.1.9"
CFBundleVersion: "20260109"
CFBundleShortVersionString: "2026.1.21"
CFBundleVersion: "20260121"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -92,7 +92,7 @@ targets:
NSAppTransportSecurity:
NSAllowsArbitraryLoadsInWebContent: true
NSBonjourServices:
- _clawdbot-gateway._tcp
- _clawdbot-gw._tcp
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the gateway.
NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing.
NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always.
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: ClawdbotTests
CFBundleShortVersionString: "2026.1.9"
CFBundleVersion: "20260109"
CFBundleShortVersionString: "2026.1.21"
CFBundleVersion: "20260121"

View File

@@ -1,5 +1,5 @@
{
"originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438",
"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

@@ -12,8 +12,7 @@ let package = Package(
.library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]),
.library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]),
.executable(name: "Clawdbot", targets: ["Clawdbot"]),
.executable(name: "clawdbot-mac-discovery", targets: ["ClawdbotDiscoveryCLI"]),
.executable(name: "clawdbot-mac-wizard", targets: ["ClawdbotWizardCLI"]),
.executable(name: "clawdbot-mac", targets: ["ClawdbotMacCLI"]),
],
dependencies: [
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
@@ -67,20 +66,13 @@ let package = Package(
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "ClawdbotDiscoveryCLI",
name: "ClawdbotMacCLI",
dependencies: [
"ClawdbotDiscovery",
],
path: "Sources/ClawdbotDiscoveryCLI",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "ClawdbotWizardCLI",
dependencies: [
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
],
path: "Sources/ClawdbotWizardCLI",
path: "Sources/ClawdbotMacCLI",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),

View File

@@ -81,7 +81,7 @@ private struct EventRow: View {
return f.string(from: date)
}
private func prettyJSON(_ dict: [String: AnyCodable]) -> String? {
private func prettyJSON(_ dict: [String: ClawdbotProtocol.AnyCodable]) -> String? {
let normalized = dict.mapValues { $0.value }
guard JSONSerialization.isValidJSONObject(normalized),
let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.prettyPrinted]),
@@ -98,7 +98,10 @@ struct AgentEventsWindow_Previews: PreviewProvider {
seq: 1,
stream: "tool",
ts: Date().timeIntervalSince1970 * 1000,
data: ["phase": AnyCodable("start"), "name": AnyCodable("bash")],
data: [
"phase": ClawdbotProtocol.AnyCodable("start"),
"name": ClawdbotProtocol.AnyCodable("bash"),
],
summary: nil)
AgentEventStore.shared.append(sample)
return AgentEventsWindow()

View File

@@ -1,6 +1,11 @@
import ClawdbotKit
import ClawdbotProtocol
import Foundation
// Prefer the ClawdbotKit wrapper to keep gateway request payloads consistent.
typealias AnyCodable = ClawdbotKit.AnyCodable
typealias InstanceIdentity = ClawdbotKit.InstanceIdentity
extension AnyCodable {
var stringValue: String? { self.value as? String }
var boolValue: Bool? { self.value as? Bool }
@@ -20,3 +25,23 @@ extension AnyCodable {
}
}
}
extension ClawdbotProtocol.AnyCodable {
var stringValue: String? { self.value as? String }
var boolValue: Bool? { self.value as? Bool }
var intValue: Int? { self.value as? Int }
var doubleValue: Double? { self.value as? Double }
var dictionaryValue: [String: ClawdbotProtocol.AnyCodable]? { self.value as? [String: ClawdbotProtocol.AnyCodable] }
var arrayValue: [ClawdbotProtocol.AnyCodable]? { self.value as? [ClawdbotProtocol.AnyCodable] }
var foundationValue: Any {
switch self.value {
case let dict as [String: ClawdbotProtocol.AnyCodable]:
dict.mapValues { $0.foundationValue }
case let array as [ClawdbotProtocol.AnyCodable]:
array.map(\.foundationValue)
default:
self.value
}
}
}

View File

@@ -426,34 +426,17 @@ extension ChannelsSettings {
}
private func resolveChannelTitle(_ id: String) -> String {
if let label = self.store.snapshot?.channelLabels[id], !label.isEmpty {
return label
}
let label = self.store.resolveChannelLabel(id)
if label != id { return label }
return id.prefix(1).uppercased() + id.dropFirst()
}
private func resolveChannelDetailTitle(_ id: String) -> String {
switch id {
case "whatsapp": "WhatsApp Web"
case "telegram": "Telegram Bot"
case "discord": "Discord Bot"
case "slack": "Slack Bot"
case "signal": "Signal REST"
case "imessage": "iMessage"
default: self.resolveChannelTitle(id)
}
self.store.resolveChannelDetailLabel(id)
}
private func resolveChannelSystemImage(_ id: String) -> String {
switch id {
case "whatsapp": "message"
case "telegram": "paperplane"
case "discord": "bubble.left.and.bubble.right"
case "slack": "number"
case "signal": "antenna.radiowaves.left.and.right"
case "imessage": "message.fill"
default: "message"
}
self.store.resolveChannelSystemImage(id)
}
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {

View File

@@ -153,9 +153,19 @@ struct ChannelsStatusSnapshot: Codable {
let application: AnyCodable?
}
struct ChannelUiMetaEntry: Codable {
let id: String
let label: String
let detailLabel: String
let systemImage: String?
}
let ts: Double
let channelOrder: [String]
let channelLabels: [String: String]
let channelDetailLabels: [String: String]?
let channelSystemImages: [String: String]?
let channelMeta: [ChannelUiMetaEntry]?
let channels: [String: AnyCodable]
let channelAccounts: [String: [ChannelAccountSnapshot]]
let channelDefaultAccountId: [String: String]
@@ -217,6 +227,47 @@ final class ChannelsStore {
var configRoot: [String: Any] = [:]
var configLoaded = false
func channelMetaEntry(_ id: String) -> ChannelsStatusSnapshot.ChannelUiMetaEntry? {
self.snapshot?.channelMeta?.first(where: { $0.id == id })
}
func resolveChannelLabel(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), !meta.label.isEmpty {
return meta.label
}
if let label = self.snapshot?.channelLabels[id], !label.isEmpty {
return label
}
return id
}
func resolveChannelDetailLabel(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), !meta.detailLabel.isEmpty {
return meta.detailLabel
}
if let detail = self.snapshot?.channelDetailLabels?[id], !detail.isEmpty {
return detail
}
return self.resolveChannelLabel(id)
}
func resolveChannelSystemImage(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), let symbol = meta.systemImage, !symbol.isEmpty {
return symbol
}
if let symbol = self.snapshot?.channelSystemImages?[id], !symbol.isEmpty {
return symbol
}
return "message"
}
func orderedChannelIds() -> [String] {
if let meta = self.snapshot?.channelMeta, !meta.isEmpty {
return meta.map(\.id)
}
return self.snapshot?.channelOrder ?? []
}
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview
}

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,19 @@ struct ConfigSettings: View {
private let isNixMode = ProcessInfo.processInfo.isNixMode
@Bindable var store: ChannelsStore
@State private var hasLoaded = false
@State private var activeSectionKey: String?
@State private var activeSubsection: SubsectionSelection?
init(store: ChannelsStore = .shared) {
self.store = store
}
var body: some View {
ScrollView {
self.content
HStack(spacing: 16) {
self.sidebar
self.detail
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.task {
guard !self.hasLoaded else { return }
guard !self.isPreview else { return }
@@ -22,42 +26,125 @@ struct ConfigSettings: View {
await self.store.loadConfigSchema()
await self.store.loadConfig()
}
.onAppear { self.ensureSelection() }
.onChange(of: self.store.configSchemaLoading) { _, loading in
if !loading { self.ensureSelection() }
}
}
}
extension ConfigSettings {
private var content: some View {
VStack(alignment: .leading, spacing: 16) {
self.header
if let status = self.store.configStatus {
Text(status)
.font(.callout)
.foregroundStyle(.secondary)
}
self.actionRow
Group {
if self.store.configSchemaLoading {
ProgressView().controlSize(.small)
} else if let schema = self.store.configSchema {
ConfigSchemaForm(store: self.store, schema: schema, path: [])
.disabled(self.isNixMode)
} else {
Text("Schema unavailable.")
private enum SubsectionSelection: Hashable {
case all
case key(String)
}
private struct ConfigSection: Identifiable {
let key: String
let label: String
let help: String?
let node: ConfigSchemaNode
var id: String { self.key }
}
private struct ConfigSubsection: Identifiable {
let key: String
let label: String
let help: String?
let node: ConfigSchemaNode
let path: ConfigPath
var id: String { self.key }
}
private var sections: [ConfigSection] {
guard let schema = self.store.configSchema else { return [] }
return self.resolveSections(schema)
}
private var activeSection: ConfigSection? {
self.sections.first { $0.key == self.activeSectionKey }
}
private var sidebar: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
if self.sections.isEmpty {
Text("No config sections available.")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 4)
} else {
ForEach(self.sections) { section in
self.sidebarRow(section)
}
}
}
if self.store.configDirty, !self.isNixMode {
Text("Unsaved changes")
.padding(.vertical, 10)
.padding(.horizontal, 10)
}
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor)))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private var detail: some View {
VStack(alignment: .leading, spacing: 16) {
if self.store.configSchemaLoading {
ProgressView().controlSize(.small)
} else if let section = self.activeSection {
self.sectionDetail(section)
} else if self.store.configSchema != nil {
self.emptyDetail
} else {
Text("Schema unavailable.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var emptyDetail: some View {
VStack(alignment: .leading, spacing: 8) {
self.header
Text("Select a config section to view settings.")
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 24)
.padding(.vertical, 18)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
private func sectionDetail(_ section: ConfigSection) -> some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 16) {
self.header
if let status = self.store.configStatus {
Text(status)
.font(.callout)
.foregroundStyle(.secondary)
}
self.actionRow
self.sectionHeader(section)
self.subsectionNav(section)
self.sectionForm(section)
if self.store.configDirty, !self.isNixMode {
Text("Unsaved changes")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
}
@ViewBuilder
@@ -71,6 +158,18 @@ extension ConfigSettings {
.foregroundStyle(.secondary)
}
private func sectionHeader(_ section: ConfigSection) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(section.label)
.font(.title3.weight(.semibold))
if let help = section.help {
Text(help)
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
private var actionRow: some View {
HStack(spacing: 10) {
Button("Reload") {
@@ -85,6 +184,204 @@ extension ConfigSettings {
}
.buttonStyle(.bordered)
}
private func sidebarRow(_ section: ConfigSection) -> some View {
let isSelected = self.activeSectionKey == section.key
return Button {
self.selectSection(section)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(section.label)
if let help = section.help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.background(Color.clear)
.contentShape(Rectangle())
}
.frame(maxWidth: .infinity, alignment: .leading)
.buttonStyle(.plain)
.contentShape(Rectangle())
}
@ViewBuilder
private func subsectionNav(_ section: ConfigSection) -> some View {
let subsections = self.resolveSubsections(for: section)
if subsections.isEmpty {
EmptyView()
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
self.subsectionButton(
title: "All",
isSelected: self.activeSubsection == .all)
{
self.activeSubsection = .all
}
ForEach(subsections) { subsection in
self.subsectionButton(
title: subsection.label,
isSelected: self.activeSubsection == .key(subsection.key))
{
self.activeSubsection = .key(subsection.key)
}
}
}
.padding(.vertical, 2)
}
}
}
private func subsectionButton(
title: String,
isSelected: Bool,
action: @escaping () -> Void) -> some View
{
Button(action: action) {
Text(title)
.font(.callout.weight(.semibold))
.foregroundStyle(isSelected ? Color.accentColor : .primary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color(nsColor: .controlBackgroundColor))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
private func sectionForm(_ section: ConfigSection) -> some View {
let subsection = self.activeSubsection
let defaultPath: ConfigPath = [.key(section.key)]
let subsections = self.resolveSubsections(for: section)
let resolved: (ConfigSchemaNode, ConfigPath) = {
if case let .key(key) = subsection,
let match = subsections.first(where: { $0.key == key })
{
return (match.node, match.path)
}
return (self.resolvedSchemaNode(section.node), defaultPath)
}()
return ConfigSchemaForm(store: self.store, schema: resolved.0, path: resolved.1)
.disabled(self.isNixMode)
}
private func ensureSelection() {
guard let schema = self.store.configSchema else { return }
let sections = self.resolveSections(schema)
guard !sections.isEmpty else { return }
let active = sections.first { $0.key == self.activeSectionKey } ?? sections[0]
if self.activeSectionKey != active.key {
self.activeSectionKey = active.key
}
self.ensureSubsection(for: active)
}
private func ensureSubsection(for section: ConfigSection) {
let subsections = self.resolveSubsections(for: section)
guard !subsections.isEmpty else {
self.activeSubsection = nil
return
}
switch self.activeSubsection {
case .all:
return
case let .key(key):
if subsections.contains(where: { $0.key == key }) { return }
case .none:
break
}
if let first = subsections.first {
self.activeSubsection = .key(first.key)
}
}
private func selectSection(_ section: ConfigSection) {
guard self.activeSectionKey != section.key else { return }
self.activeSectionKey = section.key
let subsections = self.resolveSubsections(for: section)
if let first = subsections.first {
self.activeSubsection = .key(first.key)
} else {
self.activeSubsection = nil
}
}
private func resolveSections(_ root: ConfigSchemaNode) -> [ConfigSection] {
let node = self.resolvedSchemaNode(root)
let hints = self.store.configUiHints
let keys = node.properties.keys.sorted { lhs, rhs in
let orderA = hintForPath([.key(lhs)], hints: hints)?.order ?? 0
let orderB = hintForPath([.key(rhs)], hints: hints)?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs < rhs
}
return keys.compactMap { key in
guard let child = node.properties[key] else { return nil }
let path: ConfigPath = [.key(key)]
let hint = hintForPath(path, hints: hints)
let label = hint?.label
?? child.title
?? self.humanize(key)
let help = hint?.help ?? child.description
return ConfigSection(key: key, label: label, help: help, node: child)
}
}
private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] {
let node = self.resolvedSchemaNode(section.node)
guard node.schemaType == "object" else { return [] }
let hints = self.store.configUiHints
let keys = node.properties.keys.sorted { lhs, rhs in
let orderA = hintForPath([.key(section.key), .key(lhs)], hints: hints)?.order ?? 0
let orderB = hintForPath([.key(section.key), .key(rhs)], hints: hints)?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs < rhs
}
return keys.compactMap { key in
guard let child = node.properties[key] else { return nil }
let path: ConfigPath = [.key(section.key), .key(key)]
let hint = hintForPath(path, hints: hints)
let label = hint?.label
?? child.title
?? self.humanize(key)
let help = hint?.help ?? child.description
return ConfigSubsection(
key: key,
label: label,
help: help,
node: child,
path: path)
}
}
private func resolvedSchemaNode(_ node: ConfigSchemaNode) -> ConfigSchemaNode {
let variants = node.anyOf.isEmpty ? node.oneOf : node.anyOf
if !variants.isEmpty {
let nonNull = variants.filter { !$0.isNullSchema }
if nonNull.count == 1, let only = nonNull.first { return only }
}
return node
}
private func humanize(_ key: String) -> String {
key.replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: "-", with: " ")
.capitalized
}
}
struct ConfigSettings_Previews: PreviewProvider {

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

@@ -20,7 +20,7 @@ struct ControlAgentEvent: Codable, Sendable, Identifiable {
let seq: Int
let stream: String
let ts: Double
let data: [String: AnyCodable]
let data: [String: ClawdbotProtocol.AnyCodable]
let summary: String?
}
@@ -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 {
@@ -156,8 +163,8 @@ final class ControlChannel {
timeoutMs: Double? = nil) async throws -> Data
{
do {
let rawParams = params?.reduce(into: [String: AnyCodable]()) {
$0[$1.key] = AnyCodable($1.value.base)
let rawParams = params?.reduce(into: [String: ClawdbotKit.AnyCodable]()) {
$0[$1.key] = ClawdbotKit.AnyCodable($1.value.base)
}
let data = try await GatewayConnection.shared.request(
method: method,
@@ -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)
@@ -346,7 +387,7 @@ final class ControlChannel {
let phase = event.data["phase"]?.value as? String ?? ""
let name = event.data["name"]?.value as? String
let meta = event.data["meta"]?.value as? String
let args = event.data["args"]?.value as? [String: AnyCodable]
let args = Self.bridgeToProtocolArgs(event.data["args"])
WorkActivityStore.shared.handleTool(
sessionKey: sessionKey,
phase: phase,
@@ -357,6 +398,27 @@ final class ControlChannel {
break
}
}
private static func bridgeToProtocolArgs(
_ value: ClawdbotProtocol.AnyCodable?) -> [String: ClawdbotProtocol.AnyCodable]?
{
guard let value else { return nil }
if let dict = value.value as? [String: ClawdbotProtocol.AnyCodable] {
return dict
}
if let dict = value.value as? [String: ClawdbotKit.AnyCodable],
let data = try? JSONEncoder().encode(dict),
let decoded = try? JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
{
return decoded
}
if let data = try? JSONEncoder().encode(value),
let decoded = try? JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
{
return decoded
}
return nil
}
}
extension Notification.Name {

View File

@@ -42,7 +42,8 @@ extension CronJobEditor {
self.thinking = thinking ?? ""
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
self.deliver = deliver ?? false
self.channel = GatewayAgentChannel(raw: channel)
let trimmed = (channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
self.channel = trimmed.isEmpty ? "last" : trimmed
self.to = to ?? ""
self.bestEffortDeliver = bestEffortDeliver ?? false
}
@@ -210,7 +211,8 @@ extension CronJobEditor {
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
payload["deliver"] = self.deliver
if self.deliver {
payload["channel"] = self.channel.rawValue
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
payload["channel"] = trimmed.isEmpty ? "last" : trimmed
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { payload["to"] = to }
payload["bestEffortDeliver"] = self.bestEffortDeliver

View File

@@ -14,7 +14,7 @@ extension CronJobEditor {
self.payloadKind = .agentTurn
self.agentMessage = "Run diagnostic"
self.deliver = true
self.channel = .last
self.channel = "last"
self.to = "+15551230000"
self.thinking = "low"
self.timeoutSeconds = "90"

View File

@@ -1,10 +1,12 @@
import ClawdbotProtocol
import Observation
import SwiftUI
struct CronJobEditor: View {
let job: CronJob?
@Binding var isSaving: Bool
@Binding var error: String?
@Bindable var channelsStore: ChannelsStore
let onCancel: () -> Void
let onSave: ([String: AnyCodable]) -> Void
@@ -45,13 +47,29 @@ struct CronJobEditor: View {
@State var systemEventText: String = ""
@State var agentMessage: String = ""
@State var deliver: Bool = false
@State var channel: GatewayAgentChannel = .last
@State var channel: String = "last"
@State var to: String = ""
@State var thinking: String = ""
@State var timeoutSeconds: String = ""
@State var bestEffortDeliver: Bool = false
@State var postPrefix: String = "Cron"
var channelOptions: [String] {
let ordered = self.channelsStore.orderedChannelIds()
var options = ["last"] + ordered
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty, !options.contains(trimmed) {
options.append(trimmed)
}
var seen = Set<String>()
return options.filter { seen.insert($0).inserted }
}
func channelLabel(for id: String) -> String {
if id == "last" { return "last" }
return self.channelsStore.resolveChannelLabel(id)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
@@ -333,13 +351,9 @@ struct CronJobEditor: View {
GridRow {
self.gridLabel("Channel")
Picker("", selection: self.$channel) {
Text("last").tag(GatewayAgentChannel.last)
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
Text("telegram").tag(GatewayAgentChannel.telegram)
Text("discord").tag(GatewayAgentChannel.discord)
Text("slack").tag(GatewayAgentChannel.slack)
Text("signal").tag(GatewayAgentChannel.signal)
Text("imessage").tag(GatewayAgentChannel.imessage)
ForEach(self.channelOptions, id: \.self) { channel in
Text(self.channelLabel(for: channel)).tag(channel)
}
}
.labelsHidden()
.pickerStyle(.segmented)

View File

@@ -8,13 +8,20 @@ extension CronSettings {
self.content
Spacer(minLength: 0)
}
.onAppear { self.store.start() }
.onDisappear { self.store.stop() }
.onAppear {
self.store.start()
self.channelsStore.start()
}
.onDisappear {
self.store.stop()
self.channelsStore.stop()
}
.sheet(isPresented: self.$showEditor) {
CronJobEditor(
job: self.editingJob,
isSaving: self.$isSaving,
error: self.$editorError,
channelsStore: self.channelsStore,
onCancel: {
self.showEditor = false
self.editingJob = nil

View File

@@ -47,7 +47,7 @@ struct CronSettings_Previews: PreviewProvider {
durationMs: 1234,
nextRunAtMs: nil),
]
return CronSettings(store: store)
return CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true))
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}
@@ -103,7 +103,7 @@ extension CronSettings {
store.selectedJobId = job.id
store.runEntries = [run]
let view = CronSettings(store: store)
let view = CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true))
_ = view.body
_ = view.jobRow(job)
_ = view.jobContextMenu(job)

View File

@@ -3,13 +3,15 @@ import SwiftUI
struct CronSettings: View {
@Bindable var store: CronJobsStore
@Bindable var channelsStore: ChannelsStore
@State var showEditor = false
@State var editingJob: CronJob?
@State var editorError: String?
@State var isSaving = false
@State var confirmDelete: CronJob?
init(store: CronJobsStore = .shared) {
init(store: CronJobsStore = .shared, channelsStore: ChannelsStore = .shared) {
self.store = store
self.channelsStore = channelsStore
}
}

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,39 @@ 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 +520,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

@@ -149,6 +149,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 +166,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,16 +282,17 @@ 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(
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
@@ -454,7 +465,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 +597,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 {
@@ -46,6 +47,7 @@ private struct ExecHostRequest: Codable {
var needsScreenRecording: Bool?
var agentId: String?
var sessionKey: String?
var approvalDecision: ExecApprovalDecision?
}
private struct ExecHostRunResult: Codable {
@@ -214,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:
@@ -254,10 +235,128 @@ 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
private enum ExecHostExecutor {
private struct ExecApprovalContext {
let command: [String]
let displayCommand: String
let trimmedAgent: String?
let approvals: ExecApprovalsResolved
let security: ExecSecurity
let ask: ExecAsk
let autoAllowSkills: Bool
let env: [String: String]?
let resolution: ExecCommandResolution?
let allowlistMatch: ExecAllowlistEntry?
let skillAllow: Bool
}
private static let blockedEnvKeys: Set<String> = [
"PATH",
"NODE_OPTIONS",
@@ -276,14 +375,94 @@ private enum ExecHostExecutor {
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard !command.isEmpty else {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "command required", reason: "invalid"))
return self.errorResponse(
code: "INVALID_REQUEST",
message: "command required",
reason: "invalid")
}
let context = await self.buildContext(request: request, command: command)
if context.security == .deny {
return self.errorResponse(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DISABLED: security=deny",
reason: "security=deny")
}
let approvalDecision = request.approvalDecision
if approvalDecision == .deny {
return self.errorResponse(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: user denied",
reason: "user-denied")
}
var approvedByAsk = approvalDecision != nil
if ExecApprovalHelpers.requiresAsk(
ask: context.ask,
security: context.security,
allowlistMatch: context.allowlistMatch,
skillAllow: context.skillAllow),
approvalDecision == nil
{
let decision = ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: context.displayCommand,
cwd: request.cwd,
host: "node",
security: context.security.rawValue,
ask: context.ask.rawValue,
agentId: context.trimmedAgent,
resolvedPath: context.resolution?.resolvedPath,
sessionKey: request.sessionKey))
switch decision {
case .deny:
return self.errorResponse(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: user denied",
reason: "user-denied")
case .allowAlways:
approvedByAsk = true
self.persistAllowlistEntry(decision: decision, context: context)
case .allowOnce:
approvedByAsk = true
}
}
self.persistAllowlistEntry(decision: approvalDecision, context: context)
if context.security == .allowlist,
context.allowlistMatch == nil,
!context.skillAllow,
!approvedByAsk
{
return self.errorResponse(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: allowlist miss",
reason: "allowlist-miss")
}
if let match = context.allowlistMatch {
ExecApprovalsStore.recordAllowlistUse(
agentId: context.trimmedAgent,
pattern: match.pattern,
command: context.displayCommand,
resolvedPath: context.resolution?.resolvedPath)
}
if let errorResponse = await self.ensureScreenRecordingAccess(request.needsScreenRecording) {
return errorResponse
}
return await self.runCommand(
command: command,
cwd: request.cwd,
env: context.env,
timeoutMs: request.timeoutMs)
}
private static func buildContext(request: ExecHostRequest, command: [String]) async -> ExecApprovalContext {
let displayCommand = ExecCommandFormatter.displayString(
for: command,
rawCommand: request.rawCommand)
@@ -309,102 +488,56 @@ private enum ExecHostExecutor {
} else {
skillAllow = false
}
return ExecApprovalContext(
command: command,
displayCommand: displayCommand,
trimmedAgent: trimmedAgent,
approvals: approvals,
security: security,
ask: ask,
autoAllowSkills: autoAllowSkills,
env: env,
resolution: resolution,
allowlistMatch: allowlistMatch,
skillAllow: skillAllow)
}
if security == .deny {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DISABLED: security=deny",
reason: "security=deny"))
private static func persistAllowlistEntry(
decision: ExecApprovalDecision?,
context: ExecApprovalContext)
{
guard decision == .allowAlways, context.security == .allowlist else { return }
guard let pattern = ExecApprovalHelpers.allowlistPattern(
command: context.command,
resolution: context.resolution)
else {
return
}
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
}
let requiresAsk: Bool = {
if ask == .always { return true }
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
return false
}()
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
guard needsScreenRecording == true else { return nil }
let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false
if authorized { return nil }
return self.errorResponse(
code: "UNAVAILABLE",
message: "PERMISSION_MISSING: screenRecording",
reason: "permission:screenRecording")
}
var approvedByAsk = false
if requiresAsk {
let decision = ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: displayCommand,
cwd: request.cwd,
host: "node",
security: security.rawValue,
ask: ask.rawValue,
agentId: trimmedAgent,
resolvedPath: resolution?.resolvedPath))
switch decision {
case .deny:
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: user denied",
reason: "user-denied"))
case .allowAlways:
approvedByAsk = true
if security == .allowlist {
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
if !pattern.isEmpty {
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
}
}
case .allowOnce:
approvedByAsk = true
}
}
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: allowlist miss",
reason: "allowlist-miss"))
}
if let match = allowlistMatch {
ExecApprovalsStore.recordAllowlistUse(
agentId: trimmedAgent,
pattern: match.pattern,
command: displayCommand,
resolvedPath: resolution?.resolvedPath)
}
if request.needsScreenRecording == true {
let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false
if !authorized {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(
code: "UNAVAILABLE",
message: "PERMISSION_MISSING: screenRecording",
reason: "permission:screenRecording"))
}
}
let timeoutSec = request.timeoutMs.flatMap { Double($0) / 1000.0 }
private static func runCommand(
command: [String],
cwd: String?,
env: [String: String]?,
timeoutMs: Int?) async -> ExecHostResponse
{
let timeoutSec = timeoutMs.flatMap { Double($0) / 1000.0 }
let result = await Task.detached { () -> ShellExecutor.ShellResult in
await ShellExecutor.runDetailed(
command: command,
cwd: request.cwd,
cwd: cwd,
env: env,
timeout: timeoutSec)
}.value
@@ -415,7 +548,24 @@ private enum ExecHostExecutor {
stdout: result.stdout,
stderr: result.stderr,
error: result.errorMessage)
return ExecHostResponse(
return self.successResponse(payload)
}
private static func errorResponse(
code: String,
message: String,
reason: String?) -> ExecHostResponse
{
ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: code, message: message, reason: reason))
}
private static func successResponse(_ payload: ExecHostRunResult) -> ExecHostResponse {
ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: true,

View File

@@ -15,6 +15,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
case signal
case imessage
case msteams
case bluebubbles
case webchat
init(raw: String?) {
@@ -68,6 +69,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"
@@ -147,6 +149,27 @@ actor GatewayConnection {
}
}
let nsError = lastError as NSError
if nsError.domain == URLError.errorDomain,
let fallback = await GatewayEndpointStore.shared.maybeFallbackToTailnet(from: cfg.url)
{
await self.configure(url: fallback.url, token: fallback.token, password: fallback.password)
for delayMs in [150, 400, 900] {
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
do {
guard let client = self.client else {
throw NSError(
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
}
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
lastError = error
}
}
}
throw lastError
case .remote:
let nsError = error as NSError
@@ -227,6 +250,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()
@@ -243,9 +271,9 @@ actor GatewayConnection {
return trimmed.isEmpty ? nil : trimmed
}
private func sessionDefaultString(_ defaults: [String: AnyCodable]?, key: String) -> String {
(defaults?[key]?.stringValue ?? "")
.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
private func sessionDefaultString(_ defaults: [String: ClawdbotProtocol.AnyCodable]?, key: String) -> String {
let raw = defaults?[key]?.value as? String
return (raw ?? "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
}
func cachedMainSessionKey() -> String? {
@@ -513,6 +541,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

@@ -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,
@@ -165,19 +166,23 @@ actor GatewayEndpointStore {
}
return trimmed
}
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
!configToken.isEmpty
{
return configToken
}
if isRemote {
return nil
}
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
return token
}
return nil
}
@@ -469,6 +474,36 @@ actor GatewayEndpointStore {
}
}
func maybeFallbackToTailnet(from currentURL: URL) async -> GatewayConnection.Config? {
let mode = await self.deps.mode()
guard mode == .local else { return nil }
let root = ClawdbotConfigFile.loadDict()
let bind = GatewayEndpointStore.resolveGatewayBindMode(
root: root,
env: ProcessInfo.processInfo.environment)
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(
root: root,
env: ProcessInfo.processInfo.environment)
let port = self.deps.localPort()
let token = self.deps.token()
let password = self.deps.password()
let url = URL(string: "\(scheme)://\(tailscaleIP):\(port)")!
self.logger.info("auto bind fallback to tailnet host=\(tailscaleIP, privacy: .public)")
self.setState(.ready(mode: .local, url: url, token: token, password: password))
return (url, token, password)
}
private static func resolveGatewayBindMode(
root: [String: Any],
env: [String: String]) -> String?
@@ -524,8 +559,10 @@ actor GatewayEndpointStore {
tailscaleIP: String?) -> String
{
switch bindMode {
case "tailnet", "auto":
case "tailnet":
tailscaleIP ?? "127.0.0.1"
case "auto":
"127.0.0.1"
case "custom":
customBindHost ?? "127.0.0.1"
default:
@@ -600,11 +637,12 @@ extension GatewayEndpointStore {
static func _testResolveLocalGatewayHost(
bindMode: String?,
tailscaleIP: String?) -> String
tailscaleIP: String?,
customBindHost: String? = nil) -> String
{
self.resolveLocalGatewayHost(
bindMode: bindMode,
customBindHost: nil,
customBindHost: customBindHost,
tailscaleIP: tailscaleIP)
}
}

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,13 +242,12 @@ 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 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 = linkId.flatMap { snap.channels[$0]?.linked } ?? false
let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
let label =
@@ -293,6 +312,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 +338,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 +381,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 +415,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

@@ -2,52 +2,25 @@ 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
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 +56,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 +80,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 +95,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)
}
.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,8 +131,6 @@ struct GeneralSettings: View {
if self.state.connectionMode == .remote {
self.remoteCard
}
self.cliInstaller
}
}
@@ -299,6 +212,11 @@ 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.")
@@ -346,59 +264,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 +319,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) {
@@ -763,9 +612,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,5 @@
import AppKit
import Observation
import SwiftUI
@MainActor
@@ -18,6 +19,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 +52,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
self.loadTask = Task { await self.refreshCache(force: true) }
}
self.startControlChannelObservation()
self.nodesStore.start()
}
@@ -96,6 +99,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 +188,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
@@ -469,7 +525,7 @@ extension MenuSessionsInjector {
}
case .local:
platform = "local"
host = "127.0.0.1:\(port)"
host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
case .unconfigured:
platform = nil
host = nil
@@ -598,8 +654,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 +683,6 @@ extension MenuSessionsInjector {
}
guard self.isControlChannelConnected else {
self.cachedUsageSummary = nil
self.cachedUsageErrorText = nil
self.usageCacheUpdatedAt = Date()
return
}
@@ -648,8 +705,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

@@ -217,7 +217,7 @@ final class OnboardingWizardModel {
struct OnboardingWizardStepView: View {
let step: WizardStep
let isSubmitting: Bool
let onSubmit: (AnyCodable?) -> Void
let onStepSubmit: (AnyCodable?) -> Void
@State private var textValue: String
@State private var confirmValue: Bool
@@ -229,7 +229,7 @@ struct OnboardingWizardStepView: View {
init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) {
self.step = step
self.isSubmitting = isSubmitting
self.onSubmit = onSubmit
self.onStepSubmit = onSubmit
let options = parseWizardOptions(step.options).enumerated().map { index, option in
WizardOptionItem(index: index, option: option)
}
@@ -379,27 +379,27 @@ struct OnboardingWizardStepView: View {
private func submit() {
switch wizardStepType(self.step) {
case "note", "progress":
self.onSubmit(nil)
self.onStepSubmit(nil)
case "text":
self.onSubmit(AnyCodable(self.textValue))
self.onStepSubmit(AnyCodable(self.textValue))
case "confirm":
self.onSubmit(AnyCodable(self.confirmValue))
self.onStepSubmit(AnyCodable(self.confirmValue))
case "select":
guard self.optionItems.indices.contains(self.selectedIndex) else {
self.onSubmit(nil)
self.onStepSubmit(nil)
return
}
let option = self.optionItems[self.selectedIndex].option
self.onSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
self.onStepSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
case "multiselect":
let values = self.optionItems
.filter { self.selectedIndices.contains($0.index) }
.map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) }
self.onSubmit(AnyCodable(values))
self.onStepSubmit(AnyCodable(values))
case "action":
self.onSubmit(AnyCodable(true))
self.onStepSubmit(AnyCodable(true))
default:
self.onSubmit(nil)
self.onStepSubmit(nil)
}
}
}

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.11-4</string>
<string>2026.1.21</string>
<key>CFBundleVersion</key>
<string>202601113</string>
<string>202601210</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

@@ -2,6 +2,9 @@ import AppKit
import Foundation
import Observation
import os
#if canImport(Darwin)
import Darwin
#endif
/// Manages Tailscale integration and status checking.
@Observable
@@ -100,16 +103,14 @@ final class TailscaleService {
}
func checkTailscaleStatus() async {
let previousIP = self.tailscaleIP
self.isInstalled = self.checkAppInstallation()
guard self.isInstalled else {
if !self.isInstalled {
self.isRunning = false
self.tailscaleHostname = nil
self.tailscaleIP = nil
self.statusError = "Tailscale is not installed"
return
}
if let apiResponse = await fetchTailscaleStatus() {
} else if let apiResponse = await fetchTailscaleStatus() {
self.isRunning = apiResponse.status.lowercased() == "running"
if self.isRunning {
@@ -138,6 +139,19 @@ final class TailscaleService {
self.statusError = "Please start the Tailscale app"
self.logger.info("Tailscale API not responding; app likely not running")
}
if self.tailscaleIP == nil, let fallback = Self.detectTailnetIPv4() {
self.tailscaleIP = fallback
if !self.isRunning {
self.isRunning = true
}
self.statusError = nil
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
}
if previousIP != self.tailscaleIP {
await GatewayEndpointStore.shared.refresh()
}
}
func openTailscaleApp() {
@@ -163,4 +177,50 @@ final class TailscaleService {
NSWorkspace.shared.open(url)
}
}
private nonisolated static func isTailnetIPv4(_ address: String) -> Bool {
let parts = address.split(separator: ".")
guard parts.count == 4 else { return false }
let octets = parts.compactMap { Int($0) }
guard octets.count == 4 else { return false }
let a = octets[0]
let b = octets[1]
return a == 100 && b >= 64 && b <= 127
}
private nonisolated static func detectTailnetIPv4() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
defer { freeifaddrs(addrList) }
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
let flags = Int32(ptr.pointee.ifa_flags)
let isUp = (flags & IFF_UP) != 0
let isLoopback = (flags & IFF_LOOPBACK) != 0
let family = ptr.pointee.ifa_addr.pointee.sa_family
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
var addr = ptr.pointee.ifa_addr.pointee
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = getnameinfo(
&addr,
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard result == 0 else { continue }
let len = buffer.prefix { $0 != 0 }
let bytes = len.map { UInt8(bitPattern: $0) }
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
if Self.isTailnetIPv4(ip) { return ip }
}
return nil
}
nonisolated static func fallbackTailnetIPv4() -> String? {
self.detectTailnetIPv4()
}
}

View File

@@ -52,7 +52,7 @@ enum WideAreaGatewayDiscovery {
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
let probeName = "_clawdbot-gw._tcp.\(domainTrimmed)"
guard let ptrLines = context.dig(
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
@@ -66,7 +66,7 @@ enum WideAreaGatewayDiscovery {
let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if ptr.isEmpty { continue }
let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr
let suffix = "._clawdbot-gateway._tcp.\(domainTrimmed)"
let suffix = "._clawdbot-gw._tcp.\(domainTrimmed)"
let rawInstanceName = ptrName.hasSuffix(suffix)
? String(ptrName.dropLast(suffix.count))
: ptrName
@@ -156,7 +156,7 @@ enum WideAreaGatewayDiscovery {
{
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
let probeName = "_clawdbot-gw._tcp.\(domainTrimmed)"
let ips = candidates
candidates.removeAll(keepingCapacity: true)

View File

@@ -1,150 +0,0 @@
import ClawdbotDiscovery
import Foundation
struct DiscoveryOptions {
var timeoutMs: Int = 2000
var json: Bool = false
var includeLocal: Bool = false
var help: Bool = false
static func parse(_ args: [String]) -> DiscoveryOptions {
var opts = DiscoveryOptions()
var i = 0
while i < args.count {
let arg = args[i]
switch arg {
case "-h", "--help":
opts.help = true
case "--json":
opts.json = true
case "--include-local":
opts.includeLocal = true
case "--timeout":
let next = (i + 1 < args.count) ? args[i + 1] : nil
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
opts.timeoutMs = max(100, parsed)
i += 1
}
default:
break
}
i += 1
}
return opts
}
}
struct DiscoveryOutput: Encodable {
struct Gateway: Encodable {
var displayName: String
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var gatewayPort: Int?
var cliPath: String?
var stableID: String
var debugID: String
var isLocal: Bool
}
var status: String
var timeoutMs: Int
var includeLocal: Bool
var count: Int
var gateways: [Gateway]
}
@main
struct ClawdbotDiscoveryCLI {
static func main() async {
let opts = DiscoveryOptions.parse(Array(CommandLine.arguments.dropFirst()))
if opts.help {
print("""
clawdbot-mac-discovery
Usage:
clawdbot-mac-discovery [--timeout <ms>] [--json] [--include-local]
Options:
--timeout <ms> Discovery window in milliseconds (default: 2000)
--json Emit JSON
--include-local Include gateways considered local
-h, --help Show help
""")
return
}
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
let model = GatewayDiscoveryModel(
localDisplayName: displayName,
filterLocalGateways: !opts.includeLocal)
await MainActor.run {
model.start()
}
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
try? await Task.sleep(nanoseconds: nanos)
let gateways = await MainActor.run { model.gateways }
let status = await MainActor.run { model.statusText }
await MainActor.run {
model.stop()
}
if opts.json {
let payload = DiscoveryOutput(
status: status,
timeoutMs: opts.timeoutMs,
includeLocal: opts.includeLocal,
count: gateways.count,
gateways: gateways.map {
DiscoveryOutput.Gateway(
displayName: $0.displayName,
lanHost: $0.lanHost,
tailnetDns: $0.tailnetDns,
sshPort: $0.sshPort,
gatewayPort: $0.gatewayPort,
cliPath: $0.cliPath,
stableID: $0.stableID,
debugID: $0.debugID,
isLocal: $0.isLocal)
})
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(payload),
let json = String(data: data, encoding: .utf8)
{
print(json)
} else {
print("{\"error\":\"failed to encode JSON\"}")
}
return
}
print("Gateway Discovery (macOS NWBrowser)")
print("Status: \(status)")
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
if gateways.isEmpty { return }
for gateway in gateways {
let hosts = [gateway.tailnetDns, gateway.lanHost]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: ", ")
print("- \(gateway.displayName)")
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
print(" ssh: \(gateway.sshPort)")
if let port = gateway.gatewayPort {
print(" gatewayPort: \(port)")
}
if let cliPath = gateway.cliPath {
print(" cliPath: \(cliPath)")
}
print(" isLocal: \(gateway.isLocal)")
print(" stableID: \(gateway.stableID)")
print(" debugID: \(gateway.debugID)")
}
}
}

View File

@@ -408,8 +408,7 @@ extension Request: Codable {
}
// Shared transport settings
public let controlSocketPath =
FileManager()
.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/clawdbot/control.sock")
.path
public let controlSocketPath = FileManager()
.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/clawdbot/control.sock")
.path

View File

@@ -0,0 +1,359 @@
import ClawdbotKit
import ClawdbotProtocol
import Foundation
#if canImport(Darwin)
import Darwin
#endif
struct ConnectOptions {
var url: String?
var token: String?
var password: String?
var mode: String?
var timeoutMs: Int = 15000
var json: Bool = false
var probe: Bool = false
var clientId: String = "clawdbot-macos"
var clientMode: String = "ui"
var displayName: String?
var role: String = "operator"
var scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"]
var help: Bool = false
static func parse(_ args: [String]) -> ConnectOptions {
var opts = ConnectOptions()
let flagHandlers: [String: (inout ConnectOptions) -> Void] = [
"-h": { $0.help = true },
"--help": { $0.help = true },
"--json": { $0.json = true },
"--probe": { $0.probe = true },
]
let valueHandlers: [String: (inout ConnectOptions, String) -> Void] = [
"--url": { $0.url = $1 },
"--token": { $0.token = $1 },
"--password": { $0.password = $1 },
"--mode": { $0.mode = $1 },
"--timeout": { opts, raw in
if let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) {
opts.timeoutMs = max(250, parsed)
}
},
"--client-id": { $0.clientId = $1 },
"--client-mode": { $0.clientMode = $1 },
"--display-name": { $0.displayName = $1 },
"--role": { $0.role = $1 },
"--scopes": { opts, raw in
opts.scopes = raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
},
]
var i = 0
while i < args.count {
let arg = args[i]
if let handler = flagHandlers[arg] {
handler(&opts)
i += 1
continue
}
if let handler = valueHandlers[arg], let value = self.nextValue(args, index: &i) {
handler(&opts, value)
i += 1
continue
}
i += 1
}
return opts
}
private static func nextValue(_ args: [String], index: inout Int) -> String? {
guard index + 1 < args.count else { return nil }
index += 1
return args[index].trimmingCharacters(in: .whitespacesAndNewlines)
}
}
struct ConnectOutput: Encodable {
var status: String
var url: String
var mode: String
var role: String
var clientId: String
var clientMode: String
var scopes: [String]
var snapshot: HelloOk?
var health: ProtoAnyCodable?
var error: String?
}
actor SnapshotStore {
private var value: HelloOk?
func set(_ snapshot: HelloOk) {
self.value = snapshot
}
func get() -> HelloOk? {
self.value
}
}
func runConnect(_ args: [String]) async {
let opts = ConnectOptions.parse(args)
if opts.help {
print("""
clawdbot-mac connect
Usage:
clawdbot-mac connect [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--timeout <ms>] [--probe] [--json]
[--client-id <id>] [--client-mode <mode>] [--display-name <name>]
[--role <role>] [--scopes <a,b,c>]
Options:
--url <url> Gateway WebSocket URL (overrides config)
--token <token> Gateway token (if required)
--password <pw> Gateway password (if required)
--mode <mode> Resolve from config: local|remote (default: config or local)
--timeout <ms> Request timeout (default: 15000)
--probe Force a fresh health probe
--json Emit JSON
--client-id <id> Override client id (default: clawdbot-macos)
--client-mode <m> Override client mode (default: ui)
--display-name <n> Override display name
--role <role> Override role (default: operator)
--scopes <a,b,c> Override scopes list
-h, --help Show help
""")
return
}
let config = loadGatewayConfig()
do {
let endpoint = try resolveGatewayEndpoint(opts: opts, config: config)
let displayName = opts.displayName ?? Host.current().localizedName ?? "Clawdbot macOS Debug CLI"
let connectOptions = GatewayConnectOptions(
role: opts.role,
scopes: opts.scopes,
caps: [],
commands: [],
permissions: [:],
clientId: opts.clientId,
clientMode: opts.clientMode,
clientDisplayName: displayName)
let snapshotStore = SnapshotStore()
let channel = GatewayChannelActor(
url: endpoint.url,
token: endpoint.token,
password: endpoint.password,
pushHandler: { push in
if case let .snapshot(ok) = push {
await snapshotStore.set(ok)
}
},
connectOptions: connectOptions)
let params: [String: KitAnyCodable]? = opts.probe ? ["probe": KitAnyCodable(true)] : nil
let data = try await channel.request(
method: "health",
params: params,
timeoutMs: Double(opts.timeoutMs))
let health = try? JSONDecoder().decode(ProtoAnyCodable.self, from: data)
let snapshot = await snapshotStore.get()
await channel.shutdown()
let output = ConnectOutput(
status: "ok",
url: endpoint.url.absoluteString,
mode: endpoint.mode,
role: opts.role,
clientId: opts.clientId,
clientMode: opts.clientMode,
scopes: opts.scopes,
snapshot: snapshot,
health: health,
error: nil)
printConnectOutput(output, json: opts.json)
} catch {
let endpoint = bestEffortEndpoint(opts: opts, config: config)
let fallbackMode = (opts.mode ?? config.mode ?? "local").lowercased()
let output = ConnectOutput(
status: "error",
url: endpoint?.url.absoluteString ?? "unknown",
mode: endpoint?.mode ?? fallbackMode,
role: opts.role,
clientId: opts.clientId,
clientMode: opts.clientMode,
scopes: opts.scopes,
snapshot: nil,
health: nil,
error: error.localizedDescription)
printConnectOutput(output, json: opts.json)
exit(1)
}
}
private func printConnectOutput(_ output: ConnectOutput, json: Bool) {
if json {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(output),
let text = String(data: data, encoding: .utf8)
{
print(text)
} else {
print("{\"error\":\"failed to encode JSON\"}")
}
return
}
print("Clawdbot macOS Gateway Connect")
print("Status: \(output.status)")
print("URL: \(output.url)")
print("Mode: \(output.mode)")
print("Client: \(output.clientId) (\(output.clientMode))")
print("Role: \(output.role)")
print("Scopes: \(output.scopes.joined(separator: ", "))")
if let snapshot = output.snapshot {
print("Protocol: \(snapshot._protocol)")
if let version = snapshot.server["version"]?.value as? String {
print("Server: \(version)")
}
}
if let health = output.health,
let ok = (health.value as? [String: ProtoAnyCodable])?["ok"]?.value as? Bool
{
print("Health: \(ok ? "ok" : "error")")
} else if output.health != nil {
print("Health: received")
}
if let error = output.error {
print("Error: \(error)")
}
}
private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) throws -> GatewayEndpoint {
let resolvedMode = (opts.mode ?? config.mode ?? "local").lowercased()
if let raw = opts.url, !raw.isEmpty {
guard let url = URL(string: raw) else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
}
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
mode: resolvedMode)
}
if resolvedMode == "remote" {
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty
else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
}
guard let url = URL(string: raw) else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
}
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
mode: resolvedMode)
}
let port = config.port ?? 18789
let host = resolveLocalHost(bind: config.bind)
guard let url = URL(string: "ws://\(host):\(port)") else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
}
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
mode: resolvedMode)
}
private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? {
try? resolveGatewayEndpoint(opts: opts, config: config)
}
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
if let token = opts.token, !token.isEmpty { return token }
if let token = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"], !token.isEmpty {
return token
}
if mode == "remote" {
return config.remoteToken
}
return config.token
}
private func resolvedPassword(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
if let password = opts.password, !password.isEmpty { return password }
if let password = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"], !password.isEmpty {
return password
}
if mode == "remote" {
return config.remotePassword
}
return config.password
}
private func resolveLocalHost(bind: String?) -> String {
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let tailnetIP = detectTailnetIPv4()
switch normalized {
case "tailnet":
return tailnetIP ?? "127.0.0.1"
default:
return "127.0.0.1"
}
}
private func detectTailnetIPv4() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
defer { freeifaddrs(addrList) }
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
let flags = Int32(ptr.pointee.ifa_flags)
let isUp = (flags & IFF_UP) != 0
let isLoopback = (flags & IFF_LOOPBACK) != 0
let family = ptr.pointee.ifa_addr.pointee.sa_family
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
var addr = ptr.pointee.ifa_addr.pointee
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = getnameinfo(
&addr,
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard result == 0 else { continue }
let len = buffer.prefix { $0 != 0 }
let bytes = len.map { UInt8(bitPattern: $0) }
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
if isTailnetIPv4(ip) { return ip }
}
return nil
}
private func isTailnetIPv4(_ address: String) -> Bool {
let parts = address.split(separator: ".")
guard parts.count == 4 else { return false }
let octets = parts.compactMap { Int($0) }
guard octets.count == 4 else { return false }
let a = octets[0]
let b = octets[1]
return a == 100 && b >= 64 && b <= 127
}

View File

@@ -0,0 +1,149 @@
import ClawdbotDiscovery
import Foundation
struct DiscoveryOptions {
var timeoutMs: Int = 2000
var json: Bool = false
var includeLocal: Bool = false
var help: Bool = false
static func parse(_ args: [String]) -> DiscoveryOptions {
var opts = DiscoveryOptions()
var i = 0
while i < args.count {
let arg = args[i]
switch arg {
case "-h", "--help":
opts.help = true
case "--json":
opts.json = true
case "--include-local":
opts.includeLocal = true
case "--timeout":
let next = (i + 1 < args.count) ? args[i + 1] : nil
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
opts.timeoutMs = max(100, parsed)
i += 1
}
default:
break
}
i += 1
}
return opts
}
}
struct DiscoveryOutput: Encodable {
struct Gateway: Encodable {
var displayName: String
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var gatewayPort: Int?
var cliPath: String?
var stableID: String
var debugID: String
var isLocal: Bool
}
var status: String
var timeoutMs: Int
var includeLocal: Bool
var count: Int
var gateways: [Gateway]
}
func runDiscover(_ args: [String]) async {
let opts = DiscoveryOptions.parse(args)
if opts.help {
print("""
clawdbot-mac discover
Usage:
clawdbot-mac discover [--timeout <ms>] [--json] [--include-local]
Options:
--timeout <ms> Discovery window in milliseconds (default: 2000)
--json Emit JSON
--include-local Include gateways considered local
-h, --help Show help
""")
return
}
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
let model = await MainActor.run {
GatewayDiscoveryModel(
localDisplayName: displayName,
filterLocalGateways: !opts.includeLocal)
}
await MainActor.run {
model.start()
}
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
try? await Task.sleep(nanoseconds: nanos)
let gateways = await MainActor.run { model.gateways }
let status = await MainActor.run { model.statusText }
await MainActor.run {
model.stop()
}
if opts.json {
let payload = DiscoveryOutput(
status: status,
timeoutMs: opts.timeoutMs,
includeLocal: opts.includeLocal,
count: gateways.count,
gateways: gateways.map {
DiscoveryOutput.Gateway(
displayName: $0.displayName,
lanHost: $0.lanHost,
tailnetDns: $0.tailnetDns,
sshPort: $0.sshPort,
gatewayPort: $0.gatewayPort,
cliPath: $0.cliPath,
stableID: $0.stableID,
debugID: $0.debugID,
isLocal: $0.isLocal)
})
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(payload),
let json = String(data: data, encoding: .utf8)
{
print(json)
} else {
print("{\"error\":\"failed to encode JSON\"}")
}
return
}
print("Gateway Discovery (macOS NWBrowser)")
print("Status: \(status)")
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
if gateways.isEmpty { return }
for gateway in gateways {
let hosts = [gateway.tailnetDns, gateway.lanHost]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: ", ")
print("- \(gateway.displayName)")
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
print(" ssh: \(gateway.sshPort)")
if let port = gateway.gatewayPort {
print(" gatewayPort: \(port)")
}
if let cliPath = gateway.cliPath {
print(" cliPath: \(cliPath)")
}
print(" isLocal: \(gateway.isLocal)")
print(" stableID: \(gateway.stableID)")
print(" debugID: \(gateway.debugID)")
}
}

View File

@@ -0,0 +1,56 @@
import Foundation
private struct RootCommand {
var name: String
var args: [String]
}
@main
struct ClawdbotMacCLI {
static func main() async {
let args = Array(CommandLine.arguments.dropFirst())
let command = parseRootCommand(args)
switch command?.name {
case nil:
printUsage()
case "-h", "--help", "help":
printUsage()
case "connect":
await runConnect(command?.args ?? [])
case "discover":
await runDiscover(command?.args ?? [])
case "wizard":
await runWizardCommand(command?.args ?? [])
default:
fputs("clawdbot-mac: unknown command\n", stderr)
printUsage()
exit(1)
}
}
}
private func parseRootCommand(_ args: [String]) -> RootCommand? {
guard let first = args.first else { return nil }
return RootCommand(name: first, args: Array(args.dropFirst()))
}
private func printUsage() {
print("""
clawdbot-mac
Usage:
clawdbot-mac connect [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--timeout <ms>] [--probe] [--json]
[--client-id <id>] [--client-mode <mode>] [--display-name <name>]
[--role <role>] [--scopes <a,b,c>]
clawdbot-mac discover [--timeout <ms>] [--json] [--include-local]
clawdbot-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--workspace <path>] [--json]
Examples:
clawdbot-mac connect
clawdbot-mac connect --url ws://127.0.0.1:18789 --json
clawdbot-mac discover --timeout 3000 --json
clawdbot-mac wizard --mode local
""")
}

View File

@@ -0,0 +1,60 @@
import Foundation
struct GatewayConfig {
var mode: String?
var bind: String?
var port: Int?
var remoteUrl: String?
var token: String?
var password: String?
var remoteToken: String?
var remotePassword: String?
}
struct GatewayEndpoint {
let url: URL
let token: String?
let password: String?
let mode: String
}
func loadGatewayConfig() -> GatewayConfig {
let url = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot")
.appendingPathComponent("clawdbot.json")
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return GatewayConfig()
}
var cfg = GatewayConfig()
if let gateway = json["gateway"] as? [String: Any] {
cfg.mode = gateway["mode"] as? String
cfg.bind = gateway["bind"] as? String
cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"])
if let auth = gateway["auth"] as? [String: Any] {
cfg.token = auth["token"] as? String
cfg.password = auth["password"] as? String
}
if let remote = gateway["remote"] as? [String: Any] {
cfg.remoteUrl = remote["url"] as? String
cfg.remoteToken = remote["token"] as? String
cfg.remotePassword = remote["password"] as? String
}
}
return cfg
}
func parseInt(_ value: Any?) -> Int? {
switch value {
case let number as Int:
number
case let number as Double:
Int(number)
case let raw as String:
Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
default:
nil
}
}

View File

@@ -0,0 +1,5 @@
import ClawdbotKit
import ClawdbotProtocol
typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
typealias KitAnyCodable = ClawdbotKit.AnyCodable

View File

@@ -1,3 +1,4 @@
import ClawdbotKit
import ClawdbotProtocol
import Darwin
import Foundation
@@ -48,17 +49,6 @@ struct WizardCliOptions {
}
}
struct GatewayConfig {
var mode: String?
var bind: String?
var port: Int?
var remoteUrl: String?
var token: String?
var password: String?
var remoteToken: String?
var remotePassword: String?
}
enum WizardCliError: Error, CustomStringConvertible {
case invalidUrl(String)
case missingRemoteUrl
@@ -77,68 +67,56 @@ enum WizardCliError: Error, CustomStringConvertible {
}
}
@main
struct ClawdbotWizardCLI {
static func main() async {
let opts = WizardCliOptions.parse(Array(CommandLine.arguments.dropFirst()))
if opts.help {
printUsage()
return
}
func runWizardCommand(_ args: [String]) async {
let opts = WizardCliOptions.parse(args)
if opts.help {
print("""
clawdbot-mac wizard
let config = loadGatewayConfig()
do {
guard isatty(STDIN_FILENO) != 0 else {
throw WizardCliError.gatewayError("Wizard requires an interactive TTY.")
}
let endpoint = try resolveGatewayEndpoint(opts: opts, config: config)
let client = GatewayWizardClient(
url: endpoint.url,
token: endpoint.token,
password: endpoint.password,
json: opts.json)
try await client.connect()
defer { Task { await client.close() } }
try await runWizard(client: client, opts: opts)
} catch {
fputs("wizard: \(error)\n", stderr)
exit(1)
Usage:
clawdbot-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--workspace <path>] [--json]
Options:
--url <url> Gateway WebSocket URL (overrides config)
--token <token> Gateway token (if required)
--password <pw> Gateway password (if required)
--mode <mode> Wizard mode (local|remote). Default: local
--workspace <path> Wizard workspace override
--json Print raw wizard responses
-h, --help Show help
""")
return
}
let config = loadGatewayConfig()
do {
guard isatty(STDIN_FILENO) != 0 else {
throw WizardCliError.gatewayError("Wizard requires an interactive TTY.")
}
let endpoint = try resolveWizardGatewayEndpoint(opts: opts, config: config)
let client = GatewayWizardClient(
url: endpoint.url,
token: endpoint.token,
password: endpoint.password,
json: opts.json)
try await client.connect()
defer { Task { await client.close() } }
try await runWizard(client: client, opts: opts)
} catch {
fputs("wizard: \(error)\n", stderr)
exit(1)
}
}
private struct GatewayEndpoint {
let url: URL
let token: String?
let password: String?
}
private func printUsage() {
print("""
clawdbot-mac-wizard
Usage:
clawdbot-mac-wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--workspace <path>] [--json]
Options:
--url <url> Gateway WebSocket URL (overrides config)
--token <token> Gateway token (if required)
--password <pw> Gateway password (if required)
--mode <mode> Wizard mode (local|remote). Default: local
--workspace <path> Wizard workspace override
--json Print raw wizard responses
-h, --help Show help
""")
}
private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint {
private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint {
if let raw = opts.url, !raw.isEmpty {
guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) }
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, config: config),
password: resolvedPassword(opts: opts, config: config))
password: resolvedPassword(opts: opts, config: config),
mode: (config.mode ?? "local").lowercased())
}
let mode = (config.mode ?? "local").lowercased()
@@ -150,7 +128,8 @@ private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfi
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, config: config),
password: resolvedPassword(opts: opts, config: config))
password: resolvedPassword(opts: opts, config: config),
mode: mode)
}
let port = config.port ?? 18789
@@ -161,7 +140,8 @@ private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfi
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, config: config),
password: resolvedPassword(opts: opts, config: config))
password: resolvedPassword(opts: opts, config: config),
mode: mode)
}
private func resolvedToken(opts: WizardCliOptions, config: GatewayConfig) -> String? {
@@ -186,48 +166,11 @@ private func resolvedPassword(opts: WizardCliOptions, config: GatewayConfig) ->
return config.password
}
private func loadGatewayConfig() -> GatewayConfig {
let url = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot")
.appendingPathComponent("clawdbot.json")
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return GatewayConfig()
}
var cfg = GatewayConfig()
if let gateway = json["gateway"] as? [String: Any] {
cfg.mode = gateway["mode"] as? String
cfg.bind = gateway["bind"] as? String
cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"])
if let auth = gateway["auth"] as? [String: Any] {
cfg.token = auth["token"] as? String
cfg.password = auth["password"] as? String
}
if let remote = gateway["remote"] as? [String: Any] {
cfg.remoteUrl = remote["url"] as? String
cfg.remoteToken = remote["token"] as? String
cfg.remotePassword = remote["password"] as? String
}
}
return cfg
}
private func parseInt(_ value: Any?) -> Int? {
switch value {
case let number as Int:
number
case let number as Double:
Int(number)
case let raw as String:
Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
default:
nil
}
}
actor GatewayWizardClient {
private enum ConnectChallengeError: Error {
case timeout
}
private let url: URL
private let token: String?
private let password: String?
@@ -235,6 +178,7 @@ actor GatewayWizardClient {
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private let session = URLSession(configuration: .default)
private let connectChallengeTimeoutSeconds: Double = 0.75
private var task: URLSessionWebSocketTask?
init(url: URL, token: String?, password: String?, json: Bool) {
@@ -257,7 +201,7 @@ actor GatewayWizardClient {
self.task = nil
}
func request(method: String, params: [String: AnyCodable]?) async throws -> ResponseFrame {
func request(method: String, params: [String: ProtoAnyCodable]?) async throws -> ResponseFrame {
guard let task = self.task else {
throw WizardCliError.gatewayError("gateway not connected")
}
@@ -266,7 +210,7 @@ actor GatewayWizardClient {
type: "req",
id: id,
method: method,
params: params.map { AnyCodable($0) })
params: params.map { ProtoAnyCodable($0) })
let data = try self.encoder.encode(frame)
try await task.send(.data(data))
@@ -309,28 +253,66 @@ actor GatewayWizardClient {
}
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
let client: [String: AnyCodable] = [
"id": AnyCodable("clawdbot-macos"),
"displayName": AnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"),
"version": AnyCodable("dev"),
"platform": AnyCodable(platform),
"deviceFamily": AnyCodable("Mac"),
"mode": AnyCodable("ui"),
"instanceId": AnyCodable(UUID().uuidString),
let clientId = "clawdbot-macos"
let clientMode = "ui"
let role = "operator"
let scopes: [String] = []
let client: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(clientId),
"displayName": ProtoAnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"),
"version": ProtoAnyCodable("dev"),
"platform": ProtoAnyCodable(platform),
"deviceFamily": ProtoAnyCodable("Mac"),
"mode": ProtoAnyCodable(clientMode),
"instanceId": ProtoAnyCodable(UUID().uuidString),
]
var params: [String: AnyCodable] = [
"minProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION),
"maxProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION),
"client": AnyCodable(client),
"caps": AnyCodable([String]()),
"locale": AnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier),
"userAgent": AnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
var params: [String: ProtoAnyCodable] = [
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"client": ProtoAnyCodable(client),
"caps": ProtoAnyCodable([String]()),
"locale": ProtoAnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier),
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
"role": ProtoAnyCodable(role),
"scopes": ProtoAnyCodable(scopes),
]
if let token = self.token {
params["auth"] = AnyCodable(["token": AnyCodable(token)])
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
} else if let password = self.password {
params["auth"] = AnyCodable(["password": AnyCodable(password)])
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
}
let connectNonce = try await self.waitForConnectChallenge()
let identity = DeviceIdentityStore.loadOrCreate()
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let scopesValue = scopes.joined(separator: ",")
var payloadParts = [
connectNonce == nil ? "v1" : "v2",
identity.deviceId,
clientId,
clientMode,
role,
scopesValue,
String(signedAtMs),
self.token ?? "",
]
if let connectNonce {
payloadParts.append(connectNonce)
}
let payload = payloadParts.joined(separator: "|")
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity)
{
var device: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(identity.deviceId),
"publicKey": ProtoAnyCodable(publicKey),
"signature": ProtoAnyCodable(signature),
"signedAt": ProtoAnyCodable(signedAtMs),
]
if let connectNonce {
device["nonce"] = ProtoAnyCodable(connectNonce)
}
params["device"] = ProtoAnyCodable(device)
}
let reqId = UUID().uuidString
@@ -338,31 +320,58 @@ actor GatewayWizardClient {
type: "req",
id: reqId,
method: "connect",
params: AnyCodable(params))
params: ProtoAnyCodable(params))
let data = try self.encoder.encode(frame)
try await task.send(.data(data))
let message = try await task.receive()
let frameResponse = try decodeFrame(message)
guard case let .res(res) = frameResponse, res.id == reqId else {
throw WizardCliError.gatewayError("connect failed (unexpected response)")
while true {
let message = try await task.receive()
let frameResponse = try decodeFrame(message)
if case let .res(res) = frameResponse, res.id == reqId {
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
throw WizardCliError.gatewayError(msg)
}
_ = try self.decodePayload(res, as: HelloOk.self)
return
}
}
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
throw WizardCliError.gatewayError(msg)
}
private func waitForConnectChallenge() async throws -> String? {
guard let task = self.task else { return nil }
do {
return try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
onTimeout: { ConnectChallengeError.timeout },
operation: {
while true {
let message = try await task.receive()
let frame = try await self.decodeFrame(message)
if case let .event(evt) = frame, evt.event == "connect.challenge" {
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = payload["nonce"]?.value as? String
{
return nonce
}
}
}
})
} catch {
if error is ConnectChallengeError { return nil }
throw error
}
_ = try self.decodePayload(res, as: HelloOk.self)
}
}
private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) async throws {
var params: [String: AnyCodable] = [:]
var params: [String: ProtoAnyCodable] = [:]
let mode = opts.mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if mode == "local" || mode == "remote" {
params["mode"] = AnyCodable(mode)
params["mode"] = ProtoAnyCodable(mode)
}
if let workspace = opts.workspace?.trimmingCharacters(in: .whitespacesAndNewlines), !workspace.isEmpty {
params["workspace"] = AnyCodable(workspace)
params["workspace"] = ProtoAnyCodable(workspace)
}
let startResponse = try await client.request(method: "wizard.start", params: params)
@@ -395,17 +404,17 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
if let step = decodeWizardStep(nextResult.step) {
let answer = try promptAnswer(for: step)
var answerPayload: [String: AnyCodable] = [
"stepId": AnyCodable(step.id),
var answerPayload: [String: ProtoAnyCodable] = [
"stepId": ProtoAnyCodable(step.id),
]
if !(answer is NSNull) {
answerPayload["value"] = AnyCodable(answer)
answerPayload["value"] = ProtoAnyCodable(answer)
}
let response = try await client.request(
method: "wizard.next",
params: [
"sessionId": AnyCodable(sessionId),
"answer": AnyCodable(answerPayload),
"sessionId": ProtoAnyCodable(sessionId),
"answer": ProtoAnyCodable(answerPayload),
])
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
if opts.json {
@@ -414,7 +423,7 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
} else {
let response = try await client.request(
method: "wizard.next",
params: ["sessionId": AnyCodable(sessionId)])
params: ["sessionId": ProtoAnyCodable(sessionId)])
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
if opts.json {
dumpResult(response)
@@ -424,7 +433,7 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
} catch WizardCliError.cancelled {
_ = try? await client.request(
method: "wizard.cancel",
params: ["sessionId": AnyCodable(sessionId)])
params: ["sessionId": ProtoAnyCodable(sessionId)])
throw WizardCliError.cancelled
}
}

View File

@@ -18,9 +18,10 @@ 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]
public let device: [String: AnyCodable]?
public let auth: [String: AnyCodable]?
public let locale: String?
public let useragent: String?
@@ -32,9 +33,10 @@ public struct ConnectParams: Codable, Sendable {
caps: [String]?,
commands: [String]?,
permissions: [String: AnyCodable]?,
pathenv: String?,
role: String?,
scopes: [String]?,
device: [String: AnyCodable],
device: [String: AnyCodable]?,
auth: [String: AnyCodable]?,
locale: String?,
useragent: String?
@@ -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
@@ -205,6 +209,9 @@ public struct PresenceEntry: Codable, Sendable {
public let tags: [String]?
public let text: String?
public let ts: Int
public let deviceid: String?
public let roles: [String]?
public let scopes: [String]?
public let instanceid: String?
public init(
@@ -220,6 +227,9 @@ public struct PresenceEntry: Codable, Sendable {
tags: [String]?,
text: String?,
ts: Int,
deviceid: String?,
roles: [String]?,
scopes: [String]?,
instanceid: String?
) {
self.host = host
@@ -234,6 +244,9 @@ public struct PresenceEntry: Codable, Sendable {
self.tags = tags
self.text = text
self.ts = ts
self.deviceid = deviceid
self.roles = roles
self.scopes = scopes
self.instanceid = instanceid
}
private enum CodingKeys: String, CodingKey {
@@ -249,6 +262,9 @@ public struct PresenceEntry: Codable, Sendable {
case tags
case text
case ts
case deviceid = "deviceId"
case roles
case scopes
case instanceid = "instanceId"
}
}
@@ -461,6 +477,7 @@ public struct AgentParams: Codable, Sendable {
public let replychannel: String?
public let accountid: String?
public let replyaccountid: String?
public let threadid: String?
public let timeout: Int?
public let lane: String?
public let extrasystemprompt: String?
@@ -482,6 +499,7 @@ public struct AgentParams: Codable, Sendable {
replychannel: String?,
accountid: String?,
replyaccountid: String?,
threadid: String?,
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
@@ -502,6 +520,7 @@ public struct AgentParams: Codable, Sendable {
self.replychannel = replychannel
self.accountid = accountid
self.replyaccountid = replyaccountid
self.threadid = threadid
self.timeout = timeout
self.lane = lane
self.extrasystemprompt = extrasystemprompt
@@ -523,6 +542,7 @@ public struct AgentParams: Codable, Sendable {
case replychannel = "replyChannel"
case accountid = "accountId"
case replyaccountid = "replyAccountId"
case threadid = "threadId"
case timeout
case lane
case extrasystemprompt = "extraSystemPrompt"
@@ -532,6 +552,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?
@@ -823,35 +881,68 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int?
public let includeglobal: Bool?
public let includeunknown: Bool?
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String?
public let spawnedby: String?
public let agentid: String?
public let search: String?
public init(
limit: Int?,
activeminutes: Int?,
includeglobal: Bool?,
includeunknown: Bool?,
includederivedtitles: Bool?,
includelastmessage: Bool?,
label: String?,
spawnedby: String?,
agentid: String?
agentid: String?,
search: String?
) {
self.limit = limit
self.activeminutes = activeminutes
self.includeglobal = includeglobal
self.includeunknown = includeunknown
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label
self.spawnedby = spawnedby
self.agentid = agentid
self.search = search
}
private enum CodingKeys: String, CodingKey {
case limit
case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown"
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label
case spawnedby = "spawnedBy"
case agentid = "agentId"
case search
}
}
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"
}
}
@@ -1312,6 +1403,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
public let ts: Int
public let channelorder: [String]
public let channellabels: [String: AnyCodable]
public let channeldetaillabels: [String: AnyCodable]?
public let channelsystemimages: [String: AnyCodable]?
public let channelmeta: [[String: AnyCodable]]?
public let channels: [String: AnyCodable]
public let channelaccounts: [String: AnyCodable]
public let channeldefaultaccountid: [String: AnyCodable]
@@ -1320,6 +1414,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
ts: Int,
channelorder: [String],
channellabels: [String: AnyCodable],
channeldetaillabels: [String: AnyCodable]?,
channelsystemimages: [String: AnyCodable]?,
channelmeta: [[String: AnyCodable]]?,
channels: [String: AnyCodable],
channelaccounts: [String: AnyCodable],
channeldefaultaccountid: [String: AnyCodable]
@@ -1327,6 +1424,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
self.ts = ts
self.channelorder = channelorder
self.channellabels = channellabels
self.channeldetaillabels = channeldetaillabels
self.channelsystemimages = channelsystemimages
self.channelmeta = channelmeta
self.channels = channels
self.channelaccounts = channelaccounts
self.channeldefaultaccountid = channeldefaultaccountid
@@ -1335,6 +1435,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
case ts
case channelorder = "channelOrder"
case channellabels = "channelLabels"
case channeldetaillabels = "channelDetailLabels"
case channelsystemimages = "channelSystemImages"
case channelmeta = "channelMeta"
case channels
case channelaccounts = "channelAccounts"
case channeldefaultaccountid = "channelDefaultAccountId"
@@ -1403,17 +1506,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
}
}
@@ -1864,6 +1971,7 @@ 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?
@@ -1875,6 +1983,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public let timeoutms: Int?
public init(
id: String?,
command: String,
cwd: String?,
host: String?,
@@ -1885,6 +1994,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
sessionkey: String?,
timeoutms: Int?
) {
self.id = id
self.command = command
self.cwd = cwd
self.host = host
@@ -1896,6 +2006,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
self.timeoutms = timeoutms
}
private enum CodingKeys: String, CodingKey {
case id
case command
case cwd
case host

View File

@@ -3,6 +3,8 @@ import SwiftUI
import Testing
@testable import Clawdbot
private typealias SnapshotAnyCodable = Clawdbot.AnyCodable
@Suite(.serialized)
@MainActor
struct ChannelsSettingsSmokeTests {
@@ -17,8 +19,11 @@ struct ChannelsSettingsSmokeTests {
"signal": "Signal",
"imessage": "iMessage",
],
channelDetailLabels: nil,
channelSystemImages: nil,
channelMeta: nil,
channels: [
"whatsapp": AnyCodable([
"whatsapp": SnapshotAnyCodable([
"configured": true,
"linked": true,
"authAgeMs": 86_400_000,
@@ -37,7 +42,7 @@ struct ChannelsSettingsSmokeTests {
"lastEventAt": 1_700_000_060_000,
"lastError": "needs login",
]),
"telegram": AnyCodable([
"telegram": SnapshotAnyCodable([
"configured": true,
"tokenSource": "env",
"running": true,
@@ -52,7 +57,7 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_050_000,
]),
"signal": AnyCodable([
"signal": SnapshotAnyCodable([
"configured": true,
"baseUrl": "http://127.0.0.1:8080",
"running": true,
@@ -65,7 +70,7 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_050_000,
]),
"imessage": AnyCodable([
"imessage": SnapshotAnyCodable([
"configured": false,
"running": false,
"lastError": "not configured",
@@ -100,15 +105,18 @@ struct ChannelsSettingsSmokeTests {
"signal": "Signal",
"imessage": "iMessage",
],
channelDetailLabels: nil,
channelSystemImages: nil,
channelMeta: nil,
channels: [
"whatsapp": AnyCodable([
"whatsapp": SnapshotAnyCodable([
"configured": false,
"linked": false,
"running": false,
"connected": false,
"reconnectAttempts": 0,
]),
"telegram": AnyCodable([
"telegram": SnapshotAnyCodable([
"configured": false,
"running": false,
"lastError": "bot missing",
@@ -120,7 +128,7 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_100_000,
]),
"signal": AnyCodable([
"signal": SnapshotAnyCodable([
"configured": false,
"baseUrl": "http://127.0.0.1:8080",
"running": false,
@@ -133,7 +141,7 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_200_000,
]),
"imessage": AnyCodable([
"imessage": SnapshotAnyCodable([
"configured": false,
"running": false,
"lastError": "not configured",

View File

@@ -11,16 +11,19 @@ struct CronJobEditorSmokeTests {
}
@Test func cronJobEditorBuildsBodyForNewJob() {
let channelsStore = ChannelsStore(isPreview: true)
let view = CronJobEditor(
job: nil,
isSaving: .constant(false),
error: .constant(nil),
channelsStore: channelsStore,
onCancel: {},
onSave: { _ in })
_ = view.body
}
@Test func cronJobEditorBuildsBodyForExistingJob() {
let channelsStore = ChannelsStore(isPreview: true)
let job = CronJob(
id: "job-1",
agentId: "ops",
@@ -54,31 +57,36 @@ struct CronJobEditorSmokeTests {
job: job,
isSaving: .constant(false),
error: .constant(nil),
channelsStore: channelsStore,
onCancel: {},
onSave: { _ in })
_ = view.body
}
@Test func cronJobEditorExercisesBuilders() {
let channelsStore = ChannelsStore(isPreview: true)
var view = CronJobEditor(
job: nil,
isSaving: .constant(false),
error: .constant(nil),
channelsStore: channelsStore,
onCancel: {},
onSave: { _ in })
view.exerciseForTesting()
}
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
let channelsStore = ChannelsStore(isPreview: true)
let view = CronJobEditor(
job: nil,
isSaving: .constant(false),
error: .constant(nil),
channelsStore: channelsStore,
onCancel: {},
onSave: { _ in })
var root: [String: Any] = [:]
view.applyDeleteAfterRun(to: &root, scheduleKind: .at, deleteAfterRun: true)
view.applyDeleteAfterRun(to: &root, scheduleKind: CronJobEditor.ScheduleKind.at, deleteAfterRun: true)
let raw = root["deleteAfterRun"] as? Bool
#expect(raw == true)
}

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.bluebubbles.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
}
@@ -18,6 +19,7 @@ import Testing
#expect(GatewayAgentChannel(raw: nil) == .last)
#expect(GatewayAgentChannel(raw: " ") == .last)
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
#expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles)
#expect(GatewayAgentChannel(raw: "unknown") == .last)
}
}

View File

@@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation
import os
import Testing

View File

@@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation
import os
import Testing

View File

@@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation
import os
import Testing

View File

@@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation
import os
import Testing

View File

@@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation
import Testing
@testable import Clawdbot

View File

@@ -139,4 +139,40 @@ import Testing
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
#expect(resolved.mode == .remote)
}
@Test func resolveLocalGatewayHostUsesLoopbackForAutoEvenWithTailnet() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: "100.64.1.2")
#expect(host == "127.0.0.1")
}
@Test func resolveLocalGatewayHostUsesLoopbackForAutoWithoutTailnet() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: nil)
#expect(host == "127.0.0.1")
}
@Test func resolveLocalGatewayHostPrefersTailnetForTailnetMode() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: "100.64.1.5")
#expect(host == "100.64.1.5")
}
@Test func resolveLocalGatewayHostFallsBackToLoopbackForTailnetMode() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: nil)
#expect(host == "127.0.0.1")
}
@Test func resolveLocalGatewayHostUsesCustomBindHost() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "custom",
tailscaleIP: "100.64.1.9",
customBindHost: "192.168.1.10")
#expect(host == "192.168.1.10")
}
}

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,15 +7,17 @@ import Testing
@Suite(.serialized)
struct LowCoverageHelperTests {
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
@Test func anyCodableHelperAccessors() throws {
let payload: [String: AnyCodable] = [
"title": AnyCodable("Hello"),
"flag": AnyCodable(true),
"count": AnyCodable(3),
"ratio": AnyCodable(1.25),
"list": AnyCodable([AnyCodable("a"), AnyCodable(2)]),
let payload: [String: ProtoAnyCodable] = [
"title": ProtoAnyCodable("Hello"),
"flag": ProtoAnyCodable(true),
"count": ProtoAnyCodable(3),
"ratio": ProtoAnyCodable(1.25),
"list": ProtoAnyCodable([ProtoAnyCodable("a"), ProtoAnyCodable(2)]),
]
let any = AnyCodable(payload)
let any = ProtoAnyCodable(payload)
let dict = try #require(any.dictionaryValue)
#expect(dict["title"]?.stringValue == "Hello")
#expect(dict["flag"]?.boolValue == true)
@@ -76,31 +78,27 @@ struct LowCoverageHelperTests {
#expect(result.stderr.contains("stderr-1999"))
}
@Test func pairedNodesStorePersists() async throws {
let dir = FileManager().temporaryDirectory
.appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent("nodes.json")
let store = PairedNodesStore(fileURL: url)
await store.load()
#expect(await store.all().isEmpty)
let node = PairedNode(
@Test func nodeInfoCodableRoundTrip() throws {
let info = NodeInfo(
nodeId: "node-1",
displayName: "Node One",
platform: "macOS",
version: "1.0",
coreVersion: "1.0-core",
uiVersion: "1.0-ui",
deviceFamily: "Mac",
modelIdentifier: "MacBookPro",
token: "token",
createdAtMs: 1,
lastSeenAtMs: nil)
try await store.upsert(node)
#expect(await store.find(nodeId: "node-1")?.displayName == "Node One")
try await store.touchSeen(nodeId: "node-1")
let updated = await store.find(nodeId: "node-1")
#expect(updated?.lastSeenAtMs != nil)
remoteIp: "192.168.1.2",
caps: ["chat"],
commands: ["send"],
permissions: ["send": true],
paired: true,
connected: false)
let data = try JSONEncoder().encode(info)
let decoded = try JSONDecoder().decode(NodeInfo.self, from: data)
#expect(decoded.nodeId == "node-1")
#expect(decoded.isPaired == true)
#expect(decoded.isConnected == false)
}
@Test @MainActor func presenceReporterHelpers() {

View File

@@ -21,6 +21,7 @@ import Testing
features: [:],
snapshot: snapshot,
canvashosturl: nil,
auth: nil,
policy: [:])
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello))

View File

@@ -3,13 +3,15 @@ import SwiftUI
import Testing
@testable import Clawdbot
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
@Suite(.serialized)
@MainActor
struct OnboardingWizardStepViewTests {
@Test func noteStepBuilds() {
let step = WizardStep(
id: "step-1",
type: AnyCodable("note"),
type: ProtoAnyCodable("note"),
title: "Welcome",
message: "Hello",
options: nil,
@@ -22,17 +24,17 @@ struct OnboardingWizardStepViewTests {
}
@Test func selectStepBuilds() {
let options: [[String: AnyCodable]] = [
["value": AnyCodable("local"), "label": AnyCodable("Local"), "hint": AnyCodable("This Mac")],
["value": AnyCodable("remote"), "label": AnyCodable("Remote")],
let options: [[String: ProtoAnyCodable]] = [
["value": ProtoAnyCodable("local"), "label": ProtoAnyCodable("Local"), "hint": ProtoAnyCodable("This Mac")],
["value": ProtoAnyCodable("remote"), "label": ProtoAnyCodable("Remote")],
]
let step = WizardStep(
id: "step-2",
type: AnyCodable("select"),
type: ProtoAnyCodable("select"),
title: "Mode",
message: "Choose a mode",
options: options,
initialvalue: AnyCodable("local"),
initialvalue: ProtoAnyCodable("local"),
placeholder: nil,
sensitive: nil,
executor: 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

@@ -20,7 +20,7 @@ struct WideAreaGatewayDiscoveryTests {
let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? ""
if recordType == "PTR" {
if nameserver == "@100.123.224.76" {
return "steipetacstudio-gateway._clawdbot-gateway._tcp.clawdbot.internal.\n"
return "steipetacstudio-gateway._clawdbot-gw._tcp.clawdbot.internal.\n"
}
return ""
}

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

@@ -2,7 +2,7 @@ import Foundation
public enum ClawdbotBonjour {
// v0: internal-only, subject to rename.
public static let gatewayServiceType = "_clawdbot-gateway._tcp"
public static let gatewayServiceType = "_clawdbot-gw._tcp"
public static let gatewayServiceDomain = "local."
public static let wideAreaGatewayServiceDomain = "clawdbot.internal."

View File

@@ -0,0 +1,107 @@
import Foundation
public struct DeviceAuthEntry: Codable, Sendable {
public let token: String
public let role: String
public let scopes: [String]
public let updatedAtMs: Int
public init(token: String, role: String, scopes: [String], updatedAtMs: Int) {
self.token = token
self.role = role
self.scopes = scopes
self.updatedAtMs = updatedAtMs
}
}
private struct DeviceAuthStoreFile: Codable {
var version: Int
var deviceId: String
var tokens: [String: DeviceAuthEntry]
}
public enum DeviceAuthStore {
private static let fileName = "device-auth.json"
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
guard let store = readStore(), store.deviceId == deviceId else { return nil }
let role = normalizeRole(role)
return store.tokens[role]
}
public static func storeToken(
deviceId: String,
role: String,
token: String,
scopes: [String] = []
) -> DeviceAuthEntry {
let normalizedRole = normalizeRole(role)
var next = readStore()
if next?.deviceId != deviceId {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
let entry = DeviceAuthEntry(
token: token,
role: normalizedRole,
scopes: normalizeScopes(scopes),
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000)
)
if next == nil {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
next?.tokens[normalizedRole] = entry
if let store = next {
writeStore(store)
}
return entry
}
public static func clearToken(deviceId: String, role: String) {
guard var store = readStore(), store.deviceId == deviceId else { return }
let normalizedRole = normalizeRole(role)
guard store.tokens[normalizedRole] != nil else { return }
store.tokens.removeValue(forKey: normalizedRole)
writeStore(store)
}
private static func normalizeRole(_ role: String) -> String {
role.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func normalizeScopes(_ scopes: [String]) -> [String] {
let trimmed = scopes
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
return Array(Set(trimmed)).sorted()
}
private static func fileURL() -> URL {
DeviceIdentityPaths.stateDirURL()
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(fileName, isDirectory: false)
}
private static func readStore() -> DeviceAuthStoreFile? {
let url = fileURL()
guard let data = try? Data(contentsOf: url) else { return nil }
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
return nil
}
guard decoded.version == 1 else { return nil }
return decoded
}
private static func writeStore(_ store: DeviceAuthStoreFile) {
let url = fileURL()
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
let data = try JSONEncoder().encode(store)
try data.write(to: url, options: [.atomic])
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
} catch {
// best-effort only
}
}
}

View File

@@ -1,11 +1,18 @@
import CryptoKit
import Foundation
struct DeviceIdentity: Codable, Sendable {
var deviceId: String
var publicKey: String
var privateKey: String
var createdAtMs: Int
public struct DeviceIdentity: Codable, Sendable {
public var deviceId: String
public var publicKey: String
public var privateKey: String
public var createdAtMs: Int
public init(deviceId: String, publicKey: String, privateKey: String, createdAtMs: Int) {
self.deviceId = deviceId
self.publicKey = publicKey
self.privateKey = privateKey
self.createdAtMs = createdAtMs
}
}
enum DeviceIdentityPaths {
@@ -27,10 +34,10 @@ enum DeviceIdentityPaths {
}
}
enum DeviceIdentityStore {
public enum DeviceIdentityStore {
private static let fileName = "device.json"
static func loadOrCreate() -> DeviceIdentity {
public static func loadOrCreate() -> DeviceIdentity {
let url = self.fileURL()
if let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data),
@@ -44,7 +51,7 @@ enum DeviceIdentityStore {
return identity
}
static func signPayload(_ payload: String, identity: DeviceIdentity) -> String? {
public static func signPayload(_ payload: String, identity: DeviceIdentity) -> String? {
guard let privateKeyData = Data(base64Encoded: identity.privateKey) else { return nil }
do {
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyData)
@@ -76,7 +83,7 @@ enum DeviceIdentityStore {
.replacingOccurrences(of: "=", with: "")
}
static func publicKeyBase64Url(_ identity: DeviceIdentity) -> String? {
public static func publicKeyBase64Url(_ identity: DeviceIdentity) -> String? {
guard let data = Data(base64Encoded: identity.publicKey) else { return nil }
return self.base64UrlEncode(data)
}

View File

@@ -15,6 +15,9 @@ extension URLSessionWebSocketTask: WebSocketTasking {}
public struct WebSocketTaskBox: @unchecked Sendable {
public let task: any WebSocketTasking
public init(task: any WebSocketTasking) {
self.task = task
}
public var state: URLSessionTask.State { self.task.state }
@@ -91,9 +94,20 @@ 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
private enum ConnectChallengeError: Error {
case timeout
}
public actor GatewayChannelActor {
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway")
private var task: WebSocketTaskBox?
@@ -110,9 +124,11 @@ 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
private let connectChallengeTimeoutSeconds: Double = 0.75
private var watchdogTask: Task<Void, Never>?
private var tickTask: Task<Void, Never>?
private let defaultRequestTimeoutMs: Double = 15000
@@ -141,6 +157,8 @@ public actor GatewayChannelActor {
}
}
public func authSource() -> GatewayAuthSource { self.lastAuthSource }
public func shutdown() async {
self.shouldReconnect = false
self.connected = false
@@ -256,6 +274,8 @@ public actor GatewayChannelActor {
let clientDisplayName = options.clientDisplayName ?? InstanceIdentity.displayName
let clientId = options.clientId
let clientMode = options.clientMode
let role = options.role
let scopes = options.scopes
let reqId = UUID().uuidString
var client: [String: ProtoAnyCodable] = [
@@ -278,8 +298,8 @@ public actor GatewayChannelActor {
"caps": ProtoAnyCodable(options.caps),
"locale": ProtoAnyCodable(primaryLocale),
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
"role": ProtoAnyCodable(options.role),
"scopes": ProtoAnyCodable(options.scopes),
"role": ProtoAnyCodable(role),
"scopes": ProtoAnyCodable(scopes),
]
if !options.commands.isEmpty {
params["commands"] = ProtoAnyCodable(options.commands)
@@ -287,32 +307,56 @@ public actor GatewayChannelActor {
if !options.permissions.isEmpty {
params["permissions"] = ProtoAnyCodable(options.permissions)
}
if let token = self.token {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
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)])
} else if let password = self.password {
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
}
let identity = DeviceIdentityStore.loadOrCreate()
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let scopes = options.scopes.joined(separator: ",")
let payload = [
"v1",
let connectNonce = try await self.waitForConnectChallenge()
let scopesValue = scopes.joined(separator: ",")
var payloadParts = [
connectNonce == nil ? "v1" : "v2",
identity.deviceId,
clientId,
clientMode,
options.role,
scopes,
role,
scopesValue,
String(signedAtMs),
self.token ?? "",
].joined(separator: "|")
authToken ?? "",
]
if let connectNonce {
payloadParts.append(connectNonce)
}
let payload = payloadParts.joined(separator: "|")
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
params["device"] = ProtoAnyCodable([
var device: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(identity.deviceId),
"publicKey": ProtoAnyCodable(publicKey),
"signature": ProtoAnyCodable(signature),
"signedAt": ProtoAnyCodable(signedAtMs),
])
]
if let connectNonce {
device["nonce"] = ProtoAnyCodable(connectNonce)
}
params["device"] = ProtoAnyCodable(device)
}
let frame = RequestFrame(
@@ -322,40 +366,22 @@ public actor GatewayChannelActor {
params: ProtoAnyCodable(params))
let data = try self.encoder.encode(frame)
try await self.task?.send(.data(data))
guard let msg = try await task?.receive() else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (no response)"])
do {
let response = try await self.waitForConnectResponse(reqId: reqId)
try await self.handleConnectResponse(response, identity: identity, role: role)
} catch {
if canFallbackToShared {
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
}
throw error
}
try await self.handleConnectResponse(msg, reqId: reqId)
}
private func handleConnectResponse(_ msg: URLSessionWebSocketTask.Message, reqId: String) async throws {
let data: Data? = switch msg {
case let .data(d): d
case let .string(s): s.data(using: .utf8)
@unknown default: nil
}
guard let data else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (empty response)"])
}
let decoder = JSONDecoder()
guard let frame = try? decoder.decode(GatewayFrame.self, from: data) else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (invalid response)"])
}
guard case let .res(res) = frame, res.id == reqId else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (unexpected response)"])
}
private func handleConnectResponse(
_ res: ResponseFrame,
identity: DeviceIdentity,
role: String
) async throws {
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
@@ -373,6 +399,17 @@ public actor GatewayChannelActor {
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
self.tickIntervalMs = Double(tick)
}
if let auth = ok.auth,
let deviceToken = auth["deviceToken"]?.value as? String {
let authRole = auth["role"]?.value as? String ?? role
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
.compactMap { $0.value as? String } ?? []
_ = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes)
}
self.lastTick = Date()
self.tickTask?.cancel()
self.tickTask = Task { [weak self] in
@@ -424,6 +461,7 @@ public actor GatewayChannelActor {
waiter.resume(returning: .res(res))
}
case let .event(evt):
if evt.event == "connect.challenge" { return }
if let seq = evt.seq {
if let last = lastSeq, seq > last + 1 {
await self.pushHandler?(.seqGap(expected: last + 1, received: seq))
@@ -437,6 +475,63 @@ public actor GatewayChannelActor {
}
}
private func waitForConnectChallenge() async throws -> String? {
guard let task = self.task else { return nil }
do {
return try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
onTimeout: { ConnectChallengeError.timeout },
operation: { [weak self] in
guard let self else { return nil }
while true {
let msg = try await task.receive()
guard let data = self.decodeMessageData(msg) else { continue }
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue }
if case let .event(evt) = frame, evt.event == "connect.challenge" {
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = payload["nonce"]?.value as? String {
return nonce
}
}
}
})
} catch {
if error is ConnectChallengeError { return nil }
throw error
}
}
private func waitForConnectResponse(reqId: String) async throws -> ResponseFrame {
guard let task = self.task else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (no response)"])
}
while true {
let msg = try await task.receive()
guard let data = self.decodeMessageData(msg) else { continue }
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (invalid response)"])
}
if case let .res(res) = frame, res.id == reqId {
return res
}
}
}
private nonisolated func decodeMessageData(_ msg: URLSessionWebSocketTask.Message) -> Data? {
let data: Data? = switch msg {
case let .data(data): data
case let .string(text): text.data(using: .utf8)
@unknown default: nil
}
return data
}
private func watchTicks() async {
let tolerance = self.tickIntervalMs * 2
while self.connected {
@@ -498,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

@@ -29,5 +29,10 @@ public struct GatewayDecodingError: LocalizedError, Sendable {
public let method: String
public let message: String
public init(method: String, message: String) {
self.method = method
self.message = message
}
public var errorDescription: String? { "\(self.method): \(self.message)" }
}

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)")
@@ -180,12 +213,14 @@ public actor GatewayNodeSession {
"id": AnyCodable(request.id),
"nodeId": AnyCodable(request.nodeId),
"ok": AnyCodable(response.ok),
"payloadJSON": AnyCodable(response.payloadJSON ?? NSNull()),
]
if let payloadJSON = response.payloadJSON {
params["payloadJSON"] = AnyCodable(payloadJSON)
}
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 {

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