Compare commits

..
Author SHA1 Message Date
Peter Steinberger 3094b72bea docs(changelog): add android notification tap fix 2026-01-04 17:02:46 +00:00
Peter Steinberger a63af8d8ff test(android): cover notification tap intent 2026-01-04 17:02:21 +00:00
Manuel Jiménez TorresandClaude Opus 4.5 40ffbd32cb 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 17:01:34 +00: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
870 changed files with 11524 additions and 8196 deletions

No files matched your search

+4 -3
View File
@@ -15,13 +15,14 @@ ui/test-results/
# Bun build artifacts # Bun build artifacts
*.bun-build *.bun-build
apps/macos/.build/ apps/macos/.build/
apps/shared/ClawdisKit/.build/ apps/shared/ClawdbotKit/.build/
**/ModuleCache/
bin/ bin/
bin/clawdis-mac bin/clawdbot-mac
bin/docs-list bin/docs-list
apps/macos/.build-local/ apps/macos/.build-local/
apps/macos/.swiftpm/ apps/macos/.swiftpm/
apps/shared/ClawdisKit/.swiftpm/ apps/shared/ClawdbotKit/.swiftpm/
Core/ Core/
apps/ios/*.xcodeproj/ apps/ios/*.xcodeproj/
apps/ios/*.xcworkspace/ apps/ios/*.xcworkspace/
+4
View File
@@ -108,6 +108,10 @@ function_body_length:
warning: 150 warning: 150
error: 300 error: 300
function_parameter_count:
warning: 7
error: 10
file_length: file_length:
warning: 1500 warning: 1500
error: 2500 error: 2500
+14 -14
View File
@@ -7,7 +7,7 @@
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- Install deps: `pnpm install` - 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) - Type-check/build: `pnpm build` (tsc)
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format) - Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage` - Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
@@ -32,13 +32,13 @@
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags. - PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
## Security & Configuration Tips ## Security & Configuration Tips
- Web provider stores creds at `~/.clawdis/credentials/`; rerun `clawdis login` if logged out. - Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.
- Pi sessions live under `~/.clawdis/sessions/` by default; the base directory is not configurable. - 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. - Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
## Agent-Specific Notes ## 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.** - 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.clawdis`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`. - 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. - 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`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code. - SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync. - Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
@@ -52,27 +52,27 @@
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless Peter explicitly asks. - **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. - **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session. - **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 `~/.clawdis/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. - 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 Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item. - 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. - 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. - 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: - Voice wake forwarding tips:
- Command template should stay `clawdis-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes. - Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
- launchd PATH is minimal; ensure the app’s 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`. - launchd PATH is minimal; ensure the app’s 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 `clawdis send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping. - For manual `clawdbot send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
## Exclamation Mark Escaping Workaround ## 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 ```bash
# WRONG - will send "Hello\\!" with backslash # WRONG - will send "Hello\\!" with backslash
clawdis send --to "+1234" --message 'Hello!' clawdbot send --to "+1234" --message 'Hello!'
# CORRECT - use heredoc to avoid escaping # CORRECT - use heredoc to avoid escaping
clawdis send --to "+1234" --message "$(cat <<'EOF' clawdbot send --to "+1234" --message "$(cat <<'EOF'
Hello! Hello!
EOF EOF
)" )"
``` ```
This is a Claude Code quirk, not a clawdis bug. This is a Claude Code quirk, not a clawdbot bug.
+82 -583
View File
@@ -1,611 +1,110 @@
# Changelog # Changelog
**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.
## Unreleased ## Unreleased
### Breaking ### Fixes
- Identifiers: rename bundle IDs and internal domains to `com.clawdis.*` (macOS: `com.clawdis.mac`, iOS: `com.clawdis.ios`, Android: `com.clawdis.android`) and update the gateway LaunchAgent label to `com.clawdis.gateway`. - Android: tapping the foreground service notification brings the app to the front. (#179) — thanks @Syhids
- Agent tools: drop the `clawdis_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`). - Cron tool passes `id` to the gateway for update/remove/run/runs (keeps `jobId` input). (#180) — thanks @adamgall
- Bash tool: remove `stdinMode: "pty"`/node-pty support; use the tmux skill for real TTYs.
- Sessions: primary session key is fixed to `main` (or `global` for global scope); `session.mainKey` is ignored.
### Features
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app. ## 2026.1.4
- Gateway: add config hot reload with hybrid restart strategy (`gateway.reload`) and per-section reload handling.
- UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI. ### Highlights
- Control UI: support configurable base paths (`gateway.controlUi.basePath`, default unchanged) for hosting under URL prefixes. - Rename completion: all CLIs, paths, bundle IDs, env vars, and docs standardized on **Clawdbot**.
- Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC. - Agent-to-agent relay: `sessions_send` ping‑pong with `REPLY_SKIP` plus announce step with `ANNOUNCE_SKIP`.
- Config: expose schema + UI hints for generic config forms (Web UI + future clients). - Gateway quality-of-life: config hot reload, port config support, and Control UI base paths.
- Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia. - Sandbox additions: per-session Docker sandbox with hardened limits + optional sandboxed Chromium.
- Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) (#140) — thanks @thewilloftheshadow. - New node capability: `location.get` across macOS/iOS/Android (CLI + tools).
- Agent: add optional per-session Docker sandbox for tool execution (`agent.sandbox`) with allow/deny policy and auto-pruning. - Models CLI: scan OpenRouter free models (tools/images), manage aliases/fallbacks, and show last-used model in status.
- Agent: add sandboxed Chromium browser (CDP + optional noVNC observer) for sandboxed sessions.
- Nodes: add `location.get` with Always/Precise settings on macOS/iOS/Android plus CLI/tool support. ### Breaking
- Sessions: add agent‑to‑agent post step with `ANNOUNCE_SKIP` to suppress channel announcements. - 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).
### Fixes ### Fixes
- Gateway/macOS: keep node presence fresh with periodic beacons + show presence status in Instances (#168) — thanks @mbelinky. - Doctor migrates legacy Clawdis config/service installs and normalizes sandbox Docker names.
- CI: fix lint ordering after merge cleanup (#156) — thanks @steipete. - Doctor checks sandbox image availability and offers to build or fall back to legacy images.
- CI: consolidate checks to avoid redundant installs (#144) — thanks @thewilloftheshadow. - Presence beacons keep node lists fresh; Instances view stays accurate.
- WhatsApp: support `gifPlayback` for MP4 GIF sends via CLI/gateway. - Block streaming/chunking reliability (Telegram/Discord ordering, fewer duplicates).
- Sessions: prevent `sessions_send` timeouts by running nested agent turns on a separate lane. - WhatsApp GIF playback for MP4-based GIFs.
- Sessions: use per-send run IDs for gateway agent calls to avoid wait collisions. - Onboarding + Control UI basePath handling fixes and UI polish.
- Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends. - Clearer tool summaries, reduced log noise, and safer watchdog/queue behavior.
- Auto-reply: default elevated to off when not allowed (#183) — thanks @cash-echo-bot. - Canvas host watcher resilience; build and packaging edge cases cleaned up.
- Auto-reply: fix typing TTL to 2 minutes and log TTL with s/m units.
- Bash tool: default auto-background delay to 10s.
- Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm.
- Block streaming: default to text_end and suppress duplicate block sends while in-flight.
- Block streaming: avoid duplicate block chunks when providers repeat full content on text_end.
- Block streaming: drop final payloads after soft chunking to keep Discord order intact.
- Gmail hooks: resolve gcloud Python to a real executable when PATH uses mise shims — thanks @joargp.
- Control UI: generate UUIDs when `crypto.randomUUID()` is unavailable over HTTP — thanks @ratulsarna.
- Control UI: stream live tool output cards in Chat (agent events include sessionKey).
- Chat UI: render assistant `<think>`/`<final>` markup as italic thinking text in history + streaming instead of showing raw tags.
- Agent: add soft block-stream chunking (800–1200 chars default) with paragraph/newline preference.
- Agent: route embedded run lifecycle logs through subsystem console formatting and reduce log noise.
- Agent tools: scope the Discord tool to Discord surface runs.
- Agent tools: format verbose tool summaries without brackets, with unique emojis and `tool: detail` style.
- Agent tools: emit verbose tool summaries at tool start (no debounce).
- Gateway: split server helpers/tests into hooks/session-utils/ws-log/net modules for better isolation; add unit coverage for hooks/session utils/ws log.
- Gateway: extract WS method handling + HTTP/provider/constant helpers to shrink server wiring and improve testability.
- Gateway: prevent deleting the main session and abort active runs before deleting other sessions.
- Onboarding: fix Control UI basePath usage when showing/opening gateway URLs.
- Onboarding: clarify provider requirements (WhatsApp/Signal phone numbers, iMessage Apple ID guidance) in the provider picker.
- macOS Connections: move to sidebar + detail layout with structured sections and header actions.
- macOS onboarding: increase window height so the permissions page fits without scrolling.
- Thinking: default to low for reasoning-capable models when no /think or config default is set.
- Logging: decouple file log levels from console verbosity; verbose-only details are captured when `logging.level` is debug/trace.
- Build: fix regex literal in tool-meta path detection (watch build error).
- Build: require AVX2 Bun for x86_64 relay packaging (reject baseline builds).
- Build: drop stale ClawdisCLI product from macOS build-and-run script.
- Auto-reply: add run-level telemetry + typing TTL guardrails to diagnose stuck replies.
- WhatsApp: honor per-group mention gating overrides when group ids are stored as session keys.
- Dependencies: bump pi-mono packages to 0.32.3.
### Docs ### Docs
- Skills: add Sheets/Docs examples to gog skill (#128) — thanks @mbelinky. - Sandbox setup, hot reload, port config, and session announce step coverage.
- Skills: clarify bear-notes token + callback usage (#120) — thanks @tylerwince. - Skills and onboarding clarifications + additional examples.
- Skills: document Discord `sendMessage` media attachments and `to` format clarification.
- Skills: expand peekaboo skill examples + common parameters.
- Skills: add tmux skill + interactive coding guidance in coding-agent.
- Gateway: document port configuration + multi-instance isolation.
- Gateway: document config hot reload + reload matrix.
- Onboarding/Config: add protocol notes for wizard + schema RPC.
- Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces.
- Sandbox: document per-session agent sandbox setup, browser image, and Docker build.
- macOS: clarify menu bar uses sessionKey from agent events.
- Sessions: document agent-to-agent post step and `ANNOUNCE_SKIP`.
## 2.0.0-beta5 — 2026-01-03 ## 2026.1.3 (beta 5)
### Fixed
- Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).
- Agent runtime: update pi-mono dependencies to 0.31.1 (agent-core split).
- Dependencies: bump to latest compatible versions (TypeBox, grammY, Zod, Rolldown, oxlint-tsgolint).
- Tests: cover read tool image metadata + text output.
- Tests: add queue mode coverage (collect/followup + directive parsing).
### Breaking ### Breaking
- Skills config schema moved under `skills.*`: - Skills config moved under `skills.*` (new `skills.entries`, `skills.allowBundled`).
- `skillsLoad.extraDirs` → `skills.load.extraDirs` - Group session keys now `surface:group:<id>` / `surface:channel:<id>`; legacy `group:*` removed.
- `skillsInstall.*` → `skills.install.*` - Discord config refactor; `discord.allowFrom` + `discord.requireMention` removed.
- per-skill config map moved to `skills.entries` (e.g. `skills.peekaboo.enabled` → `skills.entries.peekaboo.enabled`) - Discord/Telegram require `enabled: true` in config when using env tokens.
- new optional bundled allowlist: `skills.allowBundled` (only affects bundled skills) - Routing `allowFrom`/mention settings moved to per-surface group settings.
- Sessions: group keys now use `surface:group:<id>` / `surface:channel:<id>`; legacy `group:*` keys migrate on next message; `groupdm` keys are no longer recognized.
- Discord: remove legacy `discord.allowFrom`, `discord.guildAllowFrom`, and `discord.requireMention`; use `discord.dm` + `discord.guilds`.
- Providers: Discord/Telegram no longer auto-start from env tokens alone; add `discord: { enabled: true }` / `telegram: { enabled: true }` to your config when using `DISCORD_BOT_TOKEN` / `TELEGRAM_BOT_TOKEN`.
- Config: remove `routing.allowFrom`; use `whatsapp.allowFrom` instead (run `clawdis doctor` to migrate).
- Config: remove `routing.groupChat.requireMention` + `telegram.requireMention`; use `whatsapp.groups`, `imessage.groups`, and `telegram.groups` defaults instead (run `clawdis doctor` to migrate).
### Features ### Highlights
- Discord: expand `discord` tool actions (reactions, stickers, polls, threads, search, moderation gates) (#115) — thanks @thewilloftheshadow. - Talk Mode (continuous voice) with ElevenLabs TTS on macOS/iOS/Android.
- Discord/Telegram: add reply tags (`[[reply_to_current]]`, `[[reply_to:<id>]]`) with per-provider `replyToMode` (off|first|all) for native threaded replies. - Discord: expanded tool actions, richer routing, and threaded reply tags.
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech. - Auto-reply queue modes + session model overrides; TUI upgrades.
- Auto-reply: expand queue modes (steer/followup/collect/steer-backlog) with debounce/cap/drop options and followup backlog handling. - Nix mode (declarative config) and Docker setup flow.
- UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android). - Onboarding wizard + configure/doctor/update flows.
- Nix mode: opt-in declarative config + read-only settings UI when `CLAWDIS_NIX_MODE=1` (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward). - Signal + iMessage providers; new skills (Trello, Things, Notes/Reminders, tmux coding).
- CLI: add Google Antigravity OAuth auth option for Claude Opus 4.5/Gemini 3 (#88) — thanks @mukhtharcm. - Browser tooling upgrades (remote CDP, no-sandbox, profiles).
- Agent runtime: accept legacy `Z_AI_API_KEY` for Z.AI provider auth (maps to `ZAI_API_KEY`).
- Groups: add per-group mention gating defaults/overrides for Telegram/WhatsApp/iMessage via `*.groups` with `"*"` defaults; Discord now supports `discord.guilds."*"` as a default.
- Discord: add user-installed slash command handling with per-user sessions and auto-registration (#94) — thanks @thewilloftheshadow.
- Discord: add DM enable/allowlist plus guild channel/user/guild allowlists with id/name matching.
- Signal: add `signal-cli` JSON-RPC support for send/receive via the Signal provider.
- iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.
- Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI.
- UI: add Discord/Signal/iMessage connection panels in macOS + Control UI (thanks @thewilloftheshadow).
- Discord: allow agent-triggered reactions via `clawdis_discord` when enabled, and surface message ids in context.
- Discord: revamp guild routing config with per-guild/channel rules and slugged display names; add optional group DM support (default off).
- Discord: remove legacy guild/channel ignore lists in favor of per-guild allowlists (and proposed per-guild ignore lists).
- Skills: add Trello skill for board/list/card management (thanks @clawd).
- Docker: add containerized gateway/CLI setup via Dockerfile, compose, and setup script (thanks @dan-dr).
- Tests: add a Z.AI live test gate for smoke validation when keys are present.
- macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs.
- CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths.
- CLI: add ASCII banner header to wizard entry points.
- CLI: add `configure`, `doctor`, and `update` wizards for ongoing setup, health checks, and modernization.
- CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.
- CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.
- CLI: enhance `clawdis tui` with model/session pickers, tool cards, and slash commands (local or remote).
- Gateway: allow `sessions.patch` to set per-session model overrides (used by the TUI `/model` flow).
- Skills: allow `bun` as a node manager for skill installs.
- Skills: add `things-mac` (Things 3 CLI) for read/search plus add/update via URL scheme.
- Skills: add Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).
- Tests: add a Docker-based onboarding E2E harness.
- Tests: harden wizard E2E flows for reset, providers, skills, and remote non-interactive runs.
- Browser tools: add remote CDP URL support, Linux launcher options (`executablePath`, `noSandbox`), and surface `cdpUrl` in status.
- Skills: add tmux-first coding-agent skill + `requires.anyBins` gate for multi-CLI setup (thanks @sreekaransrinath).
### Fixes ### Fixes
- macOS codesign: make ad-hoc signing opt-in with loud warnings and document TCC permission fragility — thanks @mcinteerj. - macOS codesign/TCC hardening and menu/UI stability improvements.
- Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey). - Streaming/typing fixes; per-provider chunk limit tuning.
- macOS onboarding: add scrollable page gutter for overflowing content (#105) — thanks @thewilloftheshadow. - Remote gateway auth + token handling tightened.
- Chat UI: keep the chat scrolled to the latest message after switching sessions. - Camera capture reliability and media sizing fixes.
- Chat UI: show rich session display names in Web Chat + SwiftUI + Android.
- Auto-reply: stream completed reply blocks as soon as they finish (configurable default + break); skip empty tool-only blocks unless verbose.
- Discord: avoid duplicate sends when block streaming is enabled (race with typing hook).
- Providers: make outbound text chunk limits configurable via `*.textChunkLimit` (defaults remain 4000/Discord 2000).
- CLI onboarding: persist gateway token in config so local CLI auth works; recommend auth Off unless you need multi-machine access.
- Control UI: accept a `?token=` URL param to auto-fill Gateway auth; onboarding now opens the dashboard with token auth when configured.
- Agent prompt: remove hardcoded user name in system prompt example.
- Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android).
- Control UI: refine Web Chat session selector styling (chevron spacing + background).
- WebChat: stream live updates for sessions even when runs start outside the chat UI.
- Gateway CLI: read `CLAWDIS_GATEWAY_PASSWORD` from environment in `callGateway()` — allows `doctor`/`health` commands to auth without explicit `--password` flag.
- Gateway: add password auth support for remote gateway connections (thanks @jeffersonwarrior).
- Auto-reply: strip stray leading/trailing `HEARTBEAT_OK` from normal replies; drop short (≤ 30 chars) heartbeat acks.
- WhatsApp auto-reply: default to self-only when no config is present.
- Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines.
- Logging/Signal: treat signal-cli "Failed …" lines as errors in gateway logs.
- Discord: include recent guild context when replying to mentions and add `discord.historyLimit` to tune how many messages are captured.
- Discord: include author tag + id in group context `[from:]` lines for ping-ready replies (thanks @thewilloftheshadow).
- Discord: include replied-to message context when a Discord message references another message (thanks @thewilloftheshadow).
- Discord: preserve newlines when stripping reply tags from agent output.
- Gateway: fix TypeScript build by aligning hook mapping `channel` types and removing a dead Group DM branch in Discord monitor.
- Skills: switch imsg installer to brew tap formula.
- Skills: gate macOS-only skills by OS and surface block reasons in the Skills UI.
- Onboarding: show skill descriptions in the macOS setup flow and surface clearer Gateway/skills error messages.
- Onboarding: auto-verify Claude OAuth tokens, show “verified” when detected working, and avoid re-auth prompts unless verification fails.
- CLI onboarding: include exit code + a useful one-line summary when skill dependency installs fail.
- CLI onboarding: explain Tailscale exposure options (Off/Serve/Funnel) and colorize provider status (linked/configured/needs setup).
- CLI onboarding: add provider primers (WhatsApp/Telegram/Discord/Signal) incl. Discord bot token setup steps.
- CLI onboarding: allow skipping the “install missing skill dependencies” selection without canceling the wizard.
- CLI onboarding: always prompt for WhatsApp `whatsapp.allowFrom` and print (optionally open) the Control UI URL when done.
- CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode).
- macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states.
- macOS: keep config writes on the main actor to satisfy Swift concurrency rules.
- macOS menu: show multi-line gateway error details, add an always-visible gateway row, avoid duplicate gateway status rows, suppress transient `cancelled` device refresh errors, and auto-recover the control channel on disconnect.
- macOS menu: show session last-used timestamps in the list and add recent-message previews in session submenus.
- macOS menu: tighten session row padding and time out session preview loading with cached fallback.
- macOS: log health refresh failures and recovery to make gateway issues easier to diagnose.
- macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b
- macOS codesign: include camera entitlement so permission prompts work in the menu bar app.
- Agent tools: bash tool supports real TTY via `stdinMode: "pty"` with node-pty, warning + fallback on load/start failure.
- Agent tools: map `camera.snap` JPEG payloads to `image/jpeg` to avoid MIME mismatch errors.
- Tests: cover `camera.snap` MIME mapping to prevent image/png vs image/jpeg mismatches.
- macOS camera: wait for exposure/white balance to settle before capturing a snap to avoid dark images.
- Camera snap: add `delayMs` parameter (default 2000ms on macOS) to improve exposure reliability.
- Camera: add `camera.list` and optional `deviceId` selection for snaps/clips.
- Tests: cover camera device selection params in CLI + agent tools.
- macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b
- macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b
- macOS remote: route settings through gateway config and avoid local config reads in remote mode.
- Telegram: align token resolution for cron/agent/CLI sends (env/config/tokenFile) to prevent isolated delivery failures (#76).
- Telegram: honor per-group mention gating defaults/overrides via `telegram.groups` and `"*"` defaults (thanks @joshp123).
- Chat UI: clear composer input immediately and allow clear while editing to prevent duplicate sends (#72) — thanks @hrdwdmrbl
- Restart: use systemd on Linux (and report actual restart method) instead of always launchctl.
- Gateway relay: detect Bun binaries via execPath to resolve packaged assets on macOS.
- Cron: prevent `every` schedules without an anchor from firing in a tight loop (thanks @jamesgroat).
- Docs: add manual OAuth setup for remote/headless deployments (#67) — thanks @wstock
- Docs/agent tools: clarify that browser `wait` should be avoided by default and used only in exceptional cases.
- Docs: clarify self-chat mode and group mention gating config (#111) — thanks @rafaelreis-r.
- Browser tools: `upload` supports auto-click refs, direct `inputRef`/`element` file inputs, and emits input/change after `setFiles` so JS-heavy sites pick up attachments.
- Browser tools: harden CDP readiness (HTTP + WS), retry CDP connects, and auto-restart the clawd browser when the socket handshake stalls.
- Browser CLI: add `clawdis browser reset-profile` to move the clawd profile to Trash when it gets wedged.
- Signal: fix daemon startup race (wait for `/api/v1/check`) and normalize JSON-RPC `version` probe parsing.
- Docs/Signal: clarify bot-number vs personal-account setup (self-chat loop protection) and add a quickstart config snippet.
- Docs: refresh the CLI wizard guide and highlight onboarding in the README.
- CLI: tighten onboarding prompt typing to keep bun builds green.
- macOS: Voice Wake now fully tears down the Speech pipeline when disabled (cancel pending restarts, drop stale callbacks) to avoid high CPU in the background.
- macOS menu: add a Talk Mode action alongside the Open Dashboard/Chat/Canvas entries.
- macOS Debug: hide “Restart Gateway” when the app won’t start a local gateway (remote mode / attach-only).
- macOS Debug: add an icon for the App Logging submenu.
- macOS Talk Mode: orb overlay refresh, ElevenLabs request logging, API key status in settings, and auto-select first voice when none is configured.
- macOS Talk Mode: add hard timeout around ElevenLabs TTS synthesis to avoid getting stuck “speaking” forever on hung requests.
- macOS Talk Mode: avoid stuck playback when the audio player never starts (fail-fast + watchdog).
- macOS Talk Mode: fix audio stop ordering so disabling Talk Mode always stops in-flight playback.
- macOS Talk Mode: throttle audio-level updates (avoid per-buffer task creation) to reduce CPU/task churn.
- macOS Talk Mode: increase overlay window size so wave rings don’t clip; close button is hover-only and closer to the orb.
- WebChat: preserve chat run ordering per session so concurrent runs don’t strand the typing indicator.
- Talk Mode: fall back to system TTS when ElevenLabs is unavailable, returns non-audio, or playback fails (macOS/iOS/Android).
- Talk Mode: stream PCM on macOS/iOS for lower latency (incremental playback); Android continues MP3 streaming.
- Talk Mode: validate ElevenLabs v3 stability and latency tier directives before sending requests.
- iOS/Android Talk Mode: auto-select the first ElevenLabs voice when none is configured.
- ElevenLabs: add retry/backoff for 429/5xx and include content-type in errors for debugging.
- Talk Mode: align to the gateway’s main session key and fall back to history polling when chat events drop (prevents stuck “thinking” / missing messages).
- Talk Mode: treat history timestamps as seconds or milliseconds to avoid stale assistant picks (macOS/iOS/Android).
- Chat UI: clear streaming/tool bubbles when external runs finish, preventing duplicate assistant bubbles.
- Chat UI: user bubbles use `ui.seamColor` (fallback to a calmer default blue).
- Android Chat UI: use `onPrimary` for user bubble text to preserve contrast (thanks @Syhids).
- Control UI: sync sidebar navigation with the URL for deep-linking, and auto-scroll chat to the latest message.
- Control UI: disable Web Chat + Talk when no iOS/Android node is connected; refreshed Web Chat styling and keyboard send.
- Control UI: keep chat pinned to the latest message while typing/sending and restore drafts on send failures.
- Control UI: soften chat bubble text opacity for calmer readability.
- macOS Web Chat: improve empty/error states, focus message field on open, keep pill/send inside the input field, and make the composer pill edge-to-edge with square top corners.
- macOS: bundle Control UI assets into the app relay so the packaged app can serve them (thanks @mbelinky).
- Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android).
- iOS Talk Mode: fix chat completion wait to time out even if no events arrive (prevents “Thinking…” hangs).
- iOS Talk Mode: keep recognition running during playback to support interrupt-on-speech.
- iOS Talk Mode: preserve directive voice/model overrides across config reloads and add ElevenLabs request timeouts.
- iOS/Android Talk Mode: explicitly `chat.subscribe` when Talk Mode is active, so completion events arrive even if the Chat UI isn’t open.
- Chat UI: refresh history when another client finishes a run in the same session, so Talk Mode + Voice Wake transcripts appear consistently.
- Gateway: `voice.transcript` now also maps agent bus output to `chat` events, ensuring chat UIs refresh for voice-triggered runs.
- Gateway: auto-migrate legacy config on startup (non-Nix); Nix mode hard-fails with a clear error when legacy keys are present.
- iOS/Android: show a centered Talk Mode orb overlay while Talk Mode is enabled.
- Gateway config: inject `talk.apiKey` from `ELEVENLABS_API_KEY`/shell profile so nodes can fetch it on demand.
- Canvas A2UI: tag requests with `platform=android|ios|macos` and boost Android canvas background contrast.
- iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first).
- macOS menu: device list now uses `node.list` (devices only; no agent/tool presence entries).
- macOS menu: device list now shows connected nodes only.
- macOS menu: device rows now pack platform/version on the first line, and command lists wrap in submenus.
- macOS menu: split device platform/version across first and second rows for better fit.
- macOS Canvas: show remote control status in the debug overlay and log A2UI auto-nav decisions.
- Canvas A2UI: polish the debug status HUD styling.
- iOS node: fix ReplayKit screen recording crash caused by queue isolation assertions during capture.
- iOS Talk Mode: avoid audio tap queue assertions when starting recognition.
- macOS: use $HOME/Library/pnpm for SSH PATH exports (thanks @mbelinky).
- macOS remote: harden SSH tunnel recovery/logging, honor `gateway.remote.url` port when forwarding, clarify gateway disconnect status, and add Debug menu tunnel reset.
- iOS/Android nodes: bridge auto-connect refreshes stale tokens and settings now show richer bridge/device details.
- macOS: bundle device model resources to prevent Instances crashes (thanks @mbelinky).
- iOS/Android nodes: status pill now surfaces camera activity instead of overlay toasts.
- iOS/Android/macOS nodes: camera snaps recompress to keep base64 payloads under 5 MB.
- iOS/Android nodes: status pill now surfaces pairing, screen recording, voice wake, and foreground-required states.
- iOS/Android nodes: avoid duplicating “Gateway reconnecting…” when the bridge is already connecting.
- iOS/Android nodes: Talk Mode now lives on a side bubble (with an iOS toggle to hide it), and Android settings no longer show the Talk Mode switch.
- macOS menu: top status line now shows pending node pairing approvals (incl. repairs).
- CLI: avoid spurious gateway close errors after successful request/response cycles.
- Agent runtime: clamp tool-result images to the 5MB Anthropic limit to avoid hard request rejections.
- Agent runtime: write v2 session headers so Pi session branching stays in the Clawdis sessions dir.
- Tests: add Swift Testing coverage for camera errors and Kotest coverage for Android bridge endpoints.
## 2.0.0-beta4 — 2025-12-27 ## 2025.12.27 (betas 3–4)
### Highlights
- 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.
### Fixes ### Fixes
- Package contents: include Discord/hooks build outputs in the npm tarball to avoid missing module errors. - Packaging fixes, heartbeat cleanup, WhatsApp reconnect reliability.
- Heartbeat replies now drop any output containing `HEARTBEAT_OK`, preventing stray emoji/text from being delivered. - macOS menu/Chat UI polish and presence reporting fixes.
- macOS menu now refreshes the control channel after the gateway starts and shows “Connecting to gateway…” while the gateway is coming up.
- macOS local mode now waits for the gateway to be ready before configuring the control channel, avoiding false “no connection” flashes.
- WhatsApp watchdog now forces a reconnect even if the socket close event stalls (force-close to unblock reconnect loop).
- Gateway presence now reports macOS product version (via `sw_vers`) instead of Darwin kernel version.
## 2.0.0-beta3 — 2025-12-27 ## 2025.12.21 (beta 2)
### Highlights ### Highlights
- First-class Clawdis tools (browser, canvas, nodes, cron) replace the old `clawdis-*` skills; tool schemas are now injected directly into the agent runtime. - Bundled gateway packaging + DMG distribution pipeline.
- Per-session model selection + custom model providers: `models.providers` merges into `~/.clawdis/agent/models.json` (merge/replace modes) for LiteLLM, local OpenAI-compatible servers, Anthropic proxies, etc. - Skills platform (bundled/managed/workspace) with install gating + UI.
- Group chat activation modes: per-group `/activation mention|always` command with status visibility. - Onboarding polish and agent UX improvements.
- Discord bot transport for DMs and guild text channels, with allowlists + mention gating. - Canvas host served from Gateway; browser control simplification.
- Gateway webhooks: external `wake` and isolated `agent` hooks with dedicated token auth.
- Hook mappings + Gmail Pub/Sub helper (`clawdis hooks gmail setup/run`) with auto-renew + Tailscale Funnel support. ## 2025.12.19 (beta 1)
- Command queue modes + per-session overrides (`/queue ...`) and new `agent.maxConcurrent` cap for safe parallelism across sessions.
- Background bash tasks: `bash` auto-yields after 20s (or on demand) with a `process` tool to list/poll/log/write/kill sessions. ### Highlights
- Gateway in-process restart: `gateway` tool action triggers a SIGUSR1 restart without needing a supervisor. - 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.
### Breaking ### Breaking
- Config refactor: `inbound.*` removed; use top-level `routing` (allowlists + group rules + transcription), `messages` (prefixes/timestamps), and `session` (scoping/store/mainKey). No legacy keys read. - Switched to Pi-only agent runtime; legacy providers removed.
- Heartbeat config moved to `agent.heartbeat`: set `every: "30m"` (duration string) and optional `model`. `agent.heartbeatMinutes` is removed, and heartbeats are disabled unless `agent.heartbeat.every` is set. - Gateway became the single source of truth (no ad-hoc direct sends).
- Heartbeats now run via the gateway runner (main session) and deliver to the last used channel by default. WhatsApp reply-heartbeat behavior is removed; use `agent.heartbeat.target`/`to` (or `target: "none"`) to control delivery.
- Browser `act` no longer accepts CSS `selector`; use `snapshot` refs (default `ai`) or `evaluate` as an escape hatch.
### Fixes ## 2025.12.05–2025.12.03 (pre-Clawdbot)
- Heartbeat replies now strip repeated `HEARTBEAT_OK` tails to avoid accidental “OK OK” spam.
- Heartbeat delivery now uses the last non-empty payload, preventing tool preambles from swallowing the final reply.
- Heartbeats now skip WhatsApp delivery when the web provider is inactive or unlinked (instead of logging “no active gateway listener”).
- Heartbeat failure logs now include the error reason instead of `[object Object]`.
- Duration strings now accept `h` (hours) where durations are parsed (e.g., heartbeat intervals).
- WhatsApp inbound now normalizes more wrapper types so quoted reply bodies are extracted reliably.
- WhatsApp send now preserves existing JIDs (including group `@g.us`) instead of coercing to `@s.whatsapp.net`. (Thanks @arun-8687.)
- Telegram/WhatsApp: reply context stays in `Body`/`ReplyTo*`, but outbound replies no longer thread to the original message. (Thanks @joshp123 for the PR and follow-up question.)
- Suppressed libsignal session cleanup spam from console logs unless verbose mode is enabled.
- WhatsApp web creds persistence hardened; credentials are restored before auth checks and QR login auto-restarts if it stalls.
- Group chats now honor `routing.groupChat.requireMention=false` as the default activation when no per-group override exists.
- Gateway auth no longer supports PAM/system mode; use token or shared password.
- Tailscale Funnel now requires password auth (no token-only public exposure).
- Group `/new` resets now work with @mentions so activation guidance appears on fresh sessions.
- Group chat activation context is now injected into the system prompt at session start (and after activation changes), including /new greetings.
- Typing indicators now start only once a reply payload is produced (no "thinking" typing for silent runs).
- WhatsApp group typing now starts immediately only when the bot is mentioned; otherwise it waits until real output exists.
- Streamed `<think>` segments are stripped before partial replies are emitted.
- System prompt now tags allowlisted owner numbers as the user identity to avoid mistaken “friend” assumptions.
- LM Studio/Ollama replies now require <final> tags; streaming ignores content until <final> begins.
- LM Studio responses API: tools payloads no longer include `strict: null`, and LM Studio no longer gets forced `<think>/<final>` tags.
- Identity emoji no longer auto-prefixes replies (set `messages.responsePrefix` explicitly if desired).
- Model switches now enqueue a system event so the next run knows the active model.
- `/model status` now lists available models (same as `/model`).
- `process log` pagination is now line-based (omit `offset` to grab the last N lines).
- macOS WebChat: assistant bubbles now update correctly when toggling light/dark mode.
- macOS: avoid spawning a duplicate gateway process when an external listener already exists.
- Node bridge: when binding to a non-loopback host (e.g. Tailnet IP), also listens on `127.0.0.1` for local connections (without creating duplicate loopback listeners for `0.0.0.0`/`127.0.0.1` binds).
- UI perf: pause repeat animations when scenes are inactive (typing dots, onboarding glow, iOS status pulse), throttle voice overlay level updates, and reduce overlay focus churn.
- Canvas defaults/A2UI auto-nav aligned; debug status overlay centered; redundant await removed in `CanvasManager`.
- Gateway launchd loop fixed by removing redundant `kickstart -k`.
- CLI now hints when Peekaboo is unauthorized.
- WhatsApp web inbox listeners now clean up on close to avoid duplicate handlers.
- Gateway startup now brings up browser control before external providers; WhatsApp/Telegram/Discord auto-start can be disabled with `web.enabled`, `telegram.enabled`, or `discord.enabled`.
### Providers & Routing
- New Discord provider for DMs + guild text channels with allowlists and mention-gated replies by default.
- `routing.queue` now controls queue vs interrupt behavior globally + per surface (defaults: WhatsApp/Telegram interrupt, Discord/WebChat queue).
- `/queue <mode>` supports one-shot or per-session overrides; `/queue reset|default` clears overrides.
- `agent.maxConcurrent` caps global parallel runs while keeping per-session serialization.
### macOS app
- Update-ready state surfaced in the menu; menu sections regrouped with session submenus.
- Menu bar now shows a dedicated Nodes section under Context with inline rows, overflow submenu, and iconized actions.
- Nodes now expose consistent inline details with per-node submenus for quick copy of key fields.
- Node rows now show compact app versions (build numbers moved to submenus) and offer SSH launch from Bonjour when available.
- Menu actions are grouped below toggles; Open Canvas hides when disabled and Voice Wake now anchors the mic picker.
- Connections now include Discord provider status + configuration UI.
- Menu bar gains an Allow Camera toggle alongside Canvas.
- Session list polish: sleeping/disconnected/error states, usage bar restored, padding + bar sizing tuned, syncing menu removed, header hidden when disconnected.
- Chat UI polish: tool call cards + merged tool results, glass background, tighter composer spacing, visual effect host tweaks.
- OAuth storage moved; legacy session syncing metadata removed.
- Remote SSH tunnels now get health checks; Debug → Ports highlights unhealthy tunnels and offers Reset SSH tunnel.
- Menu bar session/node sections no longer reflow while open, keeping hover highlights aligned.
- Menu hover highlights now span the full width (including submenu arrows).
- Menu session rows now refresh while open without width changes (no more stuck “Loading sessions…”).
- Menu width no longer grows on hover when moving the mouse across rows.
- Context usage bars now have higher contrast in light mode.
- macOS node timeouts now share a single async timeout helper for consistent behavior.
- WebChat window defaults tightened (narrower width, edge-to-edge layout) and the SwiftUI tag removed from the title.
### Nodes & Canvas
- Debug status overlay gated and toggleable on macOS/iOS/Android nodes.
- Gateway now derives the canvas host URL via a shared helper for bridge + WS handshakes (avoids loopback pitfalls).
- `canvas a2ui push` validates JSONL with line errors, rejects v0.9 payloads, and supports `--text` quick renders.
- `nodes rename` lets you override paired node display names without editing JSON.
- Android scaffold asset cleanup; iOS canvas/voice wake adjustments.
### Logging & Observability
- New subsystem console formatter with color modes, shortened prefixes, and TTY detection; browser/gateway logs route through the subsystem logger.
- WhatsApp console output streamlined; chalk/tslog typing fixes.
### Web UI
- Chat is now the dashboard landing view; health status simplified; initial scroll animation removed.
### Build, Dev, Docs
- Notarization flow added for macOS release artifacts; packaging scripts updated.
- macOS signing auto-selects Developer ID → Apple Distribution → Apple Development; no ad-hoc fallback.
- Added type-aware oxlint; docs list resolves from cwd; formatting/lint cleanup and dependency bumps (Peekaboo).
- Docs refreshed for tools, custom model providers, Discord, queue/routing, group activation commands, logging, restart semantics, release notes, GitHub pages CTAs, and npm pitfalls.
- `pnpm build` now skips A2UI bundling for faster builds (run `pnpm canvas:a2ui:bundle` when needed).
### Tests
- Coverage added for models config merging, WhatsApp reply context, QR login flows, auto-reply behavior, and gateway SIGTERM timeouts.
- Added gateway webhook coverage (auth, validation, and summary posting).
- Vitest now isolates HOME/XDG config roots so tests never touch a real `~/.clawdis` install.
## 2.0.0-beta2 — 2025-12-21
Second beta focused on bundled gateway packaging, skills management, onboarding polish, and provider reliability.
### Highlights ### Highlights
- Bundled gateway packaging: bun-compiled embedded gateway, new `gateway-daemon` command, launchd support, DMG packaging (zip+DMG). - Pi-only agent path and web-only gateway workflow.
- Skills platform: managed/bundled skills, install metadata + installers (uv), skill search + website, media/transcription helpers. - Thinking/verbose directives, group chat support, and heartbeat controls.
- macOS app: new Connections settings w/ provider status + QR login, skills settings redesign w/ install targets, models list loaded from the Gateway, clearer local/remote gateway choices. - `clawdbot agent` CLI added; session tables and health reporting.
- Web/agent UX: tool summary streaming + runtime toggle, WhatsApp QR login tool, agent steering queue, voice wake routes to main session, workspace bootstrap ritual.
### Gateway & providers ## 2025.11.28–2025.11.25 (early web-only)
- Gateway: `models.list`, provider status events + RPC coverage, tailscale auth + PAM, bind-mode config, enriched agent WS logs, safer upgrade socket handling, fixed handshake auth crash.
- WhatsApp Web: QR login flow improvements (logged-out clearing, wait flow), self-chat mode handling, removed batching delay, web inbox made non-blocking.
- Telegram: normalized chat IDs with clearer error reporting.
### Canvas & browser control - Heartbeat CLI + interval handling.
- Canvas host served on Gateway port; removed standalone canvasHost port config; restored action bridge; refreshed A2UI bundle + message context; bridge canvas host for nodes. - Media MIME sniffing, size caps, and timeout fallbacks.
- A2UI full-screen gutters + status clearance after successful load to avoid overlay collisions. - Web provider reconnects and early stability fixes.
- Browser control API simplified; added MCP tool dispatch + native actions; control server can start without Playwright; hook timeouts extended.
### macOS UI polish
- Onboarding chat UI: kickoff flow, bubble tails, spacing + bottom bar refinements, window sizing tweaks, show Dock icon during onboarding.
- Skills UI: stabilized action column, fixed install target access, refined list layout and sizing, always show CLI installer.
- Remote/local gateway: auto-enable local gateway, clearer labels, re-ensure remote tunnel, hide local bridge discovery in remote mode.
### Build, CI, deps
- Bundled playwright-core + chromium-bidi/long; bun gateway bytecode builds; swiftformat/biome CI fixes; iOS lint script updates; Android icon/compiler updates; ignored new ClawdisKit `.swiftpm` path.
### Docs
- README architecture refresh + npm header image fix; onboarding/bootstrap steps; skills install guidance + new skills; browser/canvas control docs; bundled gateway + DMG packaging notes.
## 2.0.0-beta1 — 2025-12-19
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.
### 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)
### 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: only the embedded Pi runtime remains, 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.
### 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 clawd’s 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 you’re @‑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.
### 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).
## 1.5.0 — 2025-12-05
### Breaking
- Dropped all non-Pi agents (Claude, Codex, Gemini, Opencode); only the embedded Pi runtime remains 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 doesn’t 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
### 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 > `agent.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 don’t 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 Pi’s `agent_end` (or process exit) so late assistant messages aren’t truncated; 5-minute hard cap only as a failsafe.
### 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).
### 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 isn’t 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
### Highlights
- **Pluggable agents (Claude, Pi, Codex, Opencode):** agent selection via config/CLI plus 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.
### 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.
### 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 message prefix marker.
## 1.2.2 — 2025-11-28
### Changes
- Manual heartbeat sends: `clawdis heartbeat --message/--body` (web provider only); `--dry-run` previews payloads.
## 1.2.1 — 2025-11-28
### Changes
- Media MIME-first handling; hosted media extensions derived from detected MIME with tests.
### 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.
## 1.2.0 — 2025-11-27
### Changes
- Heartbeat interval default 10m for command mode; prompt `HEARTBEAT /think:high`; skips don’t 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.
## 1.1.0 — 2025-11-26
### Changes
- Web auto-replies resize/recompress media and honor `agent.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 `agent.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.
+4 -4
View File
@@ -1,9 +1,9 @@
# Contributing to Clawdis # Contributing to Clawdbot
Welcome to the lobster tank! 🦞 Welcome to the lobster tank! 🦞
## Quick Links ## Quick Links
- **GitHub:** https://github.com/steipete/clawdis - **GitHub:** https://github.com/clawdbot/clawdbot
- **Discord:** https://discord.gg/qkhbAGHRBT - **Discord:** https://discord.gg/qkhbAGHRBT
- **X/Twitter:** [@steipete](https://x.com/steipete) / [@clawdbot](https://x.com/clawdbot) - **X/Twitter:** [@steipete](https://x.com/steipete) / [@clawdbot](https://x.com/clawdbot)
@@ -20,11 +20,11 @@ Welcome to the lobster tank! 🦞
## How to Contribute ## How to Contribute
1. **Bugs & small fixes** → Open a PR! 1. **Bugs & small fixes** → Open a PR!
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/steipete/clawdis/discussions) or ask in Discord first 2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/clawdbot/clawdbot/discussions) or ask in Discord first
3. **Questions** → Discord #setup-help 3. **Questions** → Discord #setup-help
## Before You PR ## Before You PR
- Test locally with your Clawdis instance - Test locally with your Clawdbot instance
- Run linter: `npm run lint` - Run linter: `npm run lint`
- Keep PRs focused (one thing per PR) - Keep PRs focused (one thing per PR)
- Describe what & why - Describe what & why
+3 -3
View File
@@ -19,9 +19,9 @@ RUN apt-get update \
xvfb \ xvfb \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/clawdis-sandbox-browser COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/clawdbot-sandbox-browser
RUN chmod +x /usr/local/bin/clawdis-sandbox-browser RUN chmod +x /usr/local/bin/clawdbot-sandbox-browser
EXPOSE 9222 5900 6080 EXPOSE 9222 5900 6080
CMD ["clawdis-sandbox-browser"] CMD ["clawdbot-sandbox-browser"]
Submodule Peekaboo updated: 5c8eedd642...c1243a7978.
+94 -43
View File
@@ -1,7 +1,7 @@
# 🦞 CLAWDIS — Personal AI Assistant # 🦞 CLAWDBOT — Personal AI Assistant
<p align="center"> <p align="center">
<img src="https://raw.githubusercontent.com/steipete/clawdis/main/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>
<p align="center"> <p align="center">
@@ -9,23 +9,92 @@
</p> </p>
<p align="center"> <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/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/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/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="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> <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
</p> </p>
**Clawdis** is a *personal AI assistant* you run on your own devices. **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. 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. If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
Website: https://clawd.me · Docs: [`docs/index.md`](docs/index.md) · FAQ: [`docs/faq.md`](docs/faq.md) · Wizard: [`docs/wizard.md`](docs/wizard.md) · Docker (optional): [`docs/docker.md`](docs/docker.md) · Discord: https://discord.gg/clawd Website: https://clawd.me · Docs: [`docs/index.md`](docs/index.md) · FAQ: [`docs/faq.md`](docs/faq.md) · Wizard: [`docs/wizard.md`](docs/wizard.md) · Docker (optional): [`docs/docker.md`](docs/docker.md) · Discord: https://discord.gg/clawd
Preferred setup: run the onboarding wizard (`clawdis onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Windows, and Linux**. 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. 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 2.0.0-beta5 (2026-01-03)
### Highlights
- Project rename completed: CLIs, paths, bundle IDs, env vars, and docs unified on Clawdbot.
- Agent-to-agent relay: `sessions_send` ping‑pong 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)
``` ```
Your surfaces Your surfaces
│ │
@@ -36,30 +105,12 @@ Your surfaces
└──────────────┬────────────────┘ └──────────────┬────────────────┘
│ │
├─ Pi agent (RPC) ├─ Pi agent (RPC)
├─ CLI (clawdis …) ├─ CLI (clawdbot …)
├─ WebChat (browser) ├─ WebChat (browser)
├─ macOS app (Clawdis.app) ├─ macOS app (Clawdbot.app)
└─ iOS node (Canvas + voice) └─ iOS node (Canvas + voice)
``` ```
## What Clawdis does
- **Personal assistant** — one user, one identity, one memory surface.
- **Multi-surface inbox** — WhatsApp, Telegram, Discord, iMessage, WebChat, macOS, iOS. Signal support via `signal-cli` (see `docs/signal.md`). iMessage uses `imsg` (see `docs/imessage.md`).
- **Voice wake + push-to-talk** — local speech recognition on macOS/iOS.
- **Canvas** — a live visual workspace you can drive from the agent.
- **Automation-ready** — browser control, media handling, and tool streaming.
- **Local-first control plane** — the Gateway owns state, everything else connects.
- **Group chats** — mention-based by default, `/activation always|mention` per group (owner-only).
- **Nix mode** — opt-in declarative config + read-only UI when `CLAWDIS_NIX_MODE=1`.
## How it works (short)
- **Gateway** is the single source of truth for sessions/providers.
- **Loopback-first**: `ws://127.0.0.1:18789` by default.
- **Bridge** (optional) exposes a paired-node port for iOS/Android.
- **Agent runtime** is **Pi** in RPC mode.
## Quick start (from source) ## Quick start (from source)
Runtime: **Node ≥22** + **pnpm**. Runtime: **Node ≥22** + **pnpm**.
@@ -70,25 +121,25 @@ pnpm build
pnpm ui:build pnpm ui:build
# Recommended: run the onboarding wizard # Recommended: run the onboarding wizard
pnpm clawdis onboard pnpm clawdbot onboard
# Link WhatsApp (stores creds in ~/.clawdis/credentials) # Link WhatsApp (stores creds in ~/.clawdbot/credentials)
pnpm clawdis login pnpm clawdbot login
# Start the gateway # Start the gateway
pnpm clawdis gateway --port 18789 --verbose pnpm clawdbot gateway --port 18789 --verbose
# Dev loop (auto-reload on TS changes) # Dev loop (auto-reload on TS changes)
pnpm gateway:watch pnpm gateway:watch
# Send a message # Send a message
pnpm clawdis send --to +1234567890 --message "Hello from Clawdis" pnpm clawdbot send --to +1234567890 --message "Hello from Clawdbot"
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Discord) # Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Discord)
pnpm clawdis agent --message "Ship checklist" --thinking high pnpm clawdbot agent --message "Ship checklist" --thinking high
``` ```
If you run from source, prefer `pnpm clawdis …` (not global `clawdis`). If you run from source, prefer `pnpm clawdbot …` (not global `clawdbot`).
## Chat commands ## Chat commands
@@ -115,14 +166,14 @@ Send these in WhatsApp/Telegram/WebChat (group commands are owner-only):
- **Discovery + pairing**: Bonjour discovery via `BridgeDiscoveryModel` (NWBrowser). `BridgeConnectionController` auto‑connects using Keychain token or allows manual host/port. - **Discovery + pairing**: Bonjour discovery via `BridgeDiscoveryModel` (NWBrowser). `BridgeConnectionController` auto‑connects using Keychain token or allows manual host/port.
- **Node runtime**: `BridgeSession` (actor) maintains the `NWConnection`, hello handshake, ping/pong, RPC requests, and `invoke` callbacks. - **Node runtime**: `BridgeSession` (actor) maintains the `NWConnection`, hello handshake, ping/pong, RPC requests, and `invoke` callbacks.
- **Capabilities + commands**: advertises `canvas`, `screen`, `camera`, `voiceWake` (settings‑driven) and executes `canvas.*`, `canvas.a2ui.*`, `camera.*`, `screen.record` (`NodeAppModel.handleInvoke`). - **Capabilities + commands**: advertises `canvas`, `screen`, `camera`, `voiceWake` (settings‑driven) and executes `canvas.*`, `canvas.a2ui.*`, `camera.*`, `screen.record` (`NodeAppModel.handleInvoke`).
- **Canvas**: `WKWebView` with bundled Canvas scaffold + A2UI, JS eval, snapshot capture, and `clawdis://` deep‑link interception (`ScreenController`). - **Canvas**: `WKWebView` with bundled Canvas scaffold + A2UI, JS eval, snapshot capture, and `clawdbot://` deep‑link interception (`ScreenController`).
- **Voice + deep links**: voice wake sends `voice.transcript` events; `clawdis://agent` links emit `agent.request`. Voice wake triggers sync via `voicewake.get` + `voicewake.changed`. - **Voice + deep links**: voice wake sends `voice.transcript` events; `clawdbot://agent` links emit `agent.request`. Voice wake triggers sync via `voicewake.get` + `voicewake.changed`.
## Companion apps ## Companion apps
The **macOS app is critical**: it runs the menu‑bar control plane, owns local permissions (TCC), hosts Voice Wake, exposes WebChat/debug tools, and coordinates local/remote gateway mode. Most “assistant” UX lives here. The **macOS app is critical**: it runs the menu‑bar control plane, owns local permissions (TCC), hosts Voice Wake, exposes WebChat/debug tools, and coordinates local/remote gateway mode. Most “assistant” UX lives here.
### macOS (Clawdis.app) ### macOS (Clawdbot.app)
- Menu bar control for the Gateway and health. - Menu bar control for the Gateway and health.
- Voice Wake + push-to-talk overlay. - Voice Wake + push-to-talk overlay.
@@ -135,7 +186,7 @@ Build/run: `./scripts/restart-mac.sh` (packages + launches).
- Pairs as a node via the Bridge. - Pairs as a node via the Bridge.
- Voice trigger forwarding + Canvas surface. - Voice trigger forwarding + Canvas surface.
- Controlled via `clawdis nodes …`. - Controlled via `clawdbot nodes …`.
Runbook: `docs/ios/connect.md`. Runbook: `docs/ios/connect.md`.
@@ -153,7 +204,7 @@ Runbook: `docs/ios/connect.md`.
## Configuration ## Configuration
Minimal `~/.clawdis/clawdis.json`: Minimal `~/.clawdbot/clawdbot.json`:
```json5 ```json5
{ {
@@ -165,7 +216,7 @@ Minimal `~/.clawdis/clawdis.json`:
### WhatsApp ### WhatsApp
- Link the device: `pnpm clawdis login` (stores creds in `~/.clawdis/credentials`). - Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`. - Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
### Telegram ### Telegram
@@ -223,13 +274,13 @@ Browser control (optional):
## Email hooks (Gmail) ## Email hooks (Gmail)
```bash ```bash
clawdis hooks gmail setup --account you@gmail.com clawdbot hooks gmail setup --account you@gmail.com
clawdis hooks gmail run clawdbot hooks gmail run
``` ```
- [`docs/security.md`](docs/security.md) - [`docs/security.md`](docs/security.md)
- [`docs/troubleshooting.md`](docs/troubleshooting.md) - [`docs/troubleshooting.md`](docs/troubleshooting.md)
- [`docs/ios/connect.md`](docs/ios/connect.md) - [`docs/ios/connect.md`](docs/ios/connect.md)
- [`docs/clawdis-mac.md`](docs/clawdis-mac.md) - [`docs/clawdbot-mac.md`](docs/clawdbot-mac.md)
## Contributing ## Contributing
@@ -239,7 +290,7 @@ AI/vibe-coded PRs welcome! 🤖
## Clawd ## Clawd
Clawdis was built for **Clawd**, a space lobster AI assistant. Clawdbot was built for **Clawd**, a space lobster AI assistant.
- https://clawd.me - https://clawd.me
- https://soul.md - https://soul.md
+13 -13
View File
@@ -1,15 +1,15 @@
<?xml version="1.0" standalone="yes"?> <?xml version="1.0" standalone="yes"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0"> <rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel> <channel>
<title>Clawdis</title> <title>Clawdbot</title>
<item> <item>
<title>2.0.0-beta5</title> <title>2.0.0-beta5</title>
<pubDate>Sat, 03 Jan 2026 07:15:16 +0100</pubDate> <pubDate>Sat, 03 Jan 2026 07:15:16 +0100</pubDate>
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link> <link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>2765</sparkle:version> <sparkle:version>2765</sparkle:version>
<sparkle:shortVersionString>2.0.0-beta5</sparkle:shortVersionString> <sparkle:shortVersionString>2.0.0-beta5</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion> <sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdis 2.0.0-beta5</h2> <description><![CDATA[<h2>Clawdbot 2.0.0-beta5</h2>
<h3>Fixed</h3> <h3>Fixed</h3>
<ul> <ul>
<li>Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).</li> <li>Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).</li>
@@ -30,8 +30,8 @@
<li>Sessions: group keys now use <code>surface:group:<id></code> / <code>surface:channel:<id></code>; legacy <code>group:*</code> keys migrate on next message; <code>groupdm</code> keys are no longer recognized.</li> <li>Sessions: group keys now use <code>surface:group:<id></code> / <code>surface:channel:<id></code>; legacy <code>group:*</code> keys migrate on next message; <code>groupdm</code> keys are no longer recognized.</li>
<li>Discord: remove legacy <code>discord.allowFrom</code>, <code>discord.guildAllowFrom</code>, and <code>discord.requireMention</code>; use <code>discord.dm</code> + <code>discord.guilds</code>.</li> <li>Discord: remove legacy <code>discord.allowFrom</code>, <code>discord.guildAllowFrom</code>, and <code>discord.requireMention</code>; use <code>discord.dm</code> + <code>discord.guilds</code>.</li>
<li>Providers: Discord/Telegram no longer auto-start from env tokens alone; add <code>discord: { enabled: true }</code> / <code>telegram: { enabled: true }</code> to your config when using <code>DISCORD_BOT_TOKEN</code> / <code>TELEGRAM_BOT_TOKEN</code>.</li> <li>Providers: Discord/Telegram no longer auto-start from env tokens alone; add <code>discord: { enabled: true }</code> / <code>telegram: { enabled: true }</code> to your config when using <code>DISCORD_BOT_TOKEN</code> / <code>TELEGRAM_BOT_TOKEN</code>.</li>
<li>Config: remove <code>routing.allowFrom</code>; use <code>whatsapp.allowFrom</code> instead (run <code>clawdis doctor</code> to migrate).</li> <li>Config: remove <code>routing.allowFrom</code>; use <code>whatsapp.allowFrom</code> instead (run <code>clawdbot doctor</code> to migrate).</li>
<li>Config: remove <code>routing.groupChat.requireMention</code> + <code>telegram.requireMention</code>; use <code>whatsapp.groups</code>, <code>imessage.groups</code>, and <code>telegram.groups</code> defaults instead (run <code>clawdis doctor</code> to migrate).</li> <li>Config: remove <code>routing.groupChat.requireMention</code> + <code>telegram.requireMention</code>; use <code>whatsapp.groups</code>, <code>imessage.groups</code>, and <code>telegram.groups</code> defaults instead (run <code>clawdbot doctor</code> to migrate).</li>
</ul> </ul>
<h3>Features</h3> <h3>Features</h3>
<ul> <ul>
@@ -40,7 +40,7 @@
<li>Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.</li> <li>Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.</li>
<li>Auto-reply: expand queue modes (steer/followup/collect/steer-backlog) with debounce/cap/drop options and followup backlog handling.</li> <li>Auto-reply: expand queue modes (steer/followup/collect/steer-backlog) with debounce/cap/drop options and followup backlog handling.</li>
<li>UI: add optional <code>ui.seamColor</code> accent to tint the Talk Mode side bubble (macOS/iOS/Android).</li> <li>UI: add optional <code>ui.seamColor</code> accent to tint the Talk Mode side bubble (macOS/iOS/Android).</li>
<li>Nix mode: opt-in declarative config + read-only settings UI when <code>CLAWDIS_NIX_MODE=1</code> (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).</li> <li>Nix mode: opt-in declarative config + read-only settings UI when <code>CLAWDBOT_NIX_MODE=1</code> (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).</li>
<li>CLI: add Google Antigravity OAuth auth option for Claude Opus 4.5/Gemini 3 (#88) — thanks @mukhtharcm.</li> <li>CLI: add Google Antigravity OAuth auth option for Claude Opus 4.5/Gemini 3 (#88) — thanks @mukhtharcm.</li>
<li>Agent runtime: accept legacy <code>Z_AI_API_KEY</code> for Z.AI provider auth (maps to <code>ZAI_API_KEY</code>).</li> <li>Agent runtime: accept legacy <code>Z_AI_API_KEY</code> for Z.AI provider auth (maps to <code>ZAI_API_KEY</code>).</li>
<li>Groups: add per-group mention gating defaults/overrides for Telegram/WhatsApp/iMessage via <code>*.groups</code> with <code>"*"</code> defaults; Discord now supports <code>discord.guilds."*"</code> as a default.</li> <li>Groups: add per-group mention gating defaults/overrides for Telegram/WhatsApp/iMessage via <code>*.groups</code> with <code>"*"</code> defaults; Discord now supports <code>discord.guilds."*"</code> as a default.</li>
@@ -50,7 +50,7 @@
<li>iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.</li> <li>iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.</li>
<li>Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI.</li> <li>Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI.</li>
<li>UI: add Discord/Signal/iMessage connection panels in macOS + Control UI (thanks @thewilloftheshadow).</li> <li>UI: add Discord/Signal/iMessage connection panels in macOS + Control UI (thanks @thewilloftheshadow).</li>
<li>Discord: allow agent-triggered reactions via <code>clawdis_discord</code> when enabled, and surface message ids in context.</li> <li>Discord: allow agent-triggered reactions via <code>clawdbot_discord</code> when enabled, and surface message ids in context.</li>
<li>Discord: revamp guild routing config with per-guild/channel rules and slugged display names; add optional group DM support (default off).</li> <li>Discord: revamp guild routing config with per-guild/channel rules and slugged display names; add optional group DM support (default off).</li>
<li>Discord: remove legacy guild/channel ignore lists in favor of per-guild allowlists (and proposed per-guild ignore lists).</li> <li>Discord: remove legacy guild/channel ignore lists in favor of per-guild allowlists (and proposed per-guild ignore lists).</li>
<li>Skills: add Trello skill for board/list/card management (thanks @clawd).</li> <li>Skills: add Trello skill for board/list/card management (thanks @clawd).</li>
@@ -62,7 +62,7 @@
<li>CLI: add <code>configure</code>, <code>doctor</code>, and <code>update</code> wizards for ongoing setup, health checks, and modernization.</li> <li>CLI: add <code>configure</code>, <code>doctor</code>, and <code>update</code> wizards for ongoing setup, health checks, and modernization.</li>
<li>CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.</li> <li>CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.</li>
<li>CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.</li> <li>CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.</li>
<li>CLI: enhance <code>clawdis tui</code> with model/session pickers, tool cards, and slash commands (local or remote).</li> <li>CLI: enhance <code>clawdbot tui</code> with model/session pickers, tool cards, and slash commands (local or remote).</li>
<li>Gateway: allow <code>sessions.patch</code> to set per-session model overrides (used by the TUI <code>/model</code> flow).</li> <li>Gateway: allow <code>sessions.patch</code> to set per-session model overrides (used by the TUI <code>/model</code> flow).</li>
<li>Skills: allow <code>bun</code> as a node manager for skill installs.</li> <li>Skills: allow <code>bun</code> as a node manager for skill installs.</li>
<li>Skills: add <code>things-mac</code> (Things 3 CLI) for read/search plus add/update via URL scheme.</li> <li>Skills: add <code>things-mac</code> (Things 3 CLI) for read/search plus add/update via URL scheme.</li>
@@ -87,7 +87,7 @@
<li>Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android).</li> <li>Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android).</li>
<li>Control UI: refine Web Chat session selector styling (chevron spacing + background).</li> <li>Control UI: refine Web Chat session selector styling (chevron spacing + background).</li>
<li>WebChat: stream live updates for sessions even when runs start outside the chat UI.</li> <li>WebChat: stream live updates for sessions even when runs start outside the chat UI.</li>
<li>Gateway CLI: read <code>CLAWDIS_GATEWAY_PASSWORD</code> from environment in <code>callGateway()</code> — allows <code>doctor</code>/<code>health</code> commands to auth without explicit <code>--password</code> flag.</li> <li>Gateway CLI: read <code>CLAWDBOT_GATEWAY_PASSWORD</code> from environment in <code>callGateway()</code> — allows <code>doctor</code>/<code>health</code> commands to auth without explicit <code>--password</code> flag.</li>
<li>Gateway: add password auth support for remote gateway connections (thanks @jeffersonwarrior).</li> <li>Gateway: add password auth support for remote gateway connections (thanks @jeffersonwarrior).</li>
<li>Auto-reply: strip stray leading/trailing <code>HEARTBEAT_OK</code> from normal replies; drop short (≤ 30 chars) heartbeat acks.</li> <li>Auto-reply: strip stray leading/trailing <code>HEARTBEAT_OK</code> from normal replies; drop short (≤ 30 chars) heartbeat acks.</li>
<li>WhatsApp auto-reply: default to self-only when no config is present.</li> <li>WhatsApp auto-reply: default to self-only when no config is present.</li>
@@ -137,7 +137,7 @@
<li>Docs: clarify self-chat mode and group mention gating config (#111) — thanks @rafaelreis-r.</li> <li>Docs: clarify self-chat mode and group mention gating config (#111) — thanks @rafaelreis-r.</li>
<li>Browser tools: <code>upload</code> supports auto-click refs, direct <code>inputRef</code>/<code>element</code> file inputs, and emits input/change after <code>setFiles</code> so JS-heavy sites pick up attachments.</li> <li>Browser tools: <code>upload</code> supports auto-click refs, direct <code>inputRef</code>/<code>element</code> file inputs, and emits input/change after <code>setFiles</code> so JS-heavy sites pick up attachments.</li>
<li>Browser tools: harden CDP readiness (HTTP + WS), retry CDP connects, and auto-restart the clawd browser when the socket handshake stalls.</li> <li>Browser tools: harden CDP readiness (HTTP + WS), retry CDP connects, and auto-restart the clawd browser when the socket handshake stalls.</li>
<li>Browser CLI: add <code>clawdis browser reset-profile</code> to move the clawd profile to Trash when it gets wedged.</li> <li>Browser CLI: add <code>clawdbot browser reset-profile</code> to move the clawd profile to Trash when it gets wedged.</li>
<li>Signal: fix daemon startup race (wait for <code>/api/v1/check</code>) and normalize JSON-RPC <code>version</code> probe parsing.</li> <li>Signal: fix daemon startup race (wait for <code>/api/v1/check</code>) and normalize JSON-RPC <code>version</code> probe parsing.</li>
<li>Docs/Signal: clarify bot-number vs personal-account setup (self-chat loop protection) and add a quickstart config snippet.</li> <li>Docs/Signal: clarify bot-number vs personal-account setup (self-chat loop protection) and add a quickstart config snippet.</li>
<li>Docs: refresh the CLI wizard guide and highlight onboarding in the README.</li> <li>Docs: refresh the CLI wizard guide and highlight onboarding in the README.</li>
@@ -201,12 +201,12 @@
<li>macOS menu: top status line now shows pending node pairing approvals (incl. repairs).</li> <li>macOS menu: top status line now shows pending node pairing approvals (incl. repairs).</li>
<li>CLI: avoid spurious gateway close errors after successful request/response cycles.</li> <li>CLI: avoid spurious gateway close errors after successful request/response cycles.</li>
<li>Agent runtime: clamp tool-result images to the 5MB Anthropic limit to avoid hard request rejections.</li> <li>Agent runtime: clamp tool-result images to the 5MB Anthropic limit to avoid hard request rejections.</li>
<li>Agent runtime: write v2 session headers so Pi session branching stays in the Clawdis sessions dir.</li> <li>Agent runtime: write v2 session headers so Pi session branching stays in the Clawdbot sessions dir.</li>
<li>Tests: add Swift Testing coverage for camera errors and Kotest coverage for Android bridge endpoints.</li> <li>Tests: add Swift Testing coverage for camera errors and Kotest coverage for Android bridge endpoints.</li>
</ul> </ul>
<p><a href="https://github.com/steipete/clawdis/blob/main/CHANGELOG.md">View full changelog</a></p> <p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description> ]]></description>
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta5/Clawdis-2.0.0-beta5.zip" length="145432870" type="application/octet-stream" sparkle:edSignature="qKPcmSx2pAaIYz9NqFp0TY63KrcDlpctUHnNpRs6Q60qQqBWtQycLIhhvhxmGnHupaiEXJfspb/Ad9RgODIzAw=="/> <enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2.0.0-beta5/Clawdbot-2.0.0-beta5.zip" length="145432870" type="application/octet-stream" sparkle:edSignature="qKPcmSx2pAaIYz9NqFp0TY63KrcDlpctUHnNpRs6Q60qQqBWtQycLIhhvhxmGnHupaiEXJfspb/Ad9RgODIzAw=="/>
</item> </item>
</channel> </channel>
</rss> </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: Notes:
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action). - 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): 1) Start the gateway (on your “master” machine):
```bash ```bash
pnpm clawdis gateway --port 18789 --verbose pnpm clawdbot gateway --port 18789 --verbose
``` ```
2) In the Android app: 2) In the Android app:
@@ -34,8 +34,8 @@ pnpm clawdis gateway --port 18789 --verbose
3) Approve pairing (on the gateway machine): 3) Approve pairing (on the gateway machine):
```bash ```bash
clawdis nodes pending clawdbot nodes pending
clawdis nodes approve <requestId> clawdbot nodes approve <requestId>
``` ```
More details: `docs/android/connect.md`. More details: `docs/android/connect.md`.
+8 -3
View File
@@ -6,17 +6,17 @@ plugins {
} }
android { android {
namespace = "com.clawdis.android" namespace = "com.clawdbot.android"
compileSdk = 36 compileSdk = 36
sourceSets { sourceSets {
getByName("main") { getByName("main") {
assets.srcDir(file("../../shared/ClawdisKit/Sources/ClawdisKit/Resources")) assets.srcDir(file("../../shared/ClawdbotKit/Sources/ClawdbotKit/Resources"))
} }
} }
defaultConfig { defaultConfig {
applicationId = "com.clawdis.android" applicationId = "com.clawdbot.android"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = 1
@@ -48,6 +48,10 @@ android {
lint { lint {
disable += setOf("IconLauncherShape") disable += setOf("IconLauncherShape")
} }
testOptions {
unitTests.isIncludeAndroidResources = true
}
} }
kotlin { kotlin {
@@ -96,6 +100,7 @@ dependencies {
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.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-runner-junit5-jvm:6.0.7")
testImplementation("io.kotest:kotest-assertions-core-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:5.13.3") testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.13.3")
} }
@@ -32,7 +32,7 @@
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.ClawdisNode"> android:theme="@style/Theme.ClawdbotNode">
<service <service
android:name=".NodeForegroundService" android:name=".NodeForegroundService"
android:exported="false" android:exported="false"
@@ -1,4 +1,4 @@
package com.clawdis.android package com.clawdbot.android
enum class CameraHudKind { enum class CameraHudKind {
Photo, Photo,
@@ -1,4 +1,4 @@
package com.clawdis.android package com.clawdbot.android
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
@@ -1,4 +1,4 @@
package com.clawdis.android package com.clawdbot.android
enum class LocationMode(val rawValue: String) { enum class LocationMode(val rawValue: String) {
Off("off"), Off("off"),
@@ -1,4 +1,4 @@
package com.clawdis.android package com.clawdbot.android
import android.Manifest import android.Manifest
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
@@ -18,8 +18,8 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import com.clawdis.android.ui.RootScreen import com.clawdbot.android.ui.RootScreen
import com.clawdis.android.ui.ClawdisTheme import com.clawdbot.android.ui.ClawdbotTheme
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -56,7 +56,7 @@ class MainActivity : ComponentActivity() {
} }
setContent { setContent {
ClawdisTheme { ClawdbotTheme {
Surface(modifier = Modifier) { Surface(modifier = Modifier) {
RootScreen(viewModel = viewModel) RootScreen(viewModel = viewModel)
} }
@@ -1,13 +1,13 @@
package com.clawdis.android package com.clawdbot.android
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import com.clawdis.android.bridge.BridgeEndpoint import com.clawdbot.android.bridge.BridgeEndpoint
import com.clawdis.android.chat.OutgoingAttachment import com.clawdbot.android.chat.OutgoingAttachment
import com.clawdis.android.node.CameraCaptureManager import com.clawdbot.android.node.CameraCaptureManager
import com.clawdis.android.node.CanvasController import com.clawdbot.android.node.CanvasController
import com.clawdis.android.node.ScreenRecordManager import com.clawdbot.android.node.ScreenRecordManager
import com.clawdis.android.node.SmsManager import com.clawdbot.android.node.SmsManager
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
class MainViewModel(app: Application) : AndroidViewModel(app) { class MainViewModel(app: Application) : AndroidViewModel(app) {
@@ -1,4 +1,4 @@
package com.clawdis.android package com.clawdbot.android
import android.app.Application import android.app.Application
@@ -1,4 +1,4 @@
package com.clawdis.android package com.clawdbot.android
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
@@ -29,7 +29,7 @@ class NodeForegroundService : Service() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
ensureChannel() ensureChannel()
val initial = buildNotification(title = "Clawdis Node", text = "Starting…") val initial = buildNotification(title = "Clawdbot Node", text = "Starting…")
startForegroundWithTypes(notification = initial, requiresMic = false) startForegroundWithTypes(notification = initial, requiresMic = false)
val runtime = (application as NodeApp).runtime val runtime = (application as NodeApp).runtime
@@ -44,7 +44,7 @@ class NodeForegroundService : Service() {
) { status, server, connected, voiceMode, voiceListening -> ) { status, server, connected, voiceMode, voiceListening ->
Quint(status, server, connected, voiceMode, voiceListening) Quint(status, server, connected, voiceMode, voiceListening)
}.collect { (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 = val voiceSuffix =
if (voiceMode == VoiceWakeMode.Always) { if (voiceMode == VoiceWakeMode.Always) {
if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused" if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused"
@@ -91,21 +91,38 @@ class NodeForegroundService : Service() {
"Connection", "Connection",
NotificationManager.IMPORTANCE_LOW, NotificationManager.IMPORTANCE_LOW,
).apply { ).apply {
description = "Clawdis node connection status" description = "Clawdbot node connection status"
setShowBadge(false) setShowBadge(false)
} }
mgr.createNotificationChannel(channel) mgr.createNotificationChannel(channel)
} }
private fun buildNotification(title: String, text: String): Notification { 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 stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE val stopPending =
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags) PendingIntent.getService(
this,
2,
stopIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID) return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher) .setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title) .setContentTitle(title)
.setContentText(text) .setContentText(text)
.setContentIntent(launchPending)
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
@@ -146,7 +163,7 @@ class NodeForegroundService : Service() {
private const val CHANNEL_ID = "connection" private const val CHANNEL_ID = "connection"
private const val NOTIFICATION_ID = 1 private const val NOTIFICATION_ID = 1
private const val ACTION_STOP = "com.clawdis.android.action.STOP" private const val ACTION_STOP = "com.clawdbot.android.action.STOP"
fun start(context: Context) { fun start(context: Context) {
val intent = Intent(context, NodeForegroundService::class.java) val intent = Intent(context, NodeForegroundService::class.java)
@@ -1,4 +1,4 @@
package com.clawdis.android package com.clawdbot.android
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
@@ -7,31 +7,31 @@ import android.location.LocationManager
import android.os.Build import android.os.Build
import android.os.SystemClock import android.os.SystemClock
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.clawdis.android.chat.ChatController import com.clawdbot.android.chat.ChatController
import com.clawdis.android.chat.ChatMessage import com.clawdbot.android.chat.ChatMessage
import com.clawdis.android.chat.ChatPendingToolCall import com.clawdbot.android.chat.ChatPendingToolCall
import com.clawdis.android.chat.ChatSessionEntry import com.clawdbot.android.chat.ChatSessionEntry
import com.clawdis.android.chat.OutgoingAttachment import com.clawdbot.android.chat.OutgoingAttachment
import com.clawdis.android.bridge.BridgeDiscovery import com.clawdbot.android.bridge.BridgeDiscovery
import com.clawdis.android.bridge.BridgeEndpoint import com.clawdbot.android.bridge.BridgeEndpoint
import com.clawdis.android.bridge.BridgePairingClient import com.clawdbot.android.bridge.BridgePairingClient
import com.clawdis.android.bridge.BridgeSession import com.clawdbot.android.bridge.BridgeSession
import com.clawdis.android.node.CameraCaptureManager import com.clawdbot.android.node.CameraCaptureManager
import com.clawdis.android.node.LocationCaptureManager import com.clawdbot.android.node.LocationCaptureManager
import com.clawdis.android.BuildConfig import com.clawdbot.android.BuildConfig
import com.clawdis.android.node.CanvasController import com.clawdbot.android.node.CanvasController
import com.clawdis.android.node.ScreenRecordManager import com.clawdbot.android.node.ScreenRecordManager
import com.clawdis.android.node.SmsManager import com.clawdbot.android.node.SmsManager
import com.clawdis.android.protocol.ClawdisCapability import com.clawdbot.android.protocol.ClawdbotCapability
import com.clawdis.android.protocol.ClawdisCameraCommand import com.clawdbot.android.protocol.ClawdbotCameraCommand
import com.clawdis.android.protocol.ClawdisCanvasA2UIAction import com.clawdbot.android.protocol.ClawdbotCanvasA2UIAction
import com.clawdis.android.protocol.ClawdisCanvasA2UICommand import com.clawdbot.android.protocol.ClawdbotCanvasA2UICommand
import com.clawdis.android.protocol.ClawdisCanvasCommand import com.clawdbot.android.protocol.ClawdbotCanvasCommand
import com.clawdis.android.protocol.ClawdisScreenCommand import com.clawdbot.android.protocol.ClawdbotScreenCommand
import com.clawdis.android.protocol.ClawdisLocationCommand import com.clawdbot.android.protocol.ClawdbotLocationCommand
import com.clawdis.android.protocol.ClawdisSmsCommand import com.clawdbot.android.protocol.ClawdbotSmsCommand
import com.clawdis.android.voice.TalkModeManager import com.clawdbot.android.voice.TalkModeManager
import com.clawdis.android.voice.VoiceWakeManager import com.clawdbot.android.voice.VoiceWakeManager
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -369,38 +369,38 @@ class NodeRuntime(context: Context) {
private fun buildInvokeCommands(): List<String> = private fun buildInvokeCommands(): List<String> =
buildList { buildList {
add(ClawdisCanvasCommand.Present.rawValue) add(ClawdbotCanvasCommand.Present.rawValue)
add(ClawdisCanvasCommand.Hide.rawValue) add(ClawdbotCanvasCommand.Hide.rawValue)
add(ClawdisCanvasCommand.Navigate.rawValue) add(ClawdbotCanvasCommand.Navigate.rawValue)
add(ClawdisCanvasCommand.Eval.rawValue) add(ClawdbotCanvasCommand.Eval.rawValue)
add(ClawdisCanvasCommand.Snapshot.rawValue) add(ClawdbotCanvasCommand.Snapshot.rawValue)
add(ClawdisCanvasA2UICommand.Push.rawValue) add(ClawdbotCanvasA2UICommand.Push.rawValue)
add(ClawdisCanvasA2UICommand.PushJSONL.rawValue) add(ClawdbotCanvasA2UICommand.PushJSONL.rawValue)
add(ClawdisCanvasA2UICommand.Reset.rawValue) add(ClawdbotCanvasA2UICommand.Reset.rawValue)
add(ClawdisScreenCommand.Record.rawValue) add(ClawdbotScreenCommand.Record.rawValue)
if (cameraEnabled.value) { if (cameraEnabled.value) {
add(ClawdisCameraCommand.Snap.rawValue) add(ClawdbotCameraCommand.Snap.rawValue)
add(ClawdisCameraCommand.Clip.rawValue) add(ClawdbotCameraCommand.Clip.rawValue)
} }
if (locationMode.value != LocationMode.Off) { if (locationMode.value != LocationMode.Off) {
add(ClawdisLocationCommand.Get.rawValue) add(ClawdbotLocationCommand.Get.rawValue)
} }
if (sms.canSendSms()) { if (sms.canSendSms()) {
add(ClawdisSmsCommand.Send.rawValue) add(ClawdbotSmsCommand.Send.rawValue)
} }
} }
private fun buildCapabilities(): List<String> = private fun buildCapabilities(): List<String> =
buildList { buildList {
add(ClawdisCapability.Canvas.rawValue) add(ClawdbotCapability.Canvas.rawValue)
add(ClawdisCapability.Screen.rawValue) add(ClawdbotCapability.Screen.rawValue)
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue) if (cameraEnabled.value) add(ClawdbotCapability.Camera.rawValue)
if (sms.canSendSms()) add(ClawdisCapability.Sms.rawValue) if (sms.canSendSms()) add(ClawdbotCapability.Sms.rawValue)
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) { if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
add(ClawdisCapability.VoiceWake.rawValue) add(ClawdbotCapability.VoiceWake.rawValue)
} }
if (locationMode.value != LocationMode.Off) { if (locationMode.value != LocationMode.Off) {
add(ClawdisCapability.Location.rawValue) add(ClawdbotCapability.Location.rawValue)
} }
} }
@@ -552,7 +552,7 @@ class NodeRuntime(context: Context) {
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
java.util.UUID.randomUUID().toString() java.util.UUID.randomUUID().toString()
} }
val name = ClawdisCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch val name = ClawdbotCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
val surfaceId = val surfaceId =
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" } (userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
@@ -562,7 +562,7 @@ class NodeRuntime(context: Context) {
val sessionKey = "main" val sessionKey = "main"
val message = val message =
ClawdisCanvasA2UIAction.formatAgentMessage( ClawdbotCanvasA2UIAction.formatAgentMessage(
actionName = name, actionName = name,
sessionKey = sessionKey, sessionKey = sessionKey,
surfaceId = surfaceId, surfaceId = surfaceId,
@@ -596,7 +596,7 @@ class NodeRuntime(context: Context) {
try { try {
canvas.eval( canvas.eval(
ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus( ClawdbotCanvasA2UIAction.jsDispatchA2UIActionStatus(
actionId = actionId, actionId = actionId,
ok = connected && error == null, ok = connected && error == null,
error = error, error = error,
@@ -713,10 +713,10 @@ class NodeRuntime(context: Context) {
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult { private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
if ( if (
command.startsWith(ClawdisCanvasCommand.NamespacePrefix) || command.startsWith(ClawdbotCanvasCommand.NamespacePrefix) ||
command.startsWith(ClawdisCanvasA2UICommand.NamespacePrefix) || command.startsWith(ClawdbotCanvasA2UICommand.NamespacePrefix) ||
command.startsWith(ClawdisCameraCommand.NamespacePrefix) || command.startsWith(ClawdbotCameraCommand.NamespacePrefix) ||
command.startsWith(ClawdisScreenCommand.NamespacePrefix) command.startsWith(ClawdbotScreenCommand.NamespacePrefix)
) { ) {
if (!isForeground.value) { if (!isForeground.value) {
return BridgeSession.InvokeResult.error( return BridgeSession.InvokeResult.error(
@@ -725,13 +725,13 @@ class NodeRuntime(context: Context) {
) )
} }
} }
if (command.startsWith(ClawdisCameraCommand.NamespacePrefix) && !cameraEnabled.value) { if (command.startsWith(ClawdbotCameraCommand.NamespacePrefix) && !cameraEnabled.value) {
return BridgeSession.InvokeResult.error( return BridgeSession.InvokeResult.error(
code = "CAMERA_DISABLED", code = "CAMERA_DISABLED",
message = "CAMERA_DISABLED: enable Camera in Settings", message = "CAMERA_DISABLED: enable Camera in Settings",
) )
} }
if (command.startsWith(ClawdisLocationCommand.NamespacePrefix) && if (command.startsWith(ClawdbotLocationCommand.NamespacePrefix) &&
locationMode.value == LocationMode.Off locationMode.value == LocationMode.Off
) { ) {
return BridgeSession.InvokeResult.error( return BridgeSession.InvokeResult.error(
@@ -741,18 +741,18 @@ class NodeRuntime(context: Context) {
} }
return when (command) { return when (command) {
ClawdisCanvasCommand.Present.rawValue -> { ClawdbotCanvasCommand.Present.rawValue -> {
val url = CanvasController.parseNavigateUrl(paramsJson) val url = CanvasController.parseNavigateUrl(paramsJson)
canvas.navigate(url) canvas.navigate(url)
BridgeSession.InvokeResult.ok(null) BridgeSession.InvokeResult.ok(null)
} }
ClawdisCanvasCommand.Hide.rawValue -> BridgeSession.InvokeResult.ok(null) ClawdbotCanvasCommand.Hide.rawValue -> BridgeSession.InvokeResult.ok(null)
ClawdisCanvasCommand.Navigate.rawValue -> { ClawdbotCanvasCommand.Navigate.rawValue -> {
val url = CanvasController.parseNavigateUrl(paramsJson) val url = CanvasController.parseNavigateUrl(paramsJson)
canvas.navigate(url) canvas.navigate(url)
BridgeSession.InvokeResult.ok(null) BridgeSession.InvokeResult.ok(null)
} }
ClawdisCanvasCommand.Eval.rawValue -> { ClawdbotCanvasCommand.Eval.rawValue -> {
val js = val js =
CanvasController.parseEvalJs(paramsJson) CanvasController.parseEvalJs(paramsJson)
?: return BridgeSession.InvokeResult.error( ?: return BridgeSession.InvokeResult.error(
@@ -770,7 +770,7 @@ class NodeRuntime(context: Context) {
} }
BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""") BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
} }
ClawdisCanvasCommand.Snapshot.rawValue -> { ClawdbotCanvasCommand.Snapshot.rawValue -> {
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson) val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
val base64 = val base64 =
try { try {
@@ -787,7 +787,7 @@ class NodeRuntime(context: Context) {
} }
BridgeSession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""") BridgeSession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
} }
ClawdisCanvasA2UICommand.Reset.rawValue -> { ClawdbotCanvasA2UICommand.Reset.rawValue -> {
val a2uiUrl = resolveA2uiHostUrl() val a2uiUrl = resolveA2uiHostUrl()
?: return BridgeSession.InvokeResult.error( ?: return BridgeSession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED", code = "A2UI_HOST_NOT_CONFIGURED",
@@ -803,7 +803,7 @@ class NodeRuntime(context: Context) {
val res = canvas.eval(a2uiResetJS) val res = canvas.eval(a2uiResetJS)
BridgeSession.InvokeResult.ok(res) BridgeSession.InvokeResult.ok(res)
} }
ClawdisCanvasA2UICommand.Push.rawValue, ClawdisCanvasA2UICommand.PushJSONL.rawValue -> { ClawdbotCanvasA2UICommand.Push.rawValue, ClawdbotCanvasA2UICommand.PushJSONL.rawValue -> {
val messages = val messages =
try { try {
decodeA2uiMessages(command, paramsJson) decodeA2uiMessages(command, paramsJson)
@@ -826,7 +826,7 @@ class NodeRuntime(context: Context) {
val res = canvas.eval(js) val res = canvas.eval(js)
BridgeSession.InvokeResult.ok(res) BridgeSession.InvokeResult.ok(res)
} }
ClawdisCameraCommand.Snap.rawValue -> { ClawdbotCameraCommand.Snap.rawValue -> {
showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo) showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo)
triggerCameraFlash() triggerCameraFlash()
val res = val res =
@@ -840,7 +840,7 @@ class NodeRuntime(context: Context) {
showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600) showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600)
BridgeSession.InvokeResult.ok(res.payloadJson) BridgeSession.InvokeResult.ok(res.payloadJson)
} }
ClawdisCameraCommand.Clip.rawValue -> { ClawdbotCameraCommand.Clip.rawValue -> {
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
if (includeAudio) externalAudioCaptureActive.value = true if (includeAudio) externalAudioCaptureActive.value = true
try { try {
@@ -859,7 +859,7 @@ class NodeRuntime(context: Context) {
if (includeAudio) externalAudioCaptureActive.value = false if (includeAudio) externalAudioCaptureActive.value = false
} }
} }
ClawdisLocationCommand.Get.rawValue -> { ClawdbotLocationCommand.Get.rawValue -> {
val mode = locationMode.value val mode = locationMode.value
if (!isForeground.value && mode != LocationMode.Always) { if (!isForeground.value && mode != LocationMode.Always) {
return BridgeSession.InvokeResult.error( return BridgeSession.InvokeResult.error(
@@ -912,7 +912,7 @@ class NodeRuntime(context: Context) {
BridgeSession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message) BridgeSession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message)
} }
} }
ClawdisScreenCommand.Record.rawValue -> { ClawdbotScreenCommand.Record.rawValue -> {
// Status pill mirrors screen recording state so it stays visible without overlay stacking. // Status pill mirrors screen recording state so it stays visible without overlay stacking.
_screenRecordActive.value = true _screenRecordActive.value = true
try { try {
@@ -928,7 +928,7 @@ class NodeRuntime(context: Context) {
_screenRecordActive.value = false _screenRecordActive.value = false
} }
} }
ClawdisSmsCommand.Send.rawValue -> { ClawdbotSmsCommand.Send.rawValue -> {
val res = sms.send(paramsJson) val res = sms.send(paramsJson)
if (res.ok) { if (res.ok) {
BridgeSession.InvokeResult.ok(res.payloadJson) BridgeSession.InvokeResult.ok(res.payloadJson)
@@ -999,7 +999,7 @@ class NodeRuntime(context: Context) {
val raw = session.currentCanvasHostUrl()?.trim().orEmpty() val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
if (raw.isBlank()) return null if (raw.isBlank()) return null
val base = raw.trimEnd('/') val base = raw.trimEnd('/')
return "${base}/__clawdis__/a2ui/?platform=android" return "${base}/__clawdbot__/a2ui/?platform=android"
} }
private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean { private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
@@ -1034,7 +1034,7 @@ class NodeRuntime(context: Context) {
val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty() val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty()
val hasMessagesArray = obj["messages"] is JsonArray 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 val jsonl = jsonlField
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required") if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
val messages = val messages =
@@ -1091,7 +1091,7 @@ private const val a2uiReadyCheckJS: String =
""" """
(() => { (() => {
try { try {
return !!globalThis.clawdisA2UI && typeof globalThis.clawdisA2UI.applyMessages === 'function'; return !!globalThis.clawdbotA2UI && typeof globalThis.clawdbotA2UI.applyMessages === 'function';
} catch (_) { } catch (_) {
return false; return false;
} }
@@ -1102,8 +1102,8 @@ private const val a2uiResetJS: String =
""" """
(() => { (() => {
try { try {
if (!globalThis.clawdisA2UI) return { ok: false, error: "missing clawdisA2UI" }; if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
return globalThis.clawdisA2UI.reset(); return globalThis.clawdbotA2UI.reset();
} catch (e) { } catch (e) {
return { ok: false, error: String(e?.message ?? e) }; return { ok: false, error: String(e?.message ?? e) };
} }
@@ -1114,9 +1114,9 @@ private fun a2uiApplyMessagesJS(messagesJson: String): String {
return """ return """
(() => { (() => {
try { try {
if (!globalThis.clawdisA2UI) return { ok: false, error: "missing clawdisA2UI" }; if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
const messages = $messagesJson; const messages = $messagesJson;
return globalThis.clawdisA2UI.applyMessages(messages); return globalThis.clawdbotA2UI.applyMessages(messages);
} catch (e) { } catch (e) {
return { ok: false, error: String(e?.message ?? e) }; return { ok: false, error: String(e?.message ?? e) };
} }
@@ -1,4 +1,4 @@
package com.clawdis.android package com.clawdbot.android
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.Intent import android.content.Intent
@@ -115,7 +115,7 @@ class PermissionRequester(private val activity: ComponentActivity) {
private fun buildRationaleMessage(permissions: List<String>): String { private fun buildRationaleMessage(permissions: List<String>): String {
val labels = permissions.map { permissionLabel(it) } val labels = permissions.map { permissionLabel(it) }
return "Clawdis needs ${labels.joinToString(", ")} permissions to continue." return "Clawdbot needs ${labels.joinToString(", ")} permissions to continue."
} }
private fun buildSettingsMessage(permissions: List<String>): String { private fun buildSettingsMessage(permissions: List<String>): String {
@@ -1,4 +1,4 @@
package com.clawdis.android package com.clawdbot.android
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
@@ -55,7 +55,7 @@ class ScreenCaptureRequester(private val activity: ComponentActivity) {
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
AlertDialog.Builder(activity) AlertDialog.Builder(activity)
.setTitle("Screen recording required") .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) } .setPositiveButton("Continue") { _, _ -> cont.resume(true) }
.setNegativeButton("Not now") { _, _ -> cont.resume(false) } .setNegativeButton("Not now") { _, _ -> cont.resume(false) }
.setOnCancelListener { cont.resume(false) } .setOnCancelListener { cont.resume(false) }
@@ -1,6 +1,6 @@
@file:Suppress("DEPRECATION") @file:Suppress("DEPRECATION")
package com.clawdis.android package com.clawdbot.android
import android.content.Context import android.content.Context
import androidx.core.content.edit import androidx.core.content.edit
@@ -31,7 +31,7 @@ class SecurePrefs(context: Context) {
private val prefs = private val prefs =
EncryptedSharedPreferences.create( EncryptedSharedPreferences.create(
context, context,
"clawdis.node.secure", "clawdbot.node.secure",
masterKey, masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
@@ -1,4 +1,4 @@
package com.clawdis.android package com.clawdbot.android
enum class VoiceWakeMode(val rawValue: String) { enum class VoiceWakeMode(val rawValue: String) {
Off("off"), Off("off"),
@@ -1,4 +1,4 @@
package com.clawdis.android package com.clawdbot.android
object WakeWords { object WakeWords {
const val maxWords: Int = 32 const val maxWords: Int = 32
@@ -1,4 +1,4 @@
package com.clawdis.android.bridge package com.clawdbot.android.bridge
object BonjourEscapes { object BonjourEscapes {
fun decode(input: String): String { fun decode(input: String): String {
@@ -1,4 +1,4 @@
package com.clawdis.android.bridge package com.clawdbot.android.bridge
import android.content.Context import android.content.Context
import android.net.ConnectivityManager import android.net.ConnectivityManager
@@ -51,9 +51,9 @@ class BridgeDiscovery(
private val nsd = context.getSystemService(NsdManager::class.java) private val nsd = context.getSystemService(NsdManager::class.java)
private val connectivity = context.getSystemService(ConnectivityManager::class.java) private val connectivity = context.getSystemService(ConnectivityManager::class.java)
private val dns = DnsResolver.getInstance() private val dns = DnsResolver.getInstance()
private val serviceType = "_clawdis-bridge._tcp." private val serviceType = "_clawdbot-bridge._tcp."
private val wideAreaDomain = "clawdis.internal." private val wideAreaDomain = "clawdbot.internal."
private val logTag = "Clawdis/BridgeDiscovery" private val logTag = "Clawdbot/BridgeDiscovery"
private val localById = ConcurrentHashMap<String, BridgeEndpoint>() private val localById = ConcurrentHashMap<String, BridgeEndpoint>()
private val unicastById = ConcurrentHashMap<String, BridgeEndpoint>() private val unicastById = ConcurrentHashMap<String, BridgeEndpoint>()
@@ -1,4 +1,4 @@
package com.clawdis.android.bridge package com.clawdbot.android.bridge
data class BridgeEndpoint( data class BridgeEndpoint(
val stableId: String, val stableId: String,
@@ -1,4 +1,4 @@
package com.clawdis.android.bridge package com.clawdbot.android.bridge
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -1,4 +1,4 @@
package com.clawdis.android.bridge package com.clawdbot.android.bridge
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -11,7 +11,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import com.clawdis.android.BuildConfig import com.clawdbot.android.BuildConfig
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
@@ -217,7 +217,7 @@ class BridgeSession(
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked". // Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
runCatching { runCatching {
android.util.Log.d( android.util.Log.d(
"ClawdisBridge", "ClawdbotBridge",
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})", "canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
) )
} }
@@ -1,6 +1,6 @@
package com.clawdis.android.chat package com.clawdbot.android.chat
import com.clawdis.android.bridge.BridgeSession import com.clawdbot.android.bridge.BridgeSession
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -1,4 +1,4 @@
package com.clawdis.android.chat package com.clawdbot.android.chat
data class ChatMessage( data class ChatMessage(
val id: String, val id: String,
@@ -1,4 +1,4 @@
package com.clawdis.android.node package com.clawdbot.android.node
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
@@ -20,7 +20,7 @@ import androidx.camera.video.VideoRecordEvent
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.checkSelfPermission import androidx.core.content.ContextCompat.checkSelfPermission
import androidx.core.graphics.scale import androidx.core.graphics.scale
import com.clawdis.android.PermissionRequester import com.clawdbot.android.PermissionRequester
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
@@ -152,7 +152,7 @@ class CameraCaptureManager(private val context: Context) {
provider.unbindAll() provider.unbindAll()
provider.bindToLifecycle(owner, selector, videoCapture) 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 outputOptions = FileOutputOptions.Builder(file).build()
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>() val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
@@ -256,7 +256,7 @@ private suspend fun Context.cameraProvider(): ProcessCameraProvider =
private suspend fun ImageCapture.takeJpegBytes(executor: Executor): ByteArray = private suspend fun ImageCapture.takeJpegBytes(executor: Executor): ByteArray =
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
val file = File.createTempFile("clawdis-snap-", ".jpg") val file = File.createTempFile("clawdbot-snap-", ".jpg")
val options = ImageCapture.OutputFileOptions.Builder(file).build() val options = ImageCapture.OutputFileOptions.Builder(file).build()
takePicture( takePicture(
options, options,
@@ -1,4 +1,4 @@
package com.clawdis.android.node package com.clawdbot.android.node
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
@@ -17,7 +17,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import com.clawdis.android.BuildConfig import com.clawdbot.android.BuildConfig
import kotlin.coroutines.resume import kotlin.coroutines.resume
class CanvasController { class CanvasController {
@@ -84,12 +84,12 @@ class CanvasController {
withWebViewOnMain { wv -> withWebViewOnMain { wv ->
if (currentUrl == null) { if (currentUrl == null) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.d("ClawdisCanvas", "load scaffold: $scaffoldAssetUrl") Log.d("ClawdbotCanvas", "load scaffold: $scaffoldAssetUrl")
} }
wv.loadUrl(scaffoldAssetUrl) wv.loadUrl(scaffoldAssetUrl)
} else { } else {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.d("ClawdisCanvas", "load url: $currentUrl") Log.d("ClawdbotCanvas", "load url: $currentUrl")
} }
wv.loadUrl(currentUrl) wv.loadUrl(currentUrl)
} }
@@ -106,7 +106,7 @@ class CanvasController {
val js = """ val js = """
(() => { (() => {
try { try {
const api = globalThis.__clawdis; const api = globalThis.__clawdbot;
if (!api) return; if (!api) return;
if (typeof api.setDebugStatusEnabled === 'function') { if (typeof api.setDebugStatusEnabled === 'function') {
api.setDebugStatusEnabled(${if (enabled) "true" else "false"}); api.setDebugStatusEnabled(${if (enabled) "true" else "false"});
@@ -1,4 +1,4 @@
package com.clawdis.android.node package com.clawdbot.android.node
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@@ -1,4 +1,4 @@
package com.clawdis.android.node package com.clawdbot.android.node
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
@@ -1,11 +1,11 @@
package com.clawdis.android.node package com.clawdbot.android.node
import android.content.Context import android.content.Context
import android.hardware.display.DisplayManager import android.hardware.display.DisplayManager
import android.media.MediaRecorder import android.media.MediaRecorder
import android.media.projection.MediaProjectionManager import android.media.projection.MediaProjectionManager
import android.util.Base64 import android.util.Base64
import com.clawdis.android.ScreenCaptureRequester import com.clawdbot.android.ScreenCaptureRequester
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -16,13 +16,13 @@ class ScreenRecordManager(private val context: Context) {
data class Payload(val payloadJson: String) data class Payload(val payloadJson: String)
@Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null @Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null
@Volatile private var permissionRequester: com.clawdis.android.PermissionRequester? = null @Volatile private var permissionRequester: com.clawdbot.android.PermissionRequester? = null
fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) { fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) {
screenCaptureRequester = requester screenCaptureRequester = requester
} }
fun attachPermissionRequester(requester: com.clawdis.android.PermissionRequester) { fun attachPermissionRequester(requester: com.clawdbot.android.PermissionRequester) {
permissionRequester = requester permissionRequester = requester
} }
@@ -62,7 +62,7 @@ class ScreenRecordManager(private val context: Context) {
val height = metrics.heightPixels val height = metrics.heightPixels
val densityDpi = metrics.densityDpi val densityDpi = metrics.densityDpi
val file = File.createTempFile("clawdis-screen-", ".mp4") val file = File.createTempFile("clawdbot-screen-", ".mp4")
if (includeAudio) ensureMicPermission() if (includeAudio) ensureMicPermission()
val recorder = MediaRecorder() val recorder = MediaRecorder()
@@ -89,7 +89,7 @@ class ScreenRecordManager(private val context: Context) {
val surface = recorder.surface val surface = recorder.surface
virtualDisplay = virtualDisplay =
projection.createVirtualDisplay( projection.createVirtualDisplay(
"clawdis-screen", "clawdbot-screen",
width, width,
height, height,
densityDpi, densityDpi,
@@ -1,4 +1,4 @@
package com.clawdis.android.node package com.clawdbot.android.node
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
@@ -11,7 +11,7 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import com.clawdis.android.PermissionRequester import com.clawdbot.android.PermissionRequester
/** /**
* Sends SMS messages via the Android SMS API. * Sends SMS messages via the Android SMS API.
@@ -1,9 +1,9 @@
package com.clawdis.android.protocol package com.clawdbot.android.protocol
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
object ClawdisCanvasA2UIAction { object ClawdbotCanvasA2UIAction {
fun extractActionName(userAction: JsonObject): String? { fun extractActionName(userAction: JsonObject): String? {
val name = val name =
(userAction["name"] as? JsonPrimitive) (userAction["name"] as? JsonPrimitive)
@@ -61,6 +61,6 @@ object ClawdisCanvasA2UIAction {
val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"") val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"")
val okLiteral = if (ok) "true" else "false" val okLiteral = if (ok) "true" else "false"
val idEscaped = actionId.replace("\\", "\\\\").replace("\"", "\\\"") 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,6 +1,6 @@
package com.clawdis.android.protocol package com.clawdbot.android.protocol
enum class ClawdisCapability(val rawValue: String) { enum class ClawdbotCapability(val rawValue: String) {
Canvas("canvas"), Canvas("canvas"),
Camera("camera"), Camera("camera"),
Screen("screen"), Screen("screen"),
@@ -9,7 +9,7 @@ enum class ClawdisCapability(val rawValue: String) {
Location("location"), Location("location"),
} }
enum class ClawdisCanvasCommand(val rawValue: String) { enum class ClawdbotCanvasCommand(val rawValue: String) {
Present("canvas.present"), Present("canvas.present"),
Hide("canvas.hide"), Hide("canvas.hide"),
Navigate("canvas.navigate"), Navigate("canvas.navigate"),
@@ -22,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"), Push("canvas.a2ui.push"),
PushJSONL("canvas.a2ui.pushJSONL"), PushJSONL("canvas.a2ui.pushJSONL"),
Reset("canvas.a2ui.reset"), Reset("canvas.a2ui.reset"),
@@ -33,7 +33,7 @@ enum class ClawdisCanvasA2UICommand(val rawValue: String) {
} }
} }
enum class ClawdisCameraCommand(val rawValue: String) { enum class ClawdbotCameraCommand(val rawValue: String) {
Snap("camera.snap"), Snap("camera.snap"),
Clip("camera.clip"), Clip("camera.clip"),
; ;
@@ -43,7 +43,7 @@ enum class ClawdisCameraCommand(val rawValue: String) {
} }
} }
enum class ClawdisScreenCommand(val rawValue: String) { enum class ClawdbotScreenCommand(val rawValue: String) {
Record("screen.record"), Record("screen.record"),
; ;
@@ -52,7 +52,7 @@ enum class ClawdisScreenCommand(val rawValue: String) {
} }
} }
enum class ClawdisSmsCommand(val rawValue: String) { enum class ClawdbotSmsCommand(val rawValue: String) {
Send("sms.send"), Send("sms.send"),
; ;
@@ -61,7 +61,7 @@ enum class ClawdisSmsCommand(val rawValue: String) {
} }
} }
enum class ClawdisLocationCommand(val rawValue: String) { enum class ClawdbotLocationCommand(val rawValue: String) {
Get("location.get"), Get("location.get"),
; ;
@@ -1,4 +1,4 @@
package com.clawdis.android.tools package com.clawdbot.android.tools
import android.content.Context import android.content.Context
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -1,4 +1,4 @@
package com.clawdis.android.ui package com.clawdbot.android.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -1,8 +1,8 @@
package com.clawdis.android.ui package com.clawdbot.android.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import com.clawdis.android.MainViewModel import com.clawdbot.android.MainViewModel
import com.clawdis.android.ui.chat.ChatSheetContent import com.clawdbot.android.ui.chat.ChatSheetContent
@Composable @Composable
fun ChatSheet(viewModel: MainViewModel) { fun ChatSheet(viewModel: MainViewModel) {
@@ -1,4 +1,4 @@
package com.clawdis.android.ui package com.clawdbot.android.ui
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -9,7 +9,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@Composable @Composable
fun ClawdisTheme(content: @Composable () -> Unit) { fun ClawdbotTheme(content: @Composable () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
@@ -1,4 +1,4 @@
package com.clawdis.android.ui package com.clawdbot.android.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.Manifest import android.Manifest
@@ -65,8 +65,8 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupProperties
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.clawdis.android.CameraHudKind import com.clawdbot.android.CameraHudKind
import com.clawdis.android.MainViewModel import com.clawdbot.android.MainViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -334,7 +334,7 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false) WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
} }
if (isDebuggable) { if (isDebuggable) {
Log.d("ClawdisWebView", "userAgent: ${settings.userAgentString}") Log.d("ClawdbotWebView", "userAgent: ${settings.userAgentString}")
} }
isScrollContainer = true isScrollContainer = true
overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS
@@ -349,7 +349,7 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
) { ) {
if (!isDebuggable) return if (!isDebuggable) return
if (!request.isForMainFrame) return if (!request.isForMainFrame) return
Log.e("ClawdisWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}") Log.e("ClawdbotWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}")
} }
override fun onReceivedHttpError( override fun onReceivedHttpError(
@@ -360,14 +360,14 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
if (!isDebuggable) return if (!isDebuggable) return
if (!request.isForMainFrame) return if (!request.isForMainFrame) return
Log.e( Log.e(
"ClawdisWebView", "ClawdbotWebView",
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}", "onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
) )
} }
override fun onPageFinished(view: WebView, url: String?) { override fun onPageFinished(view: WebView, url: String?) {
if (isDebuggable) { if (isDebuggable) {
Log.d("ClawdisWebView", "onPageFinished: $url") Log.d("ClawdbotWebView", "onPageFinished: $url")
} }
viewModel.canvas.onPageFinished() viewModel.canvas.onPageFinished()
} }
@@ -378,7 +378,7 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
): Boolean { ): Boolean {
if (isDebuggable) { if (isDebuggable) {
Log.e( Log.e(
"ClawdisWebView", "ClawdbotWebView",
"onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}", "onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}",
) )
} }
@@ -391,7 +391,7 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
if (!isDebuggable) return false if (!isDebuggable) return false
val msg = consoleMessage ?: return false val msg = consoleMessage ?: return false
Log.d( Log.d(
"ClawdisWebView", "ClawdbotWebView",
"console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}", "console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}",
) )
return false return false
@@ -423,7 +423,7 @@ private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
} }
companion object { companion object {
const val interfaceName: String = "clawdisCanvasA2UIAction" const val interfaceName: String = "clawdbotCanvasA2UIAction"
} }
} }
@@ -1,4 +1,4 @@
package com.clawdis.android.ui package com.clawdbot.android.ui
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
@@ -52,11 +52,11 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.clawdis.android.BuildConfig import com.clawdbot.android.BuildConfig
import com.clawdis.android.LocationMode import com.clawdbot.android.LocationMode
import com.clawdis.android.MainViewModel import com.clawdbot.android.MainViewModel
import com.clawdis.android.NodeForegroundService import com.clawdbot.android.NodeForegroundService
import com.clawdis.android.VoiceWakeMode import com.clawdbot.android.VoiceWakeMode
@Composable @Composable
fun SettingsSheet(viewModel: MainViewModel) { fun SettingsSheet(viewModel: MainViewModel) {
@@ -436,7 +436,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
ListItem( ListItem(
headlineContent = { Text("Foreground Only") }, headlineContent = { Text("Foreground Only") },
supportingContent = { Text("Listens only while Clawdis is open.") }, supportingContent = { Text("Listens only while Clawdbot is open.") },
trailingContent = { trailingContent = {
RadioButton( RadioButton(
selected = voiceWakeMode == VoiceWakeMode.Foreground, selected = voiceWakeMode == VoiceWakeMode.Foreground,
@@ -482,7 +482,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button( Button(
onClick = { onClick = {
val parsed = com.clawdis.android.WakeWords.parseCommaSeparated(wakeWordsText) val parsed = com.clawdbot.android.WakeWords.parseCommaSeparated(wakeWordsText)
viewModel.setWakeWords(parsed) viewModel.setWakeWords(parsed)
}, },
enabled = isConnected, enabled = isConnected,
@@ -580,7 +580,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
) )
ListItem( ListItem(
headlineContent = { Text("While Using") }, headlineContent = { Text("While Using") },
supportingContent = { Text("Only while Clawdis is open.") }, supportingContent = { Text("Only while Clawdbot is open.") },
trailingContent = { trailingContent = {
RadioButton( RadioButton(
selected = locationMode == LocationMode.WhileUsing, selected = locationMode == LocationMode.WhileUsing,
@@ -627,7 +627,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
item { item {
ListItem( ListItem(
headlineContent = { Text("Prevent Sleep") }, 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) }, trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) },
) )
} }
@@ -1,4 +1,4 @@
package com.clawdis.android.ui package com.clawdbot.android.ui
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -1,4 +1,4 @@
package com.clawdis.android.ui package com.clawdbot.android.ui
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
@@ -1,4 +1,4 @@
package com.clawdis.android.ui.chat package com.clawdbot.android.ui.chat
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -38,7 +38,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.clawdis.android.chat.ChatSessionEntry import com.clawdbot.android.chat.ChatSessionEntry
@Composable @Composable
fun ChatComposer( fun ChatComposer(
@@ -1,4 +1,4 @@
package com.clawdis.android.ui.chat package com.clawdbot.android.ui.chat
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.util.Base64 import android.util.Base64
@@ -1,4 +1,4 @@
package com.clawdis.android.ui.chat package com.clawdbot.android.ui.chat
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -20,8 +20,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.clawdis.android.chat.ChatMessage import com.clawdbot.android.chat.ChatMessage
import com.clawdis.android.chat.ChatPendingToolCall import com.clawdbot.android.chat.ChatPendingToolCall
@Composable @Composable
fun ChatMessageListCard( fun ChatMessageListCard(
@@ -1,4 +1,4 @@
package com.clawdis.android.ui.chat package com.clawdbot.android.ui.chat
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.util.Base64 import android.util.Base64
@@ -31,10 +31,10 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import com.clawdis.android.chat.ChatMessage import com.clawdbot.android.chat.ChatMessage
import com.clawdis.android.chat.ChatMessageContent import com.clawdbot.android.chat.ChatMessageContent
import com.clawdis.android.chat.ChatPendingToolCall import com.clawdbot.android.chat.ChatPendingToolCall
import com.clawdis.android.tools.ToolDisplayRegistry import com.clawdbot.android.tools.ToolDisplayRegistry
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -1,4 +1,4 @@
package com.clawdis.android.ui.chat package com.clawdbot.android.ui.chat
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -20,7 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.clawdis.android.chat.ChatSessionEntry import com.clawdbot.android.chat.ChatSessionEntry
@Composable @Composable
fun ChatSessionsDialog( fun ChatSessionsDialog(
@@ -1,4 +1,4 @@
package com.clawdis.android.ui.chat package com.clawdbot.android.ui.chat
import android.content.ContentResolver import android.content.ContentResolver
import android.net.Uri import android.net.Uri
@@ -19,8 +19,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.clawdis.android.MainViewModel import com.clawdbot.android.MainViewModel
import com.clawdis.android.chat.OutgoingAttachment import com.clawdbot.android.chat.OutgoingAttachment
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -1,6 +1,6 @@
package com.clawdis.android.ui.chat package com.clawdbot.android.ui.chat
import com.clawdis.android.chat.ChatSessionEntry import com.clawdbot.android.chat.ChatSessionEntry
private const val MAIN_SESSION_KEY = "main" private const val MAIN_SESSION_KEY = "main"
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
@@ -1,4 +1,4 @@
package com.clawdis.android.voice package com.clawdbot.android.voice
import android.media.MediaDataSource import android.media.MediaDataSource
import kotlin.math.min import kotlin.math.min
@@ -1,4 +1,4 @@
package com.clawdis.android.voice package com.clawdbot.android.voice
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
@@ -1,4 +1,4 @@
package com.clawdis.android.voice package com.clawdbot.android.voice
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
@@ -20,7 +20,7 @@ import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener import android.speech.tts.UtteranceProgressListener
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.clawdis.android.bridge.BridgeSession import com.clawdbot.android.bridge.BridgeSession
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.util.UUID import java.util.UUID
@@ -1,4 +1,4 @@
package com.clawdis.android.voice package com.clawdbot.android.voice
object VoiceWakeCommandExtractor { object VoiceWakeCommandExtractor {
fun extractCommand(text: String, triggerWords: List<String>): String? { fun extractCommand(text: String, triggerWords: List<String>): String? {
@@ -1,4 +1,4 @@
package com.clawdis.android.voice package com.clawdbot.android.voice
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -1,4 +1,4 @@
<resources> <resources>
<string name="app_name">Clawdis Node</string> <string name="app_name">Clawdbot Node</string>
</resources> </resources>
@@ -1,5 +1,5 @@
<resources> <resources>
<style name="Theme.ClawdisNode" parent="Theme.Material3.DayNight.NoActionBar"> <style name="Theme.ClawdbotNode" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">false</item> <item name="android:windowLightStatusBar">false</item>
@@ -4,7 +4,7 @@
<base-config cleartextTrafficPermitted="true" tools:ignore="InsecureBaseConfiguration" /> <base-config cleartextTrafficPermitted="true" tools:ignore="InsecureBaseConfiguration" />
<!-- Allow HTTP for tailnet/local dev endpoints (e.g. canvas/background web). --> <!-- Allow HTTP for tailnet/local dev endpoints (e.g. canvas/background web). -->
<domain-config cleartextTrafficPermitted="true"> <domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">clawdis.internal</domain> <domain includeSubdomains="true">clawdbot.internal</domain>
</domain-config> </domain-config>
<domain-config cleartextTrafficPermitted="true"> <domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">ts.net</domain> <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.clawdis.android package com.clawdbot.android
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
@@ -1,4 +1,4 @@
package com.clawdis.android.bridge package com.clawdbot.android.bridge
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
@@ -12,7 +12,7 @@ class BonjourEscapesTest {
@Test @Test
fun decodeDecodesDecimalEscapes() { fun decodeDecodesDecimalEscapes() {
assertEquals("Clawdis Gateway", BonjourEscapes.decode("Clawdis\\032Gateway")) assertEquals("Clawdbot Gateway", BonjourEscapes.decode("Clawdbot\\032Gateway"))
assertEquals("A B", BonjourEscapes.decode("A\\032B")) assertEquals("A B", BonjourEscapes.decode("A\\032B"))
assertEquals("Peter\u2019s Mac", BonjourEscapes.decode("Peter\\226\\128\\153s Mac")) assertEquals("Peter\u2019s Mac", BonjourEscapes.decode("Peter\\226\\128\\153s Mac"))
} }
@@ -1,4 +1,4 @@
package com.clawdis.android.bridge package com.clawdbot.android.bridge
import io.kotest.core.spec.style.StringSpec import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
@@ -1,4 +1,4 @@
package com.clawdis.android.bridge package com.clawdbot.android.bridge
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -1,4 +1,4 @@
package com.clawdis.android.bridge package com.clawdbot.android.bridge
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -1,4 +1,4 @@
package com.clawdis.android.node package com.clawdbot.android.node
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@@ -1,4 +1,4 @@
package com.clawdis.android.node package com.clawdbot.android.node
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@@ -1,4 +1,4 @@
package com.clawdis.android.node package com.clawdbot.android.node
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
@@ -1,28 +1,28 @@
package com.clawdis.android.protocol package com.clawdbot.android.protocol
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
class ClawdisCanvasA2UIActionTest { class ClawdbotCanvasA2UIActionTest {
@Test @Test
fun extractActionNameAcceptsNameOrAction() { fun extractActionNameAcceptsNameOrAction() {
val nameObj = Json.parseToJsonElement("{\"name\":\"Hello\"}").jsonObject val nameObj = Json.parseToJsonElement("{\"name\":\"Hello\"}").jsonObject
assertEquals("Hello", ClawdisCanvasA2UIAction.extractActionName(nameObj)) assertEquals("Hello", ClawdbotCanvasA2UIAction.extractActionName(nameObj))
val actionObj = Json.parseToJsonElement("{\"action\":\"Wave\"}").jsonObject val actionObj = Json.parseToJsonElement("{\"action\":\"Wave\"}").jsonObject
assertEquals("Wave", ClawdisCanvasA2UIAction.extractActionName(actionObj)) assertEquals("Wave", ClawdbotCanvasA2UIAction.extractActionName(actionObj))
val fallbackObj = val fallbackObj =
Json.parseToJsonElement("{\"name\":\" \",\"action\":\"Fallback\"}").jsonObject Json.parseToJsonElement("{\"name\":\" \",\"action\":\"Fallback\"}").jsonObject
assertEquals("Fallback", ClawdisCanvasA2UIAction.extractActionName(fallbackObj)) assertEquals("Fallback", ClawdbotCanvasA2UIAction.extractActionName(fallbackObj))
} }
@Test @Test
fun formatAgentMessageMatchesSharedSpec() { fun formatAgentMessageMatchesSharedSpec() {
val msg = val msg =
ClawdisCanvasA2UIAction.formatAgentMessage( ClawdbotCanvasA2UIAction.formatAgentMessage(
actionName = "Get Weather", actionName = "Get Weather",
sessionKey = "main", sessionKey = "main",
surfaceId = "main", surfaceId = "main",
@@ -40,9 +40,9 @@ class ClawdisCanvasA2UIActionTest {
@Test @Test
fun jsDispatchA2uiStatusIsStable() { fun jsDispatchA2uiStatusIsStable() {
val js = ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId = "a1", ok = true, error = null) val js = ClawdbotCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId = "a1", ok = true, error = null)
assertEquals( assertEquals(
"window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: { id: \"a1\", ok: true, error: \"\" } }));", "window.dispatchEvent(new CustomEvent('clawdbot:a2ui-action-status', { detail: { id: \"a1\", ok: true, error: \"\" } }));",
js, 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)
}
}
@@ -1,6 +1,6 @@
package com.clawdis.android.ui.chat package com.clawdbot.android.ui.chat
import com.clawdis.android.chat.ChatSessionEntry import com.clawdbot.android.chat.ChatSessionEntry
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
@@ -1,4 +1,4 @@
package com.clawdis.android.voice package com.clawdbot.android.voice
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@@ -1,4 +1,4 @@
package com.clawdis.android.voice package com.clawdbot.android.voice
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@@ -1,35 +0,0 @@
package com.clawdis.android.protocol
import org.junit.Assert.assertEquals
import org.junit.Test
class ClawdisProtocolConstantsTest {
@Test
fun canvasCommandsUseStableStrings() {
assertEquals("canvas.present", ClawdisCanvasCommand.Present.rawValue)
assertEquals("canvas.hide", ClawdisCanvasCommand.Hide.rawValue)
assertEquals("canvas.navigate", ClawdisCanvasCommand.Navigate.rawValue)
assertEquals("canvas.eval", ClawdisCanvasCommand.Eval.rawValue)
assertEquals("canvas.snapshot", ClawdisCanvasCommand.Snapshot.rawValue)
}
@Test
fun a2uiCommandsUseStableStrings() {
assertEquals("canvas.a2ui.push", ClawdisCanvasA2UICommand.Push.rawValue)
assertEquals("canvas.a2ui.pushJSONL", ClawdisCanvasA2UICommand.PushJSONL.rawValue)
assertEquals("canvas.a2ui.reset", ClawdisCanvasA2UICommand.Reset.rawValue)
}
@Test
fun capabilitiesUseStableStrings() {
assertEquals("canvas", ClawdisCapability.Canvas.rawValue)
assertEquals("camera", ClawdisCapability.Camera.rawValue)
assertEquals("screen", ClawdisCapability.Screen.rawValue)
assertEquals("voiceWake", ClawdisCapability.VoiceWake.rawValue)
}
@Test
fun screenCommandsUseStableStrings() {
assertEquals("screen.record", ClawdisScreenCommand.Record.rawValue)
}
}
+1 -1
View File
@@ -14,6 +14,6 @@ dependencyResolutionManagement {
} }
} }
rootProject.name = "ClawdisNodeAndroid" rootProject.name = "ClawdbotNodeAndroid"
include(":app") include(":app")
+3 -3
View File
@@ -1,4 +1,4 @@
# Clawdis (iOS) # Clawdbot (iOS)
Internal-only SwiftUI app scaffold. Internal-only SwiftUI app scaffold.
@@ -11,11 +11,11 @@ brew install swiftformat swiftlint
```bash ```bash
cd apps/ios cd apps/ios
xcodegen generate xcodegen generate
open Clawdis.xcodeproj open Clawdbot.xcodeproj
``` ```
## Shared packages ## Shared packages
- `../shared/ClawdisKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing). - `../shared/ClawdbotKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing).
## fastlane ## fastlane
```bash ```bash
+2 -2
View File
@@ -1,4 +1,4 @@
import ClawdisKit import ClawdbotKit
import Foundation import Foundation
import Network import Network
@@ -14,7 +14,7 @@ actor BridgeClient {
{ {
self.lineBuffer = Data() self.lineBuffer = Data()
let connection = NWConnection(to: endpoint, using: .tcp) let connection = NWConnection(to: endpoint, using: .tcp)
let queue = DispatchQueue(label: "com.clawdis.ios.bridge-client") let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client")
defer { connection.cancel() } defer { connection.cancel() }
try await self.withTimeout(seconds: 8, purpose: "connect") { try await self.withTimeout(seconds: 8, purpose: "connect") {
try await self.startAndWaitForReady(connection, queue: queue) try await self.startAndWaitForReady(connection, queue: queue)
@@ -1,4 +1,4 @@
import ClawdisKit import ClawdbotKit
import Darwin import Darwin
import Foundation import Foundation
import Network import Network
@@ -99,7 +99,7 @@ final class BridgeConnectionController {
guard !instanceId.isEmpty else { return } guard !instanceId.isEmpty else { return }
let token = KeychainStore.loadString( let token = KeychainStore.loadString(
service: "com.clawdis.bridge", service: "com.clawdbot.bridge",
account: self.keychainAccount(instanceId: instanceId))? account: self.keychainAccount(instanceId: instanceId))?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !token.isEmpty else { return } guard !token.isEmpty else { return }
@@ -189,7 +189,7 @@ final class BridgeConnectionController {
if !refreshed.isEmpty, refreshed != token { if !refreshed.isEmpty, refreshed != token {
_ = KeychainStore.saveString( _ = KeychainStore.saveString(
refreshed, refreshed,
service: "com.clawdis.bridge", service: "com.clawdbot.bridge",
account: self.keychainAccount(instanceId: instanceId)) account: self.keychainAccount(instanceId: instanceId))
} }
appModel.connectToBridge(endpoint: endpoint, hello: self.makeHello(token: resolvedToken)) appModel.connectToBridge(endpoint: endpoint, hello: self.makeHello(token: resolvedToken))
@@ -217,46 +217,46 @@ final class BridgeConnectionController {
} }
private func currentCaps() -> [String] { private func currentCaps() -> [String] {
var caps = [ClawdisCapability.canvas.rawValue, ClawdisCapability.screen.rawValue] var caps = [ClawdbotCapability.canvas.rawValue, ClawdbotCapability.screen.rawValue]
// Default-on: if the key doesn't exist yet, treat it as enabled. // Default-on: if the key doesn't exist yet, treat it as enabled.
let cameraEnabled = let cameraEnabled =
UserDefaults.standard.object(forKey: "camera.enabled") == nil UserDefaults.standard.object(forKey: "camera.enabled") == nil
? true ? true
: UserDefaults.standard.bool(forKey: "camera.enabled") : UserDefaults.standard.bool(forKey: "camera.enabled")
if cameraEnabled { caps.append(ClawdisCapability.camera.rawValue) } if cameraEnabled { caps.append(ClawdbotCapability.camera.rawValue) }
let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey) let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey)
if voiceWakeEnabled { caps.append(ClawdisCapability.voiceWake.rawValue) } if voiceWakeEnabled { caps.append(ClawdbotCapability.voiceWake.rawValue) }
let locationModeRaw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off" let locationModeRaw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
let locationMode = ClawdisLocationMode(rawValue: locationModeRaw) ?? .off let locationMode = ClawdbotLocationMode(rawValue: locationModeRaw) ?? .off
if locationMode != .off { caps.append(ClawdisCapability.location.rawValue) } if locationMode != .off { caps.append(ClawdbotCapability.location.rawValue) }
return caps return caps
} }
private func currentCommands() -> [String] { private func currentCommands() -> [String] {
var commands: [String] = [ var commands: [String] = [
ClawdisCanvasCommand.present.rawValue, ClawdbotCanvasCommand.present.rawValue,
ClawdisCanvasCommand.hide.rawValue, ClawdbotCanvasCommand.hide.rawValue,
ClawdisCanvasCommand.navigate.rawValue, ClawdbotCanvasCommand.navigate.rawValue,
ClawdisCanvasCommand.evalJS.rawValue, ClawdbotCanvasCommand.evalJS.rawValue,
ClawdisCanvasCommand.snapshot.rawValue, ClawdbotCanvasCommand.snapshot.rawValue,
ClawdisCanvasA2UICommand.push.rawValue, ClawdbotCanvasA2UICommand.push.rawValue,
ClawdisCanvasA2UICommand.pushJSONL.rawValue, ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
ClawdisCanvasA2UICommand.reset.rawValue, ClawdbotCanvasA2UICommand.reset.rawValue,
ClawdisScreenCommand.record.rawValue, ClawdbotScreenCommand.record.rawValue,
] ]
let caps = Set(self.currentCaps()) let caps = Set(self.currentCaps())
if caps.contains(ClawdisCapability.camera.rawValue) { if caps.contains(ClawdbotCapability.camera.rawValue) {
commands.append(ClawdisCameraCommand.list.rawValue) commands.append(ClawdbotCameraCommand.list.rawValue)
commands.append(ClawdisCameraCommand.snap.rawValue) commands.append(ClawdbotCameraCommand.snap.rawValue)
commands.append(ClawdisCameraCommand.clip.rawValue) commands.append(ClawdbotCameraCommand.clip.rawValue)
} }
if caps.contains(ClawdisCapability.location.rawValue) { if caps.contains(ClawdbotCapability.location.rawValue) {
commands.append(ClawdisLocationCommand.get.rawValue) commands.append(ClawdbotLocationCommand.get.rawValue)
} }
return commands return commands
@@ -1,4 +1,4 @@
import ClawdisKit import ClawdbotKit
import Foundation import Foundation
import Network import Network
import Observation import Observation
@@ -51,11 +51,11 @@ final class BridgeDiscoveryModel {
if !self.browsers.isEmpty { return } if !self.browsers.isEmpty { return }
self.appendDebugLog("start()") self.appendDebugLog("start()")
for domain in ClawdisBonjour.bridgeServiceDomains { for domain in ClawdbotBonjour.bridgeServiceDomains {
let params = NWParameters.tcp let params = NWParameters.tcp
params.includePeerToPeer = true params.includePeerToPeer = true
let browser = NWBrowser( let browser = NWBrowser(
for: .bonjour(type: ClawdisBonjour.bridgeServiceType, domain: domain), for: .bonjour(type: ClawdbotBonjour.bridgeServiceType, domain: domain),
using: params) using: params)
browser.stateUpdateHandler = { [weak self] state in browser.stateUpdateHandler = { [weak self] state in
@@ -102,7 +102,7 @@ final class BridgeDiscoveryModel {
} }
self.browsers[domain] = browser self.browsers[domain] = browser
browser.start(queue: DispatchQueue(label: "com.clawdis.ios.bridge-discovery.\(domain)")) browser.start(queue: DispatchQueue(label: "com.clawdbot.ios.bridge-discovery.\(domain)"))
} }
} }
@@ -200,7 +200,7 @@ final class BridgeDiscoveryModel {
private static func prettifyInstanceName(_ decodedName: String) -> String { private static func prettifyInstanceName(_ decodedName: String) -> String {
let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ") let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ")
let stripped = normalized.replacingOccurrences(of: " (Clawdis)", with: "") let stripped = normalized.replacingOccurrences(of: " (Clawdbot)", with: "")
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression) .replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
return stripped.trimmingCharacters(in: .whitespacesAndNewlines) return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
} }
@@ -1,4 +1,4 @@
import ClawdisKit import ClawdbotKit
import Foundation import Foundation
import Network import Network
+2 -2
View File
@@ -1,4 +1,4 @@
import ClawdisKit import ClawdbotKit
import Foundation import Foundation
import Network import Network
@@ -78,7 +78,7 @@ actor BridgeSession {
let params = NWParameters.tcp let params = NWParameters.tcp
params.includePeerToPeer = true params.includePeerToPeer = true
let connection = NWConnection(to: endpoint, using: params) let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdis.ios.bridge-session") let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session")
self.connection = connection self.connection = connection
self.queue = queue self.queue = queue
@@ -1,8 +1,8 @@
import Foundation import Foundation
enum BridgeSettingsStore { enum BridgeSettingsStore {
private static let bridgeService = "com.clawdis.bridge" private static let bridgeService = "com.clawdbot.bridge"
private static let nodeService = "com.clawdis.node" private static let nodeService = "com.clawdbot.node"
private static let instanceIdDefaultsKey = "node.instanceId" private static let instanceIdDefaultsKey = "node.instanceId"
private static let preferredBridgeStableIDDefaultsKey = "bridge.preferredStableID" private static let preferredBridgeStableIDDefaultsKey = "bridge.preferredStableID"
@@ -1,5 +1,5 @@
import AVFoundation import AVFoundation
import ClawdisKit import ClawdbotKit
import Foundation import Foundation
actor CameraController { actor CameraController {
@@ -36,7 +36,7 @@ actor CameraController {
} }
} }
func snap(params: ClawdisCameraSnapParams) async throws -> ( func snap(params: ClawdbotCameraSnapParams) async throws -> (
format: String, format: String,
base64: String, base64: String,
width: Int, width: Int,
@@ -109,7 +109,7 @@ actor CameraController {
height: res.heightPx) height: res.heightPx)
} }
func clip(params: ClawdisCameraClipParams) async throws -> ( func clip(params: ClawdbotCameraClipParams) async throws -> (
format: String, format: String,
base64: String, base64: String,
durationMs: Int, durationMs: Int,
@@ -161,9 +161,9 @@ actor CameraController {
await Self.warmUpCaptureSession() await Self.warmUpCaptureSession()
let movURL = FileManager.default.temporaryDirectory let movURL = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-camera-\(UUID().uuidString).mov") .appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
let mp4URL = FileManager.default.temporaryDirectory let mp4URL = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-camera-\(UUID().uuidString).mp4") .appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
defer { defer {
try? FileManager.default.removeItem(at: movURL) try? FileManager.default.removeItem(at: movURL)
@@ -228,7 +228,7 @@ actor CameraController {
} }
private nonisolated static func pickCamera( private nonisolated static func pickCamera(
facing: ClawdisCameraFacing, facing: ClawdbotCameraFacing,
deviceId: String?) -> AVCaptureDevice? deviceId: String?) -> AVCaptureDevice?
{ {
if let deviceId, !deviceId.isEmpty { if let deviceId, !deviceId.isEmpty {
+4 -4
View File
@@ -1,15 +1,15 @@
import ClawdisChatUI import ClawdbotChatUI
import SwiftUI import SwiftUI
struct ChatSheet: View { struct ChatSheet: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var viewModel: ClawdisChatViewModel @State private var viewModel: ClawdbotChatViewModel
private let userAccent: Color? private let userAccent: Color?
init(bridge: BridgeSession, sessionKey: String = "main", userAccent: Color? = nil) { init(bridge: BridgeSession, sessionKey: String = "main", userAccent: Color? = nil) {
let transport = IOSBridgeChatTransport(bridge: bridge) let transport = IOSBridgeChatTransport(bridge: bridge)
self._viewModel = State( self._viewModel = State(
initialValue: ClawdisChatViewModel( initialValue: ClawdbotChatViewModel(
sessionKey: sessionKey, sessionKey: sessionKey,
transport: transport)) transport: transport))
self.userAccent = userAccent self.userAccent = userAccent
@@ -17,7 +17,7 @@ struct ChatSheet: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ClawdisChatView( ClawdbotChatView(
viewModel: self.viewModel, viewModel: self.viewModel,
showsSessionSwitcher: true, showsSessionSwitcher: true,
userAccent: self.userAccent) userAccent: self.userAccent)
@@ -1,8 +1,8 @@
import ClawdisChatUI import ClawdbotChatUI
import ClawdisKit import ClawdbotKit
import Foundation import Foundation
struct IOSBridgeChatTransport: ClawdisChatTransport, Sendable { struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
private let bridge: BridgeSession private let bridge: BridgeSession
init(bridge: BridgeSession) { init(bridge: BridgeSession) {
@@ -19,7 +19,7 @@ struct IOSBridgeChatTransport: ClawdisChatTransport, Sendable {
_ = try await self.bridge.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10) _ = try await self.bridge.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10)
} }
func listSessions(limit: Int?) async throws -> ClawdisChatSessionsListResponse { func listSessions(limit: Int?) async throws -> ClawdbotChatSessionsListResponse {
struct Params: Codable { struct Params: Codable {
var includeGlobal: Bool var includeGlobal: Bool
var includeUnknown: Bool var includeUnknown: Bool
@@ -28,7 +28,7 @@ struct IOSBridgeChatTransport: ClawdisChatTransport, Sendable {
let data = try JSONEncoder().encode(Params(includeGlobal: true, includeUnknown: false, limit: limit)) let data = try JSONEncoder().encode(Params(includeGlobal: true, includeUnknown: false, limit: limit))
let json = String(data: data, encoding: .utf8) let json = String(data: data, encoding: .utf8)
let res = try await self.bridge.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15) let res = try await self.bridge.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15)
return try JSONDecoder().decode(ClawdisChatSessionsListResponse.self, from: res) return try JSONDecoder().decode(ClawdbotChatSessionsListResponse.self, from: res)
} }
func setActiveSessionKey(_ sessionKey: String) async throws { func setActiveSessionKey(_ sessionKey: String) async throws {
@@ -38,12 +38,12 @@ struct IOSBridgeChatTransport: ClawdisChatTransport, Sendable {
try await self.bridge.sendEvent(event: "chat.subscribe", payloadJSON: json) try await self.bridge.sendEvent(event: "chat.subscribe", payloadJSON: json)
} }
func requestHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload { func requestHistory(sessionKey: String) async throws -> ClawdbotChatHistoryPayload {
struct Params: Codable { var sessionKey: String } struct Params: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey)) let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
let json = String(data: data, encoding: .utf8) let json = String(data: data, encoding: .utf8)
let res = try await self.bridge.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15) let res = try await self.bridge.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
return try JSONDecoder().decode(ClawdisChatHistoryPayload.self, from: res) return try JSONDecoder().decode(ClawdbotChatHistoryPayload.self, from: res)
} }
func sendMessage( func sendMessage(
@@ -51,13 +51,13 @@ struct IOSBridgeChatTransport: ClawdisChatTransport, Sendable {
message: String, message: String,
thinking: String, thinking: String,
idempotencyKey: String, idempotencyKey: String,
attachments: [ClawdisChatAttachmentPayload]) async throws -> ClawdisChatSendResponse attachments: [ClawdbotChatAttachmentPayload]) async throws -> ClawdbotChatSendResponse
{ {
struct Params: Codable { struct Params: Codable {
var sessionKey: String var sessionKey: String
var message: String var message: String
var thinking: String var thinking: String
var attachments: [ClawdisChatAttachmentPayload]? var attachments: [ClawdbotChatAttachmentPayload]?
var timeoutMs: Int var timeoutMs: Int
var idempotencyKey: String var idempotencyKey: String
} }
@@ -72,16 +72,16 @@ struct IOSBridgeChatTransport: ClawdisChatTransport, Sendable {
let data = try JSONEncoder().encode(params) let data = try JSONEncoder().encode(params)
let json = String(data: data, encoding: .utf8) let json = String(data: data, encoding: .utf8)
let res = try await self.bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35) let res = try await self.bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
return try JSONDecoder().decode(ClawdisChatSendResponse.self, from: res) return try JSONDecoder().decode(ClawdbotChatSendResponse.self, from: res)
} }
func requestHealth(timeoutMs: Int) async throws -> Bool { func requestHealth(timeoutMs: Int) async throws -> Bool {
let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0))) let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0)))
let res = try await self.bridge.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds) let res = try await self.bridge.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds)
return (try? JSONDecoder().decode(ClawdisGatewayHealthOK.self, from: res))?.ok ?? true return (try? JSONDecoder().decode(ClawdbotGatewayHealthOK.self, from: res))?.ok ?? true
} }
func events() -> AsyncStream<ClawdisChatTransportEvent> { func events() -> AsyncStream<ClawdbotChatTransportEvent> {
AsyncStream { continuation in AsyncStream { continuation in
let task = Task { let task = Task {
let stream = await self.bridge.subscribeServerEvents() let stream = await self.bridge.subscribeServerEvents()
@@ -94,16 +94,16 @@ struct IOSBridgeChatTransport: ClawdisChatTransport, Sendable {
continuation.yield(.seqGap) continuation.yield(.seqGap)
case "health": case "health":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break } guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
let ok = (try? JSONDecoder().decode(ClawdisGatewayHealthOK.self, from: data))?.ok ?? true let ok = (try? JSONDecoder().decode(ClawdbotGatewayHealthOK.self, from: data))?.ok ?? true
continuation.yield(.health(ok: ok)) continuation.yield(.health(ok: ok))
case "chat": case "chat":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break } guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
if let payload = try? JSONDecoder().decode(ClawdisChatEventPayload.self, from: data) { if let payload = try? JSONDecoder().decode(ClawdbotChatEventPayload.self, from: data) {
continuation.yield(.chat(payload)) continuation.yield(.chat(payload))
} }
case "agent": case "agent":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break } guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
if let payload = try? JSONDecoder().decode(ClawdisAgentEventPayload.self, from: data) { if let payload = try? JSONDecoder().decode(ClawdbotAgentEventPayload.self, from: data) {
continuation.yield(.agent(payload)) continuation.yield(.agent(payload))
} }
default: default:
@@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
@main @main
struct ClawdisApp: App { struct ClawdbotApp: App {
@State private var appModel: NodeAppModel @State private var appModel: NodeAppModel
@State private var bridgeController: BridgeConnectionController @State private var bridgeController: BridgeConnectionController
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
+8 -8
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Clawdis</string> <string>Clawdbot</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconName</key> <key>CFBundleIconName</key>
@@ -29,20 +29,20 @@
</dict> </dict>
<key>NSBonjourServices</key> <key>NSBonjourServices</key>
<array> <array>
<string>_clawdis-bridge._tcp</string> <string>_clawdbot-bridge._tcp</string>
</array> </array>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>Clawdis can capture photos or short video clips when requested via the bridge.</string> <string>Clawdbot can capture photos or short video clips when requested via the bridge.</string>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>Clawdis discovers and connects to your Clawdis bridge on the local network.</string> <string>Clawdbot discovers and connects to your Clawdbot bridge on the local network.</string>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string>Clawdis uses your location when you allow location sharing.</string> <string>Clawdbot uses your location when you allow location sharing.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key> <key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Clawdis can share your location in the background when you enable Always.</string> <string>Clawdbot can share your location in the background when you enable Always.</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>Clawdis needs microphone access for voice wake.</string> <string>Clawdbot needs microphone access for voice wake.</string>
<key>NSSpeechRecognitionUsageDescription</key> <key>NSSpeechRecognitionUsageDescription</key>
<string>Clawdis uses on-device speech recognition for voice wake.</string> <string>Clawdbot uses on-device speech recognition for voice wake.</string>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>
@@ -1,4 +1,4 @@
import ClawdisKit import ClawdbotKit
import CoreLocation import CoreLocation
import Foundation import Foundation
@@ -30,7 +30,7 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
return .fullAccuracy return .fullAccuracy
} }
func ensureAuthorization(mode: ClawdisLocationMode) async -> CLAuthorizationStatus { func ensureAuthorization(mode: ClawdbotLocationMode) async -> CLAuthorizationStatus {
guard CLLocationManager.locationServicesEnabled() else { return .denied } guard CLLocationManager.locationServicesEnabled() else { return .denied }
let status = self.manager.authorizationStatus let status = self.manager.authorizationStatus
@@ -53,8 +53,8 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
} }
func currentLocation( func currentLocation(
params: ClawdisLocationGetParams, params: ClawdbotLocationGetParams,
desiredAccuracy: ClawdisLocationAccuracy, desiredAccuracy: ClawdbotLocationAccuracy,
maxAgeMs: Int?, maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation timeoutMs: Int?) async throws -> CLLocation
{ {
@@ -67,7 +67,7 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
} }
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy) self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
let timeout = max(0, timeoutMs ?? 10_000) let timeout = max(0, timeoutMs ?? 10000)
return try await self.withTimeout(timeoutMs: timeout) { return try await self.withTimeout(timeoutMs: timeout) {
try await self.requestLocation() try await self.requestLocation()
} }
@@ -106,14 +106,14 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
} }
} }
private static func accuracyValue(_ accuracy: ClawdisLocationAccuracy) -> CLLocationAccuracy { private static func accuracyValue(_ accuracy: ClawdbotLocationAccuracy) -> CLLocationAccuracy {
switch accuracy { switch accuracy {
case .coarse: case .coarse:
return kCLLocationAccuracyKilometer kCLLocationAccuracyKilometer
case .balanced: case .balanced:
return kCLLocationAccuracyHundredMeters kCLLocationAccuracyHundredMeters
case .precise: case .precise:
return kCLLocationAccuracyBest kCLLocationAccuracyBest
} }
} }
+334 -290
View File
@@ -1,4 +1,4 @@
import ClawdisKit import ClawdbotKit
import Network import Network
import Observation import Observation
import SwiftUI import SwiftUI
@@ -89,7 +89,7 @@ final class NodeAppModel {
}() }()
guard !userAction.isEmpty else { return } guard !userAction.isEmpty else { return }
guard let name = ClawdisCanvasA2UIAction.extractActionName(userAction) else { return } guard let name = ClawdbotCanvasA2UIAction.extractActionName(userAction) else { return }
let actionId: String = { let actionId: String = {
let id = (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let id = (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return id.isEmpty ? UUID().uuidString : id return id.isEmpty ? UUID().uuidString : id
@@ -108,15 +108,15 @@ final class NodeAppModel {
let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name
let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased() let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"]) let contextJSON = ClawdbotCanvasA2UIAction.compactJSON(userAction["context"])
let sessionKey = "main" let sessionKey = "main"
let messageContext = ClawdisCanvasA2UIAction.AgentMessageContext( let messageContext = ClawdbotCanvasA2UIAction.AgentMessageContext(
actionName: name, actionName: name,
session: .init(key: sessionKey, surfaceId: surfaceId), session: .init(key: sessionKey, surfaceId: surfaceId),
component: .init(id: sourceComponentId, host: host, instanceId: instanceId), component: .init(id: sourceComponentId, host: host, instanceId: instanceId),
contextJSON: contextJSON) contextJSON: contextJSON)
let message = ClawdisCanvasA2UIAction.formatAgentMessage(messageContext) let message = ClawdbotCanvasA2UIAction.formatAgentMessage(messageContext)
let ok: Bool let ok: Bool
var errorText: String? var errorText: String?
@@ -141,7 +141,7 @@ final class NodeAppModel {
} }
} }
let js = ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId: actionId, ok: ok, error: errorText) let js = ClawdbotCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId: actionId, ok: ok, error: errorText)
do { do {
_ = try await self.screen.eval(javaScript: js) _ = try await self.screen.eval(javaScript: js)
} catch { } catch {
@@ -153,7 +153,7 @@ final class NodeAppModel {
guard let raw = await self.bridge.currentCanvasHostUrl() else { return nil } guard let raw = await self.bridge.currentCanvasHostUrl() else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil } guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString + "?platform=ios" return base.appendingPathComponent("__clawdbot__/a2ui/").absoluteString + "?platform=ios"
} }
private func showA2UIOnConnectIfNeeded() async { private func showA2UIOnConnectIfNeeded() async {
@@ -189,7 +189,7 @@ final class NodeAppModel {
self.talkMode.setEnabled(enabled) self.talkMode.setEnabled(enabled)
} }
func requestLocationPermissions(mode: ClawdisLocationMode) async -> Bool { func requestLocationPermissions(mode: ClawdbotLocationMode) async -> Bool {
guard mode != .off else { return true } guard mode != .off else { return true }
let status = await self.locationService.ensureAuthorization(mode: mode) let status = await self.locationService.ensureAuthorization(mode: mode)
switch status { switch status {
@@ -250,7 +250,9 @@ final class NodeAppModel {
return BridgeInvokeResponse( return BridgeInvokeResponse(
id: req.id, id: req.id,
ok: false, ok: false,
error: ClawdisNodeError(code: .unavailable, message: "UNAVAILABLE: node not ready")) error: ClawdbotNodeError(
code: .unavailable,
message: "UNAVAILABLE: node not ready"))
} }
return await self.handleInvoke(req) return await self.handleInvoke(req)
}) })
@@ -439,7 +441,7 @@ final class NodeAppModel {
} }
// iOS bridge forwards to the gateway; no local auth prompts here. // iOS bridge forwards to the gateway; no local auth prompts here.
// (Key-based unattended auth is handled on macOS for clawdis:// links.) // (Key-based unattended auth is handled on macOS for clawdbot:// links.)
let data = try JSONEncoder().encode(link) let data = try JSONEncoder().encode(link)
guard let json = String(bytes: data, encoding: .utf8) else { guard let json = String(bytes: data, encoding: .utf8) else {
throw NSError(domain: "NodeAppModel", code: 2, userInfo: [ throw NSError(domain: "NodeAppModel", code: 2, userInfo: [
@@ -454,17 +456,14 @@ final class NodeAppModel {
return false return false
} }
// swiftlint:disable:next function_body_length cyclomatic_complexity
private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
let command = req.command let command = req.command
if command.hasPrefix("canvas.") || command.hasPrefix("camera.") || command.hasPrefix("screen."), if self.isBackgrounded, self.isBackgroundRestricted(command) {
self.isBackgrounded
{
return BridgeInvokeResponse( return BridgeInvokeResponse(
id: req.id, id: req.id,
ok: false, ok: false,
error: ClawdisNodeError( error: ClawdbotNodeError(
code: .backgroundUnavailable, code: .backgroundUnavailable,
message: "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground")) message: "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground"))
} }
@@ -473,288 +472,36 @@ final class NodeAppModel {
return BridgeInvokeResponse( return BridgeInvokeResponse(
id: req.id, id: req.id,
ok: false, ok: false,
error: ClawdisNodeError( error: ClawdbotNodeError(
code: .unavailable, code: .unavailable,
message: "CAMERA_DISABLED: enable Camera in iOS Settings → Camera → Allow Camera")) message: "CAMERA_DISABLED: enable Camera in iOS Settings → Camera → Allow Camera"))
} }
do { do {
switch command { switch command {
case ClawdisLocationCommand.get.rawValue: case ClawdbotLocationCommand.get.rawValue:
let mode = self.locationMode() return try await self.handleLocationInvoke(req)
guard mode != .off else { case ClawdbotCanvasCommand.present.rawValue,
return BridgeInvokeResponse( ClawdbotCanvasCommand.hide.rawValue,
id: req.id, ClawdbotCanvasCommand.navigate.rawValue,
ok: false, ClawdbotCanvasCommand.evalJS.rawValue,
error: ClawdisNodeError( ClawdbotCanvasCommand.snapshot.rawValue:
code: .unavailable, return try await self.handleCanvasInvoke(req)
message: "LOCATION_DISABLED: enable Location in Settings")) case ClawdbotCanvasA2UICommand.reset.rawValue,
} ClawdbotCanvasA2UICommand.push.rawValue,
if self.isBackgrounded, mode != .always { ClawdbotCanvasA2UICommand.pushJSONL.rawValue:
return BridgeInvokeResponse( return try await self.handleCanvasA2UIInvoke(req)
id: req.id, case ClawdbotCameraCommand.list.rawValue,
ok: false, ClawdbotCameraCommand.snap.rawValue,
error: ClawdisNodeError( ClawdbotCameraCommand.clip.rawValue:
code: .backgroundUnavailable, return try await self.handleCameraInvoke(req)
message: "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always")) case ClawdbotScreenCommand.record.rawValue:
} return try await self.handleScreenRecordInvoke(req)
let params = (try? Self.decodeParams(ClawdisLocationGetParams.self, from: req.paramsJSON)) ??
ClawdisLocationGetParams()
let desired = params.desiredAccuracy ??
(self.isLocationPreciseEnabled() ? .precise : .balanced)
let status = self.locationService.authorizationStatus()
if status != .authorizedAlways && status != .authorizedWhenInUse {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
}
if self.isBackgrounded && status != .authorizedAlways {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "LOCATION_PERMISSION_REQUIRED: enable Always for background access"))
}
let location = try await self.locationService.currentLocation(
params: params,
desiredAccuracy: desired,
maxAgeMs: params.maxAgeMs,
timeoutMs: params.timeoutMs)
let isPrecise = self.locationService.accuracyAuthorization() == .fullAccuracy
let payload = ClawdisLocationPayload(
lat: location.coordinate.latitude,
lon: location.coordinate.longitude,
accuracyMeters: location.horizontalAccuracy,
altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil,
speedMps: location.speed >= 0 ? location.speed : nil,
headingDeg: location.course >= 0 ? location.course : nil,
timestamp: ISO8601DateFormatter().string(from: location.timestamp),
isPrecise: isPrecise,
source: nil)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
case ClawdisCanvasCommand.present.rawValue:
let params = (try? Self.decodeParams(ClawdisCanvasPresentParams.self, from: req.paramsJSON)) ??
ClawdisCanvasPresentParams()
let url = params.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if url.isEmpty {
self.screen.showDefaultCanvas()
} else {
self.screen.navigate(to: url)
}
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdisCanvasCommand.hide.rawValue:
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdisCanvasCommand.navigate.rawValue:
let params = try Self.decodeParams(ClawdisCanvasNavigateParams.self, from: req.paramsJSON)
self.screen.navigate(to: params.url)
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdisCanvasCommand.evalJS.rawValue:
let params = try Self.decodeParams(ClawdisCanvasEvalParams.self, from: req.paramsJSON)
let result = try await self.screen.eval(javaScript: params.javaScript)
let payload = try Self.encodePayload(["result": result])
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdisCanvasCommand.snapshot.rawValue:
let params = try? Self.decodeParams(ClawdisCanvasSnapshotParams.self, from: req.paramsJSON)
let format = params?.format ?? .jpeg
let maxWidth: CGFloat? = {
if let raw = params?.maxWidth, raw > 0 { return CGFloat(raw) }
// Keep default snapshots comfortably below the gateway client's maxPayload.
// For full-res, clients should explicitly request a larger maxWidth.
return switch format {
case .png: 900
case .jpeg: 1600
}
}()
let base64 = try await self.screen.snapshotBase64(
maxWidth: maxWidth,
format: format,
quality: params?.quality)
let payload = try Self.encodePayload([
"format": format == .jpeg ? "jpeg" : "png",
"base64": base64,
])
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdisCanvasA2UICommand.reset.rawValue:
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
}
self.screen.navigate(to: a2uiUrl)
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable"))
}
let json = try await self.screen.eval(javaScript: """
(() => {
if (!globalThis.clawdisA2UI) return JSON.stringify({ ok: false, error: "missing clawdisA2UI" });
return JSON.stringify(globalThis.clawdisA2UI.reset());
})()
""")
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
case ClawdisCanvasA2UICommand.push.rawValue, ClawdisCanvasA2UICommand.pushJSONL.rawValue:
let messages: [AnyCodable]
if command == ClawdisCanvasA2UICommand.pushJSONL.rawValue {
let params = try Self.decodeParams(ClawdisCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
messages = try ClawdisCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
} else {
do {
let params = try Self.decodeParams(ClawdisCanvasA2UIPushParams.self, from: req.paramsJSON)
messages = params.messages
} catch {
// Be forgiving: some clients still send JSONL payloads to `canvas.a2ui.push`.
let params = try Self.decodeParams(ClawdisCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
messages = try ClawdisCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
}
}
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
}
self.screen.navigate(to: a2uiUrl)
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable"))
}
let messagesJSON = try ClawdisCanvasA2UIJSONL.encodeMessagesJSONArray(messages)
let js = """
(() => {
try {
if (!globalThis.clawdisA2UI) return JSON.stringify({ ok: false, error: "missing clawdisA2UI" });
const messages = \(messagesJSON);
return JSON.stringify(globalThis.clawdisA2UI.applyMessages(messages));
} catch (e) {
return JSON.stringify({ ok: false, error: String(e?.message ?? e) });
}
})()
"""
let resultJSON = try await self.screen.eval(javaScript: js)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
case ClawdisCameraCommand.list.rawValue:
let devices = await self.camera.listDevices()
struct Payload: Codable {
var devices: [CameraController.CameraDeviceInfo]
}
let payload = try Self.encodePayload(Payload(devices: devices))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdisCameraCommand.snap.rawValue:
self.showCameraHUD(text: "Taking photo…", kind: .photo)
self.triggerCameraFlash()
let params = (try? Self.decodeParams(ClawdisCameraSnapParams.self, from: req.paramsJSON)) ??
ClawdisCameraSnapParams()
let res = try await self.camera.snap(params: params)
struct Payload: Codable {
var format: String
var base64: String
var width: Int
var height: Int
}
let payload = try Self.encodePayload(Payload(
format: res.format,
base64: res.base64,
width: res.width,
height: res.height))
self.showCameraHUD(text: "Photo captured", kind: .success, autoHideSeconds: 1.6)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdisCameraCommand.clip.rawValue:
let params = (try? Self.decodeParams(ClawdisCameraClipParams.self, from: req.paramsJSON)) ??
ClawdisCameraClipParams()
let suspended = (params.includeAudio ?? true) ? self.voiceWake.suspendForExternalAudioCapture() : false
defer { self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: suspended) }
self.showCameraHUD(text: "Recording…", kind: .recording)
let res = try await self.camera.clip(params: params)
struct Payload: Codable {
var format: String
var base64: String
var durationMs: Int
var hasAudio: Bool
}
let payload = try Self.encodePayload(Payload(
format: res.format,
base64: res.base64,
durationMs: res.durationMs,
hasAudio: res.hasAudio))
self.showCameraHUD(text: "Clip captured", kind: .success, autoHideSeconds: 1.8)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdisScreenCommand.record.rawValue:
let params = (try? Self.decodeParams(ClawdisScreenRecordParams.self, from: req.paramsJSON)) ??
ClawdisScreenRecordParams()
if let format = params.format, format.lowercased() != "mp4" {
throw NSError(domain: "Screen", code: 30, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: screen format must be mp4",
])
}
// Status pill mirrors screen recording state so it stays visible without overlay stacking.
self.screenRecordActive = true
defer { self.screenRecordActive = false }
let path = try await self.screenRecorder.record(
screenIndex: params.screenIndex,
durationMs: params.durationMs,
fps: params.fps,
includeAudio: params.includeAudio,
outPath: nil)
defer { try? FileManager.default.removeItem(atPath: path) }
let data = try Data(contentsOf: URL(fileURLWithPath: path))
struct Payload: Codable {
var format: String
var base64: String
var durationMs: Int?
var fps: Double?
var screenIndex: Int?
var hasAudio: Bool
}
let payload = try Self.encodePayload(Payload(
format: "mp4",
base64: data.base64EncodedString(),
durationMs: params.durationMs,
fps: params.fps,
screenIndex: params.screenIndex,
hasAudio: params.includeAudio ?? true))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
default: default:
return BridgeInvokeResponse( return BridgeInvokeResponse(
id: req.id, id: req.id,
ok: false, ok: false,
error: ClawdisNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) error: ClawdbotNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
} }
} catch { } catch {
if command.hasPrefix("camera.") { if command.hasPrefix("camera.") {
@@ -764,13 +511,310 @@ final class NodeAppModel {
return BridgeInvokeResponse( return BridgeInvokeResponse(
id: req.id, id: req.id,
ok: false, ok: false,
error: ClawdisNodeError(code: .unavailable, message: error.localizedDescription)) error: ClawdbotNodeError(code: .unavailable, message: error.localizedDescription))
} }
} }
private func locationMode() -> ClawdisLocationMode { private func isBackgroundRestricted(_ command: String) -> Bool {
command.hasPrefix("canvas.") || command.hasPrefix("camera.") || command.hasPrefix("screen.")
}
private func handleLocationInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let mode = self.locationMode()
guard mode != .off else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(
code: .unavailable,
message: "LOCATION_DISABLED: enable Location in Settings"))
}
if self.isBackgrounded, mode != .always {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(
code: .backgroundUnavailable,
message: "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always"))
}
let params = (try? Self.decodeParams(ClawdbotLocationGetParams.self, from: req.paramsJSON)) ??
ClawdbotLocationGetParams()
let desired = params.desiredAccuracy ??
(self.isLocationPreciseEnabled() ? .precise : .balanced)
let status = self.locationService.authorizationStatus()
if status != .authorizedAlways, status != .authorizedWhenInUse {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(
code: .unavailable,
message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
}
if self.isBackgrounded, status != .authorizedAlways {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(
code: .unavailable,
message: "LOCATION_PERMISSION_REQUIRED: enable Always for background access"))
}
let location = try await self.locationService.currentLocation(
params: params,
desiredAccuracy: desired,
maxAgeMs: params.maxAgeMs,
timeoutMs: params.timeoutMs)
let isPrecise = self.locationService.accuracyAuthorization() == .fullAccuracy
let payload = ClawdbotLocationPayload(
lat: location.coordinate.latitude,
lon: location.coordinate.longitude,
accuracyMeters: location.horizontalAccuracy,
altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil,
speedMps: location.speed >= 0 ? location.speed : nil,
headingDeg: location.course >= 0 ? location.course : nil,
timestamp: ISO8601DateFormatter().string(from: location.timestamp),
isPrecise: isPrecise,
source: nil)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
}
private func handleCanvasInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
switch req.command {
case ClawdbotCanvasCommand.present.rawValue:
let params = (try? Self.decodeParams(ClawdbotCanvasPresentParams.self, from: req.paramsJSON)) ??
ClawdbotCanvasPresentParams()
let url = params.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if url.isEmpty {
self.screen.showDefaultCanvas()
} else {
self.screen.navigate(to: url)
}
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdbotCanvasCommand.hide.rawValue:
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdbotCanvasCommand.navigate.rawValue:
let params = try Self.decodeParams(ClawdbotCanvasNavigateParams.self, from: req.paramsJSON)
self.screen.navigate(to: params.url)
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdbotCanvasCommand.evalJS.rawValue:
let params = try Self.decodeParams(ClawdbotCanvasEvalParams.self, from: req.paramsJSON)
let result = try await self.screen.eval(javaScript: params.javaScript)
let payload = try Self.encodePayload(["result": result])
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdbotCanvasCommand.snapshot.rawValue:
let params = try? Self.decodeParams(ClawdbotCanvasSnapshotParams.self, from: req.paramsJSON)
let format = params?.format ?? .jpeg
let maxWidth: CGFloat? = {
if let raw = params?.maxWidth, raw > 0 { return CGFloat(raw) }
// Keep default snapshots comfortably below the gateway client's maxPayload.
// For full-res, clients should explicitly request a larger maxWidth.
return switch format {
case .png: 900
case .jpeg: 1600
}
}()
let base64 = try await self.screen.snapshotBase64(
maxWidth: maxWidth,
format: format,
quality: params?.quality)
let payload = try Self.encodePayload([
"format": format == .jpeg ? "jpeg" : "png",
"base64": base64,
])
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
}
}
private func handleCanvasA2UIInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let command = req.command
switch command {
case ClawdbotCanvasA2UICommand.reset.rawValue:
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(
code: .unavailable,
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
}
self.screen.navigate(to: a2uiUrl)
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(
code: .unavailable,
message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable"))
}
let json = try await self.screen.eval(javaScript: """
(() => {
if (!globalThis.clawdbotA2UI) return JSON.stringify({ ok: false, error: "missing clawdbotA2UI" });
return JSON.stringify(globalThis.clawdbotA2UI.reset());
})()
""")
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
case ClawdbotCanvasA2UICommand.push.rawValue, ClawdbotCanvasA2UICommand.pushJSONL.rawValue:
let messages: [AnyCodable]
if command == ClawdbotCanvasA2UICommand.pushJSONL.rawValue {
let params = try Self.decodeParams(ClawdbotCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
messages = try ClawdbotCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
} else {
do {
let params = try Self.decodeParams(ClawdbotCanvasA2UIPushParams.self, from: req.paramsJSON)
messages = params.messages
} catch {
// Be forgiving: some clients still send JSONL payloads to `canvas.a2ui.push`.
let params = try Self.decodeParams(ClawdbotCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
messages = try ClawdbotCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
}
}
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(
code: .unavailable,
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
}
self.screen.navigate(to: a2uiUrl)
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(
code: .unavailable,
message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable"))
}
let messagesJSON = try ClawdbotCanvasA2UIJSONL.encodeMessagesJSONArray(messages)
let js = """
(() => {
try {
if (!globalThis.clawdbotA2UI) return JSON.stringify({ ok: false, error: "missing clawdbotA2UI" });
const messages = \(messagesJSON);
return JSON.stringify(globalThis.clawdbotA2UI.applyMessages(messages));
} catch (e) {
return JSON.stringify({ ok: false, error: String(e?.message ?? e) });
}
})()
"""
let resultJSON = try await self.screen.eval(javaScript: js)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
}
}
private func handleCameraInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
switch req.command {
case ClawdbotCameraCommand.list.rawValue:
let devices = await self.camera.listDevices()
struct Payload: Codable {
var devices: [CameraController.CameraDeviceInfo]
}
let payload = try Self.encodePayload(Payload(devices: devices))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdbotCameraCommand.snap.rawValue:
self.showCameraHUD(text: "Taking photo…", kind: .photo)
self.triggerCameraFlash()
let params = (try? Self.decodeParams(ClawdbotCameraSnapParams.self, from: req.paramsJSON)) ??
ClawdbotCameraSnapParams()
let res = try await self.camera.snap(params: params)
struct Payload: Codable {
var format: String
var base64: String
var width: Int
var height: Int
}
let payload = try Self.encodePayload(Payload(
format: res.format,
base64: res.base64,
width: res.width,
height: res.height))
self.showCameraHUD(text: "Photo captured", kind: .success, autoHideSeconds: 1.6)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdbotCameraCommand.clip.rawValue:
let params = (try? Self.decodeParams(ClawdbotCameraClipParams.self, from: req.paramsJSON)) ??
ClawdbotCameraClipParams()
let suspended = (params.includeAudio ?? true) ? self.voiceWake.suspendForExternalAudioCapture() : false
defer { self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: suspended) }
self.showCameraHUD(text: "Recording…", kind: .recording)
let res = try await self.camera.clip(params: params)
struct Payload: Codable {
var format: String
var base64: String
var durationMs: Int
var hasAudio: Bool
}
let payload = try Self.encodePayload(Payload(
format: res.format,
base64: res.base64,
durationMs: res.durationMs,
hasAudio: res.hasAudio))
self.showCameraHUD(text: "Clip captured", kind: .success, autoHideSeconds: 1.8)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
}
}
private func handleScreenRecordInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = (try? Self.decodeParams(ClawdbotScreenRecordParams.self, from: req.paramsJSON)) ??
ClawdbotScreenRecordParams()
if let format = params.format, format.lowercased() != "mp4" {
throw NSError(domain: "Screen", code: 30, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: screen format must be mp4",
])
}
// Status pill mirrors screen recording state so it stays visible without overlay stacking.
self.screenRecordActive = true
defer { self.screenRecordActive = false }
let path = try await self.screenRecorder.record(
screenIndex: params.screenIndex,
durationMs: params.durationMs,
fps: params.fps,
includeAudio: params.includeAudio,
outPath: nil)
defer { try? FileManager.default.removeItem(atPath: path) }
let data = try Data(contentsOf: URL(fileURLWithPath: path))
struct Payload: Codable {
var format: String
var base64: String
var durationMs: Int?
var fps: Double?
var screenIndex: Int?
var hasAudio: Bool
}
let payload = try Self.encodePayload(Payload(
format: "mp4",
base64: data.base64EncodedString(),
durationMs: params.durationMs,
fps: params.fps,
screenIndex: params.screenIndex,
hasAudio: params.includeAudio ?? true))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func locationMode() -> ClawdbotLocationMode {
let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off" let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
return ClawdisLocationMode(rawValue: raw) ?? .off return ClawdbotLocationMode(rawValue: raw) ?? .off
} }
private func isLocationPreciseEnabled() -> Bool { private func isLocationPreciseEnabled() -> Bool {
+10 -10
View File
@@ -1,4 +1,4 @@
import ClawdisKit import ClawdbotKit
import Observation import Observation
import SwiftUI import SwiftUI
import WebKit import WebKit
@@ -13,7 +13,7 @@ final class ScreenController {
var urlString: String = "" var urlString: String = ""
var errorText: String? var errorText: String?
/// Callback invoked when a clawdis:// deep link is tapped in the canvas /// Callback invoked when a clawdbot:// deep link is tapped in the canvas
var onDeepLink: ((URL) -> Void)? var onDeepLink: ((URL) -> Void)?
/// Callback invoked when the user clicks an A2UI action (e.g. button) inside the canvas web UI. /// Callback invoked when the user clicks an A2UI action (e.g. button) inside the canvas web UI.
@@ -101,7 +101,7 @@ final class ScreenController {
let js = """ let js = """
(() => { (() => {
try { try {
const api = globalThis.__clawdis; const api = globalThis.__clawdbot;
if (!api) return; if (!api) return;
if (typeof api.setDebugStatusEnabled === 'function') { if (typeof api.setDebugStatusEnabled === 'function') {
api.setDebugStatusEnabled(\(enabled ? "true" : "false")); api.setDebugStatusEnabled(\(enabled ? "true" : "false"));
@@ -124,7 +124,7 @@ final class ScreenController {
let res = try await self.eval(javaScript: """ let res = try await self.eval(javaScript: """
(() => { (() => {
try { try {
return !!globalThis.clawdisA2UI && typeof globalThis.clawdisA2UI.applyMessages === 'function'; return !!globalThis.clawdbotA2UI && typeof globalThis.clawdbotA2UI.applyMessages === 'function';
} catch (_) { return false; } } catch (_) { return false; }
})() })()
""") """)
@@ -184,7 +184,7 @@ final class ScreenController {
func snapshotBase64( func snapshotBase64(
maxWidth: CGFloat? = nil, maxWidth: CGFloat? = nil,
format: ClawdisCanvasSnapshotFormat, format: ClawdbotCanvasSnapshotFormat,
quality: Double? = nil) async throws -> String quality: Double? = nil) async throws -> String
{ {
let config = WKSnapshotConfiguration() let config = WKSnapshotConfiguration()
@@ -229,7 +229,7 @@ final class ScreenController {
subdirectory: String) subdirectory: String)
-> URL? -> URL?
{ {
let bundle = ClawdisKitResources.bundle let bundle = ClawdbotKitResources.bundle
return bundle.url(forResource: name, withExtension: ext, subdirectory: subdirectory) return bundle.url(forResource: name, withExtension: ext, subdirectory: subdirectory)
?? bundle.url(forResource: name, withExtension: ext) ?? bundle.url(forResource: name, withExtension: ext)
} }
@@ -342,7 +342,7 @@ extension Double {
// MARK: - Navigation Delegate // MARK: - Navigation Delegate
/// Handles navigation policy to intercept clawdis:// deep links from canvas /// Handles navigation policy to intercept clawdbot:// deep links from canvas
@MainActor @MainActor
private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate { private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
weak var controller: ScreenController? weak var controller: ScreenController?
@@ -357,8 +357,8 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
return return
} }
// Intercept clawdis:// deep links // Intercept clawdbot:// deep links
if url.scheme == "clawdis" { if url.scheme == "clawdbot" {
decisionHandler(.cancel) decisionHandler(.cancel)
self.controller?.onDeepLink?(url) self.controller?.onDeepLink?(url)
return return
@@ -386,7 +386,7 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
} }
private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler { private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
static let messageName = "clawdisCanvasA2UIAction" static let messageName = "clawdbotCanvasA2UIAction"
static let legacyMessageNames = ["canvas", "a2ui", "userAction", "action"] static let legacyMessageNames = ["canvas", "a2ui", "userAction", "action"]
static let handlerNames = [messageName] + legacyMessageNames static let handlerNames = [messageName] + legacyMessageNames
+215 -133
View File
@@ -40,7 +40,6 @@ final class ScreenRecordService: @unchecked Sendable {
} }
} }
// swiftlint:disable:next cyclomatic_complexity
func record( func record(
screenIndex: Int?, screenIndex: Int?,
durationMs: Int?, durationMs: Int?,
@@ -48,165 +47,244 @@ final class ScreenRecordService: @unchecked Sendable {
includeAudio: Bool?, includeAudio: Bool?,
outPath: String?) async throws -> String outPath: String?) async throws -> String
{ {
let config = try self.makeRecordConfig(
screenIndex: screenIndex,
durationMs: durationMs,
fps: fps,
includeAudio: includeAudio,
outPath: outPath)
let state = CaptureState()
let recordQueue = DispatchQueue(label: "com.clawdbot.screenrecord")
try await self.startCapture(state: state, config: config, recordQueue: recordQueue)
try await Task.sleep(nanoseconds: UInt64(config.durationMs) * 1_000_000)
try await self.stopCapture()
try self.finalizeCapture(state: state)
try await self.finishWriting(state: state)
return config.outURL.path
}
private struct RecordConfig {
let durationMs: Int
let fpsValue: Double
let includeAudio: Bool
let outURL: URL
}
private func makeRecordConfig(
screenIndex: Int?,
durationMs: Int?,
fps: Double?,
includeAudio: Bool?,
outPath: String?) throws -> RecordConfig
{
if let idx = screenIndex, idx != 0 {
throw ScreenRecordError.invalidScreenIndex(idx)
}
let durationMs = Self.clampDurationMs(durationMs) let durationMs = Self.clampDurationMs(durationMs)
let fps = Self.clampFps(fps) let fps = Self.clampFps(fps)
let fpsInt = Int32(fps.rounded()) let fpsInt = Int32(fps.rounded())
let fpsValue = Double(fpsInt) let fpsValue = Double(fpsInt)
let includeAudio = includeAudio ?? true let includeAudio = includeAudio ?? true
if let idx = screenIndex, idx != 0 { let outURL = self.makeOutputURL(outPath: outPath)
throw ScreenRecordError.invalidScreenIndex(idx)
}
let outURL: URL = {
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return URL(fileURLWithPath: outPath)
}
return FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-screen-record-\(UUID().uuidString).mp4")
}()
try? FileManager.default.removeItem(at: outURL) try? FileManager.default.removeItem(at: outURL)
let state = CaptureState() return RecordConfig(
let recordQueue = DispatchQueue(label: "com.clawdis.screenrecord") durationMs: durationMs,
fpsValue: fpsValue,
includeAudio: includeAudio,
outURL: outURL)
}
private func makeOutputURL(outPath: String?) -> URL {
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return URL(fileURLWithPath: outPath)
}
return FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4")
}
private func startCapture(
state: CaptureState,
config: RecordConfig,
recordQueue: DispatchQueue) async throws
{
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
let handler: @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void = { sample, type, error in let handler = self.makeCaptureHandler(
// ReplayKit can call the capture handler on a background queue. state: state,
// Serialize writes to avoid queue asserts. config: config,
recordQueue.async { recordQueue: recordQueue)
if let error {
state.withLock { state in
if state.handlerError == nil { state.handlerError = error }
}
return
}
guard CMSampleBufferDataIsReady(sample) else { return }
switch type {
case .video:
let pts = CMSampleBufferGetPresentationTimeStamp(sample)
let shouldSkip = state.withLock { state in
if let lastVideoTime = state.lastVideoTime {
let delta = CMTimeSubtract(pts, lastVideoTime)
return delta.seconds < (1.0 / fpsValue)
}
return false
}
if shouldSkip { return }
if state.withLock({ $0.writer == nil }) {
guard let imageBuffer = CMSampleBufferGetImageBuffer(sample) else {
state.withLock { state in
if state.handlerError == nil {
state.handlerError = ScreenRecordError.captureFailed("Missing image buffer")
}
}
return
}
let width = CVPixelBufferGetWidth(imageBuffer)
let height = CVPixelBufferGetHeight(imageBuffer)
do {
let w = try AVAssetWriter(outputURL: outURL, fileType: .mp4)
let settings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: width,
AVVideoHeightKey: height,
]
let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
vInput.expectsMediaDataInRealTime = true
guard w.canAdd(vInput) else {
throw ScreenRecordError.writeFailed("Cannot add video input")
}
w.add(vInput)
if includeAudio {
let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: nil)
aInput.expectsMediaDataInRealTime = true
if w.canAdd(aInput) {
w.add(aInput)
state.withLock { state in
state.audioInput = aInput
}
}
}
guard w.startWriting() else {
throw ScreenRecordError
.writeFailed(w.error?.localizedDescription ?? "Failed to start writer")
}
w.startSession(atSourceTime: pts)
state.withLock { state in
state.writer = w
state.videoInput = vInput
state.started = true
}
} catch {
state.withLock { state in
if state.handlerError == nil { state.handlerError = error }
}
return
}
}
let vInput = state.withLock { $0.videoInput }
let isStarted = state.withLock { $0.started }
guard let vInput, isStarted else { return }
if vInput.isReadyForMoreMediaData {
if vInput.append(sample) {
state.withLock { state in
state.sawVideo = true
state.lastVideoTime = pts
}
} else {
let err = state.withLock { $0.writer?.error }
if let err {
state.withLock { state in
if state.handlerError == nil {
state.handlerError = ScreenRecordError.writeFailed(err.localizedDescription)
}
}
}
}
}
case .audioApp, .audioMic:
let aInput = state.withLock { $0.audioInput }
let isStarted = state.withLock { $0.started }
guard includeAudio, let aInput, isStarted else { return }
if aInput.isReadyForMoreMediaData {
_ = aInput.append(sample)
}
@unknown default:
break
}
}
}
let completion: @Sendable (Error?) -> Void = { error in let completion: @Sendable (Error?) -> Void = { error in
if let error { cont.resume(throwing: error) } else { cont.resume() } if let error { cont.resume(throwing: error) } else { cont.resume() }
} }
Task { @MainActor in Task { @MainActor in
startReplayKitCapture( startReplayKitCapture(
includeAudio: includeAudio, includeAudio: config.includeAudio,
handler: handler, handler: handler,
completion: completion) completion: completion)
} }
} }
}
try await Task.sleep(nanoseconds: UInt64(durationMs) * 1_000_000) private func makeCaptureHandler(
state: CaptureState,
config: RecordConfig,
recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void
{
{ sample, type, error in
// ReplayKit can call the capture handler on a background queue.
// Serialize writes to avoid queue asserts.
recordQueue.async {
if let error {
state.withLock { state in
if state.handlerError == nil { state.handlerError = error }
}
return
}
guard CMSampleBufferDataIsReady(sample) else { return }
switch type {
case .video:
self.handleVideoSample(sample, state: state, config: config)
case .audioApp, .audioMic:
self.handleAudioSample(sample, state: state, includeAudio: config.includeAudio)
@unknown default:
break
}
}
}
}
private func handleVideoSample(
_ sample: CMSampleBuffer,
state: CaptureState,
config: RecordConfig)
{
let pts = CMSampleBufferGetPresentationTimeStamp(sample)
let shouldSkip = state.withLock { state in
if let lastVideoTime = state.lastVideoTime {
let delta = CMTimeSubtract(pts, lastVideoTime)
return delta.seconds < (1.0 / config.fpsValue)
}
return false
}
if shouldSkip { return }
if state.withLock({ $0.writer == nil }) {
self.prepareWriter(sample: sample, state: state, config: config, pts: pts)
}
let vInput = state.withLock { $0.videoInput }
let isStarted = state.withLock { $0.started }
guard let vInput, isStarted else { return }
if vInput.isReadyForMoreMediaData {
if vInput.append(sample) {
state.withLock { state in
state.sawVideo = true
state.lastVideoTime = pts
}
} else {
let err = state.withLock { $0.writer?.error }
if let err {
state.withLock { state in
if state.handlerError == nil {
state.handlerError = ScreenRecordError.writeFailed(err.localizedDescription)
}
}
}
}
}
}
private func prepareWriter(
sample: CMSampleBuffer,
state: CaptureState,
config: RecordConfig,
pts: CMTime)
{
guard let imageBuffer = CMSampleBufferGetImageBuffer(sample) else {
state.withLock { state in
if state.handlerError == nil {
state.handlerError = ScreenRecordError.captureFailed("Missing image buffer")
}
}
return
}
let width = CVPixelBufferGetWidth(imageBuffer)
let height = CVPixelBufferGetHeight(imageBuffer)
do {
let writer = try AVAssetWriter(outputURL: config.outURL, fileType: .mp4)
let settings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: width,
AVVideoHeightKey: height,
]
let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
vInput.expectsMediaDataInRealTime = true
guard writer.canAdd(vInput) else {
throw ScreenRecordError.writeFailed("Cannot add video input")
}
writer.add(vInput)
if config.includeAudio {
let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: nil)
aInput.expectsMediaDataInRealTime = true
if writer.canAdd(aInput) {
writer.add(aInput)
state.withLock { state in
state.audioInput = aInput
}
}
}
guard writer.startWriting() else {
throw ScreenRecordError.writeFailed(
writer.error?.localizedDescription ?? "Failed to start writer")
}
writer.startSession(atSourceTime: pts)
state.withLock { state in
state.writer = writer
state.videoInput = vInput
state.started = true
}
} catch {
state.withLock { state in
if state.handlerError == nil { state.handlerError = error }
}
}
}
private func handleAudioSample(
_ sample: CMSampleBuffer,
state: CaptureState,
includeAudio: Bool)
{
let aInput = state.withLock { $0.audioInput }
let isStarted = state.withLock { $0.started }
guard includeAudio, let aInput, isStarted else { return }
if aInput.isReadyForMoreMediaData {
_ = aInput.append(sample)
}
}
private func stopCapture() async throws {
let stopError = await withCheckedContinuation { cont in let stopError = await withCheckedContinuation { cont in
Task { @MainActor in Task { @MainActor in
stopReplayKitCapture { error in cont.resume(returning: error) } stopReplayKitCapture { error in cont.resume(returning: error) }
} }
} }
if let stopError { throw stopError } if let stopError { throw stopError }
}
let handlerErrorSnapshot = state.withLock { $0.handlerError } private func finalizeCapture(state: CaptureState) throws {
if let handlerErrorSnapshot { throw handlerErrorSnapshot } if let handlerErrorSnapshot = state.withLock({ $0.handlerError }) {
throw handlerErrorSnapshot
}
let writerSnapshot = state.withLock { $0.writer } let writerSnapshot = state.withLock { $0.writer }
let videoInputSnapshot = state.withLock { $0.videoInput } let videoInputSnapshot = state.withLock { $0.videoInput }
let audioInputSnapshot = state.withLock { $0.audioInput } let audioInputSnapshot = state.withLock { $0.audioInput }
@@ -217,7 +295,13 @@ final class ScreenRecordService: @unchecked Sendable {
videoInputSnapshot.markAsFinished() videoInputSnapshot.markAsFinished()
audioInputSnapshot?.markAsFinished() audioInputSnapshot?.markAsFinished()
_ = writerSnapshot
}
private func finishWriting(state: CaptureState) async throws {
guard let writerSnapshot = state.withLock({ $0.writer }) else {
throw ScreenRecordError.captureFailed("Missing writer")
}
let writerBox = UncheckedSendableBox(value: writerSnapshot) let writerBox = UncheckedSendableBox(value: writerSnapshot)
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
writerBox.value.finishWriting { writerBox.value.finishWriting {
@@ -231,8 +315,6 @@ final class ScreenRecordService: @unchecked Sendable {
} }
} }
} }
return outURL.path
} }
private nonisolated static func clampDurationMs(_ ms: Int?) -> Int { private nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
+1 -1
View File
@@ -1,4 +1,4 @@
import ClawdisKit import ClawdbotKit
import SwiftUI import SwiftUI
struct ScreenTab: View { struct ScreenTab: View {
+1 -1
View File
@@ -1,4 +1,4 @@
import ClawdisKit import ClawdbotKit
import SwiftUI import SwiftUI
import WebKit import WebKit
+30 -30
View File
@@ -1,4 +1,4 @@
import ClawdisKit import ClawdbotKit
import Network import Network
import Observation import Observation
import SwiftUI import SwiftUI
@@ -23,7 +23,7 @@ struct SettingsTab: View {
@AppStorage("talk.enabled") private var talkEnabled: Bool = false @AppStorage("talk.enabled") private var talkEnabled: Bool = false
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true @AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true @AppStorage("camera.enabled") private var cameraEnabled: Bool = true
@AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = ClawdisLocationMode.off.rawValue @AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = ClawdbotLocationMode.off.rawValue
@AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true @AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true @AppStorage("screen.preventSleep") private var preventSleep: Bool = true
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = "" @AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
@@ -36,7 +36,7 @@ struct SettingsTab: View {
@State private var connectStatus = ConnectStatusStore() @State private var connectStatus = ConnectStatusStore()
@State private var connectingBridgeID: String? @State private var connectingBridgeID: String?
@State private var localIPAddress: String? @State private var localIPAddress: String?
@State private var lastLocationModeRaw: String = ClawdisLocationMode.off.rawValue @State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -186,9 +186,9 @@ struct SettingsTab: View {
Section("Location") { Section("Location") {
Picker("Location Access", selection: self.$locationEnabledModeRaw) { Picker("Location Access", selection: self.$locationEnabledModeRaw) {
Text("Off").tag(ClawdisLocationMode.off.rawValue) Text("Off").tag(ClawdbotLocationMode.off.rawValue)
Text("While Using").tag(ClawdisLocationMode.whileUsing.rawValue) Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
Text("Always").tag(ClawdisLocationMode.always.rawValue) Text("Always").tag(ClawdbotLocationMode.always.rawValue)
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
@@ -202,7 +202,7 @@ struct SettingsTab: View {
Section("Screen") { Section("Screen") {
Toggle("Prevent Sleep", isOn: self.$preventSleep) Toggle("Prevent Sleep", isOn: self.$preventSleep)
Text("Keeps the screen awake while Clawdis is open.") Text("Keeps the screen awake while Clawdbot is open.")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -233,7 +233,7 @@ struct SettingsTab: View {
.onChange(of: self.locationEnabledModeRaw) { _, newValue in .onChange(of: self.locationEnabledModeRaw) { _, newValue in
let previous = self.lastLocationModeRaw let previous = self.lastLocationModeRaw
self.lastLocationModeRaw = newValue self.lastLocationModeRaw = newValue
guard let mode = ClawdisLocationMode(rawValue: newValue) else { return } guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return }
Task { Task {
let granted = await self.appModel.requestLocationPermissions(mode: mode) let granted = await self.appModel.requestLocationPermissions(mode: mode)
if !granted { if !granted {
@@ -312,8 +312,8 @@ struct SettingsTab: View {
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
} }
private var locationMode: ClawdisLocationMode { private var locationMode: ClawdbotLocationMode {
ClawdisLocationMode(rawValue: self.locationEnabledModeRaw) ?? .off ClawdbotLocationMode(rawValue: self.locationEnabledModeRaw) ?? .off
} }
private func appVersion() -> String { private func appVersion() -> String {
@@ -342,38 +342,38 @@ struct SettingsTab: View {
} }
private func currentCaps() -> [String] { private func currentCaps() -> [String] {
var caps = [ClawdisCapability.canvas.rawValue, ClawdisCapability.screen.rawValue] var caps = [ClawdbotCapability.canvas.rawValue, ClawdbotCapability.screen.rawValue]
let cameraEnabled = let cameraEnabled =
UserDefaults.standard.object(forKey: "camera.enabled") == nil UserDefaults.standard.object(forKey: "camera.enabled") == nil
? true ? true
: UserDefaults.standard.bool(forKey: "camera.enabled") : UserDefaults.standard.bool(forKey: "camera.enabled")
if cameraEnabled { caps.append(ClawdisCapability.camera.rawValue) } if cameraEnabled { caps.append(ClawdbotCapability.camera.rawValue) }
let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey) let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey)
if voiceWakeEnabled { caps.append(ClawdisCapability.voiceWake.rawValue) } if voiceWakeEnabled { caps.append(ClawdbotCapability.voiceWake.rawValue) }
return caps return caps
} }
private func currentCommands() -> [String] { private func currentCommands() -> [String] {
var commands: [String] = [ var commands: [String] = [
ClawdisCanvasCommand.present.rawValue, ClawdbotCanvasCommand.present.rawValue,
ClawdisCanvasCommand.hide.rawValue, ClawdbotCanvasCommand.hide.rawValue,
ClawdisCanvasCommand.navigate.rawValue, ClawdbotCanvasCommand.navigate.rawValue,
ClawdisCanvasCommand.evalJS.rawValue, ClawdbotCanvasCommand.evalJS.rawValue,
ClawdisCanvasCommand.snapshot.rawValue, ClawdbotCanvasCommand.snapshot.rawValue,
ClawdisCanvasA2UICommand.push.rawValue, ClawdbotCanvasA2UICommand.push.rawValue,
ClawdisCanvasA2UICommand.pushJSONL.rawValue, ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
ClawdisCanvasA2UICommand.reset.rawValue, ClawdbotCanvasA2UICommand.reset.rawValue,
ClawdisScreenCommand.record.rawValue, ClawdbotScreenCommand.record.rawValue,
] ]
let caps = Set(self.currentCaps()) let caps = Set(self.currentCaps())
if caps.contains(ClawdisCapability.camera.rawValue) { if caps.contains(ClawdbotCapability.camera.rawValue) {
commands.append(ClawdisCameraCommand.list.rawValue) commands.append(ClawdbotCameraCommand.list.rawValue)
commands.append(ClawdisCameraCommand.snap.rawValue) commands.append(ClawdbotCameraCommand.snap.rawValue)
commands.append(ClawdisCameraCommand.clip.rawValue) commands.append(ClawdbotCameraCommand.clip.rawValue)
} }
return commands return commands
@@ -391,7 +391,7 @@ struct SettingsTab: View {
do { do {
let statusStore = self.connectStatus let statusStore = self.connectStatus
let existing = KeychainStore.loadString( let existing = KeychainStore.loadString(
service: "com.clawdis.bridge", service: "com.clawdbot.bridge",
account: self.keychainAccount()) account: self.keychainAccount())
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
existing : existing :
@@ -419,7 +419,7 @@ struct SettingsTab: View {
if !token.isEmpty, token != existingToken { if !token.isEmpty, token != existingToken {
_ = KeychainStore.saveString( _ = KeychainStore.saveString(
token, token,
service: "com.clawdis.bridge", service: "com.clawdbot.bridge",
account: self.keychainAccount()) account: self.keychainAccount())
} }
@@ -465,7 +465,7 @@ struct SettingsTab: View {
do { do {
let statusStore = self.connectStatus let statusStore = self.connectStatus
let existing = KeychainStore.loadString( let existing = KeychainStore.loadString(
service: "com.clawdis.bridge", service: "com.clawdbot.bridge",
account: self.keychainAccount()) account: self.keychainAccount())
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
existing : existing :
@@ -493,7 +493,7 @@ struct SettingsTab: View {
if !token.isEmpty, token != existingToken { if !token.isEmpty, token != existingToken {
_ = KeychainStore.saveString( _ = KeychainStore.saveString(
token, token,
service: "com.clawdis.bridge", service: "com.clawdbot.bridge",
account: self.keychainAccount()) account: self.keychainAccount())
} }
@@ -30,7 +30,7 @@ struct VoiceWakeWordsSettingsView: View {
Text("Wake Words") Text("Wake Words")
} footer: { } footer: {
Text( Text(
"Clawdis reacts when any trigger appears in a transcription. " "Clawdbot reacts when any trigger appears in a transcription. "
+ "Keep them short to avoid false positives.") + "Keep them short to avoid false positives.")
} }
} }
+14 -9
View File
@@ -1,5 +1,5 @@
import AVFAudio import AVFAudio
import ClawdisKit import ClawdbotKit
import Foundation import Foundation
import Observation import Observation
import OSLog import OSLog
@@ -47,7 +47,7 @@ final class TalkModeManager: NSObject {
private var chatSubscribedSessionKeys = Set<String>() private var chatSubscribedSessionKeys = Set<String>()
private let logger = Logger(subsystem: "com.clawdis", category: "TalkMode") private let logger = Logger(subsystem: "com.clawdbot", category: "TalkMode")
func attachBridge(_ bridge: BridgeSession) { func attachBridge(_ bridge: BridgeSession) {
self.bridge = bridge self.bridge = bridge
@@ -288,9 +288,9 @@ final class TalkModeManager: NSObject {
self.chatSubscribedSessionKeys.insert(key) self.chatSubscribedSessionKeys.insert(key)
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)") self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
} catch { } catch {
self.logger self.logger.warning(
.warning( "chat.subscribe failed sessionKey=\(key, privacy: .public) " +
"chat.subscribe failed sessionKey=\(key, privacy: .public) err=\(error.localizedDescription, privacy: .public)") "err=\(error.localizedDescription, privacy: .public)")
} }
} }
@@ -340,7 +340,12 @@ final class TalkModeManager: NSObject {
"idempotencyKey": UUID().uuidString, "idempotencyKey": UUID().uuidString,
] ]
let data = try JSONSerialization.data(withJSONObject: payload) let data = try JSONSerialization.data(withJSONObject: payload)
let json = String(decoding: data, as: UTF8.self) guard let json = String(bytes: data, encoding: .utf8) else {
throw NSError(
domain: "TalkModeManager",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload"])
}
let res = try await bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30) let res = try await bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30)
let decoded = try JSONDecoder().decode(SendResponse.self, from: res) let decoded = try JSONDecoder().decode(SendResponse.self, from: res)
return decoded.runId return decoded.runId
@@ -523,9 +528,9 @@ final class TalkModeManager: NSObject {
self.lastPlaybackWasPCM = false self.lastPlaybackWasPCM = false
result = await self.mp3Player.play(stream: stream) result = await self.mp3Player.play(stream: stream)
} }
self.logger self.logger.info(
.info( "elevenlabs stream finished=\(result.finished, privacy: .public) " +
"elevenlabs stream finished=\(result.finished, privacy: .public) dur=\(Date().timeIntervalSince(started), privacy: .public)s") "dur=\(Date().timeIntervalSince(started), privacy: .public)s")
if !result.finished, let interruptedAt = result.interruptedAt { if !result.finished, let interruptedAt = result.interruptedAt {
self.lastInterruptedAtSeconds = interruptedAt self.lastInterruptedAtSeconds = interruptedAt
} }
Loaded 100 of 870 files, more files were not shown because too many files have changed in this diff. Show more