Compare commits

..

1351 Commits

Author SHA1 Message Date
Peter Steinberger 19affcac90 chore: update appcast for 2026.1.5-3
CI / checks (bunx biome check src, bun, lint) (push) Failing after 35s
CI / checks (bunx tsc -p tsconfig.json, bun, build) (push) Failing after 37s
CI / checks (bunx vitest run, bun, test) (push) Failing after 31s
CI / checks (pnpm build, node, build) (push) Failing after 36s
CI / checks (pnpm lint, node, lint) (push) Failing after 37s
CI / checks (pnpm protocol:check, node, protocol) (push) Failing after 30s
CI / checks (pnpm test, node, test) (push) Failing after 34s
CI / android (./gradlew --no-daemon :app:assembleDebug, build) (push) Failing after 4s
CI / android (./gradlew --no-daemon :app:testDebugUnitTest, test) (push) Failing after 3s
CI / macos-app (set -euo pipefail for attempt in 1 2 3; do if swift build --package-path apps/macos --configuration release; then exit 0 fi echo "swift build failed (attempt $attempt/3). Retrying…" sleep $((attempt * 20)) done exit 1 , build) (push) Has been cancelled
CI / macos-app (set -euo pipefail for attempt in 1 2 3; do if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then exit 0 fi echo "swift test failed (attempt $attempt/3). Retrying…" sleep $((attempt *… (push) Has been cancelled
CI / macos-app (swiftlint --config .swiftlint.yml swiftformat --lint apps/macos/Sources --config .swiftformat , lint) (push) Has been cancelled
CI / ios (push) Has been cancelled
2026-01-05 04:00:08 +01:00
Peter Steinberger 92f95abdcf docs: link to hosted docs 2026-01-05 03:59:58 +01:00
Peter Steinberger 477fa49a30 fix: include missing dist dirs in npm pack 2026-01-05 03:56:57 +01:00
Peter Steinberger 36b96c2b28 chore: update appcast for 2026.1.5-2
CI / checks (bunx biome check src, bun, lint) (push) Failing after 41s
CI / checks (bunx tsc -p tsconfig.json, bun, build) (push) Failing after 32s
CI / checks (bunx vitest run, bun, test) (push) Failing after 37s
CI / checks (pnpm build, node, build) (push) Failing after 41s
CI / checks (pnpm lint, node, lint) (push) Failing after 42s
CI / checks (pnpm protocol:check, node, protocol) (push) Failing after 37s
CI / checks (pnpm test, node, test) (push) Failing after 44s
CI / android (./gradlew --no-daemon :app:assembleDebug, build) (push) Failing after 4s
CI / android (./gradlew --no-daemon :app:testDebugUnitTest, test) (push) Failing after 3s
CI / macos-app (set -euo pipefail for attempt in 1 2 3; do if swift build --package-path apps/macos --configuration release; then exit 0 fi echo "swift build failed (attempt $attempt/3). Retrying…" sleep $((attempt * 20)) done exit 1 , build) (push) Has been cancelled
CI / macos-app (set -euo pipefail for attempt in 1 2 3; do if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then exit 0 fi echo "swift test failed (attempt $attempt/3). Retrying…" sleep $((attempt *… (push) Has been cancelled
CI / macos-app (swiftlint --config .swiftlint.yml swiftformat --lint apps/macos/Sources --config .swiftformat , lint) (push) Has been cancelled
CI / ios (push) Has been cancelled
2026-01-05 03:53:09 +01:00
Peter Steinberger 2eb78b8da7 fix: resolve qrcode ESM import for Node 25 2026-01-05 03:47:57 +01:00
Peter Steinberger 3110e37db4 chore: update appcast for 2026.1.5-1
CI / checks (bunx biome check src, bun, lint) (push) Failing after 43s
CI / checks (bunx tsc -p tsconfig.json, bun, build) (push) Failing after 39s
CI / checks (bunx vitest run, bun, test) (push) Failing after 31s
CI / checks (pnpm build, node, build) (push) Failing after 45s
CI / checks (pnpm lint, node, lint) (push) Failing after 30s
CI / checks (pnpm protocol:check, node, protocol) (push) Failing after 35s
CI / checks (pnpm test, node, test) (push) Failing after 34s
CI / android (./gradlew --no-daemon :app:assembleDebug, build) (push) Failing after 4s
CI / android (./gradlew --no-daemon :app:testDebugUnitTest, test) (push) Failing after 4s
CI / macos-app (set -euo pipefail for attempt in 1 2 3; do if swift build --package-path apps/macos --configuration release; then exit 0 fi echo "swift build failed (attempt $attempt/3). Retrying…" sleep $((attempt * 20)) done exit 1 , build) (push) Has been cancelled
CI / macos-app (set -euo pipefail for attempt in 1 2 3; do if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then exit 0 fi echo "swift test failed (attempt $attempt/3). Retrying…" sleep $((attempt *… (push) Has been cancelled
CI / macos-app (swiftlint --config .swiftlint.yml swiftformat --lint apps/macos/Sources --config .swiftformat , lint) (push) Has been cancelled
CI / ios (push) Has been cancelled
2026-01-05 03:32:53 +01:00
Peter Steinberger 93bb0257f0 fix: include sessions in npm pack and update qrcode import 2026-01-05 03:28:25 +01:00
Peter Steinberger 197b8f7c3b chore: update appcast for 2026.1.5
CI / checks (bunx biome check src, bun, lint) (push) Failing after 35s
CI / checks (bunx tsc -p tsconfig.json, bun, build) (push) Failing after 40s
CI / checks (bunx vitest run, bun, test) (push) Failing after 28s
CI / checks (pnpm build, node, build) (push) Failing after 39s
CI / checks (pnpm lint, node, lint) (push) Failing after 33s
CI / checks (pnpm protocol:check, node, protocol) (push) Failing after 34s
CI / checks (pnpm test, node, test) (push) Failing after 29s
CI / android (./gradlew --no-daemon :app:assembleDebug, build) (push) Failing after 4s
CI / android (./gradlew --no-daemon :app:testDebugUnitTest, test) (push) Failing after 3s
CI / macos-app (set -euo pipefail for attempt in 1 2 3; do if swift build --package-path apps/macos --configuration release; then exit 0 fi echo "swift build failed (attempt $attempt/3). Retrying…" sleep $((attempt * 20)) done exit 1 , build) (push) Has been cancelled
CI / macos-app (set -euo pipefail for attempt in 1 2 3; do if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then exit 0 fi echo "swift test failed (attempt $attempt/3). Retrying…" sleep $((attempt *… (push) Has been cancelled
CI / macos-app (swiftlint --config .swiftlint.yml swiftformat --lint apps/macos/Sources --config .swiftformat , lint) (push) Has been cancelled
CI / ios (push) Has been cancelled
2026-01-05 03:13:14 +01:00
Peter Steinberger deba1b6739 style: format daemon program args test 2026-01-05 02:54:08 +01:00
Peter Steinberger aab98a6d18 test: fix daemon program args fs mocks 2026-01-05 02:51:56 +01:00
Peter Steinberger 849a008f34 test: avoid max port in browser server tests 2026-01-05 02:50:48 +01:00
Peter Steinberger 8791e46cf3 fix: resolve npx gateway daemon install 2026-01-05 02:48:25 +01:00
Peter Steinberger b779029517 fix: hide duplicate doc titles 2026-01-05 02:45:14 +01:00
Peter Steinberger 9c039e8356 docs: consolidate 2026.1.5 changelog 2026-01-05 02:39:42 +01:00
Peter Steinberger 4aeba76741 docs: add ClawdHub mention 2026-01-05 02:34:02 +01:00
Peter Steinberger d92a9e351e style: fix linting order and formatting 2026-01-05 02:33:59 +01:00
Peter Steinberger a1acd7dae8 chore: add qrcode-terminal vendor module stubs 2026-01-05 02:33:55 +01:00
Peter Steinberger 67420e9a81 fix: allow group activation for allowFrom senders 2026-01-05 02:33:51 +01:00
Peter Steinberger e4335ea094 fix: bundle qr renderer in relay 2026-01-05 02:19:49 +01:00
Peter Steinberger 0c632f4855 fix: prefer tailnet IP for local gateway calls 2026-01-05 02:19:26 +01:00
Peter Steinberger a322075764 fix: use id for cron tool params 2026-01-05 02:15:11 +01:00
Peter Steinberger 359cb66e68 fix: allow wildcard control commands 2026-01-05 02:06:18 +01:00
Peter Steinberger 00370139a5 docs: clarify derived port mapping 2026-01-05 02:03:29 +01:00
Peter Steinberger 17422608b2 fix: gate /activation to owners in groups 2026-01-05 02:03:29 +01:00
Peter Steinberger f871563f37 chore: sync generated protocol swift 2026-01-05 00:54:54 +00:00
Peter Steinberger 85549ac3b6 fix: gate group activation by owner 2026-01-05 00:48:16 +00:00
Peter Steinberger 1bad96aa2b style: tidy auto-reply imports and formatting 2026-01-05 01:46:16 +01:00
Peter Steinberger b0dcdc4982 fix: avoid mixing ?? and || in discord monitor 2026-01-05 01:46:16 +01:00
Shadow 13d39b8fb1 Fix discord/slack monitor compile errors 2026-01-04 18:44:19 -06:00
Peter Steinberger 50d26d827e fix: avoid duplicate senderName in slack monitor 2026-01-05 00:43:31 +00:00
Peter Steinberger 3fc9acc105 Merge remote-tracking branch 'mcinteerj/fix/whatsapp-offline-read-receipts' 2026-01-05 01:38:24 +01:00
Peter Steinberger d58828ebd7 test: relax timeouts for slow runs 2026-01-05 01:36:30 +01:00
Peter Steinberger f90eea5195 docs: add changelog entry for WhatsApp offline read receipts 2026-01-05 01:36:30 +01:00
Jake 3f40f4ab54 style: fix lint issues 2026-01-05 01:36:30 +01:00
Jake 65a55b97e0 WhatsApp: mark offline/history messages as read 2026-01-05 01:36:29 +01:00
Peter Steinberger 852f947b44 fix: unify control command handling 2026-01-05 01:31:36 +01:00
Peter Steinberger 54ad1ead80 docs: document --dev/--profile 2026-01-05 01:27:13 +01:00
Peter Steinberger c6de1b1f7d feat: add --dev/--profile CLI profiles 2026-01-05 01:27:13 +01:00
Peter Steinberger f601dac30d style: tidy tool schema normalization 2026-01-05 01:27:13 +01:00
Peter Steinberger 2bbf2698cb fix(ui): render markdown in tool result cards 2026-01-05 01:27:13 +01:00
Peter Steinberger f6097bc6e3 fix(ui): avoid overlapping guild action buttons 2026-01-05 01:27:13 +01:00
Peter Steinberger 39e482414a chore: apply upstream autostash 2026-01-05 00:26:52 +00:00
Peter Steinberger d6933b074a fix: make control ui chat scroll page 2026-01-05 00:18:18 +00:00
Peter Steinberger bcdfe461d4 fix(ci): resolve lint and docs build failures 2026-01-05 00:17:14 +00:00
Peter Steinberger 16ce76307e docs(faq): align model ids with shorthands 2026-01-05 01:11:29 +01:00
Peter Steinberger c37b4c18e0 docs: document env loading + shell fallback 2026-01-05 01:11:29 +01:00
Peter Steinberger 2899a986a8 feat(config): add default model shorthands 2026-01-05 01:11:29 +01:00
Peter Steinberger 7a63b4995b feat: opt-in login shell env fallback 2026-01-05 01:11:29 +01:00
Peter Steinberger 7a36e6fcd9 fix(discord): avoid duplicate block replies 2026-01-05 01:11:29 +01:00
Peter Steinberger 77b19643e2 fix: load global .env fallback 2026-01-05 01:11:29 +01:00
Josh Palmer aa45f512f4 fix sessions dir from state env
what: use CLAWDBOT_STATE_DIR/CLAWDIS_STATE_DIR for session transcripts

why: isolate multi-instance gateways

tests: not run
2026-01-05 00:51:11 +01:00
Peter Steinberger 59dfe0337d docs(changelog): note OpenAI duplicate reply fix 2026-01-05 00:39:34 +01:00
Peter Steinberger 4963432777 fix(discord): avoid duplicate replies on repeated message_end 2026-01-05 00:35:42 +01:00
Josh Palmer a737bfaab4 docs: make nix-clawdbot link more prominent
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 00:24:33 +01:00
Josh Palmer 67c89e00c5 docs: add Nix installation guide and navigation
- Expand docs/nix.md from runtime-only to full installation guide
- Reference nix-clawdbot as the recommended Nix setup path
- Add "Installation" section to docs.json navigation (wizard, nix, docker, setup)
- Add Nix link to README quick links

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 00:22:15 +01:00
Peter Steinberger 8f572ab361 docs: add WhatsApp Business tip for same-phone setup 2026-01-04 23:17:26 +00:00
Peter Steinberger 435edaf997 fix: OpenAI tool schema compatibility 2026-01-05 00:15:55 +01:00
Peter Steinberger c3c9dee65e docs(tools): document agent tool allow/deny 2026-01-05 00:05:35 +01:00
Jake 946b32c842 fix(whatsapp): suppress typing during heartbeats
- Prevent typing indicator during heartbeat runs
- Add regression tests

Co-authored-by: Jake <mcinteerj@gmail.com>
2026-01-04 23:03:36 +00:00
Peter Steinberger 4dd515b65f fix(tools): honor agent tool denylist without sandbox 2026-01-05 00:02:14 +01:00
Andranik Sahakyan d9a9f6db7d fix(mac): add Sendable conformance to generated Swift protocol structs (#195)
* fix(mac): add Sendable conformance to generated Swift protocol structs

* fix(mac): make generated protocol types Sendable

* chore(mac): drop redundant Sendable extensions

* docs(changelog): thank @andranik-sahakyan for Sendable fix

* chore(swiftformat): exclude generated protocol models

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-04 22:39:21 +00:00
Jake a6f7ab499f style: fix lint issues 2026-01-05 11:25:14 +13:00
Jake 3c7c819e9b WhatsApp: mark offline/history messages as read 2026-01-05 10:23:22 +13:00
Jake 58dd2e9514 fix: also suppress typing indicators in agent-runner during heartbeats 2026-01-05 10:23:22 +13:00
Jake 424b7fe493 fix: prevent typing indicator during heartbeat background tasks 2026-01-05 10:23:22 +13:00
Peter Steinberger 1657c5e3d2 fix: route system events per session 2026-01-04 22:11:04 +01:00
Peter Steinberger 2ceceb8c25 style(ts): normalize type-only imports 2026-01-04 21:56:16 +01:00
Peter Steinberger 39be40cd23 chore(release): bump to 2026.1.5 2026-01-04 21:54:04 +01:00
Peter Steinberger 0faa200924 fix(onboarding): auto-build Control UI assets 2026-01-04 21:53:23 +01:00
Peter Steinberger ff605194ef fix(ui): render markdown in chat 2026-01-04 21:51:26 +01:00
Peter Steinberger 78998dba9e feat: add image model config + tool 2026-01-04 19:35:49 +01:00
Peter Steinberger 0716a624a8 chore(lint): apply biome fixes 2026-01-04 19:08:22 +01:00
Peter Steinberger e005dcb8e7 fix(oauth): derive oauth.json from state dir 2026-01-04 19:08:13 +01:00
Peter Steinberger 3300fba57c docs(discord): add bot creation guide 2026-01-04 19:01:04 +01:00
Nachx639 fa3a768a3a fix(macos): remove authorizedWhenInUse references (iOS-only API) (#165)
CLAuthorizationStatus.authorizedWhenInUse only exists on iOS. On macOS,
location services only support .authorizedAlways. This was causing
compilation warnings and potentially incorrect behavior.

Fixes:
- GeneralSettings.swift: Remove authorizedWhenInUse check
- PermissionManager.swift: Update ensureLocation and status methods
- MacNodeRuntime.swift: Update location permission check

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:58:01 +00:00
Peter Steinberger da4f3211b8 chore: refresh version references 2026-01-04 18:49:36 +01:00
Peter Steinberger d85f91d247 feat: guide control ui access without gui 2026-01-04 18:49:36 +01:00
Peter Steinberger 5dcf43d6ad test: cover macos location permission status 2026-01-04 18:49:36 +01:00
Shadow 50cecd8210 Discord: remove duplicate message ids 2026-01-04 11:36:18 -06:00
Onur Solmaz 7dc8ea815e docs: add macOS launchd instructions for stopping gateway
Unify the "Processes keep restarting" FAQ section to cover both macOS
(launchd) and Linux (systemd). Previously only covered Linux.

Also update the "Clean uninstall" section with macOS commands.
2026-01-04 17:29:39 +00:00
Peter Steinberger 2110cac5d6 fix(cli): add config alias and reduce probe noise 2026-01-04 17:23:34 +00:00
Peter Steinberger 9eee832282 chore: update protocol swift models 2026-01-04 18:16:36 +01:00
Peter Steinberger 5d17b84e8a test(gateway): allow webchat chat.send without node 2026-01-04 17:12:49 +00:00
Peter Steinberger 3fed0ac2e8 fix(ui): show chat send errors 2026-01-04 17:12:49 +00:00
Peter Steinberger 2694e59ba6 fix(gateway): allow Control UI chat without node 2026-01-04 17:12:49 +00:00
Peter Steinberger 266fd748d0 fix(ui): allow Control UI chat without node 2026-01-04 17:12:49 +00:00
Peter Steinberger 564cc9359d style: swiftformat gateway models 2026-01-04 18:12:33 +01:00
Peter Steinberger ff46f8ce58 chore: format models CLI 2026-01-04 18:11:41 +01:00
Peter Steinberger 8e5153ba10 docs(changelog): add android notification tap fix 2026-01-04 18:05:26 +01:00
Peter Steinberger e2c6a96cd3 test(android): cover notification tap intent 2026-01-04 18:05:26 +01:00
Manuel Jiménez Torres 7200dabfb2 feat(android): open app when tapping foreground service notification
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 18:05:26 +01:00
Peter Steinberger d923dc56ec fix: update ClawdBot Swift references 2026-01-04 17:57:53 +01:00
Peter Steinberger 5eb6b779f5 fix: macOS Swift cleanup 2026-01-04 17:57:53 +01:00
Peter Steinberger 0928e3c866 docs: update changelog for models CLI 2026-01-04 17:57:53 +01:00
Peter Steinberger 734bb6b4fd feat: add models scan and fallbacks 2026-01-04 17:57:52 +01:00
Peter Steinberger a2ba7ddf90 docs: add models cli plan 2026-01-04 17:57:35 +01:00
Cash Williams 64e656af82 fix: default elevated level to 'off' when not allowed
When elevatedAllowed is false (e.g., for heartbeat surface which isn't
in any allowFrom list), the elevated level was incorrectly defaulting
to 'on', causing bash commands to fail with 'elevated is not available'.

Now defaults to 'off' when elevated isn't allowed, so bash works
normally without trying to use elevated mode.

Fixes: https://github.com/clawdbot/clawdbot/issues/181
2026-01-04 17:36:14 +01:00
Peter Steinberger a2d7632cf3 docs: add changelog entry for cron tool fix 2026-01-04 17:18:29 +01:00
Clawd 17665d1732 fix(cron): pass 'id' instead of 'jobId' to gateway
The cron tool was passing { jobId } to the gateway for update/remove/run/runs
actions, but the gateway protocol schema expects { id }. This caused validation
errors when trying to update or remove cron jobs via the tool.

Fixes the parameter name while keeping the external tool API unchanged (still
accepts 'jobId' from callers).
2026-01-04 17:18:29 +01:00
Peter Steinberger 4e072d59c1 chore(protocol): regenerate GatewayModels 2026-01-04 16:05:47 +00:00
Peter Steinberger 94da41dc52 docs: document sandbox image recovery 2026-01-04 16:02:28 +00:00
Peter Steinberger 718299b25a feat(doctor): repair sandbox images 2026-01-04 16:02:24 +00:00
Peter Steinberger e80bd1882f chore: bump Peekaboo submodule 2026-01-04 16:02:16 +00:00
Peter Steinberger ca09078934 docs: add Discord writing style guide to skill 2026-01-04 15:42:32 +00:00
Peter Steinberger c54fcd1e74 docs: document legacy doctor migrations 2026-01-04 15:41:25 +00:00
Peter Steinberger 5f09d801d0 feat(doctor): migrate legacy Clawdis config 2026-01-04 15:40:06 +00:00
Peter Steinberger 65ad956ab4 feat(daemon): add legacy Clawdis service cleanup 2026-01-04 15:40:06 +00:00
Peter Steinberger 20e41c5a10 docs: update changelog and README 2026-01-04 16:36:40 +01:00
Peter Steinberger 5d29985c4f fix: avoid sendable issue in mac location timeout 2026-01-04 16:27:17 +01:00
Peter Steinberger 026a25d164 chore: lint and format cleanup 2026-01-04 16:24:17 +01:00
Peter Steinberger fd95ededaa refactor: streamline node invoke handling 2026-01-04 16:24:17 +01:00
Peter Steinberger c0b248f291 refactor: split connections settings/store 2026-01-04 16:24:17 +01:00
Peter Steinberger e8de7d083d feat: update onboard ASCII art to seafood shack lobster theme 2026-01-04 16:24:17 +01:00
Peter Steinberger 21826cdfb9 chore: update Peekaboo submodule 2026-01-04 16:24:17 +01:00
Peter Steinberger 8f53e9093d test: align google-shared expectations 2026-01-04 15:02:42 +00:00
Peter Steinberger 30d5511058 test: add config for gateway sigterm 2026-01-04 14:59:49 +00:00
Peter Steinberger c6b8235862 style: format tests and helpers 2026-01-04 14:57:57 +00:00
Peter Steinberger 557aa74ee8 test: update google-shared expectations 2026-01-04 14:57:57 +00:00
Peter Steinberger 7ff318d3f2 docs: note canvasHost reload requires restart 2026-01-04 15:45:42 +01:00
Peter Steinberger 8ff802a072 chore: bump Peekaboo submodule 2026-01-04 14:42:12 +00:00
Peter Steinberger b79fdd2be8 chore: ignore module cache 2026-01-04 14:41:25 +00:00
Peter Steinberger 246adaa119 chore: rename project to clawdbot 2026-01-04 14:38:51 +00:00
Peter Steinberger d48dc71fa4 feat: add canvasHost liveReload option 2026-01-04 15:22:47 +01:00
Peter Steinberger 1e555e693a fix: dedupe canvas host watcher 2026-01-04 15:15:46 +01:00
Peter Steinberger ec09b06636 fix: wire slack deps and stabilize sigterm test 2026-01-04 15:13:23 +01:00
George Tsifrikas 378e4c9b6b Fix duplicate sendMessageSlack imports
Remove duplicate import statements for sendMessageSlack that were
causing TypeScript compilation errors in deps.ts and heartbeat-runner.ts

Co-Authored-By: Warp <agent@warp.dev>
2026-01-04 14:47:17 +01:00
Peter Steinberger 5ce1eb791e chore: align rebase with main 2026-01-04 14:41:52 +01:00
Peter Steinberger 529cf91ac3 fix: keep node presence fresh 2026-01-04 14:41:52 +01:00
Mariano Belinky 672700f2b3 docs: add PR template + node presence beacon 2026-01-04 14:41:52 +01:00
Peter Steinberger 476bbd2915 fix: update lockfile and lint 2026-01-04 14:12:00 +01:00
Peter Steinberger 9616add9b1 docs: note android sms capability 2026-01-04 13:59:05 +01:00
Peter Steinberger 71fdf46f18 fix(android): refresh hello on sms permission grant 2026-01-04 13:59:05 +01:00
Peter Steinberger 0d56a73118 fix(android): add sms permission flow and tests 2026-01-04 13:59:05 +01:00
Vasanth Rao Naik Sabavat 1318276105 feat(android): add SMS sending capability to Android node
Add sms.send command to allow sending text messages via the paired Android device.

Changes:
- Add SmsManager class to handle SMS sending via Android SmsManager API
- Add ClawdisSmsCommand enum and Sms capability to protocol constants
- Wire sms.send command into NodeRuntime invoke handler
- Add SEND_SMS permission to AndroidManifest.xml
- Advertise sms capability when SEND_SMS permission is granted

The SMS capability is only advertised when the user has granted SEND_SMS
permission. Messages longer than 160 chars are automatically split into
multipart messages.
2026-01-04 13:58:05 +01:00
Peter Steinberger 7aab2ae182 docs: update changelog 2026-01-04 11:44:41 +00:00
Peter Steinberger ec6980cda0 fix: wire slack into delivery routing 2026-01-04 11:44:41 +00:00
Peter Steinberger b234d82bf3 fix: add slack deps and send helpers 2026-01-04 11:44:41 +00:00
Muhammed Mukhthar CM 9958283ced fix: Antigravity API compatibility and Gemini thinking tag leakage (#167)
* fix: ensure type:object in sanitized tool schemas for Antigravity API

The sanitizeSchemaForGoogle function strips unsupported JSON Schema
keywords like anyOf, but this can leave schemas with 'properties' and
'required' fields without a 'type' field. Both Google's Gemini API and
Anthropic via Antigravity require 'type: object' when these fields exist.

This fix adds a post-sanitization check that ensures type is set to
'object' when properties or required fields are present.

Fixes errors like:
- Gemini: 'parameters.properties: only allowed for OBJECT type'
- Anthropic: 'tools.6.custom.input_schema.type: Field required'

* fix: regenerate pi-ai patch with proper pnpm format

The patch now correctly applies via pnpm patch-commit, fixing:
- Thinking blocks: skip for Gemini, send with signature for Claude
- Schema sanitization: ensure type:object after removing anyOf
- Remove strict:null for LM Studio/Antigravity compatibility

Tested with all Antigravity models (Gemini and Claude).

* fix: strip thinking tags from block streaming output to prevent Gemini tag leakage
2026-01-04 12:44:19 +01:00
Peter Steinberger d6f8b6ac51 fix: update pi-ai patch and tests 2026-01-04 12:24:01 +01:00
Shadow 8c38a7fee8 Slack: add some fixes and connect it all up 2026-01-04 01:53:26 -06:00
jeffersonwarrior 02d7e286ea docs: add remote gateway SSH tunnel setup guide
- Add SSH config setup for remote gateway access
- Document step-by-step setup process
- Include auto-start LaunchAgent configuration
- Add troubleshooting section

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-04 07:48:20 +01:00
Peter Steinberger 3910b9b39e docs(skills): update mcporter 2026-01-04 07:26:59 +01:00
Peter Steinberger 607de4a403 fix: add slack chunk limits 2026-01-04 07:23:39 +01:00
Shadow 7701d395e9 Slack: update docs and tool display 2026-01-04 07:22:05 +01:00
Shadow 0085b2e0a9 Slack: refine scopes and onboarding 2026-01-04 07:22:02 +01:00
Shadow bf3d120f8c Slack: add new slack connection 2026-01-04 07:18:20 +01:00
Peter Steinberger 4b3ca29404 build: add homebrew to sandbox image 2026-01-04 06:12:06 +00:00
Peter Steinberger 259b14d66a chore: refresh protocol models 2026-01-04 07:07:21 +01:00
Peter Steinberger c9504a6f20 refactor: split config module 2026-01-04 07:05:17 +01:00
Peter Steinberger 5e36e2c3f3 fix: allow elevated via discord username 2026-01-04 05:47:28 +00:00
Peter Steinberger d2da305190 feat: fallback elevated allowlist to discord dms 2026-01-04 05:31:00 +00:00
Peter Steinberger be9fa124df build: add pkg-config + libasound2-dev to sandbox image 2026-01-04 05:28:08 +00:00
Peter Steinberger ff88f3c075 style: fix lint ordering 2026-01-04 06:27:54 +01:00
Peter Steinberger 1315fc4caf docs: split elevated directives 2026-01-04 05:21:12 +00:00
Peter Steinberger a03895dfa9 fix: default elevated mode to on 2026-01-04 05:19:28 +00:00
Peter Steinberger 40c3898ca1 docs: update changelog for #166 2026-01-04 06:17:07 +01:00
Peter Steinberger 6ea0eb438c style: fix lint formatting 2026-01-04 06:17:07 +01:00
Peter Steinberger 04cd1bd11a fix(macos): bridge wizard option values 2026-01-04 06:17:07 +01:00
Peter Steinberger fe0b3500cc feat: add elevated bash mode 2026-01-04 05:15:59 +00:00
Tu Nombre Real b978cc4e91 feat(macos): add Swift 6 strict concurrency compatibility
Prepares the macOS app for Swift 6 strict concurrency mode by:

1. Adding Sendable conformance to WizardNextResult, WizardStartResult,
   and WizardStatusResult in GatewayModels.swift

2. Adding AnyCodable bridging helpers in OnboardingWizard.swift to
   handle type conflicts between ClawdisProtocol and local module

3. Making CLLocationManagerDelegate methods nonisolated in:
   - MacNodeLocationService.swift
   - PermissionManager.swift (LocationPermissionRequester)

   Using Task { @MainActor in } pattern to safely access MainActor
   state from nonisolated protocol requirements.

These changes are forward-compatible and don't affect behavior on
current Swift versions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 06:09:52 +01:00
Peter Steinberger 72a9e58777 refactor(auto-reply): split reply flow 2026-01-04 05:47:37 +01:00
Peter Steinberger fd91da2b7f fix: log dynamic config reloads 2026-01-04 04:24:50 +00:00
Peter Steinberger 5673f4299a build: add sandbox common image builder 2026-01-04 04:17:13 +00:00
Peter Steinberger 770daadaf7 chore: bump Peekaboo submodule 2026-01-04 05:15:57 +01:00
Peter Steinberger 13c2f22240 refactor: split agent tools 2026-01-04 05:07:44 +01:00
Peter Steinberger f2ce455c8c fix: set writable home for sandbox browser 2026-01-04 03:49:39 +00:00
Peter Steinberger 640ec465d7 chore: bump Peekaboo submodule 2026-01-04 04:46:07 +01:00
Peter Steinberger 70f79bd926 fix: stabilize sandbox browser startup 2026-01-04 03:45:14 +00:00
Peter Steinberger 7d95f43a75 style: fix lint 2026-01-04 03:37:08 +00:00
Peter Steinberger c2f3b653c2 docs: thank scald for Notion skill 2026-01-04 04:36:28 +01:00
Peter Steinberger 12ba32c724 feat(browser): add remote-capable profiles
Co-authored-by: James Groat <james@groat.com>
2026-01-04 03:33:07 +00:00
Peter Steinberger 0e75aa2716 test: add sessions_send loopback test 2026-01-04 04:30:57 +01:00
Steve Caldwell 44990d837f feat: add Notion API skill
Create and manage Notion pages, databases, and blocks via API.
2026-01-04 04:29:44 +01:00
Shadow 3a28e3562c Discord: tools for uploading emojis and stickers! 2026-01-03 21:20:01 -06:00
Peter Steinberger 24aa3e3311 test: stabilize gateway tests 2026-01-04 04:16:38 +01:00
Peter Steinberger 3c4c2aa98c refactor: split gateway server methods 2026-01-04 04:05:18 +01:00
Peter Steinberger 3ebee63cb3 feat: add clawdhub skill 2026-01-04 04:05:10 +01:00
Peter Steinberger 6d6038b855 docs: tighten wacli skill guidance 2026-01-04 03:45:49 +01:00
Peter Steinberger 55876f7be0 test(agents): cover ping-pong announce flow 2026-01-04 03:41:58 +01:00
Peter Steinberger cd3c42d0c0 feat(sessions): add agent-to-agent ping-pong 2026-01-04 03:37:44 +01:00
Peter Steinberger add1301a51 feat(sessions): add agent-to-agent post step 2026-01-04 03:04:55 +01:00
Peter Steinberger 052cec70ae fix: render thinking text in italics 2026-01-04 02:44:11 +01:00
Peter Steinberger 534de59f7c docs: clarify menu bar sessionKey usage 2026-01-04 02:10:22 +01:00
Peter Steinberger 1d06164e18 refactor: use per-send run ids for gateway agent 2026-01-04 02:08:52 +01:00
Peter Steinberger fe67073b74 fix: avoid sessions_send timeouts 2026-01-04 01:52:01 +01:00
Peter Steinberger cbf41859aa test: relax cron default scheduler timeout 2026-01-04 01:45:50 +01:00
Cash Williams 12186e14a9 fix(android): handle unreachable gateway gracefully
Previously, if the gateway was unreachable (wrong IP, offline, etc.),
the Android app would crash with an unhandled socket exception.

Changes:
- Wrap socket.connect() in try/catch to handle connection failures
- Return PairResult with error message instead of crashing
- Display actual error message in status text instead of generic 'pairing required'

This gives users useful feedback like 'Connection refused' or
'Network is unreachable' instead of a crash.
2026-01-04 01:44:43 +01:00
Peter Steinberger fbaa109a3a fix: stabilize lint and test timeouts 2026-01-04 01:42:08 +01:00
Peter Steinberger 70d68d29d0 fix: warm agent.wait cache 2026-01-04 01:35:02 +01:00
Peter Steinberger e7615c464a docs: update apple-reminders skill for remindctl 2026-01-04 01:33:47 +01:00
Peter Steinberger a1780efb9f fix: adjust typing TTL 2026-01-04 00:26:31 +00:00
Peter Steinberger 53d954695e style: format agent.wait imports 2026-01-04 01:22:22 +01:00
Peter Steinberger 44bdd4ca0c chore: regen Swift protocol models 2026-01-04 01:20:20 +01:00
Peter Steinberger 8724c2aea8 fix: satisfy gate checks 2026-01-04 01:16:53 +01:00
Peter Steinberger e3c543ec06 fix: wait on agent.wait for sessions_send 2026-01-04 01:15:23 +01:00
Peter Steinberger 412e8b3aee test: cover gif playback send params 2026-01-03 23:57:43 +00:00
Peter Steinberger 5862f95bd2 fix: lock main session deletion 2026-01-03 23:57:17 +00:00
Peter Steinberger e17c038d18 fix: add gif playback for WhatsApp sends 2026-01-03 23:56:40 +00:00
Peter Steinberger e1dd764504 feat: add node location support 2026-01-04 00:54:44 +01:00
Peter Steinberger 52f59e6dc1 fix: drop stale ClawdisCLI build flag 2026-01-04 00:42:22 +01:00
Peter Steinberger 3bc24bf179 fix: wait for final agent response in sessions_send 2026-01-04 00:40:40 +01:00
Peter Steinberger e07fdd117d docs: migrate Mintlify config 2026-01-04 00:36:55 +01:00
Peter Steinberger 7c062e0ef2 fix: clarify provider requirements in onboarding 2026-01-03 23:29:38 +00:00
Peter Steinberger 0f1781fc2c docs: add Mintlify config 2026-01-04 00:25:42 +01:00
Peter Steinberger 0f6e566a20 fix: make sessions_send wait via agent events 2026-01-04 00:12:14 +01:00
Peter Steinberger 03ee77b0e1 docs: add mac config sync note 2026-01-04 00:09:18 +01:00
Peter Steinberger 86038ec165 chore: apply lint fixes 2026-01-04 00:06:02 +01:00
Peter Steinberger e7c9b9a749 feat: add sessions tools and send policy 2026-01-03 23:44:42 +01:00
Peter Steinberger 919d5d1dbb fix: restore sandbox PATH default 2026-01-03 22:36:16 +00:00
Peter Steinberger 3f7c69fa7f docs: note mac app config sync 2026-01-03 23:34:25 +01:00
Shadow cc07ea82a4 CI: split macOS/android checks 2026-01-03 23:25:51 +01:00
Peter Steinberger 30e22769bb docs: update changelog for #144 2026-01-03 22:25:30 +00:00
Peter Steinberger 6c406b488d ci: consolidate check jobs 2026-01-03 22:25:29 +00:00
Peter Steinberger f13f89e8b9 docs: update changelog for PR 156 2026-01-03 22:59:11 +01:00
Peter Steinberger 8b069e62fc fix: appease lint after merge 2026-01-03 22:59:11 +01:00
Shadow e2709a3ebd CI: split macOS/android checks 2026-01-03 21:55:39 +00:00
Azade 18a89a31af fix(browser): avoid esbuild __name helper in evaluateViaPlaywright
When tsx/esbuild compiles arrow functions, it adds a __name helper
for debugging. This helper doesn't exist in the browser context,
causing 'ReferenceError: __name is not defined' when using
page.evaluate() with inline functions.

The fix uses new Function() constructed at runtime, which esbuild
doesn't transform, avoiding the __name injection.
2026-01-03 22:37:21 +01:00
Peter Steinberger 934f891932 fix: include embedded agent error cause in reply 2026-01-03 21:30:43 +00:00
Peter Steinberger 5493772910 fix: tolerate missing sandbox config in embedded runner 2026-01-03 21:30:40 +00:00
Peter Steinberger c533593d8e fix: add ~/.local/bin to PATH bootstrap for uv-installed tools (fixes #78) (#150) 2026-01-03 22:25:52 +01:00
Mariano Belinky fe1b894676 docs: clarify personal vs private in README (#125) 2026-01-03 22:21:55 +01:00
Mariano Belinky d88581eb7c fix: add ~/.local/bin to PATH for uv tool binaries (#78) 2026-01-03 22:21:16 +01:00
Peter Steinberger 3d39e2ad75 feat(macos): sync gateway config 2026-01-03 22:17:04 +01:00
Peter Steinberger 2dc10ce337 docs: expand peekaboo skill docs 2026-01-03 22:14:21 +01:00
Peter Steinberger d8a417f7ff feat: add sandbox browser support 2026-01-03 22:14:18 +01:00
Peter Steinberger 107dc1aa42 style(logging): organize embedded log imports 2026-01-03 21:09:44 +00:00
Peter Steinberger 9d2d0c64c2 test(gateway): cover config reload 2026-01-03 21:01:26 +00:00
Peter Steinberger 3872f32419 fix(logging): quiet embedded run console logs 2026-01-03 20:57:39 +00:00
Peter Steinberger 3b075dff8a feat: add per-session agent sandbox 2026-01-03 21:41:58 +01:00
Peter Steinberger 7bad9f3fbd fix: drop embedded sandbox wiring 2026-01-03 20:16:53 +00:00
Peter Steinberger 16e3535ac0 refactor: remove bash pty mode 2026-01-03 20:15:10 +00:00
Peter Steinberger a15cffb7de fix: stream tool summaries early and tool output 2026-01-03 21:04:40 +01:00
Peter Steinberger 03c1599544 docs(templates): add platform formatting tips (Discord embeds, tables) 2026-01-03 20:01:17 +00:00
Shadow 6464d93bbb Discord: add forwarded message handling 2026-01-03 13:56:09 -06:00
Peter Steinberger 424d31af1f docs(templates): add voice storytelling tip for sag users 2026-01-03 19:55:32 +00:00
Peter Steinberger e9d7ac8e84 feat(gateway): add config hot reload 2026-01-03 19:52:24 +00:00
Peter Steinberger fac694fc03 docs(skills): add parallel Codex orchestration learnings
- coding-agent: document --yolo flag, git worktree + tmux pattern
- tmux: add section on orchestrating coding agents in parallel

Learnings from running 5 parallel Codex sessions to analyze GitHub issues
2026-01-03 19:45:18 +00:00
Shadow 3e84b9632d Discord: handle system message types 2026-01-03 13:15:19 -06:00
Peter Steinberger ce3fd09e14 docs(faq): add alternative providers section (OpenRouter, Z.AI)
- Added OpenRouter and Z.AI setup examples
- Emphasized using latest Claude 4.5 models, not deprecated 3.x

🦞
2026-01-03 19:14:05 +00:00
Peter Steinberger 641080a0b6 fix: document macOS permission requirements 2026-01-03 20:05:22 +01:00
Jake 99c3fc1128 Scripts: Make ad-hoc fallback opt-in with stronger TCC warnings 2026-01-03 20:05:22 +01:00
Jake 8c7b2aa2d3 Scripts: Fallback to ad-hoc signing in codesign-mac-app.sh 2026-01-03 20:05:22 +01:00
Peter Steinberger 55a07a0ef0 style: fix lint formatting 2026-01-03 18:51:25 +00:00
Peter Steinberger 9899ba53a3 Docs: add PR number for Discord reactions 2026-01-03 18:48:36 +00:00
Peter Steinberger 52458a5628 Discord: default reaction notifications to own 2026-01-03 18:48:36 +00:00
Shadow 7abd6713c8 Docs: clarify discord reaction notifications 2026-01-03 18:48:36 +00:00
Shadow 451174ca10 Discord: add reaction notification allowlist 2026-01-03 18:48:36 +00:00
Peter Steinberger cdfbd6e7eb test(gateway): align config constants in auth test 2026-01-03 19:37:09 +01:00
Peter Steinberger 350e007a5c test(agents): extend text_end coverage 2026-01-03 19:37:09 +01:00
Peter Steinberger 5e156135a1 test(gateway): avoid hoisted export errors 2026-01-03 19:37:09 +01:00
Peter Steinberger b7ec9ae475 fix(gateway): format status/code errors 2026-01-03 19:37:09 +01:00
Peter Steinberger 8a18af409d test(gateway): cover helper registries 2026-01-03 19:37:09 +01:00
Peter Steinberger 6a125b554b refactor(gateway): split server helpers 2026-01-03 19:37:09 +01:00
Shadow ce92fac983 chore: formatting 2026-01-03 12:35:16 -06:00
Peter Steinberger 341a224301 docs: credit Hyaxia in changelog and credits
Co-authored-by: Maxim Vovshin <36747317+Hyaxia@users.noreply.github.com>
2026-01-03 18:05:46 +00:00
Peter Steinberger 95cd153f33 feat: add blogwatcher skill 2026-01-03 18:00:08 +00:00
Peter Steinberger 0af89022ff fix: avoid Swift compiler crash in onboarding wizard 2026-01-03 17:59:37 +00:00
Peter Steinberger 27a8f3d061 chore: add inline guidance for block streaming 2026-01-03 18:46:59 +01:00
Peter Steinberger 72b34f7d03 fix: harden block stream dedupe 2026-01-03 18:44:07 +01:00
Peter Steinberger 73fa2e10bc refactor: split gateway server methods 2026-01-03 18:14:07 +01:00
Peter Steinberger 4a6b33d799 refactor: add gateway server helper modules 2026-01-03 18:00:45 +01:00
Peter Steinberger 145964c85e feat: add github skill 2026-01-03 17:57:13 +01:00
Peter Steinberger 217b84f2ac fix: drop final payloads after block streaming 2026-01-03 17:55:31 +01:00
Peter Steinberger 1d6de24ab3 feat: configurable control ui base path 2026-01-03 17:55:31 +01:00
Peter Steinberger 822def84d2 docs(faq): add Tailscale bind conflict + model/thinking compatibility
- Added Tailscale serve requires bind: loopback (not lan)
- Added model + thinking mode issues section (Gemini Flash, Opus, local LLMs)

From Discord #help session learnings 🦞
2026-01-03 16:53:56 +00:00
Peter Steinberger f313af75e9 fix: avoid duplicate block-stream payloads 2026-01-03 16:53:56 +00:00
Peter Steinberger 591773715e fix: honor whatsapp per-group mention overrides 2026-01-03 17:51:10 +01:00
Peter Steinberger dd6b9b510b docs: update changelog for gateway refactor 2026-01-03 17:35:29 +01:00
Peter Steinberger 6ae51ae3de refactor: split gateway server helpers and tests 2026-01-03 17:34:52 +01:00
Peter Steinberger 00c3e98431 docs: add tmux skill guidance 2026-01-03 17:31:26 +01:00
Peter Steinberger dd561f58d1 docs: expand coding-agent Pi usage 2026-01-03 17:21:17 +01:00
Peter Steinberger 200dd634fb fix: preserve block streaming order 2026-01-03 17:14:01 +01:00
Peter Steinberger 3bbdcaf87f fix: avoid duplicate block streaming 2026-01-03 17:10:47 +01:00
Peter Steinberger abff5e3b1f docs: thank @ratulsarna for control UI UUID fallback 2026-01-03 15:56:36 +00:00
Peter Steinberger 40ee0f0672 build: lock x86_64 relay to AVX2 2026-01-03 16:52:06 +01:00
Peter Steinberger 9f8eeceae7 feat: soften block streaming chunking 2026-01-03 16:48:26 +01:00
Peter Steinberger 53baba71fa feat: unify onboarding + config schema 2026-01-03 16:48:08 +01:00
Peter Steinberger 0f85080d81 Merge pull request #133 from ratulsarna/fix/ui-http-uuid
fix(ui): robust UUID generation for HTTP Control UI
2026-01-03 16:16:43 +01:00
Peter Steinberger 72f8148080 fix: clean up embedded lint 2026-01-03 15:09:07 +00:00
Peter Steinberger be3da5b856 fix: update protocol models and android parsing 2026-01-03 15:04:24 +00:00
Peter Steinberger 9a9b429f74 fix: elevate embedded run logs to info 2026-01-03 15:03:03 +00:00
Peter Steinberger 733e86516e fix: address runtime warnings in build 2026-01-03 15:01:38 +00:00
Peter Steinberger 1a00175eb7 chore: fix lint formatting 2026-01-03 14:57:49 +00:00
Peter Steinberger 77c76ca52f test: fix transcription and tool schema assertions 2026-01-03 14:55:05 +00:00
Peter Steinberger 5de3395204 fix: resolve gcloud python path 2026-01-03 14:36:48 +00:00
Peter Steinberger 4e4655f607 docs(faq): use correct codex login --device-auth command 2026-01-03 14:13:18 +00:00
Peter Steinberger 48731f494b fix: add embedded run logs and typing ttl 2026-01-03 14:09:19 +00:00
Peter Steinberger 4fcd89c3d9 docs(faq): add stop/cancel task + Codex subscription auth sections
- Added FAQ for /stop and other abort commands
- Added FAQ explaining Codex CLI browser auth vs API key
- Browser OAuth uses ChatGPT Pro subscription, API key is pay-per-token

Co-authored-by: Clawd <clawdbot@gmail.com>
2026-01-03 14:08:24 +00:00
Peter Steinberger a4f433a1b1 docs: update onboarding steps 2026-01-03 14:08:24 +00:00
Ratul Sarna 84a7ee491b fix(ui): robust UUID generation on HTTP
Fixes #131
2026-01-03 13:43:20 +00:00
Peter Steinberger 3043dd3a0c fix: restructure macOS connections settings 2026-01-03 14:25:03 +01:00
Jake 81f4a7cdb7 Agents: Fix Gemini schema compatibility and robust model discovery 2026-01-03 13:57:29 +01:00
Peter Steinberger c2a74d6d2a docs(template): add 'Write It Down' rule to AGENTS.md template
Mental notes don't survive sessions. Files do. Text > Brain 📝
2026-01-03 12:52:11 +00:00
Peter Steinberger 861e1b33f5 docs(skill): add PR review safety rules for coding-agent
- Never checkout branches in live Clawdis repo
- Clone to temp folder or use git worktree for reviews
- Added explicit examples for safe PR review workflow
2026-01-03 12:49:03 +00:00
Peter Steinberger 0647d56555 fix(build): repair tool-meta regex literal 2026-01-03 12:46:33 +00:00
Peter Steinberger ea6aea8532 docs: warn about gmail watcher port conflict 2026-01-03 12:41:44 +00:00
Peter Steinberger 6eca2edd79 chore(swift): update Swabble package lock 2026-01-03 13:38:18 +01:00
Peter Steinberger d31dfbc565 chore(canvas): refresh a2ui bundle hash 2026-01-03 13:38:12 +01:00
Peter Steinberger 1e0f776824 test(gateway): add multi-instance e2e suite 2026-01-03 13:37:46 +01:00
Peter Steinberger db36f0105d fix(gateway): validate event/response frames 2026-01-03 13:37:40 +01:00
Peter Steinberger 5377e2400a fix: avoid red gmail-watcher prefix 2026-01-03 12:36:15 +00:00
Peter Steinberger 72c0aa63fb style: tidy imports and formatting 2026-01-03 12:35:23 +00:00
Peter Steinberger 933bee220f fix(cron): pass resolved channel to agent tools 2026-01-03 12:35:23 +00:00
Peter Steinberger bd2dabfa8f fix(agents): load tool display config from disk 2026-01-03 12:35:23 +00:00
Peter Steinberger f11b352089 fix(macos): expand onboarding window height 2026-01-03 13:34:30 +01:00
Peter Steinberger bb54e60179 fix(logging): decouple file logs from console verbose 2026-01-03 12:32:14 +00:00
Peter Steinberger e52bdaa2a2 fix: repair tool meta regex 2026-01-03 12:30:46 +00:00
Peter Steinberger b6301c719b fix: default low thinking for reasoning models 2026-01-03 12:19:06 +00:00
Peter Steinberger 6e16c0699a feat: centralize tool display metadata 2026-01-03 13:18:27 +01:00
Peter Steinberger bf4ad295af docs(faq): add media/vision troubleshooting section
- Added FAQ entry for images/media not being understood
- Covers vision-capable models checklist
- Debugging steps for media pipeline
- Link to summarize.sh for exotic files

Co-authored-by: Clawd <clawdbot@gmail.com>
2026-01-03 11:43:40 +00:00
Peter Steinberger 7a80e8fe77 refactor: centralize home path shortening 2026-01-03 12:42:27 +01:00
Peter Steinberger 1ec3512925 refactor!: drop clawdis_ tool prefix 2026-01-03 12:39:52 +01:00
Peter Steinberger 772ada4308 fix: refine tool summaries and scope discord tool 2026-01-03 12:33:42 +01:00
Peter Steinberger 7165c8a7e5 refactor: rename bundle identifiers to com.clawdis 2026-01-03 12:26:22 +01:00
Peter Steinberger daa1460502 docs(discord): document sendMessage mediaUrl and to format
- Add example for sendMessage with media attachment (file:// and https://)
- Clarify that sendMessage uses 'to: channel:<id>' not 'channelId'
- Document replyTo parameter for replying to specific messages
- Add mediaUrl to inputs section
2026-01-03 11:05:09 +00:00
Peter Steinberger f47c7ac369 feat: support configurable gateway port 2026-01-03 12:00:17 +01:00
Peter Steinberger 7199813969 docs: document gateway port configuration 2026-01-03 11:46:58 +01:00
Peter Steinberger 87d5fa516d docs(skills): correct bear-notes instructions
Co-authored-by: Tyler Wince <tylerwince@users.noreply.github.com>
2026-01-03 11:34:31 +01:00
Claude 10340d2a3f feat(skills): add bear-notes skill using grizzly CLI 2026-01-03 11:29:14 +01:00
Peter Steinberger 508c4d362f docs: update changelog for gog skill 2026-01-03 11:20:17 +01:00
Mariano Belinky f73b008251 docs: add Sheets/Docs examples to gog skill 2026-01-03 11:20:17 +01:00
Peter Steinberger c583e64bb7 chore: update changelog 2026-01-03 11:17:00 +01:00
Peter Steinberger 9df63b008d docs: move telegram chunking fix to beta6 2026-01-03 11:15:57 +01:00
Peter Steinberger 3daecc092c docs: add changelog entry for telegram block replies 2026-01-03 11:12:15 +01:00
Muhammed Mukhthar CM 4d42811ecf fix(telegram): add textLimit to block reply chunking
Block streaming replies were missing the textLimit parameter in
deliverReplies(), causing long messages to fail with 'message is too
long' error instead of being chunked properly.

The final reply path already included textLimit, but the onBlockReply
callback path did not.
2026-01-03 11:12:15 +01:00
Peter Steinberger 1bebcf8033 chore: update appcast and TUI streaming handling 2026-01-03 11:06:49 +01:00
Peter Steinberger 45c555a4bd fix: use x86_64 bun for relay builds 2026-01-03 11:06:49 +01:00
Peter Steinberger 5986a83e80 fix: skip duplicate arch merge for Sparkle 2026-01-03 11:06:49 +01:00
Peter Steinberger 732de4acf0 fix: make Sparkle builds numeric + universal 2026-01-03 11:06:48 +01:00
Shadow 7400c0946e Discord: update UIs to use the new config 2026-01-03 01:02:22 -06:00
Peter Steinberger 14ee2b2c11 FAQ: Add common questions from Discord (Jan 3)
- Linux/VPS installation without Homebrew
- Minimum system requirements (runs on 1GB RAM!)
- Enterprise OAuth status (not supported yet)
- Discord DM allowlist config
- Model switching with /model
- Message queue modes with /queue
2026-01-03 06:09:51 +00:00
Peter Steinberger c3e1b8cfd9 chore: update protocol swift models 2026-01-03 06:44:21 +01:00
Peter Steinberger 67a67df35a fix: avoid unsafe string coercion in tui 2026-01-03 06:44:17 +01:00
Peter Steinberger 0f0578b268 docs: check off tui gate 2026-01-03 06:37:44 +01:00
Peter Steinberger 662208949f fix: align sessions.patch and tui typing 2026-01-03 06:37:40 +01:00
Peter Steinberger e41821342b docs: refresh tui guide 2026-01-03 06:28:36 +01:00
Peter Steinberger d3458a4fc3 feat: overhaul tui controller 2026-01-03 06:27:38 +01:00
Peter Steinberger 32c91bbb25 feat: add tui ui kit 2026-01-03 06:22:20 +01:00
Peter Steinberger aee13507f9 feat: expand tui gateway client 2026-01-03 06:17:33 +01:00
Peter Steinberger 61b67f6301 feat: extend gateway session patch 2026-01-03 06:16:49 +01:00
Peter Steinberger b86619bcd0 docs: fix appcast to only ship beta5 2026-01-03 06:12:01 +01:00
Peter Steinberger 31b5b45581 docs: refresh appcast for notarized beta5
CI / build (bun) (push) Failing after 38s
CI / build (node) (push) Failing after 39s
CI / android (push) Failing after 3s
CI / macos-app (push) Has been cancelled
CI / ios (push) Has been cancelled
2026-01-03 06:04:20 +01:00
Peter Steinberger 33cdb16b9e docs: update appcast for 2.0.0-beta5 2026-01-03 05:55:31 +01:00
Peter Steinberger 53fd7a4473 chore: fix lint ordering 2026-01-03 05:38:29 +01:00
Peter Steinberger 10d56d31e9 docs: date 2.0.0-beta5 changelog 2026-01-03 05:37:04 +01:00
Peter Steinberger 3633c829ae fix: repair discord action typing 2026-01-03 05:33:57 +01:00
Peter Steinberger 6cda84432e fix: stabilize pi-ai patch + tests 2026-01-03 05:22:09 +01:00
Peter Steinberger b914eaa6fa chore: apply biome lint fixes 2026-01-03 05:10:09 +01:00
Peter Steinberger 988b67aa30 test: refresh auto-reply expectations 2026-01-03 05:09:59 +01:00
Peter Steinberger 0ed5b82389 fix: prefer explicit hook mappings 2026-01-03 05:09:54 +01:00
Peter Steinberger b417fe5727 fix: show rich session names in chat UIs 2026-01-03 05:07:13 +01:00
Peter Steinberger fabad7aa7a docs: update changelog for antigravity oauth 2026-01-03 05:01:42 +01:00
Peter Steinberger 3c54da952a chore: refresh pi-ai patch hash 2026-01-03 05:01:42 +01:00
Peter Steinberger 2ef2646b31 chore: note lossy google schema scrub 2026-01-03 05:01:42 +01:00
mukhtharcm 82ad7e29a6 fix: reject antigravity auth in non-interactive onboarding mode 2026-01-03 05:01:42 +01:00
mukhtharcm 2290a3c8af feat: add VPS-aware Antigravity OAuth with manual URL paste fallback
Detects SSH/VPS/headless environments and prompts user to paste
the OAuth callback URL instead of relying on localhost server.

- Add antigravity-oauth.ts with VPS detection and manual OAuth flow
- Update onboard-interactive.ts to use VPS-aware flow
- Update configure.ts to use VPS-aware flow
2026-01-03 05:01:42 +01:00
mukhtharcm d216cebff5 fix: use claude-opus-4-5-thinking as default antigravity model 2026-01-03 05:01:42 +01:00
mukhtharcm 05bd345828 feat: add Google Antigravity authentication support
- Add 'antigravity' as new auth choice in onboard and configure wizards
- Implement Google Antigravity OAuth flow using loginAntigravity from pi-ai
- Update writeOAuthCredentials to accept any OAuthProvider (not just 'anthropic')
- Add schema sanitization for Google Cloud Code Assist API to fix tool call errors
- Default model set to google-antigravity/claude-opus-4-5 after successful auth

The schema sanitization removes unsupported JSON Schema keywords (patternProperties,
const, anyOf, etc.) that Google's Cloud Code Assist API doesn't understand.
2026-01-03 05:01:42 +01:00
Peter Steinberger 5eff541da8 docs: prefer spogo or spotify_player 2026-01-03 04:47:34 +01:00
Peter Steinberger 598a27cc96 docs: update changelog for tui 2026-01-03 04:47:34 +01:00
Peter Steinberger 08ce608ae7 feat: add gateway TUI 2026-01-03 04:47:34 +01:00
Peter Steinberger 928631309e docs: note queue tests 2026-01-03 04:47:34 +01:00
Peter Steinberger 971b98c96d test: cover new queue modes 2026-01-03 04:47:34 +01:00
Peter Steinberger a72da30c9a sag skill: add chat voice response guidance
When Peter asks for voice replies, generate audio with sag and send via MEDIA:
2026-01-03 03:34:31 +00:00
Peter Steinberger f7eabcb2d9 docs: note new queue modes 2026-01-03 04:27:22 +01:00
Peter Steinberger ac36eba822 feat: expand queue modes and followup backlog 2026-01-03 04:26:49 +01:00
Peter Steinberger 6160521f2f fix: guard bash pty cwd 2026-01-03 03:05:51 +00:00
Jared Verdi ca9b0dbc88 Gmail watcher: start when gateway (re)starts 2026-01-03 03:49:53 +01:00
Peter Steinberger 11c7e05f43 fix: harden pty spawn path 2026-01-03 02:36:01 +00:00
Peter Steinberger 1781105438 group chat: hint that reactions are welcome while lurking
Even when staying silent, emoji reactions show engagement without cluttering chat.
2026-01-03 02:29:32 +00:00
Peter Steinberger 632ca01fbf style: format linted files 2026-01-03 03:10:17 +01:00
Peter Steinberger b8fd22bfd8 docs: update changelog for discord actions 2026-01-03 03:07:13 +01:00
Shadow 98a1deb129 UI: add discord action toggles 2026-01-03 03:07:13 +01:00
Shadow 0c38f2df2a Discord: drop enableReactions config 2026-01-03 03:07:13 +01:00
Shadow 6bab813bb3 Discord: add reactions, stickers, and polls skill 2026-01-03 03:07:13 +01:00
Peter Steinberger d8201f8436 fix: handle null action in hooks-mapping mergeAction call 2026-01-03 02:05:01 +00:00
Peter Steinberger b28e4e95c2 docs: note gmail watcher auto-start 2026-01-03 03:04:15 +01:00
Peter Steinberger a3865f1417 group chat: add lurking guidance to system prompt
Be a good group participant: lurk and follow the conversation,
but only chime in when genuinely helpful. Quality over quantity.
2026-01-03 02:02:55 +00:00
Peter Steinberger fb10bf5f75 feat: add bash pty diagnostics 2026-01-03 01:56:54 +00:00
Peter Steinberger a9eb31e8fe fix: satisfy discord and gateway typing 2026-01-03 02:55:43 +01:00
Peter Steinberger 3ec5ce8349 docs: note onboarding scroll gutter 2026-01-03 02:55:43 +01:00
Peter Steinberger c5d70019bb fix: respect onboarding scroll indicator preference 2026-01-03 02:55:43 +01:00
Shadow a35fb3a9b4 macOS: add onboarding scroll gutter 2026-01-03 02:55:43 +01:00
Peter Steinberger 79403f9083 docs: update apple notes/reminders skill setup 2026-01-03 02:41:12 +01:00
Claude 7a44c19362 feat(skills): add Apple Notes and Reminders skills via memo CLI 2026-01-03 02:41:12 +01:00
Peter Steinberger 11fc10ea47 docs: thank contributor for telegram group gating 2026-01-03 02:34:48 +01:00
Peter Steinberger 7e4e9ecdea templates: add qmd semantic memory recall to AGENTS.md 2026-01-03 01:33:10 +00:00
Peter Steinberger 0c013a237f fix: default block streaming break to message_end 2026-01-03 01:33:10 +00:00
Peter Steinberger f85951bc65 docs: add changelog entry for gog calendar fix 2026-01-03 02:32:50 +01:00
Jared Verdi 12e27f9e5e Gateway: ack skipped hook transforms with 204 2026-01-03 02:32:50 +01:00
Jay Hickey 7e9be3c28c Update gog Calendar command date format to RFC3339
I am seeing instances where Clawdis is not including timezone in the gog calendar range requests. This results in a 400 bad request from the Google API, e.g.

```
gog calendar events primary --from 2026-01-02T00:00:00 --to 2026-01-03T23:59:59 --account <email>

Google API error (400 badRequest): Bad Request
```

While this is a valid ISO 8601 format, Google Calendar API requires a stricter RFC 3339 format like the following:

```
gog calendar events primary --from 2026-01-02T00:00:00Z --to 2026-01-03T23:59:59Z --account <email>

<calendar events listed successfully>
```
2026-01-03 02:30:32 +01:00
Peter Steinberger 3368fcf31e fix: avoid duplicate replies with block streaming 2026-01-03 02:16:01 +01:00
Peter Steinberger 32877afe55 docs: note self-chat config docs update 2026-01-03 02:05:37 +01:00
rafaelreis-r efe7eca726 docs: clarify routing.allowFrom and self-chat mode for group mentions
- Add new section explaining self-chat mode for group control
- Document routing.allowFrom as the key setting for controlling metadata mentions
- Clarify difference between whatsapp.allowFrom (DM allowlist) and routing.allowFrom (self-chat mode)
- Explain metadata mentions vs text patterns in routing.groupChat
- Add example config for responding only to specific text triggers

When routing.allowFrom contains the bot's own number, WhatsApp native
@-mentions are ignored in groups, and only mentionPatterns trigger responses.
This prevents unwanted responses when users tap-to-mention the bot owner.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 02:05:37 +01:00
Peter Steinberger 72d1fa4da5 fix: dedupe repeated block replies 2026-01-03 01:49:27 +01:00
Peter Steinberger 2042013360 test: cover provider textChunkLimit config 2026-01-03 01:49:27 +01:00
Peter Steinberger f5189cc897 refactor: move text chunk limits to providers 2026-01-03 01:49:27 +01:00
Peter Steinberger 75a9cd83a0 fix(mac): resolve camera type deprecation 2026-01-03 01:49:27 +01:00
Peter Steinberger 5684e2d658 feat: configurable outbound text chunk limits 2026-01-03 01:49:27 +01:00
Peter Steinberger 2d28fa34f5 feat: make block streaming break configurable 2026-01-03 01:49:27 +01:00
Peter Steinberger ea7d967625 Update Discord invite to vanity URL discord.gg/clawd 🦞
Thanks camerondare for the boosts! Level 3 unlocked.
2026-01-03 00:47:22 +00:00
Peter Steinberger 5dfb2b1128 coding-agent: add temp space pattern, never start in ~/clawd
Learnings from tonight:
- Codex reads AGENTS.md/SOUL.md and gets ideas about org hierarchy
- Use mktemp -d for scratch/chat sessions
- Never start in ~/clawd or agent home dirs
- Keep agents in their 'little box' 📦🦞
2026-01-03 00:35:51 +00:00
Peter Steinberger cbc599a5b8 coding-agent: add batch PR review pattern
Tonight's learnings:
- Parallel Codex army for batch PR reviews
- Fetch PR refs: git fetch origin '+refs/pull/*/head:refs/remotes/origin/pr/*'
- Use git diff origin/main...origin/pr/XX (don't checkout)
- Post results with gh pr comment
- Successfully reviewed 13 PRs in parallel! 🦞
2026-01-03 00:24:34 +00:00
Peter Steinberger 1354d0836f coding-agent: comprehensive update from Jan 2 learnings
- workdir 'little box' pattern (don't read unrelated files)
- background mode replaces tmux
- --full-auto for building, vanilla for reviewing
- parallel Codex processes supported
- PR review tips (fetch refs, use git diff, don't checkout)
- patience rules (don't kill slow sessions!)
2026-01-03 00:11:21 +00:00
Peter Steinberger b313250638 coding-agent: switch to native background mode, drop tmux requirement
- Use bash background:true instead of tmux
- Full programmatic control: log/poll/write/kill
- Simpler, no shell escaping issues
- workdir still critical for 'little box' pattern
2026-01-03 00:00:37 +00:00
Peter Steinberger e37c147ea9 coding-agent: unified workdir+tmux pattern for all tools 2026-01-02 23:58:33 +00:00
Peter Steinberger feb4f9028d coding-agent: choose reasoning effort based on task complexity 2026-01-02 23:57:46 +00:00
Peter Steinberger 4804ce5678 coding-agent: simplify to gpt-5.2-codex only, remove old models 2026-01-02 23:57:24 +00:00
Peter Steinberger 001a342f20 coding-agent: workdir pattern + patience rules
- Use bash workdir param so Codex wakes up in a 'little box'
- Prevents reading unrelated files (like my soul.md lol)
- Added rule: NEVER offer to build it yourself when user asks for Codex
- gpt-5.2-codex requires medium reasoning effort
2026-01-02 23:56:10 +00:00
Peter Steinberger fe040b84d9 chore: sync lockfile and bundle hash 2026-01-03 00:40:39 +01:00
Sreekaran Srinath 0ac30afb29 feat: add coding-agent skill and anyBins gating
Co-authored-by: Sreekaran Srinath <ss@sreekaran.com>
2026-01-03 00:40:03 +01:00
Peter Steinberger 59601eb99c fix: preserve newlines in reply tags 2026-01-02 23:36:43 +00:00
Peter Steinberger 9616f4b2b1 feat: stream reply blocks immediately 2026-01-03 00:28:33 +01:00
Peter Steinberger 9dd613edf7 fix(mac): harden remote tunnel recovery 2026-01-03 00:02:27 +01:00
Peter Steinberger 88ed58b3d0 chore: update deps and extend read tool tests 2026-01-02 23:47:28 +01:00
Peter Steinberger fc54e905c0 chore: upgrade pi-mono deps to 0.31.1 2026-01-02 23:37:08 +01:00
Peter Steinberger d1b76cb1b2 test: cover replyToMode behavior 2026-01-02 23:20:52 +01:00
Peter Steinberger 2c92ccd66e feat: add reply tags and replyToMode 2026-01-02 23:18:41 +01:00
Peter Steinberger a9ff03acaf feat: unify group mention defaults 2026-01-02 22:50:58 +01:00
Shadow 281dc10b2f Changelog: mention Discord reply context 2026-01-02 15:41:45 -06:00
Peter Steinberger fd32fc8d8d feat: add discord guild wildcard defaults 2026-01-02 22:33:26 +01:00
Peter Steinberger 47f4f59692 chore: remove stray ds_store files 2026-01-02 22:24:26 +01:00
Peter Steinberger 5cf1a9535e feat: move group mention gating to provider groups 2026-01-02 22:24:26 +01:00
Peter Steinberger e93102b276 chore: bump peekaboo submodule 2026-01-02 22:24:26 +01:00
Shadow da57c314ef Discord: clarify docs and drop legacy guild schema 2026-01-02 15:21:13 -06:00
Shadow 2676636316 Discord: fix reply context formatting 2026-01-02 14:55:07 -06:00
Shadow f3a973dc9e Discord: include reply context 2026-01-02 14:49:16 -06:00
Peter Steinberger f4a1190bdd docs: add CONTRIBUTING.md with maintainers and guidelines
- List maintainers with GitHub/X links
- Link to Discord and GitHub Discussions
- AI/vibe-coded PRs welcome with transparency guidelines
- Link from README

Co-authored-by: Clawd <clawdbot@gmail.com>
2026-01-02 20:31:41 +00:00
Peter Steinberger 118a6d7421 fix: align discord config ui 2026-01-02 21:15:59 +01:00
Peter Steinberger 4541bb2716 Merge pull request #108 from thewilloftheshadow/shadow/ui-connection-update
UI: Update connections UIs
2026-01-02 21:04:45 +01:00
Peter Steinberger 505c4262c6 docs: note optional docker setup 2026-01-02 20:59:58 +01:00
Peter Steinberger 3104b088e4 chore(canvas): update a2ui bundle hash 2026-01-02 19:58:46 +00:00
Peter Steinberger f12f814816 docs(whatsapp): add number guidance 2026-01-02 19:58:44 +00:00
Peter Steinberger 3b0ad719c9 chore(discord): add verbose diagnostics 2026-01-02 19:58:42 +00:00
Peter Steinberger e368e56246 chore(gateway): quiet provider startup logs 2026-01-02 19:58:40 +00:00
Peter Steinberger 675420013d fix(macos): resolve gateway launch args 2026-01-02 19:58:38 +00:00
Peter Steinberger eaa69fb6b2 test: silence docker onboarding noise 2026-01-02 20:46:26 +01:00
Peter Steinberger e0795cf18c test: annotate onboarding docker e2e 2026-01-02 20:41:47 +01:00
Peter Steinberger 8ed878e73c test: stabilize docker onboarding e2e 2026-01-02 20:40:33 +01:00
Peter Steinberger 08b95411df chore: add goplaces skill 2026-01-02 20:33:06 +01:00
Peter Steinberger 460fafff7f docs: thank @dan-dr for docker setup 2026-01-02 20:24:44 +01:00
Peter Steinberger 7b4fa9e1a1 test: cover camera list invoke 2026-01-02 20:24:41 +01:00
Peter Steinberger 7e4ebb22a0 Merge pull request #107 from dan-dr/main
Add Docker setup script
2026-01-02 20:24:21 +01:00
Peter Steinberger 8b47315845 fix(macos): improve session preview loading 2026-01-02 19:55:19 +01:00
Shadow 96a5e01878 macOS: swiftformat connections settings 2026-01-02 12:30:59 -06:00
Shadow 5e36390a27 macOS: fix swiftlint param count 2026-01-02 12:25:47 -06:00
Shadow 729a545173 Update connections UIs 2026-01-02 12:06:05 -06:00
Dan 488f5e2dac Merge branch 'steipete:main' into main 2026-01-02 19:53:16 +02:00
Peter Steinberger 49e89cf3f1 fix: satisfy swiftformat for ios build 2026-01-02 18:48:05 +01:00
Peter Steinberger 43f6b9ef32 fix: resolve camera tool handling 2026-01-02 17:44:25 +00:00
Peter Steinberger 8e48cffe3b fix(macos): decode session preview payload 2026-01-02 18:32:03 +01:00
Peter Steinberger 3ed01adabc feat(macos): add session previews in menu 2026-01-02 18:29:47 +01:00
Dan 4239de8060 Merge branch 'steipete:main' into main 2026-01-02 19:26:14 +02:00
Peter Steinberger cba37f99b6 test: cover camera device selection 2026-01-02 18:25:22 +01:00
Peter Steinberger 74db53d939 feat: add camera list and device selection 2026-01-02 18:23:26 +01:00
Peter Steinberger 2b34bf08da fix: resolve mac camera continuation isolation 2026-01-02 18:02:24 +01:00
Dan b92f70c52b Merge branch 'steipete:main' into main 2026-01-02 19:00:21 +02:00
Peter Steinberger 34d2e1e2e8 fix: wait for camera exposure to settle 2026-01-02 17:57:34 +01:00
Peter Steinberger 5f82739e2b test: cover camera snap mime mapping 2026-01-02 17:49:20 +01:00
Peter Steinberger d79dc4d742 fix: correct camera snap mime mapping 2026-01-02 17:43:34 +01:00
Peter Steinberger 1d12a844c2 docs: add WhatsApp disconnect workaround to FAQ
When using macOS app with WhatsApp issues:
1. Run pnpm gateway:watch (Node instead of bun)
2. Enable 'External gateway' in app debug settings

Verified gateway:watch command exists in package.json
2026-01-02 16:34:27 +00:00
Peter Steinberger 2d16450869 feat: add weather skill (wttr.in + Open-Meteo fallback)
No API key required. Two services:
- wttr.in: human-readable, emoji, ASCII art, PNG
- Open-Meteo: JSON API fallback for programmatic use

🌤️🦞
2026-01-02 16:33:31 +00:00
Peter Steinberger 2a6248dad6 fix: add camera entitlement to macOS signing 2026-01-02 17:31:59 +01:00
Peter Steinberger 8b27c03472 docs(skills/local-places): add emoji and tagline
📍 Find places, Go fast

🦞
2026-01-02 16:22:26 +00:00
Peter Steinberger baf3bea574 docs(changelog): note macOS config actor fix 2026-01-02 17:16:49 +01:00
Peter Steinberger 868b438e67 test(gateway): fix nix mode mock toggle 2026-01-02 17:15:26 +01:00
Peter Steinberger 8989bd9fd7 fix(auto-reply): default whatsapp self-only on empty config 2026-01-02 17:15:26 +01:00
Peter Steinberger a4f12babb7 test(macos): cover gateway password whitespace 2026-01-02 17:15:26 +01:00
Peter Steinberger 97e06a8eb4 chore(canvas): regenerate a2ui bundle hash 2026-01-02 17:15:26 +01:00
Peter Steinberger 0de6e38ce9 fix(macos): keep config writes on main actor 2026-01-02 17:15:26 +01:00
Peter Steinberger 314164fb8a chore: fix lint and add gateway auth tests 2026-01-02 17:15:26 +01:00
Peter Steinberger 8d925226cb docs: expand FAQ with Docker, OAuth, bun vs Node, debugging
- Docker/container setup (volumes, pnpm persistence, startup script)
- OAuth vs API key billing differences
- OAuth callback workarounds for headless/containers
- Terminal onboarding vs macOS app (terminal more stable)
- bun binary vs Node runtime (Node more stable for WhatsApp)
- gateway:watch for debugging
- Tailscale link and setup clarification

Based on community questions from Discord #help
2026-01-02 16:04:02 +00:00
Peter Steinberger f2eb2004aa docs: thank @jeffersonwarrior for gateway auth 2026-01-02 16:51:48 +01:00
Peter Steinberger bf37015c23 Merge pull request #85 from jeffersonwarrior/main
feat: add gateway password auth support and fix Swift 6.2 concurrency
2026-01-02 16:50:57 +01:00
Peter Steinberger f489b6e7a5 chore: merge origin/main 2026-01-02 16:50:29 +01:00
Peter Steinberger 921e5be8e6 fix(skills/local-places): copy files instead of submodule
Submodules are pain. Just copy the Python code directly.

🦞
2026-01-02 15:48:24 +00:00
Peter Steinberger a8bc974a2e fix: harden gateway password auth 2026-01-02 16:47:52 +01:00
Peter Steinberger 100a022ab7 feat(skills/local-places): add server as submodule
- Links to Hyaxia/local_places for easy upstream updates
- Updated SKILL.md with {baseDir}/server path

🦞
2026-01-02 15:47:42 +00:00
Peter Steinberger 6b7484a885 feat(skills): add local-places skill for Google Places search
- Wraps Hyaxia/local_places FastAPI server
- Two-step flow: resolve location → search places
- Supports filters: type, rating, price, open_now

🦞
2026-01-02 15:46:08 +00:00
Peter Steinberger 8de40e0c10 feat(macos): add Camera permission to onboarding flow
- Add 'camera' case to Capability enum
- Add UI strings (title, subtitle, icon) in PermissionsSettings
- Add ensureCamera() and camera status check in PermissionManager
- Add CameraPermissionHelper for opening System Settings

🦞 Clawd's first code contribution!
2026-01-02 15:27:54 +00:00
Peter Steinberger 9b3aef3567 fix: show skill descriptions in onboarding list 2026-01-02 16:25:28 +01:00
Peter Steinberger 25e52e19dc fix(macos): return node name 2026-01-02 15:28:34 +01:00
Peter Steinberger 68806902ff fix(macos): show gateway in devices list 2026-01-02 15:27:21 +01:00
Peter Steinberger ebf8649940 feat: add songsee skill 2026-01-02 15:22:23 +01:00
Peter Steinberger c93d02891a test: cover control ui token url 2026-01-02 15:13:05 +01:00
Peter Steinberger 87be5c737c fix(macos): suppress cancelled node refresh 2026-01-02 15:12:57 +01:00
Peter Steinberger ad9d6f616d fix: improve onboarding auth UX 2026-01-02 15:03:38 +01:00
Peter Steinberger f57f892409 fix(macos): clarify gateway error state 2026-01-02 13:48:19 +01:00
Peter Steinberger 5ecb65cbbe fix: persist gateway token for local CLI auth 2026-01-02 13:46:48 +01:00
Peter Steinberger 1e04481aaf style: format discord slash handler 2026-01-02 13:38:36 +01:00
Peter Steinberger 5f103e32bd fix: gate discord slash commands 2026-01-02 13:38:35 +01:00
Shadow fff9efe8a8 Discord: auto-register slash command 2026-01-02 13:38:35 +01:00
Shadow b135b3efb9 Discord: add slash command handling 2026-01-02 13:38:35 +01:00
Peter Steinberger 17e17f85ae docs: note gateway auto-migrate 2026-01-02 13:10:09 +01:00
Peter Steinberger ecef49605b test: cover gateway legacy auto-migrate 2026-01-02 13:09:41 +01:00
Peter Steinberger 7b72b35cca chore: update doctor migration hash 2026-01-02 13:07:26 +01:00
Peter Steinberger 16420e5b31 refactor: auto-migrate legacy config in gateway 2026-01-02 13:07:14 +01:00
Peter Steinberger 55665246bb chore: refresh doctor migration commit 2026-01-02 13:00:44 +01:00
Peter Steinberger b9b862a380 chore: note doctor migration commit 2026-01-02 13:00:29 +01:00
Peter Steinberger 0766c5e3cb refactor: move whatsapp allowFrom config 2026-01-02 13:00:29 +01:00
ddyo 8d4c6d41ab Docker: add root-level setup 2026-01-02 13:53:06 +02:00
Peter Steinberger 58d32d4542 docs: expand FAQ with skills, Tailscale, troubleshooting
- How to add/reload skills (/reset)
- Tailscale for multi-machine setups
- Using Codex to debug
- Handling supervised processes on Linux
- Clean uninstall steps
2026-01-02 11:50:09 +00:00
Peter Steinberger 6bad75827a docs: clarify Signal setup and env-token gating 2026-01-02 11:41:08 +00:00
Peter Steinberger 2b3ddabe90 fix(gateway): gate providers by config presence 2026-01-02 11:41:01 +00:00
Peter Steinberger e92b480629 fix(signal): surface signal-cli failures as errors 2026-01-02 11:40:55 +00:00
Peter Steinberger a53cdbf1b4 docs: clarify Windows is untested in FAQ 2026-01-02 11:30:27 +00:00
Peter Steinberger 21a64a9957 docs: link FAQ and add platforms note 2026-01-02 11:24:41 +00:00
Peter Steinberger d656db4d04 fix: widen discord channel type check 2026-01-02 12:23:35 +01:00
Peter Steinberger 506b66a852 docs: add FAQ with common questions from Discord
Covers:
- Installation & setup (data locations, unauthorized errors, fresh start, doctor)
- Migration & deployment (new machine, VPS, Docker)
- Multi-instance & contexts (one Clawd philosophy, groups for separation)
- Context & memory (200k tokens, autocompaction, workspace location)
- Platforms (supported platforms, multi-platform, WhatsApp numbers)
- Troubleshooting (build errors, WhatsApp logout, gateway issues)
- Chat commands reference

Based on community questions from #help channel.

🦞
2026-01-02 11:22:06 +00:00
Peter Steinberger 95f03d63ad style(ui): refresh dashboard theme 2026-01-02 11:22:06 +00:00
Peter Steinberger 7f8af736dd chore(canvas): regenerate a2ui bundle hash 2026-01-02 11:22:06 +00:00
Peter Steinberger eaacebeecc fix: improve onboarding/imessage errors 2026-01-02 12:20:48 +01:00
Peter Steinberger fd4cff06ca test: fix discord/config test lint 2026-01-02 12:20:43 +01:00
Peter Steinberger b50df6eb1d style: format linted files 2026-01-02 12:20:38 +01:00
Peter Steinberger fa16304e4f docs: note discord ignore-list removal 2026-01-02 11:54:30 +01:00
Peter Steinberger eda74d3a55 test: cover every schedule anchor boundary 2026-01-02 11:33:49 +01:00
Peter Steinberger 25762c0ac6 docs(discord): note from label includes tag/id 2026-01-02 11:32:59 +01:00
Peter Steinberger 2d7289bcad docs: update changelog for cron fix 2026-01-02 11:29:35 +01:00
Peter Steinberger 2d1d5d603d Merge pull request #80 from jamesgroat/fix/cron-every-schedule-infinite-loop
fix(cron): prevent every schedule from firing in infinite loop
2026-01-02 11:29:08 +01:00
Peter Steinberger 94206cf10f Merge pull request #92 from thewilloftheshadow/shadow/discord-id
Discord: pass user id to clawd so it can ping users
2026-01-02 11:27:37 +01:00
Peter Steinberger dc2521a1cf merge main into shadow/discord-id 2026-01-02 11:27:24 +01:00
Peter Steinberger 30b5955f22 fix(discord): add tag/id to from label 2026-01-02 11:26:09 +01:00
Peter Steinberger 4267a1b87d test: cover discord config + slug routing 2026-01-02 11:19:10 +01:00
Peter Steinberger eb44ae76f1 feat: add discord guild map + group dm controls 2026-01-02 11:15:52 +01:00
Peter Steinberger bd3d18f660 fix: unbreak TypeScript build 2026-01-02 11:02:06 +01:00
Peter Steinberger 8bd5f1b9f2 fix: improve onboarding allowlist + Control UI link 2026-01-02 10:57:04 +01:00
Peter Steinberger 71b0dcc922 Merge pull request #100 from steipete/feat/trello-skill
feat(skills): add Trello skill for board/list/card management
2026-01-02 10:47:45 +01:00
Peter Steinberger 1bf7d2f3bd docs: update trello skill requirements 2026-01-02 10:47:31 +01:00
Peter Steinberger 87127fd133 fix: refine web chat session selector 2026-01-02 10:40:24 +01:00
Peter Steinberger e85c15d178 docs: note mac app rebuilds need local 2026-01-02 10:38:18 +01:00
Peter Steinberger 0f56dce748 feat: add discord dm/guild allowlists 2026-01-02 10:32:21 +01:00
Peter Steinberger d2e2077ada fix: add top padding before first chat message 2026-01-02 10:23:40 +01:00
Peter Steinberger 9adbf47773 refactor: normalize group session keys 2026-01-02 10:14:58 +01:00
Peter Steinberger e5ee041d4e feat(skills): add Trello skill for board/list/card management 2026-01-02 08:37:15 +00:00
Shadow 63a46a85f6 feat: pass discord id to clawd so it can ping users 2026-01-01 23:30:03 -06:00
Jefferson Nunn fe87d6d8be feat(macOS): add gateway password auth support and fix Swift 6.2 concurrency
- Add CLAWDIS_GATEWAY_PASSWORD to launchd plist environment
- Read password from gateway.remote.password config in client
- Fix Swift 6.2 sending parameter violations in config save functions
- Add password parameter to GatewayConnection.Config type
- GatewayChannel now sends password in connect auth params
- GatewayEndpointStore and GatewayLaunchAgentManager read password from config
- CLI gateway client reads password from remote config and env
2026-01-01 21:34:46 -06:00
jeffersonwarrior 9387ecf043 fix(macos): support password auth mode for gateway connections
GatewayChannel now sends both 'token' and 'password' fields in the auth
payload to support both authentication modes. Gateway checks the field
matching its auth.mode configuration ('token' or 'password').

Also adds config file password fallback for remote mode, allowing
gateway password to be configured in ~/.clawdis/clawdis.json without
requiring environment variables.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-01 21:26:37 -06:00
Peter Steinberger 35582cfe8a docs: fix broken clawd.md link in index 2026-01-02 02:45:01 +00:00
Peter Steinberger 76e24653e9 fix(media): preserve GIF animation, skip JPEG optimization
- Skip JPEG optimization for image/gif content type (both local and URL)
- Preserves animation in uploaded GIFs to Discord/other providers
- Added tests for GIF preservation from local files and URLs
- Updated changelog
2026-01-02 00:56:04 +00:00
Peter Steinberger 4c2812b429 fix: refine HEARTBEAT_OK handling 2026-01-02 01:42:27 +01:00
James Groat 7154bc6857 fix(cron): prevent every schedule from firing in infinite loop
When anchorMs is not provided (always in production), the schedule
computed nextRunAtMs as nowMs, causing jobs to fire immediately and
repeatedly instead of at the configured interval.

- Change nowMs <= anchor to nowMs < anchor to prevent early return
- Add Math.max(1, ...) to ensure steps is always at least 1
- Add test for anchorMs not provided case
2026-01-01 17:30:05 -07:00
Peter Steinberger c31070db24 style: apply biome formatting 2026-01-02 01:29:05 +01:00
Peter Steinberger 336048441c docs: add imessage rpc and groups docs 2026-01-02 01:19:40 +01:00
Peter Steinberger cbac34347b feat: add imessage rpc adapter 2026-01-02 01:19:40 +01:00
Peter Steinberger 3ee27a00c7 docs(changelog): note log prefix cleanup 2026-01-02 00:15:03 +00:00
Peter Steinberger 4ec020a86d fix(logging): trim provider log prefixes 2026-01-02 00:15:01 +00:00
Peter Steinberger 464dabdc16 docs: default discord reactions to on 2026-01-02 01:11:04 +01:00
Peter Steinberger c0976ec099 fix(gateway): stream chat events for agent runs 2026-01-02 01:04:59 +01:00
Peter Steinberger 7f3113b8d4 feat: add discord reaction tool 2026-01-02 00:29:32 +01:00
Peter Steinberger 9180cbe821 fix: keep chat scrolled to bottom on session switch 2026-01-02 00:21:48 +01:00
Peter Steinberger c5daa754ff chore: refresh a2ui bundle hash 2026-01-02 00:17:59 +01:00
Peter Steinberger 23a29216d3 fix: allow remote gateway password config 2026-01-02 00:17:54 +01:00
Peter Steinberger 8a2168ecf6 style: fix swiftlint warnings 2026-01-02 00:17:49 +01:00
Peter Steinberger 38d8a669b4 fix: add discord mention context history 2026-01-01 23:58:35 +01:00
Peter Steinberger 06e379a239 fix: suppress stray HEARTBEAT_OK replies 2026-01-01 23:53:29 +01:00
Peter Steinberger 7c0379ce05 feat: add recent session switchers 2026-01-01 23:50:26 +01:00
Peter Steinberger c7c13f2d5e fix(gateway): read CLAWDIS_GATEWAY_PASSWORD from env
The CLI client (callGateway) now reads password from:
1. opts.password (explicit parameter)
2. CLAWDIS_GATEWAY_PASSWORD env var (NEW)
3. remote.password from config

This allows CLI commands like doctor/health to authenticate
without needing a --password flag when the env var is set.

Fixes auth issues for users with password-protected gateways.
2026-01-01 22:40:36 +00:00
Peter Steinberger 6df9b3f38c docs: update changelog 2026-01-01 23:30:02 +01:00
Peter Steinberger ca81d94b90 feat(cli): hint gateway reachability for local/remote 2026-01-01 23:30:02 +01:00
Peter Steinberger a39ef7181d feat(cli): add provider setup primers 2026-01-01 23:22:52 +01:00
Peter Steinberger 93b7e3431b docs: update changelog 2026-01-01 23:22:52 +01:00
Peter Steinberger dd02cc0747 docs: update changelog 2026-01-01 23:19:30 +01:00
Peter Steinberger 867883453e fix(cli): allow skipping skill dependency installs 2026-01-01 23:19:26 +01:00
Peter Steinberger a68784c319 docs: update changelog 2026-01-01 23:16:42 +01:00
Peter Steinberger 46c763410f fix(cli): colorize provider status note 2026-01-01 23:16:36 +01:00
Peter Steinberger 815d4572f6 feat(cli): explain Tailscale exposure options 2026-01-01 23:16:28 +01:00
Peter Steinberger 279a191b86 fix(macos): colorize provider status subtitles 2026-01-01 23:16:22 +01:00
Peter Steinberger f0da42917b feat(macos): verify Claude OAuth in onboarding 2026-01-01 23:16:15 +01:00
Peter Steinberger 6e87fd2d4c docs: update changelog 2026-01-01 22:55:25 +01:00
Peter Steinberger fbf5efb570 feat(process): support env overrides in exec 2026-01-01 22:55:21 +01:00
Peter Steinberger 1a3323a261 fix(cli): improve skill install failure output 2026-01-01 22:55:15 +01:00
Peter Steinberger b858fdd755 feat(macos): show skills in onboarding 2026-01-01 22:55:10 +01:00
Peter Steinberger 0aff827414 fix: preserve webchat run ordering 2026-01-01 22:46:43 +01:00
Peter Steinberger bd8a0a9f8f feat: add remote CDP browser support 2026-01-01 22:44:52 +01:00
Peter Steinberger 73d0e2cb81 fix: gate skills by OS 2026-01-01 22:25:37 +01:00
Peter Steinberger 47f816696c fix: refine A2UI status HUD styling 2026-01-01 20:47:51 +00:00
Peter Steinberger 1cf455e91c fix: use brew installer for imsg skill 2026-01-01 21:41:39 +01:00
Peter Steinberger 952c8c2d64 fix: improve canvas debug status in remote mode 2026-01-01 20:41:13 +00:00
Peter Steinberger dce3bf01fd build: refresh a2ui bundle hash 2026-01-01 20:41:09 +00:00
Peter Steinberger 7b1687d7e5 fix: resolve macOS config store concurrency 2026-01-01 21:31:37 +01:00
Peter Steinberger 9ad6863567 docs: trim changelog 2026-01-01 21:31:13 +01:00
Peter Steinberger 4c1424bb83 chore: fix lint warnings 2026-01-01 21:25:29 +01:00
Peter Steinberger c7364de2f0 fix: align telegram token resolution 2026-01-01 21:22:59 +01:00
Peter Steinberger e0043906be docs: add Discord badge 2026-01-01 21:22:00 +01:00
Peter Steinberger eda9fb5522 feat(skills): add things-mac 2026-01-01 21:12:37 +01:00
Peter Steinberger 8a775144bf docs: update changelog 2026-01-01 21:09:36 +01:00
Peter Steinberger 9b65534561 test: harden wizard e2e flow 2026-01-01 21:09:32 +01:00
Peter Steinberger f6c0618596 fix: improve web chat scroll and text 2026-01-01 21:09:28 +01:00
Peter Steinberger 15fd030fa4 docs: refresh onboarding wizard docs 2026-01-01 21:09:24 +01:00
Peter Steinberger 693be03dcc test: cover remote config routing 2026-01-01 20:29:53 +01:00
Peter Steinberger 6e3cb34024 chore: pin ElevenLabsKit + wizard note 2026-01-01 20:19:00 +01:00
Peter Steinberger bd7cd33b02 feat: add remote gateway client config 2026-01-01 20:10:50 +01:00
Peter Steinberger a72fdf7c26 feat: expand wizard setup flow 2026-01-01 19:14:14 +01:00
Peter Steinberger 850cbfe369 fix: route macOS remote config via gateway 2026-01-01 18:58:41 +01:00
Peter Steinberger 351db0632d fix(signal): map stderr INFO to log 2026-01-01 17:30:51 +00:00
Peter Steinberger d642e90cdd style: format onboarding commands 2026-01-01 17:30:51 +00:00
Peter Steinberger c454f7ac0d fix: detect bun relay assets 2026-01-01 18:30:16 +01:00
Peter Steinberger b5b47d7273 docs: update changelog 2026-01-01 17:24:42 +00:00
Peter Steinberger 7c2c541729 feat: expand onboarding wizard 2026-01-01 18:23:59 +01:00
Peter Steinberger f10abc8ee0 fix: narrow onboarding prompt types 2026-01-01 17:14:02 +00:00
Peter Steinberger 8ea27968d7 style: apply biome formatting 2026-01-01 17:06:47 +00:00
Peter Steinberger 956db9c182 fix: keep pi-ai tool types for published sdk 2026-01-01 17:02:02 +00:00
Peter Steinberger 3eb3f38adf test: add onboarding e2e harness 2026-01-01 18:01:42 +01:00
Peter Steinberger 35b66e5ad1 feat: add onboarding wizard 2026-01-01 17:58:07 +01:00
Peter Steinberger d83ea305b5 fix: satisfy bun biome formatting 2026-01-01 16:54:46 +00:00
Peter Steinberger c1d8508748 fix: clean up pi-agent-core lint 2026-01-01 16:51:08 +00:00
Peter Steinberger dc8f2bda2a fix: restart via systemd on linux 2026-01-01 17:48:28 +01:00
Peter Steinberger f0f5acfa42 fix: update pi-agent-core integration 2026-01-01 16:46:40 +00:00
Peter Steinberger 4e00edf8a7 docs: update changelog for macOS rpath fix 2026-01-01 17:44:53 +01:00
Petter Blomberg 02d5c00873 macOS: move rpath configuration to build step for reliability 2026-01-01 17:44:39 +01:00
Petter Blomberg 17009d28cf build: fix hardcoded dependency path for ElevenLabsKit 2026-01-01 17:43:27 +01:00
Peter Steinberger 325a6a4e02 docs: update changelog for chat duplicate fix 2026-01-01 17:42:30 +01:00
Marc Beaupre b51b24955c fix(chat): clear input immediately after send to prevent duplicate messages
Two issues were causing the input field to retain text after sending:

1. ChatComposer's NSViewRepresentable was skipping all updates while the
   text view was first responder. Now it allows clearing (empty binding)
   even during editing, only skipping other updates to avoid cursor jumps.

2. ChatViewModel cleared input after awaiting the network response, leaving
   text visible during the round trip. Now clears immediately after capturing
   the message content, before the async send.

Together these prevent users from accidentally re-sending messages when
the input appeared unchanged after pressing Enter.
2026-01-01 17:42:05 +01:00
Peter Steinberger a954aaa507 docs: thank contributor for macOS device resource fix 2026-01-01 17:39:54 +01:00
Petter Blomberg ad475239a5 fix(macos): prioritize main bundle for device resources to prevent crash 2026-01-01 17:39:33 +01:00
Peter Steinberger 5e280674f9 docs: require Xcode 26.2+ 2026-01-01 17:38:16 +01:00
Petter Blomberg 6cdfd143b0 docs: add macOS developer setup and troubleshooting guides 2026-01-01 17:37:19 +01:00
Petter Blomberg da454fa376 build: update A2UI bundle hash 2026-01-01 17:37:19 +01:00
Peter Steinberger 358dd4f791 merge: fix/codesign-adhoc 2026-01-01 17:34:46 +01:00
Peter Steinberger 2401abe17e docs: update changelog for codesign fix 2026-01-01 17:30:22 +01:00
Peter Steinberger 56ea6b6e43 fix: align tool schemas and health snapshot 2026-01-01 17:30:19 +01:00
Peter Steinberger 04691ed598 chore: apply biome formatting 2026-01-01 17:30:15 +01:00
William Stock 7366b55b14 docs: Add manual OAuth setup for remote/headless deployments
Expand "Remote mode note" section with:
- Exact oauth.json format required (access, refresh, expires)
- Note that auto-import doesn't work with Claude Code credentials
- jq script to convert Claude Code credentials to Clawdis format

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 17:21:27 +01:00
Peter Steinberger a248bea50f chore(browser): format CDP helpers 2026-01-01 16:19:37 +00:00
Peter Steinberger c8c84bc419 test(browser): fix chrome reachability mock 2026-01-01 16:16:55 +00:00
Peter Steinberger 5f990fb3a2 docs: note browser resiliency and reset 2026-01-01 16:15:17 +00:00
Peter Steinberger 538c1eb660 fix(browser): harden CDP readiness 2026-01-01 16:15:12 +00:00
Peter Steinberger 9f704d7aa7 docs: note macos app logging menu icon 2026-01-01 17:12:49 +01:00
Peter Steinberger a5777300d8 fix(macos): add icon to app logging menu 2026-01-01 16:48:17 +01:00
Peter Steinberger 57e1362344 docs(signal): explain bot-number setup 2026-01-01 15:37:45 +00:00
Peter Steinberger c1ccbd58f5 fix(signal): stabilize daemon + add signal delivery 2026-01-01 15:31:41 +00:00
Peter Steinberger 09a2ab420b style: biome formatting 2026-01-01 15:31:36 +00:00
Peter Steinberger 596770942a feat: add Signal provider support 2026-01-01 15:43:15 +01:00
Petter Blomberg fe5e58af91 scripts: fix ad-hoc signing crashes and bash unbound variable error 2026-01-01 15:29:01 +01:00
Peter Steinberger 0a4c2f91f5 fix: add bottom padding to macos web chat 2026-01-01 13:20:27 +01:00
Peter Steinberger 5b33a7dcbe fix: polish macos web chat composer 2026-01-01 12:49:05 +01:00
Peter Steinberger c7e2b1230c fix: make composer pill full-width 2026-01-01 12:18:18 +01:00
Peter Steinberger bdf6a23de9 fix: polish web chat empty/error state 2026-01-01 11:40:11 +01:00
Peter Steinberger 1a539b9830 fix(macos): restore swift test build 2026-01-01 11:05:14 +01:00
Peter Steinberger 3addd3420b fix: tidy web chat composer layout 2026-01-01 11:05:14 +01:00
Peter Steinberger 6ea10dd153 fix: allow direct file input uploads 2026-01-01 09:44:29 +00:00
Peter Steinberger bf0bee58b3 fix: improve browser upload triggering 2026-01-01 09:35:20 +00:00
Peter Steinberger fbcbc60e85 feat: unify skills config 2026-01-01 10:07:31 +01:00
Peter Steinberger 0a9f06d60f docs: annotate nix path resolution 2026-01-01 09:30:12 +01:00
Peter Steinberger f6956320f9 feat: centralize config paths and expose in snapshot 2026-01-01 09:22:37 +01:00
Peter Steinberger 20bc323963 docs: note nix support 2026-01-01 09:17:24 +01:00
Peter Steinberger bcead5f0f4 fix: honor nix config overrides in mac app 2026-01-01 09:17:21 +01:00
Peter Steinberger cf3049ae34 Merge pull request #40 from joshp123/upstream-preview-nix-2025-12-20
Nix mode support + macOS Info.plist template
2026-01-01 09:15:41 +01:00
Peter Steinberger ad9a9d8d35 Merge remote-tracking branch 'origin/main' into upstream-preview-nix-2025-12-20 2026-01-01 09:15:28 +01:00
Peter Steinberger 14e9077584 chore: add bench-model script 2026-01-01 08:59:31 +01:00
Peter Steinberger 43cf526b5f docs: thank contributor for PR #64 2026-01-01 08:59:24 +01:00
Peter Steinberger 2d5c401d11 fix: prefer module bundle for device models 2026-01-01 08:58:54 +01:00
Peter Steinberger 78cf68549f Merge pull request #64 from mbelinky/fix-instances-crash
Fix Instances crash by bundling device model resources
2026-01-01 08:58:35 +01:00
Peter Steinberger dececccd8e docs: thank contributor for PR #65 2026-01-01 08:55:51 +01:00
Mariano Belinky 941ad27551 Bundle Control UI in Mac app 2026-01-01 08:55:09 +01:00
Peter Steinberger 24e95ab38e docs: update changelog for PR #66 2026-01-01 08:37:49 +01:00
Mariano Belinky c4de0b8255 Use user home for pnpm path 2026-01-01 08:35:54 +01:00
Peter Steinberger 7baaca4a76 docs: add model latency bench notes 2025-12-31 22:39:42 +01:00
Mariano Belinky ea248f6743 Fix device model resources for Instances 2025-12-31 16:45:35 +01:00
Peter Steinberger f03605d8ae test: add minimax live test 2025-12-31 16:31:23 +01:00
Peter Steinberger 0babf08926 chore: add mac app logging coverage 2025-12-31 16:28:51 +01:00
Peter Steinberger 6517b05abe feat: add swift-log app logging controls 2025-12-31 16:03:18 +01:00
Peter Steinberger fa91b5fd03 docs: update changelog for Android chat bubble 2025-12-31 12:50:34 +01:00
Manuel Jiménez Torres f831ccfc63 fix(android): wrong text color in user chat bubbles 2025-12-31 12:48:59 +01:00
Peter Steinberger 12084fc4f9 test: extend Z.AI live test timeout 2025-12-31 12:43:34 +01:00
Peter Steinberger 21237dae98 feat: add Z.AI env support and live test 2025-12-31 11:36:57 +01:00
Peter Steinberger 4bdc25d072 docs: link Anthropic OAuth setup 2025-12-31 11:35:42 +01:00
Peter Steinberger 2f55abace2 fix: add brew installer for ordercli skill 2025-12-31 04:52:40 +01:00
Peter Steinberger 3213e5df2d feat: add gifgrep skill 2025-12-31 04:52:37 +01:00
Peter Steinberger 7e40147aa3 fix: gate web chat/talk on mobile nodes 2025-12-30 22:05:17 +01:00
Peter Steinberger a2a26b26fb fix: satisfy swiftformat in chat view 2025-12-30 20:41:12 +01:00
Peter Steinberger b3cf07d6cb feat: add ui theme toggle 2025-12-30 20:25:58 +01:00
Peter Steinberger ed76cd7574 fix: restore talk orb hit testing 2025-12-30 20:25:52 +01:00
Peter Steinberger 01b8a71ee6 docs: clarify browser wait guidance 2025-12-30 19:22:38 +00:00
Peter Steinberger cc86bbf27d feat: add food-order skill 2025-12-30 15:43:13 +01:00
Peter Steinberger 42cbb11de8 build: update a2ui bundle 2025-12-30 14:43:34 +01:00
Peter Steinberger 52303e8eda docs: update changelog for status pill 2025-12-30 14:39:33 +01:00
Peter Steinberger cf903be4a7 fix: avoid duplicate gateway reconnecting pill 2025-12-30 14:37:59 +01:00
Peter Steinberger 6306786645 fix: allow mp3 fallback result 2025-12-30 14:35:53 +01:00
Peter Steinberger d7b267843e fix: fallback mp3 when pcm blocked 2025-12-30 14:32:47 +01:00
Peter Steinberger 3aefe375c1 chore: update deps and add control ui routing tests 2025-12-30 14:30:46 +01:00
Peter Steinberger 3d6cc435ef fix: hop audio to main actor 2025-12-30 14:22:03 +01:00
Peter Steinberger 973bd3a427 fix: improve talk overlay input + drag 2025-12-30 14:18:51 +01:00
Peter Steinberger 7d1ec51df5 fix: modernize chat scroll position 2025-12-30 13:52:12 +01:00
Peter Steinberger 9fb74399c8 refactor: inject audio players 2025-12-30 13:46:14 +01:00
Peter Steinberger bc0a6fffd1 fix: tighten macOS menu device rows 2025-12-30 13:31:11 +01:00
Peter Steinberger fa85dd6527 docs: note macOS menu layout 2025-12-30 12:57:10 +01:00
Peter Steinberger 73d595eecc chore: sync local changes 2025-12-30 12:53:17 +01:00
Peter Steinberger 3bf8b9ccf4 fix: default android talk pcm_24000 2025-12-30 12:52:56 +01:00
Peter Steinberger 83262a67b1 refactor: extract elevenlabs kit 2025-12-30 12:48:09 +01:00
Peter Steinberger 66952a682d test: add pcm streaming smoke 2025-12-30 12:27:06 +01:00
Peter Steinberger 9df22c0093 fix: address talk streaming build 2025-12-30 12:20:32 +01:00
Peter Steinberger 27adfb76fa fix: stream elevenlabs tts playback 2025-12-30 12:17:40 +01:00
Peter Steinberger 9c532eac07 feat(talk): pause + drag overlay orb 2025-12-30 11:37:52 +01:00
Peter Steinberger 2814815312 feat: add talk voice alias map 2025-12-30 11:35:29 +01:00
Peter Steinberger ab27586674 test: cover external chat completion 2025-12-30 11:23:45 +01:00
Peter Steinberger 2749c5cac3 fix: clear external streaming bubbles 2025-12-30 11:21:57 +01:00
Peter Steinberger 715cf311df fix(ui): move mac talk orb to corner 2025-12-30 11:20:14 +01:00
Peter Steinberger 312443235d fix(ios): unblock device builds 2025-12-30 11:16:15 +01:00
Peter Steinberger 0d95d63258 fix(macos): await-safe session key selection 2025-12-30 11:07:34 +01:00
Peter Steinberger f86772f26c fix(talk): harden TTS + add system fallback 2025-12-30 07:40:02 +01:00
Peter Steinberger a7617e4d79 fix(ui): refine talk overlays 2025-12-30 06:47:35 +01:00
Peter Steinberger 7612a83fa2 fix(talk): align sessions and chat UI 2025-12-30 06:47:19 +01:00
Peter Steinberger afbd18e8df fix(talk): harden playback, interrupts, and timeouts 2025-12-30 06:05:43 +01:00
Peter Steinberger be2bc61d38 fix(talk): hard-timeout ElevenLabs synthesis 2025-12-30 05:46:47 +01:00
Peter Steinberger dcee8beb99 style: biome format gateway server tests 2025-12-30 05:34:53 +01:00
Peter Steinberger fb8f72d5a9 feat(ui): add centered talk orb 2025-12-30 05:27:29 +01:00
Peter Steinberger b3f2416a09 test: reduce flaky timeouts 2025-12-30 05:27:18 +01:00
Peter Steinberger b5ae2ccc3c fix(voice): sync talk mode chat events 2025-12-30 05:27:11 +01:00
Peter Steinberger 05efc3eace fix: avoid iOS talk mode audio tap crash 2025-12-30 04:52:57 +01:00
Peter Steinberger 24f8ff7548 chore(protocol): regenerate Swift gateway models 2025-12-30 04:42:08 +01:00
Peter Steinberger c0c6782a17 fix(android): stabilize BridgeSession shutdown 2025-12-30 04:42:02 +01:00
Peter Steinberger d2ac672f47 feat: add ui.seamColor accent 2025-12-30 04:14:36 +01:00
Peter Steinberger e3d8d5f300 fix(macos): prevent Talk Mode audio hang 2025-12-30 04:14:16 +01:00
Peter Steinberger c5d5c9fcb5 fix: make android canvas background visible 2025-12-30 04:02:52 +01:00
Peter Steinberger 2e040ee07a fix: brighten android canvas 2025-12-30 03:58:18 +01:00
Peter Steinberger 9846c46434 fix: tag A2UI platform and boost Android canvas 2025-12-30 03:49:24 +01:00
Peter Steinberger 5c7c1af44e fix: android talk timestamp parsing 2025-12-30 02:05:14 +01:00
Peter Steinberger e119a82334 feat: talk mode key distribution and tts polling 2025-12-30 01:57:58 +01:00
Peter Steinberger 02db68aa67 fix(macos): hide Restart Gateway when remote 2025-12-30 01:57:58 +01:00
Peter Steinberger 10e1e7fd44 chore: apply biome formatting 2025-12-30 00:16:07 +00:00
Peter Steinberger 7aabe73521 chore: sync pending changes 2025-12-30 00:59:30 +01:00
Peter Steinberger 37f85bb2d1 fix: expand talk overlay bounds 2025-12-30 00:58:58 +01:00
Peter Steinberger 39fccc3699 fix: talk overlay + elevenlabs defaults 2025-12-30 00:51:17 +01:00
Peter Steinberger 53eccc1c1e fix: wire talk menu + mac build 2025-12-30 00:17:10 +01:00
Peter Steinberger c56292a6ec feat: move talk mode to overlay button 2025-12-30 00:01:21 +01:00
Peter Steinberger 857cd6a28a fix: align ios lint and android build 2025-12-29 23:45:58 +01:00
Peter Steinberger 303954ae8c feat: extend status activity indicators 2025-12-29 23:42:22 +01:00
Peter Steinberger 3c338d1858 fix: adjust android talk parser for kotlin json 2025-12-29 23:26:38 +01:00
Peter Steinberger 20d7882033 feat: add talk mode across nodes 2025-12-29 23:21:05 +01:00
Peter Steinberger 6927b0fb8d fix: align camera payload caps 2025-12-29 23:20:55 +01:00
Peter Steinberger 6e83f95c83 fix: clamp tool images to 5MB 2025-12-29 22:13:39 +00:00
Peter Steinberger 8f0c8a6561 fix: cap camera snap payload size 2025-12-29 23:12:20 +01:00
Peter Steinberger a61b7056d5 feat: surface camera activity in status pill 2025-12-29 23:12:03 +01:00
Peter Steinberger f41ade9417 feat(skills): add obsidian skill 2025-12-29 22:51:42 +01:00
Peter Steinberger b0396e196f fix: refresh bridge tokens and enrich node settings 2025-12-29 22:11:12 +01:00
Peter Steinberger cf42fabfd8 test: add ios swift testing + android kotest 2025-12-29 21:10:44 +01:00
Peter Steinberger 52263bd5a3 fix: avoid cli gateway close race 2025-12-29 20:45:50 +01:00
Peter Steinberger 24151a2028 fix: mark screen recorder sendable 2025-12-29 20:28:06 +01:00
Peter Steinberger c11e2d9e5e fix: avoid self capture in ReplayKit start 2025-12-29 20:26:49 +01:00
Peter Steinberger a8c9b2810b fix: align ReplayKit stopCapture call 2025-12-29 20:25:44 +01:00
Peter Steinberger 7a849ab7d1 fix: isolate ReplayKit capture state 2025-12-29 20:24:34 +01:00
Peter Steinberger c14d738d37 fix: avoid screen recorder data races 2025-12-29 20:22:26 +01:00
Peter Steinberger 65478a6ff3 fix: avoid main-actor stopCapture error 2025-12-29 20:20:14 +01:00
Peter Steinberger 41be9232fe fix: prevent iOS screen capture crash 2025-12-29 20:10:36 +01:00
Peter Steinberger 653932e50d fix: show connected nodes only 2025-12-29 18:35:52 +01:00
Peter Steinberger 09ef991e1a chore: harden restart script 2025-12-29 18:09:27 +01:00
Josh Palmer 0f7029583c macOS: load device models from bundle resources 2025-12-29 17:49:13 +01:00
Josh Palmer 10eced9971 fix: use telegram token file for sends and guard console EPIPE 2025-12-29 17:49:13 +01:00
Josh Palmer 1d8b47785c feat(macos): add current TeamID to Peekaboo allowlist
Problem: The bridge only accepts the upstream TeamID, so packaged builds signed locally (Nix/CI) can’t use the bridge even though they are the same app.

Fix: Include the running app’s TeamID (from its code signature) in the allowlist.

Safety: TeamID gating remains; this just adds the app’s own TeamID to preserve permissions/automation in reproducible installs.
2025-12-29 17:49:13 +01:00
Josh Palmer ced271bec1 chore(macos): harden mktemp templates in codesign 2025-12-29 17:49:13 +01:00
Josh Palmer 5d19afd422 feat: improve health checks (telegram tokenFile + hints) 2025-12-29 17:49:13 +01:00
Josh Palmer b7363f7c18 feat: Nix mode config, UX, onboarding, SwiftPM plist, docs 2025-12-29 17:49:13 +01:00
Peter Steinberger aa2700ffa7 chore: set ios signing team for device builds 2025-12-29 17:38:21 +01:00
Peter Steinberger 510e2a1d17 fix: menu devices list 2025-12-29 17:31:23 +01:00
Peter Steinberger ebfe55f909 fix: enable canvas webview scrolling on mobile nodes 2025-12-29 17:13:31 +01:00
Peter Steinberger 26fa9dea97 chore: bump version to 2.0.0-beta5 2025-12-28 14:38:48 +00:00
Peter Steinberger 3bb4c0c237 fix: report macos product version in presence 2025-12-28 14:34:07 +00:00
Peter Steinberger 255a875a2a chore: refresh a2ui bundle hash 2025-12-28 12:06:48 +00:00
Peter Steinberger 2b5f3f1361 docs: clarify watchdog reconnect note 2025-12-28 12:05:06 +00:00
Peter Steinberger eb158545fc fix: force web reconnect on stalled close 2025-12-28 12:04:20 +00:00
Peter Steinberger cade7b1132 docs: clarify gateway readiness in changelog 2025-12-28 10:30:40 +00:00
Peter Steinberger d529736597 fix(macos): fully stop Voice Wake runtime when disabled 2025-12-28 10:17:30 +00:00
Peter Steinberger 8dfc031c4d fix: start gateway before control channel 2025-12-28 09:24:43 +00:00
Peter Steinberger 91c9859000 fix: harden heartbeat acks + gateway reconnect 2025-12-27 20:02:27 +00:00
Peter Steinberger 3a485a14a4 fix: skip whatsapp heartbeat when provider inactive 2025-12-27 19:34:10 +00:00
Peter Steinberger a61c27c4d0 fix: correct beta3 appcast URL 2025-12-27 20:00:08 +01:00
Peter Steinberger e5cae2a2e4 chore: release 2.0.0-beta4
CI / build (bun) (push) Failing after 34s
CI / build (node) (push) Failing after 39s
CI / android (push) Failing after 4s
CI / macos-app (push) Has been cancelled
CI / ios (push) Has been cancelled
2025-12-27 19:43:43 +01:00
Peter Steinberger 7f961237f9 chore: harden release checks 2025-12-27 19:35:39 +01:00
Peter Steinberger 69a6538567 docs: note notarytool profile 2025-12-27 19:24:24 +01:00
Peter Steinberger 5b3c18ab84 chore: release 2.0.0-beta3
CI / build (bun) (push) Failing after 30s
CI / build (node) (push) Failing after 30s
CI / android (push) Failing after 3s
CI / macos-app (push) Has been cancelled
CI / ios (push) Has been cancelled
2025-12-27 19:02:35 +01:00
Peter Steinberger 907371453d fix(macos): soften light mode usage bar track 2025-12-27 14:05:36 +01:00
Peter Steinberger 81abffd145 fix(macos): boost light mode usage bar contrast 2025-12-27 14:03:45 +01:00
Peter Steinberger 44ef8fe5c8 fix(macos): refresh sessions on menu open 2025-12-27 13:49:03 +01:00
Peter Steinberger cae78b3f91 fix: treat /model status as model list 2025-12-27 12:10:44 +00:00
Peter Steinberger c0fb814658 fix: normalize imports for lint 2025-12-27 04:02:13 +01:00
Peter Steinberger 7ce0140c81 docs: update changelog 2025-12-27 03:21:25 +01:00
Peter Steinberger 12b3034921 chore(canvas): update a2ui bundle hash 2025-12-27 03:21:20 +01:00
Peter Steinberger ec482ac867 fix(macos): tighten chat window chrome 2025-12-27 03:21:14 +01:00
Peter Steinberger ae52fb7a01 fix(macos): relax chat window min size 2025-12-27 02:55:24 +01:00
Peter Steinberger e8ff08e121 fix(macos): round chat window chrome 2025-12-27 02:51:59 +01:00
Peter Steinberger cc8e104cd6 fix(macos): enforce chat window default size 2025-12-27 02:43:50 +01:00
Peter Steinberger 5919a277bb fix(macos): stabilize menu width tracking 2025-12-27 02:43:50 +01:00
Peter Steinberger 96911d7790 fix: enqueue system event on model switch 2025-12-27 01:17:12 +00:00
Peter Steinberger acd3f7dba7 fix(macos): lock menu width on hover 2025-12-27 01:50:25 +01:00
Peter Steinberger 8aff3979db docs: add local lmstudio setup 2025-12-27 00:48:19 +00:00
Peter Steinberger eafcd862be chore: update protocol models 2025-12-27 01:45:58 +01:00
Peter Steinberger 8826170635 fix: resolve CI lint and android build 2025-12-27 01:41:43 +01:00
Peter Steinberger c54e4d0900 refactor: node tools and canvas host url 2025-12-27 01:36:29 +01:00
Peter Steinberger 52ca5c4aa2 fix: drop identity emoji response prefix 2025-12-27 00:36:04 +00:00
Peter Steinberger 95f8f80e74 fix: allow empty responsePrefix 2025-12-27 00:33:04 +00:00
Peter Steinberger 7e380bb6f8 fix: enable lmstudio responses and drop think tags 2025-12-27 00:28:52 +00:00
Peter Steinberger 2477ffd860 chore: fix lint/test gating 2025-12-26 23:54:30 +00:00
Peter Steinberger a3dc46bf9d fix(a2ui): center status overlay 2025-12-27 00:28:38 +01:00
Peter Steinberger 5c8e1b6eef feat: add model aliases + minimax shortlist 2025-12-26 23:26:14 +00:00
Peter Steinberger ae9a8ce34c fix(a2ui): center status overlay 2025-12-27 00:23:27 +01:00
Peter Steinberger 67b9a675f5 fix(macos): allow http loads in canvas webview 2025-12-27 00:20:58 +01:00
Peter Steinberger fae11e5a55 fix(gateway): advertise reachable canvas host 2025-12-27 00:07:19 +01:00
Peter Steinberger 4daf75a469 fix(macos): enforce node bridge timeouts 2025-12-27 00:02:41 +01:00
Peter Steinberger d0293649cd fix(macos): refresh menu sessions without resizing 2025-12-26 22:48:58 +01:00
Peter Steinberger 353366ac54 fix(macos): expand highlighted menu rows to full width 2025-12-26 22:41:29 +01:00
Peter Steinberger 1a8ffebb00 fix(macos): stabilize menu row width 2025-12-26 22:34:18 +01:00
Peter Steinberger 5ffbddcc57 feat(mac): add allow camera toggle 2025-12-26 21:33:22 +00:00
Peter Steinberger 5fbcbe7e52 feat(mac): add discord connections UI 2025-12-26 21:33:22 +00:00
Peter Steinberger 7daa93cf5a fix(macos): expand menu hover highlight width 2025-12-26 22:30:29 +01:00
Peter Steinberger 9e32f29d19 test: organize heartbeat test imports 2025-12-26 21:29:49 +00:00
Peter Steinberger 1f25e38c2d fix(macos): keep menu width stable while open 2025-12-26 22:27:24 +01:00
Peter Steinberger c10a386d17 fix(macos): detect and reset stale SSH tunnels 2025-12-26 22:12:33 +01:00
Peter Steinberger a13db82d28 fix(nodes): improve version reporting 2025-12-26 21:45:00 +01:00
Peter Steinberger ec392dc870 feat(mac): add node ssh and compact versions 2025-12-26 20:42:49 +00:00
Peter Steinberger 90d00fb095 fix(mac): reorder menu toggles 2025-12-26 20:42:45 +00:00
Peter Steinberger e336b7f27e fix: use final heartbeat payload 2025-12-26 20:39:20 +00:00
Peter Steinberger 7f4c992dd7 fix(mac): move action group below toggles 2025-12-26 20:31:37 +00:00
Peter Steinberger ba1626a5b9 fix(ios): accept truthy A2UI ready check 2025-12-26 21:17:37 +01:00
Peter Steinberger ab73c40bfe fix(mac): refine node submenu copy behavior 2025-12-26 20:05:23 +00:00
Peter Steinberger 4016bc2416 fix(a2ui): center empty canvas text 2025-12-26 20:43:45 +01:00
Peter Steinberger 9302daadc1 fix(mac): align node details 2025-12-26 19:32:48 +00:00
Peter Steinberger de7429e148 fix(mac): show node versions in menu 2025-12-26 19:25:28 +00:00
Peter Steinberger 5892bd45d8 fix(mac): tweak menu icons 2025-12-26 19:23:53 +00:00
Peter Steinberger 9317eccfc8 fix(mac): regroup menubar sections 2025-12-26 19:18:12 +00:00
Peter Steinberger 1236c4dafb refactor: make browser actions ref-only 2025-12-26 19:02:27 +00:00
Peter Steinberger f50f18f65a feat(mac): refine menubar nodes layout 2025-12-26 19:02:27 +00:00
Peter Steinberger 747cc4daa5 fix: gate libsignal session logs behind verbose 2025-12-26 19:02:27 +00:00
Peter Steinberger 51b6a785e6 fix(canvas): center debug status overlay 2025-12-26 20:01:23 +01:00
Peter Steinberger f4d41ef254 chore(ios): auto team id fallback 2025-12-26 18:19:48 +01:00
Peter Steinberger b9d80aa535 chore(ios): add team id helper 2025-12-26 18:16:13 +01:00
Peter Steinberger 2f8213ca9a fix(a2ui): skip bundle when inputs unchanged 2025-12-26 18:11:00 +01:00
Peter Steinberger 541b8cbb6c fix(ios): silence device build warnings 2025-12-26 18:09:44 +01:00
Peter Steinberger ed2e738ea4 fix: provider startup order and enable flags 2025-12-26 16:54:53 +00:00
Peter Steinberger 17d9ba256b fix(discord): ignore destroy promise 2025-12-26 17:21:32 +01:00
Peter Steinberger 15dbac8193 docs: update beta3 changelog 2025-12-26 17:21:29 +01:00
Peter Steinberger 2119854246 build: skip a2ui bundling in build 2025-12-26 16:00:35 +01:00
Peter Steinberger 034c93fd65 fix: align discord types 2025-12-26 14:47:15 +01:00
Peter Steinberger ce91aba4de fix: apply biome formatting 2025-12-26 14:38:37 +01:00
Peter Steinberger e33c09f8d4 fix(tests): align discord + queue changes 2025-12-26 14:32:57 +01:00
Peter Steinberger a678c3f53e refactor(queue): remove drop mode 2025-12-26 14:29:28 +01:00
Peter Steinberger 3e4fc7ff7f feat(queue): add reset/default directive 2025-12-26 14:24:53 +01:00
Peter Steinberger 8dda07a1e9 feat(queue): add queue modes and discord gating 2025-12-26 13:35:44 +01:00
Peter Steinberger e9f1851c5d chore: ignore bun build artifacts 2025-12-26 13:20:30 +01:00
Shadow ac659ff5a7 feat(discord): Discord transport 2025-12-26 13:20:30 +01:00
Peter Steinberger 557f8e5a04 fix: restore build after deps update 2025-12-26 12:17:36 +00:00
Peter Steinberger 54de5ad3fa test: isolate vitest home 2025-12-26 11:45:16 +00:00
Peter Steinberger 0709586e3a fix: support mocked model registry in catalog 2025-12-26 11:53:55 +01:00
Peter Steinberger 82ced33747 fix: align pi model discovery with auth storage 2025-12-26 11:49:13 +01:00
Peter Steinberger d31c5d7a2c style: format web inbound 2025-12-26 11:39:48 +01:00
Peter Steinberger 2045487d5e fix: extract quoted WhatsApp reply text 2025-12-26 10:51:08 +01:00
Peter Steinberger 4611e799b7 docs: note inbox listener cleanup 2025-12-26 09:37:38 +00:00
Peter Steinberger ffe9a2435b fix: clean up web inbox listeners on close 2025-12-26 09:27:06 +00:00
Peter Steinberger f5d8876384 test: expand compaction retry coverage 2025-12-26 10:22:04 +01:00
Peter Steinberger d28265cfbe fix: handle embedded agent overflow 2025-12-26 10:20:21 +01:00
Peter Steinberger 8059e83c49 chore: bump pi-mono deps 2025-12-26 10:20:21 +01:00
Peter Steinberger d6f07c9f91 chore: fix lint after logging tweaks 2025-12-26 09:08:37 +00:00
Peter Steinberger 917cb8fa67 fix: brighten gateway model console log 2025-12-26 08:45:15 +00:00
Peter Steinberger 461db9e469 fix: split whatsapp listen hint from subsystem log 2025-12-26 08:41:58 +00:00
Peter Steinberger 112908886c fix: log heartbeat failure reasons 2025-12-26 08:34:42 +00:00
Peter Steinberger f734801da1 fix: correct heartbeat log formatting 2025-12-26 08:17:29 +00:00
meaningfool ea6dc7c710 fix: correctly define pnpm workspace and clean up vite build scripts
This change adds the missing 'packages' definition to pnpm-workspace.yaml, allowing pnpm to correctly install dependencies for the 'ui' sub-package. This resolves the 'vite: command not found' error during 'ui:build'. It also reverts the temporary 'pnpm dlx' workarounds in ui/package.json.
2025-12-26 09:13:17 +01:00
Peter Steinberger cd81348ca5 chore: fix env spread lint 2025-12-26 02:02:49 +00:00
Peter Steinberger ad91a09b07 ci: avoid macos runner queue 2025-12-26 02:02:49 +00:00
Peter Steinberger 040f73a3f4 docs: clarify heartbeat defaults 2025-12-26 03:02:11 +01:00
Peter Steinberger 0d8e0ddc4f feat: unify gateway heartbeat 2025-12-26 02:35:40 +01:00
Peter Steinberger 8f9d7405ed style: fix biome formatting 2025-12-26 00:50:46 +00:00
Peter Steinberger 72267e97ca docs: note hour durations 2025-12-26 01:36:08 +01:00
Peter Steinberger 19f87f0a89 feat: allow hour durations 2025-12-26 01:34:46 +01:00
Peter Steinberger 9f7b1f0942 feat: move heartbeat config to agent.heartbeat 2025-12-26 01:13:42 +01:00
Peter Steinberger 1ef888ca23 refactor(config): drop agent.provider 2025-12-26 01:13:42 +01:00
Peter Steinberger 8b815bce94 feat(config): allow provider/model shorthand 2025-12-26 01:13:42 +01:00
Peter Steinberger 97539db36d ci: skip ios job 2025-12-26 00:04:46 +00:00
Peter Steinberger 655fa5b8e0 style: fix pi embedded runner lint 2025-12-25 23:58:37 +00:00
Peter Steinberger 9fbd3cc16f ci: ignore ios failures 2025-12-25 23:55:55 +00:00
Rolf Fredheim 2295cbb815 feat(agent): add maxConcurrent config for parallel message handling
Adds `agent.maxConcurrent` config option to control how many agent runs
can execute in parallel across all conversations. Default remains 1
(sequential) for backwards compatibility.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 23:55:41 +01:00
Peter Steinberger 198f8ea700 fix(agent): serialize runs per session 2025-12-25 23:50:52 +01:00
Peter Steinberger 9fa9199747 docs: note multi-agent session rule 2025-12-25 23:50:46 +01:00
Peter Steinberger 1cd167a59a ci: run on node 24 2025-12-25 23:05:09 +01:00
Peter Steinberger 2868dc975c chore: require node >=22.12 and fix swiftformat lint 2025-12-25 23:02:31 +01:00
meaningfool 214ab16eb2 fix: correctly define pnpm workspace and clean up vite build scripts
This change adds the missing 'packages' definition to pnpm-workspace.yaml, allowing pnpm to correctly install dependencies for the 'ui' sub-package. This resolves the 'vite: command not found' error during 'ui:build'. It also reverts the temporary 'pnpm dlx' workarounds in ui/package.json.
2025-12-25 22:52:22 +01:00
Peter Steinberger 1c88d9575e fix(webchat): refresh bubbles on theme change 2025-12-25 22:35:46 +01:00
Peter Steinberger 1e4e02ddd3 docs: update beta3 changelog 2025-12-25 21:15:45 +00:00
Peter Steinberger f6fcddbe0b fix: relax tool typing for bash tools 2025-12-25 20:27:05 +00:00
Peter Steinberger 474180c112 style: fix bash tools lint 2025-12-25 20:20:38 +00:00
Peter Steinberger c860573f13 style: fix biome formatting 2025-12-25 20:13:48 +00:00
Peter Steinberger c9c7354009 chore: add gateway:watch 2025-12-25 18:44:23 +00:00
Peter Steinberger 42eb7640f9 feat: add gateway restart tool 2025-12-25 18:05:37 +00:00
Peter Steinberger aafcd569b1 feat: line-based process logs 2025-12-25 18:03:57 +00:00
Peter Steinberger b549307ccf docs: add Sparkle HTML release notes 2025-12-25 04:27:20 +01:00
Peter Steinberger 57090d4f8d fix: align chat scroll anchor 2025-12-25 04:10:47 +01:00
Peter Steinberger 764f7586de fix: adjust tool casts for build 2025-12-25 03:36:04 +01:00
Peter Steinberger d96f2abc4e fix: resolve agent tool typing 2025-12-25 03:33:09 +01:00
Peter Steinberger 92f467e81c fix: clean agent bash lint 2025-12-25 03:29:36 +01:00
Peter Steinberger 2442186a31 fix: silence view warnings 2025-12-25 03:23:31 +01:00
Peter Steinberger 9fb74cb58a test: assert bridge does not add loopback listener 2025-12-25 01:41:09 +00:00
Peter Steinberger 81e11c1d91 fix: bridge tailnet bind also listens on loopback 2025-12-25 01:37:47 +00:00
Peter Steinberger dc93350e0a docs: add background bash changelog 2025-12-25 00:54:08 +00:00
Peter Steinberger 3c6432da1f feat: add background bash sessions 2025-12-25 00:25:11 +00:00
Peter Steinberger 4eecb6841a docs: add gmail hook quickstart 2025-12-24 22:59:09 +00:00
Peter Steinberger 3b83d3ff3a fix: preserve tool action enums 2025-12-24 22:50:40 +00:00
Peter Steinberger 88b92a9605 style: format gmail hooks and tools 2025-12-24 23:11:14 +01:00
Peter Steinberger 3bb5baa6d2 fix: default tailscale serve in settings 2025-12-24 22:09:23 +00:00
Peter Steinberger 59443d7ec6 style: format reply changes 2025-12-24 23:06:20 +01:00
Peter Steinberger c1d170e13d docs: note tailscale gmail path behavior 2025-12-24 21:56:21 +00:00
Peter Steinberger cffac6e11a fix: auto gmail serve path for tailscale 2025-12-24 21:56:17 +00:00
Peter Steinberger 79870472e1 fix: expose union tool parameters 2025-12-24 21:48:22 +00:00
Peter Steinberger 1b69c94f76 docs: clarify reply threading change 2025-12-24 22:37:32 +01:00
Peter Steinberger cf8d1cf0e7 fix: avoid threaded replies for agent output 2025-12-24 22:36:42 +01:00
Peter Steinberger 009fbeb543 chore: add gmail hook setup notes 2025-12-24 21:20:20 +00:00
Peter Steinberger 9ceb8731d3 chore: clarify gmail serve path 2025-12-24 21:20:20 +00:00
Peter Steinberger 8f934bf817 docs: update file size guidance 2025-12-24 22:19:10 +01:00
Peter Steinberger 88be2701f4 refactor: split utilities 2025-12-24 22:16:06 +01:00
Peter Steinberger 8ee62f0ac8 style: format locator selector 2025-12-24 21:49:31 +01:00
Peter Steinberger 4d4308af78 fix: resolve coverage profile symbol at runtime 2025-12-24 21:43:46 +01:00
Peter Steinberger f7c5eff35e docs: link webhook docs 2025-12-24 20:07:24 +00:00
Peter Steinberger 3bc1644f34 refactor: split canvas window 2025-12-24 21:04:52 +01:00
Peter Steinberger 27025b71db feat: add selector-based browser actions 2025-12-24 19:52:28 +00:00
Peter Steinberger 523d9ec3c2 feat: add gmail hooks wizard 2025-12-24 19:48:35 +00:00
Peter Steinberger aeb5455555 feat: add webhook hook mappings
# Conflicts:
#	src/gateway/server.ts
2025-12-24 19:48:05 +00:00
Peter Steinberger 337390b590 fix: allow overlay present access 2025-12-24 20:24:37 +01:00
Peter Steinberger 836d950e05 fix: restore voice wake overlay build 2025-12-24 20:17:01 +01:00
Peter Steinberger ad096f77fc refactor: split voice wake overlay 2025-12-24 20:09:56 +01:00
Peter Steinberger 3774494f7e test: add ios coverage tests 2025-12-24 20:00:51 +01:00
Peter Steinberger 14fae5af9e test: add ios coverage hooks 2025-12-24 20:00:45 +01:00
Peter Steinberger 65b48561a9 refactor: split critter status label 2025-12-24 19:56:24 +01:00
Peter Steinberger 842dc14c18 style: format port guardian 2025-12-24 19:41:32 +01:00
Peter Steinberger af1afa7ba6 style: format cron settings 2025-12-24 19:40:11 +01:00
Peter Steinberger 8c4c5e524b refactor: split cron settings 2025-12-24 19:36:10 +01:00
Peter Steinberger 204bd7d2c4 test: add mac coverage helpers 2025-12-24 19:29:44 +01:00
Peter Steinberger f44014ff00 refactor: split onboarding view 2025-12-24 19:29:27 +01:00
Peter Steinberger 01719b02e2 test: cover bridge settings discovery 2025-12-24 18:07:41 +01:00
Peter Steinberger 4ba86bbe00 test: cover bridge hello defaults 2025-12-24 18:07:38 +01:00
Peter Steinberger b85503b3b2 fix: guard hook payload strings 2025-12-24 17:49:52 +01:00
Peter Steinberger 131a9aa1ac style: format macos sources 2025-12-24 17:47:35 +01:00
Peter Steinberger bd223606b1 style: format gateway server 2025-12-24 17:45:39 +01:00
Peter Steinberger f4fb80e523 test: expand overlay coverage 2025-12-24 17:43:30 +01:00
Peter Steinberger 49e466dd40 test: expand menu and node coverage 2025-12-24 17:43:30 +01:00
Peter Steinberger deec315f6a test: expand settings coverage 2025-12-24 17:43:30 +01:00
Peter Steinberger 7fafe54e16 test: expand onboarding coverage 2025-12-24 17:43:30 +01:00
Peter Steinberger bdcbc829a0 test: add coverage flush helper 2025-12-24 17:43:30 +01:00
Peter Steinberger 4a64e86ecb chore: update changelog 2025-12-24 14:39:26 +00:00
Peter Steinberger 1e2946ebc6 test: extend webhook coverage 2025-12-24 14:39:21 +00:00
Peter Steinberger 1ed5ca3fde feat: add gateway webhooks 2025-12-24 14:33:05 +00:00
Peter Steinberger aa62ac4042 fix: use recognition update segments 2025-12-24 15:27:06 +01:00
Peter Steinberger e8f24910bd style: swiftformat chat ui 2025-12-24 15:10:31 +01:00
Peter Steinberger 8d34e54dc5 fix: address swiftlint warnings 2025-12-24 15:10:22 +01:00
Peter Steinberger c5ede3f167 build: align Commander dependency 2025-12-24 14:44:56 +01:00
Peter Steinberger 1cd108e891 fix: clear wake word match warning 2025-12-24 14:44:50 +01:00
Peter Steinberger 8878fd3028 ui: merge tool call results 2025-12-24 14:38:43 +01:00
Peter Steinberger a22d4e7962 fix: import AnyCodable for tool cards 2025-12-24 14:35:06 +01:00
Peter Steinberger 25d2d7389f ui: render tool call cards 2025-12-24 14:29:40 +01:00
Peter Steinberger 816b784399 ui: constrain typing indicator width 2025-12-24 14:10:32 +01:00
Peter Steinberger c250f092bb test: cover overlay level throttling 2025-12-24 13:54:03 +01:00
Peter Steinberger b9c2bdf641 docs: update changelog 2025-12-24 13:52:41 +01:00
Peter Steinberger 5ba90db049 perf: throttle voice overlay updates 2025-12-24 13:51:41 +01:00
Peter Steinberger 88d20c5419 perf: gate idle pulse animations 2025-12-24 13:51:40 +01:00
Peter Steinberger e158bee95f perf: reduce chat animation churn 2025-12-24 13:51:40 +01:00
Peter Steinberger 0139a77e94 fix: resolve ts build errors 2025-12-24 00:57:11 +00:00
Peter Steinberger e76d1b899b fix: clean telegram parse error logging 2025-12-24 00:53:27 +00:00
Peter Steinberger 3fcdd6c9d7 feat: enforce final tag parsing for embedded PI 2025-12-24 00:52:33 +00:00
Peter Steinberger bc916dbf35 feat: require final tag format in system prompt 2025-12-24 00:52:30 +00:00
Peter Steinberger 96da2efb13 style: swiftformat gateway process manager 2025-12-24 00:33:40 +00:00
Peter Steinberger 267cdf20e1 style: fix biome lint 2025-12-24 00:33:35 +00:00
Peter Steinberger 20c7df35c4 docs: note config refactor 2025-12-24 00:24:05 +00:00
Peter Steinberger 0f06e9926b docs: update routing/messages/session config 2025-12-24 00:22:57 +00:00
Peter Steinberger 93af424ce5 refactor: move inbound config 2025-12-24 00:22:52 +00:00
Peter Steinberger 5e07400cd1 refactor: update macOS config paths 2025-12-23 23:45:27 +00:00
Peter Steinberger 364a6a9444 feat: add per-session model selection 2025-12-23 23:45:20 +00:00
Peter Steinberger b6bfd8e34f fix: anchor typing loop to run 2025-12-23 15:03:05 +00:00
Peter Steinberger b05981ef27 fix: add reasoning tag hint for local providers 2025-12-23 14:34:56 +00:00
Peter Steinberger 42f1a56832 test: cover system prompt owner numbers 2025-12-23 14:20:09 +00:00
Peter Steinberger f667d56701 fix: tag owner numbers in system prompt 2025-12-23 14:19:41 +00:00
Peter Steinberger df5284beaf fix: suppress thinking stream + typing 2025-12-23 14:17:18 +00:00
Peter Steinberger 6d551b0d6e fix: normalize tool schemas for lm studio 2025-12-23 14:09:07 +00:00
Peter Steinberger 25e6339e2e chore: bump pi-mono deps 2025-12-23 14:07:54 +00:00
Peter Steinberger f70fd30cd3 chore: include runtime info in system prompt 2025-12-23 14:05:43 +00:00
Peter Steinberger 863d26558a fix: delay typing until reply payload 2025-12-23 13:55:01 +00:00
Peter Steinberger cba12a1abd fix: inject group activation in system prompt 2025-12-23 13:32:07 +00:00
Peter Steinberger 96d57a18ee chore: demote reply chunk logs 2025-12-23 13:25:56 +00:00
Peter Steinberger e54ed10bc1 fix: honor /new resets with mentions in groups 2025-12-23 13:20:11 +00:00
Peter Steinberger c8c807adcc refactor: drop PAM auth and require password for funnel 2025-12-23 13:13:09 +00:00
Peter Steinberger cd6ed79433 fix: honor group requireMention default 2025-12-23 12:53:30 +00:00
Peter Steinberger ea4b3b74bb chore: log whatsapp identity on start 2025-12-23 12:45:18 +00:00
Peter Steinberger facfd64787 fix: avoid spawning duplicate gateway when external listener exists 2025-12-23 12:43:51 +00:00
Peter Steinberger 760a83d256 docs: add offline memory system proposal 2025-12-23 13:36:59 +01:00
Peter Steinberger bbff19698b chore: flatten provider console subsystems 2025-12-23 11:27:14 +00:00
Peter Steinberger 6f38cb162c chore: bump internal version to beta3 2025-12-23 04:28:09 +01:00
Peter Steinberger af82224f82 fix: relax Sparkle delegate isolation 2025-12-23 03:36:56 +01:00
Peter Steinberger a938e9473b fix: isolate Sparkle delegate conformance 2025-12-23 03:28:39 +01:00
Peter Steinberger 3e88553d52 fix: isolate updater factory on main actor 2025-12-23 03:16:47 +01:00
Peter Steinberger 56245d5646 fix: strip repeated heartbeat ok tails 2025-12-23 03:12:24 +01:00
Peter Steinberger 4af08b1606 fix: preserve whatsapp group JIDs 2025-12-23 03:05:59 +01:00
Peter Steinberger fc4a395c88 chore: update gateway protocol models 2025-12-23 03:05:04 +01:00
Peter Steinberger de1813ab32 docs: add beta3 changelog 2025-12-23 03:02:30 +01:00
Peter Steinberger 89ace66972 style: format macOS sources 2025-12-23 03:02:09 +01:00
Peter Steinberger 63f1857bda docs: add WhatsApp integration guide 2025-12-23 03:00:27 +01:00
Peter Steinberger 279500cba4 fix: resolve build errors 2025-12-23 03:00:04 +01:00
Peter Steinberger 183270b443 fix: correct models config schema 2025-12-23 02:50:26 +01:00
Peter Steinberger a5f4332f21 style: apply biome formatting 2025-12-23 02:49:49 +01:00
Peter Steinberger 6fad79f581 docs: document custom model providers 2025-12-23 02:48:57 +01:00
Peter Steinberger dff6274a93 test: cover models config merge 2025-12-23 02:48:54 +01:00
Peter Steinberger 082c872469 feat: support custom model providers 2025-12-23 02:48:48 +01:00
Peter Steinberger 67a3dda53a fix: inject reply context into body 2025-12-23 02:44:38 +01:00
Peter Steinberger 950432eac0 test: update whatsapp reply quote assertions 2025-12-23 02:30:21 +01:00
Peter Steinberger 6550e7d562 fix: add whatsapp reply context 2025-12-23 02:30:21 +01:00
Peter Steinberger ffe75f3e20 🤖 codex: add telegram reply context
# Conflicts:
#	src/telegram/bot.ts
2025-12-23 02:30:21 +01:00
Tu Nombre Real 8431874b15 fix(macOS): remove redundant kickstart -k causing gateway restart loop
The launchd bootstrap already starts the gateway job. The subsequent
kickstart -k was killing it immediately after startup, and combined
with KeepAlive=true, this caused a port-conflict restart loop where
launchd would try to restart while the old instance was still
shutting down.

Symptoms: 'Bootstrap failed: 5: Input/output error' and repeated
'Gateway failed to start: another gateway instance is already
listening' messages in the log.
2025-12-23 01:57:54 +01:00
Peter Steinberger 54d2ccda99 feat(mac): surface update-ready state 2025-12-23 01:42:33 +01:00
Peter Steinberger 926b6d9464 chore: format wake gate + chat theme 2025-12-23 01:41:13 +01:00
Peter Steinberger abfb6832c3 fix(mac): default session menu checks 2025-12-23 01:36:01 +01:00
Peter Steinberger ceeea359fc chore: remove shared build artifacts 2025-12-23 01:32:02 +01:00
Peter Steinberger ef35868bef feat: share wake gate via SwabbleKit 2025-12-23 01:31:59 +01:00
Peter Steinberger cf48d297dd docs: explain tool exposure in pi-mono 2025-12-23 00:29:38 +00:00
Peter Steinberger 2b20e3d2b0 chore: resolve docs list from cwd 2025-12-23 00:28:55 +00:00
Peter Steinberger 918cbdcf03 refactor: lint cleanups and helpers 2025-12-23 00:28:55 +00:00
Peter Steinberger f5837dff9c chore: add oxlint type-aware lint 2025-12-23 00:28:55 +00:00
Peter Steinberger ce04308c17 refactor: remove session syncing metadata 2025-12-23 00:50:51 +01:00
Peter Steinberger c0c20ebf3e feat: replace clawdis skills with tools 2025-12-22 23:40:57 +00:00
Peter Steinberger 823195a122 style(mac): increase session row padding 2025-12-23 00:10:38 +01:00
Peter Steinberger 581583abb4 fix(mac): drop syncing menu + show state checks 2025-12-23 00:10:38 +01:00
Peter Steinberger 882fd48408 style: add visual effect host for chat 2025-12-23 00:10:38 +01:00
Peter Steinberger 91238df13f chore: alias console subsystem names 2025-12-22 23:06:15 +00:00
Peter Steinberger ca806897c2 Template: Add smart heartbeat logic for baby agents
- Added heartbeat section with proactive check guidelines
- Includes email, calendar, weather, mentions rotation
- Track checks in heartbeat-state.json
- Know when to reach out vs stay quiet
- Proactive work suggestions (memory, git, docs)

Goal: Baby agents should check in 2-4x daily, not just HEARTBEAT_OK
2025-12-22 22:55:27 +00:00
Peter Steinberger 9118884e92 fix(web): restore creds before auth check 2025-12-22 22:55:27 +00:00
Peter Steinberger e403f8b620 style(pi): sort imports 2025-12-22 22:55:27 +00:00
Peter Steinberger 6205b955da style(mac): adjust session row padding and menu options 2025-12-22 23:30:25 +01:00
Peter Steinberger d265a04b19 style(mac): pad session rows + thicken bars 2025-12-22 23:22:36 +01:00
Peter Steinberger afc09744b4 fix(mac): size highlighted session rows 2025-12-22 22:59:59 +01:00
Peter Steinberger 1e1d76d600 fix(mac): restore sessions bars with injected submenus 2025-12-22 22:49:37 +01:00
Peter Steinberger 0b70aa0c56 fix(mac): hide sessions header when disconnected 2025-12-22 22:09:26 +01:00
Peter Steinberger 4ca6591045 refactor: move OAuth storage and drop legacy sessions 2025-12-22 21:02:48 +00:00
Peter Steinberger 9717f2d374 fix: bump pi deps and fix lint 2025-12-22 20:45:38 +00:00
Peter Steinberger 469c8a1a4b fix(mac): show disconnected sessions + sleeping eyes 2025-12-22 21:13:33 +01:00
Peter Steinberger 9d47b15575 fix(mac): sessions error UI + sleeping icon 2025-12-22 21:02:45 +01:00
Peter Steinberger a11a204b8e chore(submodules): bump Peekaboo 2025-12-22 19:44:48 +00:00
Peter Steinberger e3c3d108fe refactor(logging): shorten subsystem prefixes 2025-12-22 19:42:22 +00:00
Peter Steinberger 8cadb5cf18 docs: update group chat commands 2025-12-22 20:36:34 +01:00
Peter Steinberger f10c8f2b4c feat: add group activation command 2025-12-22 20:36:29 +01:00
Peter Steinberger 5d2d701e1e docs: note mac studio session log location 2025-12-22 20:26:23 +01:00
Peter Steinberger f24d8473b1 fix(mac): restore session usage bar 2025-12-22 20:14:54 +01:00
Peter Steinberger 3412ff7003 style: add macos chat glass background 2025-12-22 19:55:17 +01:00
Peter Steinberger 15e468f5dd feat: add group chat activation mode 2025-12-22 19:32:12 +01:00
Peter Steinberger a0dd504991 feat(mac): sessions submenus 2025-12-22 19:29:24 +01:00
Peter Steinberger 19b847b23b style: tighten macos chat composer 2025-12-22 19:08:23 +01:00
Peter Steinberger 3b134c8fef style: tighten chat compose spacing 2025-12-22 19:01:58 +01:00
Peter Steinberger c872f37aae fix: remove redundant await in CanvasManager 2025-12-22 18:53:14 +01:00
Peter Steinberger 3ce5b9b0d9 test: extend gateway sigterm timeouts 2025-12-22 18:52:35 +01:00
Peter Steinberger 2d7c5f8c53 refactor: migrate embedded pi to sdk 2025-12-22 18:05:44 +01:00
Peter Steinberger 79c0fd27a0 fix: center debug status overlay 2025-12-21 20:43:06 +01:00
Peter Steinberger b06d1ed072 docs(logging): clarify console color behavior 2025-12-21 17:36:30 +00:00
Peter Steinberger 52e7a4456a refactor(logging): streamline whatsapp console output 2025-12-21 17:36:24 +00:00
Peter Steinberger f1202ff152 chore: fix lint + build 2025-12-21 15:58:37 +01:00
Peter Steinberger e4db7cbd2b chore: bump Peekaboo submodule 2025-12-21 15:57:09 +01:00
Peter Steinberger ff63204d17 fix(web): harden WhatsApp creds persistence 2025-12-21 13:58:31 +00:00
Peter Steinberger 4f3a3e93a9 style: biome formatting 2025-12-21 13:58:27 +00:00
Peter Steinberger b56d4b90ce fix(logging): repair chalk/tslog typing 2025-12-21 13:58:22 +00:00
Peter Steinberger 6c2f9b3150 chore: update Peekaboo submodule 2025-12-21 14:50:28 +01:00
Peter Steinberger a808cdce13 fix(android): drop duplicate scaffold asset 2025-12-21 14:50:28 +01:00
Peter Steinberger a8629e1855 fix(logging): simplify tty color detection 2025-12-21 13:34:13 +00:00
Peter Steinberger 0146784e18 feat(logging): add console color modes 2025-12-21 13:26:50 +00:00
Peter Steinberger 249b85af1e refactor(gateway): switch logs to subsystem logger 2025-12-21 13:24:15 +00:00
Peter Steinberger efc12ab28d refactor(browser): use subsystem logger 2025-12-21 13:24:15 +00:00
Peter Steinberger 5b2e7d4464 refactor(logging): add subsystem console formatting 2025-12-21 13:24:15 +00:00
Peter Steinberger bcd3c13e2c feat(macos): surface canvas debug status 2025-12-21 14:21:06 +01:00
Peter Steinberger 7932e966db feat(android): toggle debug canvas status 2025-12-21 14:21:06 +01:00
Peter Steinberger 30d84643db feat(ios): toggle debug canvas status 2025-12-21 14:21:06 +01:00
Peter Steinberger 264c91e620 feat(canvas): gate debug status overlay 2025-12-21 14:21:06 +01:00
Peter Steinberger db89be4106 chore: update peekaboo submodule 2025-12-21 13:10:20 +00:00
Peter Steinberger 85816a5ee2 fix(cli): hint peekaboo unauthorized 2025-12-21 13:09:48 +00:00
Peter Steinberger 5449e44381 chore: bump Peekaboo submodule 2025-12-21 14:01:28 +01:00
Peter Steinberger 20630b8744 chore: bump Peekaboo + menu cleanup 2025-12-21 13:59:41 +01:00
Peter Steinberger 3b63d1cb77 fix: auto-restart WhatsApp QR login 2025-12-21 13:36:26 +01:00
Peter Steinberger 5703b9e737 docs: clarify restart semantics 2025-12-21 12:47:18 +01:00
Peter Steinberger 02787b5674 build(mac): add notarize flow for release artifacts 2025-12-21 12:33:45 +01:00
Peter Steinberger 4021da524c fix(chat-ui): avoid animated initial scroll 2025-12-21 12:33:41 +01:00
Peter Steinberger 5adec0eae0 fix: align canvas defaults and A2UI auto-nav 2025-12-21 12:32:36 +01:00
Peter Steinberger 3f44f0b753 ui: simplify dashboard health status 2025-12-21 12:31:56 +01:00
Peter Steinberger 2a975f751b refactor(macos): regroup menu sections 2025-12-21 12:29:29 +01:00
Peter Steinberger 03bd049291 docs: refine header ctas for github pages 2025-12-21 12:29:29 +01:00
Peter Steinberger 6ddd36666e feat(ui): make chat the landing view 2025-12-21 11:24:39 +00:00
Peter Steinberger 3791db006e docs: add github/download buttons to pages header 2025-12-21 12:19:08 +01:00
Peter Steinberger 6bf8c0c17a docs: note npm release pitfalls 2025-12-21 04:10:20 +01:00
Peter Steinberger 80e1934f4e style: fix tailscale swiftformat 2025-12-21 03:52:28 +01:00
Peter Steinberger 7415fdb79b chore: whitelist npm files
CI / build (bun) (push) Failing after 35s
CI / build (node) (push) Failing after 36s
CI / android (push) Failing after 4s
CI / macos-app (push) Has been cancelled
CI / ios (push) Has been cancelled
2025-12-21 03:48:23 +01:00
Peter Steinberger b850b0dacf ci: install swiftlint and swiftformat for ios 2025-12-21 03:44:18 +01:00
Peter Steinberger 04e3d0c2fe style: swiftformat cleanup 2025-12-21 03:44:12 +01:00
Peter Steinberger 3810519671 chore: update appcast for 2.0.0-beta2 2025-12-21 03:29:03 +01:00
Peter Steinberger a08c8ef1fa chore: bump version to 2.0.0-beta2 2025-12-21 03:21:49 +01:00
Peter Steinberger 6496a288b8 fix: add A2UI inset vars 2025-12-21 03:21:49 +01:00
Peter Steinberger 9f72eb3374 docs: add canvas gutter guidance 2025-12-21 03:21:48 +01:00
Peter Steinberger e71c71c6c2 fix: add canvas gutter vars for A2UI 2025-12-21 03:21:48 +01:00
Peter Steinberger 0197fb35fe fix: clear canvas error banner on load 2025-12-21 03:21:48 +01:00
Peter Steinberger bcc5891e03 fix(mac): allow tailscale localapi http 2025-12-21 02:17:55 +00:00
Peter Steinberger f90ab3c4c2 fix(mac): trim onboarding checklist 2025-12-21 01:57:18 +00:00
Peter Steinberger 79280f3d93 fix(mac): tighten onboarding layout 2025-12-21 01:57:18 +00:00
Peter Steinberger ce79d0b9a4 docs: add Peter tailnet/gateway notes 2025-12-21 02:55:32 +01:00
Peter Steinberger a5b4a01594 fix(mac): shrink onboarding + respect existing workspace 2025-12-21 01:51:48 +00:00
Peter Steinberger 5b25eeb449 refactor(macos): remove manual identity onboarding 2025-12-21 01:39:50 +00:00
Peter Steinberger fb259e8a50 fix(mac): shrink onboarding height 2025-12-21 01:35:27 +00:00
Peter Steinberger b82dfe08a2 fix: prefer header mime for media extensions 2025-12-21 02:34:19 +01:00
Peter Steinberger 4671c9e672 fix: align A2UI canvas background 2025-12-21 02:34:19 +01:00
Peter Steinberger 00cdcd4d28 fix(mac): guard onboarding workspace bootstrap 2025-12-21 01:31:31 +00:00
Peter Steinberger 4e1fe88195 Give workspace templates actual personality
- SOUL.md: Philosophy over bullet points, genuine vs performative help
- IDENTITY.md: Invites creativity, frames identity as discovery
- USER.md: Learning about a person, not building a dossier
- BOOTSTRAP.md: Conversational first-run, not robotic steps
- AGENTS.md: 'This folder is home' - clear, direct, practical
- TOOLS.md: Explains why separate from skills, real examples

New agents should boot with spark, not corporate drone energy. 🦞
2025-12-21 01:24:13 +00:00
Peter Steinberger 28ad475ab4 feat(mac): add tailscale settings 2025-12-21 01:16:49 +00:00
Peter Steinberger 104e265633 docs: clarify wacli usage 2025-12-21 02:14:52 +01:00
Peter Steinberger 382d237a60 build: silence mac packaging warnings 2025-12-21 02:06:12 +01:00
Peter Steinberger de2fd659ab fix(mac): shrink onboarding height 2025-12-21 00:57:11 +00:00
Peter Steinberger d2fda411f3 docs: add 2.0.0-beta2 changelog 2025-12-21 01:54:27 +01:00
Peter Steinberger e02944c323 docs: fix npmjs header image 2025-12-21 01:54:27 +01:00
Peter Steinberger a01f4998c5 ci: split ios workflow 2025-12-21 00:49:20 +00:00
Peter Steinberger aa198594fd fix(mac): avoid buttonStyle ternary 2025-12-21 00:49:07 +00:00
Peter Steinberger 406a94bf76 fix: use A2UI message context 2025-12-21 01:48:21 +01:00
Peter Steinberger fef1841fee build: update iOS lint scripts 2025-12-21 01:48:21 +01:00
Peter Steinberger 1cb85fdea8 fix(mac): disambiguate skills install ForEach 2025-12-21 00:47:49 +00:00
Peter Steinberger 78263e81f1 fix(mac): restore skills install ForEach 2025-12-21 00:46:38 +00:00
Peter Steinberger 053c8d5731 feat(gateway): add tailscale auth + pam 2025-12-21 00:44:39 +00:00
Peter Steinberger d69064f364 fix(gateway): avoid crash in handshake auth 2025-12-21 00:41:06 +00:00
Peter Steinberger fedb24caf1 fix(ui): stabilize skills action column 2025-12-21 00:37:29 +00:00
Peter Steinberger 6ff8371254 feat(ui): expand control dashboard 2025-12-21 00:34:39 +00:00
Peter Steinberger 7b6eaa819e chore: ignore ClawdisKit .swiftpm 2025-12-21 01:10:06 +01:00
Peter Steinberger e94aa296e2 feat: refine skills install actions 2025-12-21 01:07:35 +01:00
Peter Steinberger 98891103d0 fix: streamline WhatsApp login flow 2025-12-21 01:07:35 +01:00
Peter Steinberger 383097a03a fix: emit delta-only node system events 2025-12-21 01:07:35 +01:00
Peter Steinberger 2b2f13ca79 fix: restore canvas action bridge 2025-12-21 01:07:35 +01:00
Peter Steinberger 78159a9435 fix(onboarding): nudge bottom padding 2025-12-20 23:52:45 +00:00
Peter Steinberger b4af7b919e fix(macos): simplify skills view and resize onboarding 2025-12-20 23:45:50 +00:00
Peter Steinberger 65056915d3 fix(onboarding): lift bottom bar 2025-12-20 23:36:24 +00:00
Peter Steinberger bc3f744e45 chore(canvas): refresh a2ui bundle 2025-12-21 00:25:56 +01:00
Peter Steinberger fb8da15b01 chore(canvas): rebuild a2ui bundle 2025-12-21 00:25:56 +01:00
Peter Steinberger 62f624b66b fix(mac): re-ensure remote gateway tunnel 2025-12-21 00:25:56 +01:00
Peter Steinberger ef20053e72 style(tests): format gateway server test 2025-12-21 00:25:56 +01:00
Peter Steinberger aae68e4f82 style(chatui): fix SwiftFormat warnings 2025-12-21 00:25:56 +01:00
Peter Steinberger 1d715d7b1b chore(ios): link AppIntents framework 2025-12-21 00:24:24 +01:00
Peter Steinberger 1d7110ea8f fix(onboarding): fit chat card 2025-12-20 23:15:35 +00:00
Peter Steinberger 80f70a58e3 fix(chat): refine onboarding bubbles 2025-12-20 23:15:29 +00:00
Peter Steinberger f7aabeba04 chore(deps): update lockfile 2025-12-20 23:00:31 +00:00
Peter Steinberger 02f6cac9d6 style(chat): use integrated bubble tail 2025-12-20 23:00:21 +00:00
Peter Steinberger df54fc6098 test(gateway): cover provider status/logout RPCs 2025-12-20 23:51:36 +01:00
Peter Steinberger fe0fb8d296 chore(canvas): rebuild a2ui bundle 2025-12-20 22:45:15 +00:00
Peter Steinberger 591120a7f7 chore(deps): update dependencies 2025-12-20 22:45:15 +00:00
Peter Steinberger 878f074494 chore(android): update kotlin compiler settings 2025-12-20 23:43:28 +01:00
Peter Steinberger c1050da852 chore(android): update icons and platform config 2025-12-20 23:43:28 +01:00
Peter Steinberger 873daf079c feat(web): emit provider status updates 2025-12-20 23:43:27 +01:00
Peter Steinberger df9e4bdd63 chore(macos): tidy discovery and runtime 2025-12-20 23:43:27 +01:00
Peter Steinberger 43ba1671f1 feat(macos): add connections settings
# Conflicts:
#	apps/macos/Sources/Clawdis/SettingsRootView.swift
2025-12-20 23:43:27 +01:00
Peter Steinberger ce4b68d5fb fix: pre-size menu context card 2025-12-20 23:43:27 +01:00
Peter Steinberger 8c18dd40a3 feat(macos): load models from gateway 2025-12-20 23:43:27 +01:00
Peter Steinberger e3015bbfb7 test(gateway): cover models.list 2025-12-20 23:43:27 +01:00
Peter Steinberger 817abd8b5f feat(gateway): add models.list 2025-12-20 23:43:27 +01:00
Peter Steinberger dbc9b00de5 docs: improve oracle skill guidance 2025-12-20 23:41:07 +01:00
Peter Steinberger b635e83651 chore(pi): bump deps, drop steerable transport 2025-12-20 22:38:12 +00:00
Peter Steinberger 7aeacdcc6c style(settings): widen window 2025-12-20 22:23:15 +00:00
Peter Steinberger 16e4a0c4bd style(onboarding): refine bubble tails 2025-12-20 22:23:06 +00:00
Peter Steinberger d613800516 fix(onboarding): anchor bottom bar and reduce height 2025-12-20 22:16:13 +00:00
Peter Steinberger 94b89216f7 style(onboarding): add speech bubble tails 2025-12-20 22:08:01 +00:00
Peter Steinberger 153e09120a style(onboarding): lower bottom row 2025-12-20 22:07:51 +00:00
Peter Steinberger 238c0c1b86 fix(onboarding): clearer bubbles and tighter composer 2025-12-20 22:03:24 +00:00
Peter Steinberger 98ff213708 style(onboarding): lower bottom controls 2025-12-20 22:03:13 +00:00
Peter Steinberger 8a2a07eddb fix(macos): always show CLI installer 2025-12-20 22:00:51 +00:00
Peter Steinberger 9076d543f3 fix(onboarding): restore bubbles and spacing 2025-12-20 21:56:03 +00:00
Peter Steinberger cd77dc9563 fix(onboarding): restore chat bubble styling 2025-12-20 21:47:43 +00:00
Peter Steinberger 9ccf80848d style(onboarding): reduce window height 2025-12-20 21:33:56 +00:00
Peter Steinberger 78cb565dc2 docs: align canvas host port guidance 2025-12-20 22:28:35 +01:00
Peter Steinberger 6a30452b4a fix: use bridge canvas host for nodes 2025-12-20 22:28:35 +01:00
Peter Steinberger e53442d983 style(voicewake): widen label and clarify language 2025-12-20 21:14:46 +00:00
Peter Steinberger bc079b29c3 fix(macos): fix skill install target access 2025-12-20 22:01:11 +01:00
Peter Steinberger cd6addd742 chore(ci): swiftformat macos settings 2025-12-20 21:52:47 +01:00
Peter Steinberger 12d6e1cddd feat(macos): choose skill install target 2025-12-20 21:52:42 +01:00
Peter Steinberger 28e5ebd72b feat(macos): support gateway bind config 2025-12-20 21:52:19 +01:00
Peter Steinberger e8106109e3 Merge remote-tracking branch 'origin/main' 2025-12-20 21:43:30 +01:00
Peter Steinberger c71d5a8a77 docs: expand sag pronunciation rules 2025-12-20 21:43:03 +01:00
Peter Steinberger d1d27a0bd6 style(onboarding): refine icon and bottom bar spacing 2025-12-20 20:24:18 +00:00
Peter Steinberger ebb7428479 style(onboarding): nudge icon up 2025-12-20 20:19:18 +00:00
Peter Steinberger 3163a42f36 chore(skills): fix eightctl homepage 2025-12-20 21:18:40 +01:00
Peter Steinberger 35a25c3dc2 refactor(macos): collapse control channel status 2025-12-20 21:17:32 +01:00
Peter Steinberger f34f374179 chore(macos): widen settings window 2025-12-20 21:17:29 +01:00
Peter Steinberger aa330350fc refactor(macos): simplify sessions header 2025-12-20 21:17:24 +01:00
Peter Steinberger a2cf1f98d9 refactor(macos): move skills filter into header 2025-12-20 21:17:20 +01:00
Peter Steinberger f84def1b60 chore(skills): add homepage metadata 2025-12-20 21:12:57 +01:00
Peter Steinberger 91d4c24078 refactor(macos): simplify skills list rows 2025-12-20 21:12:57 +01:00
Peter Steinberger 8fe0b72a04 fix: accept new ssh host keys 2025-12-20 21:06:39 +01:00
Peter Steinberger 2bcdf741f9 feat(cron): require job name 2025-12-20 19:56:49 +00:00
Peter Steinberger 9ae73e87eb fix(onboarding): restore bottom bar padding 2025-12-20 19:50:30 +00:00
Peter Steinberger 77582ff5d4 refactor(macos): refresh skills settings layout 2025-12-20 20:49:32 +01:00
Peter Steinberger 52a2dfe08b feat(onboarding): hide kickoff bubble and tweak typing 2025-12-20 19:46:06 +00:00
Peter Steinberger 09d2165d36 style(onboarding): lower welcome icon 2025-12-20 19:44:35 +00:00
Peter Steinberger fb9c1f7e65 perf(dmg): shrink rw image before lzma convert 2025-12-20 19:44:26 +00:00
Peter Steinberger abf05af474 chore(ci): format macos relay 2025-12-20 20:41:21 +01:00
Peter Steinberger 714ba2a58d docs(macos): update bundled bun notes 2025-12-20 19:35:33 +00:00
Peter Steinberger 405ff0377a refactor(macos): bundle single relay binary 2025-12-20 19:35:30 +00:00
Peter Steinberger 8421ef7b4a feat(gateway): add gateway-daemon command 2025-12-20 19:35:30 +00:00
Peter Steinberger fd151c4fc6 chore(ci): fix biome formatting 2025-12-20 20:33:27 +01:00
Peter Steinberger b36b20d246 feat(voicewake): add computer wake word 2025-12-20 20:33:03 +01:00
Peter Steinberger 44ffe41775 fix(macos): allow identity refresh off main actor 2025-12-20 20:32:04 +01:00
Peter Steinberger 2ca7c2629c chore(ci): fix swiftformat lint 2025-12-20 20:32:04 +01:00
Josh Palmer 483c0e4cea chore(ci): fix biome + swiftformat lint 2025-12-20 20:32:04 +01:00
Peter Steinberger 7d51bf0eb0 fix(macos): allow identity refresh off MainActor 2025-12-20 19:19:57 +00:00
Peter Steinberger ab4457e2a3 fix(browser): allow control server without playwright 2025-12-20 19:16:56 +00:00
Peter Steinberger 1eb6d617f5 build(macos): bundle playwright in embedded gateway 2025-12-20 19:16:52 +00:00
Peter Steinberger 21ac34bc6a fix(gateway): start browser control server 2025-12-20 19:16:49 +00:00
Peter Steinberger c050a82c3a fix(macos): patch bun Long for protobuf 2025-12-20 19:16:44 +00:00
Peter Steinberger 750408d0a2 chore(deps): add chromium-bidi and long 2025-12-20 19:16:41 +00:00
Peter Steinberger a44a313f77 test: cover ssh autofill helpers 2025-12-20 19:53:15 +01:00
Peter Steinberger d159602928 refactor: centralize gateway parsing 2025-12-20 19:53:08 +01:00
Peter Steinberger 50e817f193 fix: use local timestamps in agent envelope 2025-12-20 19:40:48 +01:00
Peter Steinberger 929a10e33d fix(web): handle self-chat mode 2025-12-20 19:32:06 +01:00
Peter Steinberger c38aeb1081 fix: resolve bonjour txt for ssh autofill 2025-12-20 19:28:40 +01:00
Peter Steinberger 35e0894655 fix: merge bonjour txt records for ssh autofill 2025-12-20 19:27:36 +01:00
Peter Steinberger 943f0d475f fix: move host lookup off main thread 2025-12-20 19:26:04 +01:00
Peter Steinberger 96cbab2b22 test: expand mime detection coverage 2025-12-20 19:16:53 +01:00
Peter Steinberger 36c85a617a fix: use file-type for mime sniffing 2025-12-20 19:13:50 +01:00
Peter Steinberger 1356498ee1 docs: add ordercli skill 2025-12-20 18:50:51 +01:00
Peter Steinberger 49ec53f4ae fix: detect main module under PM2 2025-12-20 18:39:17 +01:00
Peter Steinberger 5687a03f0b chore: biome format 2025-12-20 18:39:17 +01:00
Peter Steinberger cdb2a0736a docs(onboarding): add soul creation step 2025-12-20 17:38:54 +00:00
Peter Steinberger cfd3efb6e7 docs(templates): update workspace template guidance 2025-12-20 17:35:52 +00:00
Peter Steinberger 8ec0d813c0 test: stabilize gateway sigterm startup 2025-12-20 18:29:46 +01:00
Peter Steinberger ea5333e5f7 fix: make web inbox non-blocking 2025-12-20 18:24:05 +01:00
Peter Steinberger b13723d3d7 style: satisfy swiftformat in chat composer 2025-12-20 18:18:30 +01:00
Peter Steinberger 03a4e0c837 docs: update summarize installer spec 2025-12-20 18:01:09 +01:00
Peter Steinberger f49c20c508 fix: accept duplex upgrade sockets 2025-12-20 18:01:09 +01:00
Peter Steinberger d3821123ee test: include token for canvas host hello 2025-12-20 18:01:09 +01:00
Peter Steinberger 759ab8acbc test: mock embedded queue in auto-reply tests 2025-12-20 18:01:09 +01:00
Peter Steinberger 7a88071a16 style: format skill installer logic 2025-12-20 18:01:09 +01:00
Peter Steinberger f3c4d1a181 docs(onboarding): document chat kickoff 2025-12-20 16:52:11 +00:00
Peter Steinberger 4e491757ef feat(web): add whatsapp QR login tool 2025-12-20 16:52:11 +00:00
Peter Steinberger 5936ed7941 feat(chat): restyle onboarding chat UI 2025-12-20 16:52:11 +00:00
Peter Steinberger 6b56f7d643 feat(mac): add onboarding chat kickoff 2025-12-20 16:52:11 +00:00
Peter Steinberger e618a21f4e style: biome formatting 2025-12-20 17:50:45 +01:00
Peter Steinberger 0f271ab535 refactor: tighten steerable agent loop typing 2025-12-20 17:50:35 +01:00
Peter Steinberger 4c054917ef feat: add uv skill installers 2025-12-20 17:50:29 +01:00
Peter Steinberger b9eabe532e docs: update mac skills install types 2025-12-20 17:40:09 +01:00
Peter Steinberger 4ee292a952 refactor: drop pnpm skill installer 2025-12-20 17:39:54 +01:00
Peter Steinberger adc2900aff refactor: trim skill install spec 2025-12-20 17:39:14 +01:00
Peter Steinberger 9c801e9c08 Merge remote-tracking branch 'origin/main' 2025-12-20 17:33:00 +01:00
Peter Steinberger ba0791b896 feat: add skills search and website 2025-12-20 17:32:40 +01:00
Peter Steinberger c4a67b7d02 feat: refresh skills metadata and toggles 2025-12-20 17:32:05 +01:00
Peter Steinberger bd572c775d refactor: remove canvasHost port config 2025-12-20 17:15:43 +01:00
Peter Steinberger 65329496a7 refactor: serve canvas host on gateway port 2025-12-20 17:13:36 +01:00
Peter Steinberger 2288ec7384 fix(mac): align cli button height 2025-12-20 16:02:05 +00:00
Peter Steinberger 80b3b9e00c docs(onboarding): refine bootstrap convo 2025-12-20 15:54:40 +00:00
Peter Steinberger 3876c1679a feat(workspace): add bootstrap ritual 2025-12-20 15:48:57 +00:00
Peter Steinberger ba85f4a62a test: cover tailnet hello canvas host 2025-12-20 16:45:26 +01:00
Peter Steinberger a1b34ef0ef refactor: extract canvas a2ui handler 2025-12-20 16:45:26 +01:00
Peter Steinberger f03d2d1b33 feat: advertise cli path for remote ssh 2025-12-20 16:45:26 +01:00
Peter Steinberger c7048973bb chore(agent): track upstream steerable loop 2025-12-20 16:45:26 +01:00
Peter Steinberger e800e84a77 fix(macos): streamline onboarding ui 2025-12-20 15:20:31 +00:00
Peter Steinberger d306fcb8a2 fix(macos): validate embedded CLI helper 2025-12-20 15:12:57 +00:00
Peter Steinberger 44339a6447 feat(agent): queue steering messages 2025-12-20 16:10:53 +01:00
Peter Steinberger 675aadc6a9 docs: document steering while streaming 2025-12-20 16:10:53 +01:00
Peter Steinberger d95c09d94a feat(gateway): enrich agent WS logs 2025-12-20 14:54:38 +00:00
Peter Steinberger f508fd3fa2 feat(macos): auto-enable local gateway 2025-12-20 14:47:37 +00:00
Peter Steinberger cf96ad8ef9 fix: route voice wake to main 2025-12-20 15:33:28 +01:00
Peter Steinberger 066a2828c4 fix(macos): clarify bridge discovery labels 2025-12-20 14:27:27 +00:00
Peter Steinberger b6c11154ae Merge branch 'main' of https://github.com/steipete/clawdis 2025-12-20 14:22:08 +00:00
Peter Steinberger 6ca897e055 fix(telegram): normalize chat ids and improve errors 2025-12-20 14:21:49 +00:00
Peter Steinberger 23ffa1905a style: soften hover hud status dot 2025-12-20 15:20:58 +01:00
Peter Steinberger a88e5968ae fix(macos): hide local bridge discovery 2025-12-20 14:19:22 +00:00
Peter Steinberger 4abaf62783 feat(macos): clarify local gateway choice 2025-12-20 14:11:57 +00:00
Peter Steinberger 9bf5b92d8f fix: clarify remote gateway error 2025-12-20 15:05:57 +01:00
Peter Steinberger 044f525eb8 fix: include tailnetDns in wide-area beacons 2025-12-20 15:02:23 +01:00
Peter Steinberger 554d9bc6ce fix: stabilize a2ui bundle output 2025-12-20 14:54:37 +01:00
Peter Steinberger 49654803aa style: fix lint formatting 2025-12-20 14:54:37 +01:00
Peter Steinberger 44c951e432 test(web): cover tool summary streaming 2025-12-20 13:53:56 +00:00
Peter Steinberger e1b8c30163 feat(web): toggle tool summaries mid-run 2025-12-20 13:52:04 +00:00
Peter Steinberger 70faa4ff36 feat(web): stream tool summaries 2025-12-20 13:47:07 +00:00
Peter Steinberger 63b63cd66d style(auto-reply): format bare /new 2025-12-20 13:31:46 +00:00
Peter Steinberger 137980b46e fix(agents): support loadSkillsFromDir result 2025-12-20 13:31:46 +00:00
Peter Steinberger 055d839fc3 feat(runtime): bootstrap PATH for clawdis 2025-12-20 13:31:46 +00:00
Peter Steinberger 3e39dd49aa fix: auto-detect tailnet DNS hint 2025-12-20 14:23:53 +01:00
Peter Steinberger 082b4fb193 docs: note imsg chats json 2025-12-20 14:17:34 +01:00
Peter Steinberger de1f119a7d fix: add ClawdisIPC import 2025-12-20 14:07:07 +01:00
Peter Steinberger 7ce12863b8 fix: clarify SSH test failure 2025-12-20 14:07:07 +01:00
Peter Steinberger 1ab69948a5 chore(canvas): refresh a2ui bundle 2025-12-20 13:06:34 +00:00
Peter Steinberger 13298d84ea test(agents): cover empty managed skills dir 2025-12-20 13:04:59 +00:00
Peter Steinberger c2c5b28c70 feat(auto-reply): greet on bare /new 2025-12-20 13:04:55 +00:00
Peter Steinberger 6e200ed1c0 fix(agents): handle managed skills list 2025-12-20 12:59:57 +00:00
Peter Steinberger 3fadbb29a1 docs: refresh peekaboo skill details 2025-12-20 13:56:42 +01:00
Peter Steinberger 6e4eef4a49 docs(skill): add clawdis nodes 2025-12-20 12:56:06 +00:00
Peter Steinberger 8feb09aa89 fix(skills): ship runnable brave/openai scripts 2025-12-20 12:54:18 +00:00
Peter Steinberger e1a3bab7e5 feat(skills): add media/transcription helpers 2025-12-20 12:53:09 +00:00
Peter Steinberger e0cd5650c5 style: biome formatting 2025-12-20 12:52:14 +00:00
Peter Steinberger 80c09f0845 docs(skill): add clawdis notify 2025-12-20 12:51:20 +00:00
Peter Steinberger 1f831c6037 docs(skill): update canvas A2UI guidance 2025-12-20 12:48:08 +00:00
Peter Steinberger cc0075e988 feat: add skills settings and gateway skills management 2025-12-20 13:33:42 +01:00
Peter Steinberger 4b44a75bc1 docs: add summarize skill 2025-12-20 13:33:16 +01:00
Peter Steinberger f46beec20d docs: add clawdis cron skill 2025-12-20 13:33:16 +01:00
Peter Steinberger 973bf67683 feat(skills): add extraDirs load paths 2025-12-20 12:26:58 +00:00
Peter Steinberger ff6a918e7e feat(skills): load bundled skills 2025-12-20 12:23:53 +00:00
Peter Steinberger 5ef2666127 docs(canvas): update A2UI hosting 2025-12-20 12:17:39 +00:00
Peter Steinberger ed001a5f55 refactor(canvas): host A2UI via gateway 2025-12-20 12:17:27 +00:00
Peter Steinberger 13ebbd1a2b feat: parse skill install metadata 2025-12-20 13:00:57 +01:00
Peter Steinberger ca8e556619 docs: align brave-search skill 2025-12-20 13:00:03 +01:00
Peter Steinberger 8900c84155 docs: finalize skill install hints 2025-12-20 13:00:03 +01:00
Peter Steinberger 002d927874 docs: expand skill install hints 2025-12-20 13:00:03 +01:00
Peter Steinberger cef5bf2768 docs: add skill install hints 2025-12-20 13:00:03 +01:00
Peter Steinberger 529543b36d build: refresh a2ui bundle 2025-12-20 13:00:03 +01:00
Peter Steinberger 636e4d38d5 style: tidy macos swift formatting 2025-12-20 13:00:03 +01:00
Peter Steinberger 2d8e11b78b docs: refine skills 2025-12-20 13:00:03 +01:00
Peter Steinberger 0e2993a6c8 fix(skills): prevent skills loading crash 2025-12-20 11:49:24 +00:00
Peter Steinberger f0ebad3f21 fix: address skills lint 2025-12-20 12:29:45 +01:00
Peter Steinberger a02adcc2ef docs: link docs section 2025-12-20 12:27:25 +01:00
Peter Steinberger d1850aaada feat: add managed skills gating 2025-12-20 12:22:38 +01:00
Peter Steinberger cf21a15e06 chore: remove dist from repo 2025-12-20 12:22:38 +01:00
Peter Steinberger 13124542cf fix(a2ui): improve modal styling 2025-12-20 11:12:11 +00:00
Peter Steinberger cd5809d11f fix(a2ui): stabilize canvas host 2025-12-20 10:58:13 +00:00
Peter Steinberger 28938ddb32 chore: update a2ui bundle 2025-12-20 11:32:20 +01:00
Peter Steinberger 3c551fd36f docs(browser): update hook timeouts 2025-12-20 09:47:21 +00:00
Peter Steinberger 94c495c8ed fix(browser): default hook timeout 2m 2025-12-20 09:45:04 +00:00
Peter Steinberger f54c801bd2 fix(browser): extend hook arm timeouts 2025-12-20 09:43:58 +00:00
Peter Steinberger 429972b5c5 test(browser): cover agent contract 2025-12-20 09:34:22 +00:00
Peter Steinberger 9b8a4d0c76 docs(browser): simplify control contract 2025-12-20 03:27:17 +00:00
Peter Steinberger 235f3ce0ba refactor(browser): simplify control API 2025-12-20 03:27:12 +00:00
Peter Steinberger 06806a1ea1 fix(mac): probe loopback bridge 2025-12-20 03:05:06 +00:00
Peter Steinberger b1a85d89d2 docs(browser): update browser tool surface 2025-12-20 02:53:26 +00:00
Peter Steinberger 6fc30962d6 refactor(browser): prune browser automation surface 2025-12-20 02:53:22 +00:00
Peter Steinberger 849446ae17 refactor(cli): unify on clawdis CLI + node permissions 2025-12-20 02:08:04 +00:00
Peter Steinberger 479720c169 refactor(browser): trim observe endpoints 2025-12-20 02:07:27 +00:00
Peter Steinberger 0e94c6b025 fix(browser): restore tsc types 2025-12-20 01:27:51 +00:00
Peter Steinberger 1a51257b71 fix(mac): use gateway main session for WebChat 2025-12-20 01:27:51 +00:00
Peter Steinberger 4e74ba996d feat(macos): add unconfigured gateway mode 2025-12-20 02:21:10 +01:00
Peter Steinberger 80a87e5f9e refactor(mac): remove clawdis-mac browser cli 2025-12-20 01:06:27 +00:00
Peter Steinberger a526d3c1f2 feat(browser): add native action commands 2025-12-20 00:53:56 +00:00
Peter Steinberger d67bec0740 style: polish logging and lint hints 2025-12-20 01:48:29 +01:00
Peter Steinberger b2e11c504b fix: tighten iOS main-actor handling 2025-12-20 01:48:29 +01:00
Peter Steinberger 1b38ee8b46 fix: harden device model decoding 2025-12-20 01:48:29 +01:00
Peter Steinberger afa4a234f9 fix: remove WhatsApp batching delay 2025-12-20 01:48:29 +01:00
Peter Steinberger 46b9006de2 docs(browser): add MCP tool spec 2025-12-19 23:57:35 +00:00
Peter Steinberger d54ecc3961 test(browser): cover MCP tool routes 2025-12-19 23:57:32 +00:00
Peter Steinberger fa54950d2e feat(browser): add MCP tool dispatch 2025-12-19 23:57:26 +00:00
Peter Steinberger 0ac7a93c28 fix: decode bonjour escaped utf8 2025-12-19 23:21:07 +01:00
Peter Steinberger bc2a66da32 refactor: unify gateway discovery on bridge 2025-12-19 23:12:52 +01:00
Peter Steinberger bcced90f11 style: lighten DMG background for label contrast 2025-12-19 22:51:54 +01:00
Peter Steinberger eb076165d2 style: refine DMG arrow 2025-12-19 22:44:56 +01:00
Peter Steinberger 9248919b05 docs: note DMG background sizing 2025-12-19 22:39:30 +01:00
Peter Steinberger 5472589ddd fix: align DMG background and icon layout 2025-12-19 22:38:36 +01:00
Peter Steinberger 19f5183176 docs(mac): document dmg packaging 2025-12-19 22:22:14 +01:00
Peter Steinberger beb6e25ef0 build(macos): add dmg+zip packaging 2025-12-19 22:22:09 +01:00
Peter Steinberger 0ad49c25aa style(macos): add dmg background 2025-12-19 22:22:03 +01:00
Peter Steinberger d46823333d docs(mac): add bun gateway packaging notes 2025-12-19 22:13:13 +01:00
Peter Steinberger 836f645621 perf(macos): compile embedded gateway with bytecode 2025-12-19 22:11:41 +01:00
Peter Steinberger 96be450cbb fix: handle screen record microphone output 2025-12-19 22:09:38 +01:00
Peter Steinberger 56cb415509 fix: restore mac app build 2025-12-19 22:08:17 +01:00
Peter Steinberger 2ef2136c2c fix(macos): sign bun gateway with jit entitlements 2025-12-19 19:24:49 +01:00
Peter Steinberger 0b16b4481a chore: ignore bun build artifacts 2025-12-19 19:21:27 +01:00
Peter Steinberger 0b18f1b948 docs: update bundled gateway flow 2025-12-19 19:21:27 +01:00
Peter Steinberger a4d4a30a6b feat(macos): run bundled gateway via launchd 2025-12-19 19:21:27 +01:00
Peter Steinberger 98bbc73925 build(macos): bundle bun gateway 2025-12-19 19:21:26 +01:00
Peter Steinberger bb7f4abd4b feat(gateway): support bun-compiled embedded gateway 2025-12-19 19:21:26 +01:00
Peter Steinberger bd63b5a231 fix: show Dock icon during onboarding 2025-12-19 19:21:26 +01:00
Peter Steinberger 590f3d0e8f feat(templates): centralize workspace templates 2025-12-19 18:18:15 +00:00
Peter Steinberger 6cbfa01176 docs: document WhatsApp and Telegram config 2025-12-19 19:03:17 +01:00
Peter Steinberger f929e1b105 fix: surface gateway failure details 2025-12-19 18:48:30 +01:00
Peter Steinberger 77104395ce docs: overhaul README architecture 2025-12-19 18:41:17 +01:00
Peter Steinberger c0d5853c63 fix(deps): include playwright-core in dependencies 2025-12-19 18:38:37 +01:00
1300 changed files with 146422 additions and 33647 deletions
+48
View File
@@ -0,0 +1,48 @@
.git
.worktrees
.bun-cache
.bun
.tmp
**/.tmp
.DS_Store
**/.DS_Store
*.png
*.jpg
*.jpeg
*.webp
*.gif
*.mp4
*.mov
*.wav
*.mp3
node_modules
**/node_modules
.pnpm-store
**/.pnpm-store
.turbo
**/.turbo
.cache
**/.cache
.next
**/.next
coverage
**/coverage
*.log
tmp
**/tmp
# build artifacts
dist
**/dist
apps/macos/.build
apps/ios/build
**/*.trace
# large app trees not needed for CLI build
apps/
assets/
Peekaboo/
Swabble/
Core/
Users/
vendor/
+99 -53
View File
@@ -5,12 +5,33 @@ on:
pull_request:
jobs:
build:
checks:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
runtime: [node, bun]
include:
- runtime: node
task: lint
command: pnpm lint
- runtime: node
task: test
command: pnpm test
- runtime: node
task: build
command: pnpm build
- runtime: node
task: protocol
command: pnpm protocol:check
- runtime: bun
task: lint
command: bunx biome check src
- runtime: bun
task: test
command: bunx vitest run
- runtime: bun
task: build
command: bunx tsc -p tsconfig.json
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -34,7 +55,7 @@ jobs:
if: matrix.runtime == 'node'
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
check-latest: true
- name: Setup Bun
@@ -49,7 +70,7 @@ jobs:
if: matrix.runtime == 'bun'
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
check-latest: true
- name: Runtime versions
@@ -77,36 +98,42 @@ jobs:
pnpm -v
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Lint (node)
if: matrix.runtime == 'node'
run: pnpm lint
- name: Test (node)
if: matrix.runtime == 'node'
run: pnpm test
- name: Build (node)
if: matrix.runtime == 'node'
run: pnpm build
- name: Protocol check (node)
if: matrix.runtime == 'node'
run: pnpm protocol:check
- name: Lint (bun)
if: matrix.runtime == 'bun'
run: bunx biome check src
- name: Test (bun)
if: matrix.runtime == 'bun'
run: bunx vitest run
- name: Build (bun)
if: matrix.runtime == 'bun'
run: bunx tsc -p tsconfig.json
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
macos-app:
if: github.event_name == 'pull_request'
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
include:
- task: lint
command: |
swiftlint --config .swiftlint.yml
swiftformat --lint apps/macos/Sources --config .swiftformat
- task: build
command: |
set -euo pipefail
for attempt in 1 2 3; do
if swift build --package-path apps/macos --configuration release; then
exit 0
fi
echo "swift build failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
- task: test
command: |
set -euo pipefail
for attempt in 1 2 3; do
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
exit 0
fi
echo "swift test failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -141,35 +168,46 @@ jobs:
xcodebuild -version
swift --version
- name: SwiftLint
run: swiftlint --config .swiftlint.yml
- name: Run ${{ matrix.task }}
run: ${{ matrix.command }}
ios:
if: false # ignore iOS in CI for now
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: SwiftFormat (lint mode)
run: swiftformat --lint apps/macos/Sources --config .swiftformat
- name: Swift build (release)
- name: Checkout submodules (retry)
run: |
set -euo pipefail
for attempt in 1 2 3; do
if swift build --package-path apps/macos --configuration release; then
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "swift build failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
- name: Swift tests (coverage)
- name: Select Xcode 26.1
run: |
set -euo pipefail
for attempt in 1 2 3; do
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
exit 0
fi
echo "swift test failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
sudo xcode-select -s /Applications/Xcode_26.1.app
xcodebuild -version
- name: Install XcodeGen
run: brew install xcodegen
- name: Install SwiftLint / SwiftFormat
run: brew install swiftlint swiftformat
- name: Show toolchain
run: |
sw_vers
xcodebuild -version
swift --version
- name: Generate iOS project
run: |
@@ -307,6 +345,14 @@ jobs:
android:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- task: test
command: ./gradlew --no-daemon :app:testDebugUnitTest
- task: build
command: ./gradlew --no-daemon :app:assembleDebug
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -346,6 +392,6 @@ jobs:
"platforms;android-36" \
"build-tools;36.0.0"
- name: Android unit tests + debug build
- name: Run Android ${{ matrix.task }}
working-directory: apps/android
run: ./gradlew --no-daemon :app:testDebugUnitTest :app:assembleDebug
run: ${{ matrix.command }}
+15 -2
View File
@@ -1,25 +1,37 @@
node_modules
.env
dist
*.bun-build
pnpm-lock.yaml
coverage
.pnpm-store
.worktrees/
.DS_Store
**/.DS_Store
ui/src/ui/__screenshots__/
ui/playwright-report/
ui/test-results/
# Bun build artifacts
*.bun-build
apps/macos/.build/
apps/shared/ClawdisKit/.build/
bin/clawdis-mac
apps/shared/ClawdbotKit/.build/
**/ModuleCache/
bin/
bin/clawdbot-mac
bin/docs-list
apps/macos/.build-local/
apps/macos/.swiftpm/
apps/shared/ClawdbotKit/.swiftpm/
Core/
apps/ios/*.xcodeproj/
apps/ios/*.xcworkspace/
apps/ios/.swiftpm/
vendor/
# Vendor build artifacts
vendor/a2ui/renderers/lit/dist/
.bundle.hash
# fastlane (iOS)
apps/ios/fastlane/README.md
@@ -35,3 +47,4 @@ apps/ios/*.dSYM.zip
# provisioning profiles (local)
apps/ios/*.mobileprovision
.env
+1 -1
View File
@@ -1 +1 @@
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty
+1 -1
View File
@@ -48,4 +48,4 @@
--allman false
# Exclusions
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol,apps/macos/Sources/ClawdbotProtocol
+4
View File
@@ -108,6 +108,10 @@ function_body_length:
warning: 150
error: 300
function_parameter_count:
warning: 7
error: 10
file_length:
warning: 1500
error: 2500
+25 -15
View File
@@ -7,7 +7,7 @@
## Build, Test, and Development Commands
- Install deps: `pnpm install`
- Run CLI in dev: `pnpm clawdis ...` (tsx entry) or `pnpm dev` for `src/index.ts`.
- Run CLI in dev: `pnpm clawdbot ...` (tsx entry) or `pnpm dev` for `src/index.ts`.
- Type-check/build: `pnpm build` (tsc)
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
@@ -16,13 +16,14 @@
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
- Formatting/linting via Biome; run `pnpm lint` before commits.
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
- Keep every file ≤ 500 LOC; refactor or split before exceeding and check frequently.
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
## Testing Guidelines
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
- Mobile: before using a simulator, check for connected real devices (iOS + Android) and prefer them when available.
## Commit & Pull Request Guidelines
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
@@ -31,38 +32,47 @@
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
## Security & Configuration Tips
- Web provider stores creds at `~/.clawdis/credentials/`; rerun `clawdis login` if logged out.
- Pi sessions live under `~/.clawdis/sessions/` by default; the base directory is not configurable.
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
## Agent-Specific Notes
- Gateway currently runs only as the menubar app (launchctl shows `application.com.steipete.clawdis.debug.*`), there is no separate LaunchAgent/helper label installed. Restart via the Clawdis Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdis` rather than expecting `com.steipete.clawdis`. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdis`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- Gateway currently runs only as the menubar app (launchctl shows `application.com.steipete.clawdbot.debug.*`), 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 expecting `com.steipete.clawdbot`. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdbot`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; dont introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) instead of manual conflict resolution.
- Notary key file lives at `~/Library/CloudStorage/Dropbox/Backup/AppStore/AuthKey_NJF3NFGTS3.p8` (Sparkle keys live under `~/Library/CloudStorage/Dropbox/Backup/Sparkle`).
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
- **Multi-agent safety:** when Peter says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When Peter says "commit", scope to your changes only. When Peter says "commit all", commit everything in grouped chunks.
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless Peter explicitly asks.
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdis/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from Mac Studio, 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.
- 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 `clawdis-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Dont add extra quotes.
- launchd PATH is minimal; ensure the apps launch agent sets PATH to include `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/steipete/Library/pnpm` so `pnpm`/`clawdis` binaries resolve when invoked via `clawdis-mac`.
- For manual `clawdis send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tools escaping.
- 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 sets PATH to include `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/steipete/Library/pnpm` so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
- For manual `clawdbot send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tools escaping.
## Exclamation Mark Escaping Workaround
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdis send` with messages containing exclamation marks, use heredoc syntax:
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot send` with messages containing exclamation marks, use heredoc syntax:
```bash
# WRONG - will send "Hello\\!" with backslash
clawdis send --to "+1234" --message 'Hello!'
clawdbot send --to "+1234" --message 'Hello!'
# CORRECT - use heredoc to avoid escaping
clawdis send --to "+1234" --message "$(cat <<'EOF'
clawdbot send --to "+1234" --message "$(cat <<'EOF'
Hello!
EOF
)"
```
This is a Claude Code quirk, not a clawdis bug.
This is a Claude Code quirk, not a clawdbot bug.
+129 -168
View File
@@ -1,196 +1,157 @@
# Changelog
## 2.0.0-beta1 — 2025-12-19
**Why this looks different:** the project was renamed from **Clawdis → Clawdbot**. To make the transition clear, releases now use **date-based versions** (`YYYY.M.D`) and the changelog is **compressed** into milestone summaries. Full detail still lives in git history and the docs.
First Clawdis release post rebrand. This is a semver-major because we dropped legacy providers/agents and moved defaults to new paths while adding a full macOS companion app, a WebSocket Gateway, and an iOS node.
## Unreleased
### Bug Fixes
- macOS: Voice Wake / push-to-talk no longer initialize `AVAudioEngine` at app launch, preventing Bluetooth headphones from switching into headset profile when voice features are unused. (Thanks @Nachx639)
### Fixes
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
## 2026.1.5-3
### Fixes
- NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid `ERR_MODULE_NOT_FOUND` in Node 25 npx installs.
## 2026.1.5-2
### Fixes
- NPM package: include `dist/sessions` so `clawdbot agent` resolves session helpers in npx installs.
- Node 25: avoid unsupported directory import by targeting `qrcode-terminal/vendor/QRCode/*.js` modules.
## 2026.1.5-1
### Fixes
- NPM package: include `dist/sessions` so `clawdbot agent` resolves session helpers in npx installs.
- Node 25: avoid unsupported directory import by targeting `qrcode-terminal/vendor/QRCode/index.js`.
## 2026.1.5
### Highlights
- Models: add image-specific model config (`agent.imageModel` + fallbacks) and scan support.
- Agent tools: new `image` tool routed to the image model (when configured).
- Config: default model shorthands (`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`).
- Docs: document built-in model shorthands + precedence (user config wins).
### Fixes
- Control UI: render Markdown in tool result cards.
- Control UI: prevent overlapping action buttons in Discord guild rules on narrow layouts.
- Android: tapping the foreground service notification brings the app to the front. (#179) — thanks @Syhids
- Cron tool uses `id` for update/remove/run/runs (aligns with gateway params). (#180) — thanks @adamgall
- Control UI: chat view uses page scroll with sticky header/sidebar and fixed composer (no inner scroll frame).
- macOS: treat location permission as always-only to avoid iOS-only enums. (#165) — thanks @Nachx639
- macOS: make generated gateway protocol models `Sendable` for Swift 6 strict concurrency. (#195) — thanks @andranik-sahakyan
- macOS: bundle QR code renderer modules so DMG gateway boot doesn't crash on missing qrcode-terminal vendor files.
- WhatsApp: suppress typing indicator during heartbeat background tasks. (#190) — thanks @mcinteerj
- WhatsApp: mark offline history sync messages as read without auto-reply. (#193) — thanks @mcinteerj
- Discord: avoid duplicate replies when a provider emits late streaming `text_end` events (OpenAI/GPT).
- CLI: use tailnet IP for local gateway calls when bind is tailnet/auto (fixes #176).
- Env: load global `$CLAWDBOT_STATE_DIR/.env` (`~/.clawdbot/.env`) as a fallback after CWD `.env`.
- Env: optional login-shell env fallback (opt-in; imports expected keys without overriding existing env).
- Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas).
- Onboarding: when running from source, auto-build missing Control UI assets (`pnpm ui:build`).
- Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed).
- Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off.
- Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events.
- Commands: unify /status (inline) and command auth across providers; group bypass for authorized control commands; remove Discord /clawd slash handler.
- Control UI: render Markdown in chat messages (sanitized).
## 2026.1.4
### Highlights
- Rename completion: all CLIs, paths, bundle IDs, env vars, and docs standardized on **Clawdbot**.
- Agent-to-agent relay: `sessions_send` pingpong with `REPLY_SKIP` plus announce step with `ANNOUNCE_SKIP`.
- Gateway quality-of-life: config hot reload, port config support, and Control UI base paths.
- Sandbox additions: per-session Docker sandbox with hardened limits + optional sandboxed Chromium.
- New node capability: `location.get` across macOS/iOS/Android (CLI + tools).
- Models CLI: scan OpenRouter free models (tools/images), manage aliases/fallbacks, and show last-used model in status.
### Breaking
- Renamed to **Clawdis**: defaults now live under `~/.clawdis` (sessions in `~/.clawdis/sessions/`, IPC at `~/.clawdis/clawdis.sock`, logs in `/tmp/clawdis`). Launchd labels and config filenames follow the new name; legacy stores are copied forward on first run.
- Pi only: `inbound.reply.agent.kind` accepts only `"pi"`, and the agent CLI/CLI flags for Claude/Codex/Gemini were removed. The Pi CLI runs in RPC mode with a persistent worker.
- WhatsApp Web is the only transport; Twilio support and related CLI flags/tests were removed.
- Direct chats now collapse into a single `main` session by default (no config needed); groups stay isolated as `group:<jid>`.
- Gateway is now a loopback-only WebSocket daemon (`ws://127.0.0.1:18789`) that owns all providers/state; clients (CLI, WebChat, macOS app, nodes) connect to it. Start it explicitly (`clawdis gateway …`) or via Clawdis.app; helper subcommands no longer auto-spawn a gateway.
- Tool names drop the `clawdbot_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`).
- Bash tool removes node-pty `stdinMode: "pty"` support (use tmux for real TTYs).
- Primary session key is fixed to `main` (or `global` for global scope).
### Gateway, nodes, and automation
- New typed Gateway WS protocol (JSON schema validated) with `clawdis gateway {health,status,send,agent,call}` helpers and structured presence/instance updates for all clients.
- Optional LAN-facing bridge (`tcp://0.0.0.0:18790`) keeps the Gateway loopback-only while enabling direct Bonjour-discovered connections for paired nodes.
- Node pairing + management via `clawdis nodes {pending,approve,reject,invoke}` (used by the iOS node and future remote nodes).
- Cron jobs are Gateway-owned (`clawdis cron …`) with run history stored as JSONL and support for “isolated summary” posting into the main session.
### macOS companion app
- **Clawdis.app menu bar companion**: packaged, signed bundle with gateway start/stop, launchd toggle, project-root and pnpm/node auto-resolution, live log shortcut, restart button, and status/recipient table plus badges/dimming for attention and paused states.
- **On-device Voice Wake**: Apple speech recognizer with wake-word table, language picker, live mic meter, “hold until silence,” animated ears/legs, and main-session routing that replies on the **last used surface** (WhatsApp/Telegram/WebChat). Delivery failures are logged, and the run remains visible via WebChat/session logs.
- **WebChat & Debugging**: bundled WebChat UI, Debug tab with heartbeat sliders, session-store picker, log opener (`clawlog`), gateway restart, health probes, and scrollable settings panes.
- **Browser control**: manage clawds dedicated Chrome/Chromium with tab listing/open/focus/close, screenshots, DOM query/dump, and “AI snapshots” (aria/domSnapshot/ai) via `clawdis browser …` and UI controls.
- **Remote gateway control**: Bonjour discovery for local masters plus SSH-tunnel fallback for remote control when multicast is unavailable.
### iOS node
- New iOS companion app that pairs to the Gateway bridge, reports presence as a node, and exposes a WKWebView “Canvas” for agent-driven UI.
- `clawdis nodes invoke` supports `canvas.eval` and `canvas.snapshot` to drive and verify the iOS Canvas (fails fast when the iOS node is backgrounded).
- Voice wake words are configurable in-app; the iOS node reconnects to the last bridge when credentials are still present in Keychain.
### WhatsApp & agent experience
- Group chats fully supported: mention-gated triggers (including media-only captions), sender attribution, session primer with subject/member roster, allowlist bypass when youre @mentioned, and safer handling of view-once/ephemeral media.
- Thinking/verbosity directives: `/think` and `/verbose` acknowledge and persist per session while allowing inline overrides; verbose mode streams tool metadata with emoji/args/previews and coalesces bursts to reduce WhatsApp noise.
- Heartbeats: configurable cadence with CLI/GUI toggles; directive acks suppressed during heartbeats; array/multi-payload replies normalized for Baileys.
- Reply quality: smarter chunking on words/newlines, fallback warnings when media fails to send, self-number mention detection, and primed group sessions send the roster on first turn.
- In-chat `/status`: prints agent readiness, session context usage %, current thinking/verbose options, and when the WhatsApp web creds were refreshed (helps decide when to re-scan QR); still available via `clawdis status` CLI for web session health.
### CLI, RPC, and health
- New `clawdis agent` command plus a persistent Pi RPC worker (auto-started) enables direct agent chats; `clawdis status` renders a colored session/recipient table.
- `clawdis health` probes WhatsApp link status, connect latency, heartbeat interval, session-store recency, and IPC socket presence (JSON mode for monitors).
- Added `--help`/`--version` flags; login/logout accept `--provider` (WhatsApp default). Console output is mirrored into pino logs under `/tmp/clawdis`.
- RPC stability: stdin/stdout loop for Pi, auto-restart worker, raw error surfacing, and deliver-via-RPC when JSON agent output is returned.
### Security & hardening
- Media server blocks symlink/path traversal, clears temporary downloads, and rotates logs daily (24h retention).
- Session store purged on logout; IPC socket directory permissions tightened (0700/0600).
- Launchd PATH and helper lookup hardened for packaged macOS builds; health probes surface missing binaries quickly.
### Fixes
- Doctor migrates legacy Clawdis config/service installs and normalizes sandbox Docker names.
- Doctor checks sandbox image availability and offers to build or fall back to legacy images.
- Presence beacons keep node lists fresh; Instances view stays accurate.
- Block streaming/chunking reliability (Telegram/Discord ordering, fewer duplicates).
- WhatsApp GIF playback for MP4-based GIFs.
- Onboarding + Control UI basePath handling fixes and UI polish.
- Clearer tool summaries, reduced log noise, and safer watchdog/queue behavior.
- Canvas host watcher resilience; build and packaging edge cases cleaned up.
### Docs
- Added `docs/telegram.md` outlining the Telegram Bot API provider (grammY) and how it shares the `main` session. Default grammY throttler keeps Bot API calls under rate limits.
- Gateway can run WhatsApp + Telegram together when configured; `clawdis send --provider telegram …` sends via the Telegram bot (webhook/proxy options documented).
- Sandbox setup, hot reload, port config, and session announce step coverage.
- Skills and onboarding clarifications + additional examples.
## 1.5.0 — 2025-12-05
## 2026.1.3 (beta 5)
### Breaking
- Dropped all non-Pi agents (Claude, Codex, Gemini, Opencode); `inbound.reply.agent.kind` now only accepts `"pi"` and related CLI helpers have been removed.
- Removed Twilio support and all related commands/options (webhook/up/provider flags/wait-poll); CLAWDIS is Baileys Web-only.
### Changes
- Default agent handling now favors Pi RPC while falling back to plain command execution for non-Pi invocations, keeping heartbeat/session plumbing intact.
- Documentation updated to reflect Pi-only support and to mark legacy Claude paths as historical.
- Status command reports web session health + session recipients; config paths are locked to `~/.clawdis` with session metadata stored under `~/.clawdis/sessions/`.
- Simplified send/agent/gateway/heartbeat to web-only delivery; removed Twilio mocks/tests and dead code.
- Pi RPC timeout is now inactivity-based (5m without events) and error messages show seconds only.
- Pi sessions now write to `~/.clawdis/sessions/` by default (legacy session logs from older installs are copied over when present).
- Directive triggers (`/think`, `/verbose`, `/stop` et al.) now reply immediately using normalized bodies (timestamps/group prefixes stripped) without waiting for the agent.
- Directive/system acks carry a `⚙️` prefix and verbose parsing rejects typoed `/ver*` strings so unrelated text doesnt flip verbosity.
- Batched history blocks no longer trip directive parsing; `/think` in prior messages won't emit stray acknowledgements.
- RPC fallbacks no longer echo the user's prompt (e.g., pasting a link) when the agent returns no assistant text.
- Heartbeat prompts with `/think` no longer send directive acks; heartbeat replies stay silent on settings.
- `clawdis sessions` now renders a colored table (a la oracle) with context usage shown in k tokens and percent of the context window.
## 1.4.1 — 2025-12-04
### Changes
- Added `clawdis agent` CLI command to talk directly to the configured agent using existing session handling (no WhatsApp send), with JSON output and delivery option.
- `/new` reset trigger now works even when inbound messages have timestamp prefixes (e.g., `[Dec 4 17:35]`).
- WhatsApp mention parsing accepts nullable arrays and flattens safely to avoid missed mentions.
## 1.4.0 — 2025-12-03
- Skills config moved under `skills.*` (new `skills.entries`, `skills.allowBundled`).
- Group session keys now `surface:group:<id>` / `surface:channel:<id>`; legacy `group:*` removed.
- Discord config refactor; `discord.allowFrom` + `discord.requireMention` removed.
- Discord/Telegram require `enabled: true` in config when using env tokens.
- Routing `allowFrom`/mention settings moved to per-surface group settings.
### Highlights
- **Thinking directives & state:** `/t|/think|/thinking <level>` (aliases off|minimal|low|medium|high|max/highest). Inline applies to that message; directive-only message pins the level for the session; `/think:off` clears. Resolution: inline > session override > `inbound.reply.thinkingDefault` > off. Pi gets `--thinking <level>` (except off); other agents append cue words (`think``think hard``think harder``ultrathink`). Heartbeat probe uses `HEARTBEAT /think:high`.
- **Group chats (web provider):** Clawdis now fully supports WhatsApp groups: mention-gated triggers (including image-only @ mentions), recent group history injection, per-group sessions, sender attribution, and a first-turn primer with group subject/member roster; heartbeats are skipped for groups.
- **Group session primer:** The first turn of a group session now tells the agent it is in a WhatsApp group and lists known members/subject so it can address the right speaker.
- **Media failures are surfaced:** When a web auto-reply media fetch/send fails (e.g., HTTP 404), we now append a warning to the fallback text so you know the attachment was skipped.
- **Verbose directives + session hints:** `/v|/verbose on|full|off` mirrors thinking: inline > session > config default. Directive-only replies with an acknowledgement; invalid levels return a hint. When enabled, tool results from JSON-emitting agents (Pi, etc.) are forwarded as metadata-only `[🛠️ <tool-name> <arg>]` messages (now streamed as they happen), and new sessions surface a `🧭 New session: <id>` hint.
- **Verbose tool coalescing:** successive tool results of the same tool within ~1s are batched into one `[🛠️ tool] arg1, arg2` message to reduce WhatsApp noise.
- **Directive confirmations:** Directive-only messages now reply with an acknowledgement (`Thinking level set to high.` / `Thinking disabled.`) and reject unknown levels with a helpful hint (state is unchanged).
- **Pi stability:** RPC replies buffered until the assistant turn finishes; parsers return consistent `texts[]`; web auto-replies keep a warm Pi RPC process to avoid cold starts.
- **Claude prompt flow:** One-time `sessionIntro` with per-message `/think:high` bodyPrefix; system prompt always sent on first turn even with `sendSystemOnce`.
- **Heartbeat UX:** Backpressure skips reply heartbeats while other commands run; skips dont refresh session `updatedAt`; web heartbeats normalize array payloads and optional `heartbeatCommand`.
- **Control via WhatsApp:** Send `/restart` to restart the launchd service (`com.steipete.clawdis`) from your allowed numbers.
- **Pi completion signal:** RPC now resolves on Pis `agent_end` (or process exit) so late assistant messages arent truncated; 5-minute hard cap only as a failsafe.
- Talk Mode (continuous voice) with ElevenLabs TTS on macOS/iOS/Android.
- Discord: expanded tool actions, richer routing, and threaded reply tags.
- Auto-reply queue modes + session model overrides; TUI upgrades.
- Nix mode (declarative config) and Docker setup flow.
- Onboarding wizard + configure/doctor/update flows.
- Signal + iMessage providers; new skills (Trello, Things, Notes/Reminders, tmux coding).
- Browser tooling upgrades (remote CDP, no-sandbox, profiles).
### Reliability & UX
- Outbound chunking prefers newlines/word boundaries and enforces caps (~4000 chars for web/WhatsApp).
- Web auto-replies fall back to caption-only if media send fails; hosted media MIME-sniffed and cleaned up immediately.
- IPC gateway send shows typing indicator; batched inbound messages keep timestamps; watchdog restarts WhatsApp after long inactivity.
- Early `allowFrom` filtering prevents decryption errors; same-phone mode supported with echo suppression.
- All console output is now mirrored into pino logs (still printed to stdout/stderr), so verbose runs keep full traces.
- `--verbose` now forces log level `trace` (was `debug`) to capture every event.
- Verbose tool messages now include emoji + args + a short result preview for bash/read/edit/write/attach (derived from RPC tool start/end events).
### Fixes
- macOS codesign/TCC hardening and menu/UI stability improvements.
- Streaming/typing fixes; per-provider chunk limit tuning.
- Remote gateway auth + token handling tightened.
- Camera capture reliability and media sizing fixes.
### Security / Hardening
- IPC socket hardened (0700 dir / 0600 socket, no symlinks/foreign owners); `clawdis logout` also prunes session store.
- Media server blocks symlinks and enforces path containment; logging rotates daily and prunes >24h.
### Bug Fixes
- Web group chats now bypass the second `allowFrom` check (we still enforce it on the group participant at inbox ingest), so mentioned group messages reply even when the group JID isnt in your allowlist.
- `logVerbose` also writes to the configured Pino logger at debug level (without breaking stdout).
- Group auto-replies now append the triggering sender (`[from: Name (+E164)]`) to the batch body so agents can address the right person in group chats.
- Media-only pings now pick up mentions inside captions (image/video/etc.), so @-mentions on media-only messages trigger replies.
- MIME sniffing and redirect handling for downloads/hosted media.
- Response prefix applied to heartbeat alerts; heartbeat array payloads handled for both providers.
- Pi RPC typing exposes `signal`/`killed`; NDJSON parsers normalized across agents.
- Pi session resumes now append `--continue`, so existing history/think level are reloaded instead of starting empty.
### Testing
- Fixtures isolate session stores; added coverage for thinking directives, stateful levels, heartbeat backpressure, and agent parsing.
## 1.3.0 — 2025-12-02
## 2025.12.27 (betas 34)
### Highlights
- **Pluggable agents (Claude, Pi, Codex, Opencode):** `inbound.reply.agent` selects CLI/parser; per-agent argv builders and NDJSON parsers enable swapping without template changes.
- **Safety stop words:** `stop|esc|abort|wait|exit` immediately reply “Agent was aborted.” and mark the session so the next prompt is prefixed with an abort reminder.
- **Agent session reliability:** Only Claude returns a stable `session_id`; others may reset between runs.
- First-class tools replace `clawdbot-*` skills (browser, canvas, nodes, cron).
- Per-session model selection and custom model providers.
- Group activation commands; Discord provider for DMs/guilds.
- Gateway webhooks + Gmail Pub/Sub hooks.
- Command queue modes + `agent.maxConcurrent` cap.
- Background bash tasks with `process` tool; gateway in-process restart.
### Bug Fixes
- Empty `result` fields no longer leak raw JSON to users.
- Heartbeat alerts now honor `responsePrefix`.
- Command failures return user-friendly messages.
- Test session isolation to avoid touching real `sessions.json`.
- (Removed in 2.0.0) IPC reuse for `clawdis send/heartbeat` prevents Signal/WhatsApp session corruption.
- Web send respects media kind (image/audio/video/document) with correct limits.
### Fixes
- Packaging fixes, heartbeat cleanup, WhatsApp reconnect reliability.
- macOS menu/Chat UI polish and presence reporting fixes.
### Changes
- (Removed in 2.0.0) IPC gateway socket at `~/.clawdis/ipc/gateway.sock` with automatic CLI fallback.
- Batched inbound messages with timestamps; typing indicator after sends.
- Watchdog restarts WhatsApp after long inactivity; heartbeat logging includes minutes since last message.
- Early `allowFrom` filtering before decryption.
- Same-phone mode with echo detection and optional `inbound.samePhoneMarker`.
## 2025.12.21 (beta 2)
## 1.2.2 — 2025-11-28
### Highlights
- Bundled gateway packaging + DMG distribution pipeline.
- Skills platform (bundled/managed/workspace) with install gating + UI.
- Onboarding polish and agent UX improvements.
- Canvas host served from Gateway; browser control simplification.
### Changes
- Manual heartbeat sends: `clawdis heartbeat --message/--body` (web provider only); `--dry-run` previews payloads.
## 2025.12.19 (beta 1)
## 1.2.1 — 2025-11-28
### Highlights
- First Clawdbot release: Gateway WS control plane + optional Bridge.
- macOS menu bar companion app with Voice Wake + WebChat.
- iOS node pairing with Canvas surface.
- WhatsApp groups, thinking/verbose directives, health/status tooling.
### Changes
- Media MIME-first handling; hosted media extensions derived from detected MIME with tests.
### Breaking
- Switched to Pi-only agent runtime; legacy providers removed.
- Gateway became the single source of truth (no ad-hoc direct sends).
### Planned / in progress (from prior notes)
- Heartbeat targeting quality: clearer recipient resolution and verbose logs.
- Heartbeat delivery preview (Claude path) dry-run.
- Simulated inbound hook for local testing.
## 2025.12.052025.12.03 (pre-Clawdbot)
## 1.2.0 — 2025-11-27
### Highlights
- Pi-only agent path and web-only gateway workflow.
- Thinking/verbose directives, group chat support, and heartbeat controls.
- `clawdbot agent` CLI added; session tables and health reporting.
### Changes
- Heartbeat interval default 10m for command mode; prompt `HEARTBEAT /think:high`; skips dont refresh session; session `heartbeatIdleMinutes` support.
- Heartbeat tooling: `--session-id`, `--heartbeat-now` (inline flag on `gateway`) for immediate startup probes.
- Prompt structure: `sessionIntro` plus per-message `/think:high`; session idle up to 7 days.
- Thinking directives: `/think:<level>`; Pi uses `--thinking`; others append cue; `/think:off` no-op.
- Robustness: Baileys/WebSocket guards; global unhandled error handlers; WhatsApp LID mapping; hosted media MIME-sniffing and cleanup.
- Docs: README Clawd setup; `docs/claude-config.md` for live config.
## 2025.11.282025.11.25 (early web-only)
## 1.1.0 — 2025-11-26
### Changes
- Web auto-replies resize/recompress media and honor `inbound.reply.mediaMaxMb`.
- Detect media kind, enforce provider caps (images ≤6MB, audio/video ≤16MB, docs ≤100MB).
- `session.sendSystemOnce` and optional `sessionIntro`.
- Typing indicator refresh during commands; configurable via `inbound.reply.typingIntervalSeconds`.
- Optional audio transcription via external CLI.
- Command replies return structured payload/meta; respect `mediaMaxMb`; log Claude metadata; include `cwd` in timeout messages.
- Web provider refactor; logout command; web-only gateway start helper.
- Structured reconnect/heartbeat logging; bounded backoff with CLI/config knobs; troubleshooting guide.
- Relay help prints effective heartbeat/backoff when in web mode.
## 1.0.4 — 2025-11-25
### Changes
- Timeout fallbacks send partial stdout (≤800 chars) to the user instead of silence; tests added.
- Web gateway auto-reconnects after Baileys/WebSocket drops; close propagation tests.
## 0.1.3 — 2025-11-25
### Changes
- Auto-replies send a WhatsApp fallback message on command/Claude timeout with truncated stdout.
- Added tests for timeout fallback and partial-output truncation.
- Heartbeat CLI + interval handling.
- Media MIME sniffing, size caps, and timeout fallbacks.
- Web provider reconnects and early stability fixes.
+42
View File
@@ -0,0 +1,42 @@
# Contributing to Clawdbot
Welcome to the lobster tank! 🦞
## Quick Links
- **GitHub:** https://github.com/clawdbot/clawdbot
- **Discord:** https://discord.gg/qkhbAGHRBT
- **X/Twitter:** [@steipete](https://x.com/steipete) / [@clawdbot](https://x.com/clawdbot)
## Maintainers
- **Peter Steinberger** - Benevolent Dictator
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
- **Shadow** - Discord + Slack subsystem
- GitHub: [@4shadowed](https://github.com/4shadowed) · X: [@4shad0wed](https://x.com/4shad0wed)
- **Jos** - Telegram, API, Nix mode
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
## How to Contribute
1. **Bugs & small fixes** → Open a PR!
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/clawdbot/clawdbot/discussions) or ask in Discord first
3. **Questions** → Discord #setup-help
## Before You PR
- Test locally with your Clawdbot instance
- Run linter: `npm run lint`
- Keep PRs focused (one thing per PR)
- Describe what & why
## AI/Vibe-Coded PRs Welcome! 🤖
Built with Codex, Claude, or other AI tools? **Awesome - just mark it!**
Please include in your PR:
- [ ] Mark as AI-assisted in the PR title or description
- [ ] Note the degree of testing (untested / lightly tested / fully tested)
- [ ] Include prompts or session logs if possible (super helpful!)
- [ ] Confirm you understand what the code does
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
+16
View File
@@ -0,0 +1,16 @@
FROM node:22-bookworm
RUN corepack enable
WORKDIR /app
COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm build
RUN pnpm ui:install
RUN pnpm ui:build
ENV NODE_ENV=production
CMD ["node", "dist/index.js"]
+16
View File
@@ -0,0 +1,16 @@
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
jq \
python3 \
ripgrep \
&& rm -rf /var/lib/apt/lists/*
CMD ["sleep", "infinity"]
+27
View File
@@ -0,0 +1,27 @@
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
chromium \
curl \
fonts-liberation \
fonts-noto-color-emoji \
git \
jq \
novnc \
python3 \
websockify \
x11vnc \
xvfb \
&& rm -rf /var/lib/apt/lists/*
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/clawdbot-sandbox-browser
RUN chmod +x /usr/local/bin/clawdbot-sandbox-browser
EXPOSE 9222 5900 6080
CMD ["clawdbot-sandbox-browser"]
+247 -189
View File
@@ -1,7 +1,7 @@
# 🦞 CLAWDIS — WhatsApp & Telegram Gateway for AI Agents
# 🦞 CLAWDBOT — Personal AI Assistant
<p align="center">
<img src="docs/whatsapp-clawd.jpg" alt="CLAWDIS" width="400">
<img src="https://raw.githubusercontent.com/clawdbot/clawdbot/main/docs/whatsapp-clawd.jpg" alt="CLAWDBOT" width="400">
</p>
<p align="center">
@@ -9,152 +9,268 @@
</p>
<p align="center">
<a href="https://github.com/steipete/clawdis/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/steipete/clawdis/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
<a href="https://github.com/steipete/clawdis/releases"><img src="https://img.shields.io/github/v/release/steipete/clawdis?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
<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://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>
**CLAWDIS** is a TypeScript/Node gateway that bridges WhatsApp (Web/Baileys) and Telegram (Bot API/grammY) to a local coding agent (**Pi**).
Its like having a genius lobster in your pocket 24/7 — but with a real control plane, companion apps, and a network model that wont corrupt sessions.
**Clawdbot** is a *personal AI assistant* you run on your own devices.
It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, iMessage, WebChat), can speak and listen on macOS/iOS, 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.
Website: https://clawd.me · Docs: https://docs.clawdbot.com/ · FAQ: [`docs/faq.md`](docs/faq.md) · Wizard: [`docs/wizard.md`](docs/wizard.md) · Nix: [nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) · Docker: [`docs/docker.md`](docs/docker.md) · Discord: https://discord.gg/clawd
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Windows, and Linux**.
Using Claude Pro/Max subscription? See `docs/onboarding.md` for the Anthropic OAuth setup.
## Highlights
- **Local-first Gateway** — single control plane for sessions, providers, tools, and events.
- **Multi-surface inbox** — WhatsApp, Telegram, Discord, iMessage, WebChat, macOS, iOS/Android.
- **Voice Wake + Talk Mode** — always-on speech for macOS/iOS/Android with ElevenLabs.
- **Live Canvas** — agent-driven visual workspace with A2UI.
- **First-class tools** — browser, canvas, nodes, cron, sessions, and Discord actions.
- **Companion apps** — macOS menu bar app + iOS/Android nodes.
- **Onboarding + skills** — wizard-driven setup with bundled/managed/workspace skills.
## Everything we built so far
### Core platform
- Gateway WS control plane with sessions, presence, config, cron, webhooks, control UI, and Canvas host.
- CLI surface: gateway, agent, send, wizard, doctor/update, and TUI.
- Pi agent runtime in RPC mode with tool streaming and block streaming.
- Session model: `main` for direct chats, group isolation, activation modes, queue modes, reply-back.
- Media pipeline: images/audio/video, transcription hooks, size caps, temp file lifecycle.
### Surfaces + providers
- WhatsApp (Baileys), Telegram (grammY), Discord (discord.js), Signal (signal-cli), iMessage (imsg), WebChat.
- Group mention gating, reply tags, per-surface chunking and routing.
### Apps + nodes
- macOS app: menu bar control plane, Voice Wake/PTT, Talk Mode overlay, WebChat, Debug tools, SSH remote gateway control.
- iOS node: Canvas, Voice Wake, Talk Mode, camera, screen recording, Bonjour pairing.
- Android node: Canvas, Talk Mode, camera, screen recording, optional SMS.
- macOS node mode: system.run/notify + canvas/camera exposure.
### Tools + automation
- Browser control: dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
- Canvas: A2UI push/reset, eval, snapshot.
- Nodes: camera snap/clip, screen record, location.get, notifications.
- Cron + wakeups; webhooks; Gmail Pub/Sub triggers.
- Skills platform: bundled, managed, and workspace skills with install gating + UI.
### Ops + packaging
- Control UI + WebChat served directly from the Gateway.
- Tailscale Serve/Funnel or SSH tunnels with token/password auth.
- Nix mode for declarative config; Docker-based installs.
- Health, doctor migrations, structured logging, release tooling.
## Changes since 2026.1.4 (2026-01-04)
### Highlights
- Project rename completed: CLIs, paths, bundle IDs, env vars, and docs unified on Clawdbot.
- Agent-to-agent relay: `sessions_send` pingpong with `REPLY_SKIP` plus announce step with `ANNOUNCE_SKIP`.
- Gateway config hot reload, configurable port, and Control UI base-path support.
- Sandbox options: per-session Docker sandbox with hardened limits + optional sandboxed Chromium.
- New node capability: `location.get` across macOS/iOS/Android (CLI + tools).
### Fixes
- Presence beacons keep node lists fresh; Instances view stays accurate.
- Block streaming + chunking reliability (Telegram/Discord ordering, fewer duplicates).
- WhatsApp GIF playback for MP4-based GIFs.
- Onboarding/Control UI basePath handling fixes + UI polish.
- Cleaner logging + clearer tool summaries.
### Breaking
- Tool names drop the `clawdbot_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`).
- Bash tool removed `stdinMode: "pty"` support (use tmux for real TTYs).
- Primary session key is fixed to `main` (or `global` for global scope).
## Project rename + changelog format
Clawdis → Clawdbot. The rename touched every surface, path, and bundle ID. To make that transition explicit, releases now use **date-based versions** (`YYYY.M.D`), and the changelog is compressed into milestone summaries instead of long semver trains. Full detail still lives in git history and the docs.
## How it works (short)
```
WhatsApp / Telegram
┌──────────────────────────┐
Gateway │ ws://127.0.0.1:18789 (default: loopback)
(control UI) http://127.0.0.1:18789/ui/
│ (single source) │ tcp://0.0.0.0:18790 (optional Bridge)
└───────────┬───────────────┘
├─ Pi agent (RPC)
├─ CLI (clawdis …)
├─ Control UI (browser)
├─ macOS app (Clawdis.app)
└─ iOS node via Bridge + pairing
Your surfaces
───────────────────────────────┐
Gateway │ ws://127.0.0.1:18789
(control plane) │ tcp://0.0.0.0:18790 (optional Bridge)
└──────────────┬────────────────┘
├─ Pi agent (RPC)
├─ CLI (clawdbot …)
├─ WebChat (browser)
├─ macOS app (Clawdbot.app)
└─ iOS node (Canvas + voice)
```
## Why "CLAWDIS"?
## Skills registry (ClawdHub)
**CLAWDIS** = CLAW + TARDIS
ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can search for skills automatically and pull in new ones as needed.
Because every space lobster needs a time-and-space machine. The Doctor has a TARDIS. [Clawd](https://clawd.me) has a CLAWDIS. Both are blue. Both are chaotic. Both are loved.
https://clawdhub.com
## Features
## Quick start (from source)
- 📱 **WhatsApp Integration** — Personal WhatsApp Web (Baileys)
- ✈️ **Telegram (Bot API)** — DMs and groups via grammY
- 🛰️ **Gateway control plane** — One long-lived gateway owns provider state; clients connect over WebSocket
- 🤖 **Agent runtime** — Pi only (Pi CLI in RPC mode), with tool streaming
- 💬 **Sessions** — Direct chats collapse into `main` by default; groups are isolated
- 🔔 **Heartbeats** — Periodic check-ins for proactive AI
- 🧭 **Clawd Browser** — Dedicated Chrome/Chromium profile with tabs + screenshot control (no interference with your daily browser)
- 👥 **Group Chat Support** — Mention-based triggering
- 📎 **Media Support** — Images, audio, documents, voice notes
- 🎤 **Voice & transcription hooks** — Voice Wake (macOS/iOS) + optional transcription pipeline
- 🔧 **Tool Streaming** — Real-time display (💻📄✍️📝)
- 🖥️ **macOS Companion (Clawdis.app)** — Menu bar controls, Voice Wake, WebChat, onboarding, remote gateway control
- 📱 **iOS node** — Pairs as a node, exposes a Canvas surface, forwards voice wake transcripts
Only the Pi CLI is supported now; legacy Claude/Codex/Gemini paths have been removed.
## Network model (the “new reality”)
- **One Gateway per host**. The Gateway is the only process allowed to own the WhatsApp Web session.
- **Loopback-first**: the Gateway WebSocket listens on `ws://127.0.0.1:18789` by default.
- To expose it on your tailnet, set `gateway.bind: "tailnet"` (or run `clawdis gateway --bind tailnet`) and set `CLAWDIS_GATEWAY_TOKEN` (required for non-loopback binds).
- The browser Control UI is served from the Gateway at `http://<host>:18789/ui/` when assets are built.
- **Bridge for nodes**: when enabled, the Gateway also exposes a bridge on `tcp://0.0.0.0:18790` for paired nodes (Bonjour-discoverable). For tailnet-only setups, set `bridge.bind: "tailnet"` in `~/.clawdis/clawdis.json`.
- **Remote control**: use a VPN/tailnet or an SSH tunnel (`ssh -N -L 18789:127.0.0.1:18789 user@host`). The macOS app can drive this flow.
- **Wide-Area Bonjour (optional)**: for auto-discovery across networks (Vienna ⇄ London) over Tailscale, use unicast DNS-SD on `clawdis.internal.`; see `docs/bonjour.md`.
## Codebase
- **TypeScript (ESM)**: CLI + Gateway live in `src/` and run on Node ≥ 22.
- **macOS app (Swift)**: menu bar companion lives in `apps/macos/`.
- **iOS app (Swift)**: iOS node prototype lives in `apps/ios/`.
## Quick Start
Runtime requirement: **Node ≥22.0.0** (not bundled). The macOS app and CLI both use the host runtime; install via Homebrew or official installers before running `clawdis`.
Runtime: **Node ≥22** + **pnpm**.
```bash
# From source (recommended while the npm package is still settling)
pnpm install
pnpm build
pnpm ui:build
# Link your WhatsApp (stores creds under ~/.clawdis/credentials)
pnpm clawdis login
# Recommended: run the onboarding wizard
pnpm clawdbot onboard
# Start the gateway (WebSocket control plane)
pnpm clawdis gateway --port 18789 --verbose
# Link WhatsApp (stores creds in ~/.clawdbot/credentials)
pnpm clawdbot login
# Open the browser Control UI (after ui:build)
# http://127.0.0.1:18789/ui/
# Start the gateway
pnpm clawdbot gateway --port 18789 --verbose
# Send a WhatsApp message (WhatsApp sends go through the Gateway)
pnpm clawdis send --to +1234567890 --message "Hello from the CLAWDIS!"
# Dev loop (auto-reload on TS changes)
pnpm gateway:watch
# Talk to the agent (optionally deliver back to WhatsApp/Telegram)
pnpm clawdis agent --message "Ship checklist" --thinking high
# Send a message
pnpm clawdbot send --to +1234567890 --message "Hello from Clawdbot"
# If the port is busy, force-kill listeners then start
pnpm clawdis gateway --force
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Discord)
pnpm clawdbot agent --message "Ship checklist" --thinking high
```
### Agent workspace + skills
If you run from source, prefer `pnpm clawdbot …` (not global `clawdbot`).
Clawdis runs the embedded agent with its working directory set to the agent workspace (default: `~/clawd`, configurable via `inbound.workspace`).
## Chat commands
- Workspace files injected into the system prompt: `AGENTS.md`, `SOUL.md`, `TOOLS.md`
- Custom skills: `<workspace>/skills/<skill-name>/SKILL.md` (default: `~/clawd/skills/<skill-name>/SKILL.md`; only this location is scanned)
Send these in WhatsApp/Telegram/WebChat (group commands are owner-only):
## Companion Apps
- `/status` — health + session info (group shows activation mode)
- `/new` or `/reset` — reset the session
- `/think <level>` — off|minimal|low|medium|high
- `/verbose on|off`
- `/restart` — restart the gateway (owner-only in groups)
- `/activation mention|always` — group activation toggle (groups only)
### macOS Companion (Clawdis.app)
## Architecture
- A menu bar app that can start/stop the Gateway, show health/presence, and provide a local ops UI.
- Instances UI shows friendly hardware model names (from the vendored MIT dataset under `apps/macos/Sources/Clawdis/Resources/DeviceModels/`).
- **Voice Wake** (on-device speech recognition) and Push-to-talk overlay.
- **WebChat** embed + debug tooling (logs, status, heartbeats, sessions).
- Hosts **PeekabooBridge** for UI automation brokering (for clawd workflows).
### TypeScript Gateway (src/gateway/server.ts)
- **Single HTTP+WS server** on `ws://127.0.0.1:18789` (bind policy: loopback/lan/tailnet/auto). The first frame must be `connect`; AJV validates frames against TypeBox schemas (`src/gateway/protocol`).
- **Single source of truth** for sessions, providers, cron, voice wake, and presence. Methods cover `send`, `agent`, `chat.*`, `sessions.*`, `config.*`, `cron.*`, `voicewake.*`, `node.*`, `system-*`, `wake`.
- **Events + snapshot**: handshake returns a snapshot (presence/health) and declares event types; runtime events include `agent`, `chat`, `presence`, `tick`, `health`, `heartbeat`, `cron`, `node.pair.*`, `voicewake.changed`, `shutdown`.
- **Idempotency & safety**: `send`/`agent`/`chat.send` require idempotency keys with a TTL cache (5 min, cap 1000) to avoid doublesends on reconnects; payload sizes are capped per connection.
- **Bridge for nodes**: optional TCP bridge (`src/infra/bridge/server.ts`) is newlinedelimited JSON frames (`hello`, pairing, RPC, `invoke`); node connect/disconnect is surfaced into presence.
- **Control UI + Canvas Host**: HTTP serves Control UI assets (default `/`, optional base path) and can host a livereload Canvas host for nodes (`src/canvas-host/server.ts`), injecting the A2UI postMessage bridge.
### Voice Wake reply routing
### iOS app (apps/ios)
- **Discovery + pairing**: Bonjour discovery via `BridgeDiscoveryModel` (NWBrowser). `BridgeConnectionController` autoconnects using Keychain token or allows manual host/port.
- **Node runtime**: `BridgeSession` (actor) maintains the `NWConnection`, hello handshake, ping/pong, RPC requests, and `invoke` callbacks.
- **Capabilities + commands**: advertises `canvas`, `screen`, `camera`, `voiceWake` (settingsdriven) and executes `canvas.*`, `canvas.a2ui.*`, `camera.*`, `screen.record` (`NodeAppModel.handleInvoke`).
- **Canvas**: `WKWebView` with bundled Canvas scaffold + A2UI, JS eval, snapshot capture, and `clawdbot://` deeplink interception (`ScreenController`).
- **Voice + deep links**: voice wake sends `voice.transcript` events; `clawdbot://agent` links emit `agent.request`. Voice wake triggers sync via `voicewake.get` + `voicewake.changed`.
Voice Wake sends messages into the `main` session and replies on the **last used surface**:
## Companion apps
- WhatsApp: last direct message you sent/received.
- Telegram: last DM chat id (bot mode).
- WebChat: last WebChat thread you used.
The **macOS app is critical**: it runs the menubar control plane, owns local permissions (TCC), hosts Voice Wake, exposes WebChat/debug tools, and coordinates local/remote gateway mode. Most “assistant” UX lives here.
If delivery fails (e.g. WhatsApp disconnected / Telegram token missing), Clawdis logs the error and you can still inspect the run via WebChat/session logs.
### macOS (Clawdbot.app)
Build/run the mac app with `./scripts/restart-mac.sh` (packages, installs, and launches), or `swift build --package-path apps/macos && open dist/Clawdis.app`.
- Menu bar control for the Gateway and health.
- Voice Wake + push-to-talk overlay.
- WebChat + debug tools.
- Remote gateway control over SSH.
Build/run: `./scripts/restart-mac.sh` (packages + launches).
### iOS node (internal)
The iOS node app is an internal/prototype app that connects as a **remote node**:
- Pairs as a node via the Bridge.
- Voice trigger forwarding + Canvas surface.
- Controlled via `clawdbot nodes …`.
- **Voice trigger:** forwards transcripts into the Gateway (agent runs + wakeups).
- **Canvas screen:** a WKWebView + `<canvas>` surface the agent can control (via `canvas.eval` / `canvas.snapshot` over `node.invoke`).
- **Discovery + pairing:** finds the bridge via Bonjour (`_clawdis-bridge._tcp`) and uses Gateway-owned pairing (`clawdis nodes pending|approve`); `clawdis nodes status` shows paired nodes + capabilities.
Runbook: `docs/ios/connect.md`.
Runbook: `docs/ios/connect.md`
### Android node (internal)
- Pairs via the same Bridge + pairing flow as iOS.
- Exposes Canvas, Camera, and Screen capture commands.
- Runbook: `docs/android/connect.md`.
## Agent workspace + skills
- Workspace root: `~/clawd` (configurable via `agent.workspace`).
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
- Skills: `~/clawd/skills/<skill>/SKILL.md`.
## Configuration
Create `~/.clawdis/clawdis.json`:
Minimal `~/.clawdbot/clawdbot.json`:
```json5
{
inbound: {
whatsapp: {
allowFrom: ["+1234567890"]
}
}
```
Optional: enable/configure clawds dedicated browser control (defaults are already on):
Env vars: loaded from `.env` in the current working directory, plus a global fallback at `~/.clawdbot/.env` (aka `$CLAWDBOT_STATE_DIR/.env`) without overriding existing values.
Optional: import missing keys from your login shell env (sources your shell profile) via config or env var:
```json5
{
env: {
shellEnv: {
enabled: true,
timeoutMs: 15000
}
}
}
```
- Env var: `CLAWDBOT_LOAD_SHELL_ENV=1`
- Timeout override: `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=15000`
- Behavior: only imports known/expected keys, never overrides existing `process.env`.
### WhatsApp
- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
### Telegram
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`), `telegram.allowFrom`, or `telegram.webhookUrl` as needed.
```json5
{
telegram: {
botToken: "123456:ABCDEF"
}
}
```
### Discord
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
- Optional: set `discord.slashCommand`, `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
```json5
{
discord: {
token: "1234abcd"
}
}
```
Browser control (optional):
```json5
{
@@ -166,99 +282,41 @@ Optional: enable/configure clawds dedicated browser control (defaults are alr
}
```
## Documentation
## Docs
- [Configuration Guide](./docs/configuration.md)
- [Gateway runbook](./docs/gateway.md)
- [Web surfaces (Control UI)](./docs/web.md)
- [Discovery + transports](./docs/discovery.md)
- [Bonjour / mDNS + Wide-Area Bonjour](./docs/bonjour.md)
- [Agent Runtime](./docs/agent.md)
- [Group Chats](./docs/group-messages.md)
- [Security](./docs/security.md)
- [Troubleshooting](./docs/troubleshooting.md)
- [The Lore](./docs/lore.md) 🦞
- [Telegram (Bot API)](./docs/telegram.md)
- [iOS node runbook](./docs/ios/connect.md)
- [macOS app spec](./docs/clawdis-mac.md)
- [`docs/index.md`](docs/index.md) (overview)
- [`docs/configuration.md`](docs/configuration.md)
- [`docs/group-messages.md`](docs/group-messages.md)
- [`docs/gateway.md`](docs/gateway.md)
- [`docs/web.md`](docs/web.md)
- [`docs/discovery.md`](docs/discovery.md)
- [`docs/agent.md`](docs/agent.md)
- [`docs/discord.md`](docs/discord.md)
- [`docs/wizard.md`](docs/wizard.md)
- Webhooks + external triggers: [`docs/webhook.md`](docs/webhook.md)
- Gmail hooks (email → wake): [`docs/gmail-pubsub.md`](docs/gmail-pubsub.md)
## Email hooks (Gmail)
```bash
clawdbot hooks gmail setup --account you@gmail.com
clawdbot hooks gmail run
```
- [`docs/security.md`](docs/security.md)
- [`docs/troubleshooting.md`](docs/troubleshooting.md)
- [`docs/ios/connect.md`](docs/ios/connect.md)
- [`docs/clawdbot-mac.md`](docs/clawdbot-mac.md)
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
AI/vibe-coded PRs welcome! 🤖
## Clawd
CLAWDIS was built for **Clawd**, a space lobster AI assistant. See the full setup in [`docs/clawd.md`](./docs/clawd.md).
Clawdbot was built for **Clawd**, a space lobster AI assistant.
- 🦞 **Clawd's Home:** [clawd.me](https://clawd.me)
- 📜 **Clawd's Soul:** [soul.md](https://soul.md)
- 👨‍💻 **Peter's Blog:** [steipete.me](https://steipete.me)
- 🐦 **Twitter:** [@steipete](https://twitter.com/steipete)
## Provider
If youre running from source, use `pnpm clawdis …` instead of `clawdis …`.
### WhatsApp Web
```bash
clawdis login # scan QR, store creds
clawdis gateway # run Gateway (WS on 127.0.0.1:18789)
```
### Telegram (Bot API)
Bot-mode support (grammY only) shares the same `main` session as WhatsApp/WebChat, with groups kept isolated. Text/media sends work via `clawdis send --provider telegram` (reads `TELEGRAM_BOT_TOKEN` or `telegram.botToken`). Webhook mode is supported; see `docs/telegram.md` for setup and limits.
## Commands
| Command | Description |
|---------|-------------|
| `clawdis login` | Link WhatsApp Web via QR |
| `clawdis send` | Send a message (WhatsApp default; `--provider telegram` for bot mode). WhatsApp sends go via the Gateway WS; Telegram sends are direct. |
| `clawdis agent` | Talk directly to the agent (no WhatsApp send) |
| `clawdis browser ...` | Manage clawds dedicated browser (status/tabs/open/screenshot). |
| `clawdis gateway` | Start the Gateway server (WS control plane). Params: `--port`, `--bind`, `--token`, `--force`, `--verbose`. |
| `clawdis gateway health|status|send|agent|call` | Gateway WS clients; assume a running gateway. |
| `clawdis wake` | Enqueue a system event and optionally trigger a heartbeat via the Gateway. |
| `clawdis cron ...` | Manage scheduled jobs (via Gateway). |
| `clawdis nodes ...` | Manage nodes (pairing + status) via the Gateway. |
| `clawdis status` | Web session health + session store summary |
| `clawdis health` | Reports cached provider state from the running gateway. |
#### Gateway client params (WS only)
- `--url` (default `ws://127.0.0.1:18789`)
- `--token` (shared secret if set on the gateway)
- `--timeout <ms>` (WS call timeout)
#### Send
- `--provider whatsapp|telegram` (default whatsapp)
- `--media <path-or-url>`
- `--json` for machine-readable output
#### Health
- Reads gateway/provider state (no direct Baileys socket from the CLI).
In chat, send `/status` to see if the agent is reachable, how much context the session has used, and the current thinking/verbose toggles—no agent call required.
`/status` also shows whether your WhatsApp web session is linked and how long ago the creds were refreshed so you know when to re-scan the QR.
### Sessions, surfaces, and WebChat
- Direct chats now share a canonical session key `main` by default (configurable via `inbound.session.mainKey`). Groups stay isolated as `group:<jid>`.
- WebChat attaches to `main` and hydrates history from `~/.clawdis/sessions/<SessionId>.jsonl`, so desktop view mirrors WhatsApp/Telegram turns.
- Inbound contexts carry a `Surface` hint (e.g., `whatsapp`, `webchat`, `telegram`) for logging; replies still go back to the originating surface deterministically.
- Every inbound message is wrapped for the agent as `[Surface FROM HOST/IP TIMESTAMP] body`:
- WhatsApp: `[WhatsApp +15551234567 2025-12-09 12:34] …`
- Telegram: `[Telegram Ada Lovelace (@ada_bot) id:123456789 2025-12-09 12:34] …`
- WebChat: `[WebChat my-mac.local 10.0.0.5 2025-12-09 12:34] …`
This keeps the model aware of the transport, sender, host, and time without relying on implicit context.
## Credits
- **Peter Steinberger** ([@steipete](https://twitter.com/steipete)) — Creator
- **Mario Zechner** ([@badlogicgames](https://twitter.com/badlogicgames)) — Pi, security testing
- **Clawd** 🦞 — The space lobster who demanded a better name
## License
MIT — Free as a lobster in the ocean.
---
*"We're all just playing with our own prompts."*
🦞💙
- https://clawd.me
- https://soul.md
- https://steipete.me
+11
View File
@@ -0,0 +1,11 @@
# Changelog
## 0.2.0 — 2025-12-23
### Highlights
- Added `SwabbleKit` (multi-platform wake-word gate utilities with segment-aware gap detection).
- Swabble package now supports iOS + macOS consumers; CLI remains macOS 26-only.
### Changes
- CLI wake-word matching/stripping routed through `SwabbleKit` helpers.
- Speech pipeline types now explicitly gated to macOS 26 / iOS 26 availability.
+5 -5
View File
@@ -1,13 +1,13 @@
{
"originHash" : "3018b2c8c183d55b57ad0c4526b2380ac3b957d13a3a86e1b2845e81323c443a",
"originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748",
"pins" : [
{
"identity" : "commander",
"identity" : "elevenlabskit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander.git",
"location" : "https://github.com/steipete/ElevenLabsKit",
"state" : {
"revision" : "8b8cb4f34315ce9e5307b3a2bcd77ff73f586a02",
"version" : "0.2.0"
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
"version" : "0.1.0"
}
},
{
+21 -2
View File
@@ -4,14 +4,16 @@ import PackageDescription
let package = Package(
name: "swabble",
platforms: [
.macOS(.v26),
.macOS(.v15),
.iOS(.v17),
],
products: [
.library(name: "Swabble", targets: ["Swabble"]),
.library(name: "SwabbleKit", targets: ["SwabbleKit"]),
.executable(name: "swabble", targets: ["SwabbleCLI"]),
],
dependencies: [
.package(url: "https://github.com/steipete/Commander.git", from: "0.2.0"),
.package(path: "../Peekaboo/Commander"),
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
],
targets: [
@@ -19,13 +21,30 @@ let package = Package(
name: "Swabble",
path: "Sources/SwabbleCore",
swiftSettings: []),
.target(
name: "SwabbleKit",
path: "Sources/SwabbleKit",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "SwabbleCLI",
dependencies: [
"Swabble",
"SwabbleKit",
.product(name: "Commander", package: "Commander"),
],
path: "Sources/swabble"),
.testTarget(
name: "SwabbleKitTests",
dependencies: [
"SwabbleKit",
.product(name: "Testing", package: "swift-testing"),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
]),
.testTarget(
name: "swabbleTests",
dependencies: [
+8 -4
View File
@@ -1,9 +1,10 @@
# 🎙️ swabble — Speech.framework wake-word hook daemon (macOS 26)
swabble is a Swift 6.2, macOS 26-only rewrite of the brabble voice daemon. It listens on your mic, gates on a wake word, transcribes locally using Apple's new SpeechAnalyzer + SpeechTranscriber, then fires a shell hook with the transcript. No cloud calls, no Whisper binaries.
swabble is a Swift 6.2 wake-word hook daemon. The CLI targets macOS 26 (SpeechAnalyzer + SpeechTranscriber). The shared `SwabbleKit` target is multi-platform and exposes wake-word gating utilities for iOS/macOS apps.
- **Local-only**: Speech.framework on-device models; zero network usage.
- **Wake word**: Default `clawd` (aliases `claude`), optional `--no-wake` bypass.
- **SwabbleKit**: Shared wake gate utilities (gap-based gating when you provide speech segments).
- **Hooks**: Run any command with prefix/env, cooldown, min_chars, timeout.
- **Services**: launchd helper stubs for start/stop/install.
- **File transcribe**: TXT or SRT with time ranges (using AttributedString splits).
@@ -30,7 +31,7 @@ swift run swabble transcribe /path/to/audio.m4a --format srt --output out.srt
```
## Use as a library
Add swabble as a SwiftPM dependency and import the `Swabble` product to reuse the Speech pipeline, config loader, hook executor, and transcript store in your own app:
Add swabble as a SwiftPM dependency and import the `Swabble` or `SwabbleKit` product:
```swift
// Package.swift
@@ -38,7 +39,10 @@ dependencies: [
.package(url: "https://github.com/steipete/swabble.git", branch: "main"),
],
targets: [
.target(name: "MyApp", dependencies: [.product(name: "Swabble", package: "swabble")]),
.target(name: "MyApp", dependencies: [
.product(name: "Swabble", package: "swabble"), // Speech pipeline (macOS 26+ / iOS 26+)
.product(name: "SwabbleKit", package: "swabble"), // Wake-word gate utilities (iOS 17+ / macOS 15+)
]),
]
```
@@ -93,7 +97,7 @@ Environment variables:
## Speech pipeline
- `AVAudioEngine` tap → `BufferConverter``AnalyzerInput``SpeechAnalyzer` with a `SpeechTranscriber` module.
- Requests volatile + final results; wake gating is string match on partial/final.
- Requests volatile + final results; the CLI uses text-only wake gating today.
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
## Development
@@ -2,11 +2,13 @@ import AVFoundation
import Foundation
import Speech
@available(macOS 26.0, iOS 26.0, *)
public struct SpeechSegment: Sendable {
public let text: String
public let isFinal: Bool
}
@available(macOS 26.0, iOS 26.0, *)
public enum SpeechPipelineError: Error {
case authorizationDenied
case analyzerFormatUnavailable
@@ -14,6 +16,7 @@ public enum SpeechPipelineError: Error {
}
/// Live microphone SpeechAnalyzer SpeechTranscriber pipeline.
@available(macOS 26.0, iOS 26.0, *)
public actor SpeechPipeline {
private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer }
@@ -0,0 +1,192 @@
import Foundation
public struct WakeWordSegment: Sendable, Equatable {
public let text: String
public let start: TimeInterval
public let duration: TimeInterval
public let range: Range<String.Index>?
public init(text: String, start: TimeInterval, duration: TimeInterval, range: Range<String.Index>? = nil) {
self.text = text
self.start = start
self.duration = duration
self.range = range
}
public var end: TimeInterval { self.start + self.duration }
}
public struct WakeWordGateConfig: Sendable, Equatable {
public var triggers: [String]
public var minPostTriggerGap: TimeInterval
public var minCommandLength: Int
public init(
triggers: [String],
minPostTriggerGap: TimeInterval = 0.45,
minCommandLength: Int = 1)
{
self.triggers = triggers
self.minPostTriggerGap = minPostTriggerGap
self.minCommandLength = minCommandLength
}
}
public struct WakeWordGateMatch: Sendable, Equatable {
public let triggerEndTime: TimeInterval
public let postGap: TimeInterval
public let command: String
public init(triggerEndTime: TimeInterval, postGap: TimeInterval, command: String) {
self.triggerEndTime = triggerEndTime
self.postGap = postGap
self.command = command
}
}
public enum WakeWordGate {
private struct Token {
let normalized: String
let start: TimeInterval
let end: TimeInterval
let range: Range<String.Index>?
let text: String
}
private struct TriggerTokens {
let tokens: [String]
}
public static func match(
transcript: String,
segments: [WakeWordSegment],
config: WakeWordGateConfig)
-> WakeWordGateMatch? {
let triggerTokens = self.normalizeTriggers(config.triggers)
guard !triggerTokens.isEmpty else { return nil }
let tokens = self.normalizeSegments(segments)
guard !tokens.isEmpty else { return nil }
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
for trigger in triggerTokens {
let count = trigger.tokens.count
guard count > 0, tokens.count > count else { continue }
for i in 0...(tokens.count - count - 1) {
let matched = (0..<count).allSatisfy { tokens[i + $0].normalized == trigger.tokens[$0] }
if !matched { continue }
let triggerEnd = tokens[i + count - 1].end
let nextToken = tokens[i + count]
let gap = nextToken.start - triggerEnd
if gap < config.minPostTriggerGap { continue }
if let best, i <= best.index { continue }
best = (i, triggerEnd, gap)
}
}
guard let best else { return nil }
let command = self.commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
guard command.count >= config.minCommandLength else { return nil }
return WakeWordGateMatch(triggerEndTime: best.triggerEnd, postGap: best.gap, command: command)
}
public static func commandText(
transcript: String,
segments: [WakeWordSegment],
triggerEndTime: TimeInterval)
-> String {
let threshold = triggerEndTime + 0.001
for segment in segments where segment.start >= threshold {
if normalizeToken(segment.text).isEmpty { continue }
if let range = segment.range {
let slice = transcript[range.lowerBound...]
return String(slice).trimmingCharacters(in: Self.whitespaceAndPunctuation)
}
break
}
let text = segments
.filter { $0.start >= threshold && !self.normalizeToken($0.text).isEmpty }
.map(\.text)
.joined(separator: " ")
return text.trimmingCharacters(in: Self.whitespaceAndPunctuation)
}
public static func matchesTextOnly(text: String, triggers: [String]) -> Bool {
guard !text.isEmpty else { return false }
let normalized = text.lowercased()
for trigger in triggers {
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation).lowercased()
if token.isEmpty { continue }
if normalized.contains(token) { return true }
}
return false
}
public static func stripWake(text: String, triggers: [String]) -> String {
var out = text
for trigger in triggers {
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation)
guard !token.isEmpty else { continue }
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
}
return out.trimmingCharacters(in: self.whitespaceAndPunctuation)
}
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
var output: [TriggerTokens] = []
for trigger in triggers {
let tokens = trigger
.split(whereSeparator: { $0.isWhitespace })
.map { self.normalizeToken(String($0)) }
.filter { !$0.isEmpty }
if tokens.isEmpty { continue }
output.append(TriggerTokens(tokens: tokens))
}
return output
}
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
segments.compactMap { segment in
let normalized = self.normalizeToken(segment.text)
guard !normalized.isEmpty else { return nil }
return Token(
normalized: normalized,
start: segment.start,
end: segment.end,
range: segment.range,
text: segment.text)
}
}
private static func normalizeToken(_ token: String) -> String {
token
.trimmingCharacters(in: self.whitespaceAndPunctuation)
.lowercased()
}
private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines
.union(.punctuationCharacters)
}
#if canImport(Speech)
import Speech
public enum WakeWordSpeechSegments {
public static func from(transcription: SFTranscription, transcript: String) -> [WakeWordSegment] {
transcription.segments.map { segment in
let range = Range(segment.substringRange, in: transcript)
return WakeWordSegment(
text: segment.substring,
start: segment.timestamp,
duration: segment.duration,
range: range)
}
}
}
#endif
@@ -1,6 +1,7 @@
import Commander
import Foundation
@available(macOS 26.0, *)
@MainActor
enum CLIRegistry {
static var descriptors: [CommandDescriptor] {
@@ -1,7 +1,9 @@
import Commander
import Foundation
import Swabble
import SwabbleKit
@available(macOS 26.0, *)
@MainActor
struct ServeCommand: ParsableCommand {
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
@@ -68,17 +70,12 @@ struct ServeCommand: ParsableCommand {
}
private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool {
let lowered = text.lowercased()
if lowered.contains(cfg.wake.word.lowercased()) { return true }
return cfg.wake.aliases.contains(where: { lowered.contains($0.lowercased()) })
let triggers = [cfg.wake.word] + cfg.wake.aliases
return WakeWordGate.matchesTextOnly(text: text, triggers: triggers)
}
private static func stripWake(text: String, cfg: SwabbleConfig) -> String {
var out = text
out = out.replacingOccurrences(of: cfg.wake.word, with: "", options: [.caseInsensitive])
for alias in cfg.wake.aliases {
out = out.replacingOccurrences(of: alias, with: "", options: [.caseInsensitive])
}
return out.trimmingCharacters(in: .whitespacesAndNewlines)
let triggers = [cfg.wake.word] + cfg.wake.aliases
return WakeWordGate.stripWake(text: text, triggers: triggers)
}
}
+9 -2
View File
@@ -1,6 +1,7 @@
import Commander
import Foundation
@available(macOS 26.0, *)
@MainActor
private func runCLI() async -> Int32 {
do {
@@ -15,6 +16,7 @@ private func runCLI() async -> Int32 {
}
}
@available(macOS 26.0, *)
@MainActor
private func dispatch(invocation: CommandInvocation) async throws {
let parsed = invocation.parsedValues
@@ -95,5 +97,10 @@ private func dispatch(invocation: CommandInvocation) async throws {
}
}
let exitCode = await runCLI()
exit(exitCode)
if #available(macOS 26.0, *) {
let exitCode = await runCLI()
exit(exitCode)
} else {
fputs("error: swabble requires macOS 26 or newer\n", stderr)
exit(1)
}
@@ -0,0 +1,63 @@
import Foundation
import Testing
import SwabbleKit
@Suite struct WakeWordGateTests {
@Test func matchRequiresGapAfterTrigger() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.35, 0.1),
("thing", 0.5, 0.1),
])
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil)
}
@Test func matchAllowsGapAndExtractsCommand() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.9, 0.1),
("thing", 1.1, 0.1),
])
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
#expect(match?.command == "do thing")
}
@Test func matchHandlesMultiWordTriggers() {
let transcript = "hey clawd do it"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.8, 0.1),
("it", 1.0, 0.1),
])
let config = WakeWordGateConfig(triggers: ["hey clawd"], minPostTriggerGap: 0.3)
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
#expect(match?.command == "do it")
}
}
private func makeSegments(
transcript: String,
words: [(String, TimeInterval, TimeInterval)])
-> [WakeWordSegment] {
var searchStart = transcript.startIndex
var output: [WakeWordSegment] = []
for (word, start, duration) in words {
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
if let range { searchStart = range.upperBound }
}
return output
}
+4 -3
View File
@@ -1,11 +1,12 @@
# swabble — macOS 26 speech hook daemon (Swift 6.2)
Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript.
Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript. Shared wake-gate utilities live in `SwabbleKit` for reuse by other apps (iOS/macOS).
## Requirements
- macOS 26+, Swift 6.2, Speech.framework with on-device assets.
- Local only; no network calls during transcription.
- Wake word gating (default "clawd" plus aliases) with bypass flag `--no-wake`.
- `SwabbleKit` target (multi-platform) providing wake-word gating helpers that can use speech segment timing to require a post-trigger gap.
- Hook execution with cooldown, min_chars, timeout, prefix, env vars.
- Simple config at `~/.config/swabble/config.json` (JSON, Codable) — no TOML.
- CLI implemented with Commander (SwiftPM package `steipete/Commander`); core types are available via the SwiftPM library product `Swabble` for embedding.
@@ -17,7 +18,7 @@ Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framewo
- **CLI layer (Commander)**: Root command `swabble` with subcommands `serve`, `transcribe`, `test-hook`, `mic list|set`, `doctor`, `health`, `tail-log`. Runtime flags from Commander (`-v/--verbose`, `--json-output`, `--log-level`). Custom `--config` path applies everywhere.
- **Config**: `SwabbleConfig` Codable. Fields: audio device name/index, wake (enabled/word/aliases/sensitivity placeholder), hook (command/args/prefix/cooldown/min_chars/timeout/env), logging (level, format), transcripts (enabled, max kept), speech (locale, enableEtiquetteReplacements flag). Stored JSON; default written by `setup`.
- **Audio + Speech pipeline**: `SpeechPipeline` wraps `AVAudioEngine` input → `SpeechAnalyzer` with `SpeechTranscriber` module. Emits partial/final transcripts via async stream. Requests `.audioTimeRange` when transcripts enabled. Handles Speech permission and asset download prompts ahead of capture.
- **Wake gate**: text-based keyword match against latest partial/final; strips wake term before hook dispatch. `--no-wake` disables.
- **Wake gate**: CLI currently uses text-only keyword match; shared `SwabbleKit` gate can enforce a minimum pause between the wake word and the next token when speech segments are available. `--no-wake` disables gating.
- **Hook executor**: async `HookExecutor` spawns `Process` with configured args, prefix substitution `${hostname}`. Enforces cooldown + timeout; injects env `SWABBLE_TEXT`, `SWABBLE_PREFIX` plus user env map.
- **Transcripts store**: in-memory ring buffer; optional persisted JSON lines under `~/Library/Application Support/swabble/transcripts.log`.
- **Logging**: simple structured logger to stderr; respects log level.
@@ -25,7 +26,7 @@ Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framewo
## Out of scope (initial cut)
- Model management (Speech handles assets).
- Launchd helper (planned follow-up).
- Advanced wake-word detector (text match only for now).
- Advanced wake-word detector (segment-aware gate now lives in `SwabbleKit`; CLI still text-only until segment timing is plumbed through).
## Open decisions
- Whether to expose a UNIX control socket for `status`/`health` (currently planned as stdin/out direct calls).
+56 -21
View File
@@ -1,21 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"
xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>Clawdis Updates</title>
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
<description>Signed update feed for the Clawdis macOS companion app.</description>
<item>
<title>Clawdis 2.0.0-beta1</title>
<sparkle:releaseNotesLink>https://github.com/steipete/clawdis/releases/tag/v2.0.0-beta1</sparkle:releaseNotesLink>
<pubDate>Fri, 19 Dec 2025 17:19:50 +0000</pubDate>
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta1/Clawdis-2.0.0-beta1.zip"
sparkle:edSignature="oEpGD46U4ZyBBSY9/piUIFDJU+KlFB751JIWOW2yS0sRNHKszyG5khDHg9o9bV9Zo8DOCNF/HOi88jmtHJAaCQ=="
sparkle:version="2.0.0-beta1"
sparkle:shortVersionString="2.0.0-beta1"
length="72410016"
type="application/octet-stream" />
</item>
</channel>
</rss>
<?xml version="1.0" standalone="yes"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdis</title>
<item>
<title>2026.1.5-3</title>
<pubDate>Mon, 05 Jan 2026 03:57:59 +0100</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>3091</sparkle:version>
<sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
<h3>Fixes</h3>
<ul>
<li>NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid <code>ERR_MODULE_NOT_FOUND</code> in Node 25 npx 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.5-3/Clawdbot-2026.1.5-3.zip" length="160797048" type="application/octet-stream" sparkle:edSignature="5KYFg0SW7liwLxLJbfzd2KsAxbX06gMH0rH/W3a4V0p4N48hjz4AsSrfFLdGZSnW+6XaJjC3MN6Ynh+l7kffDQ=="/>
</item>
<item>
<title>2026.1.5-2</title>
<pubDate>Mon, 05 Jan 2026 03:51:30 +0100</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>3089</sparkle:version>
<sparkle:shortVersionString>2026.1.5-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.5-2</h2>
<h3>Fixes</h3>
<ul>
<li>NPM package: include <code>dist/sessions</code> so <code>clawdbot agent</code> resolves session helpers in npx installs.</li>
<li>Node 25: avoid unsupported directory import by targeting <code>qrcode-terminal/vendor/QRCode/*.js</code> modules.</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.5-2/Clawdbot-2026.1.5-2.zip" length="150250417" type="application/octet-stream" sparkle:edSignature="ntHNmwyHrv6cPk6NAKOT3AUkwdt5ZadrGU6mJK4GmVxi44uIMT3ZXluvnqK9SxXQwA0H0dXjiGMS/cg8NbgqDA=="/>
</item>
<item>
<title>2026.1.5-1</title>
<pubDate>Mon, 05 Jan 2026 03:32:13 +0100</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>3087</sparkle:version>
<sparkle:shortVersionString>2026.1.5-1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.5-1</h2>
<h3>Fixes</h3>
<ul>
<li>NPM package: include <code>dist/sessions</code> so <code>clawdbot agent</code> resolves session helpers in npx installs.</li>
<li>Node 25: avoid unsupported directory import by targeting <code>qrcode-terminal/vendor/QRCode/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.5-1/Clawdbot-2026.1.5-1.zip" length="150253493" type="application/octet-stream" sparkle:edSignature="gmb6ubX9AobT8OjraFY2zrS7qch050lSWA0Wuwzr68/kcCySG+GXzxSqvFp2DHTAMH3OsvkiCqQtET8qFuv9DQ=="/>
</item>
</channel>
</rss>
+5 -5
View File
@@ -1,6 +1,6 @@
## Clawdis Node (Android) (internal)
## Clawdbot Node (Android) (internal)
Modern Android node app: connects to the **Gateway-owned bridge** (`_clawdis-bridge._tcp`) over TCP and exposes **Canvas + Chat + Camera**.
Modern Android node app: connects to the **Gateway-owned bridge** (`_clawdbot-bridge._tcp`) over TCP and exposes **Canvas + Chat + Camera**.
Notes:
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
@@ -25,7 +25,7 @@ cd apps/android
1) Start the gateway (on your “master” machine):
```bash
pnpm clawdis gateway --port 18789 --verbose
pnpm clawdbot gateway --port 18789 --verbose
```
2) In the Android app:
@@ -34,8 +34,8 @@ pnpm clawdis gateway --port 18789 --verbose
3) Approve pairing (on the gateway machine):
```bash
clawdis nodes pending
clawdis nodes approve <requestId>
clawdbot nodes pending
clawdbot nodes approve <requestId>
```
More details: `docs/android/connect.md`.
+29 -9
View File
@@ -6,21 +6,21 @@ plugins {
}
android {
namespace = "com.steipete.clawdis.node"
namespace = "com.clawdbot.android"
compileSdk = 36
sourceSets {
getByName("main") {
assets.srcDir(file("../../shared/ClawdisKit/Sources/ClawdisKit/Resources"))
assets.srcDir(file("../../shared/ClawdbotKit/Sources/ClawdbotKit/Resources"))
}
}
defaultConfig {
applicationId = "com.steipete.clawdis.node"
applicationId = "com.clawdbot.android"
minSdk = 31
targetSdk = 36
versionCode = 1
versionName = "0.1"
versionName = "2.0.0-beta3"
}
buildTypes {
@@ -31,6 +31,7 @@ android {
buildFeatures {
compose = true
buildConfig = true
}
compileOptions {
@@ -38,15 +39,25 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
lint {
disable += setOf("IconLauncherShape")
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
dependencies {
@@ -56,7 +67,8 @@ dependencies {
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.activity:activity-compose:1.12.1")
implementation("androidx.activity:activity-compose:1.12.2")
implementation("androidx.webkit:webkit:1.15.0")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
@@ -86,4 +98,12 @@ dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
testImplementation("org.robolectric:robolectric:4.16")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.1")
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
+13 -4
View File
@@ -9,21 +9,30 @@
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<application
android:name=".NodeApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.ClawdisNode">
android:theme="@style/Theme.ClawdbotNode">
<service
android:name=".NodeForegroundService"
android:exported="false"
@@ -0,0 +1,197 @@
{
"version": 1,
"fallback": {
"emoji": "🧩",
"detailKeys": [
"command",
"path",
"url",
"targetUrl",
"targetId",
"ref",
"element",
"node",
"nodeId",
"jobId",
"requestId",
"to",
"channelId",
"guildId",
"userId",
"name",
"query",
"pattern",
"messageId"
]
},
"tools": {
"bash": {
"emoji": "🛠️",
"title": "Bash",
"detailKeys": ["command"]
},
"process": {
"emoji": "🧰",
"title": "Process",
"detailKeys": ["sessionId"]
},
"read": {
"emoji": "📖",
"title": "Read",
"detailKeys": ["path"]
},
"write": {
"emoji": "✍️",
"title": "Write",
"detailKeys": ["path"]
},
"edit": {
"emoji": "📝",
"title": "Edit",
"detailKeys": ["path"]
},
"attach": {
"emoji": "📎",
"title": "Attach",
"detailKeys": ["path", "url", "fileName"]
},
"browser": {
"emoji": "🌐",
"title": "Browser",
"actions": {
"status": { "label": "status" },
"start": { "label": "start" },
"stop": { "label": "stop" },
"tabs": { "label": "tabs" },
"open": { "label": "open", "detailKeys": ["targetUrl"] },
"focus": { "label": "focus", "detailKeys": ["targetId"] },
"close": { "label": "close", "detailKeys": ["targetId"] },
"snapshot": {
"label": "snapshot",
"detailKeys": ["targetUrl", "targetId", "ref", "element", "format"]
},
"screenshot": {
"label": "screenshot",
"detailKeys": ["targetUrl", "targetId", "ref", "element"]
},
"navigate": {
"label": "navigate",
"detailKeys": ["targetUrl", "targetId"]
},
"console": { "label": "console", "detailKeys": ["level", "targetId"] },
"pdf": { "label": "pdf", "detailKeys": ["targetId"] },
"upload": {
"label": "upload",
"detailKeys": ["paths", "ref", "inputRef", "element", "targetId"]
},
"dialog": {
"label": "dialog",
"detailKeys": ["accept", "promptText", "targetId"]
},
"act": {
"label": "act",
"detailKeys": ["request.kind", "request.ref", "request.selector", "request.text", "request.value"]
}
}
},
"canvas": {
"emoji": "🖼️",
"title": "Canvas",
"actions": {
"present": { "label": "present", "detailKeys": ["target", "node", "nodeId"] },
"hide": { "label": "hide", "detailKeys": ["node", "nodeId"] },
"navigate": { "label": "navigate", "detailKeys": ["url", "node", "nodeId"] },
"eval": { "label": "eval", "detailKeys": ["javaScript", "node", "nodeId"] },
"snapshot": { "label": "snapshot", "detailKeys": ["format", "node", "nodeId"] },
"a2ui_push": { "label": "A2UI push", "detailKeys": ["jsonlPath", "node", "nodeId"] },
"a2ui_reset": { "label": "A2UI reset", "detailKeys": ["node", "nodeId"] }
}
},
"nodes": {
"emoji": "📱",
"title": "Nodes",
"actions": {
"status": { "label": "status" },
"describe": { "label": "describe", "detailKeys": ["node", "nodeId"] },
"pending": { "label": "pending" },
"approve": { "label": "approve", "detailKeys": ["requestId"] },
"reject": { "label": "reject", "detailKeys": ["requestId"] },
"notify": { "label": "notify", "detailKeys": ["node", "nodeId", "title", "body"] },
"camera_snap": { "label": "camera snap", "detailKeys": ["node", "nodeId", "facing", "deviceId"] },
"camera_list": { "label": "camera list", "detailKeys": ["node", "nodeId"] },
"camera_clip": { "label": "camera clip", "detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"] },
"screen_record": {
"label": "screen record",
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
}
}
},
"cron": {
"emoji": "⏰",
"title": "Cron",
"actions": {
"status": { "label": "status" },
"list": { "label": "list" },
"add": {
"label": "add",
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
},
"update": { "label": "update", "detailKeys": ["jobId"] },
"remove": { "label": "remove", "detailKeys": ["jobId"] },
"run": { "label": "run", "detailKeys": ["jobId"] },
"runs": { "label": "runs", "detailKeys": ["jobId"] },
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
}
},
"gateway": {
"emoji": "🔌",
"title": "Gateway",
"actions": {
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }
}
},
"whatsapp_login": {
"emoji": "🟢",
"title": "WhatsApp Login",
"actions": {
"start": { "label": "start" },
"wait": { "label": "wait" }
}
},
"discord": {
"emoji": "💬",
"title": "Discord",
"actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] },
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
"sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] },
"poll": { "label": "poll", "detailKeys": ["question", "to"] },
"permissions": { "label": "permissions", "detailKeys": ["channelId"] },
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
"threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] },
"threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] },
"threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] },
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
"searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] },
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
"emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
"channelList": { "label": "channels", "detailKeys": ["guildId"] },
"voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] },
"eventList": { "label": "events", "detailKeys": ["guildId"] },
"eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] },
"timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] },
"kick": { "label": "kick", "detailKeys": ["guildId", "userId"] },
"ban": { "label": "ban", "detailKeys": ["guildId", "userId"] }
}
}
}
}
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node
package com.clawdbot.android
enum class CameraHudKind {
Photo,
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node
package com.clawdbot.android
import android.content.Context
import android.os.Build
@@ -0,0 +1,15 @@
package com.clawdbot.android
enum class LocationMode(val rawValue: String) {
Off("off"),
WhileUsing("whileUsing"),
Always("always"),
;
companion object {
fun fromRawValue(raw: String?): LocationMode {
val normalized = raw?.trim()?.lowercase()
return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off
}
}
}
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node
package com.clawdbot.android
import android.Manifest
import android.content.pm.ApplicationInfo
@@ -18,8 +18,8 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.steipete.clawdis.node.ui.RootScreen
import com.steipete.clawdis.node.ui.ClawdisTheme
import com.clawdbot.android.ui.RootScreen
import com.clawdbot.android.ui.ClawdbotTheme
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
@@ -39,6 +39,7 @@ class MainActivity : ComponentActivity() {
screenCaptureRequester = ScreenCaptureRequester(this)
viewModel.camera.attachLifecycleOwner(this)
viewModel.camera.attachPermissionRequester(permissionRequester)
viewModel.sms.attachPermissionRequester(permissionRequester)
viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester)
viewModel.screenRecorder.attachPermissionRequester(permissionRequester)
@@ -55,7 +56,7 @@ class MainActivity : ComponentActivity() {
}
setContent {
ClawdisTheme {
ClawdbotTheme {
Surface(modifier = Modifier) {
RootScreen(viewModel = viewModel)
}
@@ -1,12 +1,13 @@
package com.steipete.clawdis.node
package com.clawdbot.android
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import com.steipete.clawdis.node.bridge.BridgeEndpoint
import com.steipete.clawdis.node.chat.OutgoingAttachment
import com.steipete.clawdis.node.node.CameraCaptureManager
import com.steipete.clawdis.node.node.CanvasController
import com.steipete.clawdis.node.node.ScreenRecordManager
import com.clawdbot.android.bridge.BridgeEndpoint
import com.clawdbot.android.chat.OutgoingAttachment
import com.clawdbot.android.node.CameraCaptureManager
import com.clawdbot.android.node.CanvasController
import com.clawdbot.android.node.ScreenRecordManager
import com.clawdbot.android.node.SmsManager
import kotlinx.coroutines.flow.StateFlow
class MainViewModel(app: Application) : AndroidViewModel(app) {
@@ -15,6 +16,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val canvas: CanvasController = runtime.canvas
val camera: CameraCaptureManager = runtime.camera
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
val sms: SmsManager = runtime.sms
val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
@@ -23,21 +25,31 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val statusText: StateFlow<String> = runtime.statusText
val serverName: StateFlow<String?> = runtime.serverName
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
val isForeground: StateFlow<Boolean> = runtime.isForeground
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
val screenRecordActive: StateFlow<Boolean> = runtime.screenRecordActive
val instanceId: StateFlow<String> = runtime.instanceId
val displayName: StateFlow<String> = runtime.displayName
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
val locationMode: StateFlow<LocationMode> = runtime.locationMode
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
val preventSleep: StateFlow<Boolean> = runtime.preventSleep
val wakeWords: StateFlow<List<String>> = runtime.wakeWords
val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode
val voiceWakeStatusText: StateFlow<String> = runtime.voiceWakeStatusText
val voiceWakeIsListening: StateFlow<Boolean> = runtime.voiceWakeIsListening
val talkEnabled: StateFlow<Boolean> = runtime.talkEnabled
val talkStatusText: StateFlow<String> = runtime.talkStatusText
val talkIsListening: StateFlow<Boolean> = runtime.talkIsListening
val talkIsSpeaking: StateFlow<Boolean> = runtime.talkIsSpeaking
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
val manualHost: StateFlow<String> = runtime.manualHost
val manualPort: StateFlow<Int> = runtime.manualPort
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
val chatSessionId: StateFlow<String?> = runtime.chatSessionId
@@ -62,6 +74,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setCameraEnabled(value)
}
fun setLocationMode(mode: LocationMode) {
runtime.setLocationMode(mode)
}
fun setLocationPreciseEnabled(value: Boolean) {
runtime.setLocationPreciseEnabled(value)
}
fun setPreventSleep(value: Boolean) {
runtime.setPreventSleep(value)
}
@@ -78,6 +98,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setManualPort(value)
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
runtime.setCanvasDebugStatusEnabled(value)
}
fun setWakeWords(words: List<String>) {
runtime.setWakeWords(words)
}
@@ -90,6 +114,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setVoiceWakeMode(mode)
}
fun setTalkEnabled(enabled: Boolean) {
runtime.setTalkEnabled(enabled)
}
fun refreshBridgeHello() {
runtime.refreshBridgeHello()
}
fun connect(endpoint: BridgeEndpoint) {
runtime.connect(endpoint)
}
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node
package com.clawdbot.android
import android.app.Application
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node
package com.clawdbot.android
import android.app.Notification
import android.app.NotificationChannel
@@ -10,7 +10,6 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
@@ -30,7 +29,7 @@ class NodeForegroundService : Service() {
override fun onCreate() {
super.onCreate()
ensureChannel()
val initial = buildNotification(title = "Clawdis Node", text = "Starting…")
val initial = buildNotification(title = "Clawdbot Node", text = "Starting…")
startForegroundWithTypes(notification = initial, requiresMic = false)
val runtime = (application as NodeApp).runtime
@@ -45,7 +44,7 @@ class NodeForegroundService : Service() {
) { status, server, connected, voiceMode, voiceListening ->
Quint(status, server, connected, voiceMode, voiceListening)
}.collect { (status, server, connected, voiceMode, voiceListening) ->
val title = if (connected) "Clawdis Node · Connected" else "Clawdis Node"
val title = if (connected) "Clawdbot Node · Connected" else "Clawdbot Node"
val voiceSuffix =
if (voiceMode == VoiceWakeMode.Always) {
if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused"
@@ -85,7 +84,6 @@ class NodeForegroundService : Service() {
override fun onBind(intent: Intent?) = null
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = getSystemService(NotificationManager::class.java)
val channel =
NotificationChannel(
@@ -93,26 +91,38 @@ class NodeForegroundService : Service() {
"Connection",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Clawdis node connection status"
description = "Clawdbot node connection status"
setShowBadge(false)
}
mgr.createNotificationChannel(channel)
}
private fun buildNotification(title: String, text: String): Notification {
val launchIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val launchPending =
PendingIntent.getActivity(
this,
1,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
val flags =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags)
val stopPending =
PendingIntent.getService(
this,
2,
stopIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(text)
.setContentIntent(launchPending)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
@@ -126,11 +136,6 @@ class NodeForegroundService : Service() {
}
private fun startForegroundWithTypes(notification: Notification, requiresMic: Boolean) {
if (Build.VERSION.SDK_INT < 29) {
startForeground(NOTIFICATION_ID, notification)
return
}
if (didStartForeground && requiresMic == lastRequiresMic) {
updateNotification(notification)
return
@@ -158,15 +163,11 @@ class NodeForegroundService : Service() {
private const val CHANNEL_ID = "connection"
private const val NOTIFICATION_ID = 1
private const val ACTION_STOP = "com.steipete.clawdis.node.action.STOP"
private const val ACTION_STOP = "com.clawdbot.android.action.STOP"
fun start(context: Context) {
val intent = Intent(context, NodeForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
context.startForegroundService(intent)
}
fun stop(context: Context) {
@@ -1,34 +1,42 @@
package com.steipete.clawdis.node
package com.clawdbot.android
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.LocationManager
import android.os.Build
import android.os.SystemClock
import androidx.core.content.ContextCompat
import com.steipete.clawdis.node.chat.ChatController
import com.steipete.clawdis.node.chat.ChatMessage
import com.steipete.clawdis.node.chat.ChatPendingToolCall
import com.steipete.clawdis.node.chat.ChatSessionEntry
import com.steipete.clawdis.node.chat.OutgoingAttachment
import com.steipete.clawdis.node.bridge.BridgeDiscovery
import com.steipete.clawdis.node.bridge.BridgeEndpoint
import com.steipete.clawdis.node.bridge.BridgePairingClient
import com.steipete.clawdis.node.bridge.BridgeSession
import com.steipete.clawdis.node.node.CameraCaptureManager
import com.steipete.clawdis.node.node.CanvasController
import com.steipete.clawdis.node.node.ScreenRecordManager
import com.steipete.clawdis.node.protocol.ClawdisCapability
import com.steipete.clawdis.node.protocol.ClawdisCameraCommand
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UIAction
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UICommand
import com.steipete.clawdis.node.protocol.ClawdisCanvasCommand
import com.steipete.clawdis.node.protocol.ClawdisScreenCommand
import com.steipete.clawdis.node.voice.VoiceWakeManager
import com.clawdbot.android.chat.ChatController
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.node.CameraCaptureManager
import com.clawdbot.android.node.LocationCaptureManager
import com.clawdbot.android.BuildConfig
import com.clawdbot.android.node.CanvasController
import com.clawdbot.android.node.ScreenRecordManager
import com.clawdbot.android.node.SmsManager
import com.clawdbot.android.protocol.ClawdbotCapability
import com.clawdbot.android.protocol.ClawdbotCameraCommand
import com.clawdbot.android.protocol.ClawdbotCanvasA2UIAction
import com.clawdbot.android.protocol.ClawdbotCanvasA2UICommand
import com.clawdbot.android.protocol.ClawdbotCanvasCommand
import com.clawdbot.android.protocol.ClawdbotScreenCommand
import com.clawdbot.android.protocol.ClawdbotLocationCommand
import com.clawdbot.android.protocol.ClawdbotSmsCommand
import com.clawdbot.android.voice.TalkModeManager
import com.clawdbot.android.voice.VoiceWakeManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -53,7 +61,9 @@ class NodeRuntime(context: Context) {
val prefs = SecurePrefs(appContext)
val canvas = CanvasController()
val camera = CameraCaptureManager(appContext)
val location = LocationCaptureManager(appContext)
val screenRecorder = ScreenRecordManager(appContext)
val sms = SmsManager(appContext)
private val json = Json { ignoreUnknownKeys = true }
private val externalAudioCaptureActive = MutableStateFlow(false)
@@ -68,7 +78,7 @@ class NodeRuntime(context: Context) {
payloadJson =
buildJsonObject {
put("message", JsonPrimitive(command))
put("sessionKey", JsonPrimitive("main"))
put("sessionKey", JsonPrimitive(mainSessionKey.value))
put("thinking", JsonPrimitive(chatThinkingLevel.value))
put("deliver", JsonPrimitive(false))
}.toString(),
@@ -83,6 +93,15 @@ class NodeRuntime(context: Context) {
val voiceWakeStatusText: StateFlow<String>
get() = voiceWake.statusText
val talkStatusText: StateFlow<String>
get() = talkMode.statusText
val talkIsListening: StateFlow<Boolean>
get() = talkMode.isListening
val talkIsSpeaking: StateFlow<Boolean>
get() = talkMode.isSpeaking
private val discovery = BridgeDiscovery(appContext, scope = scope)
val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
val discoveryStatusText: StateFlow<String> = discovery.statusText
@@ -93,6 +112,9 @@ class NodeRuntime(context: Context) {
private val _statusText = MutableStateFlow("Offline")
val statusText: StateFlow<String> = _statusText.asStateFlow()
private val _mainSessionKey = MutableStateFlow("main")
val mainSessionKey: StateFlow<String> = _mainSessionKey.asStateFlow()
private val cameraHudSeq = AtomicLong(0)
private val _cameraHud = MutableStateFlow<CameraHudState?>(null)
val cameraHud: StateFlow<CameraHudState?> = _cameraHud.asStateFlow()
@@ -100,15 +122,23 @@ class NodeRuntime(context: Context) {
private val _cameraFlashToken = MutableStateFlow(0L)
val cameraFlashToken: StateFlow<Long> = _cameraFlashToken.asStateFlow()
private val _screenRecordActive = MutableStateFlow(false)
val screenRecordActive: StateFlow<Boolean> = _screenRecordActive.asStateFlow()
private val _serverName = MutableStateFlow<String?>(null)
val serverName: StateFlow<String?> = _serverName.asStateFlow()
private val _remoteAddress = MutableStateFlow<String?>(null)
val remoteAddress: StateFlow<String?> = _remoteAddress.asStateFlow()
private val _seamColorArgb = MutableStateFlow(DEFAULT_SEAM_COLOR_ARGB)
val seamColorArgb: StateFlow<Long> = _seamColorArgb.asStateFlow()
private val _isForeground = MutableStateFlow(true)
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
private var lastAutoA2uiUrl: String? = null
private val session =
BridgeSession(
scope = scope,
@@ -117,7 +147,10 @@ class NodeRuntime(context: Context) {
_serverName.value = name
_remoteAddress.value = remote
_isConnected.value = true
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
scope.launch { refreshBrandingFromGateway() }
scope.launch { refreshWakeWordsFromGateway() }
maybeNavigateToA2uiOnConnect()
},
onDisconnected = { message -> handleSessionDisconnected(message) },
onEvent = { event, payloadJson ->
@@ -129,25 +162,49 @@ class NodeRuntime(context: Context) {
)
private val chat = ChatController(scope = scope, session = session, json = json)
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
_mainSessionKey.value = "main"
chat.onDisconnected(message)
showLocalCanvasOnDisconnect()
}
private fun maybeNavigateToA2uiOnConnect() {
val a2uiUrl = resolveA2uiHostUrl() ?: return
val current = canvas.currentUrl()?.trim().orEmpty()
if (current.isEmpty() || current == lastAutoA2uiUrl) {
lastAutoA2uiUrl = a2uiUrl
canvas.navigate(a2uiUrl)
}
}
private fun showLocalCanvasOnDisconnect() {
lastAutoA2uiUrl = null
canvas.navigate("")
}
val instanceId: StateFlow<String> = prefs.instanceId
val displayName: StateFlow<String> = prefs.displayName
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
val locationMode: StateFlow<LocationMode> = prefs.locationMode
val locationPreciseEnabled: StateFlow<Boolean> = prefs.locationPreciseEnabled
val preventSleep: StateFlow<Boolean> = prefs.preventSleep
val wakeWords: StateFlow<List<String>> = prefs.wakeWords
val voiceWakeMode: StateFlow<VoiceWakeMode> = prefs.voiceWakeMode
val talkEnabled: StateFlow<Boolean> = prefs.talkEnabled
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
val manualHost: StateFlow<String> = prefs.manualHost
val manualPort: StateFlow<Int> = prefs.manualPort
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
private var didAutoConnect = false
private var suppressWakeWordsSync = false
@@ -198,6 +255,13 @@ class NodeRuntime(context: Context) {
}
}
scope.launch {
talkEnabled.collect { enabled ->
talkMode.setEnabled(enabled)
externalAudioCaptureActive.value = enabled
}
}
scope.launch(Dispatchers.Default) {
bridges.collect { list ->
if (list.isNotEmpty()) {
@@ -228,6 +292,22 @@ class NodeRuntime(context: Context) {
connect(target)
}
}
scope.launch {
combine(
canvasDebugStatusEnabled,
statusText,
serverName,
remoteAddress,
) { debugEnabled, status, server, remote ->
Quad(debugEnabled, status, server, remote)
}.distinctUntilChanged()
.collect { (debugEnabled, status, server, remote) ->
canvas.setDebugStatusEnabled(debugEnabled)
if (!debugEnabled) return@collect
canvas.setDebugStatus(status, server ?: remote)
}
}
}
fun setForeground(value: Boolean) {
@@ -242,6 +322,14 @@ class NodeRuntime(context: Context) {
prefs.setCameraEnabled(value)
}
fun setLocationMode(mode: LocationMode) {
prefs.setLocationMode(mode)
}
fun setLocationPreciseEnabled(value: Boolean) {
prefs.setLocationPreciseEnabled(value)
}
fun setPreventSleep(value: Boolean) {
prefs.setPreventSleep(value)
}
@@ -258,6 +346,10 @@ class NodeRuntime(context: Context) {
prefs.setManualPort(value)
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
prefs.setCanvasDebugStatusEnabled(value)
}
fun setWakeWords(words: List<String>) {
prefs.setWakeWords(words)
scheduleWakeWordsSyncIfNeeded()
@@ -271,63 +363,124 @@ class NodeRuntime(context: Context) {
prefs.setVoiceWakeMode(mode)
}
fun setTalkEnabled(value: Boolean) {
prefs.setTalkEnabled(value)
}
private fun buildInvokeCommands(): List<String> =
buildList {
add(ClawdbotCanvasCommand.Present.rawValue)
add(ClawdbotCanvasCommand.Hide.rawValue)
add(ClawdbotCanvasCommand.Navigate.rawValue)
add(ClawdbotCanvasCommand.Eval.rawValue)
add(ClawdbotCanvasCommand.Snapshot.rawValue)
add(ClawdbotCanvasA2UICommand.Push.rawValue)
add(ClawdbotCanvasA2UICommand.PushJSONL.rawValue)
add(ClawdbotCanvasA2UICommand.Reset.rawValue)
add(ClawdbotScreenCommand.Record.rawValue)
if (cameraEnabled.value) {
add(ClawdbotCameraCommand.Snap.rawValue)
add(ClawdbotCameraCommand.Clip.rawValue)
}
if (locationMode.value != LocationMode.Off) {
add(ClawdbotLocationCommand.Get.rawValue)
}
if (sms.canSendSms()) {
add(ClawdbotSmsCommand.Send.rawValue)
}
}
private fun buildCapabilities(): List<String> =
buildList {
add(ClawdbotCapability.Canvas.rawValue)
add(ClawdbotCapability.Screen.rawValue)
if (cameraEnabled.value) add(ClawdbotCapability.Camera.rawValue)
if (sms.canSendSms()) add(ClawdbotCapability.Sms.rawValue)
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
add(ClawdbotCapability.VoiceWake.rawValue)
}
if (locationMode.value != LocationMode.Off) {
add(ClawdbotCapability.Location.rawValue)
}
}
private fun buildPairingHello(token: String?): BridgePairingClient.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 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))
}
}
fun connect(endpoint: BridgeEndpoint) {
scope.launch {
_statusText.value = "Connecting…"
val storedToken = prefs.loadBridgeToken()
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
.joinToString(" ")
.trim()
.ifEmpty { null }
val invokeCommands =
buildList {
add(ClawdisCanvasCommand.Present.rawValue)
add(ClawdisCanvasCommand.Hide.rawValue)
add(ClawdisCanvasCommand.Navigate.rawValue)
add(ClawdisCanvasCommand.Eval.rawValue)
add(ClawdisCanvasCommand.Snapshot.rawValue)
add(ClawdisCanvasA2UICommand.Push.rawValue)
add(ClawdisCanvasA2UICommand.PushJSONL.rawValue)
add(ClawdisCanvasA2UICommand.Reset.rawValue)
add(ClawdisScreenCommand.Record.rawValue)
if (cameraEnabled.value) {
add(ClawdisCameraCommand.Snap.rawValue)
add(ClawdisCameraCommand.Clip.rawValue)
}
}
val resolved =
if (storedToken.isNullOrBlank()) {
_statusText.value = "Pairing…"
val caps = buildList {
add(ClawdisCapability.Canvas.rawValue)
add(ClawdisCapability.Screen.rawValue)
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
add(ClawdisCapability.VoiceWake.rawValue)
}
}
BridgePairingClient().pairAndHello(
endpoint = endpoint,
hello =
BridgePairingClient.Hello(
nodeId = instanceId.value,
displayName = displayName.value,
token = null,
platform = "Android",
version = "dev",
deviceFamily = "Android",
modelIdentifier = modelIdentifier,
caps = caps,
commands = invokeCommands,
),
hello = buildPairingHello(token = null),
)
} else {
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
}
if (!resolved.ok || resolved.token.isNullOrBlank()) {
_statusText.value = "Failed: pairing required"
val errorMessage = resolved.error?.trim().orEmpty().ifEmpty { "pairing required" }
_statusText.value = "Failed: $errorMessage"
return@launch
}
@@ -335,26 +488,7 @@ class NodeRuntime(context: Context) {
prefs.saveBridgeToken(authToken)
session.connect(
endpoint = endpoint,
hello =
BridgeSession.Hello(
nodeId = instanceId.value,
displayName = displayName.value,
token = authToken,
platform = "Android",
version = "dev",
deviceFamily = "Android",
modelIdentifier = modelIdentifier,
caps =
buildList {
add(ClawdisCapability.Canvas.rawValue)
add(ClawdisCapability.Screen.rawValue)
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
add(ClawdisCapability.VoiceWake.rawValue)
}
},
commands = invokeCommands,
),
hello = buildSessionHello(token = authToken),
)
}
}
@@ -366,6 +500,27 @@ class NodeRuntime(context: Context) {
)
}
private fun hasFineLocationPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
)
}
private fun hasCoarseLocationPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
)
}
private fun hasBackgroundLocationPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
)
}
fun connectManual() {
val host = manualHost.value.trim()
val port = manualPort.value
@@ -396,8 +551,7 @@ class NodeRuntime(context: Context) {
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
java.util.UUID.randomUUID().toString()
}
val name = (userActionObj["name"] as? JsonPrimitive)?.content?.trim().orEmpty()
if (name.isEmpty()) return@launch
val name = ClawdbotCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
val surfaceId =
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
@@ -407,7 +561,7 @@ class NodeRuntime(context: Context) {
val sessionKey = "main"
val message =
ClawdisCanvasA2UIAction.formatAgentMessage(
ClawdbotCanvasA2UIAction.formatAgentMessage(
actionName = name,
sessionKey = sessionKey,
surfaceId = surfaceId,
@@ -441,7 +595,7 @@ class NodeRuntime(context: Context) {
try {
canvas.eval(
ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(
ClawdbotCanvasA2UIAction.jsDispatchA2UIActionStatus(
actionId = actionId,
ok = connected && error == null,
error = error,
@@ -495,6 +649,7 @@ class NodeRuntime(context: Context) {
return
}
talkMode.handleBridgeEvent(event, payloadJson)
chat.handleBridgeEvent(event, payloadJson)
}
@@ -536,12 +691,31 @@ class NodeRuntime(context: Context) {
}
}
private suspend fun refreshBrandingFromGateway() {
if (!_isConnected.value) return
try {
val res = session.request("config.get", "{}")
val root = json.parseToJsonElement(res).asObjectOrNull()
val config = root?.get("config").asObjectOrNull()
val ui = config?.get("ui").asObjectOrNull()
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
val sessionCfg = config?.get("session").asObjectOrNull()
val rawMainKey = sessionCfg?.get("mainKey").asStringOrNull()?.trim()
_mainSessionKey.value = rawMainKey?.takeIf { it.isNotEmpty() } ?: "main"
val parsed = parseHexColorArgb(raw)
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
} catch (_: Throwable) {
// ignore
}
}
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
if (
command.startsWith(ClawdisCanvasCommand.NamespacePrefix) ||
command.startsWith(ClawdisCanvasA2UICommand.NamespacePrefix) ||
command.startsWith(ClawdisCameraCommand.NamespacePrefix) ||
command.startsWith(ClawdisScreenCommand.NamespacePrefix)
command.startsWith(ClawdbotCanvasCommand.NamespacePrefix) ||
command.startsWith(ClawdbotCanvasA2UICommand.NamespacePrefix) ||
command.startsWith(ClawdbotCameraCommand.NamespacePrefix) ||
command.startsWith(ClawdbotScreenCommand.NamespacePrefix)
) {
if (!isForeground.value) {
return BridgeSession.InvokeResult.error(
@@ -550,26 +724,34 @@ class NodeRuntime(context: Context) {
)
}
}
if (command.startsWith(ClawdisCameraCommand.NamespacePrefix) && !cameraEnabled.value) {
if (command.startsWith(ClawdbotCameraCommand.NamespacePrefix) && !cameraEnabled.value) {
return BridgeSession.InvokeResult.error(
code = "CAMERA_DISABLED",
message = "CAMERA_DISABLED: enable Camera in Settings",
)
}
if (command.startsWith(ClawdbotLocationCommand.NamespacePrefix) &&
locationMode.value == LocationMode.Off
) {
return BridgeSession.InvokeResult.error(
code = "LOCATION_DISABLED",
message = "LOCATION_DISABLED: enable Location in Settings",
)
}
return when (command) {
ClawdisCanvasCommand.Present.rawValue -> {
ClawdbotCanvasCommand.Present.rawValue -> {
val url = CanvasController.parseNavigateUrl(paramsJson)
canvas.navigate(url)
BridgeSession.InvokeResult.ok(null)
}
ClawdisCanvasCommand.Hide.rawValue -> BridgeSession.InvokeResult.ok(null)
ClawdisCanvasCommand.Navigate.rawValue -> {
ClawdbotCanvasCommand.Hide.rawValue -> BridgeSession.InvokeResult.ok(null)
ClawdbotCanvasCommand.Navigate.rawValue -> {
val url = CanvasController.parseNavigateUrl(paramsJson)
canvas.navigate(url)
BridgeSession.InvokeResult.ok(null)
}
ClawdisCanvasCommand.Eval.rawValue -> {
ClawdbotCanvasCommand.Eval.rawValue -> {
val js =
CanvasController.parseEvalJs(paramsJson)
?: return BridgeSession.InvokeResult.error(
@@ -587,7 +769,7 @@ class NodeRuntime(context: Context) {
}
BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
}
ClawdisCanvasCommand.Snapshot.rawValue -> {
ClawdbotCanvasCommand.Snapshot.rawValue -> {
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
val base64 =
try {
@@ -604,30 +786,46 @@ class NodeRuntime(context: Context) {
}
BridgeSession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
}
ClawdisCanvasA2UICommand.Reset.rawValue -> {
val ready = ensureA2uiReady()
ClawdbotCanvasA2UICommand.Reset.rawValue -> {
val a2uiUrl = resolveA2uiHostUrl()
?: return BridgeSession.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(code = "A2UI_NOT_READY", message = "A2UI not ready")
return BridgeSession.InvokeResult.error(
code = "A2UI_HOST_UNAVAILABLE",
message = "A2UI host not reachable",
)
}
val res = canvas.eval(a2uiResetJS)
BridgeSession.InvokeResult.ok(res)
}
ClawdisCanvasA2UICommand.Push.rawValue, ClawdisCanvasA2UICommand.PushJSONL.rawValue -> {
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")
}
val ready = ensureA2uiReady()
val a2uiUrl = resolveA2uiHostUrl()
?: return BridgeSession.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(code = "A2UI_NOT_READY", message = "A2UI not ready")
return BridgeSession.InvokeResult.error(
code = "A2UI_HOST_UNAVAILABLE",
message = "A2UI host not reachable",
)
}
val js = a2uiApplyMessagesJS(messages)
val res = canvas.eval(js)
BridgeSession.InvokeResult.ok(res)
}
ClawdisCameraCommand.Snap.rawValue -> {
ClawdbotCameraCommand.Snap.rawValue -> {
showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo)
triggerCameraFlash()
val res =
@@ -641,7 +839,7 @@ class NodeRuntime(context: Context) {
showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600)
BridgeSession.InvokeResult.ok(res.payloadJson)
}
ClawdisCameraCommand.Clip.rawValue -> {
ClawdbotCameraCommand.Clip.rawValue -> {
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
if (includeAudio) externalAudioCaptureActive.value = true
try {
@@ -660,15 +858,85 @@ class NodeRuntime(context: Context) {
if (includeAudio) externalAudioCaptureActive.value = false
}
}
ClawdisScreenCommand.Record.rawValue -> {
val res =
try {
screenRecorder.record(paramsJson)
} catch (err: Throwable) {
val (code, message) = invokeErrorFromThrowable(err)
return BridgeSession.InvokeResult.error(code = code, message = message)
ClawdbotLocationCommand.Get.rawValue -> {
val mode = locationMode.value
if (!isForeground.value && mode != LocationMode.Always) {
return BridgeSession.InvokeResult.error(
code = "LOCATION_BACKGROUND_UNAVAILABLE",
message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always",
)
}
if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) {
return BridgeSession.InvokeResult.error(
code = "LOCATION_PERMISSION_REQUIRED",
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
)
}
if (!isForeground.value && mode == LocationMode.Always && !hasBackgroundLocationPermission()) {
return BridgeSession.InvokeResult.error(
code = "LOCATION_PERMISSION_REQUIRED",
message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings",
)
}
val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson)
val preciseEnabled = locationPreciseEnabled.value
val accuracy =
when (desiredAccuracy) {
"precise" -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
"coarse" -> "coarse"
else -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
}
BridgeSession.InvokeResult.ok(res.payloadJson)
val providers =
when (accuracy) {
"precise" -> listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
"coarse" -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
else -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
}
try {
val payload =
location.getLocation(
desiredProviders = providers,
maxAgeMs = maxAgeMs,
timeoutMs = timeoutMs,
isPrecise = accuracy == "precise",
)
BridgeSession.InvokeResult.ok(payload.payloadJson)
} catch (err: TimeoutCancellationException) {
BridgeSession.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)
}
}
ClawdbotScreenCommand.Record.rawValue -> {
// Status pill mirrors screen recording state so it stays visible without overlay stacking.
_screenRecordActive.value = true
try {
val res =
try {
screenRecorder.record(paramsJson)
} catch (err: Throwable) {
val (code, message) = invokeErrorFromThrowable(err)
return BridgeSession.InvokeResult.error(code = code, message = message)
}
BridgeSession.InvokeResult.ok(res.payloadJson)
} finally {
_screenRecordActive.value = false
}
}
ClawdbotSmsCommand.Send.rawValue -> {
val res = sms.send(paramsJson)
if (res.ok) {
BridgeSession.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)
}
}
else ->
BridgeSession.InvokeResult.error(
@@ -707,7 +975,33 @@ class NodeRuntime(context: Context) {
return code to "$code: $message"
}
private suspend fun ensureA2uiReady(): Boolean {
private fun parseLocationParams(paramsJson: String?): Triple<Long?, Long, String?> {
if (paramsJson.isNullOrBlank()) {
return Triple(null, 10_000L, null)
}
val root =
try {
json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
}
val maxAgeMs = (root?.get("maxAgeMs") as? JsonPrimitive)?.content?.toLongOrNull()
val timeoutMs =
(root?.get("timeoutMs") as? JsonPrimitive)?.content?.toLongOrNull()?.coerceIn(1_000L, 60_000L)
?: 10_000L
val desiredAccuracy =
(root?.get("desiredAccuracy") as? JsonPrimitive)?.content?.trim()?.lowercase()
return Triple(maxAgeMs, timeoutMs, desiredAccuracy)
}
private fun resolveA2uiHostUrl(): String? {
val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
if (raw.isBlank()) return null
val base = raw.trimEnd('/')
return "${base}/__clawdbot__/a2ui/?platform=android"
}
private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
try {
val already = canvas.eval(a2uiReadyCheckJS)
if (already == "true") return true
@@ -715,8 +1009,7 @@ class NodeRuntime(context: Context) {
// ignore
}
// Ensure the default canvas scaffold is loaded; A2UI is now hosted there.
canvas.navigate("")
canvas.navigate(a2uiUrl)
repeat(50) {
try {
val ready = canvas.eval(a2uiReadyCheckJS)
@@ -740,7 +1033,7 @@ class NodeRuntime(context: Context) {
val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty()
val hasMessagesArray = obj["messages"] is JsonArray
if (command == ClawdisCanvasA2UICommand.PushJSONL.rawValue || (!hasMessagesArray && jsonlField.isNotBlank())) {
if (command == ClawdbotCanvasA2UICommand.PushJSONL.rawValue || (!hasMessagesArray && jsonlField.isNotBlank())) {
val jsonl = jsonlField
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
val messages =
@@ -791,11 +1084,13 @@ class NodeRuntime(context: Context) {
private data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
private const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A
private const val a2uiReadyCheckJS: String =
"""
(() => {
try {
return !!globalThis.clawdisA2UI && typeof globalThis.clawdisA2UI.applyMessages === 'function';
return !!globalThis.clawdbotA2UI && typeof globalThis.clawdbotA2UI.applyMessages === 'function';
} catch (_) {
return false;
}
@@ -806,8 +1101,8 @@ private const val a2uiResetJS: String =
"""
(() => {
try {
if (!globalThis.clawdisA2UI) return { ok: false, error: "missing clawdisA2UI" };
return globalThis.clawdisA2UI.reset();
if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
return globalThis.clawdbotA2UI.reset();
} catch (e) {
return { ok: false, error: String(e?.message ?? e) };
}
@@ -818,9 +1113,9 @@ private fun a2uiApplyMessagesJS(messagesJson: String): String {
return """
(() => {
try {
if (!globalThis.clawdisA2UI) return { ok: false, error: "missing clawdisA2UI" };
if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
const messages = $messagesJson;
return globalThis.clawdisA2UI.applyMessages(messages);
return globalThis.clawdbotA2UI.applyMessages(messages);
} catch (e) {
return { ok: false, error: String(e?.message ?? e) };
}
@@ -845,3 +1140,12 @@ private fun JsonElement?.asStringOrNull(): String? =
is JsonPrimitive -> content
else -> null
}
private fun parseHexColorArgb(raw: String?): Long? {
val trimmed = raw?.trim().orEmpty()
if (trimmed.isEmpty()) return null
val hex = if (trimmed.startsWith("#")) trimmed.drop(1) else trimmed
if (hex.length != 6) return null
val rgb = hex.toLongOrNull(16) ?: return null
return 0xFF000000L or rgb
}
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node
package com.clawdbot.android
import android.content.pm.PackageManager
import android.content.Intent
@@ -115,7 +115,7 @@ class PermissionRequester(private val activity: ComponentActivity) {
private fun buildRationaleMessage(permissions: List<String>): String {
val labels = permissions.map { permissionLabel(it) }
return "Clawdis needs ${labels.joinToString(", ")} to capture camera media."
return "Clawdbot needs ${labels.joinToString(", ")} permissions to continue."
}
private fun buildSettingsMessage(permissions: List<String>): String {
@@ -127,6 +127,7 @@ class PermissionRequester(private val activity: ComponentActivity) {
when (permission) {
Manifest.permission.CAMERA -> "Camera"
Manifest.permission.RECORD_AUDIO -> "Microphone"
Manifest.permission.SEND_SMS -> "SMS"
else -> permission
}
}
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node
package com.clawdbot.android
import android.app.Activity
import android.content.Context
@@ -55,7 +55,7 @@ class ScreenCaptureRequester(private val activity: ComponentActivity) {
suspendCancellableCoroutine { cont ->
AlertDialog.Builder(activity)
.setTitle("Screen recording required")
.setMessage("Clawdis needs to record the screen for this command.")
.setMessage("Clawdbot needs to record the screen for this command.")
.setPositiveButton("Continue") { _, _ -> cont.resume(true) }
.setNegativeButton("Not now") { _, _ -> cont.resume(false) }
.setOnCancelListener { cont.resume(false) }
@@ -1,8 +1,9 @@
@file:Suppress("DEPRECATION")
package com.steipete.clawdis.node
package com.clawdbot.android
import android.content.Context
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.flow.MutableStateFlow
@@ -30,7 +31,7 @@ class SecurePrefs(context: Context) {
private val prefs =
EncryptedSharedPreferences.create(
context,
"clawdis.node.secure",
"clawdbot.node.secure",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
@@ -46,6 +47,14 @@ class SecurePrefs(context: Context) {
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
private val _locationMode =
MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off")))
val locationMode: StateFlow<LocationMode> = _locationMode
private val _locationPreciseEnabled =
MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true))
val locationPreciseEnabled: StateFlow<Boolean> = _locationPreciseEnabled
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
val preventSleep: StateFlow<Boolean> = _preventSleep
@@ -62,50 +71,72 @@ class SecurePrefs(context: Context) {
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
private val _canvasDebugStatusEnabled =
MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false))
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
private val _wakeWords = MutableStateFlow(loadWakeWords())
val wakeWords: StateFlow<List<String>> = _wakeWords
private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode())
val voiceWakeMode: StateFlow<VoiceWakeMode> = _voiceWakeMode
private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false))
val talkEnabled: StateFlow<Boolean> = _talkEnabled
fun setLastDiscoveredStableId(value: String) {
val trimmed = value.trim()
prefs.edit().putString("bridge.lastDiscoveredStableId", trimmed).apply()
prefs.edit { putString("bridge.lastDiscoveredStableId", trimmed) }
_lastDiscoveredStableId.value = trimmed
}
fun setDisplayName(value: String) {
val trimmed = value.trim()
prefs.edit().putString(displayNameKey, trimmed).apply()
prefs.edit { putString(displayNameKey, trimmed) }
_displayName.value = trimmed
}
fun setCameraEnabled(value: Boolean) {
prefs.edit().putBoolean("camera.enabled", value).apply()
prefs.edit { putBoolean("camera.enabled", value) }
_cameraEnabled.value = value
}
fun setLocationMode(mode: LocationMode) {
prefs.edit { putString("location.enabledMode", mode.rawValue) }
_locationMode.value = mode
}
fun setLocationPreciseEnabled(value: Boolean) {
prefs.edit { putBoolean("location.preciseEnabled", value) }
_locationPreciseEnabled.value = value
}
fun setPreventSleep(value: Boolean) {
prefs.edit().putBoolean("screen.preventSleep", value).apply()
prefs.edit { putBoolean("screen.preventSleep", value) }
_preventSleep.value = value
}
fun setManualEnabled(value: Boolean) {
prefs.edit().putBoolean("bridge.manual.enabled", value).apply()
prefs.edit { putBoolean("bridge.manual.enabled", value) }
_manualEnabled.value = value
}
fun setManualHost(value: String) {
val trimmed = value.trim()
prefs.edit().putString("bridge.manual.host", trimmed).apply()
prefs.edit { putString("bridge.manual.host", trimmed) }
_manualHost.value = trimmed
}
fun setManualPort(value: Int) {
prefs.edit().putInt("bridge.manual.port", value).apply()
prefs.edit { putInt("bridge.manual.port", value) }
_manualPort.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)
@@ -113,14 +144,14 @@ class SecurePrefs(context: Context) {
fun saveBridgeToken(token: String) {
val key = "bridge.token.${_instanceId.value}"
prefs.edit().putString(key, token.trim()).apply()
prefs.edit { putString(key, token.trim()) }
}
private fun loadOrCreateInstanceId(): String {
val existing = prefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing
val fresh = UUID.randomUUID().toString()
prefs.edit().putString("node.instanceId", fresh).apply()
prefs.edit { putString("node.instanceId", fresh) }
return fresh
}
@@ -131,7 +162,7 @@ class SecurePrefs(context: Context) {
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
val resolved = candidate.ifEmpty { "Android Node" }
prefs.edit().putString(displayNameKey, resolved).apply()
prefs.edit { putString(displayNameKey, resolved) }
return resolved
}
@@ -139,22 +170,27 @@ class SecurePrefs(context: Context) {
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
val encoded =
JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
prefs.edit().putString("voiceWake.triggerWords", encoded).apply()
prefs.edit { putString("voiceWake.triggerWords", encoded) }
_wakeWords.value = sanitized
}
fun setVoiceWakeMode(mode: VoiceWakeMode) {
prefs.edit().putString(voiceWakeModeKey, mode.rawValue).apply()
prefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
_voiceWakeMode.value = mode
}
fun setTalkEnabled(value: Boolean) {
prefs.edit { putBoolean("talk.enabled", value) }
_talkEnabled.value = value
}
private fun loadVoiceWakeMode(): VoiceWakeMode {
val raw = prefs.getString(voiceWakeModeKey, null)
val resolved = VoiceWakeMode.fromRawValue(raw)
// Default ON (foreground) when unset.
if (raw.isNullOrBlank()) {
prefs.edit().putString(voiceWakeModeKey, resolved.rawValue).apply()
prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
}
return resolved
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node
package com.clawdbot.android
enum class VoiceWakeMode(val rawValue: String) {
Off("off"),
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node
package com.clawdbot.android
object WakeWords {
const val maxWords: Int = 32
@@ -1,10 +1,10 @@
package com.steipete.clawdis.node.bridge
package com.clawdbot.android.bridge
object BonjourEscapes {
fun decode(input: String): String {
if (input.isEmpty()) return input
val out = StringBuilder(input.length)
val bytes = mutableListOf<Byte>()
var i = 0
while (i < input.length) {
if (input[i] == '\\' && i + 3 < input.length) {
@@ -14,17 +14,22 @@ object BonjourEscapes {
if (d0.isDigit() && d1.isDigit() && d2.isDigit()) {
val value =
((d0.code - '0'.code) * 100) + ((d1.code - '0'.code) * 10) + (d2.code - '0'.code)
if (value in 0..0x10FFFF) {
out.appendCodePoint(value)
if (value in 0..255) {
bytes.add(value.toByte())
i += 4
continue
}
}
}
out.append(input[i])
i += 1
val codePoint = Character.codePointAt(input, i)
val charBytes = String(Character.toChars(codePoint)).toByteArray(Charsets.UTF_8)
for (b in charBytes) {
bytes.add(b)
}
i += Character.charCount(codePoint)
}
return out.toString()
return String(bytes.toByteArray(), Charsets.UTF_8)
}
}
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge
package com.clawdbot.android.bridge
import android.content.Context
import android.net.ConnectivityManager
@@ -6,7 +6,6 @@ import android.net.DnsResolver
import android.net.NetworkCapabilities
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.CancellationSignal
import android.util.Log
import java.io.IOException
@@ -44,6 +43,7 @@ import org.xbill.DNS.Type
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Suppress("DEPRECATION")
class BridgeDiscovery(
context: Context,
private val scope: CoroutineScope,
@@ -51,9 +51,9 @@ class BridgeDiscovery(
private val nsd = context.getSystemService(NsdManager::class.java)
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
private val dns = DnsResolver.getInstance()
private val serviceType = "_clawdis-bridge._tcp."
private val wideAreaDomain = "clawdis.internal."
private val logTag = "Clawdis/BridgeDiscovery"
private val serviceType = "_clawdbot-bridge._tcp."
private val wideAreaDomain = "clawdbot.internal."
private val logTag = "Clawdbot/BridgeDiscovery"
private val localById = ConcurrentHashMap<String, BridgeEndpoint>()
private val unicastById = ConcurrentHashMap<String, BridgeEndpoint>()
@@ -130,20 +130,36 @@ class BridgeDiscovery(
object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {}
override fun onServiceResolved(resolved: NsdServiceInfo) {
val host = resolved.host?.hostAddress ?: return
val port = resolved.port
if (port <= 0) return
override fun onServiceResolved(resolved: NsdServiceInfo) {
val host = resolved.host?.hostAddress ?: return
val port = resolved.port
if (port <= 0) return
val rawServiceName = resolved.serviceName
val serviceName = BonjourEscapes.decode(rawServiceName)
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
val id = stableId(serviceName, "local.")
localById[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
publish()
}
},
)
val rawServiceName = resolved.serviceName
val serviceName = BonjourEscapes.decode(rawServiceName)
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
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 id = stableId(serviceName, "local.")
localById[id] =
BridgeEndpoint(
stableId = id,
name = displayName,
host = host,
port = port,
lanHost = lanHost,
tailnetDns = tailnetDns,
gatewayPort = gatewayPort,
bridgePort = bridgePort,
canvasPort = canvasPort,
)
publish()
}
},
)
}
private fun publish() {
@@ -181,7 +197,6 @@ class BridgeDiscovery(
}
private fun txt(info: NsdServiceInfo, key: String): String? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return null
val bytes = info.attributes[key] ?: return null
return try {
String(bytes, Charsets.UTF_8).trim().ifEmpty { null }
@@ -190,6 +205,10 @@ class BridgeDiscovery(
}
}
private fun txtInt(info: NsdServiceInfo, key: String): Int? {
return txt(info, key)?.toIntOrNull()
}
private suspend fun refreshUnicast(domain: String) {
val ptrName = "${serviceType}${domain}"
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
@@ -228,8 +247,24 @@ class BridgeDiscovery(
}
val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain))
val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName)
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 id = stableId(instanceName, domain)
next[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
next[id] =
BridgeEndpoint(
stableId = id,
name = displayName,
host = host,
port = port,
lanHost = lanHost,
tailnetDns = tailnetDns,
gatewayPort = gatewayPort,
bridgePort = bridgePort,
canvasPort = canvasPort,
)
}
unicastById.clear()
@@ -401,7 +436,7 @@ class BridgeDiscovery(
dns.rawQuery(
network,
wireQuery,
0,
DnsResolver.FLAG_EMPTY,
dnsExecutor,
signal,
object : DnsResolver.Callback<ByteArray> {
@@ -435,6 +470,10 @@ class BridgeDiscovery(
return null
}
private fun txtIntValue(records: List<TXTRecord>, key: String): Int? {
return txtValue(records, key)?.toIntOrNull()
}
private fun decodeDnsTxtString(raw: String): String {
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
@@ -1,10 +1,15 @@
package com.steipete.clawdis.node.bridge
package com.clawdbot.android.bridge
data class BridgeEndpoint(
val stableId: String,
val name: String,
val host: String,
val port: Int,
val lanHost: String? = null,
val tailnetDns: String? = null,
val gatewayPort: Int? = null,
val bridgePort: Int? = null,
val canvasPort: Int? = null,
) {
companion object {
fun manual(host: String, port: Int): BridgeEndpoint =
@@ -16,4 +21,3 @@ data class BridgeEndpoint(
)
}
}
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge
package com.clawdbot.android.bridge
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -37,21 +37,21 @@ class BridgePairingClient {
withContext(Dispatchers.IO) {
val socket = Socket()
socket.tcpNoDelay = true
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 60_000
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())
try {
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 60_000
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"))
@@ -111,6 +111,9 @@ class BridgePairingClient {
}
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
}
} catch (e: Exception) {
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
PairResult(ok = false, token = null, error = message)
} finally {
try {
socket.close()
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge
package com.clawdbot.android.bridge
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
@@ -11,6 +11,7 @@ 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
@@ -23,6 +24,7 @@ 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
@@ -61,6 +63,7 @@ class BridgeSession(
private val json = Json { ignoreUnknownKeys = true }
private val writeLock = Mutex()
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
@Volatile private var canvasHostUrl: String? = null
private var desired: Pair<BridgeEndpoint, Hello>? = null
private var job: Job? = null
@@ -72,15 +75,27 @@ class BridgeSession(
}
}
suspend fun updateHello(hello: Hello) {
val target = desired ?: return
desired = target.first to 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
onDisconnected("Offline")
}
}
fun currentCanvasHostUrl(): String? = canvasHostUrl
suspend fun sendEvent(event: String, payloadJson: String?) {
val conn = currentConnection ?: return
conn.sendJson(
@@ -188,20 +203,7 @@ class BridgeSession(
currentConnection = conn
try {
conn.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))) }
},
)
conn.sendJson(buildHelloJson(hello))
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
@@ -209,6 +211,17 @@ class BridgeSession(
when (first["type"].asStringOrNull()) {
"hello-ok" -> {
val name = first["serverName"].asStringOrNull() ?: "Bridge"
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
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)
}
"error" -> {
@@ -287,6 +300,51 @@ class BridgeSession(
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
@@ -1,6 +1,6 @@
package com.steipete.clawdis.node.chat
package com.clawdbot.android.chat
import com.steipete.clawdis.node.bridge.BridgeSession
import com.clawdbot.android.bridge.BridgeSession
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
@@ -361,10 +361,12 @@ class ChatController(
val ts = payload["ts"].asLongOrNull() ?: System.currentTimeMillis()
if (phase == "start") {
val args = data?.get("args").asObjectOrNull()
pendingToolCallsById[toolCallId] =
ChatPendingToolCall(
toolCallId = toolCallId,
name = name,
args = args,
startedAtMs = ts,
isError = null,
)
@@ -469,7 +471,8 @@ class ChatController(
val key = obj["key"].asStringOrNull()?.trim().orEmpty()
if (key.isEmpty()) return@mapNotNull null
val updatedAt = obj["updatedAt"].asLongOrNull()
ChatSessionEntry(key = key, updatedAtMs = updatedAt)
val displayName = obj["displayName"].asStringOrNull()?.trim()
ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName)
}
}
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.chat
package com.clawdbot.android.chat
data class ChatMessage(
val id: String,
@@ -18,6 +18,7 @@ data class ChatMessageContent(
data class ChatPendingToolCall(
val toolCallId: String,
val name: String,
val args: kotlinx.serialization.json.JsonObject? = null,
val startedAtMs: Long,
val isError: Boolean? = null,
)
@@ -25,6 +26,7 @@ data class ChatPendingToolCall(
data class ChatSessionEntry(
val key: String,
val updatedAtMs: Long?,
val displayName: String? = null,
)
data class ChatHistory(
@@ -1,7 +1,8 @@
package com.steipete.clawdis.node.node
package com.clawdbot.android.node
import android.Manifest
import android.content.Context
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
@@ -18,7 +19,8 @@ import androidx.camera.video.VideoCapture
import androidx.camera.video.VideoRecordEvent
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.checkSelfPermission
import com.steipete.clawdis.node.PermissionRequester
import androidx.core.graphics.scale
import com.clawdbot.android.PermissionRequester
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
@@ -26,6 +28,7 @@ import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.concurrent.Executor
import kotlin.math.roundToInt
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -92,22 +95,45 @@ class CameraCaptureManager(private val context: Context) {
(decoded.height.toDouble() * (maxWidth.toDouble() / decoded.width.toDouble()))
.toInt()
.coerceAtLeast(1)
Bitmap.createScaledBitmap(decoded, maxWidth, h, true)
decoded.scale(maxWidth, h)
} else {
decoded
}
val out = ByteArrayOutputStream()
val jpegQuality = (quality * 100.0).toInt().coerceIn(10, 100)
if (!scaled.compress(Bitmap.CompressFormat.JPEG, jpegQuality, out)) {
throw IllegalStateException("UNAVAILABLE: failed to encode JPEG")
}
val base64 = Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
val maxPayloadBytes = 5 * 1024 * 1024
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
val maxEncodedBytes = (maxPayloadBytes / 4) * 3
val result =
JpegSizeLimiter.compressToLimit(
initialWidth = scaled.width,
initialHeight = scaled.height,
startQuality = (quality * 100.0).roundToInt().coerceIn(10, 100),
maxBytes = maxEncodedBytes,
encode = { width, height, q ->
val bitmap =
if (width == scaled.width && height == scaled.height) {
scaled
} else {
scaled.scale(width, height)
}
val out = ByteArrayOutputStream()
if (!bitmap.compress(Bitmap.CompressFormat.JPEG, q, out)) {
if (bitmap !== scaled) bitmap.recycle()
throw IllegalStateException("UNAVAILABLE: failed to encode JPEG")
}
if (bitmap !== scaled) {
bitmap.recycle()
}
out.toByteArray()
},
)
val base64 = Base64.encodeToString(result.bytes, Base64.NO_WRAP)
Payload(
"""{"format":"jpg","base64":"$base64","width":${scaled.width},"height":${scaled.height}}""",
"""{"format":"jpg","base64":"$base64","width":${result.width},"height":${result.height}}""",
)
}
@SuppressLint("MissingPermission")
suspend fun clip(paramsJson: String?): Payload =
withContext(Dispatchers.Main) {
ensureCameraPermission()
@@ -126,7 +152,7 @@ class CameraCaptureManager(private val context: Context) {
provider.unbindAll()
provider.bindToLifecycle(owner, selector, videoCapture)
val file = File.createTempFile("clawdis-clip-", ".mp4")
val file = File.createTempFile("clawdbot-clip-", ".mp4")
val outputOptions = FileOutputOptions.Builder(file).build()
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
@@ -230,7 +256,7 @@ private suspend fun Context.cameraProvider(): ProcessCameraProvider =
private suspend fun ImageCapture.takeJpegBytes(executor: Executor): ByteArray =
suspendCancellableCoroutine { cont ->
val file = File.createTempFile("clawdis-snap-", ".jpg")
val file = File.createTempFile("clawdbot-snap-", ".jpg")
val options = ImageCapture.OutputFileOptions.Builder(file).build()
takePicture(
options,
@@ -1,19 +1,23 @@
package com.steipete.clawdis.node.node
package com.clawdbot.android.node
import android.graphics.Bitmap
import android.os.Build
import android.graphics.Canvas
import android.os.Looper
import android.util.Log
import android.webkit.WebView
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import android.util.Base64
import org.json.JSONObject
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import com.clawdbot.android.BuildConfig
import kotlin.coroutines.resume
class CanvasController {
@@ -24,6 +28,9 @@ class CanvasController {
@Volatile private var webView: WebView? = null
@Volatile private var url: String? = null
@Volatile private var debugStatusEnabled: Boolean = false
@Volatile private var debugStatusTitle: String? = null
@Volatile private var debugStatusSubtitle: String? = null
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
@@ -35,6 +42,7 @@ class CanvasController {
fun attach(webView: WebView) {
this.webView = webView
reload()
applyDebugStatus()
}
fun navigate(url: String) {
@@ -43,6 +51,25 @@ class CanvasController {
reload()
}
fun currentUrl(): String? = url
fun isDefaultCanvas(): Boolean = url == null
fun setDebugStatusEnabled(enabled: Boolean) {
debugStatusEnabled = enabled
applyDebugStatus()
}
fun setDebugStatus(title: String?, subtitle: String?) {
debugStatusTitle = title
debugStatusSubtitle = subtitle
applyDebugStatus()
}
fun onPageFinished() {
applyDebugStatus()
}
private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) {
val wv = webView ?: return
if (Looper.myLooper() == Looper.getMainLooper()) {
@@ -56,13 +83,45 @@ class CanvasController {
val currentUrl = url
withWebViewOnMain { wv ->
if (currentUrl == null) {
if (BuildConfig.DEBUG) {
Log.d("ClawdbotCanvas", "load scaffold: $scaffoldAssetUrl")
}
wv.loadUrl(scaffoldAssetUrl)
} else {
if (BuildConfig.DEBUG) {
Log.d("ClawdbotCanvas", "load url: $currentUrl")
}
wv.loadUrl(currentUrl)
}
}
}
private fun applyDebugStatus() {
val enabled = debugStatusEnabled
val title = debugStatusTitle
val subtitle = debugStatusSubtitle
withWebViewOnMain { wv ->
val titleJs = title?.let { JSONObject.quote(it) } ?: "null"
val subtitleJs = subtitle?.let { JSONObject.quote(it) } ?: "null"
val js = """
(() => {
try {
const api = globalThis.__clawdbot;
if (!api) return;
if (typeof api.setDebugStatusEnabled === 'function') {
api.setDebugStatusEnabled(${if (enabled) "true" else "false"});
}
if (!${if (enabled) "true" else "false"}) return;
if (typeof api.setStatus === 'function') {
api.setStatus($titleJs, $subtitleJs);
}
} catch (_) {}
})();
""".trimIndent()
wv.evaluateJavascript(js, null)
}
}
suspend fun eval(javaScript: String): String =
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
@@ -80,7 +139,7 @@ class CanvasController {
val scaled =
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
bmp.scale(maxWidth, h)
} else {
bmp
}
@@ -97,7 +156,7 @@ class CanvasController {
val scaled =
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
bmp.scale(maxWidth, h)
} else {
bmp
}
@@ -116,7 +175,7 @@ class CanvasController {
suspendCancellableCoroutine { cont ->
val width = width.coerceAtLeast(1)
val height = height.coerceAtLeast(1)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val bitmap = createBitmap(width, height, Bitmap.Config.ARGB_8888)
// WebView isn't supported by PixelCopy.request(...) directly; draw() is the most reliable
// cross-version snapshot for this lightweight "canvas" use-case.
@@ -0,0 +1,61 @@
package com.clawdbot.android.node
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
internal data class JpegSizeLimiterResult(
val bytes: ByteArray,
val width: Int,
val height: Int,
val quality: Int,
)
internal object JpegSizeLimiter {
fun compressToLimit(
initialWidth: Int,
initialHeight: Int,
startQuality: Int,
maxBytes: Int,
minQuality: Int = 20,
minSize: Int = 256,
scaleStep: Double = 0.85,
maxScaleAttempts: Int = 6,
maxQualityAttempts: Int = 6,
encode: (width: Int, height: Int, quality: Int) -> ByteArray,
): JpegSizeLimiterResult {
require(initialWidth > 0 && initialHeight > 0) { "Invalid image size" }
require(maxBytes > 0) { "Invalid maxBytes" }
var width = initialWidth
var height = initialHeight
val clampedStartQuality = startQuality.coerceIn(minQuality, 100)
var best = JpegSizeLimiterResult(bytes = encode(width, height, clampedStartQuality), width = width, height = height, quality = clampedStartQuality)
if (best.bytes.size <= maxBytes) return best
repeat(maxScaleAttempts) {
var quality = clampedStartQuality
repeat(maxQualityAttempts) {
val bytes = encode(width, height, quality)
best = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality)
if (bytes.size <= maxBytes) return best
if (quality <= minQuality) return@repeat
quality = max(minQuality, (quality * 0.75).roundToInt())
}
val minScale = (minSize.toDouble() / min(width, height).toDouble()).coerceAtMost(1.0)
val nextScale = max(scaleStep, minScale)
val nextWidth = max(minSize, (width * nextScale).roundToInt())
val nextHeight = max(minSize, (height * nextScale).roundToInt())
if (nextWidth == width && nextHeight == height) return@repeat
width = min(nextWidth, width)
height = min(nextHeight, height)
}
if (best.bytes.size > maxBytes) {
throw IllegalStateException("CAMERA_TOO_LARGE: ${best.bytes.size} bytes > $maxBytes bytes")
}
return best
}
}
@@ -0,0 +1,117 @@
package com.clawdbot.android.node
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.os.CancellationSignal
import androidx.core.content.ContextCompat
import java.time.Instant
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
class LocationCaptureManager(private val context: Context) {
data class Payload(val payloadJson: String)
suspend fun getLocation(
desiredProviders: List<String>,
maxAgeMs: Long?,
timeoutMs: Long,
isPrecise: Boolean,
): Payload =
withContext(Dispatchers.Main) {
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
if (!manager.isProviderEnabled(LocationManager.GPS_PROVIDER) &&
!manager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
) {
throw IllegalStateException("LOCATION_UNAVAILABLE: no location providers enabled")
}
val cached = bestLastKnown(manager, desiredProviders, maxAgeMs)
val location =
cached ?: requestCurrent(manager, desiredProviders, timeoutMs)
val timestamp = DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(location.time))
val source = location.provider
val altitudeMeters = if (location.hasAltitude()) location.altitude else null
val speedMps = if (location.hasSpeed()) location.speed.toDouble() else null
val headingDeg = if (location.hasBearing()) location.bearing.toDouble() else null
Payload(
buildString {
append("{\"lat\":")
append(location.latitude)
append(",\"lon\":")
append(location.longitude)
append(",\"accuracyMeters\":")
append(location.accuracy.toDouble())
if (altitudeMeters != null) append(",\"altitudeMeters\":").append(altitudeMeters)
if (speedMps != null) append(",\"speedMps\":").append(speedMps)
if (headingDeg != null) append(",\"headingDeg\":").append(headingDeg)
append(",\"timestamp\":\"").append(timestamp).append('"')
append(",\"isPrecise\":").append(isPrecise)
append(",\"source\":\"").append(source).append('"')
append('}')
},
)
}
private fun bestLastKnown(
manager: LocationManager,
providers: List<String>,
maxAgeMs: Long?,
): Location? {
val fineOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val coarseOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (!fineOk && !coarseOk) {
throw IllegalStateException("LOCATION_PERMISSION_REQUIRED: grant Location permission")
}
val now = System.currentTimeMillis()
val candidates =
providers.mapNotNull { provider -> manager.getLastKnownLocation(provider) }
val freshest = candidates.maxByOrNull { it.time } ?: return null
if (maxAgeMs != null && now - freshest.time > maxAgeMs) return null
return freshest
}
private suspend fun requestCurrent(
manager: LocationManager,
providers: List<String>,
timeoutMs: Long,
): Location {
val fineOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val coarseOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (!fineOk && !coarseOk) {
throw IllegalStateException("LOCATION_PERMISSION_REQUIRED: grant Location permission")
}
val resolved =
providers.firstOrNull { manager.isProviderEnabled(it) }
?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available")
return withTimeout(timeoutMs.coerceAtLeast(1)) {
suspendCancellableCoroutine { cont ->
val signal = CancellationSignal()
cont.invokeOnCancellation { signal.cancel() }
manager.getCurrentLocation(resolved, signal, context.mainExecutor) { location ->
if (location != null) {
cont.resume(location)
} else {
cont.resumeWithException(IllegalStateException("LOCATION_UNAVAILABLE: no fix"))
}
}
}
}
}
}
@@ -1,11 +1,11 @@
package com.steipete.clawdis.node.node
package com.clawdbot.android.node
import android.content.Context
import android.hardware.display.DisplayManager
import android.media.MediaRecorder
import android.media.projection.MediaProjectionManager
import android.util.Base64
import com.steipete.clawdis.node.ScreenCaptureRequester
import com.clawdbot.android.ScreenCaptureRequester
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
@@ -16,13 +16,13 @@ class ScreenRecordManager(private val context: Context) {
data class Payload(val payloadJson: String)
@Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null
@Volatile private var permissionRequester: com.steipete.clawdis.node.PermissionRequester? = null
@Volatile private var permissionRequester: com.clawdbot.android.PermissionRequester? = null
fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) {
screenCaptureRequester = requester
}
fun attachPermissionRequester(requester: com.steipete.clawdis.node.PermissionRequester) {
fun attachPermissionRequester(requester: com.clawdbot.android.PermissionRequester) {
permissionRequester = requester
}
@@ -62,7 +62,7 @@ class ScreenRecordManager(private val context: Context) {
val height = metrics.heightPixels
val densityDpi = metrics.densityDpi
val file = File.createTempFile("clawdis-screen-", ".mp4")
val file = File.createTempFile("clawdbot-screen-", ".mp4")
if (includeAudio) ensureMicPermission()
val recorder = MediaRecorder()
@@ -89,7 +89,7 @@ class ScreenRecordManager(private val context: Context) {
val surface = recorder.surface
virtualDisplay =
projection.createVirtualDisplay(
"clawdis-screen",
"clawdbot-screen",
width,
height,
densityDpi,
@@ -0,0 +1,230 @@
package com.clawdbot.android.node
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.telephony.SmsManager as AndroidSmsManager
import androidx.core.content.ContextCompat
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.encodeToString
import com.clawdbot.android.PermissionRequester
/**
* Sends SMS messages via the Android SMS API.
* Requires SEND_SMS permission to be granted.
*/
class SmsManager(private val context: Context) {
private val json = JsonConfig
@Volatile private var permissionRequester: PermissionRequester? = null
data class SendResult(
val ok: Boolean,
val to: String,
val message: String?,
val error: String? = null,
val payloadJson: String,
)
internal data class ParsedParams(
val to: String,
val message: String,
)
internal sealed class ParseResult {
data class Ok(val params: ParsedParams) : ParseResult()
data class Error(
val error: String,
val to: String = "",
val message: String? = null,
) : ParseResult()
}
internal data class SendPlan(
val parts: List<String>,
val useMultipart: Boolean,
)
companion object {
internal val JsonConfig = Json { ignoreUnknownKeys = true }
internal fun parseParams(paramsJson: String?, json: Json = JsonConfig): ParseResult {
val params = paramsJson?.trim().orEmpty()
if (params.isEmpty()) {
return ParseResult.Error(error = "INVALID_REQUEST: paramsJSON required")
}
val obj = try {
json.parseToJsonElement(params).jsonObject
} catch (_: Throwable) {
null
}
if (obj == null) {
return ParseResult.Error(error = "INVALID_REQUEST: expected JSON object")
}
val to = (obj["to"] as? JsonPrimitive)?.content?.trim().orEmpty()
val message = (obj["message"] as? JsonPrimitive)?.content.orEmpty()
if (to.isEmpty()) {
return ParseResult.Error(
error = "INVALID_REQUEST: 'to' phone number required",
message = message,
)
}
if (message.isEmpty()) {
return ParseResult.Error(
error = "INVALID_REQUEST: 'message' text required",
to = to,
)
}
return ParseResult.Ok(ParsedParams(to = to, message = message))
}
internal fun buildSendPlan(
message: String,
divider: (String) -> List<String>,
): SendPlan {
val parts = divider(message).ifEmpty { listOf(message) }
return SendPlan(parts = parts, useMultipart = parts.size > 1)
}
internal fun buildPayloadJson(
json: Json = JsonConfig,
ok: Boolean,
to: String,
error: String?,
): String {
val payload =
mutableMapOf<String, JsonElement>(
"ok" to JsonPrimitive(ok),
"to" to JsonPrimitive(to),
)
if (!ok) {
payload["error"] = JsonPrimitive(error ?: "SMS_SEND_FAILED")
}
return json.encodeToString(JsonObject.serializer(), JsonObject(payload))
}
}
fun hasSmsPermission(): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.SEND_SMS
) == PackageManager.PERMISSION_GRANTED
}
fun canSendSms(): Boolean {
return hasSmsPermission() && hasTelephonyFeature()
}
fun hasTelephonyFeature(): Boolean {
return context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
}
fun attachPermissionRequester(requester: PermissionRequester) {
permissionRequester = requester
}
/**
* Send an SMS message.
*
* @param paramsJson JSON with "to" (phone number) and "message" (text) fields
* @return SendResult indicating success or failure
*/
suspend fun send(paramsJson: String?): SendResult {
if (!hasTelephonyFeature()) {
return errorResult(
error = "SMS_UNAVAILABLE: telephony not available",
)
}
if (!ensureSmsPermission()) {
return errorResult(
error = "SMS_PERMISSION_REQUIRED: grant SMS permission",
)
}
val parseResult = parseParams(paramsJson, json)
if (parseResult is ParseResult.Error) {
return errorResult(
error = parseResult.error,
to = parseResult.to,
message = parseResult.message,
)
}
val params = (parseResult as ParseResult.Ok).params
return try {
val smsManager = context.getSystemService(AndroidSmsManager::class.java)
?: throw IllegalStateException("SMS_UNAVAILABLE: SmsManager not available")
val plan = buildSendPlan(params.message) { smsManager.divideMessage(it) }
if (plan.useMultipart) {
smsManager.sendMultipartTextMessage(
params.to, // destination
null, // service center (null = default)
ArrayList(plan.parts), // message parts
null, // sent intents
null, // delivery intents
)
} else {
smsManager.sendTextMessage(
params.to, // destination
null, // service center (null = default)
params.message,// message
null, // sent intent
null, // delivery intent
)
}
okResult(to = params.to, message = params.message)
} catch (e: SecurityException) {
errorResult(
error = "SMS_PERMISSION_REQUIRED: ${e.message}",
to = params.to,
message = params.message,
)
} catch (e: Throwable) {
errorResult(
error = "SMS_SEND_FAILED: ${e.message ?: "unknown error"}",
to = params.to,
message = params.message,
)
}
}
private suspend fun ensureSmsPermission(): Boolean {
if (hasSmsPermission()) return true
val requester = permissionRequester ?: return false
val results = requester.requestIfMissing(listOf(Manifest.permission.SEND_SMS))
return results[Manifest.permission.SEND_SMS] == true
}
private fun okResult(to: String, message: String): SendResult {
return SendResult(
ok = true,
to = to,
message = message,
error = null,
payloadJson = buildPayloadJson(json = json, ok = true, to = to, error = null),
)
}
private fun errorResult(error: String, to: String = "", message: String? = null): SendResult {
return SendResult(
ok = false,
to = to,
message = message,
error = error,
payloadJson = buildPayloadJson(json = json, ok = false, to = to, error = error),
)
}
}
@@ -1,6 +1,24 @@
package com.steipete.clawdis.node.protocol
package com.clawdbot.android.protocol
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
object ClawdbotCanvasA2UIAction {
fun extractActionName(userAction: JsonObject): String? {
val name =
(userAction["name"] as? JsonPrimitive)
?.content
?.trim()
.orEmpty()
if (name.isNotEmpty()) return name
val action =
(userAction["action"] as? JsonPrimitive)
?.content
?.trim()
.orEmpty()
return action.ifEmpty { null }
}
object ClawdisCanvasA2UIAction {
fun sanitizeTagValue(value: String): String {
val trimmed = value.trim().ifEmpty { "-" }
val normalized = trimmed.replace(" ", "_")
@@ -43,7 +61,6 @@ object ClawdisCanvasA2UIAction {
val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"")
val okLiteral = if (ok) "true" else "false"
val idEscaped = actionId.replace("\\", "\\\\").replace("\"", "\\\"")
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
return "window.dispatchEvent(new CustomEvent('clawdbot:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
}
}
@@ -1,13 +1,15 @@
package com.steipete.clawdis.node.protocol
package com.clawdbot.android.protocol
enum class ClawdisCapability(val rawValue: String) {
enum class ClawdbotCapability(val rawValue: String) {
Canvas("canvas"),
Camera("camera"),
Screen("screen"),
Sms("sms"),
VoiceWake("voiceWake"),
Location("location"),
}
enum class ClawdisCanvasCommand(val rawValue: String) {
enum class ClawdbotCanvasCommand(val rawValue: String) {
Present("canvas.present"),
Hide("canvas.hide"),
Navigate("canvas.navigate"),
@@ -20,7 +22,7 @@ enum class ClawdisCanvasCommand(val rawValue: String) {
}
}
enum class ClawdisCanvasA2UICommand(val rawValue: String) {
enum class ClawdbotCanvasA2UICommand(val rawValue: String) {
Push("canvas.a2ui.push"),
PushJSONL("canvas.a2ui.pushJSONL"),
Reset("canvas.a2ui.reset"),
@@ -31,7 +33,7 @@ enum class ClawdisCanvasA2UICommand(val rawValue: String) {
}
}
enum class ClawdisCameraCommand(val rawValue: String) {
enum class ClawdbotCameraCommand(val rawValue: String) {
Snap("camera.snap"),
Clip("camera.clip"),
;
@@ -41,7 +43,7 @@ enum class ClawdisCameraCommand(val rawValue: String) {
}
}
enum class ClawdisScreenCommand(val rawValue: String) {
enum class ClawdbotScreenCommand(val rawValue: String) {
Record("screen.record"),
;
@@ -49,3 +51,21 @@ enum class ClawdisScreenCommand(val rawValue: String) {
const val NamespacePrefix: String = "screen."
}
}
enum class ClawdbotSmsCommand(val rawValue: String) {
Send("sms.send"),
;
companion object {
const val NamespacePrefix: String = "sms."
}
}
enum class ClawdbotLocationCommand(val rawValue: String) {
Get("location.get"),
;
companion object {
const val NamespacePrefix: String = "location."
}
}
@@ -0,0 +1,222 @@
package com.clawdbot.android.tools
import android.content.Context
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
@Serializable
private data class ToolDisplayActionSpec(
val label: String? = null,
val detailKeys: List<String>? = null,
)
@Serializable
private data class ToolDisplaySpec(
val emoji: String? = null,
val title: String? = null,
val label: String? = null,
val detailKeys: List<String>? = null,
val actions: Map<String, ToolDisplayActionSpec>? = null,
)
@Serializable
private data class ToolDisplayConfig(
val version: Int? = null,
val fallback: ToolDisplaySpec? = null,
val tools: Map<String, ToolDisplaySpec>? = null,
)
data class ToolDisplaySummary(
val name: String,
val emoji: String,
val title: String,
val label: String,
val verb: String?,
val detail: String?,
) {
val detailLine: String?
get() {
val parts = mutableListOf<String>()
if (!verb.isNullOrBlank()) parts.add(verb)
if (!detail.isNullOrBlank()) parts.add(detail)
return if (parts.isEmpty()) null else parts.joinToString(" · ")
}
val summaryLine: String
get() = if (detailLine != null) "${emoji} ${label}: ${detailLine}" else "${emoji} ${label}"
}
object ToolDisplayRegistry {
private const val CONFIG_ASSET = "tool-display.json"
private val json = Json { ignoreUnknownKeys = true }
@Volatile private var cachedConfig: ToolDisplayConfig? = null
fun resolve(
context: Context,
name: String?,
args: JsonObject?,
meta: String? = null,
): ToolDisplaySummary {
val trimmedName = name?.trim().orEmpty().ifEmpty { "tool" }
val key = trimmedName.lowercase()
val config = loadConfig(context)
val spec = config.tools?.get(key)
val fallback = config.fallback
val emoji = spec?.emoji ?: fallback?.emoji ?: "🧩"
val title = spec?.title ?: titleFromName(trimmedName)
val label = spec?.label ?: trimmedName
val actionRaw = args?.get("action")?.asStringOrNull()?.trim()
val action = actionRaw?.takeIf { it.isNotEmpty() }
val actionSpec = action?.let { spec?.actions?.get(it) }
val verb = normalizeVerb(actionSpec?.label ?: action)
var detail: String? = null
if (key == "read") {
detail = readDetail(args)
} else if (key == "write" || key == "edit" || key == "attach") {
detail = pathDetail(args)
}
val detailKeys = actionSpec?.detailKeys ?: spec?.detailKeys ?: fallback?.detailKeys ?: emptyList()
if (detail == null) {
detail = firstValue(args, detailKeys)
}
if (detail == null) {
detail = meta
}
if (detail != null) {
detail = shortenHomeInString(detail)
}
return ToolDisplaySummary(
name = trimmedName,
emoji = emoji,
title = title,
label = label,
verb = verb,
detail = detail,
)
}
private fun loadConfig(context: Context): ToolDisplayConfig {
val existing = cachedConfig
if (existing != null) return existing
return try {
val jsonString = context.assets.open(CONFIG_ASSET).bufferedReader().use { it.readText() }
val decoded = json.decodeFromString(ToolDisplayConfig.serializer(), jsonString)
cachedConfig = decoded
decoded
} catch (_: Throwable) {
val fallback = ToolDisplayConfig()
cachedConfig = fallback
fallback
}
}
private fun titleFromName(name: String): String {
val cleaned = name.replace("_", " ").trim()
if (cleaned.isEmpty()) return "Tool"
return cleaned
.split(Regex("\\s+"))
.joinToString(" ") { part ->
val upper = part.uppercase()
if (part.length <= 2 && part == upper) part
else upper.firstOrNull()?.toString().orEmpty() + part.lowercase().drop(1)
}
}
private fun normalizeVerb(value: String?): String? {
val trimmed = value?.trim().orEmpty()
if (trimmed.isEmpty()) return null
return trimmed.replace("_", " ")
}
private fun readDetail(args: JsonObject?): String? {
val path = args?.get("path")?.asStringOrNull() ?: return null
val offset = args["offset"].asNumberOrNull()
val limit = args["limit"].asNumberOrNull()
return if (offset != null && limit != null) {
val end = offset + limit
"${path}:${offset.toInt()}-${end.toInt()}"
} else {
path
}
}
private fun pathDetail(args: JsonObject?): String? {
return args?.get("path")?.asStringOrNull()
}
private fun firstValue(args: JsonObject?, keys: List<String>): String? {
for (key in keys) {
val value = valueForPath(args, key)
val rendered = renderValue(value)
if (!rendered.isNullOrBlank()) return rendered
}
return null
}
private fun valueForPath(args: JsonObject?, path: String): JsonElement? {
var current: JsonElement? = args
for (segment in path.split(".")) {
if (segment.isBlank()) return null
val obj = current as? JsonObject ?: return null
current = obj[segment]
}
return current
}
private fun renderValue(value: JsonElement?): String? {
if (value == null) return null
if (value is JsonPrimitive) {
if (value.isString) {
val trimmed = value.contentOrNull?.trim().orEmpty()
if (trimmed.isEmpty()) return null
val firstLine = trimmed.lineSequence().firstOrNull()?.trim().orEmpty()
if (firstLine.isEmpty()) return null
return if (firstLine.length > 160) "${firstLine.take(157)}" else firstLine
}
val raw = value.contentOrNull?.trim().orEmpty()
raw.toBooleanStrictOrNull()?.let { return it.toString() }
raw.toLongOrNull()?.let { return it.toString() }
raw.toDoubleOrNull()?.let { return it.toString() }
}
if (value is JsonArray) {
val items = value.mapNotNull { renderValue(it) }
if (items.isEmpty()) return null
val preview = items.take(3).joinToString(", ")
return if (items.size > 3) "${preview}" else preview
}
return null
}
private fun shortenHomeInString(value: String): String {
val home = System.getProperty("user.home")?.takeIf { it.isNotBlank() }
?: System.getenv("HOME")?.takeIf { it.isNotBlank() }
if (home.isNullOrEmpty()) return value
return value.replace(home, "~")
.replace(Regex("/Users/[^/]+"), "~")
.replace(Regex("/home/[^/]+"), "~")
}
private fun JsonElement?.asStringOrNull(): String? {
val primitive = this as? JsonPrimitive ?: return null
return if (primitive.isString) primitive.contentOrNull else primitive.toString()
}
private fun JsonElement?.asNumberOrNull(): Double? {
val primitive = this as? JsonPrimitive ?: return null
val raw = primitive.contentOrNull ?: return null
return raw.toDoubleOrNull()
}
}
@@ -0,0 +1,44 @@
package com.clawdbot.android.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.graphics.Color
import kotlinx.coroutines.delay
@Composable
fun CameraFlashOverlay(
token: Long,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize()) {
CameraFlash(token = token)
}
}
@Composable
private fun CameraFlash(token: Long) {
var alpha by remember { mutableFloatStateOf(0f) }
LaunchedEffect(token) {
if (token == 0L) return@LaunchedEffect
alpha = 0.85f
delay(110)
alpha = 0f
}
Box(
modifier =
Modifier
.fillMaxSize()
.alpha(alpha)
.background(Color.White),
)
}
@@ -1,8 +1,8 @@
package com.steipete.clawdis.node.ui
package com.clawdbot.android.ui
import androidx.compose.runtime.Composable
import com.steipete.clawdis.node.MainViewModel
import com.steipete.clawdis.node.ui.chat.ChatSheetContent
import com.clawdbot.android.MainViewModel
import com.clawdbot.android.ui.chat.ChatSheetContent
@Composable
fun ChatSheet(viewModel: MainViewModel) {
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui
package com.clawdbot.android.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
@@ -9,7 +9,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
@Composable
fun ClawdisTheme(content: @Composable () -> Unit) {
fun ClawdbotTheme(content: @Composable () -> Unit) {
val context = LocalContext.current
val isDark = isSystemInDarkTheme()
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
@@ -0,0 +1,444 @@
package com.clawdbot.android.ui
import android.annotation.SuppressLint
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Color
import android.util.Log
import android.view.View
import android.webkit.JavascriptInterface
import android.webkit.ConsoleMessage
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebSettings
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebViewClient
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewFeature
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.RecordVoiceOver
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.ScreenShare
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color as ComposeColor
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.core.content.ContextCompat
import com.clawdbot.android.CameraHudKind
import com.clawdbot.android.MainViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RootScreen(viewModel: MainViewModel) {
var sheet by remember { mutableStateOf<Sheet?>(null) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
val context = LocalContext.current
val serverName by viewModel.serverName.collectAsState()
val statusText by viewModel.statusText.collectAsState()
val cameraHud by viewModel.cameraHud.collectAsState()
val cameraFlashToken by viewModel.cameraFlashToken.collectAsState()
val screenRecordActive by viewModel.screenRecordActive.collectAsState()
val isForeground by viewModel.isForeground.collectAsState()
val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState()
val talkEnabled by viewModel.talkEnabled.collectAsState()
val talkStatusText by viewModel.talkStatusText.collectAsState()
val talkIsListening by viewModel.talkIsListening.collectAsState()
val talkIsSpeaking by viewModel.talkIsSpeaking.collectAsState()
val seamColorArgb by viewModel.seamColorArgb.collectAsState()
val seamColor = remember(seamColorArgb) { ComposeColor(seamColorArgb) }
val audioPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) viewModel.setTalkEnabled(true)
}
val activity =
remember(cameraHud, screenRecordActive, isForeground, statusText, voiceWakeStatusText) {
// Status pill owns transient activity state so it doesn't overlap the connection indicator.
if (!isForeground) {
return@remember StatusActivity(
title = "Foreground required",
icon = Icons.Default.Report,
contentDescription = "Foreground required",
)
}
val lowerStatus = statusText.lowercase()
if (lowerStatus.contains("repair")) {
return@remember StatusActivity(
title = "Repairing…",
icon = Icons.Default.Refresh,
contentDescription = "Repairing",
)
}
if (lowerStatus.contains("pairing") || lowerStatus.contains("approval")) {
return@remember StatusActivity(
title = "Approval pending",
icon = Icons.Default.RecordVoiceOver,
contentDescription = "Approval pending",
)
}
// Avoid duplicating the primary bridge status ("Connecting…") in the activity slot.
if (screenRecordActive) {
return@remember StatusActivity(
title = "Recording screen…",
icon = Icons.Default.ScreenShare,
contentDescription = "Recording screen",
tint = androidx.compose.ui.graphics.Color.Red,
)
}
cameraHud?.let { hud ->
return@remember when (hud.kind) {
CameraHudKind.Photo ->
StatusActivity(
title = hud.message,
icon = Icons.Default.PhotoCamera,
contentDescription = "Taking photo",
)
CameraHudKind.Recording ->
StatusActivity(
title = hud.message,
icon = Icons.Default.FiberManualRecord,
contentDescription = "Recording",
tint = androidx.compose.ui.graphics.Color.Red,
)
CameraHudKind.Success ->
StatusActivity(
title = hud.message,
icon = Icons.Default.CheckCircle,
contentDescription = "Capture finished",
)
CameraHudKind.Error ->
StatusActivity(
title = hud.message,
icon = Icons.Default.Error,
contentDescription = "Capture failed",
tint = androidx.compose.ui.graphics.Color.Red,
)
}
}
if (voiceWakeStatusText.contains("Microphone permission", ignoreCase = true)) {
return@remember StatusActivity(
title = "Mic permission",
icon = Icons.Default.Error,
contentDescription = "Mic permission required",
)
}
if (voiceWakeStatusText == "Paused") {
val suffix = if (!isForeground) " (background)" else ""
return@remember StatusActivity(
title = "Voice Wake paused$suffix",
icon = Icons.Default.RecordVoiceOver,
contentDescription = "Voice Wake paused",
)
}
null
}
val bridgeState =
remember(serverName, statusText) {
when {
serverName != null -> BridgeState.Connected
statusText.contains("connecting", ignoreCase = true) ||
statusText.contains("reconnecting", ignoreCase = true) -> BridgeState.Connecting
statusText.contains("error", ignoreCase = true) -> BridgeState.Error
else -> BridgeState.Disconnected
}
}
val voiceEnabled =
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
Box(modifier = Modifier.fillMaxSize()) {
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
}
// Camera flash must be in a Popup to render above the WebView.
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
CameraFlashOverlay(token = cameraFlashToken, modifier = Modifier.fillMaxSize())
}
// 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,
voiceEnabled = voiceEnabled,
activity = activity,
onClick = { sheet = Sheet.Settings },
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(start = 12.dp, top = 12.dp),
)
}
Popup(alignment = Alignment.TopEnd, properties = PopupProperties(focusable = false)) {
Column(
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(end = 12.dp, top = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.End,
) {
OverlayIconButton(
onClick = { sheet = Sheet.Chat },
icon = { Icon(Icons.Default.ChatBubble, contentDescription = "Chat") },
)
// Talk mode gets a dedicated side bubble instead of burying it in settings.
val baseOverlay = overlayContainerColor()
val talkContainer =
lerp(
baseOverlay,
seamColor.copy(alpha = baseOverlay.alpha),
if (talkEnabled) 0.35f else 0.22f,
)
val talkContent = if (talkEnabled) seamColor else overlayIconColor()
OverlayIconButton(
onClick = {
val next = !talkEnabled
if (next) {
val micOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
viewModel.setTalkEnabled(true)
} else {
viewModel.setTalkEnabled(false)
}
},
containerColor = talkContainer,
contentColor = talkContent,
icon = {
Icon(
Icons.Default.RecordVoiceOver,
contentDescription = "Talk Mode",
)
},
)
OverlayIconButton(
onClick = { sheet = Sheet.Settings },
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
)
}
}
if (talkEnabled) {
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
TalkOrbOverlay(
seamColor = seamColor,
statusText = talkStatusText,
isListening = talkIsListening,
isSpeaking = talkIsSpeaking,
)
}
}
val currentSheet = sheet
if (currentSheet != null) {
ModalBottomSheet(
onDismissRequest = { sheet = null },
sheetState = sheetState,
) {
when (currentSheet) {
Sheet.Chat -> ChatSheet(viewModel = viewModel)
Sheet.Settings -> SettingsSheet(viewModel = viewModel)
}
}
}
}
private enum class Sheet {
Chat,
Settings,
}
@Composable
private fun OverlayIconButton(
onClick: () -> Unit,
icon: @Composable () -> Unit,
containerColor: ComposeColor? = null,
contentColor: ComposeColor? = null,
) {
FilledTonalIconButton(
onClick = onClick,
modifier = Modifier.size(44.dp),
colors =
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = containerColor ?: overlayContainerColor(),
contentColor = contentColor ?: overlayIconColor(),
),
) {
icon()
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) {
val context = LocalContext.current
val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
AndroidView(
modifier = modifier,
factory = {
WebView(context).apply {
settings.javaScriptEnabled = true
// Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage.
settings.domStorageEnabled = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF)
}
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
}
if (isDebuggable) {
Log.d("ClawdbotWebView", "userAgent: ${settings.userAgentString}")
}
isScrollContainer = true
overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS
isVerticalScrollBarEnabled = true
isHorizontalScrollBarEnabled = true
webViewClient =
object : WebViewClient() {
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
if (!isDebuggable) return
if (!request.isForMainFrame) return
Log.e("ClawdbotWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}")
}
override fun onReceivedHttpError(
view: WebView,
request: WebResourceRequest,
errorResponse: WebResourceResponse,
) {
if (!isDebuggable) return
if (!request.isForMainFrame) return
Log.e(
"ClawdbotWebView",
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
)
}
override fun onPageFinished(view: WebView, url: String?) {
if (isDebuggable) {
Log.d("ClawdbotWebView", "onPageFinished: $url")
}
viewModel.canvas.onPageFinished()
}
override fun onRenderProcessGone(
view: WebView,
detail: android.webkit.RenderProcessGoneDetail,
): Boolean {
if (isDebuggable) {
Log.e(
"ClawdbotWebView",
"onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}",
)
}
return true
}
}
webChromeClient =
object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
if (!isDebuggable) return false
val msg = consoleMessage ?: return false
Log.d(
"ClawdbotWebView",
"console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}",
)
return false
}
}
// Use default layer/background; avoid forcing a black fill over WebView content.
val a2uiBridge =
CanvasA2UIActionBridge { payload ->
viewModel.handleCanvasA2UIActionFromWebView(payload)
}
addJavascriptInterface(a2uiBridge, CanvasA2UIActionBridge.interfaceName)
addJavascriptInterface(
CanvasA2UIActionLegacyBridge(a2uiBridge),
CanvasA2UIActionLegacyBridge.interfaceName,
)
viewModel.canvas.attach(this)
}
},
)
}
private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
@JavascriptInterface
fun postMessage(payload: String?) {
val msg = payload?.trim().orEmpty()
if (msg.isEmpty()) return
onMessage(msg)
}
companion object {
const val interfaceName: String = "clawdbotCanvasA2UIAction"
}
}
private class CanvasA2UIActionLegacyBridge(private val bridge: CanvasA2UIActionBridge) {
@JavascriptInterface
fun canvasAction(payload: String?) {
bridge.postMessage(payload)
}
@JavascriptInterface
fun postMessage(payload: String?) {
bridge.postMessage(payload)
}
companion object {
const val interfaceName: String = "Android"
}
}
@@ -1,7 +1,12 @@
package com.steipete.clawdis.node.ui
package com.clawdbot.android.ui
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
@@ -41,14 +46,17 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.steipete.clawdis.node.MainViewModel
import com.steipete.clawdis.node.NodeForegroundService
import com.steipete.clawdis.node.VoiceWakeMode
import com.clawdbot.android.BuildConfig
import com.clawdbot.android.LocationMode
import com.clawdbot.android.MainViewModel
import com.clawdbot.android.NodeForegroundService
import com.clawdbot.android.VoiceWakeMode
@Composable
fun SettingsSheet(viewModel: MainViewModel) {
@@ -56,6 +64,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
val instanceId by viewModel.instanceId.collectAsState()
val displayName by viewModel.displayName.collectAsState()
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
val locationMode by viewModel.locationMode.collectAsState()
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
val preventSleep by viewModel.preventSleep.collectAsState()
val wakeWords by viewModel.wakeWords.collectAsState()
val voiceWakeMode by viewModel.voiceWakeMode.collectAsState()
@@ -64,6 +74,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
val manualEnabled by viewModel.manualEnabled.collectAsState()
val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.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()
@@ -73,6 +84,22 @@ fun SettingsSheet(viewModel: MainViewModel) {
val listState = rememberLazyListState()
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
val deviceModel =
remember {
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
.joinToString(" ")
.trim()
.ifEmpty { "Android" }
}
val appVersion =
remember {
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
"$versionName-dev"
} else {
versionName
}
}
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
@@ -82,11 +109,63 @@ fun SettingsSheet(viewModel: MainViewModel) {
viewModel.setCameraEnabled(cameraOk)
}
var pendingLocationMode by remember { mutableStateOf<LocationMode?>(null) }
var pendingPreciseToggle by remember { mutableStateOf(false) }
val locationPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
val fineOk = perms[Manifest.permission.ACCESS_FINE_LOCATION] == true
val coarseOk = perms[Manifest.permission.ACCESS_COARSE_LOCATION] == true
val granted = fineOk || coarseOk
val requestedMode = pendingLocationMode
pendingLocationMode = null
if (pendingPreciseToggle) {
pendingPreciseToggle = false
viewModel.setLocationPreciseEnabled(fineOk)
return@rememberLauncherForActivityResult
}
if (!granted) {
viewModel.setLocationMode(LocationMode.Off)
return@rememberLauncherForActivityResult
}
if (requestedMode != null) {
viewModel.setLocationMode(requestedMode)
if (requestedMode == LocationMode.Always) {
val backgroundOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (!backgroundOk) {
openAppSettings(context)
}
}
}
}
val audioPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ ->
// Status text is handled by NodeRuntime.
}
val smsPermissionAvailable =
remember {
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
}
var smsPermissionGranted by
remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
PackageManager.PERMISSION_GRANTED,
)
}
val smsPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
smsPermissionGranted = granted
viewModel.refreshBridgeHello()
}
fun setCameraEnabledChecked(checked: Boolean) {
if (!checked) {
viewModel.setCameraEnabled(false)
@@ -103,6 +182,47 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
fun requestLocationPermissions(targetMode: LocationMode) {
val fineOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val coarseOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (fineOk || coarseOk) {
viewModel.setLocationMode(targetMode)
if (targetMode == LocationMode.Always) {
val backgroundOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (!backgroundOk) {
openAppSettings(context)
}
}
} else {
pendingLocationMode = targetMode
locationPermissionLauncher.launch(
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
)
}
}
fun setPreciseLocationChecked(checked: Boolean) {
if (!checked) {
viewModel.setLocationPreciseEnabled(false)
return
}
val fineOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (fineOk) {
viewModel.setLocationPreciseEnabled(true)
} else {
pendingPreciseToggle = true
locationPermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION))
}
}
val visibleBridges =
if (isConnected && remoteAddress != null) {
bridges.filterNot { "${it.host}:${it.port}" == remoteAddress }
@@ -130,7 +250,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
// Order parity: Node → Bridge → Voice → Camera → Screen.
// Order parity: Node → Bridge → Voice → Camera → Messaging → Location → Screen.
item { Text("Node", style = MaterialTheme.typography.titleSmall) }
item {
OutlinedTextField(
@@ -141,6 +261,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
)
}
item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) }
item { Text("Device: $deviceModel", color = MaterialTheme.colorScheme.onSurfaceVariant) }
item { Text("Version: $appVersion", color = MaterialTheme.colorScheme.onSurfaceVariant) }
item { HorizontalDivider() }
@@ -180,9 +302,27 @@ fun SettingsSheet(viewModel: MainViewModel) {
item { Text("No bridges found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
} else {
items(items = visibleBridges, key = { it.stableId }) { bridge ->
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")
}
}
ListItem(
headlineContent = { Text(bridge.name) },
supportingContent = { Text("${bridge.host}:${bridge.port}") },
supportingContent = {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
detailLines.forEach { line ->
Text(line, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
},
trailingContent = {
Button(
onClick = {
@@ -296,7 +436,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
ListItem(
headlineContent = { Text("Foreground Only") },
supportingContent = { Text("Listens only while Clawdis is open.") },
supportingContent = { Text("Listens only while Clawdbot is open.") },
trailingContent = {
RadioButton(
selected = voiceWakeMode == VoiceWakeMode.Foreground,
@@ -342,7 +482,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = {
val parsed = com.steipete.clawdis.node.WakeWords.parseCommaSeparated(wakeWordsText)
val parsed = com.clawdbot.android.WakeWords.parseCommaSeparated(wakeWordsText)
viewModel.setWakeWords(parsed)
},
enabled = isConnected,
@@ -384,16 +524,140 @@ fun SettingsSheet(viewModel: MainViewModel) {
item { HorizontalDivider() }
// Messaging
item { Text("Messaging", style = MaterialTheme.typography.titleSmall) }
item {
val buttonLabel =
when {
!smsPermissionAvailable -> "Unavailable"
smsPermissionGranted -> "Manage"
else -> "Grant"
}
ListItem(
headlineContent = { Text("SMS Permission") },
supportingContent = {
Text(
if (smsPermissionAvailable) {
"Allow the bridge to send SMS from this device."
} else {
"SMS requires a device with telephony hardware."
},
)
},
trailingContent = {
Button(
onClick = {
if (!smsPermissionAvailable) return@Button
if (smsPermissionGranted) {
openAppSettings(context)
} else {
smsPermissionLauncher.launch(Manifest.permission.SEND_SMS)
}
},
enabled = smsPermissionAvailable,
) {
Text(buttonLabel)
}
},
)
}
item { HorizontalDivider() }
// Location
item { Text("Location", style = MaterialTheme.typography.titleSmall) }
item {
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
ListItem(
headlineContent = { Text("Off") },
supportingContent = { Text("Disable location sharing.") },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.Off,
onClick = { viewModel.setLocationMode(LocationMode.Off) },
)
},
)
ListItem(
headlineContent = { Text("While Using") },
supportingContent = { Text("Only while Clawdbot is open.") },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.WhileUsing,
onClick = { requestLocationPermissions(LocationMode.WhileUsing) },
)
},
)
ListItem(
headlineContent = { Text("Always") },
supportingContent = { Text("Allow background location (requires system permission).") },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.Always,
onClick = { requestLocationPermissions(LocationMode.Always) },
)
},
)
}
}
item {
ListItem(
headlineContent = { Text("Precise Location") },
supportingContent = { Text("Use precise GPS when available.") },
trailingContent = {
Switch(
checked = locationPreciseEnabled,
onCheckedChange = ::setPreciseLocationChecked,
enabled = locationMode != LocationMode.Off,
)
},
)
}
item {
Text(
"Always may require Android Settings to allow background location.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
item { HorizontalDivider() }
// Screen
item { Text("Screen", style = MaterialTheme.typography.titleSmall) }
item {
ListItem(
headlineContent = { Text("Prevent Sleep") },
supportingContent = { Text("Keeps the screen awake while Clawdis is open.") },
supportingContent = { Text("Keeps the screen awake while Clawdbot is open.") },
trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) },
)
}
item { HorizontalDivider() }
// Debug
item { Text("Debug", style = MaterialTheme.typography.titleSmall) }
item {
ListItem(
headlineContent = { Text("Debug Canvas Status") },
supportingContent = { Text("Show status text in the canvas when debug is enabled.") },
trailingContent = {
Switch(
checked = canvasDebugStatusEnabled,
onCheckedChange = viewModel::setCanvasDebugStatusEnabled,
)
},
)
}
item { Spacer(modifier = Modifier.height(20.dp)) }
}
}
private fun openAppSettings(context: Context) {
val intent =
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
)
context.startActivity(intent)
}
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui
package com.clawdbot.android.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
@@ -30,6 +30,7 @@ fun StatusPill(
voiceEnabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
activity: StatusActivity? = null,
) {
Surface(
onClick = onClick,
@@ -62,23 +63,49 @@ fun StatusPill(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Icon(
imageVector = if (voiceEnabled) Icons.Default.Mic else Icons.Default.MicOff,
contentDescription = if (voiceEnabled) "Voice enabled" else "Voice disabled",
tint =
if (voiceEnabled) {
overlayIconColor()
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(18.dp),
)
if (activity != null) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = activity.icon,
contentDescription = activity.contentDescription,
tint = activity.tint ?: overlayIconColor(),
modifier = Modifier.size(18.dp),
)
Text(
text = activity.title,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
)
}
} else {
Icon(
imageVector = if (voiceEnabled) Icons.Default.Mic else Icons.Default.MicOff,
contentDescription = if (voiceEnabled) "Voice enabled" else "Voice disabled",
tint =
if (voiceEnabled) {
overlayIconColor()
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(18.dp),
)
}
Spacer(modifier = Modifier.width(2.dp))
}
}
}
data class StatusActivity(
val title: String,
val icon: androidx.compose.ui.graphics.vector.ImageVector,
val contentDescription: String,
val tint: Color? = null,
)
enum class BridgeState(val title: String, val color: Color) {
Connected("Connected", Color(0xFF2ECC71)),
Connecting("Connecting…", Color(0xFFF1C40F)),
@@ -0,0 +1,134 @@
package com.clawdbot.android.ui
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun TalkOrbOverlay(
seamColor: Color,
statusText: String,
isListening: Boolean,
isSpeaking: Boolean,
modifier: Modifier = Modifier,
) {
val transition = rememberInfiniteTransition(label = "talk-orb")
val t by
transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec =
infiniteRepeatable(
animation = tween(durationMillis = 1500, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "pulse",
)
val trimmed = statusText.trim()
val showStatus = trimmed.isNotEmpty() && trimmed != "Off"
val phase =
when {
isSpeaking -> "Speaking"
isListening -> "Listening"
else -> "Thinking"
}
Column(
modifier = modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(contentAlignment = Alignment.Center) {
Canvas(modifier = Modifier.size(360.dp)) {
val center = this.center
val baseRadius = size.minDimension * 0.30f
val ring1 = 1.05f + (t * 0.25f)
val ring2 = 1.20f + (t * 0.55f)
val ringAlpha1 = (1f - t) * 0.34f
val ringAlpha2 = (1f - t) * 0.22f
drawCircle(
color = seamColor.copy(alpha = ringAlpha1),
radius = baseRadius * ring1,
center = center,
style = Stroke(width = 3.dp.toPx()),
)
drawCircle(
color = seamColor.copy(alpha = ringAlpha2),
radius = baseRadius * ring2,
center = center,
style = Stroke(width = 3.dp.toPx()),
)
drawCircle(
brush =
Brush.radialGradient(
colors =
listOf(
seamColor.copy(alpha = 0.92f),
seamColor.copy(alpha = 0.40f),
Color.Black.copy(alpha = 0.56f),
),
center = center,
radius = baseRadius * 1.35f,
),
radius = baseRadius,
center = center,
)
drawCircle(
color = seamColor.copy(alpha = 0.34f),
radius = baseRadius,
center = center,
style = Stroke(width = 1.dp.toPx()),
)
}
}
if (showStatus) {
Surface(
color = Color.Black.copy(alpha = 0.40f),
shape = CircleShape,
) {
Text(
text = trimmed,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
color = Color.White.copy(alpha = 0.92f),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
)
}
} else {
Text(
text = phase,
color = Color.White.copy(alpha = 0.80f),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
)
}
}
}
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui.chat
package com.clawdbot.android.ui.chat
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -15,7 +15,6 @@ import androidx.compose.foundation.horizontalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.ButtonDefaults
@@ -39,10 +38,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.clawdbot.android.chat.ChatSessionEntry
@Composable
fun ChatComposer(
sessionKey: String,
sessions: List<ChatSessionEntry>,
healthOk: Boolean,
thinkingLevel: String,
pendingRunCount: Int,
@@ -51,13 +52,18 @@ fun ChatComposer(
onPickImages: () -> Unit,
onRemoveAttachment: (id: String) -> Unit,
onSetThinkingLevel: (level: String) -> Unit,
onShowSessions: () -> Unit,
onSelectSession: (sessionKey: String) -> Unit,
onRefresh: () -> Unit,
onAbort: () -> Unit,
onSend: (text: String) -> Unit,
) {
var input by rememberSaveable { mutableStateOf("") }
var showThinkingMenu by remember { mutableStateOf(false) }
var showSessionMenu by remember { mutableStateOf(false) }
val sessionOptions = resolveSessionChoices(sessionKey, sessions)
val currentSessionLabel =
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
@@ -73,6 +79,34 @@ fun ChatComposer(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box {
FilledTonalButton(
onClick = { showSessionMenu = true },
contentPadding = ButtonDefaults.ContentPadding,
) {
Text("Session: $currentSessionLabel")
}
DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) {
for (entry in sessionOptions) {
DropdownMenuItem(
text = { Text(entry.displayName ?: entry.key) },
onClick = {
onSelectSession(entry.key)
showSessionMenu = false
},
trailingIcon = {
if (entry.key == sessionKey) {
Text("")
} else {
Spacer(modifier = Modifier.width(10.dp))
}
},
)
}
}
}
Box {
FilledTonalButton(
onClick = { showThinkingMenu = true },
@@ -91,10 +125,6 @@ fun ChatComposer(
Spacer(modifier = Modifier.weight(1f))
FilledTonalIconButton(onClick = onShowSessions, modifier = Modifier.size(42.dp)) {
Icon(Icons.Default.FolderOpen, contentDescription = "Sessions")
}
FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
@@ -118,7 +148,7 @@ fun ChatComposer(
)
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
ConnectionPill(sessionKey = sessionKey, healthOk = healthOk)
ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk)
Spacer(modifier = Modifier.weight(1f))
if (pendingRunCount > 0) {
@@ -156,7 +186,7 @@ fun ChatComposer(
}
@Composable
private fun ConnectionPill(sessionKey: String, healthOk: Boolean) {
private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) {
Surface(
shape = RoundedCornerShape(999.dp),
color = MaterialTheme.colorScheme.surfaceContainerHighest,
@@ -171,7 +201,7 @@ private fun ConnectionPill(sessionKey: String, healthOk: Boolean) {
shape = androidx.compose.foundation.shape.CircleShape,
color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12),
) {}
Text(sessionKey, style = MaterialTheme.typography.labelSmall)
Text(sessionLabel, style = MaterialTheme.typography.labelSmall)
Text(
if (healthOk) "Connected" else "Connecting…",
style = MaterialTheme.typography.labelSmall,
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui.chat
package com.clawdbot.android.ui.chat
import android.graphics.BitmapFactory
import android.util.Base64
@@ -17,6 +17,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.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.AnnotatedString
@@ -31,7 +32,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun ChatMarkdown(text: String) {
fun ChatMarkdown(text: String, textColor: Color) {
val blocks = remember(text) { splitMarkdown(text) }
val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow
@@ -44,7 +45,7 @@ fun ChatMarkdown(text: String) {
Text(
text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
color = textColor,
)
}
is ChatMarkdownBlock.Code -> {
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui.chat
package com.clawdbot.android.ui.chat
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -20,8 +20,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import com.steipete.clawdis.node.chat.ChatMessage
import com.steipete.clawdis.node.chat.ChatPendingToolCall
import com.clawdbot.android.chat.ChatMessage
import com.clawdbot.android.chat.ChatPendingToolCall
@Composable
fun ChatMessageListCard(
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui.chat
package com.clawdbot.android.ui.chat
import android.graphics.BitmapFactory
import android.util.Base64
@@ -7,11 +7,9 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
@@ -33,11 +31,13 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.Image
import com.steipete.clawdis.node.chat.ChatMessage
import com.steipete.clawdis.node.chat.ChatMessageContent
import com.steipete.clawdis.node.chat.ChatPendingToolCall
import com.clawdbot.android.chat.ChatMessage
import com.clawdbot.android.chat.ChatMessageContent
import com.clawdbot.android.chat.ChatPendingToolCall
import com.clawdbot.android.tools.ToolDisplayRegistry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.ui.platform.LocalContext
@Composable
fun ChatMessageBubble(message: ChatMessage) {
@@ -60,20 +60,21 @@ fun ChatMessageBubble(message: ChatMessage) {
.background(bubbleBackground(isUser))
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
ChatMessageBody(content = message.content)
val textColor = textColorOverBubble(isUser)
ChatMessageBody(content = message.content, textColor = textColor)
}
}
}
}
@Composable
private fun ChatMessageBody(content: List<ChatMessageContent>) {
private fun ChatMessageBody(content: List<ChatMessageContent>, textColor: Color) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
for (part in content) {
when (part.type) {
"text" -> {
val text = part.text ?: continue
ChatMarkdown(text = text)
ChatMarkdown(text = text, textColor = textColor)
}
else -> {
val b64 = part.base64 ?: continue
@@ -105,18 +106,42 @@ fun ChatTypingIndicatorBubble() {
@Composable
fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
val context = LocalContext.current
val displays =
remember(toolCalls, context) {
toolCalls.map { ToolDisplayRegistry.resolve(context, it.name, it.args) }
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Tools", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
for (t in toolCalls.take(6)) {
Text("· ${t.name}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("Running tools…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
for (display in displays.take(6)) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
"${display.emoji} ${display.label}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
display.detailLine?.let { detail ->
Text(
detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
}
}
}
if (toolCalls.size > 6) {
Text("… +${toolCalls.size - 6} more", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
"… +${toolCalls.size - 6} more",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -131,7 +156,7 @@ fun ChatStreamingAssistantBubble(text: String) {
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
ChatMarkdown(text = text)
ChatMarkdown(text = text, textColor = MaterialTheme.colorScheme.onSurface)
}
}
}
@@ -150,6 +175,15 @@ private fun bubbleBackground(isUser: Boolean): Brush {
}
}
@Composable
private fun textColorOverBubble(isUser: Boolean): Color {
return if (isUser) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurface
}
}
@Composable
private fun ChatBase64Image(base64: String, mimeType: String?) {
var image by remember(base64) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui.chat
package com.clawdbot.android.ui.chat
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -20,7 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.steipete.clawdis.node.chat.ChatSessionEntry
import com.clawdbot.android.chat.ChatSessionEntry
@Composable
fun ChatSessionsDialog(
@@ -82,7 +82,7 @@ private fun SessionRow(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(entry.key, style = MaterialTheme.typography.bodyMedium)
Text(entry.displayName ?: entry.key, style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.weight(1f))
if (isCurrent) {
Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
@@ -90,4 +90,3 @@ private fun SessionRow(
}
}
}
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui.chat
package com.clawdbot.android.ui.chat
import android.content.ContentResolver
import android.net.Uri
@@ -14,15 +14,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.steipete.clawdis.node.MainViewModel
import com.steipete.clawdis.node.chat.OutgoingAttachment
import com.clawdbot.android.MainViewModel
import com.clawdbot.android.chat.OutgoingAttachment
import java.io.ByteArrayOutputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -40,10 +38,9 @@ fun ChatSheetContent(viewModel: MainViewModel) {
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val sessions by viewModel.chatSessions.collectAsState()
var showSessions by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.loadChat("main")
viewModel.refreshChatSessions(limit = 200)
}
val context = LocalContext.current
@@ -87,6 +84,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
ChatComposer(
sessionKey = sessionKey,
sessions = sessions,
healthOk = healthOk,
thinkingLevel = thinkingLevel,
pendingRunCount = pendingRunCount,
@@ -95,8 +93,11 @@ fun ChatSheetContent(viewModel: MainViewModel) {
onPickImages = { pickImages.launch("image/*") },
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
onShowSessions = { showSessions = true },
onRefresh = { viewModel.refreshChat() },
onSelectSession = { key -> viewModel.switchChatSession(key) },
onRefresh = {
viewModel.refreshChat()
viewModel.refreshChatSessions(limit = 200)
},
onAbort = { viewModel.abortChat() },
onSend = { text ->
val outgoing =
@@ -113,19 +114,6 @@ fun ChatSheetContent(viewModel: MainViewModel) {
},
)
}
if (showSessions) {
ChatSessionsDialog(
currentSessionKey = sessionKey,
sessions = sessions,
onDismiss = { showSessions = false },
onRefresh = { viewModel.refreshChatSessions(limit = 50) },
onSelect = { key ->
viewModel.switchChatSession(key)
showSessions = false
},
)
}
}
data class PendingImageAttachment(
@@ -0,0 +1,46 @@
package com.clawdbot.android.ui.chat
import com.clawdbot.android.chat.ChatSessionEntry
private const val MAIN_SESSION_KEY = "main"
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
fun resolveSessionChoices(
currentSessionKey: String,
sessions: List<ChatSessionEntry>,
nowMs: Long = System.currentTimeMillis(),
): List<ChatSessionEntry> {
val current = currentSessionKey.trim()
val cutoff = nowMs - RECENT_WINDOW_MS
val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L }
val recent = mutableListOf<ChatSessionEntry>()
val seen = mutableSetOf<String>()
for (entry in sorted) {
if (!seen.add(entry.key)) continue
if ((entry.updatedAtMs ?: 0L) < cutoff) continue
recent.add(entry)
}
val result = mutableListOf<ChatSessionEntry>()
val included = mutableSetOf<String>()
val mainEntry = sorted.firstOrNull { it.key == MAIN_SESSION_KEY }
if (mainEntry != null) {
result.add(mainEntry)
included.add(MAIN_SESSION_KEY)
} else if (current == MAIN_SESSION_KEY) {
result.add(ChatSessionEntry(key = MAIN_SESSION_KEY, updatedAtMs = null))
included.add(MAIN_SESSION_KEY)
}
for (entry in recent) {
if (included.add(entry.key)) {
result.add(entry)
}
}
if (current.isNotEmpty() && !included.contains(current)) {
result.add(ChatSessionEntry(key = current, updatedAtMs = null))
}
return result
}
@@ -0,0 +1,98 @@
package com.clawdbot.android.voice
import android.media.MediaDataSource
import kotlin.math.min
internal class StreamingMediaDataSource : MediaDataSource() {
private data class Chunk(val start: Long, val data: ByteArray)
private val lock = Object()
private val chunks = ArrayList<Chunk>()
private var totalSize: Long = 0
private var closed = false
private var finished = false
private var lastReadIndex = 0
fun append(data: ByteArray) {
if (data.isEmpty()) return
synchronized(lock) {
if (closed || finished) return
val chunk = Chunk(totalSize, data)
chunks.add(chunk)
totalSize += data.size.toLong()
lock.notifyAll()
}
}
fun finish() {
synchronized(lock) {
if (closed) return
finished = true
lock.notifyAll()
}
}
fun fail() {
synchronized(lock) {
closed = true
lock.notifyAll()
}
}
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
if (position < 0) return -1
synchronized(lock) {
while (!closed && !finished && position >= totalSize) {
lock.wait()
}
if (closed) return -1
if (position >= totalSize && finished) return -1
val available = (totalSize - position).toInt()
val toRead = min(size, available)
var remaining = toRead
var destOffset = offset
var pos = position
var index = findChunkIndex(pos)
while (remaining > 0 && index < chunks.size) {
val chunk = chunks[index]
val inChunkOffset = (pos - chunk.start).toInt()
if (inChunkOffset >= chunk.data.size) {
index++
continue
}
val copyLen = min(remaining, chunk.data.size - inChunkOffset)
System.arraycopy(chunk.data, inChunkOffset, buffer, destOffset, copyLen)
remaining -= copyLen
destOffset += copyLen
pos += copyLen
if (inChunkOffset + copyLen >= chunk.data.size) {
index++
}
}
return toRead - remaining
}
}
override fun getSize(): Long = -1
override fun close() {
synchronized(lock) {
closed = true
lock.notifyAll()
}
}
private fun findChunkIndex(position: Long): Int {
var index = lastReadIndex
while (index < chunks.size) {
val chunk = chunks[index]
if (position < chunk.start + chunk.data.size) break
index++
}
lastReadIndex = index
return index
}
}
@@ -0,0 +1,191 @@
package com.clawdbot.android.voice
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
private val directiveJson = Json { ignoreUnknownKeys = true }
data class TalkDirective(
val voiceId: String? = null,
val modelId: String? = null,
val speed: Double? = null,
val rateWpm: Int? = null,
val stability: Double? = null,
val similarity: Double? = null,
val style: Double? = null,
val speakerBoost: Boolean? = null,
val seed: Long? = null,
val normalize: String? = null,
val language: String? = null,
val outputFormat: String? = null,
val latencyTier: Int? = null,
val once: Boolean? = null,
)
data class TalkDirectiveParseResult(
val directive: TalkDirective?,
val stripped: String,
val unknownKeys: List<String>,
)
object TalkDirectiveParser {
fun parse(text: String): TalkDirectiveParseResult {
val normalized = text.replace("\r\n", "\n")
val lines = normalized.split("\n").toMutableList()
if (lines.isEmpty()) return TalkDirectiveParseResult(null, text, emptyList())
val firstNonEmpty = lines.indexOfFirst { it.trim().isNotEmpty() }
if (firstNonEmpty == -1) return TalkDirectiveParseResult(null, text, emptyList())
val head = lines[firstNonEmpty].trim()
if (!head.startsWith("{") || !head.endsWith("}")) {
return TalkDirectiveParseResult(null, text, emptyList())
}
val obj = parseJsonObject(head) ?: return TalkDirectiveParseResult(null, text, emptyList())
val speakerBoost =
boolValue(obj, listOf("speaker_boost", "speakerBoost"))
?: boolValue(obj, listOf("no_speaker_boost", "noSpeakerBoost"))?.not()
val directive = TalkDirective(
voiceId = stringValue(obj, listOf("voice", "voice_id", "voiceId")),
modelId = stringValue(obj, listOf("model", "model_id", "modelId")),
speed = doubleValue(obj, listOf("speed")),
rateWpm = intValue(obj, listOf("rate", "wpm")),
stability = doubleValue(obj, listOf("stability")),
similarity = doubleValue(obj, listOf("similarity", "similarity_boost", "similarityBoost")),
style = doubleValue(obj, listOf("style")),
speakerBoost = speakerBoost,
seed = longValue(obj, listOf("seed")),
normalize = stringValue(obj, listOf("normalize", "apply_text_normalization")),
language = stringValue(obj, listOf("lang", "language_code", "language")),
outputFormat = stringValue(obj, listOf("output_format", "format")),
latencyTier = intValue(obj, listOf("latency", "latency_tier", "latencyTier")),
once = boolValue(obj, listOf("once")),
)
val hasDirective = listOf(
directive.voiceId,
directive.modelId,
directive.speed,
directive.rateWpm,
directive.stability,
directive.similarity,
directive.style,
directive.speakerBoost,
directive.seed,
directive.normalize,
directive.language,
directive.outputFormat,
directive.latencyTier,
directive.once,
).any { it != null }
if (!hasDirective) return TalkDirectiveParseResult(null, text, emptyList())
val knownKeys = setOf(
"voice", "voice_id", "voiceid",
"model", "model_id", "modelid",
"speed", "rate", "wpm",
"stability", "similarity", "similarity_boost", "similarityboost",
"style",
"speaker_boost", "speakerboost",
"no_speaker_boost", "nospeakerboost",
"seed",
"normalize", "apply_text_normalization",
"lang", "language_code", "language",
"output_format", "format",
"latency", "latency_tier", "latencytier",
"once",
)
val unknownKeys = obj.keys.filter { !knownKeys.contains(it.lowercase()) }.sorted()
lines.removeAt(firstNonEmpty)
if (firstNonEmpty < lines.size) {
if (lines[firstNonEmpty].trim().isEmpty()) {
lines.removeAt(firstNonEmpty)
}
}
return TalkDirectiveParseResult(directive, lines.joinToString("\n"), unknownKeys)
}
private fun parseJsonObject(line: String): JsonObject? {
return try {
directiveJson.parseToJsonElement(line) as? JsonObject
} catch (_: Throwable) {
null
}
}
private fun stringValue(obj: JsonObject, keys: List<String>): String? {
for (key in keys) {
val value = obj[key].asStringOrNull()?.trim()
if (!value.isNullOrEmpty()) return value
}
return null
}
private fun doubleValue(obj: JsonObject, keys: List<String>): Double? {
for (key in keys) {
val value = obj[key].asDoubleOrNull()
if (value != null) return value
}
return null
}
private fun intValue(obj: JsonObject, keys: List<String>): Int? {
for (key in keys) {
val value = obj[key].asIntOrNull()
if (value != null) return value
}
return null
}
private fun longValue(obj: JsonObject, keys: List<String>): Long? {
for (key in keys) {
val value = obj[key].asLongOrNull()
if (value != null) return value
}
return null
}
private fun boolValue(obj: JsonObject, keys: List<String>): Boolean? {
for (key in keys) {
val value = obj[key].asBooleanOrNull()
if (value != null) return value
}
return null
}
}
private fun JsonElement?.asStringOrNull(): String? =
(this as? JsonPrimitive)?.takeIf { it.isString }?.content
private fun JsonElement?.asDoubleOrNull(): Double? {
val primitive = this as? JsonPrimitive ?: return null
return primitive.content.toDoubleOrNull()
}
private fun JsonElement?.asIntOrNull(): Int? {
val primitive = this as? JsonPrimitive ?: return null
return primitive.content.toIntOrNull()
}
private fun JsonElement?.asLongOrNull(): Long? {
val primitive = this as? JsonPrimitive ?: return null
return primitive.content.toLongOrNull()
}
private fun JsonElement?.asBooleanOrNull(): Boolean? {
val primitive = this as? JsonPrimitive ?: return null
val content = primitive.content.trim().lowercase()
return when (content) {
"true", "yes", "1" -> true
"false", "no", "0" -> false
else -> null
}
}
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.voice
package com.clawdbot.android.voice
object VoiceWakeCommandExtractor {
fun extractCommand(text: String, triggerWords: List<String>): String? {
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.voice
package com.clawdbot.android.voice
import android.content.Context
import android.content.Intent
@@ -1,123 +0,0 @@
package com.steipete.clawdis.node.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.steipete.clawdis.node.CameraHudKind
import com.steipete.clawdis.node.CameraHudState
import kotlinx.coroutines.delay
@Composable
fun CameraHudOverlay(
hud: CameraHudState?,
flashToken: Long,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize()) {
CameraFlash(token = flashToken)
AnimatedVisibility(
visible = hud != null,
enter = slideInVertically(initialOffsetY = { -it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { -it / 2 }) + fadeOut(),
modifier = Modifier.align(Alignment.TopStart).statusBarsPadding().padding(start = 12.dp, top = 58.dp),
) {
if (hud != null) {
Toast(hud = hud)
}
}
}
}
@Composable
private fun CameraFlash(token: Long) {
var alpha by remember { mutableFloatStateOf(0f) }
LaunchedEffect(token) {
if (token == 0L) return@LaunchedEffect
alpha = 0.85f
delay(110)
alpha = 0f
}
Box(
modifier =
Modifier
.fillMaxSize()
.alpha(alpha)
.background(Color.White),
)
}
@Composable
private fun Toast(hud: CameraHudState) {
Surface(
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
tonalElevation = 2.dp,
shadowElevation = 8.dp,
) {
Row(
modifier = Modifier.padding(vertical = 10.dp, horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (hud.kind) {
CameraHudKind.Photo -> {
Icon(Icons.Default.PhotoCamera, contentDescription = null)
Spacer(Modifier.size(10.dp))
CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp)
}
CameraHudKind.Recording -> {
Icon(Icons.Default.FiberManualRecord, contentDescription = null, tint = Color.Red)
}
CameraHudKind.Success -> {
Icon(Icons.Default.CheckCircle, contentDescription = null)
}
CameraHudKind.Error -> {
Icon(Icons.Default.Error, contentDescription = null)
}
}
Spacer(Modifier.size(10.dp))
Text(
text = hud.message,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -1,217 +0,0 @@
package com.steipete.clawdis.node.ui
import android.annotation.SuppressLint
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Color
import android.util.Log
import android.view.View
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebSettings
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.core.content.ContextCompat
import com.steipete.clawdis.node.MainViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RootScreen(viewModel: MainViewModel) {
var sheet by remember { mutableStateOf<Sheet?>(null) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
val context = LocalContext.current
val serverName by viewModel.serverName.collectAsState()
val statusText by viewModel.statusText.collectAsState()
val cameraHud by viewModel.cameraHud.collectAsState()
val cameraFlashToken by viewModel.cameraFlashToken.collectAsState()
val bridgeState =
remember(serverName, statusText) {
when {
serverName != null -> BridgeState.Connected
statusText.contains("connecting", ignoreCase = true) ||
statusText.contains("reconnecting", ignoreCase = true) -> BridgeState.Connecting
statusText.contains("error", ignoreCase = true) -> BridgeState.Error
else -> BridgeState.Disconnected
}
}
val voiceEnabled =
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
Box(modifier = Modifier.fillMaxSize()) {
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
}
// Camera HUD (flash + toast) must be in a Popup to render above the WebView.
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
CameraHudOverlay(hud = cameraHud, flashToken = cameraFlashToken, modifier = Modifier.fillMaxSize())
}
// 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,
voiceEnabled = voiceEnabled,
onClick = { sheet = Sheet.Settings },
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(start = 12.dp, top = 12.dp),
)
}
Popup(alignment = Alignment.TopEnd, properties = PopupProperties(focusable = false)) {
Column(
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(end = 12.dp, top = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.End,
) {
OverlayIconButton(
onClick = { sheet = Sheet.Chat },
icon = { Icon(Icons.Default.ChatBubble, contentDescription = "Chat") },
)
OverlayIconButton(
onClick = { sheet = Sheet.Settings },
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
)
}
}
val currentSheet = sheet
if (currentSheet != null) {
ModalBottomSheet(
onDismissRequest = { sheet = null },
sheetState = sheetState,
) {
when (currentSheet) {
Sheet.Chat -> ChatSheet(viewModel = viewModel)
Sheet.Settings -> SettingsSheet(viewModel = viewModel)
}
}
}
}
private enum class Sheet {
Chat,
Settings,
}
@Composable
private fun OverlayIconButton(
onClick: () -> Unit,
icon: @Composable () -> Unit,
) {
FilledTonalIconButton(
onClick = onClick,
modifier = Modifier.size(44.dp),
colors =
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = overlayContainerColor(),
contentColor = overlayIconColor(),
),
) {
icon()
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) {
val context = LocalContext.current
val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
AndroidView(
modifier = modifier,
factory = {
WebView(context).apply {
settings.javaScriptEnabled = true
// Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage.
settings.domStorageEnabled = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
webViewClient =
object : WebViewClient() {
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
if (!isDebuggable) return
if (!request.isForMainFrame) return
Log.e("ClawdisWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}")
}
override fun onReceivedHttpError(
view: WebView,
request: WebResourceRequest,
errorResponse: WebResourceResponse,
) {
if (!isDebuggable) return
if (!request.isForMainFrame) return
Log.e(
"ClawdisWebView",
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
)
}
}
setBackgroundColor(Color.BLACK)
setLayerType(View.LAYER_TYPE_HARDWARE, null)
addJavascriptInterface(
CanvasA2UIActionBridge { payload ->
viewModel.handleCanvasA2UIActionFromWebView(payload)
},
CanvasA2UIActionBridge.interfaceName,
)
viewModel.canvas.attach(this)
}
},
)
}
private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
@JavascriptInterface
fun postMessage(payload: String?) {
val msg = payload?.trim().orEmpty()
if (msg.isEmpty()) return
onMessage(msg)
}
companion object {
const val interfaceName: String = "clawdisCanvasA2UIAction"
}
}
@@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,4 +1,4 @@
<resources>
<string name="app_name">Clawdis Node</string>
<string name="app_name">Clawdbot Node</string>
</resources>
@@ -1,8 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.ClawdisNode" parent="Theme.Material3.DayNight.NoActionBar">
<resources>
<style name="Theme.ClawdbotNode" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include domain="file" path="." />
</full-backup-content>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<include domain="file" path="." />
</cloud-backup>
<device-transfer>
<include domain="file" path="." />
</device-transfer>
</data-extraction-rules>
@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<network-security-config xmlns:tools="http://schemas.android.com/tools">
<!-- This app is primarily used on a trusted tailnet; allow cleartext for IP-based endpoints too. -->
<base-config cleartextTrafficPermitted="true" />
<base-config cleartextTrafficPermitted="true" tools:ignore="InsecureBaseConfiguration" />
<!-- Allow HTTP for tailnet/local dev endpoints (e.g. canvas/background web). -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">clawdis.internal</domain>
<domain includeSubdomains="true">clawdbot.internal</domain>
</domain-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">ts.net</domain>
@@ -0,0 +1,43 @@
package com.clawdbot.android
import android.app.Notification
import android.content.Intent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class NodeForegroundServiceTest {
@Test
fun buildNotificationSetsLaunchIntent() {
val service = Robolectric.buildService(NodeForegroundService::class.java).get()
val notification = buildNotification(service)
val pendingIntent = notification.contentIntent
assertNotNull(pendingIntent)
val savedIntent = Shadows.shadowOf(pendingIntent).savedIntent
assertNotNull(savedIntent)
assertEquals(MainActivity::class.java.name, savedIntent.component?.className)
val expectedFlags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
assertEquals(expectedFlags, savedIntent.flags and expectedFlags)
}
private fun buildNotification(service: NodeForegroundService): Notification {
val method =
NodeForegroundService::class.java.getDeclaredMethod(
"buildNotification",
String::class.java,
String::class.java,
)
method.isAccessible = true
return method.invoke(service, "Title", "Text") as Notification
}
}
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node
package com.clawdbot.android
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge
package com.clawdbot.android.bridge
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -12,8 +12,8 @@ class BonjourEscapesTest {
@Test
fun decodeDecodesDecimalEscapes() {
assertEquals("Clawdis Gateway", BonjourEscapes.decode("Clawdis\\032Gateway"))
assertEquals("Clawdbot Gateway", BonjourEscapes.decode("Clawdbot\\032Gateway"))
assertEquals("A B", BonjourEscapes.decode("A\\032B"))
assertEquals("Peter\u2019s Mac", BonjourEscapes.decode("Peter\\226\\128\\153s Mac"))
}
}
@@ -0,0 +1,14 @@
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
}
})
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge
package com.clawdbot.android.bridge
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge
package com.clawdbot.android.bridge
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
@@ -46,7 +46,7 @@ class BridgeSessionTest {
val hello = reader.readLine()
assertTrue(hello.contains("\"type\":\"hello\""))
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
writer.write("""{"type":"hello-ok","serverName":"Test Bridge","canvasHostUrl":"http://127.0.0.1:18789"}""")
writer.write("\n")
writer.flush()
@@ -77,6 +77,7 @@ class BridgeSessionTest {
)
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()
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.node
package com.clawdbot.android.node
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
@@ -0,0 +1,47 @@
package com.clawdbot.android.node
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.min
class JpegSizeLimiterTest {
@Test
fun compressesLargePayloadsUnderLimit() {
val maxBytes = 5 * 1024 * 1024
val result =
JpegSizeLimiter.compressToLimit(
initialWidth = 4000,
initialHeight = 3000,
startQuality = 95,
maxBytes = maxBytes,
encode = { width, height, quality ->
val estimated = (width.toLong() * height.toLong() * quality.toLong()) / 100
val size = min(maxBytes.toLong() * 2, estimated).toInt()
ByteArray(size)
},
)
assertTrue(result.bytes.size <= maxBytes)
assertTrue(result.width <= 4000)
assertTrue(result.height <= 3000)
assertTrue(result.quality <= 95)
}
@Test
fun keepsSmallPayloadsAsIs() {
val maxBytes = 5 * 1024 * 1024
val result =
JpegSizeLimiter.compressToLimit(
initialWidth = 800,
initialHeight = 600,
startQuality = 90,
maxBytes = maxBytes,
encode = { _, _, _ -> ByteArray(120_000) },
)
assertEquals(800, result.width)
assertEquals(600, result.height)
assertEquals(90, result.quality)
}
}
@@ -0,0 +1,91 @@
package com.clawdbot.android.node
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SmsManagerTest {
private val json = SmsManager.JsonConfig
@Test
fun parseParamsRejectsEmptyPayload() {
val result = SmsManager.parseParams("", json)
assertTrue(result is SmsManager.ParseResult.Error)
val error = result as SmsManager.ParseResult.Error
assertEquals("INVALID_REQUEST: paramsJSON required", error.error)
}
@Test
fun parseParamsRejectsInvalidJson() {
val result = SmsManager.parseParams("not-json", json)
assertTrue(result is SmsManager.ParseResult.Error)
val error = result as SmsManager.ParseResult.Error
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
}
@Test
fun parseParamsRejectsNonObjectJson() {
val result = SmsManager.parseParams("[]", json)
assertTrue(result is SmsManager.ParseResult.Error)
val error = result as SmsManager.ParseResult.Error
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
}
@Test
fun parseParamsRejectsMissingTo() {
val result = SmsManager.parseParams("{\"message\":\"Hi\"}", json)
assertTrue(result is SmsManager.ParseResult.Error)
val error = result as SmsManager.ParseResult.Error
assertEquals("INVALID_REQUEST: 'to' phone number required", error.error)
assertEquals("Hi", error.message)
}
@Test
fun parseParamsRejectsMissingMessage() {
val result = SmsManager.parseParams("{\"to\":\"+1234\"}", json)
assertTrue(result is SmsManager.ParseResult.Error)
val error = result as SmsManager.ParseResult.Error
assertEquals("INVALID_REQUEST: 'message' text required", error.error)
assertEquals("+1234", error.to)
}
@Test
fun parseParamsTrimsToField() {
val result = SmsManager.parseParams("{\"to\":\" +1555 \",\"message\":\"Hello\"}", json)
assertTrue(result is SmsManager.ParseResult.Ok)
val ok = result as SmsManager.ParseResult.Ok
assertEquals("+1555", ok.params.to)
assertEquals("Hello", ok.params.message)
}
@Test
fun buildPayloadJsonEscapesFields() {
val payload = SmsManager.buildPayloadJson(
json = json,
ok = false,
to = "+1\"23",
error = "SMS_SEND_FAILED: \"nope\"",
)
val parsed = json.parseToJsonElement(payload).jsonObject
assertEquals("false", parsed["ok"]?.jsonPrimitive?.content)
assertEquals("+1\"23", parsed["to"]?.jsonPrimitive?.content)
assertEquals("SMS_SEND_FAILED: \"nope\"", parsed["error"]?.jsonPrimitive?.content)
}
@Test
fun buildSendPlanUsesMultipartWhenMultipleParts() {
val plan = SmsManager.buildSendPlan("hello") { listOf("a", "b") }
assertTrue(plan.useMultipart)
assertEquals(listOf("a", "b"), plan.parts)
}
@Test
fun buildSendPlanFallsBackToSinglePartWhenDividerEmpty() {
val plan = SmsManager.buildSendPlan("hello") { emptyList() }
assertFalse(plan.useMultipart)
assertEquals(listOf("hello"), plan.parts)
}
}
@@ -0,0 +1,49 @@
package com.clawdbot.android.protocol
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import org.junit.Assert.assertEquals
import org.junit.Test
class ClawdbotCanvasA2UIActionTest {
@Test
fun extractActionNameAcceptsNameOrAction() {
val nameObj = Json.parseToJsonElement("{\"name\":\"Hello\"}").jsonObject
assertEquals("Hello", ClawdbotCanvasA2UIAction.extractActionName(nameObj))
val actionObj = Json.parseToJsonElement("{\"action\":\"Wave\"}").jsonObject
assertEquals("Wave", ClawdbotCanvasA2UIAction.extractActionName(actionObj))
val fallbackObj =
Json.parseToJsonElement("{\"name\":\" \",\"action\":\"Fallback\"}").jsonObject
assertEquals("Fallback", ClawdbotCanvasA2UIAction.extractActionName(fallbackObj))
}
@Test
fun formatAgentMessageMatchesSharedSpec() {
val msg =
ClawdbotCanvasA2UIAction.formatAgentMessage(
actionName = "Get Weather",
sessionKey = "main",
surfaceId = "main",
sourceComponentId = "btnWeather",
host = "Peters iPad",
instanceId = "ipad16,6",
contextJson = "{\"city\":\"Vienna\"}",
)
assertEquals(
"CANVAS_A2UI action=Get_Weather session=main surface=main component=btnWeather host=Peter_s_iPad instance=ipad16_6 ctx={\"city\":\"Vienna\"} default=update_canvas",
msg,
)
}
@Test
fun jsDispatchA2uiStatusIsStable() {
val js = ClawdbotCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId = "a1", ok = true, error = null)
assertEquals(
"window.dispatchEvent(new CustomEvent('clawdbot:a2ui-action-status', { detail: { id: \"a1\", ok: true, error: \"\" } }));",
js,
)
}
}
@@ -0,0 +1,35 @@
package com.clawdbot.android.protocol
import org.junit.Assert.assertEquals
import org.junit.Test
class ClawdbotProtocolConstantsTest {
@Test
fun canvasCommandsUseStableStrings() {
assertEquals("canvas.present", ClawdbotCanvasCommand.Present.rawValue)
assertEquals("canvas.hide", ClawdbotCanvasCommand.Hide.rawValue)
assertEquals("canvas.navigate", ClawdbotCanvasCommand.Navigate.rawValue)
assertEquals("canvas.eval", ClawdbotCanvasCommand.Eval.rawValue)
assertEquals("canvas.snapshot", ClawdbotCanvasCommand.Snapshot.rawValue)
}
@Test
fun a2uiCommandsUseStableStrings() {
assertEquals("canvas.a2ui.push", ClawdbotCanvasA2UICommand.Push.rawValue)
assertEquals("canvas.a2ui.pushJSONL", ClawdbotCanvasA2UICommand.PushJSONL.rawValue)
assertEquals("canvas.a2ui.reset", ClawdbotCanvasA2UICommand.Reset.rawValue)
}
@Test
fun capabilitiesUseStableStrings() {
assertEquals("canvas", ClawdbotCapability.Canvas.rawValue)
assertEquals("camera", ClawdbotCapability.Camera.rawValue)
assertEquals("screen", ClawdbotCapability.Screen.rawValue)
assertEquals("voiceWake", ClawdbotCapability.VoiceWake.rawValue)
}
@Test
fun screenCommandsUseStableStrings() {
assertEquals("screen.record", ClawdbotScreenCommand.Record.rawValue)
}
}
@@ -0,0 +1,35 @@
package com.clawdbot.android.ui.chat
import com.clawdbot.android.chat.ChatSessionEntry
import org.junit.Assert.assertEquals
import org.junit.Test
class SessionFiltersTest {
@Test
fun sessionChoicesPreferMainAndRecent() {
val now = 1_700_000_000_000L
val recent1 = now - 2 * 60 * 60 * 1000L
val recent2 = now - 5 * 60 * 60 * 1000L
val stale = now - 26 * 60 * 60 * 1000L
val sessions =
listOf(
ChatSessionEntry(key = "recent-1", updatedAtMs = recent1),
ChatSessionEntry(key = "main", updatedAtMs = stale),
ChatSessionEntry(key = "old-1", updatedAtMs = stale),
ChatSessionEntry(key = "recent-2", updatedAtMs = recent2),
)
val result = resolveSessionChoices("main", sessions, nowMs = now).map { it.key }
assertEquals(listOf("main", "recent-1", "recent-2"), result)
}
@Test
fun sessionChoicesIncludeCurrentWhenMissing() {
val now = 1_700_000_000_000L
val recent = now - 10 * 60 * 1000L
val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent))
val result = resolveSessionChoices("custom", sessions, nowMs = now).map { it.key }
assertEquals(listOf("main", "custom"), result)
}
}
@@ -0,0 +1,55 @@
package com.clawdbot.android.voice
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class TalkDirectiveParserTest {
@Test
fun parsesDirectiveAndStripsHeader() {
val input = """
{"voice":"voice-123","once":true}
Hello from talk mode.
""".trimIndent()
val result = TalkDirectiveParser.parse(input)
assertEquals("voice-123", result.directive?.voiceId)
assertEquals(true, result.directive?.once)
assertEquals("Hello from talk mode.", result.stripped.trim())
}
@Test
fun ignoresUnknownKeysButReportsThem() {
val input = """
{"voice":"abc","foo":1,"bar":"baz"}
Hi there.
""".trimIndent()
val result = TalkDirectiveParser.parse(input)
assertEquals("abc", result.directive?.voiceId)
assertTrue(result.unknownKeys.containsAll(listOf("bar", "foo")))
}
@Test
fun parsesAlternateKeys() {
val input = """
{"model_id":"eleven_v3","similarity_boost":0.4,"no_speaker_boost":true,"rate":200}
Speak.
""".trimIndent()
val result = TalkDirectiveParser.parse(input)
assertEquals("eleven_v3", result.directive?.modelId)
assertEquals(0.4, result.directive?.similarity)
assertEquals(false, result.directive?.speakerBoost)
assertEquals(200, result.directive?.rateWpm)
}
@Test
fun returnsNullWhenNoDirectivePresent() {
val input = """
{}
Hello.
""".trimIndent()
val result = TalkDirectiveParser.parse(input)
assertNull(result.directive)
assertEquals(input, result.stripped)
}
}

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