Compare commits

...

766 Commits

Author SHA1 Message Date
Peter Steinberger
d4e30d559b fix: scope chat scroll lock to chat shell (#1283) (thanks @bradleypriest) 2026-01-20 06:28:11 +00:00
Bradley Priest
ffe6d9ad54 ui(chat): fix double-scroll in web UI
Chat should scroll inside the thread, not the whole page.\n\n- Constrain the app shell to the viewport and disable outer scrolling.\n- Hide page-level scrolling for the chat tab so only .chat-thread scrolls.
2026-01-20 18:20:58 +13:00
Peter Steinberger
d4df747f9f fix: harden doctor config cleanup 2026-01-20 01:43:59 +00:00
Peter Steinberger
8e33bd8610 fix: repair doctor config cleanup 2026-01-20 01:30:33 +00:00
Peter Steinberger
3036c38144 fix: clarify config invalid output 2026-01-20 00:47:33 +00:00
Peter Steinberger
d72fc1ce7f fix: highlight invalid config error 2026-01-20 00:38:52 +00:00
Peter Steinberger
c6ef7ff921 fix: harden windows argv parsing 2026-01-19 23:41:06 +00:00
Peter Steinberger
44c61a77c5 fix: strip envelopes in chat history 2026-01-19 22:52:00 +00:00
Peter Steinberger
4bac76e66d fix: improve memory status and batch fallback 2026-01-19 22:49:06 +00:00
Shadow
39dfdccf6c CLI: skip runner rebuilds when dist is fresh (#1231)
Co-authored-by: mukhtharcm <mukhtharcm@users.noreply.github.com>
2026-01-19 13:12:33 -06:00
Peter Steinberger
754494d1a0 fix(android): align node protocol payloads 2026-01-19 16:53:31 +00:00
Peter Steinberger
37af1d6946 test: harden gateway sigterm argv 2026-01-19 16:35:45 +00:00
Peter Steinberger
90ea21536b style: format gateway sigterm test 2026-01-19 16:17:47 +00:00
Peter Steinberger
3690be9419 test: stabilize gateway windows sigterm 2026-01-19 16:16:13 +00:00
Peter Steinberger
079c29ceb8 refactor(android): drop legacy bridge transport 2026-01-19 15:45:50 +00:00
Peter Steinberger
c7808a543d test: stabilize windows gateway sigterm 2026-01-19 15:17:44 +00:00
Peter Steinberger
1aed588743 fix: sanitize windows argv control chars 2026-01-19 15:06:57 +00:00
Peter Steinberger
0af4eda8c5 fix: strip noisy windows argv entries 2026-01-19 15:04:26 +00:00
Peter Steinberger
cf2fe4b4c5 test: simplify sandbox path guard test 2026-01-19 14:46:07 +00:00
Peter Steinberger
5df58e404f fix: stabilize windows cli tests 2026-01-19 14:44:17 +00:00
Peter Steinberger
ef352d4dc6 style: format windows argv helpers 2026-01-19 14:19:26 +00:00
Peter Steinberger
cb2add8459 fix: sanitize windows node argv 2026-01-19 14:16:45 +00:00
Peter Steinberger
d9c20f6fa5 fix: normalize windows argv in cli 2026-01-19 13:55:34 +00:00
Peter Steinberger
79c93b2cf8 style: resolve swift lint warnings 2026-01-19 13:37:28 +00:00
Peter Steinberger
48bfaa2371 fix: normalize quoted windows argv 2026-01-19 13:30:34 +00:00
Peter Steinberger
56316ad932 fix: strip windows node exec from argv 2026-01-19 13:08:21 +00:00
Peter Steinberger
ba2514fc4c fix: stabilize windows test timeouts 2026-01-19 12:35:58 +00:00
Peter Steinberger
9e06d945a2 fix: stabilize gateway tests on windows 2026-01-19 12:12:51 +00:00
Peter Steinberger
588dc43787 fix: resolve format/build failures 2026-01-19 11:32:15 +00:00
Peter Steinberger
b826bd668c fix: pass android lint and swiftformat 2026-01-19 11:14:27 +00:00
Peter Steinberger
e6a4cf01ee feat: migrate android node to gateway ws 2026-01-19 11:05:59 +00:00
Peter Steinberger
fcea6303ed fix: add agents identity helper 2026-01-19 10:44:18 +00:00
Peter Steinberger
9292ec9880 chore: clean artifacts and fix device roles 2026-01-19 10:09:35 +00:00
Peter Steinberger
35e7c62e78 docs: unify ws protocol + platform guides 2026-01-19 10:09:28 +00:00
Peter Steinberger
66193dab92 fix: wire gateway tls fingerprint for wss 2026-01-19 10:09:22 +00:00
Peter Steinberger
4609ed70c1 fix: align exec approval gateway timeout 2026-01-19 10:09:17 +00:00
Peter Steinberger
428241d941 fix: treat exec approval timeouts as no-decision 2026-01-19 10:09:14 +00:00
Peter Steinberger
adfb000587 fix: keep device pairing requests on later 2026-01-19 10:09:10 +00:00
Peter Steinberger
3776de906f fix: stabilize gateway ws + iOS 2026-01-19 10:09:04 +00:00
Peter Steinberger
73afbc9193 chore: ignore local build artifacts 2026-01-19 10:08:38 +00:00
Peter Steinberger
795985d339 refactor: migrate iOS gateway to unified ws 2026-01-19 10:08:33 +00:00
Peter Steinberger
2f8206862a refactor: remove bridge protocol 2026-01-19 10:08:29 +00:00
Peter Steinberger
b347d5d9cc feat: add gateway tls support 2026-01-19 10:08:01 +00:00
Peter Steinberger
73e9e787b4 feat: unify device auth + pairing 2026-01-19 10:07:56 +00:00
Peter Steinberger
47d1f23d55 fix(daemon): include HOME in service env (#1214)
Thanks @ameno-.

Co-authored-by: Ameno Osman <ameno.osman13@gmail.com>
2026-01-19 08:39:12 +00:00
Peter Steinberger
c21469b282 docs: add showcase video 2026-01-19 07:01:44 +00:00
Peter Steinberger
10a0c96ee6 fix: drop reasoning-only openai-responses history 2026-01-19 06:22:46 +00:00
Peter Steinberger
e071493bb3 Merge pull request #1213 from andrew-kurin/fix/voicecall-tailscale-path
Voice-call: fix tailscale tunnel, Twilio signatures, and callbacks
2026-01-19 06:00:33 +00:00
Peter Steinberger
2dc9c95530 style: oxfmt core files 2026-01-19 05:59:29 +00:00
Peter Steinberger
d126e7f610 fix: resolve cli-highlight types and runtime info 2026-01-19 05:57:29 +00:00
Peter Steinberger
5ee03c82b4 Merge pull request #1212 from longmaba/fix/ui-build-windows-spawn
fix(ui): enable shell mode for spawn on Windows
2026-01-19 05:43:15 +00:00
Peter Steinberger
111aeb2c4f fix: cover sync ui spawn on Windows (#1212) (thanks @longmaba) 2026-01-19 05:42:42 +00:00
Long
23c2c638b7 fix(ui): enable shell mode for spawn on Windows 2026-01-19 05:41:38 +00:00
Peter Steinberger
6b8299eb33 chore: update package resolutions 2026-01-19 05:40:04 +00:00
Peter Steinberger
9822a53649 fix: centralize cli command registry
Co-authored-by: gumadeiras <gumadeiras@users.noreply.github.com>
2026-01-19 05:36:09 +00:00
Peter Steinberger
81d392a2d7 refactor: extract TUI syntax theme and fix changelog 2026-01-19 05:32:53 +00:00
Peter Steinberger
dbcec3ffaf docs: clarify session log agent id 2026-01-19 05:27:52 +00:00
Peter Steinberger
50c5231486 refactor: reuse prompt params in context report 2026-01-19 05:27:52 +00:00
Peter Steinberger
55d034358d refactor: unify system prompt runtime params 2026-01-19 05:27:52 +00:00
Peter Steinberger
374da34936 fix: canonicalize legacy session keys 2026-01-19 05:17:31 +00:00
Peter Steinberger
c578fca687 fix(tui): generic empty-state for searchable pickers (PR #1201, thanks @vignesh07)
Co-authored-by: Vignesh Natarajan <vigneshnatarajan92@gmail.com>
2026-01-19 05:16:06 +00:00
Vignesh Natarajan
dd18765b50 feat(tui): add fuzzy search to session and agent pickers
Use SearchableSelectList for /sessions and /agents pickers,
matching the /models picker behavior.

- Session picker: search by session key, display name, or date
- Agent picker: search by agent ID or name

🤖 AI-assisted (Claude)
2026-01-19 05:16:06 +00:00
Peter Steinberger
3e06fe84dc feat: add TUI code block syntax highlighting (#1200) (thanks @vignesh07) 2026-01-19 05:07:23 +00:00
Peter Steinberger
640e19988f Merge pull request #1200 from vignesh07/feat/tui-syntax-highlighting
feat(tui): add syntax highlighting for code blocks
2026-01-19 05:05:51 +00:00
Ghost
80dae2e5e8 Voice-call: avoid streaming on notify callbacks 2026-01-18 20:27:23 -08:00
Ghost
60b87826bb Voice-call: fix Twilio status callbacks 2026-01-18 20:20:53 -08:00
Ghost
b04b51d2c4 Voice-call: fix Twilio signature ordering 2026-01-18 20:03:13 -08:00
Peter Steinberger
de33bc70e7 docs: clarify node_modules guidance 2026-01-19 04:01:36 +00:00
Peter Steinberger
0c8ba6599b fix: add plugin config schema helper 2026-01-19 03:39:36 +00:00
Peter Steinberger
d1e9490f95 fix: enforce strict config validation 2026-01-19 03:39:25 +00:00
Ghost
cb7edb669f Voice-call: fix tailscale tunnel path 2026-01-18 18:59:58 -08:00
Peter Steinberger
a9fc2ca0ef fix: add git hook setup and stable config hash sorting 2026-01-19 02:02:17 +00:00
Peter Steinberger
dd1b08b3e8 fix: add safeguard compaction tool summaries 2026-01-19 01:44:17 +00:00
cpojer
af1004ebbd Make tool calls use human language by default. 2026-01-19 01:42:23 +00:00
Peter Steinberger
f3516fb316 fix: skip respawn in gateway sigterm test 2026-01-19 01:37:10 +00:00
Peter Steinberger
79d8267413 feat: auto-recreate sandbox containers on config change 2026-01-19 01:35:27 +00:00
Peter Steinberger
99bf65c539 style: apply oxfmt 2026-01-19 01:11:42 +00:00
Peter Steinberger
6a4b5fa4b5 fix: harden windows cli launch 2026-01-19 01:11:39 +00:00
Peter Steinberger
83511c0c09 refactor: consolidate nodes cli error handling 2026-01-19 00:52:31 +00:00
Peter Steinberger
1fec41b3df refactor: share cli runtime error handling 2026-01-19 00:52:31 +00:00
Peter Steinberger
c532d161c4 refactor: streamline routed cli setup 2026-01-19 00:52:31 +00:00
Peter Steinberger
989543c9c3 fix: propagate agent run context for subagent announce 2026-01-19 00:45:30 +00:00
Vignesh Natarajan
145adf540f fix: make syntax highlighting tests environment-agnostic
Tests now verify structure and content preservation rather than
checking for specific ANSI escape codes, which may not be present
in CI environments without TTY/color support.
2026-01-18 16:40:06 -08:00
Peter Steinberger
953472bf25 feat: add exec pathPrepend config 2026-01-19 00:35:43 +00:00
Peter Steinberger
d9384785a3 fix: stabilize ci checks 2026-01-19 00:34:26 +00:00
Seb Slight
2f6b5ffdfe Web: trim HTML error bodies in web_fetch (#1193)
* Web: trim HTML error bodies in web_fetch

* fix: trim web_fetch HTML error bodies (#1193) (thanks @sebslight)

---------

Co-authored-by: Sebastian Slight <sbarrios93@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-19 00:24:16 +00:00
Vignesh Natarajan
0e3c9e4a0e feat(tui): add syntax highlighting for code blocks
Add syntax highlighting to markdown code blocks in the TUI using
cli-highlight with a VS Code Dark-inspired color theme.

Features:
- 191 languages supported via highlight.js
- Auto-detection fallback for unknown languages
- Graceful fallback to plain styling on errors
- VS Code Dark-inspired color palette

Colors:
- Purple: keywords (const, function, if, etc.)
- Teal: built-ins (console, Math, print, etc.)
- Orange: strings
- Green: numbers, comments
- Yellow: function names
- Blue: literals (true, false, null)
- Red: diff deletions
- Light blue: variables, parameters

🤖 AI-assisted (Claude) - fully tested locally
2026-01-18 16:24:14 -08:00
Peter Steinberger
15311c138a macOS: fix onboarding test helper call 2026-01-19 00:19:44 +00:00
Peter Steinberger
dec71dbcf1 docs: update README channels + deepwiki badge 2026-01-19 00:17:42 +00:00
Peter Steinberger
5a4482412d fix(plugins): prefer dist plugin-sdk in tests 2026-01-19 00:15:45 +00:00
Peter Steinberger
4cf829608c chore: remove unused program context imports 2026-01-19 00:15:45 +00:00
Peter Steinberger
8de02e6074 test: stabilize sessions_send waits 2026-01-19 00:15:45 +00:00
Peter Steinberger
d802844bd6 fix: gate gateway restarts and discord abort reconnects 2026-01-19 00:15:45 +00:00
Peter Steinberger
e97bcf4dae refactor(plugins): improve loader resolution 2026-01-19 00:15:44 +00:00
Peter Steinberger
dad8e11f1e test: harden gateway mocks and env isolation 2026-01-19 00:15:44 +00:00
Peter Steinberger
50fdd514ae refactor(logging): split config + subsystem imports 2026-01-19 00:15:44 +00:00
Peter Steinberger
ee36e12f81 fix: log plugin load errors in gateway 2026-01-19 00:15:24 +00:00
Peter Steinberger
1e5569d56a fix: refine TUI model search rendering 2026-01-19 00:15:16 +00:00
Peter Steinberger
3ce1ee84ac Usage: add cost summaries to /usage + mac menu 2026-01-19 00:05:06 +00:00
Peter Steinberger
1ea3ac0a1d Merge pull request #1197 from chriseidhof/channels
The link should be skills
2026-01-18 23:59:17 +00:00
Peter Steinberger
66b6c9e0e5 chore: document slack bolt import interop 2026-01-18 23:55:36 +00:00
Peter Steinberger
b5e99dad1f fix(slack): handle bolt CJS interop (#1191) — thanks @CoreyH
Co-authored-by: Corey Henderson <corey@example.com>
2026-01-18 23:54:50 +00:00
Peter Steinberger
6f5205d826 docs: elevate security audit callout 2026-01-18 23:37:14 +00:00
Peter Steinberger
5f975a4eff Merge pull request #1195 from gumadeiras/main
enhancement: 3x faster CLI invocation, unify boolean/env parsing, streamline CLI startup paths
2026-01-18 23:28:36 +00:00
Peter Steinberger
aadfdbc59f chore: update pnpm lockfile 2026-01-18 23:28:21 +00:00
Peter Steinberger
d5c8172197 fix: optimize routed CLI path (#1195) (thanks @gumadeiras) 2026-01-18 23:28:09 +00:00
Peter Steinberger
9e804f6f40 Merge pull request #1185 from KrauseFx/improve-anthropic-token-hints
chore(auth): Improve Anthropic token option hints in onboarding wizard
2026-01-18 23:27:58 +00:00
Peter Steinberger
bedfc3642d Merge pull request #1198 from vignesh07/feat/tui-model-picker-search
feat(tui): add fuzzy search to model picker 🔍
2026-01-18 23:27:02 +00:00
Peter Steinberger
46dcda1d0c fix: preserve fuzzy ranking in model picker (#1198) (thanks @vignesh07) 2026-01-18 23:26:42 +00:00
Vignesh Natarajan
950f8a04ea fix: prioritize exact substring matches over fuzzy in model search
- Exact substring in label (earliest position wins)
- Word-boundary prefix matches
- Description substring matches
- Fuzzy matching as fallback

This ensures 'opus' shows claude-3-opus before openrouter models.
2026-01-18 23:18:28 +00:00
Vignesh Natarajan
de44e0ad33 feat(tui): add fuzzy search to model picker
- Add SearchableSelectList component with fuzzy filtering
- Integrate with /models command for quick model search
- Support up/down navigation while typing
- Uses pi-tui's fuzzyFilter for intelligent matching
2026-01-18 23:18:28 +00:00
Peter Steinberger
c639b386da fix: hide menubar usage errors 2026-01-18 23:18:10 +00:00
Gustavo Madeira Santana
fac0110e49 removing aux funcs for benchmarking
Leftover functions I was using the benchmark and time CLI calls
2026-01-18 23:10:39 +00:00
Gustavo Madeira Santana
97971f3aef Remove unused import from run-main.ts
Deleted the unused import of hasHelpOrVersion from argv.js to clean up the code.
2026-01-18 23:10:39 +00:00
Gustavo Madeira Santana
acb523de86 CLI: streamline startup paths and env parsing
Add shared parseBooleanValue()/isTruthyEnvValue() and apply across CLI, gateway, memory, and live-test flags for consistent env handling.
Introduce route-first fast paths, lazy subcommand registration, and deferred plugin loading to reduce CLI startup overhead.
Centralize config validation via ensureConfigReady() and add config caching/deferred shell env fallback for fewer IO passes.
Harden logger initialization/imports and add focused tests for argv, boolean parsing, frontmatter, and CLI subcommands.
2026-01-18 23:10:39 +00:00
Peter Steinberger
97531f174f fix: import wizard prompter in onboarding adapters 2026-01-18 23:05:05 +00:00
Peter Steinberger
ef125d5ba7 docs: update changelog for docs:list guard 2026-01-18 22:53:59 +00:00
Peter Steinberger
86950d3474 fix: guard docs:list when docs dir missing 2026-01-18 22:53:39 +00:00
Peter Steinberger
3a9582bc41 docs: update channel allowlist guidance 2026-01-18 22:52:00 +00:00
Peter Steinberger
d198474415 feat: resolve allowlists in channel plugins 2026-01-18 22:52:00 +00:00
Peter Steinberger
ace8a1b44e feat: expand dm allowlist onboarding 2026-01-18 22:52:00 +00:00
Peter Steinberger
a7be3a9649 fix: honor telegram pairing allowlists for native commands 2026-01-18 22:52:00 +00:00
Peter Steinberger
0d543dd1ff test: update expectations for session reset behavior 2026-01-18 22:51:37 +00:00
Peter Steinberger
404c373153 feat: add agent targeting + reply overrides 2026-01-18 22:50:51 +00:00
Peter Steinberger
024691e4e7 feat(mac): manage node service in remote mode 2026-01-18 22:50:02 +00:00
Peter Steinberger
a86d7a2f35 Merge pull request #1196 from vignesh07/feat/tui-waiting-shimmer-clean
feat(tui): animated waiting status with shimmer effect 
2026-01-18 22:38:08 +00:00
Peter Steinberger
e7e34c442e fix: smooth TUI waiting shimmer (#1196) (thanks @vignesh07) 2026-01-18 22:37:36 +00:00
Peter Steinberger
9b9e8d4ae8 chore: block node_modules commits 2026-01-18 22:28:59 +00:00
Peter Steinberger
bf925e5758 chore: rename memory-lancedb extension folder 2026-01-18 22:27:22 +00:00
Peter Steinberger
c0c9df4ab7 build: update A2UI bundle hash 2026-01-18 22:26:12 +00:00
Peter Steinberger
6aa90f8b18 build: refresh A2UI bundle 2026-01-18 22:26:12 +00:00
Peter Steinberger
9af1c8a886 fix: patch session store updates 2026-01-18 22:26:12 +00:00
Peter Steinberger
ed5ece4120 fix: remove unreachable approval fallback 2026-01-18 22:26:12 +00:00
Peter Steinberger
85d1835476 feat: add live memory index progress 2026-01-18 22:25:08 +00:00
Vignesh Natarajan
e85d2dff97 TUI: pick waiting phrase once per waiting session 2026-01-18 22:19:47 +00:00
Vignesh Natarajan
fac66d4dda TUI: waiting shimmer helper + tests 2026-01-18 22:19:47 +00:00
Vignesh Natarajan
2e99369113 TUI: add animated waiting status with shimmer 2026-01-18 22:19:47 +00:00
Peter Steinberger
835f9ee575 docs: clarify envelope time work 2026-01-18 22:17:24 +00:00
Peter Steinberger
a136c6aa89 Merge pull request #1187 from fayrose/fix/compaction-failure-silent-reset
fix: return user-facing error when session reset after compaction failure
2026-01-18 22:02:36 +00:00
Chris Eidhof
af96bac2dd The link should be skills 2026-01-18 22:44:41 +01:00
Peter Steinberger
b621d4550b chore: tighten skills prompt rules 2026-01-18 21:30:27 +00:00
Lauren Rosenberg
c290217305 fix: add reserveTokensFloor suggestion to compaction error messages
When context limit is exceeded, the error message now suggests
setting agents.defaults.compaction.reserveTokensFloor to 4000
or higher to prevent future occurrences.
2026-01-18 19:37:15 +00:00
Peter Steinberger
769b286cf2 fix: make docs list node-safe 2026-01-18 19:37:13 +00:00
Peter Steinberger
690bb192e6 style: format code 2026-01-18 19:36:46 +00:00
Peter Steinberger
601a052216 fix: unblock bundled plugin load 2026-01-18 19:34:21 +00:00
Peter Steinberger
bf3021d266 fix: stabilize logging imports and tests 2026-01-18 19:34:08 +00:00
Peter Steinberger
145b2e5f52 fix: menu preview label colors 2026-01-18 19:04:01 +00:00
Peter Steinberger
4b73dc95c4 fix: normalize envelope options 2026-01-18 18:59:34 +00:00
Peter Steinberger
e17cb408a5 fix(cli): load pairing channels after plugins 2026-01-18 18:56:40 +00:00
Peter Steinberger
3cf92152c3 fix: appease tsc in test helpers 2026-01-18 18:54:59 +00:00
Peter Steinberger
c50cde2170 fix: clean up lint in gateway tests 2026-01-18 18:51:43 +00:00
Peter Steinberger
7e0bebd669 docs: update clawtributors 2026-01-18 18:51:08 +00:00
Peter Steinberger
7c49326191 fix: satisfy oxlint spread rule 2026-01-18 18:50:52 +00:00
Peter Steinberger
744d1329cb feat: make inbound envelopes configurable
Co-authored-by: Shiva Prasad <shiv19@users.noreply.github.com>
2026-01-18 18:50:37 +00:00
Peter Steinberger
42e6ff4611 feat(cli): show Telegram bot username in status 2026-01-18 18:48:25 +00:00
Peter Steinberger
5f21bf735a chore: switch repo scripts to node 2026-01-18 18:46:18 +00:00
Peter Steinberger
ee380e9ab9 fix: run cli scripts via node build runner 2026-01-18 18:43:39 +00:00
Peter Steinberger
ab340c82fb fix: stabilize tests and logging 2026-01-18 18:43:31 +00:00
Peter Steinberger
57dd0505a3 Merge pull request #1181 from sebslight/plugins/exclusive-slots
Plugins: auto-select exclusive slots
2026-01-18 18:40:38 +00:00
Peter Steinberger
d6f9f1c79a Merge pull request #1182 from zerone0x/fix/issue-1115-filter-openrouter-auto
fix(configure): filter openrouter/auto from model selection list
2026-01-18 18:32:47 +00:00
Peter Steinberger
a08a772ffc fix: add model picker regression for openrouter auto (#1182) (thanks @zerone0x) 2026-01-18 18:32:25 +00:00
zerone0x
2622b1936b fix(configure): filter openrouter/auto from model selection list
The openrouter/auto model is OpenRouter's internal routing feature,
not a callable model. While it's valid as a default (set automatically
during OpenRouter auth flow), showing it in the configure wizard's
model selection causes "Unknown model: openrouter/auto" errors when
users select it manually.

Add a HIDDEN_ROUTER_MODELS set to filter out such internal models
from the selection list while preserving existing functionality.

Fixes #1115

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-18 18:31:55 +00:00
Peter Steinberger
c0457e0cc4 fix(mac): load menu session previews 2026-01-18 18:28:48 +00:00
Peter Steinberger
ee2f0a175a docs: add Windows installer git mode 2026-01-18 18:26:20 +00:00
Lauren Rosenberg
0e94f0c018 style: apply prettier formatting 2026-01-18 18:21:11 +00:00
Lauren Rosenberg
576485b0c9 fix: return user-facing error when session reset after compaction failure
Previously, when auto-compaction failed due to context overflow, the system
would reset the session and silently continue the execution loop without
sending any response to the user. This made it appear as if messages were
being ignored.

This change ensures users receive a clear error message explaining that
the context limit was exceeded and the conversation has been reset,
consistent with how role ordering conflicts are already handled.

Fixes the silent failure case where message + compaction exceeds context limits.
2026-01-18 18:16:20 +00:00
Peter Steinberger
60efe8ed7b fix: restore bun runners for dev scripts 2026-01-18 18:00:48 +00:00
Peter Steinberger
332a20d9cc fix: update gateway watch runner 2026-01-18 17:55:50 +00:00
Felix Krause
57bf6d5eaf Improve Anthropic token option hints in onboarding wizard 2026-01-18 18:39:14 +01:00
Peter Steinberger
f16b0cf80d fix: stabilize ci protocol + openai batch retry 2026-01-18 17:05:27 +00:00
Peter Steinberger
a4ee933022 fix: hide macOS usage errors 2026-01-18 16:52:53 +00:00
Peter Steinberger
cf7437cb4c fix: unblock macOS exec host build 2026-01-18 16:44:26 +00:00
Peter Steinberger
081123c0e4 feat: route macOS node exec via app IPC 2026-01-18 16:41:44 +00:00
Peter Steinberger
5fe3c36471 fix(build): resolve ts2367 comparisons 2026-01-18 16:35:52 +00:00
Peter Steinberger
e06158c645 docs: update changelog 2026-01-18 16:35:52 +00:00
Peter Steinberger
19a8547ecd feat(onboarding): wire plugin-backed auth choices 2026-01-18 16:35:52 +00:00
Peter Steinberger
32ae4566c6 feat(config): auto-enable configured plugins 2026-01-18 16:35:52 +00:00
Peter Steinberger
be6a3d4caf fix: unblock build and slack monitor 2026-01-18 16:35:18 +00:00
Peter Steinberger
1db0384090 feat(doctor): repair launch agent bootstrap
Co-authored-by: Dr Alexander Mikhalev <alex@metacortex.engineer>
2026-01-18 16:35:18 +00:00
Peter Steinberger
d024dceef7 Merge pull request #1180 from andrew-kurin/fix/voice-call-statuscallback
fix(voice-call): resolve StatusCallback with inline TwiML (#864)
2026-01-18 16:34:58 +00:00
Peter Steinberger
5ec499e14c docs: clarify mac gateway launch behavior 2026-01-18 16:29:38 +00:00
Peter Steinberger
0b350d78d5 fix: harden macOS signing flow 2026-01-18 16:28:39 +00:00
Peter Steinberger
96ee027371 feat: list eligible hooks in onboarding 2026-01-18 16:28:39 +00:00
Peter Steinberger
ffcf3263c1 fix: exec approvals parsing + boot-md changelog 2026-01-18 16:28:39 +00:00
Sebastian Slight
cef6b16d14 Plugins: auto-select exclusive slots 2026-01-18 11:26:50 -05:00
Peter Steinberger
d06d440086 docs: clarify macOS node service IPC plan 2026-01-18 16:24:43 +00:00
Peter Steinberger
415fc9092e test(cli): align memory CLI tests 2026-01-18 16:12:10 +00:00
Peter Steinberger
0be9d773cb fix(memory): preserve fallback source id 2026-01-18 16:12:10 +00:00
Peter Steinberger
ecb45660e9 fix(cli): avoid empty spreads in approvals CLI 2026-01-18 16:12:10 +00:00
Peter Steinberger
f6fefd7f5f fix(exec-approvals): fix command token parsing 2026-01-18 16:12:10 +00:00
Peter Steinberger
4206b9684b docs(faq): refresh nodes, sessions, memory defaults
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-01-18 16:12:10 +00:00
Peter Steinberger
a4aad1c76a feat(cli): expand memory status across agents
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-01-18 16:12:10 +00:00
Peter Steinberger
9464774133 feat(memory): add gemini batches + safe reindex
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-01-18 16:12:10 +00:00
Peter Steinberger
be7191879a feat(memory): add gemini embeddings + auto select providers
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-01-18 16:12:10 +00:00
Gustavo Madeira Santana
7252938339 fix(utils): share clamp helpers
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-01-18 16:11:43 +00:00
Peter Steinberger
810394f43b fix: improve remote bin probe logging 2026-01-18 16:09:48 +00:00
Peter Steinberger
835162fb62 fix: retry openai batch indexing 2026-01-18 16:08:22 +00:00
Peter Steinberger
82883095fe docs: explain Copilot provider options 2026-01-18 16:06:48 +00:00
Peter Steinberger
49d8ad3049 feat: surface node core/ui versions in macOS 2026-01-18 16:00:36 +00:00
Peter Steinberger
1721d04405 feat: add node core/ui versions in bridge 2026-01-18 15:59:54 +00:00
Peter Steinberger
633e0d9382 Merge pull request #1164 from ngutman/feat/boot-md
feat(hooks): run BOOT.md on gateway startup
2026-01-18 15:59:53 +00:00
Ghost
e156320c51 fix(voice-call): resolve StatusCallback with inline TwiML
- Switch from inline to URL-based TwiML for outbound calls
- Store TwiML content temporarily and serve on webhook request
- Add twimlStorage map and cleanup helper methods
- Fix TwiML serving to handle CallStatus='in-progress' on initial request

Closes #864
2026-01-18 07:51:59 -08:00
Peter Steinberger
f06ce98312 refactor: rename lancedb memory plugin 2026-01-18 15:48:05 +00:00
Peter Steinberger
b546b2a48d fix: stabilize slack http receiver import 2026-01-18 15:44:17 +00:00
Peter Steinberger
c11b016d22 fix: prefer node service naming 2026-01-18 15:33:22 +00:00
Peter Steinberger
3686bde783 feat: add exec approvals tooling and service status 2026-01-18 15:23:41 +00:00
Peter Steinberger
9c06689569 fix: sanitize oversized image payloads 2026-01-18 15:21:38 +00:00
Peter Steinberger
891a2cc64a docs: tighten GitHub newline guidance 2026-01-18 15:20:09 +00:00
Peter Steinberger
01211937fc fix: link bash disabled docs 2026-01-18 15:17:09 +00:00
Peter Steinberger
4726580c7e feat(slack): add HTTP receiver webhook mode (#1143) - thanks @jdrhyne
Co-authored-by: Jonathan Rhyne <jdrhyne@users.noreply.github.com>
2026-01-18 15:04:07 +00:00
Peter Steinberger
e9a08dc507 feat: enrich system prompt docs guidance 2026-01-18 15:00:36 +00:00
Peter Steinberger
f3698e360b docs: add api usage and costs overview 2026-01-18 14:55:09 +00:00
Peter Steinberger
c69947dff8 feat: auto-enable audio understanding when keys exist 2026-01-18 14:55:09 +00:00
Peter Steinberger
173bce34b0 docs: add dep patch approval rule 2026-01-18 14:46:03 +00:00
Peter Steinberger
6a27e385b1 docs: map agent loop hook points 2026-01-18 14:43:35 +00:00
Peter Steinberger
5f0d9c3eb9 docs: expand agent loop overview 2026-01-18 14:30:12 +00:00
Peter Steinberger
0e31c8153c fix: bump Peekaboo revision 2026-01-18 14:26:19 +00:00
Peter Steinberger
9c0773c469 chore: update dependencies 2026-01-18 14:16:04 +00:00
Peter Steinberger
f5533baf61 test: add vector dedupe regression coverage 2026-01-18 14:08:06 +00:00
Peter Steinberger
60bc436e99 Merge pull request #1175 from vrknetha/fix/tool-error-fallback
Agents: surface tool failures without assistant output
2026-01-18 14:08:02 +00:00
Peter Steinberger
741b984a68 docs: fix #1151 changelog attribution 2026-01-18 14:04:38 +00:00
Peter Steinberger
858a5f48d8 Merge pull request #1176 from sibbl/fix-matrix-allowfrom
Matrix: fix redundant allowFrom assignment in monitorMatrixProvider
2026-01-18 13:57:00 +00:00
Peter Steinberger
20c26eb303 fix: prevent sqlite-vec duplicate id failures 2026-01-18 13:55:56 +00:00
Peter Steinberger
f3ef609839 fix: show exec approval alerts for local mac node 2026-01-18 13:42:23 +00:00
Sebastian Schubotz
234fe5b5cd fix(matrix): remove redundant allowFrom assignment in monitorMatrixProvider 2026-01-18 14:05:08 +01:00
vrknetha
65710932ff Agents: surface tool failures without assistant output 2026-01-18 18:35:03 +05:30
Peter Steinberger
e944f21ec0 test: drop core runtime import in matrix directory 2026-01-18 11:03:27 +00:00
Peter Steinberger
ee6e534ccb refactor: route channel runtime via plugin api 2026-01-18 11:01:16 +00:00
Nimrod Gutman
11b07f4a29 feat(hooks): run boot.md on gateway startup 2026-01-18 11:50:25 +02:00
Peter Steinberger
676d41d415 fix: seed embedding cache for atomic reindex 2026-01-18 09:28:42 +00:00
Peter Steinberger
a3a4996adb feat: add gemini memory embeddings 2026-01-18 09:09:45 +00:00
Peter Steinberger
b015c7e5ad fix: sync protocol outputs 2026-01-18 08:58:41 +00:00
Peter Steinberger
4de3c3a028 feat: add exec approvals editor in control ui and mac app 2026-01-18 08:54:38 +00:00
Peter Steinberger
b739a3897f fix: stabilize acp streams and tests 2026-01-18 08:54:00 +00:00
Peter Steinberger
c5e19f5c67 refactor: migrate messaging plugins to sdk 2026-01-18 08:54:00 +00:00
Peter Steinberger
9241e21114 fix: address acp client typing 2026-01-18 08:51:57 +00:00
Peter Steinberger
65bed815a8 fix: resolve ci failures 2026-01-18 08:45:29 +00:00
Peter Steinberger
d776cfb4e1 fix: skip launchd for remote mode 2026-01-18 08:35:14 +00:00
Peter Steinberger
c6e7e1821b test: tolerate tool summary payloads in install e2e 2026-01-18 08:33:45 +00:00
Peter Steinberger
f76ab69612 feat: add memory indexing progress options 2026-01-18 08:30:04 +00:00
Peter Steinberger
889db137b8 test: add beta tag install option for docker installer 2026-01-18 08:30:00 +00:00
Peter Steinberger
9db682750d chore: point Peekaboo to main 2026-01-18 08:29:00 +00:00
Peter Steinberger
9809b47d45 feat(acp): add interactive client harness 2026-01-18 08:27:37 +00:00
Peter Steinberger
68d79e56c2 feat: add node binding controls in control ui 2026-01-18 08:26:32 +00:00
Peter Steinberger
d3862ae30a fix(auth): preserve auto-pin preference
Co-authored-by: Mykyta Bozhenko <21245729+cheeeee@users.noreply.github.com>
2026-01-18 08:22:55 +00:00
Peter Steinberger
e49a2952d9 fix: clean up duplicate import (#1098)
Follow-up after rebase.
2026-01-18 08:15:21 +00:00
Peter Steinberger
8b57f519c3 fix: tighten native image injection (#1098)
Thanks @tyler6204.

Co-authored-by: Tyler Yust <tyler6204@users.noreply.github.com>
2026-01-18 08:15:21 +00:00
Tyler Yust
ddcc05f5f4 fix: improve error handling for file URL processing
- Enhanced error handling in image reference detection to skip malformed file URLs without crashing.
- Updated media loading logic to throw an error for invalid file URLs, ensuring better feedback for users.
2026-01-18 08:15:21 +00:00
Tyler Yust
8c0e290db1 fix: enhance image reference detection and optimize image processing
- Added support for detecting file URLs in prompts using fileURLToPath for accurate path resolution.
- Updated image loading logic to default to JPEG format for optimized image processing.
- Improved error handling in image optimization to continue processing on failures.
2026-01-18 08:15:21 +00:00
Tyler Yust
7bfc77db25 fix: improve file URL handling and enhance image loading logic
- Added handling for file URLs using fileURLToPath for proper resolution.
- Updated logic to skip relative path resolution if ref.resolved is already absolute.
- Enhanced cap calculation for image loading to handle undefined maxBytes more gracefully.
2026-01-18 08:15:21 +00:00
Tyler Yust
8d74578ceb feat: native image injection for vision-capable models
- Auto-detect and load images referenced in user prompts
- Inject history images at their original message positions
- Fix EXIF orientation - rotate before resizing in resizeToJpeg
- Sandbox security: validate paths, block remote URLs when sandbox enabled
- Prevent duplicate history image injection across turns
- Handle string-based user message content (convert to array)
- Add bounds check for message index in history processing
- Fix regex to properly match relative paths (./  ../)
- Add multi-image support for iMessage attachments
- Pass MAX_IMAGE_BYTES limit to image loading

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 08:15:21 +00:00
Peter Steinberger
f7123ec30a fix: repair context report and tool config 2026-01-18 08:15:21 +00:00
Peter Steinberger
ad4f4388f4 docs: explain per-agent exec node binding 2026-01-18 08:15:15 +00:00
Peter Steinberger
2a86504723 perf: lazy-load memory manager 2026-01-18 08:05:36 +00:00
Peter Steinberger
de3b68740a feat(acp): add experimental ACP support
Co-authored-by: Jonathan Taylor <visionik@pobox.com>
2026-01-18 08:03:36 +00:00
Peter Steinberger
efaa73f543 docs: align exec event text 2026-01-18 08:01:25 +00:00
Peter Steinberger
1589c73697 test: cover bridge exec events 2026-01-18 08:01:25 +00:00
Peter Steinberger
359d2af8a8 fix: resolve mac build errors 2026-01-18 08:00:58 +00:00
Peter Steinberger
fa897e5dfe docs: explain node host use cases 2026-01-18 07:59:03 +00:00
Peter Steinberger
7fa8ae56cb docs: add exec events to bridge protocol 2026-01-18 07:59:03 +00:00
Peter Steinberger
ec27c813cc fix(fallback): handle timeout aborts
Co-authored-by: Mykyta Bozhenko <21245729+cheeeee@users.noreply.github.com>
2026-01-18 07:52:44 +00:00
Peter Steinberger
3b24fe639a chore: remove peekaboo submodule 2026-01-18 07:47:32 +00:00
Peter Steinberger
e5cca6e432 chore: switch Peekaboo to SPM 2026-01-18 07:47:31 +00:00
Peter Steinberger
ae0b4c4990 feat: add exec host routing + node daemon 2026-01-18 07:46:00 +00:00
Peter Steinberger
49bd2d96fa test: fix gateway test lint 2026-01-18 07:44:14 +00:00
Peter Steinberger
ca350fc66c chore(format): oxfmt memory 2026-01-18 07:30:07 +00:00
Peter Steinberger
30338ce1a7 refactor: share memory plugin config helpers 2026-01-18 07:24:16 +00:00
Peter Steinberger
faa94f0168 Merge pull request #1148 from TSavo/refactor/gateway-test-monkeypatching
refactor: remove monkeypatching from gateway tests
2026-01-18 07:16:33 +00:00
Peter Steinberger
f5c84768ff chore(format): oxfmt 2026-01-18 07:14:40 +00:00
Peter Steinberger
df752d4706 Merge pull request #1149 from radek-paclt/feature/memory-plugin-v2
feat(memory): add lifecycle hooks and vector memory plugin
2026-01-18 07:10:06 +00:00
Peter Steinberger
c9c9516206 refactor(memory): extract sync + status helpers 2026-01-18 07:03:06 +00:00
Peter Steinberger
d3b15c6afa ci: stabilize vitest runs 2026-01-18 06:58:54 +00:00
Peter Steinberger
f86b24c511 refactor(session): centralize thread reset detection
Co-authored-by: Austin Mudd <austinm911@gmail.com>
2026-01-18 06:55:04 +00:00
Peter Steinberger
b5ddf08763 test: expand soul-evil coverage 2026-01-18 06:39:26 +00:00
Peter Steinberger
367826f6e4 feat(session): add daily reset policy
Co-authored-by: Austin Mudd <austinm911@gmail.com>
2026-01-18 06:37:37 +00:00
Peter Steinberger
f03c3b3f05 docs: update changelog for #1147
Co-authored-by: Andrew Lauppe <andy@t5tele.com>
2026-01-18 06:37:29 +00:00
Radek Paclt
ebfeb7a6bf feat(memory): add lifecycle hooks and vector memory plugin
Add plugin lifecycle hooks infrastructure:
- before_agent_start: inject context before agent loop
- agent_end: analyze conversation after completion
- 13 hook types total (message, tool, session, gateway hooks)

Memory plugin implementation:
- LanceDB vector storage with OpenAI embeddings
- kind: "memory" to integrate with upstream slot system
- Auto-recall: injects <relevant-memories> when context found
- Auto-capture: stores preferences, decisions, entities
- Rule-based capture filtering with 0.95 similarity dedup
- Tools: memory_recall, memory_store, memory_forget
- CLI: clawdbot ltm list|search|stats

Plugin infrastructure:
- api.on() method for hook registration
- Global hook runner singleton for cross-module access
- Priority ordering and error catching

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 06:34:43 +00:00
Peter Steinberger
ac1b2d8c40 chore(gate): fix lint and protocol 2026-01-18 06:31:02 +00:00
Peter Steinberger
2087f0c6a1 ci: bump vitest timeouts 2026-01-18 06:31:02 +00:00
Peter Steinberger
bcfdcc6820 fix: keep bootstrap files in context report 2026-01-18 06:30:01 +00:00
Peter Steinberger
b65acfcbb7 chore(lint): fix context report bootstrap destructure 2026-01-18 06:30:01 +00:00
Peter Steinberger
f7fcfafb4c fix: resolve lint after rebase 2026-01-18 06:30:01 +00:00
Peter Steinberger
15606b4d88 test: cover bundled memory plugin package metadata 2026-01-18 06:30:01 +00:00
Peter Steinberger
bb8f08734a build: package memory-core as a workspace plugin 2026-01-18 06:30:01 +00:00
Peter Steinberger
0b00e591e1 fix(streaming): emit assistant deltas
Co-authored-by: Andrew Lauppe <andy@t5tele.com>
2026-01-18 06:24:52 +00:00
Peter Steinberger
e39fd7dbb3 docs: update bundled hooks list 2026-01-18 06:23:09 +00:00
Peter Steinberger
b8a82923e9 docs: add soul-evil hook docs 2026-01-18 06:21:00 +00:00
Peter Steinberger
28f8b7bafa refactor: add hook guards and test helpers 2026-01-18 06:15:24 +00:00
Peter Steinberger
32dd052260 chore: show plugin hooks in plugins info 2026-01-18 06:14:09 +00:00
Peter Steinberger
8f7f7ee7dc feat: add /exec session overrides 2026-01-18 06:12:54 +00:00
Peter Steinberger
1d8614c7c2 fix: align exec tool config and test timeouts 2026-01-18 06:12:53 +00:00
Peter Steinberger
436c5fd751 fix(openai-http): reuse history markers for chat prompts
Co-authored-by: Andrew Lauppe <andy@t5tele.com>
2026-01-18 06:07:59 +00:00
Peter Steinberger
f5f7f47c81 chore(format): oxfmt hooks-cli 2026-01-18 06:03:22 +00:00
Peter Steinberger
d4bd387e0e chore(gate): fix lint and formatting 2026-01-18 06:01:25 +00:00
Peter Steinberger
d1c85cb32d test(gateway): stabilize cron temp cleanup 2026-01-18 06:01:25 +00:00
Peter Steinberger
a3a2c641a7 test(usage): cover modes and full footer 2026-01-18 06:01:25 +00:00
Peter Steinberger
54d7551b53 refactor(usage): centralize responseUsage mode 2026-01-18 06:01:25 +00:00
Peter Steinberger
e2c10a2b7a feat: support plugin-managed hooks 2026-01-18 05:57:05 +00:00
Peter Steinberger
88b37e80fc refactor: expand bootstrap helpers and tests 2026-01-18 05:51:55 +00:00
Peter Steinberger
d5be8fa576 test: avoid timer hangs in cron tests 2026-01-18 05:44:22 +00:00
Peter Steinberger
208398973b test: stabilize gateway suites 2026-01-18 05:44:22 +00:00
Peter Steinberger
8f998741b7 fix: shorten doctor gateway health timeout in non-interactive 2026-01-18 05:44:22 +00:00
Peter Steinberger
9c0ff87c86 fix: align plugin runtime and exec wiring 2026-01-18 05:44:22 +00:00
Peter Steinberger
1a0d1cb7b2 test: stabilize gateway ports and timers 2026-01-18 05:44:22 +00:00
Peter Steinberger
cf8b3ed988 fix: harden memory indexing and embedded session locks 2026-01-18 05:41:45 +00:00
Peter Steinberger
b7575a889e refactor: align status with plugin memory slot 2026-01-18 05:40:10 +00:00
Peter Steinberger
154d4a43db build: export plugin-sdk for extensions 2026-01-18 05:40:10 +00:00
Peter Steinberger
b5c023044b docs: expand memory hybrid search explainer 2026-01-18 05:40:10 +00:00
Peter Steinberger
072a13f3b2 test: expand memory hybrid coverage 2026-01-18 05:40:10 +00:00
Peter Steinberger
c00ea63bb0 refactor: split memory manager internals 2026-01-18 05:40:10 +00:00
Peter Steinberger
8350758635 chore(lint): fix unused vars and formatting 2026-01-18 05:38:23 +00:00
Peter Steinberger
2dabce59ce feat(slash-commands): usage footer modes 2026-01-18 05:35:35 +00:00
tsavo
b594f5130d refactor: add afterEach cleanup to all gateway tests
Added afterEach hooks with server/ws cleanup to:
- server.channels.test.ts (3 tests)
- server.config-apply.test.ts (2 tests)
- server.sessions-send.test.ts (already had this)

This ensures ports are properly released between tests, preventing
timeout issues from port conflicts.
2026-01-17 21:35:01 -08:00
tsavo
e2bb5eecf3 refactor: remove monkeypatching from gateway tests
Replace manual process.env backup/restore with vi.stubEnv():
- server.config-apply.test.ts: Simplified env var pattern
- server.channels.test.ts: Simplified env var pattern
- server.sessions-send.test.ts: Added afterEach cleanup hook, removed try-finally blocks from all 4 tests

Uses proper Vitest isolation instead of manual restoration.
2026-01-17 21:32:14 -08:00
Peter Steinberger
e7a4931932 refactor: centralize bootstrap file resolution 2026-01-18 05:31:04 +00:00
Peter Steinberger
ad3c12a43a feat: add bootstrap hook and soul-evil hook 2026-01-18 05:24:47 +00:00
Peter Steinberger
7e2d91f3b7 test: cover subagent helpers 2026-01-18 05:19:56 +00:00
Peter Steinberger
97cef49046 refactor: share subagent helpers 2026-01-18 05:19:56 +00:00
Peter Steinberger
016693a1f5 fix: abort embedded prompts on cancel 2026-01-18 05:18:10 +00:00
Peter Steinberger
89c5185f1c feat: migrate zalouser plugin to sdk
# Conflicts:
#	CHANGELOG.md
2026-01-18 05:17:40 +00:00
Peter Steinberger
b105745299 feat: expand subagent status visibility 2026-01-18 04:46:04 +00:00
Peter Steinberger
1ae415e395 fix: align agent exec config 2026-01-18 04:37:15 +00:00
Peter Steinberger
55aff22274 feat: surface batch request progress 2026-01-18 04:30:15 +00:00
Peter Steinberger
e4e1396a98 perf: improve batch status logging 2026-01-18 04:28:14 +00:00
Peter Steinberger
331b8157b0 docs: clarify plugin agent tool config 2026-01-18 04:28:00 +00:00
Peter Steinberger
efdb33c975 feat: add exec host approvals flow 2026-01-18 04:27:41 +00:00
Peter Steinberger
fa1079214b fix: include query in Twilio webhook verification 2026-01-18 04:25:28 +00:00
Peter Steinberger
82e49af5a7 fix: resolve plugin tool meta typing 2026-01-18 04:24:16 +00:00
Peter Steinberger
fabc2882aa fix: avoid keychain prompts in embedded runner 2026-01-18 04:19:28 +00:00
Peter Steinberger
6b3d3f5e21 refactor: centralize plugin tool policy helpers 2026-01-18 04:18:32 +00:00
Peter Steinberger
6da6582ced feat: add optional plugin tools 2026-01-18 04:08:00 +00:00
Peter Steinberger
45bf07ba31 Update canvas skill with Tailscale integration details and architecture 2026-01-18 03:57:19 +00:00
Peter Steinberger
50ae43f886 Add canvas skill documentation 2026-01-18 03:55:52 +00:00
Peter Steinberger
afb877a96b perf: speed up memory batch polling 2026-01-18 03:55:14 +00:00
Peter Steinberger
0d9172d761 fix: persist session origin metadata 2026-01-18 03:41:51 +00:00
Peter Steinberger
dad69afc84 fix: align plugin runtime types 2026-01-18 03:41:25 +00:00
Peter Steinberger
787bed4996 test: stabilize doctor + pi-embedded suites 2026-01-18 03:40:47 +00:00
Peter Steinberger
b6d470a679 feat: migrate zalo plugin to sdk 2026-01-18 03:37:26 +00:00
Peter Steinberger
5fa1a63978 Merge pull request #1136 from cheeeee/fix/prompt-failover
fix(agent): Enable model fallback for prompt-phase quota/rate limit errors
2026-01-18 03:32:03 +00:00
Peter Steinberger
6cc57ae772 feat: add bluebubbles plugin 2026-01-18 03:17:43 +00:00
Peter Steinberger
0f6f7059d9 test: stabilize embedded runner tests 2026-01-18 02:55:41 +00:00
Peter Steinberger
67f63ecd7e chore: remove tracked artifacts 2026-01-18 02:55:07 +00:00
Peter Steinberger
1420d113d8 refactor: migrate extensions to plugin sdk 2026-01-18 02:55:07 +00:00
Peter Steinberger
5b4651d9ed refactor: add plugin sdk runtime scaffolding 2026-01-18 02:52:30 +00:00
Peter Steinberger
5f22b68268 feat: add session origin metadata helpers 2026-01-18 02:42:11 +00:00
Peter Steinberger
34590d2144 feat: persist session origin metadata across connectors 2026-01-18 02:42:10 +00:00
Peter Steinberger
0c93b9b7bb style: apply oxfmt 2026-01-18 02:19:35 +00:00
Peter Steinberger
b659db0a5b chore(changelog): align 2026.1.17 versions 2026-01-18 02:13:56 +00:00
Peter Steinberger
9fd9f4c896 feat(plugins): add memory slot plugin 2026-01-18 02:12:10 +00:00
Peter Steinberger
005b831023 test: stabilize env-dependent tool defaults 2026-01-18 01:57:54 +00:00
Peter Steinberger
8013c4717c feat: show memory summary in status 2026-01-18 01:57:54 +00:00
Peter Steinberger
14e6b21b50 test: cover perplexity baseUrl precedence 2026-01-18 01:56:34 +00:00
Peter Steinberger
62354dff9c refactor: share allowlist match metadata
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-18 01:49:25 +00:00
Peter Steinberger
ccb30665f7 feat: add hybrid memory search 2026-01-18 01:47:58 +00:00
Peter Steinberger
0fb2777c6d feat: add memory embedding cache 2026-01-18 01:47:58 +00:00
Peter Steinberger
568b8ee96c refactor: split web tools and docs 2026-01-18 01:42:54 +00:00
Peter Steinberger
fc60699f03 fix: delay discord slow listener warnings 2026-01-18 01:41:10 +00:00
Peter Steinberger
c1da78a271 refactor: share teams allowlist matching helpers
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-18 01:37:22 +00:00
Peter Steinberger
0674f1fa3c feat: add exec approvals allowlists 2026-01-18 01:34:31 +00:00
Mykyta Bozhenko
448394a0de fix(agent): Enable model fallback for prompt-phase quota/rate limit errors
When a prompt submission fails with quota or rate limit errors, throw
FailoverError instead of the raw promptError. This enables the model
fallback system to try alternative models.

Previously, rate limit errors during the prompt phase (before streaming)
were thrown directly, bypassing fallback. Only response-phase errors
triggered model fallback.

Now checks if fallback models are configured and the error is failover-
eligible. If so, wraps in FailoverError to trigger the fallback chain.
2026-01-18 01:29:48 +00:00
Peter Steinberger
3a0fd6be3c test: stub slack allowlist resolvers 2026-01-18 01:25:19 +00:00
Peter Steinberger
8b1bec11d0 feat: speed up memory batch indexing 2026-01-18 01:24:51 +00:00
Peter Steinberger
f73dbdbaea refactor: unify channel config matching and gating
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-18 01:24:00 +00:00
Peter Steinberger
05f49d2846 fix(slack): resolve allowlists async 2026-01-18 01:23:25 +00:00
Peter Steinberger
1d83389776 Merge pull request #1131 from CMLKevin/feat/perplexity-search-provider
feat(web): add Perplexity Sonar as alternative search provider
2026-01-18 01:16:00 +00:00
Peter Steinberger
e0e8f11f70 fix: bundle Textual resources in macOS app 2026-01-18 01:15:19 +00:00
Peter Steinberger
36d88f6079 fix: normalize gateway dev mode detection 2026-01-18 01:08:47 +00:00
Peter Steinberger
2c070952e1 Merge pull request #1120 from mukhtharcm/qwen-portal-oauth
Models: add Qwen Portal OAuth support
2026-01-18 01:04:46 +00:00
Peter Steinberger
fc45148155 fix: harden qwen oauth flow (#1120) (thanks @mukhtharcm) 2026-01-18 01:03:08 +00:00
Muhammed Mukhthar CM
215c395fc2 UI: simplify Qwen labels 2026-01-18 01:03:08 +00:00
Muhammed Mukhthar CM
b56b67cdbd UI: label Qwen provider 2026-01-18 01:03:08 +00:00
Muhammed Mukhthar CM
a760db9921 Docs: add Qwen Portal provider 2026-01-18 01:03:08 +00:00
Muhammed Mukhthar CM
8eb80ee40a Models: add Qwen Portal OAuth support 2026-01-18 01:03:08 +00:00
Peter Steinberger
f9e3b129ed test: reindex on embedding model change 2026-01-18 01:00:57 +00:00
Peter Steinberger
e5050abe2a docs: note model change reindex 2026-01-18 01:00:57 +00:00
Peter Steinberger
4f0771f67b fix(channels): clean up discord resolve typing 2026-01-18 01:00:25 +00:00
Peter Steinberger
075ff675ac refactor(channels): share allowlist + resolver helpers 2026-01-18 01:00:25 +00:00
Peter Steinberger
c7ea47e886 feat(channels): add resolve command + defaults 2026-01-18 01:00:24 +00:00
Rodrigo Uroz
b543339373 Update tagline.ts with a nice reference from an old movie 2026-01-18 00:59:43 +00:00
Peter Steinberger
22c7f659f6 fix: surface match metadata in audits and slack logs
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-18 00:50:36 +00:00
Peter Steinberger
79a44d0da4 refactor(channels): unify target parsing 2026-01-18 00:31:42 +00:00
Peter Steinberger
d593a809f0 fix: apply openai batch defaults 2026-01-18 00:29:18 +00:00
Peter Steinberger
22add31e91 docs: update changelog for sessions_spawn thinking 2026-01-18 00:17:28 +00:00
Peter Steinberger
b44d740720 refactor: centralize cli manager cleanup
Co-authored-by: Nicholas Spisak <jsnsdirect@gmail.com>
2026-01-18 00:16:01 +00:00
Peter Steinberger
4d590f9254 refactor(slack): centralize target parsing 2026-01-18 00:15:05 +00:00
Peter Steinberger
a5aa48beea feat: add dm allowlist match metadata logs
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-18 00:14:44 +00:00
Peter Steinberger
1bf3861ca4 feat: add thinking override to sessions_spawn 2026-01-18 00:14:18 +00:00
Kevin Lin
ff9d069a33 feat(web): add Perplexity Sonar as alternative search provider 2026-01-18 08:08:36 +08:00
joshrad-dev
f8052be369 docs: add docs for Copilot device flow 2026-01-18 00:06:04 +00:00
Peter Steinberger
a08438ae97 refactor(discord): centralize target parsing
Co-authored-by: Jonathan Rhyne <jonathan@pspdfkit.com>
2026-01-18 00:04:38 +00:00
Peter Steinberger
fe00d6aacf feat: add matrix room match metadata logs
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-18 00:00:00 +00:00
Peter Steinberger
984692cda2 refactor: reuse channel config resolver in matrix extension
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-17 23:53:05 +00:00
Peter Steinberger
4c12c4fc04 feat: add channel match metadata logs
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-17 23:48:45 +00:00
Peter Steinberger
794bab45ff fix: harden memory cli manager cleanup
Co-authored-by: Nicholas Spisak <jsnsdirect@gmail.com>
2026-01-17 23:45:42 +00:00
Peter Steinberger
16e5fa1db9 test: cover daemon install helpers 2026-01-17 23:41:45 +00:00
Peter Steinberger
125be3e111 fix: restore wizard/doctor imports 2026-01-17 23:41:45 +00:00
Peter Steinberger
b60a53e10d feat: enable batch indexing by default 2026-01-17 23:29:40 +00:00
Peter Steinberger
9de762faa2 refactor: unify gateway daemon install plan 2026-01-17 23:29:34 +00:00
Peter Steinberger
5aed38eebc fix(discord): honor thread allowlists in reactions
Co-authored-by: Codex <codex@openai.com>
2026-01-17 23:03:51 +00:00
Peter Steinberger
e63e483c38 refactor(channels): share channel config matching
Co-authored-by: Codex <codex@openai.com>
2026-01-17 23:03:51 +00:00
Shadow
277e43e32c Discord: inherit thread allowlists 2026-01-17 23:03:51 +00:00
Peter Steinberger
852aa16ca0 fix: stabilize memory sync progress 2026-01-17 23:02:03 +00:00
Peter Steinberger
82b7153ac1 fix: handle daemon install failure in wizard 2026-01-17 23:00:34 +00:00
Peter Steinberger
7d2e510087 fix: retry embedding 5xx errors 2026-01-17 22:48:50 +00:00
Peter Steinberger
9ca4c10e59 test: cover channels capabilities probes 2026-01-17 22:33:18 +00:00
Peter Steinberger
a31a79396b feat: add OpenAI batch memory indexing 2026-01-17 22:32:04 +00:00
Peter Steinberger
acc3eb11d0 Update bird skill with Twitter posting wisdom from Ruby
- CLI for reading only (Twitter flags CLI posts as automated)
- Browser tool with paste hack for writing
- React input workaround with ClipboardEvent
- Selectors and rate limiting tips
- Credit: Shadow's Ruby documented the forbidden arts
2026-01-17 22:28:23 +00:00
Peter Steinberger
9d9fff2991 fix: sessions list label fallback
Co-authored-by: abdaraxus <abdaraxus@users.noreply.github.com>
2026-01-17 22:22:01 +00:00
Peter Steinberger
030ed5d592 fix: skip empty memory chunks 2026-01-17 21:58:59 +00:00
Peter Steinberger
f6d359932a fix: parallelize memory embedding indexing 2026-01-17 21:57:12 +00:00
Peter Steinberger
3200b51160 fix: format exec elevated flag first in tool summaries 2026-01-17 21:54:24 +00:00
Peter Steinberger
4b11ebb30e fix: split long memory lines 2026-01-17 21:11:56 +00:00
Peter Steinberger
40345642fa fix: show memory index counts in progress 2026-01-17 21:09:22 +00:00
Peter Steinberger
e932772230 fix: report memory index progress 2026-01-17 20:42:04 +00:00
Peter Steinberger
63d466fe5e fix(telegram): expand text_link entities in inbound text
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 20:41:34 +00:00
Peter Steinberger
c2fada7062 fix: suppress duplicate discord slow-listener logs 2026-01-17 20:37:36 +00:00
Peter Steinberger
d9c29f5ce5 fix: add agent context to ws logs 2026-01-17 20:37:36 +00:00
Peter Steinberger
f5d5ef6857 feat: confirm memory index completion 2026-01-17 20:35:15 +00:00
Peter Steinberger
361a17415f chore: release 2026.1.17-1 2026-01-17 20:26:24 +00:00
Peter Steinberger
fb393c3c51 feat: add progress to memory status deep 2026-01-17 20:25:19 +00:00
Peter Steinberger
e0158c5d5d feat: add deep memory status checks 2026-01-17 20:18:36 +00:00
Peter Steinberger
be12b0771c fix: soften windows daemon install 2026-01-17 20:12:26 +00:00
Peter Steinberger
1309fc1f48 test: expand frontmatter coverage 2026-01-17 20:12:04 +00:00
Peter Steinberger
4fdecfb845 fix: split memory embedding batches 2026-01-17 20:10:11 +00:00
Peter Steinberger
31c6f178f3 fix: preserve inline frontmatter values 2026-01-17 19:56:10 +00:00
Peter Steinberger
1e2ab8bf1e fix: improve frontmatter parsing 2026-01-17 19:56:10 +00:00
Sebastian Slight
35a1d81518 fix: handle multi-line metadata blocks in HOOK.md frontmatter
The frontmatter parser was using a simple line-by-line regex that only
captured single-line key-value pairs. This meant multi-line metadata
blocks (as used by bundled hooks) were not parsed correctly.

Changes:
- Add extractMultiLineValue() to handle indented continuation lines
- Use JSON5 instead of JSON.parse() to support trailing commas
- Add comprehensive test coverage for frontmatter parsing

Fixes #1113
2026-01-17 19:56:10 +00:00
Peter Steinberger
1c4297d8b5 test: update memory cli mocks for vector probe 2026-01-17 19:49:41 +00:00
Peter Steinberger
e3638a9a9e fix: probe memory vector availability 2026-01-17 19:46:34 +00:00
Peter Steinberger
1f8558771a Docs: note MiniMax usage endpoint 2026-01-17 19:45:54 +00:00
Peter Steinberger
2e231d09ec Infra: update MiniMax usage endpoint 2026-01-17 19:45:48 +00:00
Peter Steinberger
727c07bd88 feat: add slack user scopes and teams graph hints 2026-01-17 19:33:03 +00:00
Peter Steinberger
c32ad19377 docs: restore changelog entries 2026-01-17 19:32:30 +00:00
Peter Steinberger
ef40ab2933 test: expand memory cli coverage 2026-01-17 19:30:46 +00:00
Peter Steinberger
e71fa4a145 docs: note session log disk access 2026-01-17 19:30:46 +00:00
Peter Steinberger
a7c0887f94 feat: add per-provider scope probes to channels capabilities 2026-01-17 19:28:52 +00:00
Peter Steinberger
53218b91c6 fix: close memory cli managers 2026-01-17 19:20:55 +00:00
Peter Steinberger
2d4de656d2 test: avoid global SIGTERM emit in child-process-bridge 2026-01-17 19:20:48 +00:00
Peter Steinberger
b0f44acf9e chore: bump versions to 2026.1.17 2026-01-17 19:16:35 +00:00
Peter Steinberger
a828e60067 feat: add channels capabilities command 2026-01-17 19:06:07 +00:00
Peter Steinberger
96df70fccf fix: add nested agent log context 2026-01-17 18:59:59 +00:00
Peter Steinberger
0e49dca53c feat: add experimental session memory source 2026-01-17 18:53:52 +00:00
Peter Steinberger
8ec4af4641 fix(status): show 2 usage windows in /status (#1101)
Thanks @rhjoh.

Co-authored-by: Rhys Johnston <rhys.johnston00@gmail.com>
2026-01-17 18:46:41 +00:00
Peter Steinberger
2f6d9417bd test(memory): await watch sync completion 2026-01-17 18:45:42 +00:00
Peter Steinberger
534a012a4e style: apply oxfmt 2026-01-17 18:32:23 +00:00
Peter Steinberger
7a3fa9ce03 feat: show update availability in status 2026-01-17 18:23:27 +00:00
Peter Steinberger
8a67d29748 fix: improve WSL2 systemd daemon hints 2026-01-17 18:19:55 +00:00
Peter Steinberger
408f4f2dac fix: reuse shared ansi stripper 2026-01-17 18:18:14 +00:00
Peter Steinberger
3df2dc0b15 fix: normalize exec tool alias naming 2026-01-17 18:15:45 +00:00
Peter Steinberger
5304a8c2d1 fix: add timestamped tool context to logs 2026-01-17 18:14:21 +00:00
Peter Steinberger
1569d29b2d fix: normalize telegram forwarded context (#1090) (thanks @sleontenko) 2026-01-17 18:08:23 +00:00
Peter Steinberger
50c8e74230 fix(doctor): avoid ack reaction migration without config (#1087)
Thanks @YuriNachos.

Co-authored-by: Yuri Chukhlib <YuriNachos@users.noreply.github.com>
2026-01-17 18:07:06 +00:00
Peter Steinberger
1045b032a2 refactor(logging): use subsystem loggers for discord/ws 2026-01-17 18:03:40 +00:00
Peter Steinberger
a813343aa7 docs: clarify model refs and runtime notes
Co-authored-by: Yuri Chukhlib <YuriNachos@users.noreply.github.com>
2026-01-17 18:03:40 +00:00
Peter Steinberger
5a08471dcd feat: add sqlite-vec memory search acceleration 2026-01-17 18:02:34 +00:00
Peter Steinberger
252dfbcd40 fix: include context in elevated exec denial 2026-01-17 17:55:11 +00:00
Peter Steinberger
75588fe732 test: expand semver parsing coverage 2026-01-17 17:54:41 +00:00
Peter Steinberger
9bbdeb3d52 Merge pull request #1111 from artuskg/fix/cli-install-version-suffix
macos: keep CLI install build suffix
2026-01-17 17:46:13 +00:00
Peter Steinberger
ec9ba5b784 fix: show full gateway version string in status (#1111) (thanks @artuskg) 2026-01-17 17:45:14 +00:00
Artus KG
cee4149884 macos: handle empty install version safely 2026-01-17 17:45:14 +00:00
Artus KG
5599e4cf35 test: make session update timestamp UTC 2026-01-17 17:45:14 +00:00
Artus KG
84cdd2df73 changelog: note CLI install build suffix fix 2026-01-17 17:45:14 +00:00
Artus KG
7929f57460 macos: keep CLI install build suffix 2026-01-17 17:45:04 +00:00
Peter Steinberger
7876679c5d style: apply oxfmt 2026-01-17 17:44:54 +00:00
Peter Steinberger
bc6928525d docs: note discord interaction logging fix 2026-01-17 17:42:56 +00:00
Peter Steinberger
755c847d9a fix: soften discord interaction logging 2026-01-17 17:42:46 +00:00
Peter Steinberger
80a8639940 refactor: centralize telegram send param parsing 2026-01-17 17:36:37 +00:00
Peter Steinberger
6cb5704291 Merge pull request #1085 from dan-dr/chore/kimi-code-provider
Add Kimi Code provider onboarding
2026-01-17 17:36:30 +00:00
Peter Steinberger
4a987c836d fix: add Kimi Code docs + defaults (#1085) (thanks @dan-dr) 2026-01-17 17:35:40 +00:00
Peter Steinberger
a2fb55326c Merge pull request #1099 from mukhtharcm/feat/message-tool-voice-support
feat(telegram): support sending audio as native voice notes via asVoice param in message tool
2026-01-17 17:33:20 +00:00
Peter Steinberger
af29c6a980 fix: allow media-only telegram voice sends (#1099) (thanks @mukhtharcm) 2026-01-17 17:33:08 +00:00
Muhammed Mukhthar CM
f2a0e8e5bb feat(telegram): support sending audio as native voice notes via asVoice param in message tool 2026-01-17 17:32:50 +00:00
Peter Steinberger
f6456c2883 Merge pull request #1106 from gumadeiras/patch-2
Remove extra 'logging' page in the docs
2026-01-17 17:32:15 +00:00
Peter Steinberger
39f0d000d1 Merge pull request #1088 from sibbl/fix-matrix
feat(matrix): fix sending bug, add specific support for voice messages and images
2026-01-17 17:27:44 +00:00
Peter Steinberger
a8d9d630bc fix: handle legacy matrix polls (#1088) (thanks @sibbl) 2026-01-17 17:27:12 +00:00
ddyo
e93a1d8138 feat: add kimi code provider onboarding 2026-01-17 17:25:07 +00:00
Peter Steinberger
f6681be6f4 style: tidy macOS config UI formatting 2026-01-17 17:22:42 +00:00
Peter Steinberger
c79ac3fe81 test: cover semver suffix variants 2026-01-17 17:15:08 +00:00
Sebastian Schubotz
b78b06353a feat(matrix): add specific voice message + image sending extending generic attachment sending 2026-01-17 17:12:38 +00:00
Sebastian Schubotz
c49b6cc241 fix(matrix): fix sending being broken by normalizing thread ID normalization in message sending functions; improve matrix types 2026-01-17 17:12:38 +00:00
Peter Steinberger
30c945fe92 fix: cover semver patch suffix parsing (#1110) (thanks @zerone0x) 2026-01-17 16:50:05 +00:00
Peter Steinberger
dfd511c310 Merge pull request #1110 from zerone0x/fix/issue-1107-semver-prerelease-suffix
fix(macos): parse semver patch correctly when version has prerelease suffix
2026-01-17 16:45:49 +00:00
Peter Steinberger
1657525201 chore: prep 2026.1.17 and onboard flow 2026-01-17 16:41:25 +00:00
zerone0x
3e4b0d0505 fix(macos): parse semver patch correctly when version has prerelease suffix
Strip prerelease (`-beta.1`) and build (`-4`) suffixes from the patch
component before parsing as integer. Previously `2026.1.11-4` parsed to
`patch: 0` because `Int("11-4")` returns nil; now correctly yields
`patch: 11`.

Fixes #1107

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-18 00:31:20 +08:00
Michael Behr
003c6c9ae1 add patches/.gitkeep (#1104) 2026-01-17 08:50:50 -06:00
Gustavo Madeira Santana
41fbb4d8b0 Remove extra 'logging' page in the docs
Removed 'logging' from the list of gateways.
2026-01-17 09:49:39 -05:00
Peter Steinberger
eecb340f64 style: format update-cli 2026-01-17 12:51:08 +00:00
Peter Steinberger
d029eaa0bb docs: tighten release preflight 2026-01-17 12:47:54 +00:00
Peter Steinberger
9c7dcc1ed7 chore: update appcast for 2026.1.16-2 2026-01-17 12:46:42 +00:00
Peter Steinberger
be37b39782 docs: clarify build-info release check 2026-01-17 12:34:37 +00:00
Peter Steinberger
49c35c752c fix: stamp build commit metadata 2026-01-17 12:30:11 +00:00
Peter Steinberger
25d8043b9d feat: add gateway update check on start 2026-01-17 12:07:17 +00:00
Peter Steinberger
f9f4a953fc docs: restore tmux skill 2026-01-17 11:52:50 +00:00
Peter Steinberger
34c3fbc66c chore: set extension versions to 2026.1.16 2026-01-17 11:40:25 +00:00
Peter Steinberger
a9f21b3d3a feat: add update channel support 2026-01-17 11:40:05 +00:00
Peter Steinberger
ed5c5629f6 fix: cut 2026.1.16-1 beta 2026-01-17 11:12:43 +00:00
Peter Steinberger
868952f958 docs: add release guardrails 2026-01-17 11:12:27 +00:00
Peter Steinberger
9b9836be71 fix: repair 2026.1.16 beta pack 2026-01-17 11:08:37 +00:00
Peter Steinberger
22cd839cb2 fix: include media-understanding in npm pack 2026-01-17 11:03:46 +00:00
Peter Steinberger
dc3ac9fa28 docs: update coding-agent skill guidance 2026-01-17 10:59:23 +00:00
Peter Steinberger
c874fa9712 chore: bump 2026.1.16 for beta 2026-01-17 10:49:49 +00:00
Peter Steinberger
6b784a9771 style: oxfmt 2026-01-17 10:26:08 +00:00
Peter Steinberger
f8e673cdbc fix: block invalid config startup
Co-authored-by: Muhammed Mukhthar CM <mukhtharcm@gmail.com>
2026-01-17 10:25:24 +00:00
Peter Steinberger
ad360b4d18 docs(discord): clarify slash command visibility 2026-01-17 10:19:34 +00:00
Peter Steinberger
69ba2765de refactor(security): harden CommandAuthorized plumbing 2026-01-17 10:19:34 +00:00
Peter Steinberger
31e8ecca10 fix: format verbose tool output by channel 2026-01-17 10:17:57 +00:00
Peter Steinberger
4ca38286d8 chore: fix lint/format and update changelog
Co-authored-by: ItzR3NO <ItzR3NO@users.noreply.github.com>
2026-01-17 10:16:35 +00:00
Peter Steinberger
fbf1c3ca3c test: cover plugin enable/disable semantics 2026-01-17 10:16:35 +00:00
Peter Steinberger
1a4313c2aa fix: avoid crash on memory embeddings errors (#1004) 2026-01-17 09:45:53 +00:00
Peter Steinberger
a6deb0d9d5 feat: bundle provider auth plugins
Co-authored-by: ItzR3NO <ItzR3NO@users.noreply.github.com>
2026-01-17 09:38:53 +00:00
Peter Steinberger
b6ea5895b6 fix: gate image tool and deepgram audio payload 2026-01-17 09:34:40 +00:00
Peter Steinberger
d66bc65ca6 refactor: unify media provider options 2026-01-17 09:28:05 +00:00
Peter Steinberger
89f85ddeab fix: normalize deepgram audio upload bytes 2026-01-17 09:19:27 +00:00
Peter Steinberger
bbb71c9198 refactor: prune legacy group targets 2026-01-17 09:01:47 +00:00
Peter Steinberger
ae6792522d feat: add deepgram audio options 2026-01-17 08:53:42 +00:00
Peter Steinberger
e637bbdfb5 feat: add Deepgram audio transcription
Co-authored-by: Safzan Pirani <safzanpirani@users.noreply.github.com>
2026-01-17 08:53:42 +00:00
Peter Steinberger
869ef0c5ba refactor(macos): centralize process pipe draining 2026-01-17 08:53:10 +00:00
Peter Steinberger
1002c74d9c refactor: share inbound envelope label helper 2026-01-17 08:51:31 +00:00
Peter Steinberger
61e60f3b84 docs: update changelog for 2026.1.16 2026-01-17 08:50:33 +00:00
Peter Steinberger
13b931c006 refactor: prune legacy group prefixes 2026-01-17 08:47:25 +00:00
Peter Steinberger
ab49fe0e92 fix: tidy iMessage/Signal sender envelopes (#1080) - thanks @tyler6204
Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM>
2026-01-17 08:29:54 +00:00
Tyler Yust
d0bc08a934 fix: reduce redundant envelope formatting for iMessage and Signal 2026-01-17 08:29:54 +00:00
Tyler Yust
64d21f5ea8 fix: improve message handling by logging sender issues 2026-01-17 08:29:54 +00:00
Peter Steinberger
0a95d8a840 fix(skills): fix watcher ignored typing 2026-01-17 08:28:09 +00:00
Peter Steinberger
56f3a2de25 fix(security): default-deny command execution 2026-01-17 08:28:09 +00:00
Peter Steinberger
d8b463d0b3 fix: cap pending process output 2026-01-17 08:26:12 +00:00
Peter Steinberger
eef3df9fa5 fix(macos): drain subprocess pipes before wait (#1081)
Thanks @thesash.

Co-authored-by: Sash Catanzarite <sashcatanzarite@Sash-MacBook-Pro-14in-3.local>
2026-01-17 08:24:59 +00:00
Peter Steinberger
837eea4ebd fix: refresh TUI session info after runs 2026-01-17 08:22:32 +00:00
Peter Steinberger
55622bac06 Merge pull request #1079 from d-ploutarchos/fix/tui-token-refresh
TUI: refresh token counts after agent runs complete
2026-01-17 08:17:03 +00:00
Peter Steinberger
f172ccfcf6 fix: remove stray skills watcher bracket 2026-01-17 08:15:21 +00:00
Peter Steinberger
a2a6893566 fix: allow skills watcher ignore list 2026-01-17 08:12:57 +00:00
Peter Steinberger
616ee3075c fix: repair skills watcher ignored typing 2026-01-17 08:12:00 +00:00
Peter Steinberger
c5239f6a8e fix: stabilize pty tests and media kind 2026-01-17 08:10:44 +00:00
Peter Steinberger
cccd7c7b8e test: stabilize windows pty expectations 2026-01-17 08:07:31 +00:00
Peter Steinberger
8f1132e8ec fix: share skills watcher ignores 2026-01-17 08:07:06 +00:00
Peter Steinberger
e6477363e9 refactor: normalize channel capabilities typing 2026-01-17 08:06:35 +00:00
Peter Steinberger
1a4fc8dea6 fix: guard memory sync errors 2026-01-17 08:04:48 +00:00
Peter Steinberger
a3daf3d115 style: oxfmt 2026-01-17 08:01:46 +00:00
Peter Steinberger
f3f80509e3 test: cover tg/group/topic inline button targets (#1072) — thanks @danielz1z
Co-authored-by: danielz1z <danielz1z@users.noreply.github.com>
2026-01-17 08:00:16 +00:00
Peter Steinberger
7cebe7a506 style: run oxfmt 2026-01-17 08:00:05 +00:00
Peter Steinberger
5986175268 fix: restore CI lint/build 2026-01-17 08:00:05 +00:00
Peter Steinberger
7630c6dccb Merge pull request #1074 from roshanasingh4/fix/1056-ignore-heavy-watch-paths
Fix #1056: prevent macOS FD exhaustion by ignoring node_modules in skills watcher
2026-01-17 07:56:54 +00:00
Peter Steinberger
d63cc1e8a7 fix: allow telegram prefixes/topics for inline buttons (#1072) — thanks @danielz1z
Co-authored-by: danielz1z <danielz1z@users.noreply.github.com>
2026-01-17 07:49:57 +00:00
danielz1z
80bb6b712c fix: handle telegram: prefix in resolveTelegramTargetChatType
When using inlineButtons="dm" or "group" scope, the validation check
resolveTelegramTargetChatType() failed for numeric chat IDs because
normalizeTelegramMessagingTarget() adds a "telegram:" prefix during
target resolution.

For example, target "5232990709" becomes "telegram:5232990709" after
normalization, but the regex /^-?\d+$/ expects a pure numeric string.

The fix strips the telegram: prefix before checking the numeric pattern.

Adds tests for resolveTelegramTargetChatType with various input formats.
2026-01-17 07:47:14 +00:00
Peter Steinberger
410b8f223e fix: keep extension relay list current (#1073)
Thanks @roshanasingh4.

Co-authored-by: Roshan Singh <88576930+roshanasingh4@users.noreply.github.com>
2026-01-17 07:43:31 +00:00
Roshan Singh
693f152895 Fix #1035: refresh extension tab metadata
Handle Target.targetInfoChanged in extension relay so /json/list reflects updated title/url after navigation. Adds regression coverage.
2026-01-17 07:43:09 +00:00
Peter Steinberger
78a4441ac2 test: stabilize bash send-keys submit 2026-01-17 07:41:24 +00:00
Peter Steinberger
c92265a51b refactor: canonicalize gateway session store keys 2026-01-17 07:41:24 +00:00
Peter Steinberger
d5fdda8e28 refactor: prune room legacy 2026-01-17 07:41:24 +00:00
Dimitrios Ploutarchos
cddf198321 TUI: refresh token counts after agent runs complete. Closes #1078 2026-01-17 07:40:59 +00:00
Peter Steinberger
6d969fe58e refactor: normalize media attachment selection 2026-01-17 07:38:11 +00:00
Peter Steinberger
68c7d577a4 chore: drop target format helper 2026-01-17 07:36:13 +00:00
Peter Steinberger
1ea8917e2b refactor: trim resolver exports 2026-01-17 07:36:09 +00:00
Peter Steinberger
07c93dfd30 refactor: streamline target resolver helpers 2026-01-17 07:34:26 +00:00
Peter Steinberger
cf0ea6c756 refactor: unify target resolver metadata 2026-01-17 07:34:26 +00:00
Peter Steinberger
8c9e32c4a3 refactor: share sessions list row type
Co-authored-by: Adam Holt <mail@adamholt.co.nz>
2026-01-17 07:34:21 +00:00
Peter Steinberger
34d59d7913 refactor: rename hooks docs and add tests 2026-01-17 07:32:54 +00:00
Peter Steinberger
0c0d9e1d22 Merge pull request #1071 from danielz1z/fix/capabilities-object-format
fix: handle object-format capabilities in normalizeCapabilities
2026-01-17 07:31:52 +00:00
Peter Steinberger
2ee45d50a4 refactor: tighten media diagnostics 2026-01-17 07:27:38 +00:00
Peter Steinberger
0c0e1e4226 refactor: extend media understanding 2026-01-17 07:17:13 +00:00
Peter Steinberger
86a46874da fix: preserve discord chunk whitespace 2026-01-17 07:11:21 +00:00
Peter Steinberger
3a6ee5ee00 feat: unify hooks installs and webhooks 2026-01-17 07:08:04 +00:00
Peter Steinberger
5dc87a2ed4 fix: respond to PTY cursor queries 2026-01-17 07:05:24 +00:00
Peter Steinberger
a85ddf258c fix: expose deliveryContext in sessions_list
Co-authored-by: Adam Holt <mail@adamholt.co.nz>
2026-01-17 06:54:31 +00:00
Peter Steinberger
1f3a09b43b fix: persist deliveryContext on last-route updates
Co-authored-by: Adam Holt <mail@adamholt.co.nz>
2026-01-17 06:54:31 +00:00
Peter Steinberger
7b31b280f8 refactor: reuse agent outbound target resolution
Co-authored-by: Adam Holt <mail@adamholt.co.nz>
2026-01-17 06:54:31 +00:00
Peter Steinberger
6a3ed5c850 fix(security): gate slash/control commands 2026-01-17 06:49:34 +00:00
Peter Steinberger
7ed55682b7 fix(build): allow @lydell/node-pty builds 2026-01-17 06:49:33 +00:00
Peter Steinberger
37a2eee837 refactor: drop legacy session store keys 2026-01-17 06:48:44 +00:00
Peter Steinberger
353d778988 refactor: centralize target normalization 2026-01-17 06:45:11 +00:00
Peter Steinberger
5a1ff5b9e7 refactor: tune media understanding 2026-01-17 06:44:19 +00:00
Peter Steinberger
3dc4a96330 feat: add process submit helper 2026-01-17 06:38:56 +00:00
Peter Steinberger
65a8a93854 fix: normalize delivery routing context
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 06:38:33 +00:00
Peter Steinberger
eb8a0510e0 refactor: unify queue drop handling 2026-01-17 06:38:33 +00:00
Peter Steinberger
a4178e4062 fix: stabilize pty send-keys tests 2026-01-17 06:32:24 +00:00
Roshan Singh
e7953d8164 Fix #1056: ignore heavy paths in skills watcher
On macOS, watching deep dependency trees can exhaust file descriptors and lead to spawn EBADF failures. The skills watcher only needs to observe skill changes, so ignore dotfiles, node_modules, and dist by default. Adds regression coverage.
2026-01-17 06:26:27 +00:00
Peter Steinberger
5ebfc0738f feat: add session slug generator 2026-01-17 06:23:26 +00:00
Peter Steinberger
bd32cc40e6 feat: add keypad key mappings 2026-01-17 06:22:05 +00:00
Peter Steinberger
b31d8d3b10 feat: add tmux-style process key helpers 2026-01-17 06:12:56 +00:00
Peter Steinberger
331141ad77 refactor: centralize message target resolution
Co-authored-by: Thinh Dinh <tobalsan@users.noreply.github.com>
2026-01-17 06:04:49 +00:00
Peter Steinberger
c7ae5100fa refactor: share queue helpers
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 06:02:27 +00:00
Peter Steinberger
285ed8bac3 fix: sync delivery routing context
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 06:02:27 +00:00
Peter Steinberger
e59d8c5436 style: oxfmt format 2026-01-17 05:48:56 +00:00
Peter Steinberger
8b42902cee refactor: drop legacy room chatType 2026-01-17 05:46:40 +00:00
Peter Steinberger
07a3db153d feat: notify on exec exit 2026-01-17 05:43:34 +00:00
Peter Steinberger
68d35be383 feat: emit tool outputs for full verbose 2026-01-17 05:40:21 +00:00
Peter Steinberger
99dd428862 feat: extend verbose tool feedback 2026-01-17 05:33:39 +00:00
Peter Steinberger
4d314db750 refactor: extract subagent announce queue
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 05:29:07 +00:00
Peter Steinberger
ccea3a0615 refactor: unify delivery target resolution
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 05:29:06 +00:00
Peter Steinberger
f4f20c6762 refactor: normalize session route fields
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 05:29:06 +00:00
Peter Steinberger
a624878973 fix(security): gate slash commands by sender 2026-01-17 05:25:42 +00:00
Peter Steinberger
c8b826ea8c fix: add media understanding decision types 2026-01-17 05:24:54 +00:00
Peter Steinberger
f7089cde54 fix: unify inbound sender labels 2026-01-17 05:21:09 +00:00
danielz1z
f42b12646d fix: handle object-format capabilities in normalizeCapabilities
When capabilities is configured as an object (e.g., { inlineButtons: "dm" })
instead of a string array, normalizeCapabilities() would crash with
"capabilities.map is not a function".

This can occur when using the new Telegram inline buttons scoping feature:
  channels.telegram.capabilities.inlineButtons = "dm"

The fix adds an Array.isArray() guard to return undefined for non-array
capabilities, allowing channel-specific handlers (like
resolveTelegramInlineButtonsScope) to process the object format separately.

Fixes crash when using object-format TelegramCapabilitiesConfig.
2026-01-17 05:11:57 +00:00
Peter Steinberger
572e04d5fb refactor(cli): split outbound send deps 2026-01-17 05:06:39 +00:00
Peter Steinberger
bc49c20434 fix: finalize inbound contexts 2026-01-17 05:06:39 +00:00
Peter Steinberger
4b085f23e0 docs: note live target cache refresh
Co-authored-by: Thinh Dinh <tobalsan@users.noreply.github.com>
2026-01-17 05:00:15 +00:00
Peter Steinberger
c4ea25a509 feat: add exec pty support 2026-01-17 04:57:11 +00:00
Peter Steinberger
312cb75c50 fix: trim /status oauth output 2026-01-17 04:54:28 +00:00
Peter Steinberger
ee738e6578 test: fix mocks for target resolver 2026-01-17 04:41:02 +00:00
Peter Steinberger
fcb7c9ff65 refactor: unify media understanding pipeline 2026-01-17 04:39:00 +00:00
Peter Steinberger
49ecbd8fea test: expand accountId routing coverage
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 04:33:24 +00:00
Peter Steinberger
19ee6699d2 refactor: clarify subagent announce origin
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 04:33:24 +00:00
Peter Steinberger
5fcc9b3244 refactor: centralize target errors and cache lookups 2026-01-17 04:28:22 +00:00
Peter Steinberger
3efc5e54fa fix: preserve account routing for explicit targets
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 04:24:59 +00:00
Peter Steinberger
780c811146 refactor: migrate subagent registry store v2
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 04:24:59 +00:00
Peter Steinberger
4f37f66264 refactor: normalize delivery context
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 04:24:59 +00:00
Peter Steinberger
8ebfa2950d refactor: align message target wording 2026-01-17 04:18:59 +00:00
Peter Steinberger
9a60d431c5 docs: credit #1034 in changelog 2026-01-17 04:15:46 +00:00
Peter Steinberger
6e4d86f426 refactor: require target for message actions 2026-01-17 04:15:46 +00:00
Peter Steinberger
87cecd0268 refactor: align channel chatType 2026-01-17 04:13:06 +00:00
Peter Steinberger
388b2bce01 refactor: add inbound context helpers 2026-01-17 04:05:34 +00:00
Peter Steinberger
a2b5b1f0cb refactor: normalize inbound context 2026-01-17 04:05:33 +00:00
Peter Steinberger
9f4b7a1683 fix: normalize subagent announce delivery origin
Co-authored-by: Adam Holt <mail@adamholt.co.nz>
2026-01-17 04:03:28 +00:00
Peter Steinberger
dd68faef23 refactor: split message tool schema + action handling 2026-01-17 03:58:55 +00:00
Peter Steinberger
97cfa0846c chore: remove legacy transcription helpers 2026-01-17 03:54:46 +00:00
Peter Steinberger
1b973f7506 feat: add inbound media understanding
Co-authored-by: Tristan Manchester <tmanchester96@gmail.com>
2026-01-17 03:54:46 +00:00
Peter Steinberger
4b749f1b8f refactor: share telegram caption splitting 2026-01-17 03:50:09 +00:00
Peter Steinberger
7f1f9473a0 refactor: dedupe message action helpers 2026-01-17 03:46:03 +00:00
Peter Steinberger
a32e5d040c Merge pull request #1063 from mukhtharcm/fix/telegram-caption-split
fix(telegram): split long captions into follow-up messages
2026-01-17 03:42:13 +00:00
Peter Steinberger
a82217a5f3 chore: format + regenerate protocol 2026-01-17 03:40:49 +00:00
Peter Steinberger
09bed2ccde refactor: centralize outbound policy + target schema 2026-01-17 03:33:56 +00:00
Peter Steinberger
3af391eec7 refactor: centralize group sender identity 2026-01-17 03:32:48 +00:00
Peter Steinberger
204309dd3c Merge pull request #1064 from connorshea/main
fix: Fix oxlint config file name and use a valid config.
2026-01-17 03:26:09 +00:00
Peter Steinberger
3fcd6fadf3 fix: land oxlint config follow-ups (#1064) (thanks @connorshea) 2026-01-17 03:25:05 +00:00
Connor Shea
78136c4368 Update oxlint config to put ignore pattern inside the oxlint config. 2026-01-17 03:19:42 +00:00
Connor Shea
25edbdacc2 Fix config file 2026-01-17 03:19:42 +00:00
Connor Shea
451532f6f1 Fix oxlint config file name and use a valid config. 2026-01-17 03:19:42 +00:00
Peter Steinberger
bc7d603867 test: expand accountId delivery coverage
Co-authored-by: Adam Holt <adam91holt@users.noreply.github.com>
2026-01-17 03:17:33 +00:00
Peter Steinberger
46015a3dd8 feat: add cross-context messaging resolver
Co-authored-by: Thinh Dinh <tobalsan@users.noreply.github.com>
2026-01-17 03:17:13 +00:00
Peter Steinberger
1481a3d90f fix: include sender info for iMessage/Signal group messages 2026-01-17 02:52:01 +00:00
Peter Steinberger
96a1d03f08 fix: remove stale previousSessionEntry param 2026-01-17 02:52:01 +00:00
Peter Steinberger
0291105913 fix: thread accountId through subagent announce delivery
Co-authored-by: Adam Holt <adam91holt@users.noreply.github.com>
2026-01-17 02:45:18 +00:00
Peter Steinberger
dbf8829283 docs: clarify remote access setups 2026-01-17 02:19:16 +00:00
Muhammed Mukhthar CM
02184dd055 fix(telegram): split long captions into follow-up messages 2026-01-17 02:16:01 +00:00
Peter Steinberger
d5332ae29a fix: thread accountId through subagent announce
Co-authored-by: Adam Holt <adam91holt@users.noreply.github.com>
2026-01-17 02:09:35 +00:00
Peter Steinberger
4ba6f6e8ee chore: update PR landing rule 2026-01-17 02:06:36 +00:00
Peter Steinberger
fdaeada3ec feat: mirror delivered outbound messages (#1031)
Co-authored-by: T Savo <TSavo@users.noreply.github.com>
2026-01-17 02:03:18 +00:00
Peter Steinberger
3fb699a84b style: apply oxfmt 2026-01-17 01:55:42 +00:00
Peter Steinberger
767f55b127 fix: wire previous session entry and stabilize jpeg test 2026-01-17 01:55:35 +00:00
Peter Steinberger
20897e943f docs: credit ThomsenDrake 2026-01-17 01:46:29 +00:00
Peter Steinberger
2d1078fc52 docs: note NO_REPLY guidance for message tool 2026-01-17 01:44:20 +00:00
Peter Steinberger
19016f16e0 fix: queue subagent announce delivery 2026-01-17 01:44:13 +00:00
Peter Steinberger
b8e3725106 docs: expand internal hooks intro 2026-01-17 01:40:09 +00:00
Peter Steinberger
413dfc6d6d docs: add beginner intro for internal hooks 2026-01-17 01:35:46 +00:00
Peter Steinberger
faba508fe0 feat: add internal hooks system 2026-01-17 01:31:57 +00:00
Peter Steinberger
a76cbc43bb fix(browser): remote profile tab ops follow-up (#1060) (thanks @mukhtharcm)
Landed via follow-up to #1057.

Gate: pnpm lint && pnpm build && pnpm test
2026-01-17 01:28:22 +00:00
Peter Steinberger
e16ce1a0a1 style: format health/status files 2026-01-17 01:25:10 +00:00
Peter Steinberger
fa2b92bb00 docs: update health/status + doctor docs 2026-01-17 01:19:43 +00:00
Peter Steinberger
c592f395df test: update health/status and legacy migration coverage 2026-01-17 01:19:43 +00:00
Peter Steinberger
f14d622c0f refactor: centralize account bindings + health probes 2026-01-17 01:19:43 +00:00
Peter Steinberger
99aba3a5c4 test: drop legacy connections settings smoke test 2026-01-17 01:13:45 +00:00
Peter Steinberger
58e02087b5 docs: align channels naming in mac tests 2026-01-17 01:13:45 +00:00
Peter Steinberger
fd49f39a72 Merge pull request #1057 from mukhtharcm/feat/browser-persistent-tabs-remote-profiles
feat(browser): use persistent Playwright connections for remote profile tab operations
2026-01-17 00:57:58 +00:00
Peter Steinberger
bbef30daa5 fix: browser remote tab ops harden (#1057) (thanks @mukhtharcm) 2026-01-17 00:57:35 +00:00
Peter Steinberger
c8b865d582 Merge pull request #1040 from clawdbot/shadow/config-ui
Config: schema-driven channels and settings
2026-01-17 00:45:42 +00:00
Peter Steinberger
4b7c6d4f8f fix: note config-first channel auth (#1040) (thanks @thewilloftheshadow) 2026-01-17 00:43:37 +00:00
Peter Steinberger
c22d2b2ffd docs: fix changelog after 2026.1.15 release 2026-01-17 00:43:37 +00:00
Peter Steinberger
0179717d61 feat: enhance web_fetch fallbacks 2026-01-17 00:43:37 +00:00
Peter Steinberger
abf4c02a0d feat: improve web_fetch readability extraction 2026-01-17 00:43:05 +00:00
Peter Steinberger
03a9907055 fix: prefer config tokens over env for discord/telegram 2026-01-17 00:43:05 +00:00
Peter Steinberger
66c99e1608 feat(ui): delete sessions from Control UI 2026-01-17 00:43:05 +00:00
Peter Steinberger
15a95f988a feat: expand skill command registration 2026-01-17 00:43:05 +00:00
Peter Steinberger
7ecf733342 fix: align channel config schemas and env precedence 2026-01-17 00:43:05 +00:00
Shadow
3ec221c70e macOS: fix config form rendering 2026-01-17 00:43:05 +00:00
Shadow
cc2d617ea6 Docs: note schema-driven config UI 2026-01-17 00:43:05 +00:00
Shadow
503aad1417 Changelog: note schema-driven channels UI 2026-01-17 00:43:05 +00:00
Shadow
1ad26d6fea Config: schema-driven channels and settings 2026-01-17 00:43:05 +00:00
Muhammed Mukhthar CM
02a4de0029 feat(browser): use persistent Playwright connections for remote profile tab operations
For remote CDP profiles (e.g., Browserless), tab operations now use Playwright's
persistent connection instead of stateless HTTP requests. This ensures tabs
persist across operations rather than being terminated after each request.

Changes:
- pw-session.ts: Add listPagesViaPlaywright, createPageViaPlaywright, and
  closePageByTargetIdViaPlaywright functions using the cached Playwright connection
- pw-ai.ts: Export new functions for dynamic import
- server-context.ts: For remote profiles (!cdpIsLoopback), use Playwright-based
  tab operations; local profiles continue using HTTP endpoints
- server-context.ts: Fix ensureTabAvailable to not require wsUrl for remote
  profiles since Playwright accesses pages directly

This is a follow-up to #895 which added authentication support for remote CDP
profiles. The original PR description mentioned switching to persistent Playwright
connections for tab operations, but only the auth changes were merged.
2026-01-17 00:42:53 +00:00
Peter Steinberger
bcfc9bead5 docs: expand iMessage remote setup 2026-01-17 00:39:48 +00:00
Peter Steinberger
1be0e9b9fb Merge pull request #1054 from tyler6204/fix/imsg-remote-attachments
iMessage: Add remote attachment support for VM/SSH deployments
2026-01-17 00:37:21 +00:00
Peter Steinberger
6e5eddf292 fix: avoid imessage rpc restart loop 2026-01-17 00:35:24 +00:00
Peter Steinberger
64a2ef4a18 refactor: simplify env var substitution scan 2026-01-17 00:34:00 +00:00
Peter Steinberger
25399d39cb fix: harden env var substitution parsing (#1044) (thanks @sebslight) 2026-01-17 00:29:08 +00:00
Peter Steinberger
731080375a Merge pull request #1044 from sebslight/env-var-substitution
feat(config): add env var substitution in config values
2026-01-17 00:25:26 +00:00
Sash Catanzarite
89bbbe75a6 fix: honor message tool channel for tool dedupe (#1053)
- Treat message tool `channel` as provider hint for dedupe/suppression.
- Prefer NO_REPLY after message tool sends to avoid duplicate replies.

Co-authored-by: Sash Catanzarite <1166151+thesash@users.noreply.github.com>
2026-01-17 00:23:51 +00:00
Peter Steinberger
f69298d7eb docs: clarify model key format 2026-01-17 00:15:37 +00:00
Peter Steinberger
6280305899 Merge pull request #1049 from YuriNachos/fix/issue-1020-sessions-perms
fix(sessions): preserve 0600 permissions on sessions.json writes
2026-01-17 00:07:49 +00:00
Peter Steinberger
543d1ea3c1 docs: fix changelog after 2026.1.15 release 2026-01-17 00:03:56 +00:00
Peter Steinberger
1569db1754 style: format with oxfmt 2026-01-17 00:03:00 +00:00
Peter Steinberger
c54c665f97 feat: enhance web_fetch fallbacks 2026-01-17 00:00:49 +00:00
Peter Steinberger
a84000c6d9 docs(changelog): note PR #1050 fixes
Co-authored-by: Yurii Chukhlib <yuri.v.chu@gmail.com>
2026-01-16 23:59:04 +00:00
Peter Steinberger
a979a62f8e fix(openai-image-gen): handle url and b64_json responses
Co-authored-by: Yurii Chukhlib <yuri.v.chu@gmail.com>
2026-01-16 23:59:04 +00:00
Peter Steinberger
13e2dd97a7 fix(session): preserve overrides on /new reset
Co-authored-by: Yurii Chukhlib <yuri.v.chu@gmail.com>
2026-01-16 23:59:04 +00:00
Yurii Chukhlib
6bba84b043 fix(channels): include linked field in WhatsApp describeAccount
Fixes #1030

The describeAccount function for WhatsApp was not returning the
linked field, causing the Channels status section to show
"Not linked" even when WhatsApp was properly linked and working.

The fix adds the linked field to describeAccount, set to the same
value as configured (Boolean(account.authDir)). This ensures that
the Channels section and Health section show consistent status.
2026-01-16 23:59:04 +00:00
Peter Steinberger
e31251293b fix: scope history injection to pending-only 2026-01-16 23:52:42 +00:00
Tyler Yust
7a9ff18260 iMessage: Add remote attachment support for VM/SSH deployments 2026-01-16 15:51:42 -08:00
Peter Steinberger
56ed5cc2d9 fix: prefer config over env for matrix creds 2026-01-16 23:24:18 +00:00
Peter Steinberger
af31e0d969 docs: redirect /install/node to install section 2026-01-16 23:18:50 +00:00
Peter Steinberger
37fa4f7eef feat: improve web_fetch readability extraction 2026-01-16 23:18:01 +00:00
Peter Steinberger
9aad6dfe1b Merge pull request #1046 from YuriNachos/feature/web-search-localization
feat(web-search): add country and language parameters
2026-01-16 23:17:46 +00:00
Peter Steinberger
6b8db36a15 docs: clarify Brave web_search defaults (#1046) (thanks @YuriNachos) 2026-01-16 23:16:59 +00:00
Yurii Chukhlib
171060541a docs(web-search): document country and language parameters
Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-16 23:14:33 +00:00
Yurii Chukhlib
003547c818 test(web-search): add tests for country and language parameters
Added three new test cases to verify the new country, search_lang, and ui_lang
parameters are correctly passed to the Brave Search API.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-16 23:14:33 +00:00
Yurii Chukhlib
c4e1064066 feat(web-search): pass country and language params to Brave API
- Add country, search_lang, and ui_lang optional parameters to runWebSearch
- Pass new parameters to Brave Search API as URL query parameters
- Update cache key to include localization parameters
- Wire parameters through createWebSearchTool execute function
2026-01-16 23:14:33 +00:00
Yurii Chukhlib
d163dbcfcd feat(web-search): add country and language optional parameters to schema 2026-01-16 23:14:33 +00:00
Yurii Chukhlib
2acfeb1096 fix(openai-image-gen): remove deprecated response_format, use URL download 2026-01-16 23:14:33 +00:00
Yurii Chukhlib
b3b6d421cc fix(session): reset compactionCount on /new and /reset 2026-01-16 23:14:33 +00:00
Yurii Chukhlib
cf72b9db3c fix(sessions): preserve 0600 permissions on sessions.json writes 2026-01-16 23:14:33 +00:00
Peter Steinberger
106e308953 fix: prefer config tokens over env for discord/telegram 2026-01-16 23:13:00 +00:00
Peter Steinberger
bf72a126d1 docs: add /help hub and Node/npm PATH guide 2026-01-16 23:10:29 +00:00
Peter Steinberger
28a5d124c3 fix: stabilize transport-ready test timing 2026-01-16 23:03:12 +00:00
Peter Steinberger
b4045e6adb fix: remove stale pnpm patch entry 2026-01-16 22:56:08 +00:00
Peter Steinberger
a7bec3340f fix: drop unsigned gemini tool calls from history 2026-01-16 22:43:16 +00:00
Peter Steinberger
a4e99ecdaf chore: remove patch references 2026-01-16 22:41:57 +00:00
Peter Steinberger
dcd20d564f docs: expand directory CLI guide 2026-01-16 22:40:36 +00:00
Peter Steinberger
59f6ea9b21 feat: directory for plugin channels 2026-01-16 22:40:36 +00:00
Peter Steinberger
e44f28bd4f feat: unify directory across channels 2026-01-16 22:40:36 +00:00
Peter Steinberger
929b86e302 feat(ui): delete sessions from Control UI 2026-01-16 22:33:47 +00:00
Peter Steinberger
76d3d58b5c chore: oxfmt 2026-01-16 22:33:47 +00:00
Peter Steinberger
548a32c8d4 chore: drop unused patches 2026-01-16 22:31:21 +00:00
Peter Steinberger
500c75b4f0 fix: align ZAI thinking toggles 2026-01-16 22:26:43 +00:00
Peter Steinberger
3567dc4a47 fix: hard-stop sessions.delete cleanup 2026-01-16 22:24:13 +00:00
Peter Steinberger
7df37c2dbd fix: override tar to 7.5.3 2026-01-16 22:07:34 +00:00
Peter Steinberger
28a4cbc4ef docs: mention stopping sub-agents 2026-01-16 22:05:13 +00:00
Peter Steinberger
21fe4d9ded fix: bump tar to 7.5.3 2026-01-16 21:58:32 +00:00
Peter Steinberger
05d149a49b fix: treat reply-to-bot as implicit mention across channels 2026-01-16 21:51:01 +00:00
Peter Steinberger
97a41a6509 fix: suppress zero sub-agent stop note 2026-01-16 21:41:55 +00:00
Peter Steinberger
a0be85c34c fix: /stop aborts subagents 2026-01-16 21:37:22 +00:00
Sebastian
a36735b913 feat(config): add env var substitution in config values
Support ${VAR_NAME} syntax in any config string value, substituted at
config load time. Useful for referencing API keys and secrets from
environment variables without hardcoding them in the config file.

- Only uppercase env vars matched: [A-Z_][A-Z0-9_]*
- Missing/empty env vars throw MissingEnvVarError with path context
- Escape with $${VAR} to output literal ${VAR}
- Works with $include (included files also get substitution)

Closes #1009
2026-01-16 16:32:07 -05:00
tsu
390bd11f33 feat: add zalouser channel + directory CLI (#1032) (thanks @suminhthanh)
- Unified UX: channels login + message send; no plugin-specific top-level command\n- Added generic directory CLI for channel identity/groups\n- Docs: channel + plugin pages
2026-01-16 21:28:18 +00:00
Peter Steinberger
16768a9998 fix: start fresh cron sessions each run 2026-01-16 21:27:56 +00:00
adityashaw2
e9d6869290 Telegram: Add reply-chain detection to bypass mention requirement (#1038)
* Telegram: add reply-chain detection to bypass mention requirement

* fix: allow telegram reply-chain mention bypass (#1038) (thanks @adityashaw2)

---------

Co-authored-by: Aditya Shaw <aditya@adityashaw.dev>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-16 21:24:45 +00:00
Peter Steinberger
9072e35f08 fix: hard-abort clears queues on /stop 2026-01-16 21:15:25 +00:00
Peter Steinberger
d887027e95 style: run oxfmt 2026-01-16 21:11:55 +00:00
Peter Steinberger
168a0f4998 Merge pull request #1016 from timolins/main
Models: add Vercel AI Gateway auth
2026-01-16 21:06:56 +00:00
Peter Steinberger
f3a37664d5 fix: tighten Vercel AI Gateway onboarding docs/tests (#1016) (thanks @timolins) 2026-01-16 21:06:21 +00:00
Peter Steinberger
8bcbe68637 chore: sync Peekaboo submodule on install 2026-01-16 21:04:58 +00:00
Timo Lins
beb9eac5f7 Models: add Vercel AI Gateway auth 2026-01-16 21:00:15 +00:00
Peter Steinberger
0dcffcd5b0 fix: repair orphaned user turns before embedded prompts 2026-01-16 20:52:18 +00:00
Peter Steinberger
56efbce31e feat: enable telegram reaction notifications by default 2026-01-16 20:51:42 +00:00
Peter Steinberger
e7c42884fc test: cover transport readiness waits 2026-01-16 20:48:43 +00:00
Peter Steinberger
08c0405f0f fix: quiet skill command normalization logs 2026-01-16 20:37:23 +00:00
Peter Steinberger
470add877c feat: default telegram reaction level minimal 2026-01-16 20:35:47 +00:00
Peter Steinberger
aaa310c047 fix: bound signal/imessage transport readiness waits
Co-authored-by: Szpadel <1857251+Szpadel@users.noreply.github.com>
2026-01-16 20:33:04 +00:00
Ruby
0cd24137e8 feat: add session.identityLinks for cross-platform DM session linking (#1033)
Co-authored-by: Shadow <shadow@clawd.bot>
2026-01-16 14:23:22 -06:00
Peter Steinberger
8ffb8cc363 Merge pull request #1013 from marcmarg/fix/format-parameter-and-subagent-auth
Fix format parameter conflict and subagent auth inheritance
2026-01-16 20:20:40 +00:00
Peter Steinberger
7a9854cb06 chore: update clawtributors 2026-01-16 20:20:26 +00:00
Marc
5ee4456c6e fix: merge subagent auth profiles 2026-01-16 20:20:26 +00:00
Marc
de31583021 fix: avoid format keyword in tool schemas
Co-authored-by: marcmarg <marcmarg@users.noreply.github.com>
2026-01-16 20:20:26 +00:00
Peter Steinberger
38b49aa0f6 feat: expand skill command registration 2026-01-16 20:17:32 +00:00
Peter Steinberger
69761e8a51 feat: scope telegram inline buttons 2026-01-16 20:16:41 +00:00
Peter Steinberger
3431d3d115 chore: tweak tool call narration guidance (#1008)
Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
2026-01-16 19:56:04 +00:00
Peter Steinberger
fe9e027d58 test: deflake background exec timeout 2026-01-16 19:48:52 +00:00
Peter Steinberger
33d17957e5 test: cover doctor launchctl env overrides (#1037)
* test: cover doctor launchctl env overrides

* style(macos): fix swiftformat lint
2026-01-16 19:40:45 +00:00
Nima Karimi
25ae5f897e fix(macos): check config file mode for gateway token/password resolution (#1022)
* fix: honor config gateway mode for credentials

* chore: oxfmt doctor platform notes

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-16 19:29:48 +00:00
Peter Steinberger
624ff09314 test: expand gateway auth probe coverage 2026-01-16 19:16:03 +00:00
Peter Steinberger
c8003ae472 docs: update changelog for gateway probe fix (#1011) (thanks @ivanrvpereira) 2026-01-16 19:07:44 +00:00
Peter Steinberger
6bf627bce8 Merge pull request #1011 from ivanrvpereira/fix/security-audit-gateway-auth
fix(security): resolve local auth for gateway probe
2026-01-16 19:05:07 +00:00
henrymascot
08525435e0 showcase: add Linear CLI and Beeper CLI
- @NessZerra Linear CLI - manage Linear issues from terminal, works with Claude Code/Clawdbot
- @jules Beeper CLI - read/send/archive messages via Beeper Desktop MCP API
2026-01-16 19:05:02 +00:00
Yurii Chukhlib
9e39a56033 fix(sessions): preserve 0600 permissions on sessions.json writes 2026-01-16 19:44:14 +01:00
Shadow
026cf1130e Changelog: note Discord skill truncation 2026-01-16 10:03:53 -06:00
Wilkins
bb14b19922 fix: truncate skill command descriptions to 100 chars for Discord (#1018)
* fix: truncate skill command descriptions to 100 chars for Discord

Discord slash commands have a 100 character limit for descriptions.
Skill descriptions were not being truncated, causing command registration
to fail with an empty error from the Discord API.

* style: format

* style: format
2026-01-16 10:01:59 -06:00
Marc
78279fb758 fix: inherit auth-profiles from main agent for subagents
When a subagent is spawned, it creates a new agent directory but has no
auth-profiles.json. This adds a fallback in ensureAuthProfileStore() to
inherit auth-profiles from the main agent when the subagent has none,
ensuring subagents can use OAuth tokens without manual file copying.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 15:51:50 +01:00
Marc
38cf90f6db fix: rename format parameter to avoid JSON Schema keyword conflict
- Rename `format` to `snapshotFormat` in browser-tool schema and implementation
- Rename `format` to `outputFormat` in canvas-tool schema and implementation
- The parameter name `format` conflicts with JSON Schema keyword, causing
  Google Cloud Code Assist to reject the schema as invalid

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 15:50:02 +01:00
Ivan Pereira
544ca062a3 test(security): add coverage for gateway probe auth selection 2026-01-16 13:31:01 +00:00
Ivan Pereira
be9aa5494a fix(security): resolve local auth for gateway probe 2026-01-16 13:19:55 +00:00
Peter Steinberger
0d6af15d1c feat: add user-invocable skill commands 2026-01-16 12:10:29 +00:00
Peter Steinberger
eda9410bce fix: stabilize docker test suite 2026-01-16 11:47:14 +00:00
Peter Steinberger
a51ed8a5dd fix(cli): auto-update global installs 2026-01-16 11:45:37 +00:00
Peter Steinberger
19bcbf85df fix: scope whatsapp self-chat response prefix 2026-01-16 10:54:11 +00:00
Peter Steinberger
f49d0e5476 fix: expand exec abort/timeout coverage 2026-01-16 10:43:22 +00:00
Peter Steinberger
9c4c9c5edd chore: release 2026.1.15 2026-01-16 10:37:30 +00:00
gerardward2007
0f34255359 chore: ignore local identity files (#1001) (thanks @gerardward2007)
* chore: ignore local identity files (IDENTITY.md, USER.md)

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

* chore: ignore local identity files (#1001) (thanks @gerardward2007)

* chore: format session status tool

* chore: format bash exec background abort test

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-16 10:30:04 +00:00
tosh-hamburg
de5fb65cb8 fix: docker-setup fails on Synology because of problem with bun (#1002) 2026-01-16 10:03:56 +00:00
Roshan Singh
e773f84e39 fix: keep background exec aborts from killing sessions (#1000) (thanks @roshanasingh4)
When exec returns early in background mode, the tool-call AbortSignal can fire and previously caused killProcessTree(SIGKILL). Ignore abort after yielding/backgrounding so background sessions keep running.
2026-01-16 10:01:39 +00:00
1754 changed files with 121368 additions and 48582 deletions

11
.gitignore vendored
View File

@@ -1,4 +1,5 @@
node_modules
**/node_modules/
.env
docker-compose.extra.yml
dist
@@ -31,6 +32,11 @@ apps/ios/*.xcodeproj/
apps/ios/*.xcworkspace/
apps/ios/.swiftpm/
vendor/
apps/ios/Clawdbot.xcodeproj/
apps/ios/Clawdbot.xcodeproj/**
apps/macos/.build/**
**/*.bun-build
apps/ios/*.xcfilelist
# Vendor build artifacts
vendor/a2ui/renderers/lit/dist/
@@ -43,6 +49,8 @@ apps/ios/fastlane/Preview.html
apps/ios/fastlane/screenshots/
apps/ios/fastlane/test_output/
apps/ios/fastlane/logs/
apps/ios/fastlane/.env
apps/ios/fastlane/report.xml
# fastlane build artifacts (local)
apps/ios/*.ipa
@@ -55,3 +63,6 @@ apps/ios/*.mobileprovision
# Local untracked files
.local/
.vscode/
IDENTITY.md
USER.md
.tgz

4
.gitmodules vendored
View File

@@ -1,4 +0,0 @@
[submodule "Peekaboo"]
path = Peekaboo
url = https://github.com/steipete/Peekaboo.git
branch = main

2
.npmrc
View File

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

12
.oxlintrc.json Normal file
View File

@@ -0,0 +1,12 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"unicorn",
"typescript",
"oxc"
],
"categories": {
"correctness": "error"
},
"ignorePatterns": ["src/canvas-host/a2ui/a2ui.bundle.js"]
}

View File

@@ -1,4 +0,0 @@
{
"$schema": "https://json.schemastore.org/oxlintrc",
"extends": ["recommended"]
}

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
src/canvas-host/a2ui/a2ui.bundle.js

View File

@@ -1,6 +1,6 @@
# Repository Guidelines
- Repo: https://github.com/clawdbot/clawdbot
- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
## Project Structure & Module Organization
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
@@ -8,6 +8,10 @@
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
- Core channel docs: `docs/channels/`
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
## Docs Linking (Mintlify)
- Docs are hosted on Mintlify (docs.clawd.bot).
@@ -54,6 +58,7 @@
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless its truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
- When working on an issue: reference the issue in the changelog entry.
- When merging a PR: leave a PR comment that explains exactly what we did and include the SHA hashes.
@@ -61,7 +66,7 @@
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
## Shorthand Commands
- `sync up`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
- `sync`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
### PR Workflow (Review vs Land)
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
@@ -72,16 +77,19 @@
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
- Environment variables: see `~/.profile`.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
## Troubleshooting
- Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
## Agent-Specific Notes
- Vocabulary: "makeup" = "mac app".
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency.
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
- 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.**
@@ -107,14 +115,15 @@
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
- Voice wake forwarding tips:
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Dont add extra quotes.
- launchd PATH is minimal; ensure the apps launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tools escaping.
- Release guardrails: do not change version numbers without operators explicit consent; always ask permission before running any npm publish/release step.
## NPM + 1Password (publish/verify)
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
@@ -123,19 +132,3 @@
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
- Kill the tmux session after publish.
## Exclamation Mark Escaping Workaround
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
```bash
# WRONG - will send "Hello\\!" with backslash
clawdbot message send --to "+1234" --message 'Hello!'
# CORRECT - use heredoc to avoid escaping
clawdbot message send --to "+1234" --message "$(cat <<'EOF'
Hello!
EOF
)"
```
This is a Claude Code quirk, not a clawdbot bug.

View File

@@ -1,6 +1,344 @@
# Changelog
## 2026.1.15 (unreleased)
Docs: https://docs.clawd.bot
## 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.
### 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
### Breaking
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
### Changes
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
- 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
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
- macOS: stop syncing Peekaboo as a git submodule 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.
### Fixes
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
- 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)
- 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.
## 2026.1.16-2
### Changes
- CLI: stamp build commit into dist metadata so banners show the commit in npm installs.
- CLI: close memory manager after memory commands to avoid hanging processes. (#1127) — thanks @NicholasSpisak.
## 2026.1.16-1
### Highlights
- Hooks: add hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake. https://docs.clawd.bot/hooks
- Media: add inbound media understanding (image/audio/video) with provider + CLI fallbacks. https://docs.clawd.bot/nodes/media-understanding
- Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh. https://docs.clawd.bot/plugins/zalouser
- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins. https://docs.clawd.bot/providers/vercel-ai-gateway
- Sessions: add `session.identityLinks` for cross-platform DM session li nking. (#1033) — thanks @thewilloftheshadow. https://docs.clawd.bot/concepts/session
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos. https://docs.clawd.bot/tools/web
### Breaking
- **BREAKING:** `clawdbot message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan.
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; hooks live under `clawdbot hooks`. https://docs.clawd.bot/cli/webhooks
- **BREAKING:** `clawdbot plugins install <path>` now copies into `~/.clawdbot/extensions` (use `--link` to keep path-based loading).
### Changes
- Plugins: ship bundled plugins disabled by default and allow overrides by installed versions. (#1066) — thanks @ItzR3NO.
- Plugins: add bundled Antigravity + Gemini CLI OAuth + Copilot Proxy provider plugins. (#1066) — thanks @ItzR3NO.
- Tools: improve `web_fetch` extraction using Readability (with fallback).
- Tools: add Firecrawl fallback for `web_fetch` when configured.
- Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites.
- Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails.
- Tools: default `exec` exit notifications and auto-migrate legacy `tools.bash` to `tools.exec`.
- Tools: add `exec` PTY support for interactive sessions. https://docs.clawd.bot/tools/exec
- Tools: add tmux-style `process send-keys` and bracketed paste helpers for PTY sessions.
- Tools: add `process submit` helper to send CR for PTY sessions.
- Tools: respond to PTY cursor position queries to unblock interactive TUIs.
- Tools: include tool outputs in verbose mode and expand verbose tool feedback.
- Skills: update coding-agent guidance to prefer PTY-enabled exec runs and simplify tmux usage.
- TUI: refresh session token counts after runs complete or fail. (#1079) — thanks @d-ploutarchos.
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
- Directory: unify `clawdbot directory` across channels and plugin channels.
- UI: allow deleting sessions from the Control UI.
- Memory: add sqlite-vec vector acceleration with CLI status details.
- Memory: add experimental session transcript indexing for memory_search (opt-in via memorySearch.experimental.sessionMemory + sources).
- Skills: add user-invocable skill commands and expanded skill command registration.
- Telegram: default reaction level to minimal and enable reaction notifications by default.
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
- iMessage: add remote attachment support for VM/SSH deployments.
- Messages: refresh live directory cache results when resolving targets.
- Messages: mirror delivered outbound text/media into session transcripts. (#1031) — thanks @TSavo.
- Messages: avoid redundant sender envelopes for iMessage + Signal group chats. (#1080) — thanks @tyler6204.
- Media: normalize Deepgram audio upload bytes for fetch compatibility.
- Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup.
- Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs.
- Config: support env var substitution in config values. (#1044) — thanks @sebslight.
- Health: add per-agent session summaries and account-level health details, and allow selective probes. (#1047) — thanks @gumadeiras.
- Hooks: add hook pack installs (npm/path/zip/tar) with `clawdbot.hooks` manifests and `clawdbot hooks install/update`.
- Plugins: add zip installs and `--link` to avoid copying local paths.
### Fixes
- macOS: drain subprocess pipes before waiting to avoid deadlocks. (#1081) — thanks @thesash.
- Verbose: wrap tool summaries/output in markdown only for markdown-capable channels.
- Tools: include provider/session context in elevated exec denial errors.
- Tools: normalize exec tool alias naming in tool error logs.
- Logging: reuse shared ANSI stripping to keep console capture lint-clean.
- Logging: prefix nested agent output with session/run/channel context.
- Telegram: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z.
- Telegram: split long captions into follow-up messages.
- Config: block startup on invalid config, preserve best-effort doctor config, and keep rolling config backups. (#1083) — thanks @mukhtharcm.
- Sub-agents: normalize announce delivery origin + queue bucketing by accountId to keep multi-account routing stable. (#1061, #1058) — thanks @adam91holt.
- Sessions: include deliveryContext in sessions.list and reuse normalized delivery routing for announce/restart fallbacks. (#1058)
- Sessions: propagate deliveryContext into last-route updates to keep account/channel routing stable. (#1058)
- Sessions: preserve overrides on `/new` reset.
- Memory: prevent unhandled rejections when watch/interval sync fails. (#1076) — thanks @roshanasingh4.
- Memory: avoid gateway crash when embeddings return 429/insufficient_quota (disable tool + surface error). (#1004)
- Gateway: honor explicit delivery targets without implicit accountId fallback; preserve lastAccountId for implicit routing.
- Gateway: avoid reusing last-to/accountId when the requested channel differs; sync deliveryContext with last route fields.
- Build: allow `@lydell/node-pty` builds on supported platforms.
- Repo: fix oxlint config filename and move ignore pattern into config. (#1064) — thanks @connorshea.
- Messages: `/stop` now hard-aborts queued followups and sub-agent runs; suppress zero-count stop notes.
- Messages: honor message tool channel when deduping sends.
- Messages: include sender labels for live group messages across channels, matching queued/history formatting. (#1059)
- Sessions: reset `compactionCount` on `/new` and `/reset`, and preserve `sessions.json` file mode (0600).
- Sessions: repair orphaned user turns before embedded prompts.
- Sessions: hard-stop `sessions.delete` cleanup.
- Channels: treat replies to the bot as implicit mentions across supported channels.
- Channels: normalize object-format capabilities in channel capability parsing.
- Security: default-deny slash/control commands unless a channel computed `CommandAuthorized` (fixes accidental “open” behavior), and ensure WhatsApp + Zalo plugin channels gate inline `/…` tokens correctly. https://docs.clawd.bot/gateway/security
- Security: redact sensitive text in gateway WS logs.
- Tools: cap pending `exec` process output to avoid unbounded buffers.
- CLI: speed up `clawdbot sandbox-explain` by avoiding heavy plugin imports when normalizing channel ids.
- Browser: remote profile tab operations prefer persistent Playwright and avoid silent HTTP fallbacks. (#1057) — thanks @mukhtharcm.
- Browser: remote profile tab ops follow-up: shared Playwright loader, Playwright-based focus, and more coverage (incl. opt-in live Browserless test). (follow-up to #1057) — thanks @mukhtharcm.
- Browser: refresh extension relay tab metadata after navigation so `/json/list` stays current. (#1073) — thanks @roshanasingh4.
- WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message.
- WhatsApp: include `linked` field in `describeAccount`.
- Agents: drop unsigned Gemini tool calls and avoid JSON Schema `format` keyword collisions.
- Agents: hide the image tool when the primary model already supports images.
- Agents: avoid duplicate sends by replying with `NO_REPLY` after `message` tool sends.
- Auth: inherit/merge sub-agent auth profiles from the main agent.
- Gateway: resolve local auth for security probe and validate gateway token/password file modes. (#1011, #1022) — thanks @ivanrvpereira, @kkarimi.
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
- iMessage: avoid RPC restart loops.
- OpenAI image-gen: handle URL + `b64_json` responses and remove deprecated `response_format` (use URL downloads).
- CLI: auto-update global installs when installed via a package manager.
- Routing: migrate legacy `accountID` bindings to `accountId` and remove legacy fallback lookups. (#1047) — thanks @gumadeiras.
- Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr.
- Security: bump `tar` to 7.5.3.
- Models: align ZAI thinking toggles.
- iMessage/Signal: include sender metadata for non-queued group messages. (#1059)
- Discord: preserve whitespace when chunking long lines so message splits keep spacing intact.
- Skills: fix skills watcher ignored list typing (tsc).
## 2026.1.15
### Highlights
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
@@ -11,17 +349,24 @@
### Breaking
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
### Changes
- UI/Apps: move channel/config settings to schema-driven forms and rename Connections → Channels. (#1040) — thanks @thewilloftheshadow.
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
- Telegram: scope inline buttons with allowlist default + callback gating in DMs/groups.
- Telegram: default reaction notifications to own.
- Tools: improve `web_fetch` extraction using Readability (with fallback).
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
- TUI: show provider/model labels for the active session and default model.
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
- UI: show gateway auth guidance + doc link on unauthorized Control UI connections.
- UI: add session deletion action in Control UI sessions list. (#1017) — thanks @Szpadel.
- Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `clawdbot security audit`.
- Apps: store node auth tokens encrypted (Keychain/SecurePrefs).
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
@@ -46,6 +391,17 @@
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
### Fixes
- Messages: make `/stop` clear queued followups and pending session lane work for a hard abort.
- Messages: make `/stop` abort active sub-agent runs spawned from the requester session and report how many were stopped.
- WhatsApp: report linked status consistently in channel status. (#1050) — thanks @YuriNachos.
- Sessions: keep per-session overrides when `/new` resets compaction counters. (#1050) — thanks @YuriNachos.
- Skills: allow OpenAI image-gen helper to handle URL or base64 responses. (#1050) — thanks @YuriNachos.
- WhatsApp: default response prefix only for self-chat, using identity name when set.
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
- iMessage: treat missing `imsg rpc` support as fatal to avoid restart loops.
- Auth: merge main auth profiles into per-agent stores for sub-agents and document inheritance. (#1013) — thanks @marcmarg.
- Agents: avoid JSON Schema `format` collisions in tool params by renaming snapshot format fields. (#1013) — thanks @marcmarg.
- Fix: make `clawdbot update` auto-update global installs when installed via a package manager.
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
@@ -57,6 +413,7 @@
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads.
- Fix: honor message tool channel for duplicate suppression (prefer `NO_REPLY` after `message` tool sends). (#1053) — thanks @sashcatanzarite.
- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).

View File

@@ -25,6 +25,8 @@ RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV CLAWDBOT_PREFER_PNPM=1
RUN pnpm ui:install
RUN pnpm ui:build

Submodule Peekaboo deleted from 5c195f5e46

View File

@@ -11,12 +11,13 @@
<p align="center">
<a href="https://github.com/clawdbot/clawdbot/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/clawdbot/clawdbot/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
<a href="https://github.com/clawdbot/clawdbot/releases"><img src="https://img.shields.io/github/v/release/clawdbot/clawdbot?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
<a href="https://deepwiki.com/clawdbot/clawdbot"><img src="https://img.shields.io/badge/DeepWiki-clawdbot-111111?style=for-the-badge" alt="DeepWiki"></a>
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
</p>
**Clawdbot** is a *personal AI assistant* you run on your own devices.
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
@@ -64,7 +65,7 @@ clawdbot gateway --port 18789 --verbose
# Send a message
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord/Microsoft Teams)
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
clawdbot agent --message "Ship checklist" --thinking high
```
@@ -106,7 +107,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## Highlights
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat, macOS, iOS/Android.
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
@@ -128,7 +129,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
### Channels
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/webchat).
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.clawd.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (extension), [Matrix](https://docs.clawd.bot/channels/matrix) (extension), [Zalo](https://docs.clawd.bot/channels/zalo) (extension), [Zalo Personal](https://docs.clawd.bot/channels/zalouser) (extension), [WebChat](https://docs.clawd.bot/web/webchat).
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
### Apps + nodes
@@ -159,7 +160,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## How it works (short)
```
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Microsoft Teams / WebChat
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
┌───────────────────────────────┐
@@ -249,7 +250,7 @@ Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands ar
- `/compact` — compact session context (summary)
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
- `/verbose on|off`
- `/cost on|off` — append per-response token/cost usage lines
- `/usage off|tokens|full` — per-response usage footer
- `/restart` — restart the gateway (owner-only in groups)
- `/activation mention|always` — group activation toggle (groups only)
@@ -474,23 +475,25 @@ Core contributors:
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/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/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/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/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/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/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/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/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/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/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/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/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
<a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></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/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/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/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/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/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/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/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/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/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/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a>
<a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/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/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/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/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></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/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/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/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/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/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/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/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/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/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a>
<a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></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=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/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/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></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/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a>
<a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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=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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/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/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/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=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></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/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/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/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

@@ -6,7 +6,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit",
"state" : {
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
"version" : "0.1.0"
}
},

View File

@@ -13,7 +13,7 @@ let package = Package(
.executable(name: "swabble", targets: ["SwabbleCLI"]),
],
dependencies: [
.package(path: "../Peekaboo/Commander"),
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"),
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
],
targets: [

View File

@@ -2,6 +2,103 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdbot</title>
<item>
<title>2026.1.16-2</title>
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>6273</sparkle:version>
<sparkle:shortVersionString>2026.1.16-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
<h3>Changes</h3>
<ul>
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
</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>
@@ -174,38 +271,5 @@
]]></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>
<item>
<title>2026.1.12-2</title>
<pubDate>Tue, 13 Jan 2026 10:05:25 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5534</sparkle:version>
<sparkle:shortVersionString>2026.1.12-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.12-2</h2>
<h3>Fixes</h3>
<ul>
<li>Packaging: include <code>dist/memory/**</code> in the npm tarball (fixes <code>ERR_MODULE_NOT_FOUND</code> for <code>dist/memory/index.js</code>).</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.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/>
</item>
<item>
<title>2026.1.11-3</title>
<pubDate>Mon, 12 Jan 2026 10:40:23 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5212</sparkle:version>
<sparkle:shortVersionString>2026.1.11-3</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.11-3</h2>
<h3>Fixes</h3>
<ul>
<li>CLI: avoid top-level await warnings in the entrypoint on fresh installs.</li>
<li>CLI: show a commit hash in the banner for npm installs (package.json gitHead fallback).</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.11-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/>
</item>
</channel>
</rss>

View File

@@ -1,6 +1,6 @@
## Clawdbot Node (Android) (internal)
Modern Android node app: connects to the **Gateway-owned bridge** (`_clawdbot-bridge._tcp`) over TCP and exposes **Canvas + Chat + Camera**.
Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gateway._tcp`) and exposes **Canvas + Chat + Camera**.
Notes:
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
@@ -30,7 +30,7 @@ pnpm clawdbot gateway --port 18789 --verbose
2) In the Android app:
- Open **Settings**
- Either select a discovered bridge under **Discovered Bridges**, or use **Advanced → Manual Bridge** (host + port).
- Either select a discovered gateway under **Discovered Gateways**, or use **Advanced → Manual Gateway** (host + port).
3) Approve pairing (on the gateway machine):
```bash
@@ -38,7 +38,7 @@ clawdbot nodes pending
clawdbot nodes approve <requestId>
```
More details: `docs/android/connect.md`.
More details: `docs/platforms/android.md`.
## Permissions

View File

@@ -103,6 +103,7 @@ dependencies {
implementation("androidx.security:security-crypto:1.1.0")
implementation("androidx.exifinterface:exifinterface:1.4.2")
implementation("com.squareup.okhttp3:okhttp:5.3.2")
// CameraX (for node.invoke camera.* parity)
implementation("androidx.camera:camera-core:1.5.2")
@@ -112,7 +113,7 @@ dependencies {
implementation("androidx.camera:camera-view:1.5.2")
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
implementation("dnsjava:dnsjava:3.6.3")
implementation("dnsjava:dnsjava:3.6.4")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")

View File

@@ -2,7 +2,7 @@ package com.clawdbot.android
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import com.clawdbot.android.bridge.BridgeEndpoint
import com.clawdbot.android.gateway.GatewayEndpoint
import com.clawdbot.android.chat.OutgoingAttachment
import com.clawdbot.android.node.CameraCaptureManager
import com.clawdbot.android.node.CanvasController
@@ -18,7 +18,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
val sms: SmsManager = runtime.sms
val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
val gateways: StateFlow<List<GatewayEndpoint>> = runtime.gateways
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
val isConnected: StateFlow<Boolean> = runtime.isConnected
@@ -50,6 +50,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
val manualHost: StateFlow<String> = runtime.manualHost
val manualPort: StateFlow<Int> = runtime.manualPort
val manualTls: StateFlow<Boolean> = runtime.manualTls
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
@@ -99,6 +100,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setManualPort(value)
}
fun setManualTls(value: Boolean) {
runtime.setManualTls(value)
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
runtime.setCanvasDebugStatusEnabled(value)
}
@@ -119,11 +124,11 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setTalkEnabled(enabled)
}
fun refreshBridgeHello() {
runtime.refreshBridgeHello()
fun refreshGatewayConnection() {
runtime.refreshGatewayConnection()
}
fun connect(endpoint: BridgeEndpoint) {
fun connect(endpoint: GatewayEndpoint) {
runtime.connect(endpoint)
}

View File

@@ -12,11 +12,13 @@ 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.bridge.BridgeDiscovery
import com.clawdbot.android.bridge.BridgeEndpoint
import com.clawdbot.android.bridge.BridgePairingClient
import com.clawdbot.android.bridge.BridgeSession
import com.clawdbot.android.bridge.BridgeTlsParams
import com.clawdbot.android.gateway.DeviceIdentityStore
import com.clawdbot.android.gateway.GatewayClientInfo
import com.clawdbot.android.gateway.GatewayConnectOptions
import com.clawdbot.android.gateway.GatewayDiscovery
import com.clawdbot.android.gateway.GatewayEndpoint
import com.clawdbot.android.gateway.GatewaySession
import com.clawdbot.android.gateway.GatewayTlsParams
import com.clawdbot.android.node.CameraCaptureManager
import com.clawdbot.android.node.LocationCaptureManager
import com.clawdbot.android.BuildConfig
@@ -74,7 +76,7 @@ class NodeRuntime(context: Context) {
context = appContext,
scope = scope,
onCommand = { command ->
session.sendEvent(
nodeSession.sendNodeEvent(
event = "agent.request",
payloadJson =
buildJsonObject {
@@ -103,10 +105,12 @@ class NodeRuntime(context: Context) {
val talkIsSpeaking: StateFlow<Boolean>
get() = talkMode.isSpeaking
private val discovery = BridgeDiscovery(appContext, scope = scope)
val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
private val discovery = GatewayDiscovery(appContext, scope = scope)
val gateways: StateFlow<List<GatewayEndpoint>> = discovery.gateways
val discoveryStatusText: StateFlow<String> = discovery.statusText
private val identityStore = DeviceIdentityStore(appContext)
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
@@ -139,52 +143,87 @@ class NodeRuntime(context: Context) {
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
private var lastAutoA2uiUrl: String? = null
private var operatorConnected = false
private var nodeConnected = false
private var operatorStatusText: String = "Offline"
private var nodeStatusText: String = "Offline"
private var connectedEndpoint: GatewayEndpoint? = null
private val session =
BridgeSession(
private val operatorSession =
GatewaySession(
scope = scope,
identityStore = identityStore,
onConnected = { name, remote, mainSessionKey ->
_statusText.value = "Connected"
operatorConnected = true
operatorStatusText = "Connected"
_serverName.value = name
_remoteAddress.value = remote
_isConnected.value = true
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
applyMainSessionKey(mainSessionKey)
updateStatus()
scope.launch { refreshBrandingFromGateway() }
scope.launch { refreshWakeWordsFromGateway() }
},
onDisconnected = { message ->
operatorConnected = false
operatorStatusText = message
_serverName.value = null
_remoteAddress.value = null
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
_mainSessionKey.value = "main"
}
val mainKey = resolveMainSessionKey()
talkMode.setMainSessionKey(mainKey)
chat.applyMainSessionKey(mainKey)
chat.onDisconnected(message)
updateStatus()
},
onEvent = { event, payloadJson ->
handleGatewayEvent(event, payloadJson)
},
)
private val nodeSession =
GatewaySession(
scope = scope,
identityStore = identityStore,
onConnected = { _, _, _ ->
nodeConnected = true
nodeStatusText = "Connected"
updateStatus()
maybeNavigateToA2uiOnConnect()
},
onDisconnected = { message -> handleSessionDisconnected(message) },
onEvent = { event, payloadJson ->
handleBridgeEvent(event, payloadJson)
onDisconnected = { message ->
nodeConnected = false
nodeStatusText = message
updateStatus()
showLocalCanvasOnDisconnect()
},
onEvent = { _, _ -> },
onInvoke = { req ->
handleInvoke(req.command, req.paramsJson)
},
onTlsFingerprint = { stableId, fingerprint ->
prefs.saveBridgeTlsFingerprint(stableId, fingerprint)
prefs.saveGatewayTlsFingerprint(stableId, fingerprint)
},
)
private val chat = ChatController(scope = scope, session = session, json = json)
private val chat: ChatController =
ChatController(
scope = scope,
session = operatorSession,
json = json,
supportsChatSubscribe = false,
)
private val talkMode: TalkModeManager by lazy {
TalkModeManager(context = appContext, scope = scope).also { it.attachSession(session) }
}
private fun handleSessionDisconnected(message: String) {
_statusText.value = message
_serverName.value = null
_remoteAddress.value = null
_isConnected.value = false
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
_mainSessionKey.value = "main"
}
val mainKey = resolveMainSessionKey()
talkMode.setMainSessionKey(mainKey)
chat.applyMainSessionKey(mainKey)
chat.onDisconnected(message)
showLocalCanvasOnDisconnect()
TalkModeManager(
context = appContext,
scope = scope,
session = operatorSession,
supportsChatSubscribe = false,
isConnected = { operatorConnected },
)
}
private fun applyMainSessionKey(candidate: String?) {
@@ -197,6 +236,18 @@ class NodeRuntime(context: Context) {
chat.applyMainSessionKey(trimmed)
}
private fun updateStatus() {
_isConnected.value = operatorConnected
_statusText.value =
when {
operatorConnected && nodeConnected -> "Connected"
operatorConnected && !nodeConnected -> "Connected (node offline)"
!operatorConnected && nodeConnected -> "Connected (operator offline)"
operatorStatusText.isNotBlank() && operatorStatusText != "Offline" -> operatorStatusText
else -> nodeStatusText
}
}
private fun resolveMainSessionKey(): String {
val trimmed = _mainSessionKey.value.trim()
return if (trimmed.isEmpty()) "main" else trimmed
@@ -228,6 +279,7 @@ class NodeRuntime(context: Context) {
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
val manualHost: StateFlow<String> = prefs.manualHost
val manualPort: StateFlow<Int> = prefs.manualPort
val manualTls: StateFlow<Boolean> = prefs.manualTls
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
@@ -288,24 +340,21 @@ class NodeRuntime(context: Context) {
}
scope.launch(Dispatchers.Default) {
bridges.collect { list ->
gateways.collect { list ->
if (list.isNotEmpty()) {
// Persist the last discovered bridge (best-effort UX parity with iOS).
// Persist the last discovered gateway (best-effort UX parity with iOS).
prefs.setLastDiscoveredStableId(list.last().stableId)
}
if (didAutoConnect) return@collect
if (_isConnected.value) return@collect
val token = prefs.loadBridgeToken()
if (token.isNullOrBlank()) return@collect
if (manualEnabled.value) {
val host = manualHost.value.trim()
val port = manualPort.value
if (host.isNotEmpty() && port in 1..65535) {
didAutoConnect = true
connect(BridgeEndpoint.manual(host = host, port = port))
connect(GatewayEndpoint.manual(host = host, port = port))
}
return@collect
}
@@ -371,6 +420,10 @@ class NodeRuntime(context: Context) {
prefs.setManualPort(value)
}
fun setManualTls(value: Boolean) {
prefs.setManualTls(value)
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
prefs.setCanvasDebugStatusEnabled(value)
}
@@ -429,99 +482,87 @@ class NodeRuntime(context: Context) {
}
}
private fun buildPairingHello(token: String?): BridgePairingClient.Hello {
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
.joinToString(" ")
.trim()
.ifEmpty { null }
private fun resolvedVersionName(): String {
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
val advertisedVersion =
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
"$versionName-dev"
} else {
versionName
}
return BridgePairingClient.Hello(
nodeId = instanceId.value,
displayName = displayName.value,
token = token,
platform = "Android",
version = advertisedVersion,
deviceFamily = "Android",
modelIdentifier = modelIdentifier,
caps = buildCapabilities(),
commands = buildInvokeCommands(),
)
}
private fun buildSessionHello(token: String?): BridgeSession.Hello {
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
.joinToString(" ")
.trim()
.ifEmpty { null }
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
val advertisedVersion =
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
"$versionName-dev"
} else {
versionName
}
return BridgeSession.Hello(
nodeId = instanceId.value,
displayName = displayName.value,
token = token,
platform = "Android",
version = advertisedVersion,
deviceFamily = "Android",
modelIdentifier = modelIdentifier,
caps = buildCapabilities(),
commands = buildInvokeCommands(),
)
}
fun refreshBridgeHello() {
scope.launch {
if (!_isConnected.value) return@launch
val token = prefs.loadBridgeToken()
if (token.isNullOrBlank()) return@launch
session.updateHello(buildSessionHello(token))
return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
"$versionName-dev"
} else {
versionName
}
}
fun connect(endpoint: BridgeEndpoint) {
scope.launch {
_statusText.value = "Connecting…"
val storedToken = prefs.loadBridgeToken()
val tls = resolveTlsParams(endpoint)
val resolved =
if (storedToken.isNullOrBlank()) {
_statusText.value = "Pairing…"
BridgePairingClient().pairAndHello(
endpoint = endpoint,
hello = buildPairingHello(token = null),
tls = tls,
onTlsFingerprint = { fingerprint ->
prefs.saveBridgeTlsFingerprint(endpoint.stableId, fingerprint)
},
)
} else {
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
}
private fun resolveModelIdentifier(): String? {
return listOfNotNull(Build.MANUFACTURER, Build.MODEL)
.joinToString(" ")
.trim()
.ifEmpty { null }
}
if (!resolved.ok || resolved.token.isNullOrBlank()) {
val errorMessage = resolved.error?.trim().orEmpty().ifEmpty { "pairing required" }
_statusText.value = "Failed: $errorMessage"
return@launch
}
private fun buildUserAgent(): String {
val version = resolvedVersionName()
val release = Build.VERSION.RELEASE?.trim().orEmpty()
val releaseLabel = if (release.isEmpty()) "unknown" else release
return "ClawdbotAndroid/$version (Android $releaseLabel; SDK ${Build.VERSION.SDK_INT})"
}
val authToken = requireNotNull(resolved.token).trim()
prefs.saveBridgeToken(authToken)
session.connect(
endpoint = endpoint,
hello = buildSessionHello(token = authToken),
tls = tls,
)
}
private fun buildClientInfo(clientId: String, clientMode: String): GatewayClientInfo {
return GatewayClientInfo(
id = clientId,
displayName = displayName.value,
version = resolvedVersionName(),
platform = "android",
mode = clientMode,
instanceId = instanceId.value,
deviceFamily = "Android",
modelIdentifier = resolveModelIdentifier(),
)
}
private fun buildNodeConnectOptions(): GatewayConnectOptions {
return GatewayConnectOptions(
role = "node",
scopes = emptyList(),
caps = buildCapabilities(),
commands = buildInvokeCommands(),
permissions = emptyMap(),
client = buildClientInfo(clientId = "node-host", clientMode = "node"),
userAgent = buildUserAgent(),
)
}
private fun buildOperatorConnectOptions(): GatewayConnectOptions {
return GatewayConnectOptions(
role = "operator",
scopes = emptyList(),
caps = emptyList(),
commands = emptyList(),
permissions = emptyMap(),
client = buildClientInfo(clientId = "clawdbot-control-ui", clientMode = "ui"),
userAgent = buildUserAgent(),
)
}
fun refreshGatewayConnection() {
val endpoint = connectedEndpoint ?: return
val token = prefs.loadGatewayToken()
val password = prefs.loadGatewayPassword()
val tls = resolveTlsParams(endpoint)
operatorSession.connect(endpoint, token, password, buildOperatorConnectOptions(), tls)
nodeSession.connect(endpoint, token, password, buildNodeConnectOptions(), tls)
operatorSession.reconnect()
nodeSession.reconnect()
}
fun connect(endpoint: GatewayEndpoint) {
connectedEndpoint = endpoint
operatorStatusText = "Connecting…"
nodeStatusText = "Connecting…"
updateStatus()
val token = prefs.loadGatewayToken()
val password = prefs.loadGatewayPassword()
val tls = resolveTlsParams(endpoint)
operatorSession.connect(endpoint, token, password, buildOperatorConnectOptions(), tls)
nodeSession.connect(endpoint, token, password, buildNodeConnectOptions(), tls)
}
private fun hasRecordAudioPermission(): Boolean {
@@ -559,20 +600,32 @@ class NodeRuntime(context: Context) {
_statusText.value = "Failed: invalid manual host/port"
return
}
connect(BridgeEndpoint.manual(host = host, port = port))
connect(GatewayEndpoint.manual(host = host, port = port))
}
fun disconnect() {
session.disconnect()
connectedEndpoint = null
operatorSession.disconnect()
nodeSession.disconnect()
}
private fun resolveTlsParams(endpoint: BridgeEndpoint): BridgeTlsParams? {
val stored = prefs.loadBridgeTlsFingerprint(endpoint.stableId)
private fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? {
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
val manual = endpoint.stableId.startsWith("manual|")
if (manual) {
if (!manualTls.value) return null
return GatewayTlsParams(
required = true,
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
allowTOFU = stored == null,
stableId = endpoint.stableId,
)
}
if (hinted) {
return BridgeTlsParams(
return GatewayTlsParams(
required = true,
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
allowTOFU = stored == null,
@@ -581,7 +634,7 @@ class NodeRuntime(context: Context) {
}
if (!stored.isNullOrBlank()) {
return BridgeTlsParams(
return GatewayTlsParams(
required = true,
expectedFingerprint = stored,
allowTOFU = false,
@@ -589,15 +642,6 @@ class NodeRuntime(context: Context) {
)
}
if (manual) {
return BridgeTlsParams(
required = false,
expectedFingerprint = null,
allowTOFU = true,
stableId = endpoint.stableId,
)
}
return null
}
@@ -637,11 +681,11 @@ class NodeRuntime(context: Context) {
contextJson = contextJson,
)
val connected = isConnected.value
val connected = nodeConnected
var error: String? = null
if (connected) {
try {
session.sendEvent(
nodeSession.sendNodeEvent(
event = "agent.request",
payloadJson =
buildJsonObject {
@@ -656,7 +700,7 @@ class NodeRuntime(context: Context) {
error = e.message ?: "send failed"
}
} else {
error = "bridge not connected"
error = "gateway not connected"
}
try {
@@ -702,7 +746,7 @@ class NodeRuntime(context: Context) {
chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments)
}
private fun handleBridgeEvent(event: String, payloadJson: String?) {
private fun handleGatewayEvent(event: String, payloadJson: String?) {
if (event == "voicewake.changed") {
if (payloadJson.isNullOrBlank()) return
try {
@@ -716,8 +760,8 @@ class NodeRuntime(context: Context) {
return
}
talkMode.handleBridgeEvent(event, payloadJson)
chat.handleBridgeEvent(event, payloadJson)
talkMode.handleGatewayEvent(event, payloadJson)
chat.handleGatewayEvent(event, payloadJson)
}
private fun applyWakeWordsFromGateway(words: List<String>) {
@@ -738,7 +782,7 @@ class NodeRuntime(context: Context) {
val jsonList = snapshot.joinToString(separator = ",") { it.toJsonString() }
val params = """{"triggers":[$jsonList]}"""
try {
session.request("voicewake.set", params)
operatorSession.request("voicewake.set", params)
} catch (_: Throwable) {
// ignore
}
@@ -748,7 +792,7 @@ class NodeRuntime(context: Context) {
private suspend fun refreshWakeWordsFromGateway() {
if (!_isConnected.value) return
try {
val res = session.request("voicewake.get", "{}")
val res = operatorSession.request("voicewake.get", "{}")
val payload = json.parseToJsonElement(res).asObjectOrNull() ?: return
val array = payload["triggers"] as? JsonArray ?: return
val triggers = array.mapNotNull { it.asStringOrNull() }
@@ -761,7 +805,7 @@ class NodeRuntime(context: Context) {
private suspend fun refreshBrandingFromGateway() {
if (!_isConnected.value) return
try {
val res = session.request("config.get", "{}")
val res = operatorSession.request("config.get", "{}")
val root = json.parseToJsonElement(res).asObjectOrNull()
val config = root?.get("config").asObjectOrNull()
val ui = config?.get("ui").asObjectOrNull()
@@ -777,7 +821,7 @@ class NodeRuntime(context: Context) {
}
}
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
private suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult {
if (
command.startsWith(ClawdbotCanvasCommand.NamespacePrefix) ||
command.startsWith(ClawdbotCanvasA2UICommand.NamespacePrefix) ||
@@ -785,14 +829,14 @@ class NodeRuntime(context: Context) {
command.startsWith(ClawdbotScreenCommand.NamespacePrefix)
) {
if (!isForeground.value) {
return BridgeSession.InvokeResult.error(
return GatewaySession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
)
}
}
if (command.startsWith(ClawdbotCameraCommand.NamespacePrefix) && !cameraEnabled.value) {
return BridgeSession.InvokeResult.error(
return GatewaySession.InvokeResult.error(
code = "CAMERA_DISABLED",
message = "CAMERA_DISABLED: enable Camera in Settings",
)
@@ -800,7 +844,7 @@ class NodeRuntime(context: Context) {
if (command.startsWith(ClawdbotLocationCommand.NamespacePrefix) &&
locationMode.value == LocationMode.Off
) {
return BridgeSession.InvokeResult.error(
return GatewaySession.InvokeResult.error(
code = "LOCATION_DISABLED",
message = "LOCATION_DISABLED: enable Location in Settings",
)
@@ -810,18 +854,18 @@ class NodeRuntime(context: Context) {
ClawdbotCanvasCommand.Present.rawValue -> {
val url = CanvasController.parseNavigateUrl(paramsJson)
canvas.navigate(url)
BridgeSession.InvokeResult.ok(null)
GatewaySession.InvokeResult.ok(null)
}
ClawdbotCanvasCommand.Hide.rawValue -> BridgeSession.InvokeResult.ok(null)
ClawdbotCanvasCommand.Hide.rawValue -> GatewaySession.InvokeResult.ok(null)
ClawdbotCanvasCommand.Navigate.rawValue -> {
val url = CanvasController.parseNavigateUrl(paramsJson)
canvas.navigate(url)
BridgeSession.InvokeResult.ok(null)
GatewaySession.InvokeResult.ok(null)
}
ClawdbotCanvasCommand.Eval.rawValue -> {
val js =
CanvasController.parseEvalJs(paramsJson)
?: return BridgeSession.InvokeResult.error(
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: javaScript required",
)
@@ -829,12 +873,12 @@ class NodeRuntime(context: Context) {
try {
canvas.eval(js)
} catch (err: Throwable) {
return BridgeSession.InvokeResult.error(
return GatewaySession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
)
}
BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
}
ClawdbotCanvasCommand.Snapshot.rawValue -> {
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
@@ -846,51 +890,51 @@ class NodeRuntime(context: Context) {
maxWidth = snapshotParams.maxWidth,
)
} catch (err: Throwable) {
return BridgeSession.InvokeResult.error(
return GatewaySession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
)
}
BridgeSession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
}
ClawdbotCanvasA2UICommand.Reset.rawValue -> {
val a2uiUrl = resolveA2uiHostUrl()
?: return BridgeSession.InvokeResult.error(
?: return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED",
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
)
val ready = ensureA2uiReady(a2uiUrl)
if (!ready) {
return BridgeSession.InvokeResult.error(
return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_UNAVAILABLE",
message = "A2UI host not reachable",
)
}
val res = canvas.eval(a2uiResetJS)
BridgeSession.InvokeResult.ok(res)
GatewaySession.InvokeResult.ok(res)
}
ClawdbotCanvasA2UICommand.Push.rawValue, ClawdbotCanvasA2UICommand.PushJSONL.rawValue -> {
val messages =
try {
decodeA2uiMessages(command, paramsJson)
} catch (err: Throwable) {
return BridgeSession.InvokeResult.error(code = "INVALID_REQUEST", message = err.message ?: "invalid A2UI payload")
return GatewaySession.InvokeResult.error(code = "INVALID_REQUEST", message = err.message ?: "invalid A2UI payload")
}
val a2uiUrl = resolveA2uiHostUrl()
?: return BridgeSession.InvokeResult.error(
?: return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED",
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
)
val ready = ensureA2uiReady(a2uiUrl)
if (!ready) {
return BridgeSession.InvokeResult.error(
return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_UNAVAILABLE",
message = "A2UI host not reachable",
)
}
val js = a2uiApplyMessagesJS(messages)
val res = canvas.eval(js)
BridgeSession.InvokeResult.ok(res)
GatewaySession.InvokeResult.ok(res)
}
ClawdbotCameraCommand.Snap.rawValue -> {
showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo)
@@ -901,10 +945,10 @@ class NodeRuntime(context: Context) {
} catch (err: Throwable) {
val (code, message) = invokeErrorFromThrowable(err)
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2200)
return BridgeSession.InvokeResult.error(code = code, message = message)
return GatewaySession.InvokeResult.error(code = code, message = message)
}
showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600)
BridgeSession.InvokeResult.ok(res.payloadJson)
GatewaySession.InvokeResult.ok(res.payloadJson)
}
ClawdbotCameraCommand.Clip.rawValue -> {
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
@@ -917,10 +961,10 @@ class NodeRuntime(context: Context) {
} catch (err: Throwable) {
val (code, message) = invokeErrorFromThrowable(err)
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2400)
return BridgeSession.InvokeResult.error(code = code, message = message)
return GatewaySession.InvokeResult.error(code = code, message = message)
}
showCameraHud(message = "Clip captured", kind = CameraHudKind.Success, autoHideMs = 1800)
BridgeSession.InvokeResult.ok(res.payloadJson)
GatewaySession.InvokeResult.ok(res.payloadJson)
} finally {
if (includeAudio) externalAudioCaptureActive.value = false
}
@@ -928,19 +972,19 @@ class NodeRuntime(context: Context) {
ClawdbotLocationCommand.Get.rawValue -> {
val mode = locationMode.value
if (!isForeground.value && mode != LocationMode.Always) {
return BridgeSession.InvokeResult.error(
return GatewaySession.InvokeResult.error(
code = "LOCATION_BACKGROUND_UNAVAILABLE",
message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always",
)
}
if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) {
return BridgeSession.InvokeResult.error(
return GatewaySession.InvokeResult.error(
code = "LOCATION_PERMISSION_REQUIRED",
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
)
}
if (!isForeground.value && mode == LocationMode.Always && !hasBackgroundLocationPermission()) {
return BridgeSession.InvokeResult.error(
return GatewaySession.InvokeResult.error(
code = "LOCATION_PERMISSION_REQUIRED",
message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings",
)
@@ -967,15 +1011,15 @@ class NodeRuntime(context: Context) {
timeoutMs = timeoutMs,
isPrecise = accuracy == "precise",
)
BridgeSession.InvokeResult.ok(payload.payloadJson)
GatewaySession.InvokeResult.ok(payload.payloadJson)
} catch (err: TimeoutCancellationException) {
BridgeSession.InvokeResult.error(
GatewaySession.InvokeResult.error(
code = "LOCATION_TIMEOUT",
message = "LOCATION_TIMEOUT: no fix in time",
)
} catch (err: Throwable) {
val message = err.message ?: "LOCATION_UNAVAILABLE: no fix"
BridgeSession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message)
GatewaySession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message)
}
}
ClawdbotScreenCommand.Record.rawValue -> {
@@ -987,9 +1031,9 @@ class NodeRuntime(context: Context) {
screenRecorder.record(paramsJson)
} catch (err: Throwable) {
val (code, message) = invokeErrorFromThrowable(err)
return BridgeSession.InvokeResult.error(code = code, message = message)
return GatewaySession.InvokeResult.error(code = code, message = message)
}
BridgeSession.InvokeResult.ok(res.payloadJson)
GatewaySession.InvokeResult.ok(res.payloadJson)
} finally {
_screenRecordActive.value = false
}
@@ -997,16 +1041,16 @@ class NodeRuntime(context: Context) {
ClawdbotSmsCommand.Send.rawValue -> {
val res = sms.send(paramsJson)
if (res.ok) {
BridgeSession.InvokeResult.ok(res.payloadJson)
GatewaySession.InvokeResult.ok(res.payloadJson)
} else {
val error = res.error ?: "SMS_SEND_FAILED"
val idx = error.indexOf(':')
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEND_FAILED"
BridgeSession.InvokeResult.error(code = code, message = error)
GatewaySession.InvokeResult.error(code = code, message = error)
}
}
else ->
BridgeSession.InvokeResult.error(
GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: unknown command",
)
@@ -1062,7 +1106,9 @@ class NodeRuntime(context: Context) {
}
private fun resolveA2uiHostUrl(): String? {
val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
val nodeRaw = nodeSession.currentCanvasHostUrl()?.trim().orEmpty()
val operatorRaw = operatorSession.currentCanvasHostUrl()?.trim().orEmpty()
val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw
if (raw.isBlank()) return null
val base = raw.trimEnd('/')
return "${base}/__clawdbot__/a2ui/?platform=android"

View File

@@ -58,17 +58,30 @@ class SecurePrefs(context: Context) {
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
val preventSleep: StateFlow<Boolean> = _preventSleep
private val _manualEnabled = MutableStateFlow(prefs.getBoolean("bridge.manual.enabled", false))
private val _manualEnabled =
MutableStateFlow(readBoolWithMigration("gateway.manual.enabled", "bridge.manual.enabled", false))
val manualEnabled: StateFlow<Boolean> = _manualEnabled
private val _manualHost = MutableStateFlow(prefs.getString("bridge.manual.host", "")!!)
private val _manualHost =
MutableStateFlow(readStringWithMigration("gateway.manual.host", "bridge.manual.host", ""))
val manualHost: StateFlow<String> = _manualHost
private val _manualPort = MutableStateFlow(prefs.getInt("bridge.manual.port", 18790))
private val _manualPort =
MutableStateFlow(readIntWithMigration("gateway.manual.port", "bridge.manual.port", 18789))
val manualPort: StateFlow<Int> = _manualPort
private val _manualTls =
MutableStateFlow(readBoolWithMigration("gateway.manual.tls", null, true))
val manualTls: StateFlow<Boolean> = _manualTls
private val _lastDiscoveredStableId =
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
MutableStateFlow(
readStringWithMigration(
"gateway.lastDiscoveredStableID",
"bridge.lastDiscoveredStableId",
"",
),
)
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
private val _canvasDebugStatusEnabled =
@@ -86,7 +99,7 @@ class SecurePrefs(context: Context) {
fun setLastDiscoveredStableId(value: String) {
val trimmed = value.trim()
prefs.edit { putString("bridge.lastDiscoveredStableId", trimmed) }
prefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
_lastDiscoveredStableId.value = trimmed
}
@@ -117,43 +130,62 @@ class SecurePrefs(context: Context) {
}
fun setManualEnabled(value: Boolean) {
prefs.edit { putBoolean("bridge.manual.enabled", value) }
prefs.edit { putBoolean("gateway.manual.enabled", value) }
_manualEnabled.value = value
}
fun setManualHost(value: String) {
val trimmed = value.trim()
prefs.edit { putString("bridge.manual.host", trimmed) }
prefs.edit { putString("gateway.manual.host", trimmed) }
_manualHost.value = trimmed
}
fun setManualPort(value: Int) {
prefs.edit { putInt("bridge.manual.port", value) }
prefs.edit { putInt("gateway.manual.port", value) }
_manualPort.value = value
}
fun setManualTls(value: Boolean) {
prefs.edit { putBoolean("gateway.manual.tls", value) }
_manualTls.value = value
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
_canvasDebugStatusEnabled.value = value
}
fun loadBridgeToken(): String? {
val key = "bridge.token.${_instanceId.value}"
return prefs.getString(key, null)
fun loadGatewayToken(): String? {
val key = "gateway.token.${_instanceId.value}"
val stored = prefs.getString(key, null)?.trim()
if (!stored.isNullOrEmpty()) return stored
val legacy = prefs.getString("bridge.token.${_instanceId.value}", null)?.trim()
return legacy?.takeIf { it.isNotEmpty() }
}
fun saveBridgeToken(token: String) {
val key = "bridge.token.${_instanceId.value}"
fun saveGatewayToken(token: String) {
val key = "gateway.token.${_instanceId.value}"
prefs.edit { putString(key, token.trim()) }
}
fun loadBridgeTlsFingerprint(stableId: String): String? {
val key = "bridge.tls.$stableId"
fun loadGatewayPassword(): String? {
val key = "gateway.password.${_instanceId.value}"
val stored = prefs.getString(key, null)?.trim()
return stored?.takeIf { it.isNotEmpty() }
}
fun saveGatewayPassword(password: String) {
val key = "gateway.password.${_instanceId.value}"
prefs.edit { putString(key, password.trim()) }
}
fun loadGatewayTlsFingerprint(stableId: String): String? {
val key = "gateway.tls.$stableId"
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
}
fun saveBridgeTlsFingerprint(stableId: String, fingerprint: String) {
val key = "bridge.tls.$stableId"
fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) {
val key = "gateway.tls.$stableId"
prefs.edit { putString(key, fingerprint.trim()) }
}
@@ -225,4 +257,40 @@ class SecurePrefs(context: Context) {
defaultWakeWords
}
}
private fun readBoolWithMigration(newKey: String, oldKey: String?, defaultValue: Boolean): Boolean {
if (prefs.contains(newKey)) {
return prefs.getBoolean(newKey, defaultValue)
}
if (oldKey != null && prefs.contains(oldKey)) {
val value = prefs.getBoolean(oldKey, defaultValue)
prefs.edit { putBoolean(newKey, value) }
return value
}
return defaultValue
}
private fun readStringWithMigration(newKey: String, oldKey: String?, defaultValue: String): String {
if (prefs.contains(newKey)) {
return prefs.getString(newKey, defaultValue) ?: defaultValue
}
if (oldKey != null && prefs.contains(oldKey)) {
val value = prefs.getString(oldKey, defaultValue) ?: defaultValue
prefs.edit { putString(newKey, value) }
return value
}
return defaultValue
}
private fun readIntWithMigration(newKey: String, oldKey: String?, defaultValue: Int): Int {
if (prefs.contains(newKey)) {
return prefs.getInt(newKey, defaultValue)
}
if (oldKey != null && prefs.contains(oldKey)) {
val value = prefs.getInt(oldKey, defaultValue)
prefs.edit { putInt(newKey, value) }
return value
}
return defaultValue
}
}

View File

@@ -1,158 +0,0 @@
package com.clawdbot.android.bridge
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.buildJsonObject
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.InetSocketAddress
class BridgePairingClient {
private val json = Json { ignoreUnknownKeys = true }
data class Hello(
val nodeId: String,
val displayName: String?,
val token: String?,
val platform: String?,
val version: String?,
val deviceFamily: String?,
val modelIdentifier: String?,
val caps: List<String>?,
val commands: List<String>?,
)
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
suspend fun pairAndHello(
endpoint: BridgeEndpoint,
hello: Hello,
tls: BridgeTlsParams? = null,
onTlsFingerprint: ((String) -> Unit)? = null,
): PairResult =
withContext(Dispatchers.IO) {
if (tls != null) {
try {
return@withContext pairAndHelloWithTls(endpoint, hello, tls, onTlsFingerprint)
} catch (e: Exception) {
if (tls.required) throw e
}
}
pairAndHelloWithTls(endpoint, hello, null, null)
}
private fun pairAndHelloWithTls(
endpoint: BridgeEndpoint,
hello: Hello,
tls: BridgeTlsParams?,
onTlsFingerprint: ((String) -> Unit)?,
): PairResult {
val socket =
createBridgeSocket(tls) { fingerprint ->
onTlsFingerprint?.invoke(fingerprint)
}
socket.tcpNoDelay = true
try {
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 60_000
startTlsHandshakeIfNeeded(socket)
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
fun send(line: String) {
writer.write(line)
writer.write("\n")
writer.flush()
}
fun sendJson(obj: JsonObject) = send(obj.toString())
sendJson(
buildJsonObject {
put("type", JsonPrimitive("hello"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.token?.let { put("token", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
},
)
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
?: return PairResult(ok = false, token = null, error = "unexpected bridge response")
return when (firstObj["type"].asStringOrNull()) {
"hello-ok" -> PairResult(ok = true, token = hello.token)
"error" -> {
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
return PairResult(ok = false, token = null, error = "$code: $message")
}
sendJson(
buildJsonObject {
put("type", JsonPrimitive("pair-request"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
},
)
while (true) {
val nextLine = reader.readLine() ?: break
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
when (next["type"].asStringOrNull()) {
"pair-ok" -> {
val token = next["token"].asStringOrNull()
return PairResult(ok = !token.isNullOrBlank(), token = token)
}
"error" -> {
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
val m = next["message"].asStringOrNull() ?: "pairing failed"
return PairResult(ok = false, token = null, error = "$c: $m")
}
}
}
PairResult(ok = false, token = null, error = "pairing failed")
}
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
}
} catch (e: Exception) {
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
return PairResult(ok = false, token = null, error = message)
} finally {
try {
socket.close()
} catch (_: Throwable) {
// ignore
}
}
}
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asStringOrNull(): String? =
when (this) {
is JsonNull -> null
is JsonPrimitive -> content
else -> null
}

View File

@@ -1,398 +0,0 @@
package com.clawdbot.android.bridge
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import com.clawdbot.android.BuildConfig
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.InetSocketAddress
import java.net.URI
import java.net.Socket
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
class BridgeSession(
private val scope: CoroutineScope,
private val onConnected: (serverName: String, remoteAddress: String?, mainSessionKey: String?) -> Unit,
private val onDisconnected: (message: String) -> Unit,
private val onEvent: (event: String, payloadJson: String?) -> Unit,
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
) {
data class Hello(
val nodeId: String,
val displayName: String?,
val token: String?,
val platform: String?,
val version: String?,
val deviceFamily: String?,
val modelIdentifier: String?,
val caps: List<String>?,
val commands: List<String>?,
)
data class InvokeRequest(val id: String, val command: String, val paramsJson: String?)
data class InvokeResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) {
companion object {
fun ok(payloadJson: String?) = InvokeResult(ok = true, payloadJson = payloadJson, error = null)
fun error(code: String, message: String) =
InvokeResult(ok = false, payloadJson = null, error = ErrorShape(code = code, message = message))
}
}
data class ErrorShape(val code: String, val message: String)
private val json = Json { ignoreUnknownKeys = true }
private val writeLock = Mutex()
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
@Volatile private var canvasHostUrl: String? = null
@Volatile private var mainSessionKey: String? = null
private data class DesiredConnection(
val endpoint: BridgeEndpoint,
val hello: Hello,
val tls: BridgeTlsParams?,
)
private var desired: DesiredConnection? = null
private var job: Job? = null
fun connect(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams? = null) {
desired = DesiredConnection(endpoint, hello, tls)
if (job == null) {
job = scope.launch(Dispatchers.IO) { runLoop() }
}
}
suspend fun updateHello(hello: Hello) {
val target = desired ?: return
desired = target.copy(hello = hello)
val conn = currentConnection ?: return
conn.sendJson(buildHelloJson(hello))
}
fun disconnect() {
desired = null
// Unblock connectOnce() read loop. Coroutine cancellation alone won't interrupt BufferedReader.readLine().
currentConnection?.closeQuietly()
scope.launch(Dispatchers.IO) {
job?.cancelAndJoin()
job = null
canvasHostUrl = null
mainSessionKey = null
onDisconnected("Offline")
}
}
fun currentCanvasHostUrl(): String? = canvasHostUrl
fun currentMainSessionKey(): String? = mainSessionKey
suspend fun sendEvent(event: String, payloadJson: String?) {
val conn = currentConnection ?: return
conn.sendJson(
buildJsonObject {
put("type", JsonPrimitive("event"))
put("event", JsonPrimitive(event))
if (payloadJson != null) put("payloadJSON", JsonPrimitive(payloadJson)) else put("payloadJSON", JsonNull)
},
)
}
suspend fun request(method: String, paramsJson: String?): String {
val conn = currentConnection ?: throw IllegalStateException("not connected")
val id = UUID.randomUUID().toString()
val deferred = CompletableDeferred<RpcResponse>()
pending[id] = deferred
conn.sendJson(
buildJsonObject {
put("type", JsonPrimitive("req"))
put("id", JsonPrimitive(id))
put("method", JsonPrimitive(method))
if (paramsJson != null) put("paramsJSON", JsonPrimitive(paramsJson)) else put("paramsJSON", JsonNull)
},
)
val res = deferred.await()
if (res.ok) return res.payloadJson ?: ""
val err = res.error
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
}
private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
private class Connection(private val socket: Socket, private val reader: BufferedReader, private val writer: BufferedWriter, private val writeLock: Mutex) {
val remoteAddress: String? =
socket.inetAddress?.hostAddress?.takeIf { it.isNotBlank() }?.let { "${it}:${socket.port}" }
suspend fun sendJson(obj: JsonObject) {
writeLock.withLock {
writer.write(obj.toString())
writer.write("\n")
writer.flush()
}
}
fun closeQuietly() {
try {
socket.close()
} catch (_: Throwable) {
// ignore
}
}
}
@Volatile private var currentConnection: Connection? = null
private suspend fun runLoop() {
var attempt = 0
while (scope.isActive) {
val target = desired
if (target == null) {
currentConnection?.closeQuietly()
currentConnection = null
delay(250)
continue
}
val (endpoint, hello, tls) = target
try {
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
connectOnce(endpoint, hello, tls)
attempt = 0
} catch (err: Throwable) {
attempt += 1
onDisconnected("Bridge error: ${err.message ?: err::class.java.simpleName}")
val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong())
delay(sleepMs)
}
}
}
private fun invokeErrorFromThrowable(err: Throwable): InvokeResult {
val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName
val parts = msg.split(":", limit = 2)
if (parts.size == 2) {
val code = parts[0].trim()
val rest = parts[1].trim()
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
return InvokeResult.error(code = code, message = rest.ifEmpty { msg })
}
}
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
}
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) =
withContext(Dispatchers.IO) {
if (tls != null) {
try {
connectWithSocket(endpoint, hello, tls)
return@withContext
} catch (err: Throwable) {
if (tls.required) throw err
}
}
connectWithSocket(endpoint, hello, null)
}
private suspend fun connectWithSocket(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) {
val socket =
createBridgeSocket(tls) { fingerprint ->
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
}
socket.tcpNoDelay = true
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 0
startTlsHandshakeIfNeeded(socket)
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
val conn = Connection(socket, reader, writer, writeLock)
currentConnection = conn
try {
conn.sendJson(buildHelloJson(hello))
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
?: throw IllegalStateException("unexpected bridge response")
when (first["type"].asStringOrNull()) {
"hello-ok" -> {
val name = first["serverName"].asStringOrNull() ?: "Bridge"
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null }
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
mainSessionKey = rawMainSessionKey
if (BuildConfig.DEBUG) {
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
runCatching {
android.util.Log.d(
"ClawdbotBridge",
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
)
}
}
onConnected(name, conn.remoteAddress, rawMainSessionKey)
}
"error" -> {
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
val msg = first["message"].asStringOrNull() ?: "connect failed"
throw IllegalStateException("$code: $msg")
}
else -> throw IllegalStateException("unexpected bridge response")
}
while (scope.isActive) {
val line = reader.readLine() ?: break
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
when (frame["type"].asStringOrNull()) {
"event" -> {
val event = frame["event"].asStringOrNull() ?: continue
val payload = frame["payloadJSON"].asStringOrNull()
onEvent(event, payload)
}
"ping" -> {
val id = frame["id"].asStringOrNull() ?: ""
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
}
"res" -> {
val id = frame["id"].asStringOrNull() ?: continue
val ok = frame["ok"].asBooleanOrNull() ?: false
val payloadJson = frame["payloadJSON"].asStringOrNull()
val error =
frame["error"]?.let {
val obj = it.asObjectOrNull() ?: return@let null
val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE"
val msg = obj["message"].asStringOrNull() ?: "request failed"
ErrorShape(code, msg)
}
pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error))
}
"invoke" -> {
val id = frame["id"].asStringOrNull() ?: continue
val command = frame["command"].asStringOrNull() ?: ""
val params = frame["paramsJSON"].asStringOrNull()
val result =
try {
onInvoke(InvokeRequest(id, command, params))
} catch (err: Throwable) {
invokeErrorFromThrowable(err)
}
conn.sendJson(
buildJsonObject {
put("type", JsonPrimitive("invoke-res"))
put("id", JsonPrimitive(id))
put("ok", JsonPrimitive(result.ok))
if (result.payloadJson != null) put("payloadJSON", JsonPrimitive(result.payloadJson))
if (result.error != null) {
put(
"error",
buildJsonObject {
put("code", JsonPrimitive(result.error.code))
put("message", JsonPrimitive(result.error.message))
},
)
}
},
)
}
"invoke-res" -> {
// gateway->node only (ignore)
}
}
}
} finally {
currentConnection = null
for ((_, waiter) in pending) {
waiter.cancel()
}
pending.clear()
conn.closeQuietly()
}
}
private fun buildHelloJson(hello: Hello): JsonObject =
buildJsonObject {
put("type", JsonPrimitive("hello"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.token?.let { put("token", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
}
private fun normalizeCanvasHostUrl(raw: String?, endpoint: BridgeEndpoint): String? {
val trimmed = raw?.trim().orEmpty()
val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { URI(it) }.getOrNull() }
val host = parsed?.host?.trim().orEmpty()
val port = parsed?.port ?: -1
val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" }
if (trimmed.isNotBlank() && !isLoopbackHost(host)) {
return trimmed
}
val fallbackHost =
endpoint.tailnetDns?.trim().takeIf { !it.isNullOrEmpty() }
?: endpoint.lanHost?.trim().takeIf { !it.isNullOrEmpty() }
?: endpoint.host.trim()
if (fallbackHost.isEmpty()) return trimmed.ifBlank { null }
val fallbackPort = endpoint.canvasPort ?: if (port > 0) port else 18793
val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost
return "$scheme://$formattedHost:$fallbackPort"
}
private fun isLoopbackHost(raw: String?): Boolean {
val host = raw?.trim()?.lowercase().orEmpty()
if (host.isEmpty()) return false
if (host == "localhost") return true
if (host == "::1") return true
if (host == "0.0.0.0" || host == "::") return true
return host.startsWith("127.")
}
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asStringOrNull(): String? =
when (this) {
is JsonNull -> null
is JsonPrimitive -> content
else -> null
}
private fun JsonElement?.asBooleanOrNull(): Boolean? =
when (this) {
is JsonPrimitive -> {
val c = content.trim()
when {
c.equals("true", ignoreCase = true) -> true
c.equals("false", ignoreCase = true) -> false
else -> null
}
}
else -> null
}

View File

@@ -1,6 +1,6 @@
package com.clawdbot.android.chat
import com.clawdbot.android.bridge.BridgeSession
import com.clawdbot.android.gateway.GatewaySession
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
@@ -20,8 +20,9 @@ import kotlinx.serialization.json.buildJsonObject
class ChatController(
private val scope: CoroutineScope,
private val session: BridgeSession,
private val session: GatewaySession,
private val json: Json,
private val supportsChatSubscribe: Boolean,
) {
private val _sessionKey = MutableStateFlow("main")
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
@@ -224,7 +225,7 @@ class ChatController(
}
}
fun handleBridgeEvent(event: String, payloadJson: String?) {
fun handleGatewayEvent(event: String, payloadJson: String?) {
when (event) {
"tick" -> {
scope.launch { pollHealthIfNeeded(force = false) }
@@ -259,10 +260,12 @@ class ChatController(
val key = _sessionKey.value
try {
try {
session.sendEvent("chat.subscribe", """{"sessionKey":"$key"}""")
} catch (_: Throwable) {
// best-effort
if (supportsChatSubscribe) {
try {
session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""")
} catch (_: Throwable) {
// best-effort
}
}
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")

View File

@@ -1,4 +1,4 @@
package com.clawdbot.android.bridge
package com.clawdbot.android.gateway
object BonjourEscapes {
fun decode(input: String): String {

View File

@@ -0,0 +1,146 @@
package com.clawdbot.android.gateway
import android.content.Context
import android.util.Base64
import java.io.File
import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.Signature
import java.security.spec.PKCS8EncodedKeySpec
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class DeviceIdentity(
val deviceId: String,
val publicKeyRawBase64: String,
val privateKeyPkcs8Base64: String,
val createdAtMs: Long,
)
class DeviceIdentityStore(context: Context) {
private val json = Json { ignoreUnknownKeys = true }
private val identityFile = File(context.filesDir, "clawdbot/identity/device.json")
@Synchronized
fun loadOrCreate(): DeviceIdentity {
val existing = load()
if (existing != null) {
val derived = deriveDeviceId(existing.publicKeyRawBase64)
if (derived != null && derived != existing.deviceId) {
val updated = existing.copy(deviceId = derived)
save(updated)
return updated
}
return existing
}
val fresh = generate()
save(fresh)
return fresh
}
fun signPayload(payload: String, identity: DeviceIdentity): String? {
return try {
val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
val keySpec = PKCS8EncodedKeySpec(privateKeyBytes)
val keyFactory = KeyFactory.getInstance("Ed25519")
val privateKey = keyFactory.generatePrivate(keySpec)
val signature = Signature.getInstance("Ed25519")
signature.initSign(privateKey)
signature.update(payload.toByteArray(Charsets.UTF_8))
base64UrlEncode(signature.sign())
} catch (_: Throwable) {
null
}
}
fun publicKeyBase64Url(identity: DeviceIdentity): String? {
return try {
val raw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
base64UrlEncode(raw)
} catch (_: Throwable) {
null
}
}
private fun load(): DeviceIdentity? {
return try {
if (!identityFile.exists()) return null
val raw = identityFile.readText(Charsets.UTF_8)
val decoded = json.decodeFromString(DeviceIdentity.serializer(), raw)
if (decoded.deviceId.isBlank() ||
decoded.publicKeyRawBase64.isBlank() ||
decoded.privateKeyPkcs8Base64.isBlank()
) {
null
} else {
decoded
}
} catch (_: Throwable) {
null
}
}
private fun save(identity: DeviceIdentity) {
try {
identityFile.parentFile?.mkdirs()
val encoded = json.encodeToString(DeviceIdentity.serializer(), identity)
identityFile.writeText(encoded, Charsets.UTF_8)
} catch (_: Throwable) {
// best-effort only
}
}
private fun generate(): DeviceIdentity {
val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair()
val spki = keyPair.public.encoded
val rawPublic = stripSpkiPrefix(spki)
val deviceId = sha256Hex(rawPublic)
val privateKey = keyPair.private.encoded
return DeviceIdentity(
deviceId = deviceId,
publicKeyRawBase64 = Base64.encodeToString(rawPublic, Base64.NO_WRAP),
privateKeyPkcs8Base64 = Base64.encodeToString(privateKey, Base64.NO_WRAP),
createdAtMs = System.currentTimeMillis(),
)
}
private fun deriveDeviceId(publicKeyRawBase64: String): String? {
return try {
val raw = Base64.decode(publicKeyRawBase64, Base64.DEFAULT)
sha256Hex(raw)
} catch (_: Throwable) {
null
}
}
private fun stripSpkiPrefix(spki: ByteArray): ByteArray {
if (spki.size == ED25519_SPKI_PREFIX.size + 32 &&
spki.copyOfRange(0, ED25519_SPKI_PREFIX.size).contentEquals(ED25519_SPKI_PREFIX)
) {
return spki.copyOfRange(ED25519_SPKI_PREFIX.size, spki.size)
}
return spki
}
private fun sha256Hex(data: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256").digest(data)
val out = StringBuilder(digest.size * 2)
for (byte in digest) {
out.append(String.format("%02x", byte))
}
return out.toString()
}
private fun base64UrlEncode(data: ByteArray): String {
return Base64.encodeToString(data, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
}
companion object {
private val ED25519_SPKI_PREFIX =
byteArrayOf(
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
)
}
}

View File

@@ -1,4 +1,4 @@
package com.clawdbot.android.bridge
package com.clawdbot.android.gateway
import android.content.Context
import android.net.ConnectivityManager
@@ -44,21 +44,21 @@ import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Suppress("DEPRECATION")
class BridgeDiscovery(
class GatewayDiscovery(
context: Context,
private val scope: CoroutineScope,
) {
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-bridge._tcp."
private val serviceType = "_clawdbot-gateway._tcp."
private val wideAreaDomain = "clawdbot.internal."
private val logTag = "Clawdbot/BridgeDiscovery"
private val logTag = "Clawdbot/GatewayDiscovery"
private val localById = ConcurrentHashMap<String, BridgeEndpoint>()
private val unicastById = ConcurrentHashMap<String, BridgeEndpoint>()
private val _bridges = MutableStateFlow<List<BridgeEndpoint>>(emptyList())
val bridges: StateFlow<List<BridgeEndpoint>> = _bridges.asStateFlow()
private val localById = ConcurrentHashMap<String, GatewayEndpoint>()
private val unicastById = ConcurrentHashMap<String, GatewayEndpoint>()
private val _gateways = MutableStateFlow<List<GatewayEndpoint>>(emptyList())
val gateways: StateFlow<List<GatewayEndpoint>> = _gateways.asStateFlow()
private val _statusText = MutableStateFlow("Searching…")
val statusText: StateFlow<String> = _statusText.asStateFlow()
@@ -77,7 +77,7 @@ class BridgeDiscovery(
override fun onDiscoveryStopped(serviceType: String) {}
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
if (serviceInfo.serviceType != this@BridgeDiscovery.serviceType) return
if (serviceInfo.serviceType != this@GatewayDiscovery.serviceType) return
resolve(serviceInfo)
}
@@ -141,13 +141,12 @@ class BridgeDiscovery(
val lanHost = txt(resolved, "lanHost")
val tailnetDns = txt(resolved, "tailnetDns")
val gatewayPort = txtInt(resolved, "gatewayPort")
val bridgePort = txtInt(resolved, "bridgePort")
val canvasPort = txtInt(resolved, "canvasPort")
val tlsEnabled = txtBool(resolved, "bridgeTls")
val tlsFingerprint = txt(resolved, "bridgeTlsSha256")
val tlsEnabled = txtBool(resolved, "gatewayTls")
val tlsFingerprint = txt(resolved, "gatewayTlsSha256")
val id = stableId(serviceName, "local.")
localById[id] =
BridgeEndpoint(
GatewayEndpoint(
stableId = id,
name = displayName,
host = host,
@@ -155,7 +154,6 @@ class BridgeDiscovery(
lanHost = lanHost,
tailnetDns = tailnetDns,
gatewayPort = gatewayPort,
bridgePort = bridgePort,
canvasPort = canvasPort,
tlsEnabled = tlsEnabled,
tlsFingerprintSha256 = tlsFingerprint,
@@ -167,7 +165,7 @@ class BridgeDiscovery(
}
private fun publish() {
_bridges.value =
_gateways.value =
(localById.values + unicastById.values).sortedBy { it.name.lowercase() }
_statusText.value = buildStatusText()
}
@@ -186,7 +184,7 @@ class BridgeDiscovery(
}
return when {
localCount == 0 && wideRcode == null -> "Searching for bridges…"
localCount == 0 && wideRcode == null -> "Searching for gateways…"
localCount == 0 -> "$wide"
else -> "Local: $localCount$wide"
}
@@ -223,7 +221,7 @@ class BridgeDiscovery(
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
val ptrRecords = records(ptrMsg, Section.ANSWER).mapNotNull { it as? PTRRecord }
val next = LinkedHashMap<String, BridgeEndpoint>()
val next = LinkedHashMap<String, GatewayEndpoint>()
for (ptr in ptrRecords) {
val instanceFqdn = ptr.target.toString()
val srv =
@@ -259,13 +257,12 @@ class BridgeDiscovery(
val lanHost = txtValue(txt, "lanHost")
val tailnetDns = txtValue(txt, "tailnetDns")
val gatewayPort = txtIntValue(txt, "gatewayPort")
val bridgePort = txtIntValue(txt, "bridgePort")
val canvasPort = txtIntValue(txt, "canvasPort")
val tlsEnabled = txtBoolValue(txt, "bridgeTls")
val tlsFingerprint = txtValue(txt, "bridgeTlsSha256")
val tlsEnabled = txtBoolValue(txt, "gatewayTls")
val tlsFingerprint = txtValue(txt, "gatewayTlsSha256")
val id = stableId(instanceName, domain)
next[id] =
BridgeEndpoint(
GatewayEndpoint(
stableId = id,
name = displayName,
host = host,
@@ -273,7 +270,6 @@ class BridgeDiscovery(
lanHost = lanHost,
tailnetDns = tailnetDns,
gatewayPort = gatewayPort,
bridgePort = bridgePort,
canvasPort = canvasPort,
tlsEnabled = tlsEnabled,
tlsFingerprintSha256 = tlsFingerprint,

View File

@@ -1,6 +1,6 @@
package com.clawdbot.android.bridge
package com.clawdbot.android.gateway
data class BridgeEndpoint(
data class GatewayEndpoint(
val stableId: String,
val name: String,
val host: String,
@@ -8,15 +8,14 @@ data class BridgeEndpoint(
val lanHost: String? = null,
val tailnetDns: String? = null,
val gatewayPort: Int? = null,
val bridgePort: Int? = null,
val canvasPort: Int? = null,
val tlsEnabled: Boolean = false,
val tlsFingerprintSha256: String? = null,
) {
companion object {
fun manual(host: String, port: Int): BridgeEndpoint =
BridgeEndpoint(
stableId = "manual|$host|$port",
fun manual(host: String, port: Int): GatewayEndpoint =
GatewayEndpoint(
stableId = "manual|${host.lowercase()}|$port",
name = "$host:$port",
host = host,
port = port,

View File

@@ -0,0 +1,3 @@
package com.clawdbot.android.gateway
const val GATEWAY_PROTOCOL_VERSION = 3

View File

@@ -0,0 +1,630 @@
package com.clawdbot.android.gateway
import android.util.Log
import java.util.Locale
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
data class GatewayClientInfo(
val id: String,
val displayName: String?,
val version: String,
val platform: String,
val mode: String,
val instanceId: String?,
val deviceFamily: String?,
val modelIdentifier: String?,
)
data class GatewayConnectOptions(
val role: String,
val scopes: List<String>,
val caps: List<String>,
val commands: List<String>,
val permissions: Map<String, Boolean>,
val client: GatewayClientInfo,
val userAgent: String? = null,
)
class GatewaySession(
private val scope: CoroutineScope,
private val identityStore: DeviceIdentityStore,
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
private val onDisconnected: (message: String) -> Unit,
private val onEvent: (event: String, payloadJson: String?) -> Unit,
private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null,
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
) {
data class InvokeRequest(
val id: String,
val nodeId: String,
val command: String,
val paramsJson: String?,
val timeoutMs: Long?,
)
data class InvokeResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) {
companion object {
fun ok(payloadJson: String?) = InvokeResult(ok = true, payloadJson = payloadJson, error = null)
fun error(code: String, message: String) =
InvokeResult(ok = false, payloadJson = null, error = ErrorShape(code = code, message = message))
}
}
data class ErrorShape(val code: String, val message: String)
private val json = Json { ignoreUnknownKeys = true }
private val writeLock = Mutex()
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
@Volatile private var canvasHostUrl: String? = null
@Volatile private var mainSessionKey: String? = null
private data class DesiredConnection(
val endpoint: GatewayEndpoint,
val token: String?,
val password: String?,
val options: GatewayConnectOptions,
val tls: GatewayTlsParams?,
)
private var desired: DesiredConnection? = null
private var job: Job? = null
@Volatile private var currentConnection: Connection? = null
fun connect(
endpoint: GatewayEndpoint,
token: String?,
password: String?,
options: GatewayConnectOptions,
tls: GatewayTlsParams? = null,
) {
desired = DesiredConnection(endpoint, token, password, options, tls)
if (job == null) {
job = scope.launch(Dispatchers.IO) { runLoop() }
}
}
fun disconnect() {
desired = null
currentConnection?.closeQuietly()
scope.launch(Dispatchers.IO) {
job?.cancelAndJoin()
job = null
canvasHostUrl = null
mainSessionKey = null
onDisconnected("Offline")
}
}
fun reconnect() {
currentConnection?.closeQuietly()
}
fun currentCanvasHostUrl(): String? = canvasHostUrl
fun currentMainSessionKey(): String? = mainSessionKey
suspend fun sendNodeEvent(event: String, payloadJson: String?) {
val conn = currentConnection ?: return
val parsedPayload = payloadJson?.let { parseJsonOrNull(it) }
val params =
buildJsonObject {
put("event", JsonPrimitive(event))
if (parsedPayload != null) {
put("payload", parsedPayload)
} else if (payloadJson != null) {
put("payloadJSON", JsonPrimitive(payloadJson))
} else {
put("payloadJSON", JsonNull)
}
}
try {
conn.request("node.event", params, timeoutMs = 8_000)
} catch (err: Throwable) {
Log.w("ClawdbotGateway", "node.event failed: ${err.message ?: err::class.java.simpleName}")
}
}
suspend fun request(method: String, paramsJson: String?, timeoutMs: Long = 15_000): String {
val conn = currentConnection ?: throw IllegalStateException("not connected")
val params =
if (paramsJson.isNullOrBlank()) {
null
} else {
json.parseToJsonElement(paramsJson)
}
val res = conn.request(method, params, timeoutMs)
if (res.ok) return res.payloadJson ?: ""
val err = res.error
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
}
private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
private inner class Connection(
private val endpoint: GatewayEndpoint,
private val token: String?,
private val password: String?,
private val options: GatewayConnectOptions,
private val tls: GatewayTlsParams?,
) {
private val connectDeferred = CompletableDeferred<Unit>()
private val closedDeferred = CompletableDeferred<Unit>()
private val isClosed = AtomicBoolean(false)
private val client: OkHttpClient = buildClient()
private var socket: WebSocket? = null
private val loggerTag = "ClawdbotGateway"
val remoteAddress: String =
if (endpoint.host.contains(":")) {
"[${endpoint.host}]:${endpoint.port}"
} else {
"${endpoint.host}:${endpoint.port}"
}
suspend fun connect() {
val scheme = if (tls != null) "wss" else "ws"
val url = "$scheme://${endpoint.host}:${endpoint.port}"
val request = Request.Builder().url(url).build()
socket = client.newWebSocket(request, Listener())
try {
connectDeferred.await()
} catch (err: Throwable) {
throw err
}
}
suspend fun request(method: String, params: JsonElement?, timeoutMs: Long): RpcResponse {
val id = UUID.randomUUID().toString()
val deferred = CompletableDeferred<RpcResponse>()
pending[id] = deferred
val frame =
buildJsonObject {
put("type", JsonPrimitive("req"))
put("id", JsonPrimitive(id))
put("method", JsonPrimitive(method))
if (params != null) put("params", params)
}
sendJson(frame)
return try {
withTimeout(timeoutMs) { deferred.await() }
} catch (err: TimeoutCancellationException) {
pending.remove(id)
throw IllegalStateException("request timeout")
}
}
suspend fun sendJson(obj: JsonObject) {
val jsonString = obj.toString()
writeLock.withLock {
socket?.send(jsonString)
}
}
suspend fun awaitClose() = closedDeferred.await()
fun closeQuietly() {
if (isClosed.compareAndSet(false, true)) {
socket?.close(1000, "bye")
socket = null
closedDeferred.complete(Unit)
}
}
private fun buildClient(): OkHttpClient {
val builder = OkHttpClient.Builder()
val tlsConfig = buildGatewayTlsConfig(tls) { fingerprint ->
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
}
if (tlsConfig != null) {
builder.sslSocketFactory(tlsConfig.sslSocketFactory, tlsConfig.trustManager)
builder.hostnameVerifier(tlsConfig.hostnameVerifier)
}
return builder.build()
}
private inner class Listener : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
scope.launch {
try {
sendConnect()
} catch (err: Throwable) {
connectDeferred.completeExceptionally(err)
closeQuietly()
}
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
scope.launch { handleMessage(text) }
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
if (!connectDeferred.isCompleted) {
connectDeferred.completeExceptionally(t)
}
if (isClosed.compareAndSet(false, true)) {
failPending()
closedDeferred.complete(Unit)
onDisconnected("Gateway error: ${t.message ?: t::class.java.simpleName}")
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
if (!connectDeferred.isCompleted) {
connectDeferred.completeExceptionally(IllegalStateException("Gateway closed: $reason"))
}
if (isClosed.compareAndSet(false, true)) {
failPending()
closedDeferred.complete(Unit)
onDisconnected("Gateway closed: $reason")
}
}
}
private suspend fun sendConnect() {
val payload = buildConnectParams()
val res = request("connect", payload, timeoutMs = 8_000)
if (!res.ok) {
val msg = res.error?.message ?: "connect failed"
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 rawCanvas = obj["canvasHostUrl"].asStringOrNull()
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint)
val sessionDefaults =
obj["snapshot"].asObjectOrNull()
?.get("sessionDefaults").asObjectOrNull()
mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull()
onConnected(serverName, remoteAddress, mainSessionKey)
connectDeferred.complete(Unit)
}
private fun buildConnectParams(): JsonObject {
val client = options.client
val locale = Locale.getDefault().toLanguageTag()
val clientObj =
buildJsonObject {
put("id", JsonPrimitive(client.id))
client.displayName?.let { put("displayName", JsonPrimitive(it)) }
put("version", JsonPrimitive(client.version))
put("platform", JsonPrimitive(client.platform))
put("mode", JsonPrimitive(client.mode))
client.instanceId?.let { put("instanceId", JsonPrimitive(it)) }
client.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
}
val authToken = token?.trim().orEmpty()
val authPassword = password?.trim().orEmpty()
val authJson =
when {
authToken.isNotEmpty() ->
buildJsonObject {
put("token", JsonPrimitive(authToken))
}
authPassword.isNotEmpty() ->
buildJsonObject {
put("password", JsonPrimitive(authPassword))
}
else -> null
}
val identity = identityStore.loadOrCreate()
val signedAtMs = System.currentTimeMillis()
val payload =
buildDeviceAuthPayload(
deviceId = identity.deviceId,
clientId = client.id,
clientMode = client.mode,
role = options.role,
scopes = options.scopes,
signedAtMs = signedAtMs,
token = if (authToken.isNotEmpty()) authToken else null,
)
val signature = identityStore.signPayload(payload, identity)
val publicKey = identityStore.publicKeyBase64Url(identity)
val deviceJson =
if (!signature.isNullOrBlank() && !publicKey.isNullOrBlank()) {
buildJsonObject {
put("id", JsonPrimitive(identity.deviceId))
put("publicKey", JsonPrimitive(publicKey))
put("signature", JsonPrimitive(signature))
put("signedAt", JsonPrimitive(signedAtMs))
}
} else {
null
}
return buildJsonObject {
put("minProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION))
put("maxProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION))
put("client", clientObj)
if (options.caps.isNotEmpty()) put("caps", JsonArray(options.caps.map(::JsonPrimitive)))
if (options.commands.isNotEmpty()) put("commands", JsonArray(options.commands.map(::JsonPrimitive)))
if (options.permissions.isNotEmpty()) {
put(
"permissions",
buildJsonObject {
options.permissions.forEach { (key, value) ->
put(key, JsonPrimitive(value))
}
},
)
}
put("role", JsonPrimitive(options.role))
if (options.scopes.isNotEmpty()) put("scopes", JsonArray(options.scopes.map(::JsonPrimitive)))
authJson?.let { put("auth", it) }
deviceJson?.let { put("device", it) }
put("locale", JsonPrimitive(locale))
options.userAgent?.trim()?.takeIf { it.isNotEmpty() }?.let {
put("userAgent", JsonPrimitive(it))
}
}
}
private suspend fun handleMessage(text: String) {
val frame = json.parseToJsonElement(text).asObjectOrNull() ?: return
when (frame["type"].asStringOrNull()) {
"res" -> handleResponse(frame)
"event" -> handleEvent(frame)
}
}
private fun handleResponse(frame: JsonObject) {
val id = frame["id"].asStringOrNull() ?: return
val ok = frame["ok"].asBooleanOrNull() ?: false
val payloadJson = frame["payload"]?.let { payload -> payload.toString() }
val error =
frame["error"]?.asObjectOrNull()?.let { obj ->
val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE"
val msg = obj["message"].asStringOrNull() ?: "request failed"
ErrorShape(code, msg)
}
pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error))
}
private fun handleEvent(frame: JsonObject) {
val event = frame["event"].asStringOrNull() ?: return
val payloadJson =
frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull()
if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) {
handleInvokeEvent(payloadJson)
return
}
onEvent(event, payloadJson)
}
private fun handleInvokeEvent(payloadJson: String) {
val payload =
try {
json.parseToJsonElement(payloadJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return
val id = payload["id"].asStringOrNull() ?: return
val nodeId = payload["nodeId"].asStringOrNull() ?: return
val command = payload["command"].asStringOrNull() ?: return
val params =
payload["paramsJSON"].asStringOrNull()
?: payload["params"]?.let { value -> if (value is JsonNull) null else value.toString() }
val timeoutMs = payload["timeoutMs"].asLongOrNull()
scope.launch {
val result =
try {
onInvoke?.invoke(InvokeRequest(id, nodeId, command, params, timeoutMs))
?: InvokeResult.error("UNAVAILABLE", "invoke handler missing")
} catch (err: Throwable) {
invokeErrorFromThrowable(err)
}
sendInvokeResult(id, nodeId, result)
}
}
private suspend fun sendInvokeResult(id: String, nodeId: String, result: InvokeResult) {
val parsedPayload = result.payloadJson?.let { parseJsonOrNull(it) }
val params =
buildJsonObject {
put("id", JsonPrimitive(id))
put("nodeId", JsonPrimitive(nodeId))
put("ok", JsonPrimitive(result.ok))
if (parsedPayload != null) {
put("payload", parsedPayload)
} else if (result.payloadJson != null) {
put("payloadJSON", JsonPrimitive(result.payloadJson))
}
result.error?.let { err ->
put(
"error",
buildJsonObject {
put("code", JsonPrimitive(err.code))
put("message", JsonPrimitive(err.message))
},
)
}
}
try {
request("node.invoke.result", params, timeoutMs = 15_000)
} catch (err: Throwable) {
Log.w(loggerTag, "node.invoke.result failed: ${err.message ?: err::class.java.simpleName}")
}
}
private fun invokeErrorFromThrowable(err: Throwable): InvokeResult {
val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName
val parts = msg.split(":", limit = 2)
if (parts.size == 2) {
val code = parts[0].trim()
val rest = parts[1].trim()
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
return InvokeResult.error(code = code, message = rest.ifEmpty { msg })
}
}
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
}
private fun failPending() {
for ((_, waiter) in pending) {
waiter.cancel()
}
pending.clear()
}
}
private suspend fun runLoop() {
var attempt = 0
while (scope.isActive) {
val target = desired
if (target == null) {
currentConnection?.closeQuietly()
currentConnection = null
delay(250)
continue
}
try {
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
connectOnce(target)
attempt = 0
} catch (err: Throwable) {
attempt += 1
onDisconnected("Gateway error: ${err.message ?: err::class.java.simpleName}")
val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong())
delay(sleepMs)
}
}
}
private suspend fun connectOnce(target: DesiredConnection) = withContext(Dispatchers.IO) {
val conn = Connection(target.endpoint, target.token, target.password, target.options, target.tls)
currentConnection = conn
try {
conn.connect()
conn.awaitClose()
} finally {
currentConnection = null
canvasHostUrl = null
mainSessionKey = null
}
}
private fun buildDeviceAuthPayload(
deviceId: String,
clientId: String,
clientMode: String,
role: String,
scopes: List<String>,
signedAtMs: Long,
token: String?,
): String {
val scopeString = scopes.joinToString(",")
val authToken = token.orEmpty()
return listOf(
"v1",
deviceId,
clientId,
clientMode,
role,
scopeString,
signedAtMs.toString(),
authToken,
).joinToString("|")
}
private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? {
val trimmed = raw?.trim().orEmpty()
val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { java.net.URI(it) }.getOrNull() }
val host = parsed?.host?.trim().orEmpty()
val port = parsed?.port ?: -1
val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" }
if (trimmed.isNotBlank() && !isLoopbackHost(host)) {
return trimmed
}
val fallbackHost =
endpoint.tailnetDns?.trim().takeIf { !it.isNullOrEmpty() }
?: endpoint.lanHost?.trim().takeIf { !it.isNullOrEmpty() }
?: endpoint.host.trim()
if (fallbackHost.isEmpty()) return trimmed.ifBlank { null }
val fallbackPort = endpoint.canvasPort ?: if (port > 0) port else 18793
val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost
return "$scheme://$formattedHost:$fallbackPort"
}
private fun isLoopbackHost(raw: String?): Boolean {
val host = raw?.trim()?.lowercase().orEmpty()
if (host.isEmpty()) return false
if (host == "localhost") return true
if (host == "::1") return true
if (host == "0.0.0.0" || host == "::") return true
return host.startsWith("127.")
}
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asStringOrNull(): String? =
when (this) {
is JsonNull -> null
is JsonPrimitive -> content
else -> null
}
private fun JsonElement?.asBooleanOrNull(): Boolean? =
when (this) {
is JsonPrimitive -> {
val c = content.trim()
when {
c.equals("true", ignoreCase = true) -> true
c.equals("false", ignoreCase = true) -> false
else -> null
}
}
else -> null
}
private fun JsonElement?.asLongOrNull(): Long? =
when (this) {
is JsonPrimitive -> content.toLongOrNull()
else -> null
}
private fun parseJsonOrNull(payload: String): JsonElement? {
val trimmed = payload.trim()
if (trimmed.isEmpty()) return null
return try {
Json.parseToJsonElement(trimmed)
} catch (_: Throwable) {
null
}
}

View File

@@ -1,25 +1,34 @@
package com.clawdbot.android.bridge
package com.clawdbot.android.gateway
import android.annotation.SuppressLint
import java.net.Socket
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
data class BridgeTlsParams(
data class GatewayTlsParams(
val required: Boolean,
val expectedFingerprint: String?,
val allowTOFU: Boolean,
val stableId: String,
)
fun createBridgeSocket(params: BridgeTlsParams?, onStore: ((String) -> Unit)? = null): Socket {
if (params == null) return Socket()
data class GatewayTlsConfig(
val sslSocketFactory: SSLSocketFactory,
val trustManager: X509TrustManager,
val hostnameVerifier: HostnameVerifier,
)
fun buildGatewayTlsConfig(
params: GatewayTlsParams?,
onStore: ((String) -> Unit)? = null,
): GatewayTlsConfig? {
if (params == null) return null
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
val defaultTrust = defaultTrustManager()
@SuppressLint("CustomX509TrustManager")
@@ -34,7 +43,7 @@ fun createBridgeSocket(params: BridgeTlsParams?, onStore: ((String) -> Unit)? =
val fingerprint = sha256Hex(chain[0].encoded)
if (expected != null) {
if (fingerprint != expected) {
throw CertificateException("bridge TLS fingerprint mismatch")
throw CertificateException("gateway TLS fingerprint mismatch")
}
return
}
@@ -50,13 +59,11 @@ fun createBridgeSocket(params: BridgeTlsParams?, onStore: ((String) -> Unit)? =
val context = SSLContext.getInstance("TLS")
context.init(null, arrayOf(trustManager), SecureRandom())
return context.socketFactory.createSocket()
}
fun startTlsHandshakeIfNeeded(socket: Socket) {
if (socket is SSLSocket) {
socket.startHandshake()
}
return GatewayTlsConfig(
sslSocketFactory = context.socketFactory,
trustManager = trustManager,
hostnameVerifier = HostnameVerifier { _, _ -> true },
)
}
private fun defaultTrustManager(): X509TrustManager {

View File

@@ -118,7 +118,7 @@ fun RootScreen(viewModel: MainViewModel) {
contentDescription = "Approval pending",
)
}
// Avoid duplicating the primary bridge status ("Connecting…") in the activity slot.
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
if (screenRecordActive) {
return@remember StatusActivity(
@@ -179,14 +179,14 @@ fun RootScreen(viewModel: MainViewModel) {
null
}
val bridgeState =
val gatewayState =
remember(serverName, statusText) {
when {
serverName != null -> BridgeState.Connected
serverName != null -> GatewayState.Connected
statusText.contains("connecting", ignoreCase = true) ||
statusText.contains("reconnecting", ignoreCase = true) -> BridgeState.Connecting
statusText.contains("error", ignoreCase = true) -> BridgeState.Error
else -> BridgeState.Disconnected
statusText.contains("reconnecting", ignoreCase = true) -> GatewayState.Connecting
statusText.contains("error", ignoreCase = true) -> GatewayState.Error
else -> GatewayState.Disconnected
}
}
@@ -206,7 +206,7 @@ fun RootScreen(viewModel: MainViewModel) {
// Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches.
Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) {
StatusPill(
bridge = bridgeState,
gateway = gatewayState,
voiceEnabled = voiceEnabled,
activity = activity,
onClick = { sheet = Sheet.Settings },

View File

@@ -48,6 +48,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -74,11 +75,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
val manualEnabled by viewModel.manualEnabled.collectAsState()
val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState()
val manualTls by viewModel.manualTls.collectAsState()
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
val statusText by viewModel.statusText.collectAsState()
val serverName by viewModel.serverName.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
val bridges by viewModel.bridges.collectAsState()
val gateways by viewModel.gateways.collectAsState()
val discoveryStatusText by viewModel.discoveryStatusText.collectAsState()
val listState = rememberLazyListState()
@@ -163,7 +165,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
val smsPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
smsPermissionGranted = granted
viewModel.refreshBridgeHello()
viewModel.refreshGatewayConnection()
}
fun setCameraEnabledChecked(checked: Boolean) {
@@ -223,20 +225,20 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
val visibleBridges =
val visibleGateways =
if (isConnected && remoteAddress != null) {
bridges.filterNot { "${it.host}:${it.port}" == remoteAddress }
gateways.filterNot { "${it.host}:${it.port}" == remoteAddress }
} else {
bridges
gateways
}
val bridgeDiscoveryFooterText =
if (visibleBridges.isEmpty()) {
val gatewayDiscoveryFooterText =
if (visibleGateways.isEmpty()) {
discoveryStatusText
} else if (isConnected) {
"Discovery active • ${visibleBridges.size} other bridge${if (visibleBridges.size == 1) "" else "s"} found"
"Discovery active • ${visibleGateways.size} other gateway${if (visibleGateways.size == 1) "" else "s"} found"
} else {
"Discovery active • ${visibleBridges.size} bridge${if (visibleBridges.size == 1) "" else "s"} found"
"Discovery active • ${visibleGateways.size} gateway${if (visibleGateways.size == 1) "" else "s"} found"
}
LazyColumn(
@@ -250,7 +252,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
// Order parity: Node → Bridge → Voice → Camera → Messaging → Location → Screen.
// Order parity: Node → Gateway → Voice → Camera → Messaging → Location → Screen.
item { Text("Node", style = MaterialTheme.typography.titleSmall) }
item {
OutlinedTextField(
@@ -266,8 +268,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
item { HorizontalDivider() }
// Bridge
item { Text("Bridge", style = MaterialTheme.typography.titleSmall) }
// Gateway
item { Text("Gateway", style = MaterialTheme.typography.titleSmall) }
item { ListItem(headlineContent = { Text("Status") }, supportingContent = { Text(statusText) }) }
if (serverName != null) {
item { ListItem(headlineContent = { Text("Server") }, supportingContent = { Text(serverName!!) }) }
@@ -291,31 +293,30 @@ fun SettingsSheet(viewModel: MainViewModel) {
item { HorizontalDivider() }
if (!isConnected || visibleBridges.isNotEmpty()) {
if (!isConnected || visibleGateways.isNotEmpty()) {
item {
Text(
if (isConnected) "Other Bridges" else "Discovered Bridges",
if (isConnected) "Other Gateways" else "Discovered Gateways",
style = MaterialTheme.typography.titleSmall,
)
}
if (!isConnected && visibleBridges.isEmpty()) {
item { Text("No bridges found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
if (!isConnected && visibleGateways.isEmpty()) {
item { Text("No gateways found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
} else {
items(items = visibleBridges, key = { it.stableId }) { bridge ->
items(items = visibleGateways, key = { it.stableId }) { gateway ->
val detailLines =
buildList {
add("IP: ${bridge.host}:${bridge.port}")
bridge.lanHost?.let { add("LAN: $it") }
bridge.tailnetDns?.let { add("Tailnet: $it") }
if (bridge.gatewayPort != null || bridge.bridgePort != null || bridge.canvasPort != null) {
val gw = bridge.gatewayPort?.toString() ?: ""
val br = (bridge.bridgePort ?: bridge.port).toString()
val canvas = bridge.canvasPort?.toString() ?: ""
add("Ports: gw $gw · bridge $br · canvas $canvas")
add("IP: ${gateway.host}:${gateway.port}")
gateway.lanHost?.let { add("LAN: $it") }
gateway.tailnetDns?.let { add("Tailnet: $it") }
if (gateway.gatewayPort != null || gateway.canvasPort != null) {
val gw = (gateway.gatewayPort ?: gateway.port).toString()
val canvas = gateway.canvasPort?.toString() ?: ""
add("Ports: gw $gw · canvas $canvas")
}
}
ListItem(
headlineContent = { Text(bridge.name) },
headlineContent = { Text(gateway.name) },
supportingContent = {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
detailLines.forEach { line ->
@@ -327,7 +328,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
Button(
onClick = {
NodeForegroundService.start(context)
viewModel.connect(bridge)
viewModel.connect(gateway)
},
) {
Text("Connect")
@@ -338,7 +339,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
item {
Text(
bridgeDiscoveryFooterText,
gatewayDiscoveryFooterText,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
@@ -352,7 +353,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
item {
ListItem(
headlineContent = { Text("Advanced") },
supportingContent = { Text("Manual bridge connection") },
supportingContent = { Text("Manual gateway connection") },
trailingContent = {
Icon(
imageVector = if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
@@ -369,7 +370,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
AnimatedVisibility(visible = advancedExpanded) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
ListItem(
headlineContent = { Text("Use Manual Bridge") },
headlineContent = { Text("Use Manual Gateway") },
supportingContent = { Text("Use this when discovery is blocked.") },
trailingContent = { Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) },
)
@@ -388,6 +389,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled,
)
ListItem(
headlineContent = { Text("Require TLS") },
supportingContent = { Text("Pin the gateway certificate on first connect.") },
trailingContent = { Switch(checked = manualTls, onCheckedChange = viewModel::setManualTls, enabled = manualEnabled) },
modifier = Modifier.alpha(if (manualEnabled) 1f else 0.5f),
)
val hostOk = manualHost.trim().isNotEmpty()
val portOk = manualPort in 1..65535
@@ -496,7 +503,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
item {
Text(
if (isConnected) {
"Any node can edit wake words. Changes sync via the gateway bridge."
"Any node can edit wake words. Changes sync via the gateway."
} else {
"Connect to a gateway to sync wake words globally."
},
@@ -511,7 +518,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
item {
ListItem(
headlineContent = { Text("Allow Camera") },
supportingContent = { Text("Allows the bridge to request photos or short video clips (foreground only).") },
supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).") },
trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) },
)
}
@@ -538,7 +545,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
supportingContent = {
Text(
if (smsPermissionAvailable) {
"Allow the bridge to send SMS from this device."
"Allow the gateway to send SMS from this device."
} else {
"SMS requires a device with telephony hardware."
},

View File

@@ -26,7 +26,7 @@ import androidx.compose.ui.unit.dp
@Composable
fun StatusPill(
bridge: BridgeState,
gateway: GatewayState,
voiceEnabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
@@ -49,11 +49,11 @@ fun StatusPill(
Surface(
modifier = Modifier.size(9.dp),
shape = CircleShape,
color = bridge.color,
color = gateway.color,
) {}
Text(
text = bridge.title,
text = gateway.title,
style = MaterialTheme.typography.labelLarge,
)
}
@@ -106,7 +106,7 @@ data class StatusActivity(
val tint: Color? = null,
)
enum class BridgeState(val title: String, val color: Color) {
enum class GatewayState(val title: String, val color: Color) {
Connected("Connected", Color(0xFF2ECC71)),
Connecting("Connecting…", Color(0xFFF1C40F)),
Error("Error", Color(0xFFE74C3C)),

View File

@@ -20,7 +20,7 @@ import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.util.Log
import androidx.core.content.ContextCompat
import com.clawdbot.android.bridge.BridgeSession
import com.clawdbot.android.gateway.GatewaySession
import com.clawdbot.android.isCanonicalMainSessionKey
import com.clawdbot.android.normalizeMainKey
import java.net.HttpURLConnection
@@ -46,6 +46,9 @@ import kotlin.math.max
class TalkModeManager(
private val context: Context,
private val scope: CoroutineScope,
private val session: GatewaySession,
private val supportsChatSubscribe: Boolean,
private val isConnected: () -> Boolean,
) {
companion object {
private const val tag = "TalkMode"
@@ -99,7 +102,6 @@ class TalkModeManager(
private var modelOverrideActive = false
private var mainSessionKey: String = "main"
private var session: BridgeSession? = null
private var pendingRunId: String? = null
private var pendingFinal: CompletableDeferred<Boolean>? = null
private var chatSubscribedSessionKey: String? = null
@@ -112,11 +114,6 @@ class TalkModeManager(
private var systemTtsPending: CompletableDeferred<Unit>? = null
private var systemTtsPendingId: String? = null
fun attachSession(session: BridgeSession) {
this.session = session
chatSubscribedSessionKey = null
}
fun setMainSessionKey(sessionKey: String?) {
val trimmed = sessionKey?.trim().orEmpty()
if (trimmed.isEmpty()) return
@@ -136,7 +133,7 @@ class TalkModeManager(
}
}
fun handleBridgeEvent(event: String, payloadJson: String?) {
fun handleGatewayEvent(event: String, payloadJson: String?) {
if (event != "chat") return
if (payloadJson.isNullOrBlank()) return
val pending = pendingRunId ?: return
@@ -306,25 +303,24 @@ class TalkModeManager(
reloadConfig()
val prompt = buildPrompt(transcript)
val bridge = session
if (bridge == null) {
_statusText.value = "Bridge not connected"
Log.w(tag, "finalize: bridge not connected")
if (!isConnected()) {
_statusText.value = "Gateway not connected"
Log.w(tag, "finalize: gateway not connected")
start()
return
}
try {
val startedAt = System.currentTimeMillis().toDouble() / 1000.0
subscribeChatIfNeeded(bridge = bridge, sessionKey = mainSessionKey)
subscribeChatIfNeeded(session = session, sessionKey = mainSessionKey)
Log.d(tag, "chat.send start sessionKey=${mainSessionKey.ifBlank { "main" }} chars=${prompt.length}")
val runId = sendChat(prompt, bridge)
val runId = sendChat(prompt, session)
Log.d(tag, "chat.send ok runId=$runId")
val ok = waitForChatFinal(runId)
if (!ok) {
Log.w(tag, "chat final timeout runId=$runId; attempting history fallback")
}
val assistant = waitForAssistantText(bridge, startedAt, if (ok) 12_000 else 25_000)
val assistant = waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000)
if (assistant.isNullOrBlank()) {
_statusText.value = "No reply"
Log.w(tag, "assistant text timeout runId=$runId")
@@ -343,12 +339,13 @@ class TalkModeManager(
}
}
private suspend fun subscribeChatIfNeeded(bridge: BridgeSession, sessionKey: String) {
private suspend fun subscribeChatIfNeeded(session: GatewaySession, sessionKey: String) {
if (!supportsChatSubscribe) return
val key = sessionKey.trim()
if (key.isEmpty()) return
if (chatSubscribedSessionKey == key) return
try {
bridge.sendEvent("chat.subscribe", """{"sessionKey":"$key"}""")
session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""")
chatSubscribedSessionKey = key
Log.d(tag, "chat.subscribe ok sessionKey=$key")
} catch (err: Throwable) {
@@ -370,7 +367,7 @@ class TalkModeManager(
return lines.joinToString("\n")
}
private suspend fun sendChat(message: String, bridge: BridgeSession): String {
private suspend fun sendChat(message: String, session: GatewaySession): String {
val runId = UUID.randomUUID().toString()
val params =
buildJsonObject {
@@ -380,7 +377,7 @@ class TalkModeManager(
put("timeoutMs", JsonPrimitive(30_000))
put("idempotencyKey", JsonPrimitive(runId))
}
val res = bridge.request("chat.send", params.toString())
val res = session.request("chat.send", params.toString())
val parsed = parseRunId(res) ?: runId
if (parsed != runId) {
pendingRunId = parsed
@@ -411,13 +408,13 @@ class TalkModeManager(
}
private suspend fun waitForAssistantText(
bridge: BridgeSession,
session: GatewaySession,
sinceSeconds: Double,
timeoutMs: Long,
): String? {
val deadline = SystemClock.elapsedRealtime() + timeoutMs
while (SystemClock.elapsedRealtime() < deadline) {
val text = fetchLatestAssistantText(bridge, sinceSeconds)
val text = fetchLatestAssistantText(session, sinceSeconds)
if (!text.isNullOrBlank()) return text
delay(300)
}
@@ -425,11 +422,11 @@ class TalkModeManager(
}
private suspend fun fetchLatestAssistantText(
bridge: BridgeSession,
session: GatewaySession,
sinceSeconds: Double? = null,
): String? {
val key = mainSessionKey.ifBlank { "main" }
val res = bridge.request("chat.history", "{\"sessionKey\":\"$key\"}")
val res = session.request("chat.history", "{\"sessionKey\":\"$key\"}")
val root = json.parseToJsonElement(res).asObjectOrNull() ?: return null
val messages = root["messages"] as? JsonArray ?: return null
for (item in messages.reversed()) {
@@ -813,12 +810,11 @@ class TalkModeManager(
}
private suspend fun reloadConfig() {
val bridge = session ?: return
val envVoice = System.getenv("ELEVENLABS_VOICE_ID")?.trim()
val sagVoice = System.getenv("SAG_VOICE_ID")?.trim()
val envKey = System.getenv("ELEVENLABS_API_KEY")?.trim()
try {
val res = bridge.request("config.get", "{}")
val res = session.request("config.get", "{}")
val root = json.parseToJsonElement(res).asObjectOrNull()
val config = root?.get("config").asObjectOrNull()
val talk = config?.get("talk").asObjectOrNull()

View File

@@ -1,14 +0,0 @@
package com.clawdbot.android.bridge
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
class BridgeEndpointKotestTest : StringSpec({
"manual endpoint builds stable id + name" {
val endpoint = BridgeEndpoint.manual("10.0.0.5", 18790)
endpoint.stableId shouldBe "manual|10.0.0.5|18790"
endpoint.name shouldBe "10.0.0.5:18790"
endpoint.host shouldBe "10.0.0.5"
endpoint.port shouldBe 18790
}
})

View File

@@ -1,108 +0,0 @@
package com.clawdbot.android.bridge
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.ServerSocket
class BridgePairingClientTest {
@Test
fun helloOkReturnsExistingToken() = runBlocking {
val serverSocket = ServerSocket(0)
val port = serverSocket.localPort
val server =
async(Dispatchers.IO) {
serverSocket.use { ss ->
val sock = ss.accept()
sock.use { s ->
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
val hello = reader.readLine()
assertTrue(hello.contains("\"type\":\"hello\""))
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
writer.write("\n")
writer.flush()
}
}
}
val client = BridgePairingClient()
val res =
client.pairAndHello(
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
hello =
BridgePairingClient.Hello(
nodeId = "node-1",
displayName = "Android Node",
token = "token-123",
platform = "Android",
version = "test",
deviceFamily = "Android",
modelIdentifier = "SM-X000",
caps = null,
commands = null,
),
)
assertTrue(res.ok)
assertEquals("token-123", res.token)
server.await()
}
@Test
fun notPairedTriggersPairRequestAndReturnsToken() = runBlocking {
val serverSocket = ServerSocket(0)
val port = serverSocket.localPort
val server =
async(Dispatchers.IO) {
serverSocket.use { ss ->
val sock = ss.accept()
sock.use { s ->
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
reader.readLine() // hello
writer.write("""{"type":"error","code":"NOT_PAIRED","message":"not paired"}""")
writer.write("\n")
writer.flush()
val pairReq = reader.readLine()
assertTrue(pairReq.contains("\"type\":\"pair-request\""))
writer.write("""{"type":"pair-ok","token":"new-token"}""")
writer.write("\n")
writer.flush()
}
}
}
val client = BridgePairingClient()
val res =
client.pairAndHello(
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
hello =
BridgePairingClient.Hello(
nodeId = "node-1",
displayName = "Android Node",
token = null,
platform = "Android",
version = "test",
deviceFamily = "Android",
modelIdentifier = "SM-X000",
caps = null,
commands = null,
),
)
assertTrue(res.ok)
assertEquals("new-token", res.token)
server.await()
}
}

View File

@@ -1,307 +0,0 @@
package com.clawdbot.android.bridge
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.ServerSocket
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class BridgeSessionTest {
@Test
fun requestReturnsPayloadJson() = runBlocking {
val serverSocket = ServerSocket(0)
val port = serverSocket.localPort
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val connected = CompletableDeferred<Unit>()
val session =
BridgeSession(
scope = scope,
onConnected = { _, _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) },
)
val server =
async(Dispatchers.IO) {
serverSocket.use { ss ->
val sock = ss.accept()
sock.use { s ->
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
val hello = reader.readLine()
assertTrue(hello.contains("\"type\":\"hello\""))
writer.write("""{"type":"hello-ok","serverName":"Test Bridge","canvasHostUrl":"http://127.0.0.1:18789"}""")
writer.write("\n")
writer.flush()
val req = reader.readLine()
assertTrue(req.contains("\"type\":\"req\""))
val id = extractJsonString(req, "id")
writer.write("""{"type":"res","id":"$id","ok":true,"payloadJSON":"{\"value\":123}"}""")
writer.write("\n")
writer.flush()
}
}
}
session.connect(
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
hello =
BridgeSession.Hello(
nodeId = "node-1",
displayName = "Android Node",
token = null,
platform = "Android",
version = "test",
deviceFamily = null,
modelIdentifier = null,
caps = null,
commands = null,
),
)
connected.await()
assertEquals("http://127.0.0.1:18789", session.currentCanvasHostUrl())
val payload = session.request(method = "health", paramsJson = null)
assertEquals("""{"value":123}""", payload)
server.await()
session.disconnect()
scope.cancel()
}
@Test
fun requestThrowsOnErrorResponse() = runBlocking {
val serverSocket = ServerSocket(0)
val port = serverSocket.localPort
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val connected = CompletableDeferred<Unit>()
val session =
BridgeSession(
scope = scope,
onConnected = { _, _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) },
)
val server =
async(Dispatchers.IO) {
serverSocket.use { ss ->
val sock = ss.accept()
sock.use { s ->
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
reader.readLine() // hello
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
writer.write("\n")
writer.flush()
val req = reader.readLine()
val id = extractJsonString(req, "id")
writer.write(
"""{"type":"res","id":"$id","ok":false,"error":{"code":"FORBIDDEN","message":"nope"}}""",
)
writer.write("\n")
writer.flush()
}
}
}
session.connect(
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
hello =
BridgeSession.Hello(
nodeId = "node-1",
displayName = "Android Node",
token = null,
platform = "Android",
version = "test",
deviceFamily = null,
modelIdentifier = null,
caps = null,
commands = null,
),
)
connected.await()
try {
session.request(method = "chat.history", paramsJson = """{"sessionKey":"main"}""")
throw AssertionError("expected request() to throw")
} catch (e: IllegalStateException) {
assertTrue(e.message?.contains("FORBIDDEN: nope") == true)
}
server.await()
session.disconnect()
scope.cancel()
}
@Test
fun invokeResReturnsErrorWhenHandlerThrows() = runBlocking {
val serverSocket = ServerSocket(0)
val port = serverSocket.localPort
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val connected = CompletableDeferred<Unit>()
val session =
BridgeSession(
scope = scope,
onConnected = { _, _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { throw IllegalStateException("FOO_BAR: boom") },
)
val invokeResLine = CompletableDeferred<String>()
val server =
async(Dispatchers.IO) {
serverSocket.use { ss ->
val sock = ss.accept()
sock.use { s ->
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
reader.readLine() // hello
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
writer.write("\n")
writer.flush()
// Ask the node to invoke something; handler will throw.
writer.write("""{"type":"invoke","id":"i1","command":"canvas.snapshot","paramsJSON":null}""")
writer.write("\n")
writer.flush()
val res = reader.readLine()
invokeResLine.complete(res)
}
}
}
session.connect(
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
hello =
BridgeSession.Hello(
nodeId = "node-1",
displayName = "Android Node",
token = null,
platform = "Android",
version = "test",
deviceFamily = null,
modelIdentifier = null,
caps = null,
commands = null,
),
)
connected.await()
// Give the reader loop time to process.
val line = invokeResLine.await()
assertTrue(line.contains("\"type\":\"invoke-res\""))
assertTrue(line.contains("\"ok\":false"))
assertTrue(line.contains("\"code\":\"FOO_BAR\""))
assertTrue(line.contains("\"message\":\"boom\""))
server.await()
session.disconnect()
scope.cancel()
}
@Test(timeout = 12_000)
fun reconnectsAfterBridgeClosesDuringHello() = runBlocking {
val serverSocket = ServerSocket(0)
val port = serverSocket.localPort
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val connected = CountDownLatch(1)
val connectionsSeen = CountDownLatch(2)
val session =
BridgeSession(
scope = scope,
onConnected = { _, _, _ -> connected.countDown() },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) },
)
val server =
async(Dispatchers.IO) {
serverSocket.use { ss ->
// First connection: read hello, then close (no response).
val sock1 = ss.accept()
sock1.use { s ->
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
reader.readLine() // hello
connectionsSeen.countDown()
}
// Second connection: complete hello.
val sock2 = ss.accept()
sock2.use { s ->
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
reader.readLine() // hello
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
writer.write("\n")
writer.flush()
connectionsSeen.countDown()
Thread.sleep(200)
}
}
}
session.connect(
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
hello =
BridgeSession.Hello(
nodeId = "node-1",
displayName = "Android Node",
token = null,
platform = "Android",
version = "test",
deviceFamily = null,
modelIdentifier = null,
caps = null,
commands = null,
),
)
assertTrue("expected two connection attempts", connectionsSeen.await(8, TimeUnit.SECONDS))
assertTrue("expected session to connect", connected.await(8, TimeUnit.SECONDS))
session.disconnect()
scope.cancel()
server.await()
}
}
private fun extractJsonString(raw: String, key: String): String {
val needle = "\"$key\":\""
val start = raw.indexOf(needle)
if (start < 0) throw IllegalArgumentException("missing key $key in $raw")
val from = start + needle.length
val end = raw.indexOf('"', from)
if (end < 0) throw IllegalArgumentException("unterminated string for $key in $raw")
return raw.substring(from, end)
}

View File

@@ -1,4 +1,4 @@
package com.clawdbot.android.bridge
package com.clawdbot.android.gateway
import org.junit.Assert.assertEquals
import org.junit.Test

View File

@@ -1,244 +0,0 @@
import ClawdbotKit
import Foundation
import Network
actor BridgeClient {
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private var lineBuffer = Data()
func pairAndHello(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: BridgeTLSParams? = nil,
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{
do {
return try await self.pairAndHelloOnce(
endpoint: endpoint,
hello: hello,
tls: tls,
onStatus: onStatus)
} catch {
if let tls, !tls.required {
return try await self.pairAndHelloOnce(
endpoint: endpoint,
hello: hello,
tls: nil,
onStatus: onStatus)
}
throw error
}
}
private func pairAndHelloOnce(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: BridgeTLSParams?,
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{
self.lineBuffer = Data()
let params = self.makeParameters(tls: tls)
let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client")
defer { connection.cancel() }
try await self.withTimeout(seconds: 8, purpose: "connect") {
try await self.startAndWaitForReady(connection, queue: queue)
}
onStatus?("Authenticating…")
try await self.send(hello, over: connection)
let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> ReceivedFrame in
guard let frame = try await self.receiveFrame(over: connection) else {
throw NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Bridge closed connection during hello",
])
}
return frame
}
switch first.base.type {
case "hello-ok":
// We only return a token if we have one; callers should treat empty as "no token yet".
return hello.token ?? ""
case "error":
let err = try self.decoder.decode(BridgeErrorFrame.self, from: first.data)
if err.code != "NOT_PAIRED", err.code != "UNAUTHORIZED" {
throw NSError(domain: "Bridge", code: 1, userInfo: [
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
])
}
onStatus?("Requesting approval…")
try await self.send(
BridgePairRequest(
nodeId: hello.nodeId,
displayName: hello.displayName,
platform: hello.platform,
version: hello.version,
deviceFamily: hello.deviceFamily,
modelIdentifier: hello.modelIdentifier,
caps: hello.caps,
commands: hello.commands),
over: connection)
onStatus?("Waiting for approval…")
let ok = try await self.withTimeout(seconds: 60, purpose: "pairing approval") {
while let next = try await self.receiveFrame(over: connection) {
switch next.base.type {
case "pair-ok":
return try self.decoder.decode(BridgePairOk.self, from: next.data)
case "error":
let e = try self.decoder.decode(BridgeErrorFrame.self, from: next.data)
throw NSError(domain: "Bridge", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
])
default:
continue
}
}
throw NSError(domain: "Bridge", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Pairing failed: bridge closed connection",
])
}
return ok.token
default:
throw NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response",
])
}
}
private func send(_ obj: some Encodable, over connection: NWConnection) async throws {
let data = try self.encoder.encode(obj)
var line = Data()
line.append(data)
line.append(0x0A)
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
connection.send(content: line, completion: .contentProcessed { err in
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
})
}
}
private struct ReceivedFrame {
var base: BridgeBaseFrame
var data: Data
}
private func receiveFrame(over connection: NWConnection) async throws -> ReceivedFrame? {
guard let lineData = try await self.receiveLineData(over: connection) else {
return nil
}
let base = try self.decoder.decode(BridgeBaseFrame.self, from: lineData)
return ReceivedFrame(base: base, data: lineData)
}
private func receiveChunk(over connection: NWConnection) async throws -> Data {
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Data, Error>) in
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
if let error {
cont.resume(throwing: error)
return
}
if isComplete {
cont.resume(returning: Data())
return
}
cont.resume(returning: data ?? Data())
}
}
}
private func receiveLineData(over connection: NWConnection) async throws -> Data? {
while true {
if let idx = self.lineBuffer.firstIndex(of: 0x0A) {
let line = self.lineBuffer.prefix(upTo: idx)
self.lineBuffer.removeSubrange(...idx)
return Data(line)
}
let chunk = try await self.receiveChunk(over: connection)
if chunk.isEmpty { return nil }
self.lineBuffer.append(chunk)
}
}
private func makeParameters(tls: BridgeTLSParams?) -> NWParameters {
if let tlsOptions = makeBridgeTLSOptions(tls) {
let tcpOptions = NWProtocolTCP.Options()
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
params.includePeerToPeer = true
return params
}
let params = NWParameters.tcp
params.includePeerToPeer = true
return params
}
private struct TimeoutError: LocalizedError, Sendable {
var purpose: String
var seconds: Int
var errorDescription: String? {
if self.purpose == "pairing approval" {
return
"Timed out waiting for approval (\(self.seconds)s). " +
"Approve the node on your gateway and try again."
}
return "Timed out during \(self.purpose) (\(self.seconds)s)."
}
}
private func withTimeout<T: Sendable>(
seconds: Int,
purpose: String,
_ op: @escaping @Sendable () async throws -> T) async throws -> T
{
try await AsyncTimeout.withTimeout(
seconds: Double(seconds),
onTimeout: { TimeoutError(purpose: purpose, seconds: seconds) },
operation: op)
}
private func startAndWaitForReady(_ connection: NWConnection, queue: DispatchQueue) async throws {
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
final class ResumeFlag: @unchecked Sendable {
private let lock = NSLock()
private var value = false
func trySet() -> Bool {
self.lock.lock()
defer { self.lock.unlock() }
if self.value { return false }
self.value = true
return true
}
}
let didResume = ResumeFlag()
connection.stateUpdateHandler = { state in
switch state {
case .ready:
if didResume.trySet() { cont.resume(returning: ()) }
case let .failed(err):
if didResume.trySet() { cont.resume(throwing: err) }
case let .waiting(err):
if didResume.trySet() { cont.resume(throwing: err) }
case .cancelled:
if didResume.trySet() {
cont.resume(throwing: NSError(domain: "Bridge", code: 50, userInfo: [
NSLocalizedDescriptionKey: "Connection cancelled",
]))
}
default:
break
}
}
connection.start(queue: queue)
}
}
}

View File

@@ -1,26 +0,0 @@
import ClawdbotKit
import Foundation
import Network
enum BridgeEndpointID {
static func stableID(_ endpoint: NWEndpoint) -> String {
switch endpoint {
case let .service(name, type, domain, _):
// Keep this stable across encode/decode differences (e.g. `\032` for spaces).
let normalizedName = Self.normalizeServiceNameForID(name)
return "\(type)|\(domain)|\(normalizedName)"
default:
return String(describing: endpoint)
}
}
static func prettyDescription(_ endpoint: NWEndpoint) -> String {
BonjourEscapes.decode(String(describing: endpoint))
}
private static func normalizeServiceNameForID(_ rawName: String) -> String {
let decoded = BonjourEscapes.decode(rawName)
let normalized = decoded.split(whereSeparator: \.isWhitespace).joined(separator: " ")
return normalized.trimmingCharacters(in: .whitespacesAndNewlines)
}
}

View File

@@ -1,422 +0,0 @@
import ClawdbotKit
import Foundation
import Network
actor BridgeSession {
private struct TimeoutError: LocalizedError {
var message: String
var errorDescription: String? { self.message }
}
enum State: Sendable, Equatable {
case idle
case connecting
case connected(serverName: String)
case failed(message: String)
}
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private var connection: NWConnection?
private var queue: DispatchQueue?
private var buffer = Data()
private var pendingRPC: [String: CheckedContinuation<BridgeRPCResponse, Error>] = [:]
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:]
private(set) var state: State = .idle
private var canvasHostUrl: String?
private var mainSessionKey: String?
func currentCanvasHostUrl() -> String? {
self.canvasHostUrl
}
func currentRemoteAddress() -> String? {
guard let endpoint = self.connection?.currentPath?.remoteEndpoint else { return nil }
return Self.prettyRemoteEndpoint(endpoint)
}
private static func prettyRemoteEndpoint(_ endpoint: NWEndpoint) -> String? {
switch endpoint {
case let .hostPort(host, port):
let hostString = Self.prettyHostString(host)
if hostString.contains(":") {
return "[\(hostString)]:\(port)"
}
return "\(hostString):\(port)"
default:
return String(describing: endpoint)
}
}
private static func prettyHostString(_ host: NWEndpoint.Host) -> String {
var hostString = String(describing: host)
hostString = hostString.replacingOccurrences(of: "::ffff:", with: "")
guard let percentIndex = hostString.firstIndex(of: "%") else { return hostString }
let prefix = hostString[..<percentIndex]
let allowed = CharacterSet(charactersIn: "0123456789abcdefABCDEF:.")
let isIPAddressPrefix = prefix.unicodeScalars.allSatisfy { allowed.contains($0) }
if isIPAddressPrefix {
return String(prefix)
}
return hostString
}
func connect(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: BridgeTLSParams? = nil,
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
async throws
{
await self.disconnect()
self.state = .connecting
do {
try await self.connectOnce(
endpoint: endpoint,
hello: hello,
tls: tls,
onConnected: onConnected,
onInvoke: onInvoke)
} catch {
if let tls, !tls.required {
try await self.connectOnce(
endpoint: endpoint,
hello: hello,
tls: nil,
onConnected: onConnected,
onInvoke: onInvoke)
return
}
throw error
}
}
private func connectOnce(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: BridgeTLSParams?,
onConnected: (@Sendable (String, String?) async -> Void)?,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
{
let params = self.makeParameters(tls: tls)
let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session")
self.connection = connection
self.queue = queue
let stateStream = Self.makeStateStream(for: connection)
connection.start(queue: queue)
try await Self.waitForReady(stateStream, timeoutSeconds: 6)
try await Self.withTimeout(seconds: 6) {
try await self.send(hello)
}
guard let line = try await Self.withTimeout(seconds: 6, operation: {
try await self.receiveLine()
}),
let data = line.data(using: .utf8),
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
else {
await self.disconnect()
throw NSError(domain: "Bridge", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response",
])
}
if base.type == "hello-ok" {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName)
self.canvasHostUrl = ok.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines)
let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
self.mainSessionKey = (mainKey?.isEmpty == false) ? mainKey : nil
await onConnected?(ok.serverName, self.mainSessionKey)
} else if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
self.state = .failed(message: "\(err.code): \(err.message)")
await self.disconnect()
throw NSError(domain: "Bridge", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
])
} else {
self.state = .failed(message: "Unexpected bridge response")
await self.disconnect()
throw NSError(domain: "Bridge", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response",
])
}
while true {
guard let next = try await self.receiveLine() else { break }
guard let nextData = next.data(using: .utf8) else { continue }
guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue }
switch nextBase.type {
case "res":
let res = try self.decoder.decode(BridgeRPCResponse.self, from: nextData)
if let cont = self.pendingRPC.removeValue(forKey: res.id) {
cont.resume(returning: res)
}
case "event":
let evt = try self.decoder.decode(BridgeEventFrame.self, from: nextData)
self.broadcastServerEvent(evt)
case "ping":
let ping = try self.decoder.decode(BridgePing.self, from: nextData)
try await self.send(BridgePong(type: "pong", id: ping.id))
case "invoke":
let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData)
let res = await onInvoke(req)
try await self.send(res)
default:
continue
}
}
await self.disconnect()
}
func sendEvent(event: String, payloadJSON: String?) async throws {
try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON))
}
func request(method: String, paramsJSON: String?, timeoutSeconds: Int = 15) async throws -> Data {
guard self.connection != nil else {
throw NSError(domain: "Bridge", code: 11, userInfo: [
NSLocalizedDescriptionKey: "not connected",
])
}
let id = UUID().uuidString
let req = BridgeRPCRequest(type: "req", id: id, method: method, paramsJSON: paramsJSON)
let timeoutTask = Task {
try await Task.sleep(nanoseconds: UInt64(timeoutSeconds) * 1_000_000_000)
await self.timeoutRPC(id: id)
}
defer { timeoutTask.cancel() }
let res: BridgeRPCResponse = try await withCheckedThrowingContinuation { cont in
Task { [weak self] in
guard let self else { return }
await self.beginRPC(id: id, request: req, continuation: cont)
}
}
if res.ok {
let payload = res.payloadJSON ?? ""
guard let data = payload.data(using: .utf8) else {
throw NSError(domain: "Bridge", code: 12, userInfo: [
NSLocalizedDescriptionKey: "Bridge response not UTF-8",
])
}
return data
}
let code = res.error?.code ?? "UNAVAILABLE"
let message = res.error?.message ?? "request failed"
throw NSError(domain: "Bridge", code: 13, userInfo: [
NSLocalizedDescriptionKey: "\(code): \(message)",
])
}
func subscribeServerEvents(bufferingNewest: Int = 200) -> AsyncStream<BridgeEventFrame> {
let id = UUID()
let session = self
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
self.serverEventSubscribers[id] = continuation
continuation.onTermination = { @Sendable _ in
Task { await session.removeServerEventSubscriber(id) }
}
}
}
func disconnect() async {
self.connection?.cancel()
self.connection = nil
self.queue = nil
self.buffer = Data()
self.canvasHostUrl = nil
self.mainSessionKey = nil
let pending = self.pendingRPC.values
self.pendingRPC.removeAll()
for cont in pending {
cont.resume(throwing: NSError(domain: "Bridge", code: 14, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: connection closed",
]))
}
for (_, cont) in self.serverEventSubscribers {
cont.finish()
}
self.serverEventSubscribers.removeAll()
self.state = .idle
}
func currentMainSessionKey() -> String? {
self.mainSessionKey
}
private func beginRPC(
id: String,
request: BridgeRPCRequest,
continuation: CheckedContinuation<BridgeRPCResponse, Error>) async
{
self.pendingRPC[id] = continuation
do {
try await self.send(request)
} catch {
await self.failRPC(id: id, error: error)
}
}
private func makeParameters(tls: BridgeTLSParams?) -> NWParameters {
if let tlsOptions = makeBridgeTLSOptions(tls) {
let tcpOptions = NWProtocolTCP.Options()
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
params.includePeerToPeer = true
return params
}
let params = NWParameters.tcp
params.includePeerToPeer = true
return params
}
private func timeoutRPC(id: String) async {
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: request timeout",
]))
}
private func failRPC(id: String, error: Error) async {
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
cont.resume(throwing: error)
}
private func broadcastServerEvent(_ evt: BridgeEventFrame) {
for (_, cont) in self.serverEventSubscribers {
cont.yield(evt)
}
}
private func removeServerEventSubscriber(_ id: UUID) {
self.serverEventSubscribers[id] = nil
}
private func send(_ obj: some Encodable) async throws {
guard let connection = self.connection else {
throw NSError(domain: "Bridge", code: 10, userInfo: [
NSLocalizedDescriptionKey: "not connected",
])
}
let data = try self.encoder.encode(obj)
var line = Data()
line.append(data)
line.append(0x0A)
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
connection.send(content: line, completion: .contentProcessed { err in
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
})
}
}
private func receiveLine() async throws -> String? {
while true {
if let idx = self.buffer.firstIndex(of: 0x0A) {
let lineData = self.buffer.prefix(upTo: idx)
self.buffer.removeSubrange(...idx)
return String(data: lineData, encoding: .utf8)
}
let chunk = try await self.receiveChunk()
if chunk.isEmpty { return nil }
self.buffer.append(chunk)
}
}
private func receiveChunk() async throws -> Data {
guard let connection = self.connection else { return Data() }
return try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Data, Error>) in
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
if let error {
cont.resume(throwing: error)
return
}
if isComplete {
cont.resume(returning: Data())
return
}
cont.resume(returning: data ?? Data())
}
}
}
private static func withTimeout<T: Sendable>(
seconds: Double,
operation: @escaping @Sendable () async throws -> T) async throws -> T
{
try await AsyncTimeout.withTimeout(
seconds: seconds,
onTimeout: { TimeoutError(message: "UNAVAILABLE: connection timeout") },
operation: operation)
}
private static func makeStateStream(for connection: NWConnection) -> AsyncStream<NWConnection.State> {
AsyncStream { continuation in
continuation.onTermination = { @Sendable _ in
connection.stateUpdateHandler = nil
}
connection.stateUpdateHandler = { state in
continuation.yield(state)
switch state {
case .ready, .cancelled, .failed, .waiting:
continuation.finish()
case .setup, .preparing:
break
@unknown default:
break
}
}
}
}
private static func waitForReady(
_ stateStream: AsyncStream<NWConnection.State>,
timeoutSeconds: Double) async throws
{
try await self.withTimeout(seconds: timeoutSeconds) {
for await state in stateStream {
switch state {
case .ready:
return
case let .failed(error):
throw error
case let .waiting(error):
throw error
case .cancelled:
throw TimeoutError(message: "UNAVAILABLE: connection cancelled")
case .setup, .preparing:
break
@unknown default:
break
}
}
throw TimeoutError(message: "UNAVAILABLE: connection ended")
}
}
}

View File

@@ -1,112 +0,0 @@
import Foundation
enum BridgeSettingsStore {
private static let bridgeService = "com.clawdbot.bridge"
private static let nodeService = "com.clawdbot.node"
private static let instanceIdDefaultsKey = "node.instanceId"
private static let preferredBridgeStableIDDefaultsKey = "bridge.preferredStableID"
private static let lastDiscoveredBridgeStableIDDefaultsKey = "bridge.lastDiscoveredStableID"
private static let instanceIdAccount = "instanceId"
private static let preferredBridgeStableIDAccount = "preferredStableID"
private static let lastDiscoveredBridgeStableIDAccount = "lastDiscoveredStableID"
static func bootstrapPersistence() {
self.ensureStableInstanceID()
self.ensurePreferredBridgeStableID()
self.ensureLastDiscoveredBridgeStableID()
}
static func loadStableInstanceID() -> String? {
KeychainStore.loadString(service: self.nodeService, account: self.instanceIdAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func saveStableInstanceID(_ instanceId: String) {
_ = KeychainStore.saveString(instanceId, service: self.nodeService, account: self.instanceIdAccount)
}
static func loadPreferredBridgeStableID() -> String? {
KeychainStore.loadString(service: self.bridgeService, account: self.preferredBridgeStableIDAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func savePreferredBridgeStableID(_ stableID: String) {
_ = KeychainStore.saveString(
stableID,
service: self.bridgeService,
account: self.preferredBridgeStableIDAccount)
}
static func loadLastDiscoveredBridgeStableID() -> String? {
KeychainStore.loadString(service: self.bridgeService, account: self.lastDiscoveredBridgeStableIDAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func saveLastDiscoveredBridgeStableID(_ stableID: String) {
_ = KeychainStore.saveString(
stableID,
service: self.bridgeService,
account: self.lastDiscoveredBridgeStableIDAccount)
}
private static func ensureStableInstanceID() {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: self.instanceIdDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
{
if self.loadStableInstanceID() == nil {
self.saveStableInstanceID(existing)
}
return
}
if let stored = self.loadStableInstanceID(), !stored.isEmpty {
defaults.set(stored, forKey: self.instanceIdDefaultsKey)
return
}
let fresh = UUID().uuidString
self.saveStableInstanceID(fresh)
defaults.set(fresh, forKey: self.instanceIdDefaultsKey)
}
private static func ensurePreferredBridgeStableID() {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: self.preferredBridgeStableIDDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
{
if self.loadPreferredBridgeStableID() == nil {
self.savePreferredBridgeStableID(existing)
}
return
}
if let stored = self.loadPreferredBridgeStableID(), !stored.isEmpty {
defaults.set(stored, forKey: self.preferredBridgeStableIDDefaultsKey)
}
}
private static func ensureLastDiscoveredBridgeStableID() {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: self.lastDiscoveredBridgeStableIDDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
{
if self.loadLastDiscoveredBridgeStableID() == nil {
self.saveLastDiscoveredBridgeStableID(existing)
}
return
}
if let stored = self.loadLastDiscoveredBridgeStableID(), !stored.isEmpty {
defaults.set(stored, forKey: self.lastDiscoveredBridgeStableIDDefaultsKey)
}
}
}

View File

@@ -1,66 +0,0 @@
import CryptoKit
import Foundation
import Network
import Security
struct BridgeTLSParams: Sendable {
let required: Bool
let expectedFingerprint: String?
let allowTOFU: Bool
let storeKey: String?
}
enum BridgeTLSStore {
private static let service = "com.clawdbot.bridge.tls"
static func loadFingerprint(stableID: String) -> String? {
KeychainStore.loadString(service: service, account: stableID)?.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func saveFingerprint(_ value: String, stableID: String) {
_ = KeychainStore.saveString(value, service: service, account: stableID)
}
}
func makeBridgeTLSOptions(_ params: BridgeTLSParams?) -> NWProtocolTLS.Options? {
guard let params else { return nil }
let options = NWProtocolTLS.Options()
let expected = params.expectedFingerprint.map(normalizeBridgeFingerprint)
let allowTOFU = params.allowTOFU
let storeKey = params.storeKey
sec_protocol_options_set_verify_block(
options.securityProtocolOptions,
{ _, trust, complete in
let trustRef = sec_trust_copy_ref(trust).takeRetainedValue()
if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate],
let cert = chain.first
{
let data = SecCertificateCopyData(cert) as Data
let fingerprint = sha256Hex(data)
if let expected {
complete(fingerprint == expected)
return
}
if allowTOFU {
if let storeKey { BridgeTLSStore.saveFingerprint(fingerprint, stableID: storeKey) }
complete(true)
return
}
}
let ok = SecTrustEvaluateWithError(trustRef, nil)
complete(ok)
},
DispatchQueue(label: "com.clawdbot.bridge.tls.verify"))
return options
}
private func sha256Hex(_ data: Data) -> String {
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
private func normalizeBridgeFingerprint(_ raw: String) -> String {
raw.lowercased().filter { $0.isHexDigit }
}

View File

@@ -44,7 +44,7 @@ actor CameraController {
{
let facing = params.facing ?? .front
let format = params.format ?? .jpg
// Default to a reasonable max width to keep bridge payload sizes manageable.
// Default to a reasonable max width to keep gateway payload sizes manageable.
// If you need the full-res photo, explicitly request a larger maxWidth.
let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
let quality = Self.clampQuality(params.quality)
@@ -270,7 +270,7 @@ actor CameraController {
nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
let v = ms ?? 3000
// Keep clips short by default; avoid huge base64 payloads on the bridge.
// Keep clips short by default; avoid huge base64 payloads on the gateway.
return min(60000, max(250, v))
}

View File

@@ -1,4 +1,5 @@
import ClawdbotChatUI
import ClawdbotKit
import SwiftUI
struct ChatSheet: View {
@@ -6,8 +7,8 @@ struct ChatSheet: View {
@State private var viewModel: ClawdbotChatViewModel
private let userAccent: Color?
init(bridge: BridgeSession, sessionKey: String, userAccent: Color? = nil) {
let transport = IOSBridgeChatTransport(bridge: bridge)
init(gateway: GatewayNodeSession, sessionKey: String, userAccent: Color? = nil) {
let transport = IOSGatewayChatTransport(gateway: gateway)
self._viewModel = State(
initialValue: ClawdbotChatViewModel(
sessionKey: sessionKey,

View File

@@ -1,12 +1,13 @@
import ClawdbotChatUI
import ClawdbotKit
import ClawdbotProtocol
import Foundation
struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
private let bridge: BridgeSession
struct IOSGatewayChatTransport: ClawdbotChatTransport, Sendable {
private let gateway: GatewayNodeSession
init(bridge: BridgeSession) {
self.bridge = bridge
init(gateway: GatewayNodeSession) {
self.gateway = gateway
}
func abortRun(sessionKey: String, runId: String) async throws {
@@ -16,7 +17,7 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
}
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey, runId: runId))
let json = String(data: data, encoding: .utf8)
_ = try await self.bridge.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10)
_ = try await self.gateway.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10)
}
func listSessions(limit: Int?) async throws -> ClawdbotChatSessionsListResponse {
@@ -27,7 +28,7 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
}
let data = try JSONEncoder().encode(Params(includeGlobal: true, includeUnknown: false, limit: limit))
let json = String(data: data, encoding: .utf8)
let res = try await self.bridge.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15)
let res = try await self.gateway.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15)
return try JSONDecoder().decode(ClawdbotChatSessionsListResponse.self, from: res)
}
@@ -35,14 +36,14 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
struct Subscribe: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey))
let json = String(data: data, encoding: .utf8)
try await self.bridge.sendEvent(event: "chat.subscribe", payloadJSON: json)
await self.gateway.sendEvent(event: "chat.subscribe", payloadJSON: json)
}
func requestHistory(sessionKey: String) async throws -> ClawdbotChatHistoryPayload {
struct Params: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
let json = String(data: data, encoding: .utf8)
let res = try await self.bridge.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
let res = try await self.gateway.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
return try JSONDecoder().decode(ClawdbotChatHistoryPayload.self, from: res)
}
@@ -71,20 +72,20 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
idempotencyKey: idempotencyKey)
let data = try JSONEncoder().encode(params)
let json = String(data: data, encoding: .utf8)
let res = try await self.bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
return try JSONDecoder().decode(ClawdbotChatSendResponse.self, from: res)
}
func requestHealth(timeoutMs: Int) async throws -> Bool {
let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0)))
let res = try await self.bridge.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds)
let res = try await self.gateway.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds)
return (try? JSONDecoder().decode(ClawdbotGatewayHealthOK.self, from: res))?.ok ?? true
}
func events() -> AsyncStream<ClawdbotChatTransportEvent> {
AsyncStream { continuation in
let task = Task {
let stream = await self.bridge.subscribeServerEvents()
let stream = await self.gateway.subscribeServerEvents()
for await evt in stream {
if Task.isCancelled { return }
switch evt.event {
@@ -93,18 +94,26 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
case "seqGap":
continuation.yield(.seqGap)
case "health":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
let ok = (try? JSONDecoder().decode(ClawdbotGatewayHealthOK.self, from: data))?.ok ?? true
guard let payload = evt.payload else { break }
let ok = (try? GatewayPayloadDecoding.decode(
payload,
as: ClawdbotGatewayHealthOK.self))?.ok ?? true
continuation.yield(.health(ok: ok))
case "chat":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
if let payload = try? JSONDecoder().decode(ClawdbotChatEventPayload.self, from: data) {
continuation.yield(.chat(payload))
guard let payload = evt.payload else { break }
if let chatPayload = try? GatewayPayloadDecoding.decode(
payload,
as: ClawdbotChatEventPayload.self)
{
continuation.yield(.chat(chatPayload))
}
case "agent":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
if let payload = try? JSONDecoder().decode(ClawdbotAgentEventPayload.self, from: data) {
continuation.yield(.agent(payload))
guard let payload = evt.payload else { break }
if let agentPayload = try? GatewayPayloadDecoding.decode(
payload,
as: ClawdbotAgentEventPayload.self)
{
continuation.yield(.agent(agentPayload))
}
default:
break

View File

@@ -3,14 +3,14 @@ import SwiftUI
@main
struct ClawdbotApp: App {
@State private var appModel: NodeAppModel
@State private var bridgeController: BridgeConnectionController
@State private var gatewayController: GatewayConnectionController
@Environment(\.scenePhase) private var scenePhase
init() {
BridgeSettingsStore.bootstrapPersistence()
GatewaySettingsStore.bootstrapPersistence()
let appModel = NodeAppModel()
_appModel = State(initialValue: appModel)
_bridgeController = State(initialValue: BridgeConnectionController(appModel: appModel))
_gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel))
}
var body: some Scene {
@@ -18,13 +18,13 @@ struct ClawdbotApp: App {
RootCanvas()
.environment(self.appModel)
.environment(self.appModel.voiceWake)
.environment(self.bridgeController)
.environment(self.gatewayController)
.onOpenURL { url in
Task { await self.appModel.handleDeepLink(url: url) }
}
.onChange(of: self.scenePhase) { _, newValue in
self.appModel.setScenePhase(newValue)
self.bridgeController.setScenePhase(newValue)
self.gatewayController.setScenePhase(newValue)
}
}
}

View File

@@ -6,40 +6,23 @@ import Observation
import SwiftUI
import UIKit
protocol BridgePairingClient: Sendable {
func pairAndHello(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: BridgeTLSParams?,
onStatus: (@Sendable (String) -> Void)?) async throws -> String
}
extension BridgeClient: BridgePairingClient {}
@MainActor
@Observable
final class BridgeConnectionController {
private(set) var bridges: [BridgeDiscoveryModel.DiscoveredBridge] = []
final class GatewayConnectionController {
private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = []
private(set) var discoveryStatusText: String = "Idle"
private(set) var discoveryDebugLog: [BridgeDiscoveryModel.DebugLogEntry] = []
private(set) var discoveryDebugLog: [GatewayDiscoveryModel.DebugLogEntry] = []
private let discovery = BridgeDiscoveryModel()
private let discovery = GatewayDiscoveryModel()
private weak var appModel: NodeAppModel?
private var didAutoConnect = false
private let bridgeClientFactory: @Sendable () -> any BridgePairingClient
init(
appModel: NodeAppModel,
startDiscovery: Bool = true,
bridgeClientFactory: @escaping @Sendable () -> any BridgePairingClient = { BridgeClient() })
{
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
self.appModel = appModel
self.bridgeClientFactory = bridgeClientFactory
BridgeSettingsStore.bootstrapPersistence()
GatewaySettingsStore.bootstrapPersistence()
let defaults = UserDefaults.standard
self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "bridge.discovery.debugLogs"))
self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "gateway.discovery.debugLogs"))
self.updateFromDiscovery()
self.observeDiscovery()
@@ -64,18 +47,61 @@ final class BridgeConnectionController {
}
}
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
guard let host = self.resolveGatewayHost(gateway) else { return }
let port = gateway.gatewayPort ?? 18789
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
guard let url = self.buildGatewayURL(
host: host,
port: port,
useTLS: tlsParams?.required == true)
else { return }
self.didAutoConnect = true
self.startAutoConnect(
url: url,
gatewayStableID: gateway.stableID,
tls: tlsParams,
token: token,
password: password)
}
func connectManual(host: String, port: Int, useTLS: Bool) async {
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
let stableID = self.manualStableID(host: host, port: port)
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: useTLS)
guard let url = self.buildGatewayURL(
host: host,
port: port,
useTLS: tlsParams?.required == true)
else { return }
self.didAutoConnect = true
self.startAutoConnect(
url: url,
gatewayStableID: stableID,
tls: tlsParams,
token: token,
password: password)
}
private func updateFromDiscovery() {
let newBridges = self.discovery.bridges
self.bridges = newBridges
let newGateways = self.discovery.gateways
self.gateways = newGateways
self.discoveryStatusText = self.discovery.statusText
self.discoveryDebugLog = self.discovery.debugLog
self.updateLastDiscoveredBridge(from: newBridges)
self.updateLastDiscoveredGateway(from: newGateways)
self.maybeAutoConnect()
}
private func observeDiscovery() {
withObservationTracking {
_ = self.discovery.bridges
_ = self.discovery.gateways
_ = self.discovery.statusText
_ = self.discovery.debugLog
} onChange: { [weak self] in
@@ -90,181 +116,176 @@ final class BridgeConnectionController {
private func maybeAutoConnect() {
guard !self.didAutoConnect else { return }
guard let appModel = self.appModel else { return }
guard appModel.bridgeServerName == nil else { return }
guard appModel.gatewayServerName == nil else { return }
let defaults = UserDefaults.standard
let manualEnabled = defaults.bool(forKey: "bridge.manual.enabled")
let manualEnabled = defaults.bool(forKey: "gateway.manual.enabled")
let instanceId = defaults.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !instanceId.isEmpty else { return }
let token = KeychainStore.loadString(
service: "com.clawdbot.bridge",
account: self.keychainAccount(instanceId: instanceId))?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !token.isEmpty else { return }
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
if manualEnabled {
let manualHost = defaults.string(forKey: "bridge.manual.host")?
let manualHost = defaults.string(forKey: "gateway.manual.host")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !manualHost.isEmpty else { return }
let manualPort = defaults.integer(forKey: "bridge.manual.port")
let resolvedPort = manualPort > 0 ? manualPort : 18790
guard let port = NWEndpoint.Port(rawValue: UInt16(resolvedPort)) else { return }
let manualPort = defaults.integer(forKey: "gateway.manual.port")
let resolvedPort = manualPort > 0 ? manualPort : 18789
let manualTLS = defaults.bool(forKey: "gateway.manual.tls")
let stableID = self.manualStableID(host: manualHost, port: resolvedPort)
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: manualTLS)
guard let url = self.buildGatewayURL(
host: manualHost,
port: resolvedPort,
useTLS: tlsParams?.required == true)
else { return }
self.didAutoConnect = true
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
let stableID = BridgeEndpointID.stableID(endpoint)
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
self.startAutoConnect(
endpoint: endpoint,
bridgeStableID: stableID,
url: url,
gatewayStableID: stableID,
tls: tlsParams,
token: token,
instanceId: instanceId)
password: password)
return
}
let preferredStableID = defaults.string(forKey: "bridge.preferredStableID")?
let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let lastDiscoveredStableID = defaults.string(forKey: "bridge.lastDiscoveredStableID")?
let lastDiscoveredStableID = defaults.string(forKey: "gateway.lastDiscoveredStableID")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty }
guard let targetStableID = candidates.first(where: { id in
self.bridges.contains(where: { $0.stableID == id })
self.gateways.contains(where: { $0.stableID == id })
}) else { return }
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
guard let host = self.resolveGatewayHost(target) else { return }
let port = target.gatewayPort ?? 18789
let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
else { return }
let tlsParams = self.resolveDiscoveredTLSParams(bridge: target)
self.didAutoConnect = true
self.startAutoConnect(
endpoint: target.endpoint,
bridgeStableID: target.stableID,
url: url,
gatewayStableID: target.stableID,
tls: tlsParams,
token: token,
instanceId: instanceId)
password: password)
}
private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
private func updateLastDiscoveredGateway(from gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
let defaults = UserDefaults.standard
let preferred = defaults.string(forKey: "bridge.preferredStableID")?
let preferred = defaults.string(forKey: "gateway.preferredStableID")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let existingLast = defaults.string(forKey: "bridge.lastDiscoveredStableID")?
let existingLast = defaults.string(forKey: "gateway.lastDiscoveredStableID")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
// Avoid overriding user intent (preferred/lastDiscovered are also set on manual Connect).
guard preferred.isEmpty, existingLast.isEmpty else { return }
guard let first = bridges.first else { return }
guard let first = gateways.first else { return }
defaults.set(first.stableID, forKey: "bridge.lastDiscoveredStableID")
BridgeSettingsStore.saveLastDiscoveredBridgeStableID(first.stableID)
}
private func makeHello(token: String) -> BridgeHello {
let defaults = UserDefaults.standard
let nodeId = defaults.string(forKey: "node.instanceId") ?? "ios-node"
let displayName = self.resolvedDisplayName(defaults: defaults)
return BridgeHello(
nodeId: nodeId,
displayName: displayName,
token: token,
platform: self.platformString(),
version: self.appVersion(),
deviceFamily: self.deviceFamily(),
modelIdentifier: self.modelIdentifier(),
caps: self.currentCaps(),
commands: self.currentCommands())
}
private func keychainAccount(instanceId: String) -> String {
"bridge-token.\(instanceId)"
defaults.set(first.stableID, forKey: "gateway.lastDiscoveredStableID")
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(first.stableID)
}
private func startAutoConnect(
endpoint: NWEndpoint,
bridgeStableID: String,
tls: BridgeTLSParams?,
token: String,
instanceId: String)
url: URL,
gatewayStableID: String,
tls: GatewayTLSParams?,
token: String?,
password: String?)
{
guard let appModel else { return }
let connectOptions = self.makeConnectOptions()
Task { [weak self] in
guard let self else { return }
do {
let hello = self.makeHello(token: token)
let refreshed = try await self.bridgeClientFactory().pairAndHello(
endpoint: endpoint,
hello: hello,
tls: tls,
onStatus: { status in
Task { @MainActor in
appModel.bridgeStatusText = status
}
})
let resolvedToken = refreshed.isEmpty ? token : refreshed
if !refreshed.isEmpty, refreshed != token {
_ = KeychainStore.saveString(
refreshed,
service: "com.clawdbot.bridge",
account: self.keychainAccount(instanceId: instanceId))
}
appModel.connectToBridge(
endpoint: endpoint,
bridgeStableID: bridgeStableID,
tls: tls,
hello: self.makeHello(token: resolvedToken))
} catch {
await MainActor.run {
appModel.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
}
await MainActor.run {
appModel.gatewayStatusText = "Connecting…"
}
appModel.connectToGateway(
url: url,
gatewayStableID: gatewayStableID,
tls: tls,
token: token,
password: password,
connectOptions: connectOptions)
}
}
private func resolveDiscoveredTLSParams(
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
{
let stableID = bridge.stableID
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
private func resolveDiscoveredTLSParams(gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams? {
let stableID = gateway.stableID
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
return BridgeTLSParams(
if gateway.tlsEnabled || gateway.tlsFingerprintSha256 != nil || stored != nil {
return GatewayTLSParams(
required: true,
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
expectedFingerprint: gateway.tlsFingerprintSha256 ?? stored,
allowTOFU: stored == nil,
storeKey: stableID)
}
if let stored {
return BridgeTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
}
return nil
}
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
return BridgeTLSParams(
private func resolveManualTLSParams(stableID: String, tlsEnabled: Bool) -> GatewayTLSParams? {
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
if tlsEnabled || stored != nil {
return GatewayTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
allowTOFU: stored == nil,
storeKey: stableID)
}
return BridgeTLSParams(
required: false,
expectedFingerprint: nil,
allowTOFU: true,
storeKey: stableID)
return nil
}
private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
return lanHost
}
if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty {
return tailnet
}
return nil
}
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
let scheme = useTLS ? "wss" : "ws"
var components = URLComponents()
components.scheme = scheme
components.host = host
components.port = port
return components.url
}
private func manualStableID(host: String, port: Int) -> String {
"manual|\(host.lowercased())|\(port)"
}
private func makeConnectOptions() -> GatewayConnectOptions {
let defaults = UserDefaults.standard
let displayName = self.resolvedDisplayName(defaults: defaults)
return GatewayConnectOptions(
role: "node",
scopes: [],
caps: self.currentCaps(),
commands: self.currentCommands(),
permissions: [:],
clientId: "clawdbot-ios",
clientMode: "node",
clientDisplayName: displayName)
}
private func resolvedDisplayName(defaults: UserDefaults) -> String {
@@ -313,6 +334,11 @@ final class BridgeConnectionController {
ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
ClawdbotCanvasA2UICommand.reset.rawValue,
ClawdbotScreenCommand.record.rawValue,
ClawdbotSystemCommand.notify.rawValue,
ClawdbotSystemCommand.which.rawValue,
ClawdbotSystemCommand.run.rawValue,
ClawdbotSystemCommand.execApprovalsGet.rawValue,
ClawdbotSystemCommand.execApprovalsSet.rawValue,
]
let caps = Set(self.currentCaps())
@@ -368,11 +394,7 @@ final class BridgeConnectionController {
}
#if DEBUG
extension BridgeConnectionController {
func _test_makeHello(token: String) -> BridgeHello {
self.makeHello(token: token)
}
extension GatewayConnectionController {
func _test_resolvedDisplayName(defaults: UserDefaults) -> String {
self.resolvedDisplayName(defaults: defaults)
}
@@ -401,8 +423,8 @@ extension BridgeConnectionController {
self.appVersion()
}
func _test_setBridges(_ bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
self.bridges = bridges
func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
self.gateways = gateways
}
func _test_triggerAutoConnect() {

View File

@@ -1,9 +1,9 @@
import SwiftUI
import UIKit
struct BridgeDiscoveryDebugLogView: View {
@Environment(BridgeConnectionController.self) private var bridgeController
@AppStorage("bridge.discovery.debugLogs") private var debugLogsEnabled: Bool = false
struct GatewayDiscoveryDebugLogView: View {
@Environment(GatewayConnectionController.self) private var gatewayController
@AppStorage("gateway.discovery.debugLogs") private var debugLogsEnabled: Bool = false
var body: some View {
List {
@@ -12,11 +12,11 @@ struct BridgeDiscoveryDebugLogView: View {
.foregroundStyle(.secondary)
}
if self.bridgeController.discoveryDebugLog.isEmpty {
if self.gatewayController.discoveryDebugLog.isEmpty {
Text("No log entries yet.")
.foregroundStyle(.secondary)
} else {
ForEach(self.bridgeController.discoveryDebugLog) { entry in
ForEach(self.gatewayController.discoveryDebugLog) { entry in
VStack(alignment: .leading, spacing: 2) {
Text(Self.formatTime(entry.ts))
.font(.caption)
@@ -35,13 +35,13 @@ struct BridgeDiscoveryDebugLogView: View {
Button("Copy") {
UIPasteboard.general.string = self.formattedLog()
}
.disabled(self.bridgeController.discoveryDebugLog.isEmpty)
.disabled(self.gatewayController.discoveryDebugLog.isEmpty)
}
}
}
private func formattedLog() -> String {
self.bridgeController.discoveryDebugLog
self.gatewayController.discoveryDebugLog
.map { "\(Self.formatISO($0.ts)) \($0.message)" }
.joined(separator: "\n")
}

View File

@@ -5,14 +5,14 @@ import Observation
@MainActor
@Observable
final class BridgeDiscoveryModel {
final class GatewayDiscoveryModel {
struct DebugLogEntry: Identifiable, Equatable {
var id = UUID()
var ts: Date
var message: String
}
struct DiscoveredBridge: Identifiable, Equatable {
struct DiscoveredGateway: Identifiable, Equatable {
var id: String { self.stableID }
var name: String
var endpoint: NWEndpoint
@@ -21,19 +21,18 @@ final class BridgeDiscoveryModel {
var lanHost: String?
var tailnetDns: String?
var gatewayPort: Int?
var bridgePort: Int?
var canvasPort: Int?
var tlsEnabled: Bool
var tlsFingerprintSha256: String?
var cliPath: String?
}
var bridges: [DiscoveredBridge] = []
var gateways: [DiscoveredGateway] = []
var statusText: String = "Idle"
private(set) var debugLog: [DebugLogEntry] = []
private var browsers: [String: NWBrowser] = [:]
private var bridgesByDomain: [String: [DiscoveredBridge]] = [:]
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
private var statesByDomain: [String: NWBrowser.State] = [:]
private var debugLoggingEnabled = false
private var lastStableIDs = Set<String>()
@@ -45,7 +44,7 @@ final class BridgeDiscoveryModel {
self.debugLog = []
} else if !wasEnabled {
self.appendDebugLog("debug logging enabled")
self.appendDebugLog("snapshot: status=\(self.statusText) bridges=\(self.bridges.count)")
self.appendDebugLog("snapshot: status=\(self.statusText) gateways=\(self.gateways.count)")
}
}
@@ -53,11 +52,11 @@ final class BridgeDiscoveryModel {
if !self.browsers.isEmpty { return }
self.appendDebugLog("start()")
for domain in ClawdbotBonjour.bridgeServiceDomains {
for domain in ClawdbotBonjour.gatewayServiceDomains {
let params = NWParameters.tcp
params.includePeerToPeer = true
let browser = NWBrowser(
for: .bonjour(type: ClawdbotBonjour.bridgeServiceType, domain: domain),
for: .bonjour(type: ClawdbotBonjour.gatewayServiceType, domain: domain),
using: params)
browser.stateUpdateHandler = { [weak self] state in
@@ -72,7 +71,7 @@ final class BridgeDiscoveryModel {
browser.browseResultsChangedHandler = { [weak self] results, _ in
Task { @MainActor in
guard let self else { return }
self.bridgesByDomain[domain] = results.compactMap { result -> DiscoveredBridge? in
self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in
switch result.endpoint {
case let .service(name, _, _, _):
let decodedName = BonjourEscapes.decode(name)
@@ -82,18 +81,17 @@ final class BridgeDiscoveryModel {
.map(Self.prettifyInstanceName)
.flatMap { $0.isEmpty ? nil : $0 }
let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName)
return DiscoveredBridge(
return DiscoveredGateway(
name: prettyName,
endpoint: result.endpoint,
stableID: BridgeEndpointID.stableID(result.endpoint),
debugID: BridgeEndpointID.prettyDescription(result.endpoint),
stableID: GatewayEndpointID.stableID(result.endpoint),
debugID: GatewayEndpointID.prettyDescription(result.endpoint),
lanHost: Self.txtValue(txt, key: "lanHost"),
tailnetDns: Self.txtValue(txt, key: "tailnetDns"),
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
bridgePort: Self.txtIntValue(txt, key: "bridgePort"),
canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
tlsEnabled: Self.txtBoolValue(txt, key: "bridgeTls"),
tlsFingerprintSha256: Self.txtValue(txt, key: "bridgeTlsSha256"),
tlsEnabled: Self.txtBoolValue(txt, key: "gatewayTls"),
tlsFingerprintSha256: Self.txtValue(txt, key: "gatewayTlsSha256"),
cliPath: Self.txtValue(txt, key: "cliPath"))
default:
return nil
@@ -101,12 +99,12 @@ final class BridgeDiscoveryModel {
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
self.recomputeBridges()
self.recomputeGateways()
}
}
self.browsers[domain] = browser
browser.start(queue: DispatchQueue(label: "com.clawdbot.ios.bridge-discovery.\(domain)"))
browser.start(queue: DispatchQueue(label: "com.clawdbot.ios.gateway-discovery.\(domain)"))
}
}
@@ -116,14 +114,14 @@ final class BridgeDiscoveryModel {
browser.cancel()
}
self.browsers = [:]
self.bridgesByDomain = [:]
self.gatewaysByDomain = [:]
self.statesByDomain = [:]
self.bridges = []
self.gateways = []
self.statusText = "Stopped"
}
private func recomputeBridges() {
let next = self.bridgesByDomain.values
private func recomputeGateways() {
let next = self.gatewaysByDomain.values
.flatMap(\.self)
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
@@ -134,7 +132,7 @@ final class BridgeDiscoveryModel {
self.appendDebugLog("results: total=\(next.count) added=\(added.count) removed=\(removed.count)")
}
self.lastStableIDs = nextIDs
self.bridges = next
self.gateways = next
}
private func updateStatusText() {

View File

@@ -0,0 +1,226 @@
import Foundation
enum GatewaySettingsStore {
private static let gatewayService = "com.clawdbot.gateway"
private static let legacyBridgeService = "com.clawdbot.bridge"
private static let nodeService = "com.clawdbot.node"
private static let instanceIdDefaultsKey = "node.instanceId"
private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID"
private static let lastDiscoveredGatewayStableIDDefaultsKey = "gateway.lastDiscoveredStableID"
private static let manualEnabledDefaultsKey = "gateway.manual.enabled"
private static let manualHostDefaultsKey = "gateway.manual.host"
private static let manualPortDefaultsKey = "gateway.manual.port"
private static let manualTlsDefaultsKey = "gateway.manual.tls"
private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs"
private static let legacyPreferredBridgeStableIDDefaultsKey = "bridge.preferredStableID"
private static let legacyLastDiscoveredBridgeStableIDDefaultsKey = "bridge.lastDiscoveredStableID"
private static let legacyManualEnabledDefaultsKey = "bridge.manual.enabled"
private static let legacyManualHostDefaultsKey = "bridge.manual.host"
private static let legacyManualPortDefaultsKey = "bridge.manual.port"
private static let legacyDiscoveryDebugLogsDefaultsKey = "bridge.discovery.debugLogs"
private static let instanceIdAccount = "instanceId"
private static let preferredGatewayStableIDAccount = "preferredStableID"
private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID"
static func bootstrapPersistence() {
self.ensureStableInstanceID()
self.ensurePreferredGatewayStableID()
self.ensureLastDiscoveredGatewayStableID()
self.migrateLegacyDefaults()
}
static func loadStableInstanceID() -> String? {
KeychainStore.loadString(service: self.nodeService, account: self.instanceIdAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func saveStableInstanceID(_ instanceId: String) {
_ = KeychainStore.saveString(instanceId, service: self.nodeService, account: self.instanceIdAccount)
}
static func loadPreferredGatewayStableID() -> String? {
KeychainStore.loadString(service: self.gatewayService, account: self.preferredGatewayStableIDAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func savePreferredGatewayStableID(_ stableID: String) {
_ = KeychainStore.saveString(
stableID,
service: self.gatewayService,
account: self.preferredGatewayStableIDAccount)
}
static func loadLastDiscoveredGatewayStableID() -> String? {
KeychainStore.loadString(service: self.gatewayService, account: self.lastDiscoveredGatewayStableIDAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func saveLastDiscoveredGatewayStableID(_ stableID: String) {
_ = KeychainStore.saveString(
stableID,
service: self.gatewayService,
account: self.lastDiscoveredGatewayStableIDAccount)
}
static func loadGatewayToken(instanceId: String) -> String? {
let account = self.gatewayTokenAccount(instanceId: instanceId)
let token = KeychainStore.loadString(service: self.gatewayService, account: account)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if token?.isEmpty == false { return token }
let legacyAccount = self.legacyBridgeTokenAccount(instanceId: instanceId)
let legacy = KeychainStore.loadString(service: self.legacyBridgeService, account: legacyAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let legacy, !legacy.isEmpty {
_ = KeychainStore.saveString(legacy, service: self.gatewayService, account: account)
return legacy
}
return nil
}
static func saveGatewayToken(_ token: String, instanceId: String) {
_ = KeychainStore.saveString(
token,
service: self.gatewayService,
account: self.gatewayTokenAccount(instanceId: instanceId))
}
static func loadGatewayPassword(instanceId: String) -> String? {
KeychainStore.loadString(
service: self.gatewayService,
account: self.gatewayPasswordAccount(instanceId: instanceId))?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func saveGatewayPassword(_ password: String, instanceId: String) {
_ = KeychainStore.saveString(
password,
service: self.gatewayService,
account: self.gatewayPasswordAccount(instanceId: instanceId))
}
private static func gatewayTokenAccount(instanceId: String) -> String {
"gateway-token.\(instanceId)"
}
private static func legacyBridgeTokenAccount(instanceId: String) -> String {
"bridge-token.\(instanceId)"
}
private static func gatewayPasswordAccount(instanceId: String) -> String {
"gateway-password.\(instanceId)"
}
private static func ensureStableInstanceID() {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: self.instanceIdDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
{
if self.loadStableInstanceID() == nil {
self.saveStableInstanceID(existing)
}
return
}
if let stored = self.loadStableInstanceID(), !stored.isEmpty {
defaults.set(stored, forKey: self.instanceIdDefaultsKey)
return
}
let fresh = UUID().uuidString
self.saveStableInstanceID(fresh)
defaults.set(fresh, forKey: self.instanceIdDefaultsKey)
}
private static func ensurePreferredGatewayStableID() {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: self.preferredGatewayStableIDDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
{
if self.loadPreferredGatewayStableID() == nil {
self.savePreferredGatewayStableID(existing)
}
return
}
if let stored = self.loadPreferredGatewayStableID(), !stored.isEmpty {
defaults.set(stored, forKey: self.preferredGatewayStableIDDefaultsKey)
}
}
private static func ensureLastDiscoveredGatewayStableID() {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
{
if self.loadLastDiscoveredGatewayStableID() == nil {
self.saveLastDiscoveredGatewayStableID(existing)
}
return
}
if let stored = self.loadLastDiscoveredGatewayStableID(), !stored.isEmpty {
defaults.set(stored, forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)
}
}
private static func migrateLegacyDefaults() {
let defaults = UserDefaults.standard
if defaults.string(forKey: self.preferredGatewayStableIDDefaultsKey)?.isEmpty != false,
let legacy = defaults.string(forKey: self.legacyPreferredBridgeStableIDDefaultsKey),
!legacy.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
defaults.set(legacy, forKey: self.preferredGatewayStableIDDefaultsKey)
self.savePreferredGatewayStableID(legacy)
}
if defaults.string(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)?.isEmpty != false,
let legacy = defaults.string(forKey: self.legacyLastDiscoveredBridgeStableIDDefaultsKey),
!legacy.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
defaults.set(legacy, forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)
self.saveLastDiscoveredGatewayStableID(legacy)
}
if defaults.object(forKey: self.manualEnabledDefaultsKey) == nil,
defaults.object(forKey: self.legacyManualEnabledDefaultsKey) != nil
{
defaults.set(
defaults.bool(forKey: self.legacyManualEnabledDefaultsKey),
forKey: self.manualEnabledDefaultsKey)
}
if defaults.string(forKey: self.manualHostDefaultsKey)?.isEmpty != false,
let legacy = defaults.string(forKey: self.legacyManualHostDefaultsKey),
!legacy.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
defaults.set(legacy, forKey: self.manualHostDefaultsKey)
}
if defaults.integer(forKey: self.manualPortDefaultsKey) == 0,
defaults.integer(forKey: self.legacyManualPortDefaultsKey) > 0
{
defaults.set(
defaults.integer(forKey: self.legacyManualPortDefaultsKey),
forKey: self.manualPortDefaultsKey)
}
if defaults.object(forKey: self.discoveryDebugLogsDefaultsKey) == nil,
defaults.object(forKey: self.legacyDiscoveryDebugLogsDefaultsKey) != nil
{
defaults.set(
defaults.bool(forKey: self.legacyDiscoveryDebugLogsDefaultsKey),
forKey: self.discoveryDebugLogsDefaultsKey)
}
}
}

View File

@@ -29,12 +29,12 @@
</dict>
<key>NSBonjourServices</key>
<array>
<string>_clawdbot-bridge._tcp</string>
<string>_clawdbot-gateway._tcp</string>
</array>
<key>NSCameraUsageDescription</key>
<string>Clawdbot can capture photos or short video clips when requested via the bridge.</string>
<string>Clawdbot can capture photos or short video clips when requested via the gateway.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Clawdbot discovers and connects to your Clawdbot bridge on the local network.</string>
<string>Clawdbot discovers and connects to your Clawdbot gateway on the local network.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Clawdbot can share your location in the background when you enable Always.</string>
<key>NSLocationWhenInUseUsageDescription</key>

View File

@@ -18,15 +18,15 @@ final class NodeAppModel {
let screen = ScreenController()
let camera = CameraController()
private let screenRecorder = ScreenRecordService()
var bridgeStatusText: String = "Offline"
var bridgeServerName: String?
var bridgeRemoteAddress: String?
var connectedBridgeID: String?
var gatewayStatusText: String = "Offline"
var gatewayServerName: String?
var gatewayRemoteAddress: String?
var connectedGatewayID: String?
var seamColorHex: String?
var mainSessionKey: String = "main"
private let bridge = BridgeSession()
private var bridgeTask: Task<Void, Never>?
private let gateway = GatewayNodeSession()
private var gatewayTask: Task<Void, Never>?
private var voiceWakeSyncTask: Task<Void, Never>?
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
let voiceWake = VoiceWakeManager()
@@ -34,7 +34,8 @@ final class NodeAppModel {
private let locationService = LocationService()
private var lastAutoA2uiURL: String?
var bridgeSession: BridgeSession { self.bridge }
private var gatewayConnected = false
var gatewaySession: GatewayNodeSession { self.gateway }
var cameraHUDText: String?
var cameraHUDKind: CameraHUDKind?
@@ -54,7 +55,7 @@ final class NodeAppModel {
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
self.voiceWake.setEnabled(enabled)
self.talkMode.attachBridge(self.bridge)
self.talkMode.attachGateway(self.gateway)
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
self.talkMode.setEnabled(talkEnabled)
@@ -120,9 +121,9 @@ final class NodeAppModel {
let ok: Bool
var errorText: String?
if await !self.isBridgeConnected() {
if await !self.isGatewayConnected() {
ok = false
errorText = "bridge not connected"
errorText = "gateway not connected"
} else {
do {
try await self.sendAgentRequest(link: AgentDeepLink(
@@ -150,7 +151,7 @@ final class NodeAppModel {
}
private func resolveA2UIHostURL() async -> String? {
guard let raw = await self.bridge.currentCanvasHostUrl() else { return nil }
guard let raw = await self.gateway.currentCanvasHostUrl() else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
return base.appendingPathComponent("__clawdbot__/a2ui/").absoluteString + "?platform=ios"
@@ -202,56 +203,70 @@ final class NodeAppModel {
}
}
func connectToBridge(
endpoint: NWEndpoint,
bridgeStableID: String,
tls: BridgeTLSParams?,
hello: BridgeHello)
func connectToGateway(
url: URL,
gatewayStableID: String,
tls: GatewayTLSParams?,
token: String?,
password: String?,
connectOptions: GatewayConnectOptions)
{
self.bridgeTask?.cancel()
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
let id = bridgeStableID.trimmingCharacters(in: .whitespacesAndNewlines)
self.connectedBridgeID = id.isEmpty ? BridgeEndpointID.stableID(endpoint) : id
self.gatewayTask?.cancel()
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
let id = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
self.connectedGatewayID = id.isEmpty ? url.absoluteString : id
self.gatewayConnected = false
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
let sessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
self.bridgeTask = Task {
self.gatewayTask = Task {
var attempt = 0
while !Task.isCancelled {
await MainActor.run {
if attempt == 0 {
self.bridgeStatusText = "Connecting…"
self.gatewayStatusText = "Connecting…"
} else {
self.bridgeStatusText = "Reconnecting…"
self.gatewayStatusText = "Reconnecting…"
}
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
}
do {
try await self.bridge.connect(
endpoint: endpoint,
hello: hello,
tls: tls,
onConnected: { [weak self] serverName, mainSessionKey in
try await self.gateway.connect(
url: url,
token: token,
password: password,
connectOptions: connectOptions,
sessionBox: sessionBox,
onConnected: { [weak self] in
guard let self else { return }
await MainActor.run {
self.bridgeStatusText = "Connected"
self.bridgeServerName = serverName
self.gatewayStatusText = "Connected"
self.gatewayServerName = url.host ?? "gateway"
self.gatewayConnected = true
}
await MainActor.run {
self.applyMainSessionKey(mainSessionKey)
}
if let addr = await self.bridge.currentRemoteAddress() {
if let addr = await self.gateway.currentRemoteAddress() {
await MainActor.run {
self.bridgeRemoteAddress = addr
self.gatewayRemoteAddress = addr
}
}
await self.refreshBrandingFromGateway()
await self.startVoiceWakeSync()
await self.showA2UIOnConnectIfNeeded()
},
onDisconnected: { [weak self] reason in
guard let self else { return }
await MainActor.run {
self.gatewayStatusText = "Disconnected"
self.gatewayRemoteAddress = nil
self.gatewayConnected = false
self.showLocalCanvasOnDisconnect()
self.gatewayStatusText = "Disconnected: \(reason)"
}
},
onInvoke: { [weak self] req in
guard let self else {
return BridgeInvokeResponse(
@@ -265,19 +280,16 @@ final class NodeAppModel {
})
if Task.isCancelled { break }
await MainActor.run {
self.showLocalCanvasOnDisconnect()
}
attempt += 1
let sleepSeconds = min(6.0, 0.35 * pow(1.7, Double(attempt)))
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
attempt = 0
try? await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
if Task.isCancelled { break }
attempt += 1
await MainActor.run {
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.gatewayStatusText = "Gateway error: \(error.localizedDescription)"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.gatewayConnected = false
self.showLocalCanvasOnDisconnect()
}
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
@@ -286,10 +298,11 @@ final class NodeAppModel {
}
await MainActor.run {
self.bridgeStatusText = "Offline"
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil
self.gatewayStatusText = "Offline"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.connectedGatewayID = nil
self.gatewayConnected = false
self.seamColorHex = nil
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = "main"
@@ -300,16 +313,17 @@ final class NodeAppModel {
}
}
func disconnectBridge() {
self.bridgeTask?.cancel()
self.bridgeTask = nil
func disconnectGateway() {
self.gatewayTask?.cancel()
self.gatewayTask = nil
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
Task { await self.bridge.disconnect() }
self.bridgeStatusText = "Offline"
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil
Task { await self.gateway.disconnect() }
self.gatewayStatusText = "Offline"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.connectedGatewayID = nil
self.gatewayConnected = false
self.seamColorHex = nil
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = "main"
@@ -347,7 +361,7 @@ final class NodeAppModel {
private func refreshBrandingFromGateway() async {
do {
let res = try await self.bridge.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
let res = try await self.gateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
guard let config = json["config"] as? [String: Any] else { return }
let ui = config["ui"] as? [String: Any]
@@ -378,7 +392,7 @@ final class NodeAppModel {
else { return }
do {
_ = try await self.bridge.request(method: "voicewake.set", paramsJSON: json, timeoutSeconds: 12)
_ = try await self.gateway.request(method: "voicewake.set", paramsJSON: json, timeoutSeconds: 12)
} catch {
// Best-effort only.
}
@@ -391,12 +405,14 @@ final class NodeAppModel {
await self.refreshWakeWordsFromGateway()
let stream = await self.bridge.subscribeServerEvents(bufferingNewest: 200)
let stream = await self.gateway.subscribeServerEvents(bufferingNewest: 200)
for await evt in stream {
if Task.isCancelled { return }
guard evt.event == "voicewake.changed" else { continue }
guard let payloadJSON = evt.payloadJSON else { continue }
guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: payloadJSON) else { continue }
guard let payload = evt.payload else { continue }
struct Payload: Decodable { var triggers: [String] }
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
VoiceWakePreferences.saveTriggerWords(triggers)
}
}
@@ -404,7 +420,7 @@ final class NodeAppModel {
private func refreshWakeWordsFromGateway() async {
do {
let data = try await self.bridge.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8)
let data = try await self.gateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8)
guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return }
VoiceWakePreferences.saveTriggerWords(triggers)
} catch {
@@ -413,6 +429,11 @@ final class NodeAppModel {
}
func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
if await !self.isGatewayConnected() {
throw NSError(domain: "Gateway", code: 10, userInfo: [
NSLocalizedDescriptionKey: "Gateway not connected",
])
}
struct Payload: Codable {
var text: String
var sessionKey: String?
@@ -424,7 +445,7 @@ final class NodeAppModel {
NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8",
])
}
try await self.bridge.sendEvent(event: "voice.transcript", payloadJSON: json)
await self.gateway.sendEvent(event: "voice.transcript", payloadJSON: json)
}
func handleDeepLink(url: URL) async {
@@ -445,8 +466,8 @@ final class NodeAppModel {
return
}
guard await self.isBridgeConnected() else {
self.screen.errorText = "Bridge not connected (cannot forward deep link)."
guard await self.isGatewayConnected() else {
self.screen.errorText = "Gateway not connected (cannot forward deep link)."
return
}
@@ -465,7 +486,7 @@ final class NodeAppModel {
])
}
// iOS bridge forwards to the gateway; no local auth prompts here.
// iOS gateway forwards to the gateway; no local auth prompts here.
// (Key-based unattended auth is handled on macOS for clawdbot:// links.)
let data = try JSONEncoder().encode(link)
guard let json = String(bytes: data, encoding: .utf8) else {
@@ -473,12 +494,11 @@ final class NodeAppModel {
NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8",
])
}
try await self.bridge.sendEvent(event: "agent.request", payloadJSON: json)
await self.gateway.sendEvent(event: "agent.request", payloadJSON: json)
}
private func isBridgeConnected() async -> Bool {
if case .connected = await self.bridge.state { return true }
return false
private func isGatewayConnected() async -> Bool {
self.gatewayConnected
}
private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
@@ -837,26 +857,29 @@ final class NodeAppModel {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func locationMode() -> ClawdbotLocationMode {
}
private extension NodeAppModel {
func locationMode() -> ClawdbotLocationMode {
let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
return ClawdbotLocationMode(rawValue: raw) ?? .off
}
private func isLocationPreciseEnabled() -> Bool {
func isLocationPreciseEnabled() -> Bool {
if UserDefaults.standard.object(forKey: "location.preciseEnabled") == nil { return true }
return UserDefaults.standard.bool(forKey: "location.preciseEnabled")
}
private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
guard let json, let data = json.data(using: .utf8) else {
throw NSError(domain: "Bridge", code: 20, userInfo: [
throw NSError(domain: "Gateway", code: 20, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
])
}
return try JSONDecoder().decode(type, from: data)
}
private static func encodePayload(_ obj: some Encodable) throws -> String {
static func encodePayload(_ obj: some Encodable) throws -> String {
let data = try JSONEncoder().encode(obj)
guard let json = String(bytes: data, encoding: .utf8) else {
throw NSError(domain: "NodeAppModel", code: 21, userInfo: [
@@ -866,17 +889,17 @@ final class NodeAppModel {
return json
}
private func isCameraEnabled() -> Bool {
func isCameraEnabled() -> Bool {
// Default-on: if the key doesn't exist yet, treat it as enabled.
if UserDefaults.standard.object(forKey: "camera.enabled") == nil { return true }
return UserDefaults.standard.bool(forKey: "camera.enabled")
}
private func triggerCameraFlash() {
func triggerCameraFlash() {
self.cameraFlashNonce &+= 1
}
private func showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
func showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
self.cameraHUDDismissTask?.cancel()
withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {

View File

@@ -29,7 +29,7 @@ struct RootCanvas: View {
ZStack {
CanvasContent(
systemColorScheme: self.systemColorScheme,
bridgeStatus: self.bridgeStatus,
gatewayStatus: self.gatewayStatus,
voiceWakeEnabled: self.voiceWakeEnabled,
voiceWakeToastText: self.voiceWakeToastText,
cameraHUDText: self.appModel.cameraHUDText,
@@ -52,7 +52,7 @@ struct RootCanvas: View {
SettingsTab()
case .chat:
ChatSheet(
bridge: self.appModel.bridgeSession,
gateway: self.appModel.gatewaySession,
sessionKey: self.appModel.mainSessionKey,
userAccent: self.appModel.seamColor)
}
@@ -62,9 +62,9 @@ struct RootCanvas: View {
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
.onAppear { self.updateCanvasDebugStatus() }
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.bridgeStatusText) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.bridgeServerName) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.bridgeRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
guard let newValue else { return }
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -91,10 +91,10 @@ struct RootCanvas: View {
}
}
private var bridgeStatus: StatusPill.BridgeState {
if self.appModel.bridgeServerName != nil { return .connected }
private var gatewayStatus: StatusPill.GatewayState {
if self.appModel.gatewayServerName != nil { return .connected }
let text = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
if text.localizedCaseInsensitiveContains("connecting") ||
text.localizedCaseInsensitiveContains("reconnecting")
{
@@ -115,8 +115,8 @@ struct RootCanvas: View {
private func updateCanvasDebugStatus() {
self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled)
guard self.canvasDebugStatusEnabled else { return }
let title = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let subtitle = self.appModel.bridgeServerName ?? self.appModel.bridgeRemoteAddress
let title = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
}
}
@@ -126,7 +126,7 @@ private struct CanvasContent: View {
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
var systemColorScheme: ColorScheme
var bridgeStatus: StatusPill.BridgeState
var gatewayStatus: StatusPill.GatewayState
var voiceWakeEnabled: Bool
var voiceWakeToastText: String?
var cameraHUDText: String?
@@ -177,7 +177,7 @@ private struct CanvasContent: View {
}
.overlay(alignment: .topLeading) {
StatusPill(
bridge: self.bridgeStatus,
gateway: self.gatewayStatus,
voiceWakeEnabled: self.voiceWakeEnabled,
activity: self.statusActivity,
brighten: self.brightenButtons,
@@ -208,15 +208,15 @@ private struct CanvasContent: View {
tint: .orange)
}
let bridgeStatus = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let bridgeLower = bridgeStatus.lowercased()
if bridgeLower.contains("repair") {
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayLower = gatewayStatus.lowercased()
if gatewayLower.contains("repair") {
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
}
if bridgeLower.contains("approval") || bridgeLower.contains("pairing") {
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
}
// Avoid duplicating the primary bridge status ("Connecting") in the activity slot.
// Avoid duplicating the primary gateway status ("Connecting") in the activity slot.
if self.appModel.screenRecordActive {
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)

View File

@@ -24,7 +24,7 @@ struct RootTabs: View {
}
.overlay(alignment: .topLeading) {
StatusPill(
bridge: self.bridgeStatus,
gateway: self.gatewayStatus,
voiceWakeEnabled: self.voiceWakeEnabled,
activity: self.statusActivity,
onTap: { self.selectedTab = 2 })
@@ -64,10 +64,10 @@ struct RootTabs: View {
}
}
private var bridgeStatus: StatusPill.BridgeState {
if self.appModel.bridgeServerName != nil { return .connected }
private var gatewayStatus: StatusPill.GatewayState {
if self.appModel.gatewayServerName != nil { return .connected }
let text = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
if text.localizedCaseInsensitiveContains("connecting") ||
text.localizedCaseInsensitiveContains("reconnecting")
{
@@ -90,15 +90,15 @@ struct RootTabs: View {
tint: .orange)
}
let bridgeStatus = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let bridgeLower = bridgeStatus.lowercased()
if bridgeLower.contains("repair") {
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayLower = gatewayStatus.lowercased()
if gatewayLower.contains("repair") {
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
}
if bridgeLower.contains("approval") || bridgeLower.contains("pairing") {
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
}
// Avoid duplicating the primary bridge status ("Connecting") in the activity slot.
// Avoid duplicating the primary gateway status ("Connecting") in the activity slot.
if self.appModel.screenRecordActive {
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)

View File

@@ -15,7 +15,7 @@ extension ConnectStatusStore: @unchecked Sendable {}
struct SettingsTab: View {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager
@Environment(BridgeConnectionController.self) private var bridgeController: BridgeConnectionController
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
@Environment(\.dismiss) private var dismiss
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
@@ -26,17 +26,20 @@ struct SettingsTab: View {
@AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = ClawdbotLocationMode.off.rawValue
@AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
@AppStorage("bridge.lastDiscoveredStableID") private var lastDiscoveredBridgeStableID: String = ""
@AppStorage("bridge.manual.enabled") private var manualBridgeEnabled: Bool = false
@AppStorage("bridge.manual.host") private var manualBridgeHost: String = ""
@AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790
@AppStorage("bridge.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
@AppStorage("gateway.manual.port") private var manualGatewayPort: Int = 18789
@AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true
@AppStorage("gateway.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
@State private var connectStatus = ConnectStatusStore()
@State private var connectingBridgeID: String?
@State private var connectingGatewayID: String?
@State private var localIPAddress: String?
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
@State private var gatewayToken: String = ""
@State private var gatewayPassword: String = ""
var body: some View {
NavigationStack {
@@ -61,12 +64,12 @@ struct SettingsTab: View {
LabeledContent("Model", value: self.modelIdentifier())
}
Section("Bridge") {
LabeledContent("Discovery", value: self.bridgeController.discoveryStatusText)
LabeledContent("Status", value: self.appModel.bridgeStatusText)
if let serverName = self.appModel.bridgeServerName {
Section("Gateway") {
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
LabeledContent("Status", value: self.appModel.gatewayStatusText)
if let serverName = self.appModel.gatewayServerName {
LabeledContent("Server", value: serverName)
if let addr = self.appModel.bridgeRemoteAddress {
if let addr = self.appModel.gatewayRemoteAddress {
let parts = Self.parseHostPort(from: addr)
let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr)
LabeledContent("Address") {
@@ -96,12 +99,12 @@ struct SettingsTab: View {
}
Button("Disconnect", role: .destructive) {
self.appModel.disconnectBridge()
self.appModel.disconnectGateway()
}
self.bridgeList(showing: .availableOnly)
self.gatewayList(showing: .availableOnly)
} else {
self.bridgeList(showing: .all)
self.gatewayList(showing: .all)
}
if let text = self.connectStatus.text {
@@ -111,19 +114,21 @@ struct SettingsTab: View {
}
DisclosureGroup("Advanced") {
Toggle("Use Manual Bridge", isOn: self.$manualBridgeEnabled)
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
TextField("Host", text: self.$manualBridgeHost)
TextField("Host", text: self.$manualGatewayHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Port", value: self.$manualBridgePort, format: .number)
TextField("Port", value: self.$manualGatewayPort, format: .number)
.keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
Button {
Task { await self.connectManual() }
} label: {
if self.connectingBridgeID == "manual" {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
@@ -133,26 +138,32 @@ struct SettingsTab: View {
Text("Connect (Manual)")
}
}
.disabled(self.connectingBridgeID != nil || self.manualBridgeHost
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty || self.manualBridgePort <= 0 || self.manualBridgePort > 65535)
.isEmpty || self.manualGatewayPort <= 0 || self.manualGatewayPort > 65535)
Text(
"Use this when mDNS/Bonjour discovery is blocked. "
+ "The bridge runs on the gateway (default port 18790).")
+ "The gateway WebSocket listens on port 18789 by default.")
.font(.footnote)
.foregroundStyle(.secondary)
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
self.bridgeController.setDiscoveryDebugLoggingEnabled(newValue)
self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
}
NavigationLink("Discovery Logs") {
BridgeDiscoveryDebugLogView()
GatewayDiscoveryDebugLogView()
}
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
TextField("Gateway Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
}
}
@@ -179,7 +190,7 @@ struct SettingsTab: View {
Section("Camera") {
Toggle("Allow Camera", isOn: self.$cameraEnabled)
Text("Allows the bridge to request photos or short video clips (foreground only).")
Text("Allows the gateway to request photos or short video clips (foreground only).")
.font(.footnote)
.foregroundStyle(.secondary)
}
@@ -221,13 +232,30 @@ struct SettingsTab: View {
.onAppear {
self.localIPAddress = Self.primaryIPv4Address()
self.lastLocationModeRaw = self.locationEnabledModeRaw
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedInstanceId.isEmpty {
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
}
}
.onChange(of: self.preferredBridgeStableID) { _, newValue in
.onChange(of: self.preferredGatewayStableID) { _, newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
BridgeSettingsStore.savePreferredBridgeStableID(trimmed)
GatewaySettingsStore.savePreferredGatewayStableID(trimmed)
}
.onChange(of: self.appModel.bridgeServerName) { _, _ in
.onChange(of: self.gatewayToken) { _, newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !instanceId.isEmpty else { return }
GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId)
}
.onChange(of: self.gatewayPassword) { _, newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !instanceId.isEmpty else { return }
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
}
.onChange(of: self.appModel.gatewayServerName) { _, _ in
self.connectStatus.text = nil
}
.onChange(of: self.locationEnabledModeRaw) { _, newValue in
@@ -248,14 +276,14 @@ struct SettingsTab: View {
}
@ViewBuilder
private func bridgeList(showing: BridgeListMode) -> some View {
if self.bridgeController.bridges.isEmpty {
Text("No bridges found yet.")
private func gatewayList(showing: GatewayListMode) -> some View {
if self.gatewayController.gateways.isEmpty {
Text("No gateways found yet.")
.foregroundStyle(.secondary)
} else {
let connectedID = self.appModel.connectedBridgeID
let rows = self.bridgeController.bridges.filter { bridge in
let isConnected = bridge.stableID == connectedID
let connectedID = self.appModel.connectedGatewayID
let rows = self.gatewayController.gateways.filter { gateway in
let isConnected = gateway.stableID == connectedID
switch showing {
case .all:
return true
@@ -265,14 +293,14 @@ struct SettingsTab: View {
}
if rows.isEmpty, showing == .availableOnly {
Text("No other bridges found.")
Text("No other gateways found.")
.foregroundStyle(.secondary)
} else {
ForEach(rows) { bridge in
ForEach(rows) { gateway in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(bridge.name)
let detailLines = self.bridgeDetailLines(bridge)
Text(gateway.name)
let detailLines = self.gatewayDetailLines(gateway)
ForEach(detailLines, id: \.self) { line in
Text(line)
.font(.footnote)
@@ -282,31 +310,27 @@ struct SettingsTab: View {
Spacer()
Button {
Task { await self.connect(bridge) }
Task { await self.connect(gateway) }
} label: {
if self.connectingBridgeID == bridge.id {
if self.connectingGatewayID == gateway.id {
ProgressView()
.progressViewStyle(.circular)
} else {
Text("Connect")
}
}
.disabled(self.connectingBridgeID != nil)
.disabled(self.connectingGatewayID != nil)
}
}
}
}
}
private enum BridgeListMode: Equatable {
private enum GatewayListMode: Equatable {
case all
case availableOnly
}
private func keychainAccount() -> String {
"bridge-token.\(self.instanceId)"
}
private func platformString() -> String {
let v = ProcessInfo.processInfo.operatingSystemVersion
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
@@ -341,228 +365,37 @@ struct SettingsTab: View {
return trimmed.isEmpty ? "unknown" : trimmed
}
private func currentCaps() -> [String] {
var caps = [ClawdbotCapability.canvas.rawValue, ClawdbotCapability.screen.rawValue]
private func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
self.connectingGatewayID = gateway.id
self.manualGatewayEnabled = false
self.preferredGatewayStableID = gateway.stableID
GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID)
self.lastDiscoveredGatewayStableID = gateway.stableID
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
defer { self.connectingGatewayID = nil }
let cameraEnabled =
UserDefaults.standard.object(forKey: "camera.enabled") == nil
? true
: UserDefaults.standard.bool(forKey: "camera.enabled")
if cameraEnabled { caps.append(ClawdbotCapability.camera.rawValue) }
let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey)
if voiceWakeEnabled { caps.append(ClawdbotCapability.voiceWake.rawValue) }
return caps
}
private func currentCommands() -> [String] {
var commands: [String] = [
ClawdbotCanvasCommand.present.rawValue,
ClawdbotCanvasCommand.hide.rawValue,
ClawdbotCanvasCommand.navigate.rawValue,
ClawdbotCanvasCommand.evalJS.rawValue,
ClawdbotCanvasCommand.snapshot.rawValue,
ClawdbotCanvasA2UICommand.push.rawValue,
ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
ClawdbotCanvasA2UICommand.reset.rawValue,
ClawdbotScreenCommand.record.rawValue,
]
let caps = Set(self.currentCaps())
if caps.contains(ClawdbotCapability.camera.rawValue) {
commands.append(ClawdbotCameraCommand.list.rawValue)
commands.append(ClawdbotCameraCommand.snap.rawValue)
commands.append(ClawdbotCameraCommand.clip.rawValue)
}
return commands
}
private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async {
self.connectingBridgeID = bridge.id
self.manualBridgeEnabled = false
self.preferredBridgeStableID = bridge.stableID
BridgeSettingsStore.savePreferredBridgeStableID(bridge.stableID)
self.lastDiscoveredBridgeStableID = bridge.stableID
BridgeSettingsStore.saveLastDiscoveredBridgeStableID(bridge.stableID)
defer { self.connectingBridgeID = nil }
do {
let statusStore = self.connectStatus
let existing = KeychainStore.loadString(
service: "com.clawdbot.bridge",
account: self.keychainAccount())
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
existing :
nil
let hello = BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: existingToken,
platform: self.platformString(),
version: self.appVersion(),
deviceFamily: self.deviceFamily(),
modelIdentifier: self.modelIdentifier(),
caps: self.currentCaps(),
commands: self.currentCommands())
let tlsParams = self.resolveDiscoveredTLSParams(bridge: bridge)
let token = try await BridgeClient().pairAndHello(
endpoint: bridge.endpoint,
hello: hello,
tls: tlsParams,
onStatus: { status in
Task { @MainActor in
statusStore.text = status
}
})
if !token.isEmpty, token != existingToken {
_ = KeychainStore.saveString(
token,
service: "com.clawdbot.bridge",
account: self.keychainAccount())
}
self.appModel.connectToBridge(
endpoint: bridge.endpoint,
bridgeStableID: bridge.stableID,
tls: tlsParams,
hello: BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: token,
platform: self.platformString(),
version: self.appVersion(),
deviceFamily: self.deviceFamily(),
modelIdentifier: self.modelIdentifier(),
caps: self.currentCaps(),
commands: self.currentCommands()))
} catch {
self.connectStatus.text = "Failed: \(error.localizedDescription)"
}
await self.gatewayController.connect(gateway)
}
private func connectManual() async {
let host = self.manualBridgeHost.trimmingCharacters(in: .whitespacesAndNewlines)
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty else {
self.connectStatus.text = "Failed: host required"
return
}
guard self.manualBridgePort > 0, self.manualBridgePort <= 65535 else {
self.connectStatus.text = "Failed: invalid port"
return
}
guard let port = NWEndpoint.Port(rawValue: UInt16(self.manualBridgePort)) else {
guard self.manualGatewayPort > 0, self.manualGatewayPort <= 65535 else {
self.connectStatus.text = "Failed: invalid port"
return
}
self.connectingBridgeID = "manual"
self.manualBridgeEnabled = true
defer { self.connectingBridgeID = nil }
self.connectingGatewayID = "manual"
self.manualGatewayEnabled = true
defer { self.connectingGatewayID = nil }
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
let stableID = BridgeEndpointID.stableID(endpoint)
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
do {
let statusStore = self.connectStatus
let existing = KeychainStore.loadString(
service: "com.clawdbot.bridge",
account: self.keychainAccount())
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
existing :
nil
let hello = BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: existingToken,
platform: self.platformString(),
version: self.appVersion(),
deviceFamily: self.deviceFamily(),
modelIdentifier: self.modelIdentifier(),
caps: self.currentCaps(),
commands: self.currentCommands())
let token = try await BridgeClient().pairAndHello(
endpoint: endpoint,
hello: hello,
tls: tlsParams,
onStatus: { status in
Task { @MainActor in
statusStore.text = status
}
})
if !token.isEmpty, token != existingToken {
_ = KeychainStore.saveString(
token,
service: "com.clawdbot.bridge",
account: self.keychainAccount())
}
self.appModel.connectToBridge(
endpoint: endpoint,
bridgeStableID: stableID,
tls: tlsParams,
hello: BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: token,
platform: self.platformString(),
version: self.appVersion(),
deviceFamily: self.deviceFamily(),
modelIdentifier: self.modelIdentifier(),
caps: self.currentCaps(),
commands: self.currentCommands()))
} catch {
self.connectStatus.text = "Failed: \(error.localizedDescription)"
}
}
private func resolveDiscoveredTLSParams(
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
{
let stableID = bridge.stableID
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
return BridgeTLSParams(
required: true,
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
allowTOFU: stored == nil,
storeKey: stableID)
}
if let stored {
return BridgeTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
}
return nil
}
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
return BridgeTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
}
return BridgeTLSParams(
required: false,
expectedFingerprint: nil,
allowTOFU: true,
storeKey: stableID)
await self.gatewayController.connectManual(
host: host,
port: self.manualGatewayPort,
useTLS: self.manualGatewayTLS)
}
private static func primaryIPv4Address() -> String? {
@@ -611,23 +444,21 @@ struct SettingsTab: View {
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
}
private func bridgeDetailLines(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) -> [String] {
private func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
var lines: [String] = []
if let lanHost = bridge.lanHost { lines.append("LAN: \(lanHost)") }
if let tailnet = bridge.tailnetDns { lines.append("Tailnet: \(tailnet)") }
if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") }
if let tailnet = gateway.tailnetDns { lines.append("Tailnet: \(tailnet)") }
let gatewayPort = bridge.gatewayPort
let bridgePort = bridge.bridgePort
let canvasPort = bridge.canvasPort
if gatewayPort != nil || bridgePort != nil || canvasPort != nil {
let gatewayPort = gateway.gatewayPort
let canvasPort = gateway.canvasPort
if gatewayPort != nil || canvasPort != nil {
let gw = gatewayPort.map(String.init) ?? ""
let br = bridgePort.map(String.init) ?? ""
let canvas = canvasPort.map(String.init) ?? ""
lines.append("Ports: gw \(gw) · bridge \(br) · canvas \(canvas)")
lines.append("Ports: gateway \(gw) · canvas \(canvas)")
}
if lines.isEmpty {
lines.append(bridge.debugID)
lines.append(gateway.debugID)
}
return lines

View File

@@ -42,7 +42,7 @@ struct VoiceWakeWordsSettingsView: View {
}
}
.onChange(of: self.triggerWords) { _, newValue in
// Keep local voice wake responsive even if bridge isn't connected yet.
// Keep local voice wake responsive even if the gateway isn't connected yet.
VoiceWakePreferences.saveTriggerWords(newValue)
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue)

View File

@@ -3,7 +3,7 @@ import SwiftUI
struct StatusPill: View {
@Environment(\.scenePhase) private var scenePhase
enum BridgeState: Equatable {
enum GatewayState: Equatable {
case connected
case connecting
case error
@@ -34,7 +34,7 @@ struct StatusPill: View {
var tint: Color?
}
var bridge: BridgeState
var gateway: GatewayState
var voiceWakeEnabled: Bool
var activity: Activity?
var brighten: Bool = false
@@ -47,12 +47,12 @@ struct StatusPill: View {
HStack(spacing: 10) {
HStack(spacing: 8) {
Circle()
.fill(self.bridge.color)
.fill(self.gateway.color)
.frame(width: 9, height: 9)
.scaleEffect(self.bridge == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0)
.opacity(self.bridge == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0)
.scaleEffect(self.gateway == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0)
.opacity(self.gateway == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0)
Text(self.bridge.title)
Text(self.gateway.title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary)
}
@@ -95,26 +95,26 @@ struct StatusPill: View {
.buttonStyle(.plain)
.accessibilityLabel("Status")
.accessibilityValue(self.accessibilityValue)
.onAppear { self.updatePulse(for: self.bridge, scenePhase: self.scenePhase) }
.onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase) }
.onDisappear { self.pulse = false }
.onChange(of: self.bridge) { _, newValue in
.onChange(of: self.gateway) { _, newValue in
self.updatePulse(for: newValue, scenePhase: self.scenePhase)
}
.onChange(of: self.scenePhase) { _, newValue in
self.updatePulse(for: self.bridge, scenePhase: newValue)
self.updatePulse(for: self.gateway, scenePhase: newValue)
}
.animation(.easeInOut(duration: 0.18), value: self.activity?.title)
}
private var accessibilityValue: String {
if let activity {
return "\(self.bridge.title), \(activity.title)"
return "\(self.gateway.title), \(activity.title)"
}
return "\(self.bridge.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
}
private func updatePulse(for bridge: BridgeState, scenePhase: ScenePhase) {
guard bridge == .connecting, scenePhase == .active else {
private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase) {
guard gateway == .connecting, scenePhase == .active else {
withAnimation(.easeOut(duration: 0.2)) { self.pulse = false }
return
}

View File

@@ -1,5 +1,6 @@
import AVFAudio
import ClawdbotKit
import ClawdbotProtocol
import Foundation
import Observation
import OSLog
@@ -42,15 +43,15 @@ final class TalkModeManager: NSObject {
var pcmPlayer: PCMStreamingAudioPlaying = PCMStreamingAudioPlayer.shared
var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.shared
private var bridge: BridgeSession?
private var gateway: GatewayNodeSession?
private let silenceWindow: TimeInterval = 0.7
private var chatSubscribedSessionKeys = Set<String>()
private let logger = Logger(subsystem: "com.clawdbot", category: "TalkMode")
func attachBridge(_ bridge: BridgeSession) {
self.bridge = bridge
func attachGateway(_ gateway: GatewayNodeSession) {
self.gateway = gateway
}
func updateMainSessionKey(_ sessionKey: String?) {
@@ -232,9 +233,9 @@ final class TalkModeManager: NSObject {
await self.reloadConfig()
let prompt = self.buildPrompt(transcript: transcript)
guard let bridge else {
self.statusText = "Bridge not connected"
self.logger.warning("finalize: bridge not connected")
guard let gateway else {
self.statusText = "Gateway not connected"
self.logger.warning("finalize: gateway not connected")
await self.start()
return
}
@@ -245,9 +246,9 @@ final class TalkModeManager: NSObject {
await self.subscribeChatIfNeeded(sessionKey: sessionKey)
self.logger.info(
"chat.send start sessionKey=\(sessionKey, privacy: .public) chars=\(prompt.count, privacy: .public)")
let runId = try await self.sendChat(prompt, bridge: bridge)
let runId = try await self.sendChat(prompt, gateway: gateway)
self.logger.info("chat.send ok runId=\(runId, privacy: .public)")
let completion = await self.waitForChatCompletion(runId: runId, bridge: bridge, timeoutSeconds: 120)
let completion = await self.waitForChatCompletion(runId: runId, gateway: gateway, timeoutSeconds: 120)
if completion == .timeout {
self.logger.warning(
"chat completion timeout runId=\(runId, privacy: .public); attempting history fallback")
@@ -264,7 +265,7 @@ final class TalkModeManager: NSObject {
}
guard let assistantText = try await self.waitForAssistantText(
bridge: bridge,
gateway: gateway,
since: startedAt,
timeoutSeconds: completion == .final ? 12 : 25)
else {
@@ -286,31 +287,22 @@ final class TalkModeManager: NSObject {
private func subscribeChatIfNeeded(sessionKey: String) async {
let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return }
guard let bridge else { return }
guard let gateway else { return }
guard !self.chatSubscribedSessionKeys.contains(key) else { return }
do {
let payload = "{\"sessionKey\":\"\(key)\"}"
try await bridge.sendEvent(event: "chat.subscribe", payloadJSON: payload)
self.chatSubscribedSessionKeys.insert(key)
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
} catch {
let err = error.localizedDescription
self.logger.warning("chat.subscribe failed key=\(key, privacy: .public) err=\(err, privacy: .public)")
}
let payload = "{\"sessionKey\":\"\(key)\"}"
await gateway.sendEvent(event: "chat.subscribe", payloadJSON: payload)
self.chatSubscribedSessionKeys.insert(key)
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
}
private func unsubscribeAllChats() async {
guard let bridge else { return }
guard let gateway else { return }
let keys = self.chatSubscribedSessionKeys
self.chatSubscribedSessionKeys.removeAll()
for key in keys {
do {
let payload = "{\"sessionKey\":\"\(key)\"}"
try await bridge.sendEvent(event: "chat.unsubscribe", payloadJSON: payload)
} catch {
// ignore
}
let payload = "{\"sessionKey\":\"\(key)\"}"
await gateway.sendEvent(event: "chat.unsubscribe", payloadJSON: payload)
}
}
@@ -336,7 +328,7 @@ final class TalkModeManager: NSObject {
}
}
private func sendChat(_ message: String, bridge: BridgeSession) async throws -> String {
private func sendChat(_ message: String, gateway: GatewayNodeSession) async throws -> String {
struct SendResponse: Decodable { let runId: String }
let payload: [String: Any] = [
"sessionKey": self.mainSessionKey,
@@ -352,26 +344,27 @@ final class TalkModeManager: NSObject {
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload"])
}
let res = try await bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30)
let res = try await gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30)
let decoded = try JSONDecoder().decode(SendResponse.self, from: res)
return decoded.runId
}
private func waitForChatCompletion(
runId: String,
bridge: BridgeSession,
gateway: GatewayNodeSession,
timeoutSeconds: Int = 120) async -> ChatCompletionState
{
let stream = await bridge.subscribeServerEvents(bufferingNewest: 200)
let stream = await gateway.subscribeServerEvents(bufferingNewest: 200)
return await withTaskGroup(of: ChatCompletionState.self) { group in
group.addTask { [runId] in
for await evt in stream {
if Task.isCancelled { return .timeout }
guard evt.event == "chat", let payload = evt.payloadJSON else { continue }
guard let data = payload.data(using: .utf8) else { continue }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue }
if (json["runId"] as? String) != runId { continue }
if let state = json["state"] as? String {
guard evt.event == "chat", let payload = evt.payload else { continue }
guard let chatEvent = try? GatewayPayloadDecoding.decode(payload, as: ChatEvent.self) else {
continue
}
guard chatEvent.runid == runId else { continue }
if let state = chatEvent.state.value as? String {
switch state {
case "final": return .final
case "aborted": return .aborted
@@ -393,13 +386,13 @@ final class TalkModeManager: NSObject {
}
private func waitForAssistantText(
bridge: BridgeSession,
gateway: GatewayNodeSession,
since: Double,
timeoutSeconds: Int) async throws -> String?
{
let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds))
while Date() < deadline {
if let text = try await self.fetchLatestAssistantText(bridge: bridge, since: since) {
if let text = try await self.fetchLatestAssistantText(gateway: gateway, since: since) {
return text
}
try? await Task.sleep(nanoseconds: 300_000_000)
@@ -407,8 +400,8 @@ final class TalkModeManager: NSObject {
return nil
}
private func fetchLatestAssistantText(bridge: BridgeSession, since: Double? = nil) async throws -> String? {
let res = try await bridge.request(
private func fetchLatestAssistantText(gateway: GatewayNodeSession, since: Double? = nil) async throws -> String? {
let res = try await gateway.request(
method: "chat.history",
paramsJSON: "{\"sessionKey\":\"\(self.mainSessionKey)\"}",
timeoutSeconds: 15)
@@ -649,9 +642,9 @@ final class TalkModeManager: NSObject {
}
private func reloadConfig() async {
guard let bridge else { return }
guard let gateway else { return }
do {
let res = try await bridge.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
let res = try await gateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
guard let config = json["config"] as? [String: Any] else { return }
let talk = config["talk"] as? [String: Any]

View File

@@ -1,196 +0,0 @@
import ClawdbotKit
import Foundation
import Network
import Testing
@testable import Clawdbot
@Suite struct BridgeClientTests {
private final class LineServer: @unchecked Sendable {
private let queue = DispatchQueue(label: "com.clawdbot.tests.bridge-client-server")
private let listener: NWListener
private var connection: NWConnection?
private var buffer = Data()
init() throws {
self.listener = try NWListener(using: .tcp, on: .any)
}
func start() async throws -> NWEndpoint.Port {
try await withCheckedThrowingContinuation(isolation: nil) { cont in
self.listener.stateUpdateHandler = { state in
switch state {
case .ready:
if let port = self.listener.port {
cont.resume(returning: port)
} else {
cont.resume(
throwing: NSError(domain: "LineServer", code: 1, userInfo: [
NSLocalizedDescriptionKey: "listener missing port",
]))
}
case let .failed(err):
cont.resume(throwing: err)
default:
break
}
}
self.listener.newConnectionHandler = { [weak self] conn in
guard let self else { return }
self.connection = conn
conn.start(queue: self.queue)
}
self.listener.start(queue: self.queue)
}
}
func stop() {
self.connection?.cancel()
self.connection = nil
self.listener.cancel()
}
func waitForConnection(timeoutMs: Int = 2000) async throws -> NWConnection {
let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0)
while Date() < deadline {
if let connection = self.connection { return connection }
try await Task.sleep(nanoseconds: 10_000_000)
}
throw NSError(domain: "LineServer", code: 2, userInfo: [
NSLocalizedDescriptionKey: "timed out waiting for connection",
])
}
func receiveLine(timeoutMs: Int = 2000) async throws -> Data? {
let connection = try await self.waitForConnection(timeoutMs: timeoutMs)
let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0)
while Date() < deadline {
if let idx = self.buffer.firstIndex(of: 0x0A) {
let line = self.buffer.prefix(upTo: idx)
self.buffer.removeSubrange(...idx)
return Data(line)
}
let chunk = try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<
Data,
Error,
>) in
connection
.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
if let error {
cont.resume(throwing: error)
return
}
if isComplete {
cont.resume(returning: Data())
return
}
cont.resume(returning: data ?? Data())
}
}
if chunk.isEmpty { return nil }
self.buffer.append(chunk)
}
throw NSError(domain: "LineServer", code: 3, userInfo: [
NSLocalizedDescriptionKey: "timed out waiting for line",
])
}
func sendLine(_ line: String) async throws {
let connection = try await self.waitForConnection()
var data = Data(line.utf8)
data.append(0x0A)
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
connection.send(content: data, completion: .contentProcessed { err in
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
})
}
}
}
@Test func helloOkReturnsExistingToken() async throws {
let server = try LineServer()
let port = try await server.start()
defer { server.stop() }
let serverTask = Task {
let line = try await server.receiveLine()
#expect(line != nil)
_ = try JSONDecoder().decode(BridgeHello.self, from: line ?? Data())
try await server.sendLine(#"{"type":"hello-ok","serverName":"Test Gateway"}"#)
}
defer { serverTask.cancel() }
let client = BridgeClient()
let token = try await client.pairAndHello(
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: port),
hello: BridgeHello(
nodeId: "ios-node",
displayName: "iOS",
token: "existing-token",
platform: "ios",
version: "1"),
onStatus: nil)
#expect(token == "existing-token")
_ = try await serverTask.value
}
@Test func notPairedTriggersPairRequestAndReturnsToken() async throws {
let server = try LineServer()
let port = try await server.start()
defer { server.stop() }
let serverTask = Task {
let helloLine = try await server.receiveLine()
#expect(helloLine != nil)
_ = try JSONDecoder().decode(BridgeHello.self, from: helloLine ?? Data())
try await server.sendLine(#"{"type":"error","code":"NOT_PAIRED","message":"not paired"}"#)
let pairLine = try await server.receiveLine()
#expect(pairLine != nil)
_ = try JSONDecoder().decode(BridgePairRequest.self, from: pairLine ?? Data())
try await server.sendLine(#"{"type":"pair-ok","token":"paired-token"}"#)
}
defer { serverTask.cancel() }
let client = BridgeClient()
let token = try await client.pairAndHello(
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: port),
hello: BridgeHello(nodeId: "ios-node", displayName: "iOS", token: nil, platform: "ios", version: "1"),
onStatus: nil)
#expect(token == "paired-token")
_ = try await serverTask.value
}
@Test func unexpectedErrorIsSurfaced() async {
do {
let server = try LineServer()
let port = try await server.start()
defer { server.stop() }
let serverTask = Task {
let helloLine = try await server.receiveLine()
#expect(helloLine != nil)
_ = try JSONDecoder().decode(BridgeHello.self, from: helloLine ?? Data())
try await server.sendLine(#"{"type":"error","code":"NOPE","message":"nope"}"#)
}
defer { serverTask.cancel() }
let client = BridgeClient()
_ = try await client.pairAndHello(
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: port),
hello: BridgeHello(nodeId: "ios-node", displayName: "iOS", token: nil, platform: "ios", version: "1"),
onStatus: nil)
Issue.record("Expected pairAndHello to throw for unexpected error code")
} catch {
#expect(error.localizedDescription.contains("NOPE"))
}
}
}

View File

@@ -1,347 +0,0 @@
import ClawdbotKit
import Foundation
import Network
import Testing
import UIKit
@testable import Clawdbot
private struct KeychainEntry: Hashable {
let service: String
let account: String
}
private let bridgeService = "com.clawdbot.bridge"
private let nodeService = "com.clawdbot.node"
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID")
private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID")
private actor MockBridgePairingClient: BridgePairingClient {
private(set) var lastToken: String?
private let resultToken: String
init(resultToken: String) {
self.resultToken = resultToken
}
func pairAndHello(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: BridgeTLSParams?,
onStatus: (@Sendable (String) -> Void)?) async throws -> String
{
self.lastToken = hello.token
onStatus?("Testing…")
return self.resultToken
}
}
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
let defaults = UserDefaults.standard
var snapshot: [String: Any?] = [:]
for key in updates.keys {
snapshot[key] = defaults.object(forKey: key)
}
for (key, value) in updates {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
defer {
for (key, value) in snapshot {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
return try body()
}
@MainActor
private func withUserDefaults<T>(
_ updates: [String: Any?],
_ body: () async throws -> T) async rethrows -> T
{
let defaults = UserDefaults.standard
var snapshot: [String: Any?] = [:]
for key in updates.keys {
snapshot[key] = defaults.object(forKey: key)
}
for (key, value) in updates {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
defer {
for (key, value) in snapshot {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
return try await body()
}
private func withKeychainValues<T>(_ updates: [KeychainEntry: String?], _ body: () throws -> T) rethrows -> T {
var snapshot: [KeychainEntry: String?] = [:]
for entry in updates.keys {
snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account)
}
for (entry, value) in updates {
if let value {
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
} else {
_ = KeychainStore.delete(service: entry.service, account: entry.account)
}
}
defer {
for (entry, value) in snapshot {
if let value {
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
} else {
_ = KeychainStore.delete(service: entry.service, account: entry.account)
}
}
}
return try body()
}
@MainActor
private func withKeychainValues<T>(
_ updates: [KeychainEntry: String?],
_ body: () async throws -> T) async rethrows -> T
{
var snapshot: [KeychainEntry: String?] = [:]
for entry in updates.keys {
snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account)
}
for (entry, value) in updates {
if let value {
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
} else {
_ = KeychainStore.delete(service: entry.service, account: entry.account)
}
}
defer {
for (entry, value) in snapshot {
if let value {
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
} else {
_ = KeychainStore.delete(service: entry.service, account: entry.account)
}
}
}
return try await body()
}
@Suite(.serialized) struct BridgeConnectionControllerTests {
@Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() {
let defaults = UserDefaults.standard
let displayKey = "node.displayName"
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
withUserDefaults([displayKey: nil, "node.instanceId": "ios-test"]) {
let appModel = NodeAppModel()
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
let resolved = controller._test_resolvedDisplayName(defaults: defaults)
#expect(!resolved.isEmpty)
#expect(defaults.string(forKey: displayKey) == resolved)
}
}
}
@Test @MainActor func resolvedDisplayNamePreservesCustomValue() {
let defaults = UserDefaults.standard
let displayKey = "node.displayName"
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
withUserDefaults([displayKey: "My iOS Node", "node.instanceId": "ios-test"]) {
let appModel = NodeAppModel()
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
let resolved = controller._test_resolvedDisplayName(defaults: defaults)
#expect(resolved == "My iOS Node")
#expect(defaults.string(forKey: displayKey) == "My iOS Node")
}
}
}
@Test @MainActor func makeHelloBuildsCapsAndCommands() {
let voiceWakeKey = VoiceWakePreferences.enabledKey
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
withUserDefaults([
"node.instanceId": "ios-test",
"node.displayName": "Test Node",
"camera.enabled": false,
voiceWakeKey: true,
]) {
let appModel = NodeAppModel()
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
let hello = controller._test_makeHello(token: "token-123")
#expect(hello.nodeId == "ios-test")
#expect(hello.displayName == "Test Node")
#expect(hello.token == "token-123")
let caps = Set(hello.caps ?? [])
#expect(caps.contains(ClawdbotCapability.canvas.rawValue))
#expect(caps.contains(ClawdbotCapability.screen.rawValue))
#expect(caps.contains(ClawdbotCapability.voiceWake.rawValue))
#expect(!caps.contains(ClawdbotCapability.camera.rawValue))
let commands = Set(hello.commands ?? [])
#expect(commands.contains(ClawdbotCanvasCommand.present.rawValue))
#expect(commands.contains(ClawdbotScreenCommand.record.rawValue))
#expect(!commands.contains(ClawdbotCameraCommand.snap.rawValue))
#expect(!(hello.platform ?? "").isEmpty)
#expect(!(hello.deviceFamily ?? "").isEmpty)
#expect(!(hello.modelIdentifier ?? "").isEmpty)
#expect(!(hello.version ?? "").isEmpty)
}
}
}
@Test @MainActor func makeHelloIncludesCameraCommandsWhenEnabled() {
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
withUserDefaults([
"node.instanceId": "ios-test",
"node.displayName": "Test Node",
"camera.enabled": true,
VoiceWakePreferences.enabledKey: false,
]) {
let appModel = NodeAppModel()
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
let hello = controller._test_makeHello(token: "token-456")
let caps = Set(hello.caps ?? [])
#expect(caps.contains(ClawdbotCapability.camera.rawValue))
let commands = Set(hello.commands ?? [])
#expect(commands.contains(ClawdbotCameraCommand.snap.rawValue))
#expect(commands.contains(ClawdbotCameraCommand.clip.rawValue))
}
}
}
@Test @MainActor func autoConnectRefreshesTokenOnUnauthorized() async {
let bridge = BridgeDiscoveryModel.DiscoveredBridge(
name: "Gateway",
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 18790),
stableID: "bridge-1",
debugID: "bridge-debug",
lanHost: "Mac.local",
tailnetDns: nil,
gatewayPort: 18789,
bridgePort: 18790,
canvasPort: 18793,
tlsEnabled: false,
tlsFingerprintSha256: nil,
cliPath: nil)
let mock = MockBridgePairingClient(resultToken: "new-token")
let account = "bridge-token.ios-test"
await withKeychainValues([
instanceIdEntry: nil,
preferredBridgeEntry: nil,
lastBridgeEntry: nil,
KeychainEntry(service: bridgeService, account: account): "old-token",
]) {
await withUserDefaults([
"node.instanceId": "ios-test",
"bridge.lastDiscoveredStableID": "bridge-1",
"bridge.manual.enabled": false,
]) {
let appModel = NodeAppModel()
let controller = BridgeConnectionController(
appModel: appModel,
startDiscovery: false,
bridgeClientFactory: { mock })
controller._test_setBridges([bridge])
controller._test_triggerAutoConnect()
for _ in 0..<20 {
if appModel.connectedBridgeID == bridge.stableID { break }
try? await Task.sleep(nanoseconds: 50_000_000)
}
#expect(appModel.connectedBridgeID == bridge.stableID)
let stored = KeychainStore.loadString(service: bridgeService, account: account)
#expect(stored == "new-token")
let lastToken = await mock.lastToken
#expect(lastToken == "old-token")
}
}
}
@Test @MainActor func autoConnectPrefersPreferredBridgeOverLastDiscovered() async {
let bridgeA = BridgeDiscoveryModel.DiscoveredBridge(
name: "Gateway A",
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 18790),
stableID: "bridge-1",
debugID: "bridge-a",
lanHost: "MacA.local",
tailnetDns: nil,
gatewayPort: 18789,
bridgePort: 18790,
canvasPort: 18793,
tlsEnabled: false,
tlsFingerprintSha256: nil,
cliPath: nil)
let bridgeB = BridgeDiscoveryModel.DiscoveredBridge(
name: "Gateway B",
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 28790),
stableID: "bridge-2",
debugID: "bridge-b",
lanHost: "MacB.local",
tailnetDns: nil,
gatewayPort: 28789,
bridgePort: 28790,
canvasPort: 28793,
tlsEnabled: false,
tlsFingerprintSha256: nil,
cliPath: nil)
let mock = MockBridgePairingClient(resultToken: "token-ok")
let account = "bridge-token.ios-test"
await withKeychainValues([
instanceIdEntry: nil,
preferredBridgeEntry: nil,
lastBridgeEntry: nil,
KeychainEntry(service: bridgeService, account: account): "old-token",
]) {
await withUserDefaults([
"node.instanceId": "ios-test",
"bridge.preferredStableID": "bridge-2",
"bridge.lastDiscoveredStableID": "bridge-1",
"bridge.manual.enabled": false,
]) {
let appModel = NodeAppModel()
let controller = BridgeConnectionController(
appModel: appModel,
startDiscovery: false,
bridgeClientFactory: { mock })
controller._test_setBridges([bridgeA, bridgeB])
controller._test_triggerAutoConnect()
for _ in 0..<20 {
if appModel.connectedBridgeID == bridgeB.stableID { break }
try? await Task.sleep(nanoseconds: 50_000_000)
}
#expect(appModel.connectedBridgeID == bridgeB.stableID)
}
}
}
}

View File

@@ -1,48 +0,0 @@
import Foundation
import Testing
@testable import Clawdbot
@Suite struct BridgeSessionTests {
@Test func initialStateIsIdle() async {
let session = BridgeSession()
#expect(await session.state == .idle)
}
@Test func requestFailsWhenNotConnected() async {
let session = BridgeSession()
do {
_ = try await session.request(method: "health", paramsJSON: nil, timeoutSeconds: 1)
Issue.record("Expected request to throw when not connected")
} catch let error as NSError {
#expect(error.domain == "Bridge")
#expect(error.code == 11)
}
}
@Test func sendEventFailsWhenNotConnected() async {
let session = BridgeSession()
do {
try await session.sendEvent(event: "tick", payloadJSON: nil)
Issue.record("Expected sendEvent to throw when not connected")
} catch let error as NSError {
#expect(error.domain == "Bridge")
#expect(error.code == 10)
}
}
@Test func disconnectFinishesServerEventStreams() async throws {
let session = BridgeSession()
let stream = await session.subscribeServerEvents(bufferingNewest: 1)
let consumer = Task { @Sendable in
for await _ in stream {}
}
await session.disconnect()
_ = await consumer.result
#expect(await session.state == .idle)
}
}

View File

@@ -0,0 +1,79 @@
import ClawdbotKit
import Foundation
import Testing
import UIKit
@testable import Clawdbot
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
let defaults = UserDefaults.standard
var snapshot: [String: Any?] = [:]
for key in updates.keys {
snapshot[key] = defaults.object(forKey: key)
}
for (key, value) in updates {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
defer {
for (key, value) in snapshot {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
return try body()
}
@Suite(.serialized) struct GatewayConnectionControllerTests {
@Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() {
let defaults = UserDefaults.standard
let displayKey = "node.displayName"
withUserDefaults([displayKey: nil, "node.instanceId": "ios-test"]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let resolved = controller._test_resolvedDisplayName(defaults: defaults)
#expect(!resolved.isEmpty)
#expect(defaults.string(forKey: displayKey) == resolved)
}
}
@Test @MainActor func currentCapsReflectToggles() {
withUserDefaults([
"node.instanceId": "ios-test",
"node.displayName": "Test Node",
"camera.enabled": true,
"location.enabledMode": ClawdbotLocationMode.always.rawValue,
VoiceWakePreferences.enabledKey: true,
]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let caps = Set(controller._test_currentCaps())
#expect(caps.contains(ClawdbotCapability.canvas.rawValue))
#expect(caps.contains(ClawdbotCapability.screen.rawValue))
#expect(caps.contains(ClawdbotCapability.camera.rawValue))
#expect(caps.contains(ClawdbotCapability.location.rawValue))
#expect(caps.contains(ClawdbotCapability.voiceWake.rawValue))
}
}
@Test @MainActor func currentCommandsIncludeLocationWhenEnabled() {
withUserDefaults([
"node.instanceId": "ios-test",
"location.enabledMode": ClawdbotLocationMode.whileUsing.rawValue,
]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let commands = Set(controller._test_currentCommands())
#expect(commands.contains(ClawdbotLocationCommand.get.rawValue))
}
}
}

View File

@@ -1,9 +1,9 @@
import Testing
@testable import Clawdbot
@Suite(.serialized) struct BridgeDiscoveryModelTests {
@Suite(.serialized) struct GatewayDiscoveryModelTests {
@Test @MainActor func debugLoggingCapturesLifecycleAndResets() {
let model = BridgeDiscoveryModel()
let model = GatewayDiscoveryModel()
#expect(model.debugLog.isEmpty)
#expect(model.statusText == "Idle")
@@ -13,7 +13,7 @@ import Testing
model.stop()
#expect(model.statusText == "Stopped")
#expect(model.bridges.isEmpty)
#expect(model.gateways.isEmpty)
#expect(model.debugLog.count >= 3)
model.setDebugLoggingEnabled(false)

View File

@@ -3,30 +3,30 @@ import Network
import Testing
@testable import Clawdbot
@Suite struct BridgeEndpointIDTests {
@Suite struct GatewayEndpointIDTests {
@Test func stableIDForServiceDecodesAndNormalizesName() {
let endpoint = NWEndpoint.service(
name: "Clawdbot\\032Bridge \\032 Node\n",
type: "_clawdbot-bridge._tcp",
name: "Clawdbot\\032Gateway \\032 Node\n",
type: "_clawdbot-gateway._tcp",
domain: "local.",
interface: nil)
#expect(BridgeEndpointID.stableID(endpoint) == "_clawdbot-bridge._tcp|local.|Clawdbot Bridge Node")
#expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gateway._tcp|local.|Clawdbot Gateway Node")
}
@Test func stableIDForNonServiceUsesEndpointDescription() {
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 4242)
#expect(BridgeEndpointID.stableID(endpoint) == String(describing: endpoint))
#expect(GatewayEndpointID.stableID(endpoint) == String(describing: endpoint))
}
@Test func prettyDescriptionDecodesBonjourEscapes() {
let endpoint = NWEndpoint.service(
name: "Clawdbot\\032Bridge",
type: "_clawdbot-bridge._tcp",
name: "Clawdbot\\032Gateway",
type: "_clawdbot-gateway._tcp",
domain: "local.",
interface: nil)
let pretty = BridgeEndpointID.prettyDescription(endpoint)
let pretty = GatewayEndpointID.prettyDescription(endpoint)
#expect(pretty == BonjourEscapes.decode(String(describing: endpoint)))
#expect(!pretty.localizedCaseInsensitiveContains("\\032"))
}

View File

@@ -7,11 +7,11 @@ private struct KeychainEntry: Hashable {
let account: String
}
private let bridgeService = "com.clawdbot.bridge"
private let gatewayService = "com.clawdbot.gateway"
private let nodeService = "com.clawdbot.node"
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID")
private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID")
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")
private func snapshotDefaults(_ keys: [String]) -> [String: Any?] {
let defaults = UserDefaults.standard
@@ -59,14 +59,14 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
applyKeychain(snapshot)
}
@Suite(.serialized) struct BridgeSettingsStoreTests {
@Suite(.serialized) struct GatewaySettingsStoreTests {
@Test func bootstrapCopiesDefaultsToKeychainWhenMissing() {
let defaultsKeys = [
"node.instanceId",
"bridge.preferredStableID",
"bridge.lastDiscoveredStableID",
"gateway.preferredStableID",
"gateway.lastDiscoveredStableID",
]
let entries = [instanceIdEntry, preferredBridgeEntry, lastBridgeEntry]
let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
let defaultsSnapshot = snapshotDefaults(defaultsKeys)
let keychainSnapshot = snapshotKeychain(entries)
defer {
@@ -76,29 +76,29 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
applyDefaults([
"node.instanceId": "node-test",
"bridge.preferredStableID": "preferred-test",
"bridge.lastDiscoveredStableID": "last-test",
"gateway.preferredStableID": "preferred-test",
"gateway.lastDiscoveredStableID": "last-test",
])
applyKeychain([
instanceIdEntry: nil,
preferredBridgeEntry: nil,
lastBridgeEntry: nil,
preferredGatewayEntry: nil,
lastGatewayEntry: nil,
])
BridgeSettingsStore.bootstrapPersistence()
GatewaySettingsStore.bootstrapPersistence()
#expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test")
#expect(KeychainStore.loadString(service: bridgeService, account: "preferredStableID") == "preferred-test")
#expect(KeychainStore.loadString(service: bridgeService, account: "lastDiscoveredStableID") == "last-test")
#expect(KeychainStore.loadString(service: gatewayService, account: "preferredStableID") == "preferred-test")
#expect(KeychainStore.loadString(service: gatewayService, account: "lastDiscoveredStableID") == "last-test")
}
@Test func bootstrapCopiesKeychainToDefaultsWhenMissing() {
let defaultsKeys = [
"node.instanceId",
"bridge.preferredStableID",
"bridge.lastDiscoveredStableID",
"gateway.preferredStableID",
"gateway.lastDiscoveredStableID",
]
let entries = [instanceIdEntry, preferredBridgeEntry, lastBridgeEntry]
let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
let defaultsSnapshot = snapshotDefaults(defaultsKeys)
let keychainSnapshot = snapshotKeychain(entries)
defer {
@@ -108,20 +108,20 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
applyDefaults([
"node.instanceId": nil,
"bridge.preferredStableID": nil,
"bridge.lastDiscoveredStableID": nil,
"gateway.preferredStableID": nil,
"gateway.lastDiscoveredStableID": nil,
])
applyKeychain([
instanceIdEntry: "node-from-keychain",
preferredBridgeEntry: "preferred-from-keychain",
lastBridgeEntry: "last-from-keychain",
preferredGatewayEntry: "preferred-from-keychain",
lastGatewayEntry: "last-from-keychain",
])
BridgeSettingsStore.bootstrapPersistence()
GatewaySettingsStore.bootstrapPersistence()
let defaults = UserDefaults.standard
#expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain")
#expect(defaults.string(forKey: "bridge.preferredStableID") == "preferred-from-keychain")
#expect(defaults.string(forKey: "bridge.lastDiscoveredStableID") == "last-from-keychain")
#expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain")
#expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain")
}
}

View File

@@ -1,19 +1,15 @@
import ClawdbotKit
import Testing
@testable import Clawdbot
@Suite struct IOSBridgeChatTransportTests {
@Test func requestsFailFastWhenBridgeNotConnected() async {
let bridge = BridgeSession()
let transport = IOSBridgeChatTransport(bridge: bridge)
do {
try await transport.setActiveSessionKey("node-test")
Issue.record("Expected setActiveSessionKey to throw when bridge not connected")
} catch {}
@Suite struct IOSGatewayChatTransportTests {
@Test func requestsFailFastWhenGatewayNotConnected() async {
let gateway = GatewayNodeSession()
let transport = IOSGatewayChatTransport(gateway: gateway)
do {
_ = try await transport.requestHistory(sessionKey: "node-test")
Issue.record("Expected requestHistory to throw when bridge not connected")
Issue.record("Expected requestHistory to throw when gateway not connected")
} catch {}
do {
@@ -23,11 +19,12 @@ import Testing
thinking: "low",
idempotencyKey: "idempotency",
attachments: [])
Issue.record("Expected sendMessage to throw when bridge not connected")
Issue.record("Expected sendMessage to throw when gateway not connected")
} catch {}
do {
_ = try await transport.requestHealth(timeoutMs: 250)
Issue.record("Expected requestHealth to throw when gateway not connected")
} catch {}
}
}

View File

@@ -159,7 +159,7 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
let appModel = NodeAppModel()
let url = URL(string: "clawdbot://agent?message=hello")!
await appModel.handleDeepLink(url: url)
#expect(appModel.screen.errorText?.contains("Bridge not connected") == true)
#expect(appModel.screen.errorText?.contains("Gateway not connected") == true)
}
@Test @MainActor func handleDeepLinkRejectsOversizedMessage() async {
@@ -170,7 +170,7 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
#expect(appModel.screen.errorText?.contains("Deep link too large") == true)
}
@Test @MainActor func sendVoiceTranscriptThrowsWhenBridgeOffline() async {
@Test @MainActor func sendVoiceTranscriptThrowsWhenGatewayOffline() async {
let appModel = NodeAppModel()
await #expect(throws: Error.self) {
try await appModel.sendVoiceTranscript(text: "hello", sessionKey: "main")

View File

@@ -1,3 +1,4 @@
import ClawdbotKit
import SwiftUI
import Testing
import UIKit
@@ -14,35 +15,35 @@ import UIKit
}
@Test @MainActor func statusPillConnectingBuildsAViewHierarchy() {
let root = StatusPill(bridge: .connecting, voiceWakeEnabled: true, brighten: true) {}
let root = StatusPill(gateway: .connecting, voiceWakeEnabled: true, brighten: true) {}
_ = Self.host(root)
}
@Test @MainActor func statusPillDisconnectedBuildsAViewHierarchy() {
let root = StatusPill(bridge: .disconnected, voiceWakeEnabled: false) {}
let root = StatusPill(gateway: .disconnected, voiceWakeEnabled: false) {}
_ = Self.host(root)
}
@Test @MainActor func settingsTabBuildsAViewHierarchy() {
let appModel = NodeAppModel()
let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false)
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let root = SettingsTab()
.environment(appModel)
.environment(appModel.voiceWake)
.environment(bridgeController)
.environment(gatewayController)
_ = Self.host(root)
}
@Test @MainActor func rootTabsBuildAViewHierarchy() {
let appModel = NodeAppModel()
let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false)
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let root = RootTabs()
.environment(appModel)
.environment(appModel.voiceWake)
.environment(bridgeController)
.environment(gatewayController)
_ = Self.host(root)
}
@@ -66,8 +67,8 @@ import UIKit
@Test @MainActor func chatSheetBuildsAViewHierarchy() {
let appModel = NodeAppModel()
let bridge = BridgeSession()
let root = ChatSheet(bridge: bridge, sessionKey: "test")
let gateway = GatewayNodeSession()
let root = ChatSheet(gateway: gateway, sessionKey: "test")
.environment(appModel)
.environment(appModel.voiceWake)
_ = Self.host(root)

View File

@@ -35,6 +35,8 @@ targets:
- package: ClawdbotKit
- package: ClawdbotKit
product: ClawdbotChatUI
- package: ClawdbotKit
product: ClawdbotProtocol
- package: Swabble
product: SwabbleKit
- sdk: AppIntents.framework
@@ -86,12 +88,12 @@ targets:
UIApplicationSupportsMultipleScenes: false
UIBackgroundModes:
- audio
NSLocalNetworkUsageDescription: Clawdbot discovers and connects to your Clawdbot bridge on the local network.
NSLocalNetworkUsageDescription: Clawdbot discovers and connects to your Clawdbot gateway on the local network.
NSAppTransportSecurity:
NSAllowsArbitraryLoadsInWebContent: true
NSBonjourServices:
- _clawdbot-bridge._tcp
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the bridge.
- _clawdbot-gateway._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.
NSMicrophoneUsageDescription: Clawdbot needs microphone access for voice wake.

View File

@@ -1,24 +1,33 @@
{
"originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b",
"originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438",
"pins" : [
{
"identity" : "axorcist",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/AXorcist.git",
"state" : {
"revision" : "c75d06f7f93e264a9786edc2b78c04973061cb2f",
"version" : "0.1.0"
}
},
{
"identity" : "commander",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander.git",
"state" : {
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
"version" : "0.2.1"
}
},
{
"identity" : "elevenlabskit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit",
"state" : {
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
"version" : "0.1.0"
}
},
{
"identity" : "eventsource",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mattt/eventsource.git",
"state" : {
"revision" : "ca2a9d90cbe49e09b92f4b6ebd922c03ebea51d0",
"version" : "1.3.0"
}
},
{
"identity" : "menubarextraaccess",
"kind" : "remoteSourceControl",
@@ -28,6 +37,15 @@
"version" : "1.2.2"
}
},
{
"identity" : "peekaboo",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Peekaboo.git",
"state" : {
"branch" : "main",
"revision" : "bace59f90bb276f1c6fb613acfda3935ec4a7a90"
}
},
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
@@ -46,33 +64,6 @@
"version" : "1.2.1"
}
},
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "810496cf121e525d660cd0ea89a758740476b85f",
"version" : "1.5.1"
}
},
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms",
"state" : {
"revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804",
"version" : "1.1.1"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections",
"state" : {
"branch" : "main",
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
}
},
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
@@ -82,24 +73,6 @@
"version" : "1.3.2"
}
},
{
"identity" : "swift-configuration",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-configuration",
"state" : {
"revision" : "3528deb75256d7dcbb0d71fa75077caae0a8c749",
"version" : "1.0.0"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095",
"version" : "4.2.0"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
@@ -118,24 +91,6 @@
"version" : "1.1.1"
}
},
{
"identity" : "swift-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/modelcontextprotocol/swift-sdk.git",
"state" : {
"revision" : "c0407a0b52677cb395d824cac2879b963075ba8c",
"version" : "0.10.2"
}
},
{
"identity" : "swift-service-lifecycle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/swift-service-lifecycle",
"state" : {
"revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348",
"version" : "2.9.1"
}
},
{
"identity" : "swift-subprocess",
"kind" : "remoteSourceControl",

View File

@@ -20,19 +20,11 @@ let package = Package(
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
.package(url: "https://github.com/steipete/Peekaboo.git", branch: "main"),
.package(path: "../shared/ClawdbotKit"),
.package(path: "../../Swabble"),
.package(path: "../../Peekaboo/Core/PeekabooCore"),
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
],
targets: [
.target(
name: "ClawdbotProtocol",
dependencies: [],
path: "Sources/ClawdbotProtocol",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.target(
name: "ClawdbotIPC",
dependencies: [],
@@ -53,16 +45,16 @@ let package = Package(
dependencies: [
"ClawdbotIPC",
"ClawdbotDiscovery",
"ClawdbotProtocol",
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
.product(name: "ClawdbotChatUI", package: "ClawdbotKit"),
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
.product(name: "SwabbleKit", package: "swabble"),
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
.product(name: "Subprocess", package: "swift-subprocess"),
.product(name: "Logging", package: "swift-log"),
.product(name: "Sparkle", package: "Sparkle"),
.product(name: "PeekabooBridge", package: "PeekabooCore"),
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
.product(name: "PeekabooBridge", package: "Peekaboo"),
.product(name: "PeekabooAutomationKit", package: "Peekaboo"),
],
exclude: [
"Resources/Info.plist",
@@ -86,7 +78,7 @@ let package = Package(
.executableTarget(
name: "ClawdbotWizardCLI",
dependencies: [
"ClawdbotProtocol",
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
],
path: "Sources/ClawdbotWizardCLI",
swiftSettings: [
@@ -98,7 +90,7 @@ let package = Package(
"ClawdbotIPC",
"Clawdbot",
"ClawdbotDiscovery",
"ClawdbotProtocol",
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
.product(name: "SwabbleKit", package: "swabble"),
],
swiftSettings: [

64
apps/macos/README.md Normal file
View File

@@ -0,0 +1,64 @@
# Clawdbot macOS app (dev + signing)
## Quick dev run
```bash
# from repo root
scripts/restart-mac.sh
```
Options:
```bash
scripts/restart-mac.sh --no-sign # fastest dev; ad-hoc signing (TCC permissions do not stick)
scripts/restart-mac.sh --sign # force code signing (requires cert)
```
## Packaging flow
```bash
scripts/package-mac-app.sh
```
Creates `dist/Clawdbot.app` and signs it via `scripts/codesign-mac-app.sh`.
## Signing behavior
Auto-selects identity (first match):
1) Developer ID Application
2) Apple Distribution
3) Apple Development
4) first available identity
If none found:
- errors by default
- set `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` to ad-hoc sign
## Team ID audit (Sparkle mismatch guard)
After signing, we read the app bundle Team ID and compare every Mach-O inside the app.
If any embedded binary has a different Team ID, signing fails.
Skip the audit:
```bash
SKIP_TEAM_ID_CHECK=1 scripts/package-mac-app.sh
```
## Library validation workaround (dev only)
If Sparkle Team ID mismatch blocks loading (common with Apple Development certs), opt in:
```bash
DISABLE_LIBRARY_VALIDATION=1 scripts/package-mac-app.sh
```
This adds `com.apple.security.cs.disable-library-validation` to app entitlements.
Use for local dev only; keep off for release builds.
## Useful env flags
- `SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"`
- `ALLOW_ADHOC_SIGNING=1` (ad-hoc, TCC permissions do not persist)
- `CODESIGN_TIMESTAMP=off` (offline debug)
- `DISABLE_LIBRARY_VALIDATION=1` (dev-only Sparkle workaround)
- `SKIP_TEAM_ID_CHECK=1` (bypass audit)

View File

@@ -170,8 +170,15 @@ final class AppState {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
}
var systemRunPolicy: SystemRunPolicy {
didSet { self.ifNotPreview { MacNodeConfigFile.setSystemRunPolicy(self.systemRunPolicy) } }
var execApprovalMode: ExecApprovalQuickMode {
didSet {
self.ifNotPreview {
ExecApprovalsStore.updateDefaults { defaults in
defaults.security = self.execApprovalMode.security
defaults.ask = self.execApprovalMode.ask
}
}
}
}
/// Tracks whether the Canvas panel is currently visible (not persisted).
@@ -257,30 +264,8 @@ final class AppState {
let configRoot = ClawdbotConfigFile.loadDict()
let configGateway = configRoot["gateway"] as? [String: Any]
let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let configMode: ConnectionMode? = switch configModeRaw {
case "local":
.local
case "remote":
.remote
default:
nil
}
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
let configHasRemoteUrl = !(configRemoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
let resolvedConnectionMode: ConnectionMode = if let configMode {
configMode
} else if configHasRemoteUrl {
.remote
} else if let storedMode {
ConnectionMode(rawValue: storedMode) ?? .local
} else {
onboardingSeen ? .local : .unconfigured
}
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
self.connectionMode = resolvedConnectionMode
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
@@ -296,7 +281,8 @@ final class AppState {
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
self.systemRunPolicy = SystemRunPolicy.load()
let execDefaults = ExecApprovalsStore.resolveDefaults()
self.execApprovalMode = ExecApprovalQuickMode.from(security: execDefaults.security, ask: execDefaults.ask)
self.peekabooBridgeEnabled = UserDefaults.standard
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
if !self.isPreview {

View File

@@ -1,454 +0,0 @@
import ClawdbotKit
import Foundation
import Network
import OSLog
struct BridgeNodeInfo: Sendable {
var nodeId: String
var displayName: String?
var platform: String?
var version: String?
var deviceFamily: String?
var modelIdentifier: String?
var remoteAddress: String?
var caps: [String]?
}
actor BridgeConnectionHandler {
private let connection: NWConnection
private let logger: Logger
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private let queue = DispatchQueue(label: "com.clawdbot.bridge.connection")
private var buffer = Data()
private var isAuthenticated = false
private var nodeId: String?
private var pendingInvokes: [String: CheckedContinuation<BridgeInvokeResponse, Error>] = [:]
private var isClosed = false
init(connection: NWConnection, logger: Logger) {
self.connection = connection
self.logger = logger
}
enum AuthResult: Sendable {
case ok
case notPaired
case unauthorized
case error(code: String, message: String)
}
enum PairResult: Sendable {
case ok(token: String)
case rejected
case error(code: String, message: String)
}
private struct FrameContext: Sendable {
var serverName: String
var resolveAuth: @Sendable (BridgeHello) async -> AuthResult
var handlePair: @Sendable (BridgePairRequest) async -> PairResult
var onAuthenticated: (@Sendable (BridgeNodeInfo) async -> Void)?
var onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)?
var onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)?
}
func run(
resolveAuth: @escaping @Sendable (BridgeHello) async -> AuthResult,
handlePair: @escaping @Sendable (BridgePairRequest) async -> PairResult,
onAuthenticated: (@Sendable (BridgeNodeInfo) async -> Void)? = nil,
onDisconnected: (@Sendable (String) async -> Void)? = nil,
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)? = nil,
onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)? = nil) async
{
self.configureStateLogging()
self.connection.start(queue: self.queue)
let context = FrameContext(
serverName: Host.current().localizedName ?? ProcessInfo.processInfo.hostName,
resolveAuth: resolveAuth,
handlePair: handlePair,
onAuthenticated: onAuthenticated,
onEvent: onEvent,
onRequest: onRequest)
while true {
do {
guard let line = try await self.receiveLine() else { break }
guard let data = line.data(using: .utf8) else { continue }
let base = try self.decoder.decode(BridgeBaseFrame.self, from: data)
try await self.handleFrame(
baseType: base.type,
data: data,
context: context)
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
await self.close(with: onDisconnected)
}
private func configureStateLogging() {
self.connection.stateUpdateHandler = { [logger] state in
switch state {
case .ready:
logger.debug("bridge conn ready")
case let .failed(err):
logger.error("bridge conn failed: \(err.localizedDescription, privacy: .public)")
default:
break
}
}
}
private func handleFrame(
baseType: String,
data: Data,
context: FrameContext) async throws
{
switch baseType {
case "hello":
await self.handleHelloFrame(
data: data,
context: context)
case "pair-request":
await self.handlePairRequestFrame(
data: data,
context: context)
case "event":
await self.handleEventFrame(data: data, onEvent: context.onEvent)
case "req":
try await self.handleRPCRequestFrame(data: data, onRequest: context.onRequest)
case "ping":
try await self.handlePingFrame(data: data)
case "invoke-res":
await self.handleInvokeResponseFrame(data: data)
default:
await self.sendError(code: "INVALID_REQUEST", message: "unknown type")
}
}
private func handleHelloFrame(
data: Data,
context: FrameContext) async
{
do {
let hello = try self.decoder.decode(BridgeHello.self, from: data)
let nodeId = hello.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
self.nodeId = nodeId
let result = await context.resolveAuth(hello)
await self.handleAuthResult(result, serverName: context.serverName)
if case .ok = result {
await context.onAuthenticated?(
BridgeNodeInfo(
nodeId: nodeId,
displayName: hello.displayName,
platform: hello.platform,
version: hello.version,
deviceFamily: hello.deviceFamily,
modelIdentifier: hello.modelIdentifier,
remoteAddress: self.remoteAddressString(),
caps: hello.caps))
}
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
private func handlePairRequestFrame(
data: Data,
context: FrameContext) async
{
do {
let req = try self.decoder.decode(BridgePairRequest.self, from: data)
let nodeId = req.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
self.nodeId = nodeId
let enriched = BridgePairRequest(
type: req.type,
nodeId: nodeId,
displayName: req.displayName,
platform: req.platform,
version: req.version,
deviceFamily: req.deviceFamily,
modelIdentifier: req.modelIdentifier,
caps: req.caps,
commands: req.commands,
remoteAddress: self.remoteAddressString(),
silent: req.silent)
let result = await context.handlePair(enriched)
await self.handlePairResult(result, serverName: context.serverName)
if case .ok = result {
await context.onAuthenticated?(
BridgeNodeInfo(
nodeId: nodeId,
displayName: enriched.displayName,
platform: enriched.platform,
version: enriched.version,
deviceFamily: enriched.deviceFamily,
modelIdentifier: enriched.modelIdentifier,
remoteAddress: enriched.remoteAddress,
caps: enriched.caps))
}
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
private func handleEventFrame(
data: Data,
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)?) async
{
guard self.isAuthenticated, let nodeId = self.nodeId else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
return
}
do {
let evt = try self.decoder.decode(BridgeEventFrame.self, from: data)
await onEvent?(nodeId, evt)
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
private func handleRPCRequestFrame(
data: Data,
onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)?) async throws
{
let req = try self.decoder.decode(BridgeRPCRequest.self, from: data)
guard self.isAuthenticated, let nodeId = self.nodeId else {
try await self.send(
BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAUTHORIZED", message: "not authenticated")))
return
}
if let onRequest {
let res = await onRequest(nodeId, req)
try await self.send(res)
} else {
try await self.send(
BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: "RPC not supported")))
}
}
private func handlePingFrame(data: Data) async throws {
guard self.isAuthenticated else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
return
}
let ping = try self.decoder.decode(BridgePing.self, from: data)
try await self.send(BridgePong(type: "pong", id: ping.id))
}
private func handleInvokeResponseFrame(data: Data) async {
guard self.isAuthenticated else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
return
}
do {
let res = try self.decoder.decode(BridgeInvokeResponse.self, from: data)
if let cont = self.pendingInvokes.removeValue(forKey: res.id) {
cont.resume(returning: res)
}
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
private func remoteAddressString() -> String? {
switch self.connection.endpoint {
case let .hostPort(host: host, port: _):
let value = String(describing: host)
return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : value
default:
return nil
}
}
func remoteAddress() -> String? {
self.remoteAddressString()
}
private func handlePairResult(_ result: PairResult, serverName: String) async {
switch result {
case let .ok(token):
do {
try await self.send(BridgePairOk(type: "pair-ok", token: token))
self.isAuthenticated = true
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
try await self.send(
BridgeHelloOk(
type: "hello-ok",
serverName: serverName,
mainSessionKey: mainSessionKey))
} catch {
self.logger.error("bridge send pair-ok failed: \(error.localizedDescription, privacy: .public)")
}
case .rejected:
await self.sendError(code: "UNAUTHORIZED", message: "pairing rejected")
case let .error(code, message):
await self.sendError(code: code, message: message)
}
}
private func handleAuthResult(_ result: AuthResult, serverName: String) async {
switch result {
case .ok:
self.isAuthenticated = true
do {
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
try await self.send(
BridgeHelloOk(
type: "hello-ok",
serverName: serverName,
mainSessionKey: mainSessionKey))
} catch {
self.logger.error("bridge send hello-ok failed: \(error.localizedDescription, privacy: .public)")
}
case .notPaired:
await self.sendError(code: "NOT_PAIRED", message: "pairing required")
case .unauthorized:
await self.sendError(code: "UNAUTHORIZED", message: "invalid token")
case let .error(code, message):
await self.sendError(code: code, message: message)
}
}
private func sendError(code: String, message: String) async {
do {
try await self.send(BridgeErrorFrame(type: "error", code: code, message: message))
} catch {
self.logger.error("bridge send error failed: \(error.localizedDescription, privacy: .public)")
}
}
func invoke(command: String, paramsJSON: String?) async throws -> BridgeInvokeResponse {
guard self.isAuthenticated else {
throw NSError(domain: "Bridge", code: 1, userInfo: [
NSLocalizedDescriptionKey: "UNAUTHORIZED: not authenticated",
])
}
let id = UUID().uuidString
let req = BridgeInvokeRequest(type: "invoke", id: id, command: command, paramsJSON: paramsJSON)
let timeoutTask = Task {
try await Task.sleep(nanoseconds: 15 * 1_000_000_000)
await self.timeoutInvoke(id: id)
}
defer { timeoutTask.cancel() }
return try await withCheckedThrowingContinuation { cont in
Task { [weak self] in
guard let self else { return }
await self.beginInvoke(id: id, request: req, continuation: cont)
}
}
}
private func beginInvoke(
id: String,
request: BridgeInvokeRequest,
continuation: CheckedContinuation<BridgeInvokeResponse, Error>) async
{
self.pendingInvokes[id] = continuation
do {
try await self.send(request)
} catch {
await self.failInvoke(id: id, error: error)
}
}
private func timeoutInvoke(id: String) async {
guard let cont = self.pendingInvokes.removeValue(forKey: id) else { return }
cont.resume(throwing: NSError(domain: "Bridge", code: 3, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: invoke timeout",
]))
}
private func failInvoke(id: String, error: Error) async {
guard let cont = self.pendingInvokes.removeValue(forKey: id) else { return }
cont.resume(throwing: error)
}
private func send(_ obj: some Encodable) async throws {
let data = try self.encoder.encode(obj)
var line = Data()
line.append(data)
line.append(0x0A) // \n
let _: Void = try await withCheckedThrowingContinuation { cont in
self.connection.send(content: line, completion: .contentProcessed { err in
if let err {
cont.resume(throwing: err)
} else {
cont.resume(returning: ())
}
})
}
}
func sendServerEvent(event: String, payloadJSON: String?) async {
guard self.isAuthenticated else { return }
do {
try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON))
} catch {
self.logger.error("bridge send event failed: \(error.localizedDescription, privacy: .public)")
}
}
private func receiveLine() async throws -> String? {
while true {
if let idx = self.buffer.firstIndex(of: 0x0A) {
let lineData = self.buffer.prefix(upTo: idx)
self.buffer.removeSubrange(...idx)
return String(data: lineData, encoding: .utf8)
}
let chunk = try await self.receiveChunk()
if chunk.isEmpty { return nil }
self.buffer.append(chunk)
}
}
private func receiveChunk() async throws -> Data {
try await withCheckedThrowingContinuation { cont in
self.connection
.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
if let error {
cont.resume(throwing: error)
return
}
if isComplete {
cont.resume(returning: Data())
return
}
cont.resume(returning: data ?? Data())
}
}
}
private func close(with onDisconnected: (@Sendable (String) async -> Void)? = nil) async {
if self.isClosed { return }
self.isClosed = true
let nodeId = self.nodeId
let pending = self.pendingInvokes.values
self.pendingInvokes.removeAll()
for cont in pending {
cont.resume(throwing: NSError(domain: "Bridge", code: 4, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: connection closed",
]))
}
self.connection.cancel()
if let nodeId {
await onDisconnected?(nodeId)
}
}
}

View File

@@ -1,542 +0,0 @@
import AppKit
import ClawdbotKit
import ClawdbotProtocol
import Foundation
import Network
import OSLog
actor BridgeServer {
static let shared = BridgeServer()
private let logger = Logger(subsystem: "com.clawdbot", category: "bridge")
private var listener: NWListener?
private var isRunning = false
private var store: PairedNodesStore?
private var connections: [String: BridgeConnectionHandler] = [:]
private var nodeInfoById: [String: BridgeNodeInfo] = [:]
private var presenceTasks: [String: Task<Void, Never>] = [:]
private var chatSubscriptions: [String: Set<String>] = [:]
private var gatewayPushTask: Task<Void, Never>?
func start() async {
if self.isRunning { return }
self.isRunning = true
do {
let storeURL = try Self.defaultStoreURL()
let store = PairedNodesStore(fileURL: storeURL)
await store.load()
self.store = store
let params = NWParameters.tcp
params.includePeerToPeer = true
let listener = try NWListener(using: params, on: .any)
listener.newConnectionHandler = { [weak self] connection in
guard let self else { return }
Task { await self.handle(connection: connection) }
}
listener.stateUpdateHandler = { [weak self] state in
guard let self else { return }
Task { await self.handleListenerState(state) }
}
listener.start(queue: DispatchQueue(label: "com.clawdbot.bridge"))
self.listener = listener
} catch {
self.logger.error("bridge start failed: \(error.localizedDescription, privacy: .public)")
self.isRunning = false
}
}
func stop() async {
self.isRunning = false
self.listener?.cancel()
self.listener = nil
}
private func handleListenerState(_ state: NWListener.State) {
switch state {
case .ready:
self.logger.info("bridge listening")
case let .failed(err):
self.logger.error("bridge listener failed: \(err.localizedDescription, privacy: .public)")
case .cancelled:
self.logger.info("bridge listener cancelled")
case .waiting:
self.logger.info("bridge listener waiting")
case .setup:
break
@unknown default:
break
}
}
private func handle(connection: NWConnection) async {
let handler = BridgeConnectionHandler(connection: connection, logger: self.logger)
await handler.run(
resolveAuth: { [weak self] hello in
await self?.authorize(hello: hello) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable")
},
handlePair: { [weak self] request in
await self?.pair(request: request) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable")
},
onAuthenticated: { [weak self] node in
await self?.registerConnection(handler: handler, node: node)
},
onDisconnected: { [weak self] nodeId in
await self?.unregisterConnection(nodeId: nodeId)
},
onEvent: { [weak self] nodeId, evt in
await self?.handleEvent(nodeId: nodeId, evt: evt)
},
onRequest: { [weak self] nodeId, req in
await self?.handleRequest(nodeId: nodeId, req: req)
?? BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: "bridge unavailable"))
})
}
func invoke(nodeId: String, command: String, paramsJSON: String?) async throws -> BridgeInvokeResponse {
guard let handler = self.connections[nodeId] else {
throw NSError(domain: "Bridge", code: 10, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: node not connected",
])
}
return try await handler.invoke(command: command, paramsJSON: paramsJSON)
}
func connectedNodeIds() -> [String] {
Array(self.connections.keys).sorted()
}
func connectedNodes() -> [BridgeNodeInfo] {
self.nodeInfoById.values.sorted { a, b in
(a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId)
}
}
func pairedNodes() async -> [PairedNode] {
guard let store = self.store else { return [] }
return await store.all()
}
private func registerConnection(handler: BridgeConnectionHandler, node: BridgeNodeInfo) async {
self.connections[node.nodeId] = handler
self.nodeInfoById[node.nodeId] = node
await self.beaconPresence(nodeId: node.nodeId, reason: "connect")
self.startPresenceTask(nodeId: node.nodeId)
self.ensureGatewayPushTask()
}
private func unregisterConnection(nodeId: String) async {
await self.beaconPresence(nodeId: nodeId, reason: "disconnect")
self.stopPresenceTask(nodeId: nodeId)
self.connections.removeValue(forKey: nodeId)
self.nodeInfoById.removeValue(forKey: nodeId)
self.chatSubscriptions[nodeId] = nil
self.stopGatewayPushTaskIfIdle()
}
private struct VoiceTranscriptPayload: Codable, Sendable {
var text: String
var sessionKey: String?
}
private func handleEvent(nodeId: String, evt: BridgeEventFrame) async {
switch evt.event {
case "chat.subscribe":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { return }
struct Subscribe: Codable { var sessionKey: String }
guard let payload = try? JSONDecoder().decode(Subscribe.self, from: data) else { return }
let key = payload.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return }
var set = self.chatSubscriptions[nodeId] ?? Set<String>()
set.insert(key)
self.chatSubscriptions[nodeId] = set
case "chat.unsubscribe":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { return }
struct Unsubscribe: Codable { var sessionKey: String }
guard let payload = try? JSONDecoder().decode(Unsubscribe.self, from: data) else { return }
let key = payload.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return }
var set = self.chatSubscriptions[nodeId] ?? Set<String>()
set.remove(key)
self.chatSubscriptions[nodeId] = set.isEmpty ? nil : set
case "voice.transcript":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
return
}
guard let payload = try? JSONDecoder().decode(VoiceTranscriptPayload.self, from: data) else {
return
}
let text = payload.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
let sessionKey = payload.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? "main"
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: text,
sessionKey: sessionKey,
thinking: "low",
deliver: false,
to: nil,
channel: .last))
case "agent.request":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
return
}
guard let link = try? JSONDecoder().decode(AgentDeepLink.self, from: data) else {
return
}
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !message.isEmpty else { return }
guard message.count <= 20000 else { return }
let sessionKey = link.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? "node-\(nodeId)"
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let channel = GatewayAgentChannel(raw: link.channel)
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: message,
sessionKey: sessionKey,
thinking: thinking,
deliver: link.deliver,
to: to,
channel: channel))
default:
break
}
}
private func handleRequest(nodeId: String, req: BridgeRPCRequest) async -> BridgeRPCResponse {
let allowed: Set<String> = ["chat.history", "chat.send", "health"]
guard allowed.contains(req.method) else {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "FORBIDDEN", message: "Method not allowed"))
}
let params: [String: ClawdbotProtocol.AnyCodable]?
if let json = req.paramsJSON?.trimmingCharacters(in: .whitespacesAndNewlines), !json.isEmpty {
guard let data = json.data(using: .utf8) else {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "INVALID_REQUEST", message: "paramsJSON not UTF-8"))
}
do {
params = try JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
} catch {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "INVALID_REQUEST", message: error.localizedDescription))
}
} else {
params = nil
}
do {
let data = try await GatewayConnection.shared.request(method: req.method, params: params, timeoutMs: 30000)
guard let json = String(data: data, encoding: .utf8) else {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: "Response not UTF-8"))
}
return BridgeRPCResponse(id: req.id, ok: true, payloadJSON: json)
} catch {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: error.localizedDescription))
}
}
private func ensureGatewayPushTask() {
if self.gatewayPushTask != nil { return }
self.gatewayPushTask = Task { [weak self] in
guard let self else { return }
do {
try await GatewayConnection.shared.refresh()
} catch {
// We'll still forward events once the gateway comes up.
}
let stream = await GatewayConnection.shared.subscribe()
for await push in stream {
if Task.isCancelled { return }
await self.forwardGatewayPush(push)
}
}
}
private func stopGatewayPushTaskIfIdle() {
guard self.connections.isEmpty else { return }
self.gatewayPushTask?.cancel()
self.gatewayPushTask = nil
}
private func forwardGatewayPush(_ push: GatewayPush) async {
let subscribedNodes = self.chatSubscriptions.keys.filter { self.connections[$0] != nil }
guard !subscribedNodes.isEmpty else { return }
switch push {
case let .snapshot(hello):
let payloadJSON = (try? JSONEncoder().encode(hello.snapshot.health))
.flatMap { String(data: $0, encoding: .utf8) }
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "health", payloadJSON: payloadJSON)
}
case let .event(evt):
switch evt.event {
case "health":
guard let payload = evt.payload else { return }
let payloadJSON = (try? JSONEncoder().encode(payload))
.flatMap { String(data: $0, encoding: .utf8) }
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "health", payloadJSON: payloadJSON)
}
case "tick":
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "tick", payloadJSON: nil)
}
case "chat":
guard let payload = evt.payload else { return }
let payloadData = try? JSONEncoder().encode(payload)
let payloadJSON = payloadData.flatMap { String(data: $0, encoding: .utf8) }
struct MinimalChat: Codable { var sessionKey: String }
let sessionKey = payloadData.flatMap { try? JSONDecoder().decode(MinimalChat.self, from: $0) }?
.sessionKey
if let sessionKey {
for nodeId in subscribedNodes {
guard self.chatSubscriptions[nodeId]?.contains(sessionKey) == true else { continue }
await self.connections[nodeId]?.sendServerEvent(event: "chat", payloadJSON: payloadJSON)
}
} else {
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "chat", payloadJSON: payloadJSON)
}
}
default:
break
}
case .seqGap:
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "seqGap", payloadJSON: nil)
}
}
}
private func beaconPresence(nodeId: String, reason: String) async {
let paired = await self.store?.find(nodeId: nodeId)
let host = paired?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? nodeId
let version = paired?.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let platform = paired?.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let ip = await self.connections[nodeId]?.remoteAddress()
var tags: [String] = ["node", "ios"]
if let platform { tags.append(platform) }
let summary = [
"Node: \(host)\(ip.map { " (\($0))" } ?? "")",
platform.map { "platform \($0)" },
version.map { "app \($0)" },
"mode node",
"reason \(reason)",
].compactMap(\.self).joined(separator: " · ")
var params: [String: ClawdbotProtocol.AnyCodable] = [
"text": ClawdbotProtocol.AnyCodable(summary),
"instanceId": ClawdbotProtocol.AnyCodable(nodeId),
"host": ClawdbotProtocol.AnyCodable(host),
"mode": ClawdbotProtocol.AnyCodable("node"),
"reason": ClawdbotProtocol.AnyCodable(reason),
"tags": ClawdbotProtocol.AnyCodable(tags),
]
if let ip { params["ip"] = ClawdbotProtocol.AnyCodable(ip) }
if let version { params["version"] = ClawdbotProtocol.AnyCodable(version) }
await GatewayConnection.shared.sendSystemEvent(params)
}
private func startPresenceTask(nodeId: String) {
self.presenceTasks[nodeId]?.cancel()
self.presenceTasks[nodeId] = Task.detached { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 180 * 1_000_000_000)
if Task.isCancelled { return }
await self?.beaconPresence(nodeId: nodeId, reason: "periodic")
}
}
}
private func stopPresenceTask(nodeId: String) {
self.presenceTasks[nodeId]?.cancel()
self.presenceTasks.removeValue(forKey: nodeId)
}
private func authorize(hello: BridgeHello) async -> BridgeConnectionHandler.AuthResult {
let nodeId = hello.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
if nodeId.isEmpty {
return .error(code: "INVALID_REQUEST", message: "nodeId required")
}
guard let store = self.store else {
return .error(code: "UNAVAILABLE", message: "store unavailable")
}
guard let paired = await store.find(nodeId: nodeId) else {
return .notPaired
}
guard let token = hello.token, token == paired.token else {
return .unauthorized
}
do {
var updated = paired
let name = hello.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let platform = hello.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let version = hello.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let deviceFamily = hello.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let modelIdentifier = hello.modelIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
if updated.displayName != name { updated.displayName = name }
if updated.platform != platform { updated.platform = platform }
if updated.version != version { updated.version = version }
if updated.deviceFamily != deviceFamily { updated.deviceFamily = deviceFamily }
if updated.modelIdentifier != modelIdentifier { updated.modelIdentifier = modelIdentifier }
if updated != paired {
try await store.upsert(updated)
} else {
try await store.touchSeen(nodeId: nodeId)
}
} catch {
// ignore
}
return .ok
}
private func pair(request: BridgePairRequest) async -> BridgeConnectionHandler.PairResult {
let nodeId = request.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
if nodeId.isEmpty {
return .error(code: "INVALID_REQUEST", message: "nodeId required")
}
guard let store = self.store else {
return .error(code: "UNAVAILABLE", message: "store unavailable")
}
let existing = await store.find(nodeId: nodeId)
let approved = await BridgePairingApprover.approve(request: request, isRepair: existing != nil)
if !approved {
return .rejected
}
let token = UUID().uuidString.replacingOccurrences(of: "-", with: "")
let nowMs = Int(Date().timeIntervalSince1970 * 1000)
let node = PairedNode(
nodeId: nodeId,
displayName: request.displayName,
platform: request.platform,
version: request.version,
deviceFamily: request.deviceFamily,
modelIdentifier: request.modelIdentifier,
token: token,
createdAtMs: nowMs,
lastSeenAtMs: nowMs)
do {
try await store.upsert(node)
return .ok(token: token)
} catch {
return .error(code: "UNAVAILABLE", message: "failed to persist pairing")
}
}
private static func defaultStoreURL() throws -> URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
guard let base else {
throw NSError(
domain: "Bridge",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Application Support unavailable"])
}
return base
.appendingPathComponent("Clawdbot", isDirectory: true)
.appendingPathComponent("bridge", isDirectory: true)
.appendingPathComponent("paired-nodes.json", isDirectory: false)
}
}
@MainActor
enum BridgePairingApprover {
static func approve(request: BridgePairRequest, isRepair: Bool) async -> Bool {
await withCheckedContinuation { cont in
let name = request.displayName ?? request.nodeId
let remote = request.remoteAddress?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let alert = NSAlert()
alert.messageText = isRepair ? "Re-pair Clawdbot Node?" : "Pair Clawdbot Node?"
alert.informativeText = """
Node: \(name)
IP: \(remote ?? "unknown")
Platform: \(request.platform ?? "unknown")
Version: \(request.version ?? "unknown")
"""
alert.addButton(withTitle: "Approve")
alert.addButton(withTitle: "Reject")
if #available(macOS 11.0, *), alert.buttons.indices.contains(1) {
alert.buttons[1].hasDestructiveAction = true
}
let resp = alert.runModal()
cont.resume(returning: resp == .alertFirstButtonReturn)
}
}
}
#if DEBUG
extension BridgeServer {
func exerciseForTesting() async {
let conn = NWConnection(to: .hostPort(host: "127.0.0.1", port: 22), using: .tcp)
let handler = BridgeConnectionHandler(connection: conn, logger: self.logger)
self.connections["node-1"] = handler
self.nodeInfoById["node-1"] = BridgeNodeInfo(
nodeId: "node-1",
displayName: "Node One",
platform: "macOS",
version: "1.0.0",
deviceFamily: "Mac",
modelIdentifier: "MacBookPro18,1",
remoteAddress: "127.0.0.1",
caps: ["chat", "voice"])
_ = self.connectedNodeIds()
_ = self.connectedNodes()
self.handleListenerState(.ready)
self.handleListenerState(.failed(NWError.posix(.ECONNREFUSED)))
self.handleListenerState(.waiting(NWError.posix(.ETIMEDOUT)))
self.handleListenerState(.cancelled)
self.handleListenerState(.setup)
let subscribe = BridgeEventFrame(event: "chat.subscribe", payloadJSON: "{\"sessionKey\":\"main\"}")
await self.handleEvent(nodeId: "node-1", evt: subscribe)
let unsubscribe = BridgeEventFrame(event: "chat.unsubscribe", payloadJSON: "{\"sessionKey\":\"main\"}")
await self.handleEvent(nodeId: "node-1", evt: unsubscribe)
let invalid = BridgeRPCRequest(id: "req-1", method: "invalid.method", paramsJSON: nil)
_ = await self.handleRequest(nodeId: "node-1", req: invalid)
}
}
#endif

View File

@@ -1,59 +0,0 @@
import Foundation
struct PairedNode: Codable, Equatable {
var nodeId: String
var displayName: String?
var platform: String?
var version: String?
var deviceFamily: String?
var modelIdentifier: String?
var token: String
var createdAtMs: Int
var lastSeenAtMs: Int?
}
actor PairedNodesStore {
private let fileURL: URL
private var nodes: [String: PairedNode] = [:]
init(fileURL: URL) {
self.fileURL = fileURL
}
func load() {
do {
let data = try Data(contentsOf: self.fileURL)
let decoded = try JSONDecoder().decode([String: PairedNode].self, from: data)
self.nodes = decoded
} catch {
self.nodes = [:]
}
}
func all() -> [PairedNode] {
self.nodes.values.sorted { a, b in (a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId) }
}
func find(nodeId: String) -> PairedNode? {
self.nodes[nodeId]
}
func upsert(_ node: PairedNode) async throws {
self.nodes[node.nodeId] = node
try await self.persist()
}
func touchSeen(nodeId: String) async throws {
guard var node = self.nodes[nodeId] else { return }
node.lastSeenAtMs = Int(Date().timeIntervalSince1970 * 1000)
self.nodes[nodeId] = node
try await self.persist()
}
private func persist() async throws {
let dir = self.fileURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let data = try JSONEncoder().encode(self.nodes)
try data.write(to: self.fileURL, options: [.atomic])
}
}

View File

@@ -35,7 +35,7 @@ enum CLIInstaller {
}
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async {
let expected = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
let prefix = Self.installPrefix()
await statusHandler("Installing clawdbot CLI…")
let cmd = self.installScriptCommand(version: expected, prefix: prefix)

View File

@@ -1,5 +1,6 @@
import AppKit
import ClawdbotIPC
import ClawdbotKit
import Foundation
import OSLog

View File

@@ -0,0 +1,363 @@
import SwiftUI
struct ConfigSchemaForm: View {
@Bindable var store: ChannelsStore
let schema: ConfigSchemaNode
let path: ConfigPath
var body: some View {
self.renderNode(self.schema, path: self.path)
}
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
let storedValue = self.store.configValue(at: path)
let value = storedValue ?? schema.explicitDefault
let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description
let variants = schema.anyOf.isEmpty ? schema.oneOf : schema.anyOf
if !variants.isEmpty {
let nonNull = variants.filter { !$0.isNullSchema }
if nonNull.count == 1, let only = nonNull.first {
return self.renderNode(only, path: path)
}
let literals = nonNull.compactMap(\.literalValue)
if !literals.isEmpty, literals.count == nonNull.count {
return AnyView(
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
Picker(
"",
selection: self.enumBinding(
path,
options: literals,
defaultValue: schema.explicitDefault))
{
Text("Select…").tag(-1)
ForEach(literals.indices, id: \ .self) { index in
Text(String(describing: literals[index])).tag(index)
}
}
.pickerStyle(.menu)
})
}
}
switch schema.schemaType {
case "object":
return AnyView(
VStack(alignment: .leading, spacing: 12) {
if let label {
Text(label)
.font(.callout.weight(.semibold))
}
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
let properties = schema.properties
let sortedKeys = properties.keys.sorted { lhs, rhs in
let orderA = hintForPath(path + [.key(lhs)], hints: store.configUiHints)?.order ?? 0
let orderB = hintForPath(path + [.key(rhs)], hints: store.configUiHints)?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs < rhs
}
ForEach(sortedKeys, id: \ .self) { key in
if let child = properties[key] {
self.renderNode(child, path: path + [.key(key)])
}
}
if schema.allowsAdditionalProperties {
self.renderAdditionalProperties(schema, path: path, value: value)
}
})
case "array":
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
case "boolean":
return AnyView(
Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) {
if let label { Text(label) } else { Text("Enabled") }
}
.help(help ?? ""))
case "number", "integer":
return AnyView(self.renderNumberField(schema, path: path, label: label, help: help))
case "string":
return AnyView(self.renderStringField(schema, path: path, label: label, help: help))
default:
return AnyView(
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
Text("Unsupported field type.")
.font(.caption)
.foregroundStyle(.secondary)
})
}
}
@ViewBuilder
private func renderStringField(
_ schema: ConfigSchemaNode,
path: ConfigPath,
label: String?,
help: String?) -> some View
{
let hint = hintForPath(path, hints: store.configUiHints)
let placeholder = hint?.placeholder ?? ""
let sensitive = hint?.sensitive ?? isSensitivePath(path)
let defaultValue = schema.explicitDefault as? String
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
if let options = schema.enumValues {
Picker("", selection: self.enumBinding(path, options: options, defaultValue: schema.explicitDefault)) {
Text("Select…").tag(-1)
ForEach(options.indices, id: \ .self) { index in
Text(String(describing: options[index])).tag(index)
}
}
.pickerStyle(.menu)
} else if sensitive {
SecureField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
} else {
TextField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
}
}
}
@ViewBuilder
private func renderNumberField(
_ schema: ConfigSchemaNode,
path: ConfigPath,
label: String?,
help: String?) -> some View
{
let defaultValue = (schema.explicitDefault as? Double)
?? (schema.explicitDefault as? Int).map(Double.init)
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
TextField(
"",
text: self.numberBinding(
path,
isInteger: schema.schemaType == "integer",
defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
}
}
@ViewBuilder
private func renderArray(
_ schema: ConfigSchemaNode,
path: ConfigPath,
value: Any?,
label: String?,
help: String?) -> some View
{
let items = value as? [Any] ?? []
let itemSchema = schema.items
VStack(alignment: .leading, spacing: 10) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(items.indices, id: \ .self) { index in
HStack(alignment: .top, spacing: 8) {
if let itemSchema {
self.renderNode(itemSchema, path: path + [.index(index)])
} else {
Text(String(describing: items[index]))
}
Button("Remove") {
var next = items
next.remove(at: index)
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
Button("Add") {
var next = items
if let itemSchema {
next.append(itemSchema.defaultValue)
} else {
next.append("")
}
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
@ViewBuilder
private func renderAdditionalProperties(
_ schema: ConfigSchemaNode,
path: ConfigPath,
value: Any?) -> some View
{
if let additionalSchema = schema.additionalProperties {
let dict = value as? [String: Any] ?? [:]
let reserved = Set(schema.properties.keys)
let extras = dict.keys.filter { !reserved.contains($0) }.sorted()
VStack(alignment: .leading, spacing: 8) {
Text("Extra entries")
.font(.callout.weight(.semibold))
if extras.isEmpty {
Text("No extra entries yet.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(extras, id: \ .self) { key in
let itemPath: ConfigPath = path + [.key(key)]
HStack(alignment: .top, spacing: 8) {
TextField("Key", text: self.mapKeyBinding(path: path, key: key))
.textFieldStyle(.roundedBorder)
.frame(width: 160)
self.renderNode(additionalSchema, path: itemPath)
Button("Remove") {
var next = dict
next.removeValue(forKey: key)
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
Button("Add") {
var next = dict
var index = 1
var key = "new-\(index)"
while next[key] != nil {
index += 1
key = "new-\(index)"
}
next[key] = additionalSchema.defaultValue
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
private func stringBinding(_ path: ConfigPath, defaultValue: String?) -> Binding<String> {
Binding(
get: {
if let value = store.configValue(at: path) as? String { return value }
return defaultValue ?? ""
},
set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
self.store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
})
}
private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> {
Binding(
get: {
if let value = store.configValue(at: path) as? Bool { return value }
return defaultValue ?? false
},
set: { newValue in
self.store.updateConfigValue(path: path, value: newValue)
})
}
private func numberBinding(
_ path: ConfigPath,
isInteger: Bool,
defaultValue: Double?) -> Binding<String>
{
Binding(
get: {
if let value = store.configValue(at: path) { return String(describing: value) }
guard let defaultValue else { return "" }
return isInteger ? String(Int(defaultValue)) : String(defaultValue)
},
set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
self.store.updateConfigValue(path: path, value: nil)
} else if let value = Double(trimmed) {
self.store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
}
})
}
private func enumBinding(
_ path: ConfigPath,
options: [Any],
defaultValue: Any?) -> Binding<Int>
{
Binding(
get: {
let value = self.store.configValue(at: path) ?? defaultValue
guard let value else { return -1 }
return options.firstIndex { option in
String(describing: option) == String(describing: value)
} ?? -1
},
set: { index in
guard index >= 0, index < options.count else {
self.store.updateConfigValue(path: path, value: nil)
return
}
self.store.updateConfigValue(path: path, value: options[index])
})
}
private func mapKeyBinding(path: ConfigPath, key: String) -> Binding<String> {
Binding(
get: { key },
set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard trimmed != key else { return }
let current = self.store.configValue(at: path) as? [String: Any] ?? [:]
guard current[trimmed] == nil else { return }
var next = current
next[trimmed] = current[key]
next.removeValue(forKey: key)
self.store.updateConfigValue(path: path, value: next)
})
}
}
struct ChannelConfigForm: View {
@Bindable var store: ChannelsStore
let channelId: String
var body: some View {
if self.store.configSchemaLoading {
ProgressView().controlSize(.small)
} else if let schema = store.channelConfigSchema(for: channelId) {
ConfigSchemaForm(store: self.store, schema: schema, path: [.key("channels"), .key(self.channelId)])
} else {
Text("Schema unavailable for this channel.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}

View File

@@ -0,0 +1,139 @@
import SwiftUI
extension ChannelsSettings {
func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View {
GroupBox(title) {
VStack(alignment: .leading, spacing: 10) {
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@ViewBuilder
func channelHeaderActions(_ channel: ChannelItem) -> some View {
HStack(spacing: 8) {
if channel.id == "whatsapp" {
Button("Logout") {
Task { await self.store.logoutWhatsApp() }
}
.buttonStyle(.bordered)
.disabled(self.store.whatsappBusy)
}
if channel.id == "telegram" {
Button("Logout") {
Task { await self.store.logoutTelegram() }
}
.buttonStyle(.bordered)
.disabled(self.store.telegramBusy)
}
Button {
Task { await self.store.refresh(probe: true) }
} label: {
if self.store.isRefreshing {
ProgressView().controlSize(.small)
} else {
Text("Refresh")
}
}
.buttonStyle(.bordered)
.disabled(self.store.isRefreshing)
}
.controlSize(.small)
}
var whatsAppSection: some View {
VStack(alignment: .leading, spacing: 16) {
self.formSection("Linking") {
if let message = self.store.whatsappLoginMessage {
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let qr = self.store.whatsappLoginQrDataUrl, let image = self.qrImage(from: qr) {
Image(nsImage: image)
.resizable()
.interpolation(.none)
.frame(width: 180, height: 180)
.cornerRadius(8)
}
HStack(spacing: 12) {
Button {
Task { await self.store.startWhatsAppLogin(force: false) }
} label: {
if self.store.whatsappBusy {
ProgressView().controlSize(.small)
} else {
Text("Show QR")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.whatsappBusy)
Button("Relink") {
Task { await self.store.startWhatsAppLogin(force: true) }
}
.buttonStyle(.bordered)
.disabled(self.store.whatsappBusy)
}
.font(.caption)
}
self.configEditorSection(channelId: "whatsapp")
}
}
@ViewBuilder
func genericChannelSection(_ channel: ChannelItem) -> some View {
VStack(alignment: .leading, spacing: 16) {
self.configEditorSection(channelId: channel.id)
}
}
@ViewBuilder
private func configEditorSection(channelId: String) -> some View {
self.formSection("Configuration") {
ChannelConfigForm(store: self.store, channelId: channelId)
}
self.configStatusMessage
HStack(spacing: 12) {
Button {
Task { await self.store.saveConfigDraft() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig || !self.store.configDirty)
Button("Reload") {
Task { await self.store.reloadConfigDraft() }
}
.buttonStyle(.bordered)
.disabled(self.store.isSavingConfig)
Spacer()
}
.font(.caption)
}
@ViewBuilder
var configStatusMessage: some View {
if let status = self.store.configStatus {
Text(status)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}

View File

@@ -1,6 +1,7 @@
import ClawdbotProtocol
import SwiftUI
extension ConnectionsSettings {
extension ChannelsSettings {
private func channelStatus<T: Decodable>(
_ id: String,
as type: T.Type) -> T?
@@ -242,16 +243,18 @@ extension ConnectionsSettings {
return lines.isEmpty ? nil : lines.joined(separator: " · ")
}
var isTelegramTokenLocked: Bool {
self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?.tokenSource == "env"
}
var isDiscordTokenLocked: Bool {
self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?.tokenSource == "env"
}
var orderedChannels: [ConnectionChannel] {
ConnectionChannel.allCases.sorted { lhs, rhs in
var orderedChannels: [ChannelItem] {
let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]
let order = self.store.snapshot?.channelOrder ?? fallback
let channels = order.enumerated().map { index, id in
ChannelItem(
id: id,
title: self.resolveChannelTitle(id),
detailTitle: self.resolveChannelDetailTitle(id),
systemImage: self.resolveChannelSystemImage(id),
sortOrder: index)
}
return channels.sorted { lhs, rhs in
let lhsEnabled = self.channelEnabled(lhs)
let rhsEnabled = self.channelEnabled(rhs)
if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled }
@@ -259,11 +262,11 @@ extension ConnectionsSettings {
}
}
var enabledChannels: [ConnectionChannel] {
var enabledChannels: [ChannelItem] {
self.orderedChannels.filter { self.channelEnabled($0) }
}
var availableChannels: [ConnectionChannel] {
var availableChannels: [ChannelItem] {
self.orderedChannels.filter { !self.channelEnabled($0) }
}
@@ -277,143 +280,183 @@ extension ConnectionsSettings {
}
}
func channelEnabled(_ channel: ConnectionChannel) -> Bool {
switch channel {
case .whatsapp:
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return false }
return status.configured || status.linked || status.running
case .telegram:
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return false }
return status.configured || status.running
case .discord:
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return false }
return status.configured || status.running
case .signal:
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return false }
return status.configured || status.running
case .imessage:
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return false }
return status.configured || status.running
}
func channelEnabled(_ channel: ChannelItem) -> Bool {
let status = self.channelStatusDictionary(channel.id)
let configured = status?["configured"]?.boolValue ?? false
let running = status?["running"]?.boolValue ?? false
let connected = status?["connected"]?.boolValue ?? false
let accountActive = self.store.snapshot?.channelAccounts[channel.id]?.contains(
where: { $0.configured == true || $0.running == true || $0.connected == true }) ?? false
return configured || running || connected || accountActive
}
@ViewBuilder
func channelSection(_ channel: ConnectionChannel) -> some View {
switch channel {
case .whatsapp:
func channelSection(_ channel: ChannelItem) -> some View {
if channel.id == "whatsapp" {
self.whatsAppSection
case .telegram:
self.telegramSection
case .discord:
self.discordSection
case .signal:
self.signalSection
case .imessage:
self.imessageSection
} else {
self.genericChannelSection(channel)
}
}
func channelTint(_ channel: ConnectionChannel) -> Color {
switch channel {
case .whatsapp:
self.whatsAppTint
case .telegram:
self.telegramTint
case .discord:
self.discordTint
case .signal:
self.signalTint
case .imessage:
self.imessageTint
func channelTint(_ channel: ChannelItem) -> Color {
switch channel.id {
case "whatsapp":
return self.whatsAppTint
case "telegram":
return self.telegramTint
case "discord":
return self.discordTint
case "signal":
return self.signalTint
case "imessage":
return self.imessageTint
default:
if self.channelHasError(channel) { return .orange }
if self.channelEnabled(channel) { return .green }
return .secondary
}
}
func channelSummary(_ channel: ConnectionChannel) -> String {
switch channel {
case .whatsapp:
self.whatsAppSummary
case .telegram:
self.telegramSummary
case .discord:
self.discordSummary
case .signal:
self.signalSummary
case .imessage:
self.imessageSummary
func channelSummary(_ channel: ChannelItem) -> String {
switch channel.id {
case "whatsapp":
return self.whatsAppSummary
case "telegram":
return self.telegramSummary
case "discord":
return self.discordSummary
case "signal":
return self.signalSummary
case "imessage":
return self.imessageSummary
default:
if self.channelHasError(channel) { return "Error" }
if self.channelEnabled(channel) { return "Active" }
return "Not configured"
}
}
func channelDetails(_ channel: ConnectionChannel) -> String? {
switch channel {
case .whatsapp:
self.whatsAppDetails
case .telegram:
self.telegramDetails
case .discord:
self.discordDetails
case .signal:
self.signalDetails
case .imessage:
self.imessageDetails
func channelDetails(_ channel: ChannelItem) -> String? {
switch channel.id {
case "whatsapp":
return self.whatsAppDetails
case "telegram":
return self.telegramDetails
case "discord":
return self.discordDetails
case "signal":
return self.signalDetails
case "imessage":
return self.imessageDetails
default:
let status = self.channelStatusDictionary(channel.id)
if let err = status?["lastError"]?.stringValue, !err.isEmpty {
return "Error: \(err)"
}
return nil
}
}
func channelLastCheckText(_ channel: ConnectionChannel) -> String {
func channelLastCheckText(_ channel: ChannelItem) -> String {
guard let date = self.channelLastCheck(channel) else { return "never" }
return relativeAge(from: date)
}
func channelLastCheck(_ channel: ConnectionChannel) -> Date? {
switch channel {
case .whatsapp:
func channelLastCheck(_ channel: ChannelItem) -> Date? {
switch channel.id {
case "whatsapp":
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return nil }
return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt)
case .telegram:
case "telegram":
return self
.date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
.lastProbeAt)
case .discord:
case "discord":
return self
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.lastProbeAt)
case .signal:
case "signal":
return self
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
case .imessage:
case "imessage":
return self
.date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)?
.lastProbeAt)
default:
let status = self.channelStatusDictionary(channel.id)
if let probeAt = status?["lastProbeAt"]?.doubleValue {
return self.date(fromMs: probeAt)
}
if let accounts = self.store.snapshot?.channelAccounts[channel.id] {
let last = accounts.compactMap { $0.lastInboundAt ?? $0.lastOutboundAt }.max()
return self.date(fromMs: last)
}
return nil
}
}
func channelHasError(_ channel: ConnectionChannel) -> Bool {
switch channel {
case .whatsapp:
func channelHasError(_ channel: ChannelItem) -> Bool {
switch channel.id {
case "whatsapp":
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true
case .telegram:
case "telegram":
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case .discord:
case "discord":
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case .signal:
case "signal":
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case .imessage:
case "imessage":
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
default:
let status = self.channelStatusDictionary(channel.id)
return status?["lastError"]?.stringValue?.isEmpty == false
}
}
private func resolveChannelTitle(_ id: String) -> String {
if let label = self.store.snapshot?.channelLabels[id], !label.isEmpty {
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)
}
}
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"
}
}
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
self.store.snapshot?.channels[id]?.dictionaryValue
}
}

View File

@@ -1,6 +1,6 @@
import AppKit
extension ConnectionsSettings {
extension ChannelsSettings {
func date(fromMs ms: Double?) -> Date? {
guard let ms else { return nil }
return Date(timeIntervalSince1970: ms / 1000)

View File

@@ -1,6 +1,6 @@
import SwiftUI
extension ConnectionsSettings {
extension ChannelsSettings {
var body: some View {
HStack(spacing: 0) {
self.sidebar
@@ -57,7 +57,7 @@ extension ConnectionsSettings {
private var emptyDetail: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Connections")
Text("Channels")
.font(.title3.weight(.semibold))
Text("Select a channel to view status and settings.")
.font(.callout)
@@ -67,7 +67,7 @@ extension ConnectionsSettings {
.padding(.vertical, 18)
}
private func channelDetail(_ channel: ConnectionChannel) -> some View {
private func channelDetail(_ channel: ChannelItem) -> some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 16) {
self.detailHeader(for: channel)
@@ -81,7 +81,7 @@ extension ConnectionsSettings {
}
}
private func sidebarRow(_ channel: ConnectionChannel) -> some View {
private func sidebarRow(_ channel: ChannelItem) -> some View {
let isSelected = self.selectedChannel == channel
return Button {
self.selectedChannel = channel
@@ -119,7 +119,7 @@ extension ConnectionsSettings {
.padding(.top, 2)
}
private func detailHeader(for channel: ConnectionChannel) -> some View {
private func detailHeader(for channel: ChannelItem) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Label(channel.detailTitle, systemImage: channel.systemImage)

View File

@@ -0,0 +1,19 @@
import AppKit
import SwiftUI
struct ChannelsSettings: View {
struct ChannelItem: Identifiable, Hashable {
let id: String
let title: String
let detailTitle: String
let systemImage: String
let sortOrder: Int
}
@Bindable var store: ChannelsStore
@State var selectedChannel: ChannelItem?
init(store: ChannelsStore = .shared) {
self.store = store
}
}

View File

@@ -0,0 +1,154 @@
import ClawdbotProtocol
import Foundation
extension ChannelsStore {
func loadConfigSchema() async {
guard !self.configSchemaLoading else { return }
self.configSchemaLoading = true
defer { self.configSchemaLoading = false }
do {
let res: ConfigSchemaResponse = try await GatewayConnection.shared.requestDecoded(
method: .configSchema,
params: nil,
timeoutMs: 8000)
let schemaValue = res.schema.foundationValue
self.configSchema = ConfigSchemaNode(raw: schemaValue)
let hintValues = res.uihints.mapValues { $0.foundationValue }
self.configUiHints = decodeUiHints(hintValues)
} catch {
self.configStatus = error.localizedDescription
}
}
func loadConfig() async {
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .configGet,
params: nil,
timeoutMs: 10000)
self.configStatus = snap.valid == false
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
: nil
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
self.configDraft = cloneConfigValue(self.configRoot) as? [String: Any] ?? self.configRoot
self.configDirty = false
self.configLoaded = true
self.applyUIConfig(snap)
} catch {
self.configStatus = error.localizedDescription
}
}
private func applyUIConfig(_ snap: ConfigSnapshot) {
let ui = snap.config?["ui"]?.dictionaryValue
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
}
func channelConfigSchema(for channelId: String) -> ConfigSchemaNode? {
guard let root = self.configSchema else { return nil }
return root.node(at: [.key("channels"), .key(channelId)])
}
func configValue(at path: ConfigPath) -> Any? {
if let value = valueAtPath(self.configDraft, path: path) {
return value
}
guard path.count >= 2 else { return nil }
if case .key("channels") = path[0], case .key = path[1] {
let fallbackPath = Array(path.dropFirst())
return valueAtPath(self.configDraft, path: fallbackPath)
}
return nil
}
func updateConfigValue(path: ConfigPath, value: Any?) {
var root: Any = self.configDraft
setValue(&root, path: path, value: value)
self.configDraft = root as? [String: Any] ?? self.configDraft
self.configDirty = true
}
func saveConfigDraft() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
defer { self.isSavingConfig = false }
do {
try await ConfigStore.save(self.configDraft)
await self.loadConfig()
} catch {
self.configStatus = error.localizedDescription
}
}
func reloadConfigDraft() async {
await self.loadConfig()
}
}
private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
var current: Any? = root
for segment in path {
switch segment {
case let .key(key):
guard let dict = current as? [String: Any] else { return nil }
current = dict[key]
case let .index(index):
guard let array = current as? [Any], array.indices.contains(index) else { return nil }
current = array[index]
}
}
return current
}
private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
guard let segment = path.first else { return }
switch segment {
case let .key(key):
var dict = root as? [String: Any] ?? [:]
if path.count == 1 {
if let value {
dict[key] = value
} else {
dict.removeValue(forKey: key)
}
root = dict
return
}
var child = dict[key] ?? [:]
setValue(&child, path: Array(path.dropFirst()), value: value)
dict[key] = child
root = dict
case let .index(index):
var array = root as? [Any] ?? []
if index >= array.count {
array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1))
}
if path.count == 1 {
if let value {
array[index] = value
} else if array.indices.contains(index) {
array.remove(at: index)
}
root = array
return
}
var child = array[index]
setValue(&child, path: Array(path.dropFirst()), value: value)
array[index] = child
root = array
}
}
private func cloneConfigValue(_ value: Any) -> Any {
guard JSONSerialization.isValidJSONObject(value) else { return value }
do {
let data = try JSONSerialization.data(withJSONObject: value, options: [])
return try JSONSerialization.jsonObject(with: data, options: [])
} catch {
return value
}
}

View File

@@ -1,13 +1,14 @@
import ClawdbotProtocol
import Foundation
extension ConnectionsStore {
extension ChannelsStore {
func start() {
guard !self.isPreview else { return }
guard self.pollTask == nil else { return }
self.pollTask = Task.detached { [weak self] in
guard let self else { return }
await self.refresh(probe: true)
await self.loadConfigSchema()
await self.loadConfig()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))

View File

@@ -187,49 +187,10 @@ struct ConfigSnapshot: Codable {
let issues: [Issue]?
}
struct DiscordGuildChannelForm: Identifiable {
let id = UUID()
var key: String
var allow: Bool
var requireMention: Bool
init(key: String = "", allow: Bool = true, requireMention: Bool = false) {
self.key = key
self.allow = allow
self.requireMention = requireMention
}
}
struct DiscordGuildForm: Identifiable {
let id = UUID()
var key: String
var slug: String
var requireMention: Bool
var reactionNotifications: String
var users: String
var channels: [DiscordGuildChannelForm]
init(
key: String = "",
slug: String = "",
requireMention: Bool = false,
reactionNotifications: String = "own",
users: String = "",
channels: [DiscordGuildChannelForm] = [])
{
self.key = key
self.slug = slug
self.requireMention = requireMention
self.reactionNotifications = reactionNotifications
self.users = users
self.channels = channels
}
}
@MainActor
@Observable
final class ConnectionsStore {
static let shared = ConnectionsStore()
final class ChannelsStore {
static let shared = ChannelsStore()
var snapshot: ChannelsStatusSnapshot?
var lastError: String?
@@ -240,75 +201,21 @@ final class ConnectionsStore {
var whatsappLoginQrDataUrl: String?
var whatsappLoginConnected: Bool?
var whatsappBusy = false
var telegramToken: String = ""
var telegramRequireMention = true
var telegramAllowFrom: String = ""
var telegramProxy: String = ""
var telegramWebhookUrl: String = ""
var telegramWebhookSecret: String = ""
var telegramWebhookPath: String = ""
var telegramBusy = false
var discordEnabled = true
var discordToken: String = ""
var discordDmEnabled = true
var discordAllowFrom: String = ""
var discordGroupEnabled = false
var discordGroupChannels: String = ""
var discordMediaMaxMb: String = ""
var discordHistoryLimit: String = ""
var discordTextChunkLimit: String = ""
var discordReplyToMode: String = "off"
var discordGuilds: [DiscordGuildForm] = []
var discordActionReactions = true
var discordActionStickers = true
var discordActionPolls = true
var discordActionPermissions = true
var discordActionMessages = true
var discordActionThreads = true
var discordActionPins = true
var discordActionSearch = true
var discordActionMemberInfo = true
var discordActionRoleInfo = true
var discordActionChannelInfo = true
var discordActionVoiceStatus = true
var discordActionEvents = true
var discordActionRoles = false
var discordActionModeration = false
var discordSlashEnabled = false
var discordSlashName: String = ""
var discordSlashSessionPrefix: String = ""
var discordSlashEphemeral = true
var signalEnabled = true
var signalAccount: String = ""
var signalHttpUrl: String = ""
var signalHttpHost: String = ""
var signalHttpPort: String = ""
var signalCliPath: String = ""
var signalAutoStart = true
var signalReceiveMode: String = ""
var signalIgnoreAttachments = false
var signalIgnoreStories = false
var signalSendReadReceipts = false
var signalAllowFrom: String = ""
var signalMediaMaxMb: String = ""
var imessageEnabled = true
var imessageCliPath: String = ""
var imessageDbPath: String = ""
var imessageService: String = "auto"
var imessageRegion: String = ""
var imessageAllowFrom: String = ""
var imessageIncludeAttachments = false
var imessageMediaMaxMb: String = ""
var configStatus: String?
var isSavingConfig = false
var configSchemaLoading = false
var configSchema: ConfigSchemaNode?
var configUiHints: [String: ConfigUiHint] = [:]
var configDraft: [String: Any] = [:]
var configDirty = false
let interval: TimeInterval = 45
let isPreview: Bool
var pollTask: Task<Void, Never>?
var configRoot: [String: Any] = [:]
var configLoaded = false
var configHash: String?
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview

View File

@@ -214,9 +214,10 @@ enum CommandResolver {
subcommand: String,
extraArgs: [String] = [],
defaults: UserDefaults = .standard,
configRoot: [String: Any]? = nil,
searchPaths: [String]? = nil) -> [String]
{
let settings = self.connectionSettings(defaults: defaults)
let settings = self.connectionSettings(defaults: defaults, configRoot: configRoot)
if settings.mode == .remote, let ssh = self.sshNodeCommand(
subcommand: subcommand,
extraArgs: extraArgs,
@@ -264,12 +265,14 @@ enum CommandResolver {
subcommand: String,
extraArgs: [String] = [],
defaults: UserDefaults = .standard,
configRoot: [String: Any]? = nil,
searchPaths: [String]? = nil) -> [String]
{
self.clawdbotNodeCommand(
subcommand: subcommand,
extraArgs: extraArgs,
defaults: defaults,
configRoot: configRoot,
searchPaths: searchPaths)
}
@@ -384,15 +387,12 @@ enum CommandResolver {
let cliPath: String
}
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
let modeRaw = defaults.string(forKey: connectionModeKey)
let mode: AppState.ConnectionMode
if let modeRaw {
mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
} else {
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
mode = seen ? .local : .unconfigured
}
static func connectionSettings(
defaults: UserDefaults = .standard,
configRoot: [String: Any]? = nil) -> RemoteSettings
{
let root = configRoot ?? ClawdbotConfigFile.loadDict()
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
let target = defaults.string(forKey: remoteTargetKey) ?? ""
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? ""

View File

@@ -0,0 +1,204 @@
import Foundation
enum ConfigPathSegment: Hashable {
case key(String)
case index(Int)
}
typealias ConfigPath = [ConfigPathSegment]
struct ConfigUiHint {
let label: String?
let help: String?
let order: Double?
let advanced: Bool?
let sensitive: Bool?
let placeholder: String?
init(raw: [String: Any]) {
self.label = raw["label"] as? String
self.help = raw["help"] as? String
if let order = raw["order"] as? Double {
self.order = order
} else if let orderInt = raw["order"] as? Int {
self.order = Double(orderInt)
} else {
self.order = nil
}
self.advanced = raw["advanced"] as? Bool
self.sensitive = raw["sensitive"] as? Bool
self.placeholder = raw["placeholder"] as? String
}
}
struct ConfigSchemaNode {
let raw: [String: Any]
init?(raw: Any) {
guard let dict = raw as? [String: Any] else { return nil }
self.raw = dict
}
var title: String? { self.raw["title"] as? String }
var description: String? { self.raw["description"] as? String }
var enumValues: [Any]? { self.raw["enum"] as? [Any] }
var constValue: Any? { self.raw["const"] }
var explicitDefault: Any? { self.raw["default"] }
var requiredKeys: Set<String> {
Set((self.raw["required"] as? [String]) ?? [])
}
var typeList: [String] {
if let type = self.raw["type"] as? String { return [type] }
if let types = self.raw["type"] as? [String] { return types }
return []
}
var schemaType: String? {
let filtered = self.typeList.filter { $0 != "null" }
if let first = filtered.first { return first }
return self.typeList.first
}
var isNullSchema: Bool {
let types = self.typeList
return types.count == 1 && types.first == "null"
}
var properties: [String: ConfigSchemaNode] {
guard let props = self.raw["properties"] as? [String: Any] else { return [:] }
return props.compactMapValues { ConfigSchemaNode(raw: $0) }
}
var anyOf: [ConfigSchemaNode] {
guard let raw = self.raw["anyOf"] as? [Any] else { return [] }
return raw.compactMap { ConfigSchemaNode(raw: $0) }
}
var oneOf: [ConfigSchemaNode] {
guard let raw = self.raw["oneOf"] as? [Any] else { return [] }
return raw.compactMap { ConfigSchemaNode(raw: $0) }
}
var literalValue: Any? {
if let constValue { return constValue }
if let enumValues, enumValues.count == 1 { return enumValues[0] }
return nil
}
var items: ConfigSchemaNode? {
if let items = self.raw["items"] as? [Any], let first = items.first {
return ConfigSchemaNode(raw: first)
}
if let items = self.raw["items"] {
return ConfigSchemaNode(raw: items)
}
return nil
}
var additionalProperties: ConfigSchemaNode? {
if let additional = self.raw["additionalProperties"] as? [String: Any] {
return ConfigSchemaNode(raw: additional)
}
return nil
}
var allowsAdditionalProperties: Bool {
if let allow = self.raw["additionalProperties"] as? Bool { return allow }
return self.additionalProperties != nil
}
var defaultValue: Any {
if let value = self.raw["default"] { return value }
switch self.schemaType {
case "object":
return [String: Any]()
case "array":
return [Any]()
case "boolean":
return false
case "integer":
return 0
case "number":
return 0.0
case "string":
return ""
default:
return ""
}
}
func node(at path: ConfigPath) -> ConfigSchemaNode? {
var current: ConfigSchemaNode? = self
for segment in path {
guard let node = current else { return nil }
switch segment {
case let .key(key):
if node.schemaType == "object" {
if let next = node.properties[key] {
current = next
continue
}
if let additional = node.additionalProperties {
current = additional
continue
}
return nil
}
return nil
case .index:
guard node.schemaType == "array" else { return nil }
current = node.items
}
}
return current
}
}
func decodeUiHints(_ raw: [String: Any]) -> [String: ConfigUiHint] {
raw.reduce(into: [:]) { result, entry in
if let hint = entry.value as? [String: Any] {
result[entry.key] = ConfigUiHint(raw: hint)
}
}
}
func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiHint? {
let key = pathKey(path)
if let direct = hints[key] { return direct }
let segments = key.split(separator: ".").map(String.init)
for (hintKey, hint) in hints {
guard hintKey.contains("*") else { continue }
let hintSegments = hintKey.split(separator: ".").map(String.init)
guard hintSegments.count == segments.count else { continue }
var match = true
for (index, seg) in segments.enumerated() {
let hintSegment = hintSegments[index]
if hintSegment != "*", hintSegment != seg {
match = false
break
}
}
if match { return hint }
}
return nil
}
func isSensitivePath(_ path: ConfigPath) -> Bool {
let key = pathKey(path).lowercased()
return key.contains("token")
|| key.contains("password")
|| key.contains("secret")
|| key.contains("apikey")
|| key.hasSuffix("key")
}
func pathKey(_ path: ConfigPath) -> String {
path.compactMap { segment -> String? in
switch segment {
case let .key(key): return key
case .index: return nil
}
}
.joined(separator: ".")
}

View File

@@ -4,86 +4,54 @@ import SwiftUI
struct ConfigSettings: View {
private let isPreview = ProcessInfo.processInfo.isPreview
private let isNixMode = ProcessInfo.processInfo.isNixMode
private let state = AppStateStore.shared
private let labelColumnWidth: CGFloat = 120
private static let browserAttachOnlyHelp =
"When enabled, the browser server will only connect if the clawd browser is already running."
private static let browserProfileNote =
"Clawd uses a separate Chrome profile and ports (default 18791/18792) "
+ "so it wont interfere with your daily browser."
@State private var configModel: String = ""
@State private var configSaving = false
@Bindable var store: ChannelsStore
@State private var hasLoaded = false
@State private var models: [ModelChoice] = []
@State private var modelsLoading = false
@State private var modelSearchQuery: String = ""
@State private var isModelPickerOpen = false
@State private var modelError: String?
@State private var modelsSourceLabel: String?
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
@State private var allowAutosave = false
@State private var heartbeatMinutes: Int?
@State private var heartbeatBody: String = "HEARTBEAT"
// clawd browser settings (stored in ~/.clawdbot/clawdbot.json under "browser")
@State private var browserEnabled: Bool = true
@State private var browserControlUrl: String = "http://127.0.0.1:18791"
@State private var browserColorHex: String = "#FF4500"
@State private var browserAttachOnly: Bool = false
// Talk mode settings (stored in ~/.clawdbot/clawdbot.json under "talk")
@State private var talkVoiceId: String = ""
@State private var talkInterruptOnSpeech: Bool = true
@State private var talkApiKey: String = ""
@State private var gatewayApiKeyFound = false
@FocusState private var modelSearchFocused: Bool
private struct ConfigDraft {
let configModel: String
let heartbeatMinutes: Int?
let heartbeatBody: String
let browserEnabled: Bool
let browserControlUrl: String
let browserColorHex: String
let browserAttachOnly: Bool
let talkVoiceId: String
let talkApiKey: String
let talkInterruptOnSpeech: Bool
init(store: ChannelsStore = .shared) {
self.store = store
}
var body: some View {
ScrollView { self.content }
.onChange(of: self.modelCatalogPath) { _, _ in
Task { await self.loadModels() }
}
.onChange(of: self.modelCatalogReloadBump) { _, _ in
Task { await self.loadModels() }
}
.task {
guard !self.hasLoaded else { return }
guard !self.isPreview else { return }
self.hasLoaded = true
await self.loadConfig()
await self.loadModels()
await self.refreshGatewayTalkApiKey()
self.allowAutosave = true
}
ScrollView {
self.content
}
.task {
guard !self.hasLoaded else { return }
guard !self.isPreview else { return }
self.hasLoaded = true
await self.store.loadConfigSchema()
await self.store.loadConfig()
}
}
}
extension ConfigSettings {
private var content: some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 16) {
self.header
self.agentSection
.disabled(self.isNixMode)
self.heartbeatSection
.disabled(self.isNixMode)
self.talkSection
.disabled(self.isNixMode)
self.browserSection
.disabled(self.isNixMode)
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.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if self.store.configDirty, !self.isNixMode {
Text("Unsaved changes")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -94,843 +62,33 @@ extension ConfigSettings {
@ViewBuilder
private var header: some View {
Text("Clawdbot CLI config")
Text("Config")
.font(.title3.weight(.semibold))
Text(self.isNixMode
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
: "Edit ~/.clawdbot/clawdbot.json (agent / session / routing / messages).")
: "Edit ~/.clawdbot/clawdbot.json using the schema-driven form.")
.font(.callout)
.foregroundStyle(.secondary)
}
private var agentSection: some View {
GroupBox("Agent") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Model")
VStack(alignment: .leading, spacing: 6) {
self.modelPickerField
self.modelMetaLabels
}
}
private var actionRow: some View {
HStack(spacing: 10) {
Button("Reload") {
Task { await self.store.reloadConfigDraft() }
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.disabled(!self.store.configLoaded)
private var modelPickerField: some View {
Button {
guard !self.modelsLoading else { return }
self.isModelPickerOpen = true
} label: {
HStack(spacing: 8) {
Text(self.modelPickerLabel)
.foregroundStyle(self.modelPickerLabelIsPlaceholder ? .secondary : .primary)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 8)
Image(systemName: "chevron.up.chevron.down")
.foregroundStyle(.secondary)
Button(self.store.isSavingConfig ? "Saving…" : "Save") {
Task { await self.store.saveConfigDraft() }
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
.disabled(self.isNixMode || self.store.isSavingConfig || !self.store.configDirty)
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 6)
.fill(
Color(nsColor: .textBackgroundColor)))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(
Color.secondary.opacity(0.25),
lineWidth: 1))
.popover(isPresented: self.$isModelPickerOpen, arrowEdge: .bottom) {
self.modelPickerPopover
}
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
.onChange(of: self.isModelPickerOpen) { _, isOpen in
if isOpen {
self.modelSearchQuery = ""
self.modelSearchFocused = true
}
}
}
private var modelPickerPopover: some View {
VStack(alignment: .leading, spacing: 10) {
TextField("Search models", text: self.$modelSearchQuery)
.textFieldStyle(.roundedBorder)
.focused(self.$modelSearchFocused)
.controlSize(.small)
.onSubmit {
if let exact = self.exactMatchForQuery() {
self.selectModel(exact)
return
}
if let manual = self.manualEntryCandidate {
self.selectManualModel(manual)
return
}
if self.modelSearchMatches.count == 1 {
self.selectModel(self.modelSearchMatches[0])
}
}
List {
if self.modelSearchMatches.isEmpty {
Text("No models match \"\(self.modelSearchQuery)\"")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
ForEach(self.modelSearchMatches) { choice in
Button {
self.selectModel(choice)
} label: {
HStack(spacing: 8) {
Text(choice.name)
.lineLimit(1)
Spacer(minLength: 8)
Text(choice.provider.uppercased())
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.vertical, 2)
.padding(.horizontal, 6)
.background(Color.secondary.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 4))
}
.padding(.vertical, 2)
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
}
}
if let manual = self.manualEntryCandidate {
Button("Use \"\(manual)\"") {
self.selectManualModel(manual)
}
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
}
}
.listStyle(.inset)
}
.frame(width: 340, height: 260)
.padding(8)
}
@ViewBuilder
private var modelMetaLabels: some View {
if self.shouldShowProviderHintForSelection {
self.statusLine(label: "Tip: prefer provider/model (e.g. openai-codex/gpt-5.2)", color: .orange)
}
if let contextLabel = self.selectedContextLabel {
Text(contextLabel)
.font(.footnote)
.foregroundStyle(.secondary)
}
if let authMode = self.selectedAnthropicAuthMode {
HStack(spacing: 8) {
Circle()
.fill(authMode.isConfigured ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text("Anthropic auth: \(authMode.shortLabel)")
}
.font(.footnote)
.foregroundStyle(authMode.isConfigured ? Color.secondary : Color.orange)
.help(self.anthropicAuthHelpText)
AnthropicAuthControls(connectionMode: self.state.connectionMode)
}
if let modelError {
Text(modelError)
.font(.footnote)
.foregroundStyle(.secondary)
}
if let modelsSourceLabel {
Text("Model catalog: \(modelsSourceLabel)")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
private var anthropicAuthHelpText: String {
"Determined from Clawdbot OAuth token file (~/.clawdbot/credentials/oauth.json) " +
"or environment variables (ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)."
}
private var heartbeatSection: some View {
GroupBox("Heartbeat") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Schedule")
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 12) {
Stepper(
value: Binding(
get: { self.heartbeatMinutes ?? 10 },
set: { self.heartbeatMinutes = $0; self.autosaveConfig() }),
in: 0...720)
{
Text("Every \(self.heartbeatMinutes ?? 10) min")
.frame(width: 150, alignment: .leading)
}
.help("Set to 0 to disable automatic heartbeats")
TextField("HEARTBEAT", text: self.$heartbeatBody)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.onChange(of: self.heartbeatBody) { _, _ in
self.autosaveConfig()
}
.help("Message body sent on each heartbeat")
}
Text("Heartbeats keep agent sessions warm; 0 minutes disables them.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var browserSection: some View {
GroupBox("Browser (clawd)") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$browserEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
.onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() }
}
GridRow {
self.gridLabel("Control URL")
TextField("http://127.0.0.1:18791", text: self.$browserControlUrl)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.disabled(!self.browserEnabled)
.onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() }
}
GridRow {
self.gridLabel("Browser path")
VStack(alignment: .leading, spacing: 2) {
if let label = self.browserPathLabel {
Text(label)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
} else {
Text("")
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Accent")
HStack(spacing: 8) {
TextField("#FF4500", text: self.$browserColorHex)
.textFieldStyle(.roundedBorder)
.frame(width: 120)
.disabled(!self.browserEnabled)
.onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() }
Circle()
.fill(self.browserColor)
.frame(width: 12, height: 12)
.overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1))
Text("lobster-orange")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
GridRow {
self.gridLabel("Attach only")
Toggle("", isOn: self.$browserAttachOnly)
.labelsHidden()
.toggleStyle(.checkbox)
.disabled(!self.browserEnabled)
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
.help(Self.browserAttachOnlyHelp)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(Self.browserProfileNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var talkSection: some View {
GroupBox("Talk Mode") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Voice ID")
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
TextField("ElevenLabs voice ID", text: self.$talkVoiceId)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.onChange(of: self.talkVoiceId) { _, _ in self.autosaveConfig() }
if !self.talkVoiceSuggestions.isEmpty {
Menu {
ForEach(self.talkVoiceSuggestions, id: \.self) { value in
Button(value) {
self.talkVoiceId = value
self.autosaveConfig()
}
}
} label: {
Label("Suggestions", systemImage: "chevron.up.chevron.down")
}
.fixedSize()
}
}
Text("Defaults to ELEVENLABS_VOICE_ID / SAG_VOICE_ID if unset.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
GridRow {
self.gridLabel("API key")
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
SecureField("ELEVENLABS_API_KEY", text: self.$talkApiKey)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.disabled(self.hasEnvApiKey)
.onChange(of: self.talkApiKey) { _, _ in self.autosaveConfig() }
if !self.hasEnvApiKey, !self.talkApiKey.isEmpty {
Button("Clear") {
self.talkApiKey = ""
self.autosaveConfig()
}
}
}
self.statusLine(label: self.apiKeyStatusLabel, color: self.apiKeyStatusColor)
if self.hasEnvApiKey {
Text("Using ELEVENLABS_API_KEY from the environment.")
.font(.footnote)
.foregroundStyle(.secondary)
} else if self.gatewayApiKeyFound,
self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
Text("Using API key from the gateway profile.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
GridRow {
self.gridLabel("Interrupt")
Toggle("Stop speaking when you start talking", isOn: self.$talkInterruptOnSpeech)
.labelsHidden()
.toggleStyle(.checkbox)
.onChange(of: self.talkInterruptOnSpeech) { _, _ in self.autosaveConfig() }
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func gridLabel(_ text: String) -> some View {
Text(text)
.foregroundStyle(.secondary)
.frame(width: self.labelColumnWidth, alignment: .leading)
}
private func statusLine(label: String, color: Color) -> some View {
HStack(spacing: 6) {
Circle()
.fill(color)
.frame(width: 6, height: 6)
Text(label)
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(.top, 2)
.buttonStyle(.bordered)
}
}
extension ConfigSettings {
private func loadConfig() async {
let parsed = await ConfigStore.load()
let agents = parsed["agents"] as? [String: Any]
let defaults = agents?["defaults"] as? [String: Any]
let heartbeat = defaults?["heartbeat"] as? [String: Any]
let heartbeatEvery = heartbeat?["every"] as? String
let heartbeatBody = heartbeat?["prompt"] as? String
let browser = parsed["browser"] as? [String: Any]
let talk = parsed["talk"] as? [String: Any]
let loadedModel: String = {
if let raw = defaults?["model"] as? String { return raw }
if let modelDict = defaults?["model"] as? [String: Any],
let primary = modelDict["primary"] as? String { return primary }
return ""
}()
if !loadedModel.isEmpty {
self.configModel = loadedModel
} else {
self.configModel = SessionLoader.fallbackModel
}
if let heartbeatEvery {
let digits = heartbeatEvery.trimmingCharacters(in: .whitespacesAndNewlines)
.prefix { $0.isNumber }
if let minutes = Int(digits) {
self.heartbeatMinutes = minutes
}
}
if let heartbeatBody, !heartbeatBody.isEmpty { self.heartbeatBody = heartbeatBody }
if let browser {
if let enabled = browser["enabled"] as? Bool { self.browserEnabled = enabled }
if let url = browser["controlUrl"] as? String, !url.isEmpty { self.browserControlUrl = url }
if let color = browser["color"] as? String, !color.isEmpty { self.browserColorHex = color }
if let attachOnly = browser["attachOnly"] as? Bool { self.browserAttachOnly = attachOnly }
}
if let talk {
if let voice = talk["voiceId"] as? String { self.talkVoiceId = voice }
if let apiKey = talk["apiKey"] as? String { self.talkApiKey = apiKey }
if let interrupt = talk["interruptOnSpeech"] as? Bool {
self.talkInterruptOnSpeech = interrupt
}
}
}
private func refreshGatewayTalkApiKey() async {
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .configGet,
params: nil,
timeoutMs: 8000)
let talk = snap.config?["talk"]?.dictionaryValue
let apiKey = talk?["apiKey"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
self.gatewayApiKeyFound = !(apiKey ?? "").isEmpty
} catch {
self.gatewayApiKeyFound = false
}
}
private func autosaveConfig() {
guard self.allowAutosave, !self.isNixMode else { return }
Task { await self.saveConfig() }
}
private func saveConfig() async {
guard !self.configSaving else { return }
self.configSaving = true
defer { self.configSaving = false }
let configModel = self.configModel
let heartbeatMinutes = self.heartbeatMinutes
let heartbeatBody = self.heartbeatBody
let browserEnabled = self.browserEnabled
let browserControlUrl = self.browserControlUrl
let browserColorHex = self.browserColorHex
let browserAttachOnly = self.browserAttachOnly
let talkVoiceId = self.talkVoiceId
let talkApiKey = self.talkApiKey
let talkInterruptOnSpeech = self.talkInterruptOnSpeech
let draft = ConfigDraft(
configModel: configModel,
heartbeatMinutes: heartbeatMinutes,
heartbeatBody: heartbeatBody,
browserEnabled: browserEnabled,
browserControlUrl: browserControlUrl,
browserColorHex: browserColorHex,
browserAttachOnly: browserAttachOnly,
talkVoiceId: talkVoiceId,
talkApiKey: talkApiKey,
talkInterruptOnSpeech: talkInterruptOnSpeech)
let errorMessage = await ConfigSettings.buildAndSaveConfig(draft)
if let errorMessage {
self.modelError = errorMessage
}
}
@MainActor
private static func buildAndSaveConfig(_ draft: ConfigDraft) async -> String? {
var root = await ConfigStore.load()
var agents = root["agents"] as? [String: Any] ?? [:]
var defaults = agents["defaults"] as? [String: Any] ?? [:]
var browser = root["browser"] as? [String: Any] ?? [:]
var talk = root["talk"] as? [String: Any] ?? [:]
let chosenModel = draft.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedModel = chosenModel
if !trimmedModel.isEmpty {
var model = defaults["model"] as? [String: Any] ?? [:]
model["primary"] = trimmedModel
defaults["model"] = model
var models = defaults["models"] as? [String: Any] ?? [:]
if models[trimmedModel] == nil {
models[trimmedModel] = [:]
}
defaults["models"] = models
}
if let heartbeatMinutes = draft.heartbeatMinutes {
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
heartbeat["every"] = "\(heartbeatMinutes)m"
defaults["heartbeat"] = heartbeat
}
let trimmedBody = draft.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedBody.isEmpty {
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
heartbeat["prompt"] = trimmedBody
defaults["heartbeat"] = heartbeat
}
if defaults.isEmpty {
agents.removeValue(forKey: "defaults")
} else {
agents["defaults"] = defaults
}
if agents.isEmpty {
root.removeValue(forKey: "agents")
} else {
root["agents"] = agents
}
browser["enabled"] = draft.browserEnabled
let trimmedUrl = draft.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedUrl.isEmpty { browser["controlUrl"] = trimmedUrl }
let trimmedColor = draft.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedColor.isEmpty { browser["color"] = trimmedColor }
browser["attachOnly"] = draft.browserAttachOnly
root["browser"] = browser
let trimmedVoice = draft.talkVoiceId.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedVoice.isEmpty {
talk.removeValue(forKey: "voiceId")
} else {
talk["voiceId"] = trimmedVoice
}
let trimmedApiKey = draft.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedApiKey.isEmpty {
talk.removeValue(forKey: "apiKey")
} else {
talk["apiKey"] = trimmedApiKey
}
talk["interruptOnSpeech"] = draft.talkInterruptOnSpeech
root["talk"] = talk
do {
try await ConfigStore.save(root)
return nil
} catch {
return error.localizedDescription
}
}
}
extension ConfigSettings {
private var browserColor: Color {
let raw = self.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
let hex = raw.hasPrefix("#") ? String(raw.dropFirst()) : raw
guard hex.count == 6, let value = Int(hex, radix: 16) else { return .orange }
let r = Double((value >> 16) & 0xFF) / 255.0
let g = Double((value >> 8) & 0xFF) / 255.0
let b = Double(value & 0xFF) / 255.0
return Color(red: r, green: g, blue: b)
}
private var talkVoiceSuggestions: [String] {
let env = ProcessInfo.processInfo.environment
let candidates = [
self.talkVoiceId,
env["ELEVENLABS_VOICE_ID"] ?? "",
env["SAG_VOICE_ID"] ?? "",
]
var seen = Set<String>()
return candidates
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.filter { seen.insert($0).inserted }
}
private var hasEnvApiKey: Bool {
let raw = ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"] ?? ""
return !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var apiKeyStatusLabel: String {
if self.hasEnvApiKey { return "ElevenLabs API key: found (environment)" }
if !self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "ElevenLabs API key: stored in config"
}
if self.gatewayApiKeyFound { return "ElevenLabs API key: found (gateway)" }
return "ElevenLabs API key: missing"
}
private var apiKeyStatusColor: Color {
if self.hasEnvApiKey { return .green }
if !self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return .green }
if self.gatewayApiKeyFound { return .green }
return .red
}
private var browserPathLabel: String? {
guard self.browserEnabled else { return nil }
let host = (URL(string: self.browserControlUrl)?.host ?? "").lowercased()
if !host.isEmpty, !Self.isLoopbackHost(host) {
return "remote (\(host))"
}
guard let candidate = Self.detectedBrowserCandidate() else { return nil }
return candidate.executablePath ?? candidate.appPath
}
private struct BrowserCandidate {
let name: String
let appPath: String
let executablePath: String?
}
private static func detectedBrowserCandidate() -> BrowserCandidate? {
let candidates: [(name: String, appName: String)] = [
("Google Chrome Canary", "Google Chrome Canary.app"),
("Chromium", "Chromium.app"),
("Google Chrome", "Google Chrome.app"),
]
let roots = [
"/Applications",
"\(NSHomeDirectory())/Applications",
]
let fm = FileManager.default
for (name, appName) in candidates {
for root in roots {
let appPath = "\(root)/\(appName)"
if fm.fileExists(atPath: appPath) {
let bundle = Bundle(url: URL(fileURLWithPath: appPath))
let exec = bundle?.executableURL?.path
return BrowserCandidate(name: name, appPath: appPath, executablePath: exec)
}
}
}
return nil
}
private static func isLoopbackHost(_ host: String) -> Bool {
if host == "localhost" { return true }
if host == "127.0.0.1" { return true }
if host == "::1" { return true }
return false
}
}
extension ConfigSettings {
private func loadModels() async {
guard !self.modelsLoading else { return }
self.modelsLoading = true
self.modelError = nil
self.modelsSourceLabel = nil
do {
let res: ModelsListResult =
try await GatewayConnection.shared
.requestDecoded(
method: .modelsList,
timeoutMs: 15000)
self.models = res.models
self.modelsSourceLabel = "gateway"
} catch {
do {
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
self.models = loaded
self.modelsSourceLabel = "local fallback"
} catch {
self.modelError = error.localizedDescription
self.models = []
}
}
self.modelsLoading = false
}
private struct ModelsListResult: Decodable {
let models: [ModelChoice]
}
private var modelSearchMatches: [ModelChoice] {
let raw = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !raw.isEmpty else { return self.models }
let tokens = raw
.split(whereSeparator: { $0.isWhitespace })
.map { token in
token.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
}
.filter { !$0.isEmpty }
guard !tokens.isEmpty else { return self.models }
return self.models.filter { choice in
let haystack = [
choice.id,
choice.name,
choice.provider,
self.modelRef(for: choice),
]
.joined(separator: " ")
.lowercased()
return tokens.allSatisfy { haystack.contains($0) }
}
}
private var selectedModelChoice: ModelChoice? {
guard !self.configModel.isEmpty else { return nil }
return self.models.first(where: { self.matchesConfigModel($0) })
}
private var modelPickerLabel: String {
if let choice = self.selectedModelChoice {
return "\(choice.name)\(choice.provider.uppercased())"
}
if !self.configModel.isEmpty { return self.configModel }
return "Select model"
}
private var modelPickerLabelIsPlaceholder: Bool {
self.configModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var manualEntryCandidate: String? {
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
guard !cleaned.isEmpty else { return nil }
guard !self.isKnownModelRef(cleaned) else { return nil }
return cleaned
}
private func isKnownModelRef(_ value: String) -> Bool {
let needle = value.lowercased()
return self.models.contains { choice in
choice.id.lowercased() == needle
|| self.modelRef(for: choice).lowercased() == needle
}
}
private func modelRef(for choice: ModelChoice) -> String {
let id = choice.id.trimmingCharacters(in: .whitespacesAndNewlines)
let provider = choice.provider.trimmingCharacters(in: .whitespacesAndNewlines)
guard !provider.isEmpty else { return id }
let normalizedProvider = provider.lowercased()
if id.lowercased().hasPrefix("\(normalizedProvider)/") {
return id
}
return "\(normalizedProvider)/\(id)"
}
private func matchesConfigModel(_ choice: ModelChoice) -> Bool {
let configured = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
guard !configured.isEmpty else { return false }
if configured.caseInsensitiveCompare(choice.id) == .orderedSame { return true }
let ref = self.modelRef(for: choice)
return configured.caseInsensitiveCompare(ref) == .orderedSame
}
private func exactMatchForQuery() -> ModelChoice? {
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%")).lowercased()
guard !cleaned.isEmpty else { return nil }
return self.models.first(where: { choice in
let id = choice.id.lowercased()
if id == cleaned { return true }
return self.modelRef(for: choice).lowercased() == cleaned
})
}
private var shouldShowProviderHint: Bool {
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
return !cleaned.contains("/")
}
private var shouldShowProviderHintForSelection: Bool {
let trimmed = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
return !trimmed.contains("/")
}
private func selectModel(_ choice: ModelChoice) {
self.configModel = self.modelRef(for: choice)
self.autosaveConfig()
self.isModelPickerOpen = false
}
private func selectManualModel(_ value: String) {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let slash = trimmed.firstIndex(of: "/") {
let provider = trimmed[..<slash].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let model = trimmed[trimmed.index(after: slash)...].trimmingCharacters(in: .whitespacesAndNewlines)
self.configModel = provider.isEmpty ? String(model) : "\(provider)/\(model)"
} else {
self.configModel = trimmed
}
self.autosaveConfig()
self.isModelPickerOpen = false
}
private var selectedContextLabel: String? {
guard
let choice = self.selectedModelChoice,
let context = choice.contextWindow
else {
return nil
}
let human = context >= 1000 ? "\(context / 1000)k" : "\(context)"
return "Context window: \(human) tokens"
}
private var selectedAnthropicAuthMode: AnthropicAuthMode? {
guard let choice = self.selectedModelChoice else { return nil }
guard choice.provider.lowercased() == "anthropic" else { return nil }
return AnthropicAuthResolver.resolve()
}
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 10) {
configuration.label
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
configuration.content
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
#if DEBUG
struct ConfigSettings_Previews: PreviewProvider {
static var previews: some View {
ConfigSettings()
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}
#endif

View File

@@ -12,6 +12,9 @@ final class ConnectionModeCoordinator {
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
switch mode {
case .unconfigured:
if let error = await NodeServiceManager.stop() {
NodesStore.shared.lastError = "Node service stop failed: \(error)"
}
await RemoteTunnelManager.shared.stopAll()
WebChatManager.shared.resetTunnels()
GatewayProcessManager.shared.stop()
@@ -20,6 +23,9 @@ 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 RemoteTunnelManager.shared.stopAll()
WebChatManager.shared.resetTunnels()
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
@@ -50,6 +56,9 @@ final class ConnectionModeCoordinator {
WebChatManager.shared.resetTunnels()
do {
if let error = await NodeServiceManager.start() {
NodesStore.shared.lastError = "Node service start failed: \(error)"
}
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote(

View File

@@ -0,0 +1,49 @@
import Foundation
enum EffectiveConnectionModeSource: Sendable, Equatable {
case configMode
case configRemoteURL
case userDefaults
case onboarding
}
struct EffectiveConnectionMode: Sendable, Equatable {
let mode: AppState.ConnectionMode
let source: EffectiveConnectionModeSource
}
enum ConnectionModeResolver {
static func resolve(
root: [String: Any],
defaults: UserDefaults = .standard) -> EffectiveConnectionMode
{
let gateway = root["gateway"] as? [String: Any]
let configModeRaw = (gateway?["mode"] as? String) ?? ""
let configMode = configModeRaw
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
switch configMode {
case "local":
return EffectiveConnectionMode(mode: .local, source: .configMode)
case "remote":
return EffectiveConnectionMode(mode: .remote, source: .configMode)
default:
break
}
let remoteURLRaw = ((gateway?["remote"] as? [String: Any])?["url"] as? String) ?? ""
let remoteURL = remoteURLRaw.trimmingCharacters(in: .whitespacesAndNewlines)
if !remoteURL.isEmpty {
return EffectiveConnectionMode(mode: .remote, source: .configRemoteURL)
}
if let storedModeRaw = defaults.string(forKey: connectionModeKey) {
let storedMode = AppState.ConnectionMode(rawValue: storedModeRaw) ?? .local
return EffectiveConnectionMode(mode: storedMode, source: .userDefaults)
}
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
return EffectiveConnectionMode(mode: seen ? .local : .unconfigured, source: .onboarding)
}
}

View File

@@ -1,707 +0,0 @@
import SwiftUI
extension ConnectionsSettings {
func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View {
GroupBox(title) {
VStack(alignment: .leading, spacing: 10) {
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@ViewBuilder
func channelHeaderActions(_ channel: ConnectionChannel) -> some View {
HStack(spacing: 8) {
if channel == .whatsapp {
Button("Logout") {
Task { await self.store.logoutWhatsApp() }
}
.buttonStyle(.bordered)
.disabled(self.store.whatsappBusy)
}
if channel == .telegram {
Button("Logout") {
Task { await self.store.logoutTelegram() }
}
.buttonStyle(.bordered)
.disabled(self.store.telegramBusy)
}
Button {
Task { await self.store.refresh(probe: true) }
} label: {
if self.store.isRefreshing {
ProgressView().controlSize(.small)
} else {
Text("Refresh")
}
}
.buttonStyle(.bordered)
.disabled(self.store.isRefreshing)
}
.controlSize(.small)
}
var whatsAppSection: some View {
VStack(alignment: .leading, spacing: 16) {
self.formSection("Linking") {
if let message = self.store.whatsappLoginMessage {
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let qr = self.store.whatsappLoginQrDataUrl, let image = self.qrImage(from: qr) {
Image(nsImage: image)
.resizable()
.interpolation(.none)
.frame(width: 180, height: 180)
.cornerRadius(8)
}
HStack(spacing: 12) {
Button {
Task { await self.store.startWhatsAppLogin(force: false) }
} label: {
if self.store.whatsappBusy {
ProgressView().controlSize(.small)
} else {
Text("Show QR")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.whatsappBusy)
Button("Relink") {
Task { await self.store.startWhatsAppLogin(force: true) }
}
.buttonStyle(.bordered)
.disabled(self.store.whatsappBusy)
}
.font(.caption)
}
}
}
var telegramSection: some View {
VStack(alignment: .leading, spacing: 16) {
self.formSection("Authentication") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Bot token")
if self.showTelegramToken {
TextField("123:abc", text: self.$store.telegramToken)
.textFieldStyle(.roundedBorder)
.disabled(self.isTelegramTokenLocked)
} else {
SecureField("123:abc", text: self.$store.telegramToken)
.textFieldStyle(.roundedBorder)
.disabled(self.isTelegramTokenLocked)
}
Toggle("Show", isOn: self.$showTelegramToken)
.toggleStyle(.switch)
.disabled(self.isTelegramTokenLocked)
}
}
}
self.formSection("Access") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Require mention")
Toggle("", isOn: self.$store.telegramRequireMention)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Allow from")
TextField("123456789, @team", text: self.$store.telegramAllowFrom)
.textFieldStyle(.roundedBorder)
}
}
}
self.formSection("Webhook") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Webhook URL")
TextField("https://example.com/telegram-webhook", text: self.$store.telegramWebhookUrl)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Webhook secret")
TextField("secret", text: self.$store.telegramWebhookSecret)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Webhook path")
TextField("/telegram-webhook", text: self.$store.telegramWebhookPath)
.textFieldStyle(.roundedBorder)
}
}
}
self.formSection("Network") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Proxy")
TextField("socks5://localhost:9050", text: self.$store.telegramProxy)
.textFieldStyle(.roundedBorder)
}
}
}
if self.isTelegramTokenLocked {
Text("Token set via TELEGRAM_BOT_TOKEN env; config edits wont override it.")
.font(.caption)
.foregroundStyle(.secondary)
}
self.configStatusMessage
HStack(spacing: 12) {
Button {
Task { await self.store.saveTelegramConfig() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig)
Spacer()
}
.font(.caption)
}
}
var discordSection: some View {
VStack(alignment: .leading, spacing: 16) {
self.formSection("Authentication") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$store.discordEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Bot token")
if self.showDiscordToken {
TextField("bot token", text: self.$store.discordToken)
.textFieldStyle(.roundedBorder)
.disabled(self.isDiscordTokenLocked)
} else {
SecureField("bot token", text: self.$store.discordToken)
.textFieldStyle(.roundedBorder)
.disabled(self.isDiscordTokenLocked)
}
Toggle("Show", isOn: self.$showDiscordToken)
.toggleStyle(.switch)
.disabled(self.isDiscordTokenLocked)
}
}
}
self.formSection("Messages") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Allow DMs from")
TextField("123456789, username#1234", text: self.$store.discordAllowFrom)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("DMs enabled")
Toggle("", isOn: self.$store.discordDmEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Group DMs")
Toggle("", isOn: self.$store.discordGroupEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Group channels")
TextField("channelId1, channelId2", text: self.$store.discordGroupChannels)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Reply to mode")
Picker("", selection: self.$store.discordReplyToMode) {
Text("off").tag("off")
Text("first").tag("first")
Text("all").tag("all")
}
.labelsHidden()
}
}
}
self.formSection("Limits") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Media max MB")
TextField("8", text: self.$store.discordMediaMaxMb)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("History limit")
TextField("20", text: self.$store.discordHistoryLimit)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Text chunk limit")
TextField("2000", text: self.$store.discordTextChunkLimit)
.textFieldStyle(.roundedBorder)
}
}
}
self.formSection("Slash command") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$store.discordSlashEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Slash name")
TextField("clawd", text: self.$store.discordSlashName)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Session prefix")
TextField("discord:slash", text: self.$store.discordSlashSessionPrefix)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Ephemeral")
Toggle("", isOn: self.$store.discordSlashEphemeral)
.labelsHidden()
.toggleStyle(.checkbox)
}
}
}
GroupBox("Guilds") {
VStack(alignment: .leading, spacing: 12) {
ForEach(self.$store.discordGuilds) { $guild in
VStack(alignment: .leading, spacing: 10) {
HStack {
TextField("guild id or slug", text: $guild.key)
.textFieldStyle(.roundedBorder)
Button("Remove") {
self.store.discordGuilds.removeAll { $0.id == guild.id }
}
.buttonStyle(.bordered)
}
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Slug")
TextField("optional slug", text: $guild.slug)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Require mention")
Toggle("", isOn: $guild.requireMention)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Reaction notifications")
Picker("", selection: $guild.reactionNotifications) {
Text("Off").tag("off")
Text("Own").tag("own")
Text("All").tag("all")
Text("Allowlist").tag("allowlist")
}
.labelsHidden()
.pickerStyle(.segmented)
}
GridRow {
self.gridLabel("Users allowlist")
TextField("123456789, username#1234", text: $guild.users)
.textFieldStyle(.roundedBorder)
}
}
Text("Channels")
.font(.caption)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 8) {
ForEach($guild.channels) { $channel in
HStack(spacing: 10) {
TextField("channel id or slug", text: $channel.key)
.textFieldStyle(.roundedBorder)
Toggle("Allow", isOn: $channel.allow)
.toggleStyle(.checkbox)
Toggle("Require mention", isOn: $channel.requireMention)
.toggleStyle(.checkbox)
Button("Remove") {
guild.channels.removeAll { $0.id == channel.id }
}
.buttonStyle(.bordered)
}
}
Button("Add channel") {
guild.channels.append(DiscordGuildChannelForm())
}
.buttonStyle(.bordered)
}
}
.padding(10)
.background(Color.secondary.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
Button("Add guild") {
self.store.discordGuilds.append(DiscordGuildForm())
}
.buttonStyle(.bordered)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
GroupBox("Tool actions") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Reactions")
Toggle("", isOn: self.$store.discordActionReactions)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Stickers")
Toggle("", isOn: self.$store.discordActionStickers)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Polls")
Toggle("", isOn: self.$store.discordActionPolls)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Permissions")
Toggle("", isOn: self.$store.discordActionPermissions)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Messages")
Toggle("", isOn: self.$store.discordActionMessages)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Threads")
Toggle("", isOn: self.$store.discordActionThreads)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Pins")
Toggle("", isOn: self.$store.discordActionPins)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Search")
Toggle("", isOn: self.$store.discordActionSearch)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Member info")
Toggle("", isOn: self.$store.discordActionMemberInfo)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Role info")
Toggle("", isOn: self.$store.discordActionRoleInfo)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Channel info")
Toggle("", isOn: self.$store.discordActionChannelInfo)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Voice status")
Toggle("", isOn: self.$store.discordActionVoiceStatus)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Events")
Toggle("", isOn: self.$store.discordActionEvents)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Role changes")
Toggle("", isOn: self.$store.discordActionRoles)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Moderation")
Toggle("", isOn: self.$store.discordActionModeration)
.labelsHidden()
.toggleStyle(.checkbox)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
if self.isDiscordTokenLocked {
Text("Token set via DISCORD_BOT_TOKEN env; config edits wont override it.")
.font(.caption)
.foregroundStyle(.secondary)
}
self.configStatusMessage
HStack(spacing: 12) {
Button {
Task { await self.store.saveDiscordConfig() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig)
Spacer()
}
.font(.caption)
}
}
var signalSection: some View {
VStack(alignment: .leading, spacing: 16) {
self.formSection("Connection") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$store.signalEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Account")
TextField("+15551234567", text: self.$store.signalAccount)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("HTTP URL")
TextField("http://127.0.0.1:8080", text: self.$store.signalHttpUrl)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("HTTP host")
TextField("127.0.0.1", text: self.$store.signalHttpHost)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("HTTP port")
TextField("8080", text: self.$store.signalHttpPort)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("CLI path")
TextField("signal-cli", text: self.$store.signalCliPath)
.textFieldStyle(.roundedBorder)
}
}
}
self.formSection("Behavior") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Auto start")
Toggle("", isOn: self.$store.signalAutoStart)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Receive mode")
Picker("", selection: self.$store.signalReceiveMode) {
Text("Default").tag("")
Text("on-start").tag("on-start")
Text("manual").tag("manual")
}
.labelsHidden()
.pickerStyle(.menu)
}
GridRow {
self.gridLabel("Ignore attachments")
Toggle("", isOn: self.$store.signalIgnoreAttachments)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Ignore stories")
Toggle("", isOn: self.$store.signalIgnoreStories)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Read receipts")
Toggle("", isOn: self.$store.signalSendReadReceipts)
.labelsHidden()
.toggleStyle(.checkbox)
}
}
}
self.formSection("Access & limits") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Allow from")
TextField("12345, +1555", text: self.$store.signalAllowFrom)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Media max MB")
TextField("8", text: self.$store.signalMediaMaxMb)
.textFieldStyle(.roundedBorder)
}
}
}
self.configStatusMessage
HStack(spacing: 12) {
Button {
Task { await self.store.saveSignalConfig() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig)
Spacer()
}
.font(.caption)
}
}
var imessageSection: some View {
VStack(alignment: .leading, spacing: 16) {
self.formSection("Connection") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$store.imessageEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("CLI path")
TextField("imsg", text: self.$store.imessageCliPath)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("DB path")
TextField("~/Library/Messages/chat.db", text: self.$store.imessageDbPath)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Service")
Picker("", selection: self.$store.imessageService) {
Text("auto").tag("auto")
Text("imessage").tag("imessage")
Text("sms").tag("sms")
}
.labelsHidden()
.pickerStyle(.menu)
}
}
}
self.formSection("Behavior") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Region")
TextField("US", text: self.$store.imessageRegion)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Allow from")
TextField("chat_id:101, +1555", text: self.$store.imessageAllowFrom)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Attachments")
Toggle("", isOn: self.$store.imessageIncludeAttachments)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Media max MB")
TextField("16", text: self.$store.imessageMediaMaxMb)
.textFieldStyle(.roundedBorder)
}
}
}
self.configStatusMessage
HStack(spacing: 12) {
Button {
Task { await self.store.saveIMessageConfig() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig)
Spacer()
}
.font(.caption)
}
}
@ViewBuilder
var configStatusMessage: some View {
if let status = self.store.configStatus {
Text(status)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
func gridLabel(_ text: String) -> some View {
Text(text)
.font(.callout.weight(.semibold))
.frame(width: 140, alignment: .leading)
}
}

View File

@@ -1,63 +0,0 @@
import AppKit
import SwiftUI
struct ConnectionsSettings: View {
enum ConnectionChannel: String, CaseIterable, Identifiable, Hashable {
case whatsapp
case telegram
case discord
case signal
case imessage
var id: String { self.rawValue }
var sortOrder: Int {
switch self {
case .whatsapp: 0
case .telegram: 1
case .discord: 2
case .signal: 3
case .imessage: 4
}
}
var title: String {
switch self {
case .whatsapp: "WhatsApp"
case .telegram: "Telegram"
case .discord: "Discord"
case .signal: "Signal"
case .imessage: "iMessage"
}
}
var detailTitle: String {
switch self {
case .whatsapp: "WhatsApp Web"
case .telegram: "Telegram Bot"
case .discord: "Discord Bot"
case .signal: "Signal REST"
case .imessage: "iMessage (imsg)"
}
}
var systemImage: String {
switch self {
case .whatsapp: "message"
case .telegram: "paperplane"
case .discord: "bubble.left.and.bubble.right"
case .signal: "antenna.radiowaves.left.and.right"
case .imessage: "message.fill"
}
}
}
@Bindable var store: ConnectionsStore
@State var selectedChannel: ConnectionChannel?
@State var showTelegramToken = false
@State var showDiscordToken = false
init(store: ConnectionsStore = .shared) {
self.store = store
}
}

View File

@@ -1,594 +0,0 @@
import ClawdbotProtocol
import Foundation
extension ConnectionsStore {
var isTelegramTokenLocked: Bool {
self.snapshot?.decodeChannel("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
.tokenSource == "env"
}
var isDiscordTokenLocked: Bool {
self.snapshot?.decodeChannel("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.tokenSource == "env"
}
func loadConfig() async {
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .configGet,
params: nil,
timeoutMs: 10000)
self.configStatus = snap.valid == false
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
: nil
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
self.configHash = snap.hash
self.configLoaded = true
self.applyUIConfig(snap)
self.applyTelegramConfig(snap)
self.applyDiscordConfig(snap)
self.applySignalConfig(snap)
self.applyIMessageConfig(snap)
} catch {
self.configStatus = error.localizedDescription
}
}
private func applyUIConfig(_ snap: ConfigSnapshot) {
let ui = snap.config?[
"ui",
]?.dictionaryValue
let rawSeam = ui?[
"seamColor",
]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
}
private func resolveChannelConfig(_ snap: ConfigSnapshot, key: String) -> [String: AnyCodable]? {
if let channels = snap.config?["channels"]?.dictionaryValue,
let entry = channels[key]?.dictionaryValue
{
return entry
}
return snap.config?[key]?.dictionaryValue
}
private func applyTelegramConfig(_ snap: ConfigSnapshot) {
let telegram = self.resolveChannelConfig(snap, key: "telegram")
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
let groups = telegram?["groups"]?.dictionaryValue
let defaultGroup = groups?["*"]?.dictionaryValue
self.telegramRequireMention = defaultGroup?["requireMention"]?.boolValue
?? telegram?["requireMention"]?.boolValue
?? true
self.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue)
self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
self.telegramWebhookSecret = telegram?["webhookSecret"]?.stringValue ?? ""
self.telegramWebhookPath = telegram?["webhookPath"]?.stringValue ?? ""
}
private func applyDiscordConfig(_ snap: ConfigSnapshot) {
let discord = self.resolveChannelConfig(snap, key: "discord")
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
self.discordToken = discord?["token"]?.stringValue ?? ""
let discordDm = discord?["dm"]?.dictionaryValue
self.discordDmEnabled = discordDm?["enabled"]?.boolValue ?? true
self.discordAllowFrom = self.stringList(from: discordDm?["allowFrom"]?.arrayValue)
self.discordGroupEnabled = discordDm?["groupEnabled"]?.boolValue ?? false
self.discordGroupChannels = self.stringList(from: discordDm?["groupChannels"]?.arrayValue)
self.discordMediaMaxMb = self.numberString(from: discord?["mediaMaxMb"])
self.discordHistoryLimit = self.numberString(from: discord?["historyLimit"])
self.discordTextChunkLimit = self.numberString(from: discord?["textChunkLimit"])
self.discordReplyToMode = self.replyMode(from: discord?["replyToMode"]?.stringValue)
self.discordGuilds = self.decodeDiscordGuilds(discord?["guilds"]?.dictionaryValue)
let discordActions = discord?["actions"]?.dictionaryValue
self.discordActionReactions = discordActions?["reactions"]?.boolValue ?? true
self.discordActionStickers = discordActions?["stickers"]?.boolValue ?? true
self.discordActionPolls = discordActions?["polls"]?.boolValue ?? true
self.discordActionPermissions = discordActions?["permissions"]?.boolValue ?? true
self.discordActionMessages = discordActions?["messages"]?.boolValue ?? true
self.discordActionThreads = discordActions?["threads"]?.boolValue ?? true
self.discordActionPins = discordActions?["pins"]?.boolValue ?? true
self.discordActionSearch = discordActions?["search"]?.boolValue ?? true
self.discordActionMemberInfo = discordActions?["memberInfo"]?.boolValue ?? true
self.discordActionRoleInfo = discordActions?["roleInfo"]?.boolValue ?? true
self.discordActionChannelInfo = discordActions?["channelInfo"]?.boolValue ?? true
self.discordActionVoiceStatus = discordActions?["voiceStatus"]?.boolValue ?? true
self.discordActionEvents = discordActions?["events"]?.boolValue ?? true
self.discordActionRoles = discordActions?["roles"]?.boolValue ?? false
self.discordActionModeration = discordActions?["moderation"]?.boolValue ?? false
let slash = discord?["slashCommand"]?.dictionaryValue
self.discordSlashEnabled = slash?["enabled"]?.boolValue ?? false
self.discordSlashName = slash?["name"]?.stringValue ?? ""
self.discordSlashSessionPrefix = slash?["sessionPrefix"]?.stringValue ?? ""
self.discordSlashEphemeral = slash?["ephemeral"]?.boolValue ?? true
}
private func decodeDiscordGuilds(_ guilds: [String: AnyCodable]?) -> [DiscordGuildForm] {
guard let guilds else { return [] }
return guilds
.map { key, value in
let entry = value.dictionaryValue ?? [:]
let slug = entry["slug"]?.stringValue ?? ""
let requireMention = entry["requireMention"]?.boolValue ?? false
let reactionModeRaw = entry["reactionNotifications"]?.stringValue ?? ""
let reactionNotifications = ["off", "own", "all", "allowlist"].contains(reactionModeRaw)
? reactionModeRaw
: "own"
let users = self.stringList(from: entry["users"]?.arrayValue)
let channels: [DiscordGuildChannelForm] = if let channelMap = entry["channels"]?.dictionaryValue {
channelMap.map { channelKey, channelValue in
let channelEntry = channelValue.dictionaryValue ?? [:]
let allow = channelEntry["allow"]?.boolValue ?? true
let channelRequireMention = channelEntry["requireMention"]?.boolValue ?? false
return DiscordGuildChannelForm(
key: channelKey,
allow: allow,
requireMention: channelRequireMention)
}
} else {
[]
}
return DiscordGuildForm(
key: key,
slug: slug,
requireMention: requireMention,
reactionNotifications: reactionNotifications,
users: users,
channels: channels)
}
.sorted { $0.key < $1.key }
}
private func applySignalConfig(_ snap: ConfigSnapshot) {
let signal = self.resolveChannelConfig(snap, key: "signal")
self.signalEnabled = signal?["enabled"]?.boolValue ?? true
self.signalAccount = signal?["account"]?.stringValue ?? ""
self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? ""
self.signalHttpHost = signal?["httpHost"]?.stringValue ?? ""
self.signalHttpPort = self.numberString(from: signal?["httpPort"])
self.signalCliPath = signal?["cliPath"]?.stringValue ?? ""
self.signalAutoStart = signal?["autoStart"]?.boolValue ?? true
self.signalReceiveMode = signal?["receiveMode"]?.stringValue ?? ""
self.signalIgnoreAttachments = signal?["ignoreAttachments"]?.boolValue ?? false
self.signalIgnoreStories = signal?["ignoreStories"]?.boolValue ?? false
self.signalSendReadReceipts = signal?["sendReadReceipts"]?.boolValue ?? false
self.signalAllowFrom = self.stringList(from: signal?["allowFrom"]?.arrayValue)
self.signalMediaMaxMb = self.numberString(from: signal?["mediaMaxMb"])
}
private func applyIMessageConfig(_ snap: ConfigSnapshot) {
let imessage = self.resolveChannelConfig(snap, key: "imessage")
self.imessageEnabled = imessage?["enabled"]?.boolValue ?? true
self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? ""
self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? ""
self.imessageService = imessage?["service"]?.stringValue ?? "auto"
self.imessageRegion = imessage?["region"]?.stringValue ?? ""
self.imessageAllowFrom = self.stringList(from: imessage?["allowFrom"]?.arrayValue)
self.imessageIncludeAttachments = imessage?["includeAttachments"]?.boolValue ?? false
self.imessageMediaMaxMb = self.numberString(from: imessage?["mediaMaxMb"])
}
private func channelConfigRoot(for key: String) -> [String: Any] {
if let channels = self.configRoot["channels"] as? [String: Any],
let entry = channels[key] as? [String: Any]
{
return entry
}
return self.configRoot[key] as? [String: Any] ?? [:]
}
func saveTelegramConfig() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
defer { self.isSavingConfig = false }
if !self.configLoaded {
await self.loadConfig()
}
var telegram: [String: Any] = [:]
if !self.isTelegramTokenLocked {
self.setPatchString(&telegram, key: "botToken", value: self.telegramToken)
}
telegram["requireMention"] = NSNull()
telegram["groups"] = [
"*": [
"requireMention": self.telegramRequireMention,
],
]
let allow = self.splitCsv(self.telegramAllowFrom)
self.setPatchList(&telegram, key: "allowFrom", values: allow)
self.setPatchString(&telegram, key: "proxy", value: self.telegramProxy)
self.setPatchString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
self.setPatchString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
self.setPatchString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
await self.persistChannelPatch("telegram", payload: telegram)
}
func saveDiscordConfig() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
defer { self.isSavingConfig = false }
if !self.configLoaded {
await self.loadConfig()
}
let base = self.channelConfigRoot(for: "discord")
let discord = self.buildDiscordPatch(base: base)
await self.persistChannelPatch("discord", payload: discord)
}
func saveSignalConfig() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
defer { self.isSavingConfig = false }
if !self.configLoaded {
await self.loadConfig()
}
var signal: [String: Any] = [:]
self.setPatchBool(&signal, key: "enabled", value: self.signalEnabled, defaultValue: true)
self.setPatchString(&signal, key: "account", value: self.signalAccount)
self.setPatchString(&signal, key: "httpUrl", value: self.signalHttpUrl)
self.setPatchString(&signal, key: "httpHost", value: self.signalHttpHost)
self.setPatchNumber(&signal, key: "httpPort", value: self.signalHttpPort)
self.setPatchString(&signal, key: "cliPath", value: self.signalCliPath)
self.setPatchBool(&signal, key: "autoStart", value: self.signalAutoStart, defaultValue: true)
self.setPatchString(&signal, key: "receiveMode", value: self.signalReceiveMode)
self.setPatchBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments, defaultValue: false)
self.setPatchBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories, defaultValue: false)
self.setPatchBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts, defaultValue: false)
let allow = self.splitCsv(self.signalAllowFrom)
self.setPatchList(&signal, key: "allowFrom", values: allow)
self.setPatchNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
await self.persistChannelPatch("signal", payload: signal)
}
func saveIMessageConfig() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
defer { self.isSavingConfig = false }
if !self.configLoaded {
await self.loadConfig()
}
var imessage: [String: Any] = [:]
self.setPatchBool(&imessage, key: "enabled", value: self.imessageEnabled, defaultValue: true)
self.setPatchString(&imessage, key: "cliPath", value: self.imessageCliPath)
self.setPatchString(&imessage, key: "dbPath", value: self.imessageDbPath)
let service = self.trimmed(self.imessageService)
if service.isEmpty || service == "auto" {
imessage["service"] = NSNull()
} else {
imessage["service"] = service
}
self.setPatchString(&imessage, key: "region", value: self.imessageRegion)
let allow = self.splitCsv(self.imessageAllowFrom)
self.setPatchList(&imessage, key: "allowFrom", values: allow)
self.setPatchBool(
&imessage,
key: "includeAttachments",
value: self.imessageIncludeAttachments,
defaultValue: false)
self.setPatchNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
await self.persistChannelPatch("imessage", payload: imessage)
}
private func buildDiscordPatch(base: [String: Any]) -> [String: Any] {
var discord: [String: Any] = [:]
self.setPatchBool(&discord, key: "enabled", value: self.discordEnabled, defaultValue: true)
if !self.isDiscordTokenLocked {
self.setPatchString(&discord, key: "token", value: self.discordToken)
}
if let dm = self.buildDiscordDmPatch() {
discord["dm"] = dm
} else {
discord["dm"] = NSNull()
}
self.setPatchNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
self.setPatchInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
self.setPatchInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
let replyToMode = self.trimmed(self.discordReplyToMode)
if replyToMode.isEmpty || replyToMode == "off" || !["first", "all"].contains(replyToMode) {
discord["replyToMode"] = NSNull()
} else {
discord["replyToMode"] = replyToMode
}
let baseGuilds = base["guilds"] as? [String: Any] ?? [:]
if let guilds = self.buildDiscordGuildsPatch(base: baseGuilds) {
discord["guilds"] = guilds
} else {
discord["guilds"] = NSNull()
}
if let actions = self.buildDiscordActionsPatch() {
discord["actions"] = actions
} else {
discord["actions"] = NSNull()
}
if let slash = self.buildDiscordSlashPatch() {
discord["slashCommand"] = slash
} else {
discord["slashCommand"] = NSNull()
}
return discord
}
private func buildDiscordDmPatch() -> [String: Any]? {
var dm: [String: Any] = [:]
self.setPatchBool(&dm, key: "enabled", value: self.discordDmEnabled, defaultValue: true)
let allow = self.splitCsv(self.discordAllowFrom)
self.setPatchList(&dm, key: "allowFrom", values: allow)
self.setPatchBool(&dm, key: "groupEnabled", value: self.discordGroupEnabled, defaultValue: false)
let groupChannels = self.splitCsv(self.discordGroupChannels)
self.setPatchList(&dm, key: "groupChannels", values: groupChannels)
return dm.isEmpty ? nil : dm
}
private func buildDiscordGuildsPatch(base: [String: Any]) -> Any? {
if self.discordGuilds.isEmpty {
return NSNull()
}
var patch: [String: Any] = [:]
let baseKeys = Set(base.keys)
var formKeys = Set<String>()
for entry in self.discordGuilds {
let key = self.trimmed(entry.key)
guard !key.isEmpty else { continue }
formKeys.insert(key)
let baseGuild = base[key] as? [String: Any] ?? [:]
patch[key] = self.buildDiscordGuildPatch(entry, base: baseGuild)
}
for key in baseKeys.subtracting(formKeys) {
patch[key] = NSNull()
}
return patch.isEmpty ? NSNull() : patch
}
private func buildDiscordGuildPatch(_ entry: DiscordGuildForm, base: [String: Any]) -> [String: Any] {
var payload: [String: Any] = [:]
let slug = self.trimmed(entry.slug)
if slug.isEmpty {
payload["slug"] = NSNull()
} else {
payload["slug"] = slug
}
if entry.requireMention {
payload["requireMention"] = true
} else {
payload["requireMention"] = NSNull()
}
if ["off", "all", "allowlist"].contains(entry.reactionNotifications) {
payload["reactionNotifications"] = entry.reactionNotifications
} else {
payload["reactionNotifications"] = NSNull()
}
let users = self.splitCsv(entry.users)
self.setPatchList(&payload, key: "users", values: users)
let baseChannels = base["channels"] as? [String: Any] ?? [:]
if let channels = self.buildDiscordChannelsPatch(base: baseChannels, forms: entry.channels) {
payload["channels"] = channels
} else {
payload["channels"] = NSNull()
}
return payload
}
private func buildDiscordChannelsPatch(base: [String: Any], forms: [DiscordGuildChannelForm]) -> Any? {
if forms.isEmpty {
return NSNull()
}
var patch: [String: Any] = [:]
let baseKeys = Set(base.keys)
var formKeys = Set<String>()
for channel in forms {
let channelKey = self.trimmed(channel.key)
guard !channelKey.isEmpty else { continue }
formKeys.insert(channelKey)
var channelPayload: [String: Any] = [:]
self.setPatchBool(&channelPayload, key: "allow", value: channel.allow, defaultValue: true)
self.setPatchBool(
&channelPayload,
key: "requireMention",
value: channel.requireMention,
defaultValue: false)
patch[channelKey] = channelPayload
}
for key in baseKeys.subtracting(formKeys) {
patch[key] = NSNull()
}
return patch.isEmpty ? NSNull() : patch
}
private func buildDiscordActionsPatch() -> [String: Any]? {
var actions: [String: Any] = [:]
self.setAction(&actions, key: "reactions", value: self.discordActionReactions, defaultValue: true)
self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true)
self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true)
self.setAction(&actions, key: "permissions", value: self.discordActionPermissions, defaultValue: true)
self.setAction(&actions, key: "messages", value: self.discordActionMessages, defaultValue: true)
self.setAction(&actions, key: "threads", value: self.discordActionThreads, defaultValue: true)
self.setAction(&actions, key: "pins", value: self.discordActionPins, defaultValue: true)
self.setAction(&actions, key: "search", value: self.discordActionSearch, defaultValue: true)
self.setAction(&actions, key: "memberInfo", value: self.discordActionMemberInfo, defaultValue: true)
self.setAction(&actions, key: "roleInfo", value: self.discordActionRoleInfo, defaultValue: true)
self.setAction(&actions, key: "channelInfo", value: self.discordActionChannelInfo, defaultValue: true)
self.setAction(&actions, key: "voiceStatus", value: self.discordActionVoiceStatus, defaultValue: true)
self.setAction(&actions, key: "events", value: self.discordActionEvents, defaultValue: true)
self.setAction(&actions, key: "roles", value: self.discordActionRoles, defaultValue: false)
self.setAction(&actions, key: "moderation", value: self.discordActionModeration, defaultValue: false)
return actions.isEmpty ? nil : actions
}
private func buildDiscordSlashPatch() -> [String: Any]? {
var slash: [String: Any] = [:]
self.setPatchBool(&slash, key: "enabled", value: self.discordSlashEnabled, defaultValue: false)
self.setPatchString(&slash, key: "name", value: self.discordSlashName)
self.setPatchString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
self.setPatchBool(&slash, key: "ephemeral", value: self.discordSlashEphemeral, defaultValue: true)
return slash.isEmpty ? nil : slash
}
private func persistChannelPatch(_ channelId: String, payload: [String: Any]) async {
do {
guard let baseHash = self.configHash else {
self.configStatus = "Config hash missing; reload and retry."
return
}
let data = try JSONSerialization.data(
withJSONObject: ["channels": [channelId: payload]],
options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else {
self.configStatus = "Failed to encode config."
return
}
let params: [String: AnyCodable] = [
"raw": AnyCodable(raw),
"baseHash": AnyCodable(baseHash),
]
_ = try await GatewayConnection.shared.requestRaw(
method: .configPatch,
params: params,
timeoutMs: 10000)
self.configStatus = "Saved to ~/.clawdbot/clawdbot.json."
await self.loadConfig()
await self.refresh(probe: true)
} catch {
self.configStatus = error.localizedDescription
}
}
private func stringList(from values: [AnyCodable]?) -> String {
guard let values else { return "" }
let strings = values.compactMap { entry -> String? in
if let str = entry.stringValue { return str }
if let intVal = entry.intValue { return String(intVal) }
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
return nil
}
return strings.joined(separator: ", ")
}
private func numberString(from value: AnyCodable?) -> String {
if let number = value?.doubleValue ?? value?.intValue.map(Double.init) {
return String(Int(number))
}
return ""
}
private func replyMode(from value: String?) -> String {
if let value, ["off", "first", "all"].contains(value) {
return value
}
return "off"
}
private func splitCsv(_ value: String) -> [String] {
value
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
private func trimmed(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func setPatchString(_ target: inout [String: Any], key: String, value: String) {
let trimmed = self.trimmed(value)
if trimmed.isEmpty {
target[key] = NSNull()
} else {
target[key] = trimmed
}
}
private func setPatchNumber(_ target: inout [String: Any], key: String, value: String) {
let trimmed = self.trimmed(value)
if trimmed.isEmpty {
target[key] = NSNull()
return
}
if let number = Double(trimmed) {
target[key] = number
} else {
target[key] = NSNull()
}
}
private func setPatchInt(
_ target: inout [String: Any],
key: String,
value: String,
allowZero: Bool)
{
let trimmed = self.trimmed(value)
if trimmed.isEmpty {
target[key] = NSNull()
return
}
guard let number = Int(trimmed) else {
target[key] = NSNull()
return
}
let isValid = allowZero ? number >= 0 : number > 0
guard isValid else {
target[key] = NSNull()
return
}
target[key] = number
}
private func setPatchBool(
_ target: inout [String: Any],
key: String,
value: Bool,
defaultValue: Bool)
{
if value == defaultValue {
target[key] = NSNull()
} else {
target[key] = value
}
}
private func setPatchList(_ target: inout [String: Any], key: String, values: [String]) {
if values.isEmpty {
target[key] = NSNull()
} else {
target[key] = values
}
}
private func setAction(
_ actions: inout [String: Any],
key: String,
value: Bool,
defaultValue: Bool)
{
if value == defaultValue {
actions[key] = NSNull()
} else {
actions[key] = value
}
}
}

View File

@@ -1,3 +1,4 @@
import ClawdbotKit
import ClawdbotProtocol
import Foundation
import Observation

View File

@@ -0,0 +1,99 @@
import Charts
import SwiftUI
struct CostUsageHistoryMenuView: View {
let summary: GatewayCostUsageSummary
let width: CGFloat
var body: some View {
VStack(alignment: .leading, spacing: 10) {
self.header
self.chart
self.footer
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(width: max(1, self.width), alignment: .leading)
}
private var header: some View {
let todayKey = CostUsageMenuDateParser.format(Date())
let todayEntry = self.summary.daily.first { $0.date == todayKey }
let todayCost = CostUsageFormatting.formatUsd(todayEntry?.totalCost) ?? "n/a"
let totalCost = CostUsageFormatting.formatUsd(self.summary.totals.totalCost) ?? "n/a"
return HStack(alignment: .firstTextBaseline, spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text("Today")
.font(.caption2)
.foregroundStyle(.secondary)
Text(todayCost)
.font(.system(size: 14, weight: .semibold))
}
VStack(alignment: .leading, spacing: 2) {
Text("Last \(self.summary.days)d")
.font(.caption2)
.foregroundStyle(.secondary)
Text(totalCost)
.font(.system(size: 14, weight: .semibold))
}
Spacer()
}
}
private var chart: some View {
let entries = self.summary.daily.compactMap { entry -> (Date, Double)? in
guard let date = CostUsageMenuDateParser.parse(entry.date) else { return nil }
return (date, entry.totalCost)
}
return Chart(entries, id: \.0) { entry in
BarMark(
x: .value("Day", entry.0),
y: .value("Cost", entry.1))
.foregroundStyle(Color.accentColor)
.cornerRadius(3)
}
.chartXAxis {
AxisMarks(values: .stride(by: .day, count: 7)) {
AxisGridLine().foregroundStyle(.clear)
AxisValueLabel(format: .dateTime.month().day())
}
}
.chartYAxis {
AxisMarks(position: .leading) {
AxisGridLine()
AxisValueLabel()
}
}
.frame(height: 110)
}
private var footer: some View {
if self.summary.totals.missingCostEntries == 0 {
return AnyView(EmptyView())
}
return AnyView(
Text("Partial: \(self.summary.totals.missingCostEntries) entries missing cost")
.font(.caption2)
.foregroundStyle(.secondary))
}
}
private enum CostUsageMenuDateParser {
static let formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone.current
return formatter
}()
static func parse(_ value: String) -> Date? {
self.formatter.date(from: value)
}
static func format(_ date: Date) -> String {
self.formatter.string(from: date)
}
}

View File

@@ -1,3 +1,4 @@
import ClawdbotKit
import ClawdbotProtocol
import Foundation
import Observation

View File

@@ -900,7 +900,7 @@ extension DebugSettings {
}
}
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 10) {
configuration.label

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