Compare commits

..
1150 changed files with 22535 additions and 124956 deletions

No files matched your search

-1
View File
@@ -1 +0,0 @@
* text=auto eol=lf
-28
View File
@@ -1,28 +0,0 @@
---
name: Bug report
about: Report a problem or unexpected behavior in Clawdbot.
title: "[Bug]: "
labels: bug
---
## Summary
What went wrong?
## Steps to reproduce
1.
2.
3.
## Expected behavior
What did you expect to happen?
## Actual behavior
What actually happened?
## Environment
- Clawdbot version:
- OS:
- Install method (pnpm/npx/docker/etc):
## Logs or screenshots
Paste relevant logs or add screenshots (redact secrets).
-8
View File
@@ -1,8 +0,0 @@
blank_issues_enabled: true
contact_links:
- name: Onboarding
url: https://discord.gg/clawd
about: New to Clawdbot? Join Discord for setup guidance from Krill in #help.
- name: Support
url: https://discord.gg/clawd
about: Get help from Krill and the community on Discord in #help.
-18
View File
@@ -1,18 +0,0 @@
---
name: Feature request
about: Suggest an idea or improvement for Clawdbot.
title: "[Feature]: "
labels: enhancement
---
## Summary
Describe the problem you are trying to solve or the opportunity you see.
## Proposed solution
What would you like Clawdbot to do?
## Alternatives considered
Any other approaches you have considered?
## Additional context
Links, screenshots, or related issues.
+10 -80
View File
@@ -6,7 +6,7 @@ on:
jobs:
checks:
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
@@ -52,100 +52,32 @@ jobs:
exit 1
- name: Setup Node.js
if: matrix.runtime == 'node'
uses: actions/setup-node@v4
with:
node-version: 24
check-latest: true
- name: Setup Bun
if: matrix.runtime == 'bun'
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# bun.sh downloads currently fail with:
# "Failed to list releases from GitHub: 401" -> "Unexpected HTTP response: 400"
bun-download-url: "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip"
- name: Runtime versions
run: |
node -v
npm -v
bun -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
- name: Enable corepack and pin pnpm
run: |
corepack enable
corepack prepare pnpm@10.23.0 --activate
pnpm -v
- name: Install dependencies
env:
CI: true
run: |
export PATH="$NODE_BIN:$PATH"
which node
node -v
pnpm -v
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
checks-windows:
runs-on: blacksmith-4vcpu-windows-2025
defaults:
run:
shell: bash
strategy:
fail-fast: false
matrix:
include:
- runtime: node
task: lint
command: pnpm lint
- runtime: node
task: test
command: pnpm test
- runtime: node
task: build
command: pnpm build
- runtime: node
task: protocol
command: pnpm protocol:check
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Checkout submodules (retry)
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
- name: Setup Node.js
- name: Setup Node.js (tooling for bun)
if: matrix.runtime == 'bun'
uses: actions/setup-node@v4
with:
node-version: 24
check-latest: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Runtime versions
run: |
node -v
npm -v
bun -v
if [ "${{ matrix.runtime }}" = "bun" ]; then bun -v; fi
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
@@ -412,7 +344,7 @@ jobs:
PY
android:
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
@@ -448,8 +380,6 @@ jobs:
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
accept-android-sdk-licenses: false
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
-37
View File
@@ -1,37 +0,0 @@
name: Workflow Sanity
on:
pull_request:
push:
jobs:
no-tabs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fail on tabs in workflow files
run: |
python - <<'PY'
from __future__ import annotations
import pathlib
import sys
root = pathlib.Path(".github/workflows")
bad: list[str] = []
for path in sorted(root.rglob("*.yml")):
if b"\t" in path.read_bytes():
bad.append(str(path))
for path in sorted(root.rglob("*.yaml")):
if b"\t" in path.read_bytes():
bad.append(str(path))
if bad:
print("Tabs found in workflow file(s):")
for path in bad:
print(f"- {path}")
sys.exit(1)
PY
-6
View File
@@ -3,8 +3,6 @@ node_modules
dist
*.bun-build
pnpm-lock.yaml
bun.lock
bun.lockb
coverage
.pnpm-store
.worktrees/
@@ -50,7 +48,3 @@ apps/ios/*.dSYM.zip
# provisioning profiles (local)
apps/ios/*.mobileprovision
.env
# Local untracked files
.local/
.vscode/
+1 -1
View File
@@ -48,4 +48,4 @@
--allman false
# Exclusions
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol,apps/macos/Sources/ClawdbotProtocol
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol
+16 -51
View File
@@ -5,19 +5,9 @@
- Tests: colocated `*.test.ts`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
## Docs Linking (Mintlify)
- Docs are hosted on Mintlify (docs.clawd.bot).
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
## Build, Test, and Development Commands
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
- Install deps: `pnpm install`
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
- Node remains supported for running built output (`dist/*`) and production installs.
- Run CLI in dev: `pnpm clawdbot ...` (tsx entry) or `pnpm dev` for `src/index.ts`.
- Type-check/build: `pnpm build` (tsc)
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
@@ -25,10 +15,8 @@
## Coding Style & Naming Conventions
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
- Formatting/linting via Biome; run `pnpm lint` before commits.
- Add brief code comments for tricky or non-obvious logic.
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
- Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` for CLI command, package/binary, paths, and config keys.
## Testing Guidelines
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
@@ -42,69 +30,46 @@
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- Group related changes; avoid bundling unrelated refactors.
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
- When working on an issue: reference the issue in the changelog entry.
- When merging a PR: leave a PR comment that explains exactly what we did and include the SHA hashes.
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
### PR Workflow (Review vs Land)
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
## Security & Configuration Tips
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
## Troubleshooting
- Rebrand/migration issues (Clawdis → Clawdbot) or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
## Agent-Specific Notes
- Vocabulary: "makeup" = "mac app".
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
- Gateway currently runs only as the menubar app (launchctl shows `application.com.steipete.clawdbot.debug.*`), there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than expecting `com.steipete.clawdbot`. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdbot`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; 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.
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/Clawdbot/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), `docs/platforms/mac/release.md` (APP_VERSION/APP_BUILD examples), Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
- Release signing/notary keys are managed outside the repo; follow internal release docs.
- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are expected in your environment (per internal release docs).
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks.
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested.
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) instead of manual conflict resolution.
- Notary key file lives at `~/Library/CloudStorage/Dropbox/Backup/AppStore/AuthKey_NJF3NFGTS3.p8` (Sparkle keys live under `~/Library/CloudStorage/Dropbox/Backup/Sparkle`).
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
- **Multi-agent safety:** when Peter says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When Peter says "commit", scope to your changes only. When Peter says "commit all", commit everything in grouped chunks.
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless Peter explicitly asks.
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
- 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 another machine, 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 Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
- Voice wake forwarding tips:
- Command template should stay `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 PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
- 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 `clawdbot send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
## Exclamation Mark Escaping Workaround
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot send` with messages containing exclamation marks, use heredoc syntax:
```bash
# WRONG - will send "Hello\\!" with backslash
clawdbot message send --to "+1234" --message 'Hello!'
clawdbot send --to "+1234" --message 'Hello!'
# CORRECT - use heredoc to avoid escaping
clawdbot message send --to "+1234" --message "$(cat <<'EOF'
clawdbot send --to "+1234" --message "$(cat <<'EOF'
Hello!
EOF
)"
+93 -180
View File
@@ -1,197 +1,110 @@
# 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
- macOS: add node bridge heartbeat pings to detect half-open sockets and reconnect cleanly. (#572) — thanks @ngutman
- Node bridge: harden keepalive + heartbeat handling (TCP keepalive, better disconnects, and keepalive config tests). (#577) — thanks @steipete
- Control UI: improve mobile responsiveness. (#558) — thanks @carlulsoe
- CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
- Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc
- Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro
- Discord: avoid category parent overrides for channel allowlists and refactor thread context helpers. (#588) — thanks @steipete
- Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow
- Commands: accept /models as an alias for /model.
- Commands: add `/usage` as an alias for `/status`. (#492) — thanks @lc0rp
- Commands: harden slash command registry and list text-only commands in `/commands`.
- Models/Auth: show per-agent auth candidates in `/model status`, and add `clawdbot models auth order {get,set,clear}` (per-agent auth rotation overrides). — thanks @steipete
- Debugging: add raw model stream logging flags and document gateway watch mode.
- Gateway: decode dns-sd escaped UTF-8 in discovery output and show scan progress immediately. — thanks @steipete
- Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled).
- CLI: move `clawdbot message` to subcommands (`message send|poll|…`), fold Discord/Slack/Telegram/WhatsApp tools into `message`, and require `--provider` unless only one provider is configured.
- CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging.
- Hooks: default hook agent delivery to true. (#533) — thanks @mcinteerj
- Hooks: normalize hook agent providers (aliases + msteams support).
- WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj
- iMessage: isolate group-ish threads by `chat_id` when `is_group=false` to avoid DM session bleed. (#535) — thanks @mdahmann
- Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223
- Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj
- Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles.
- Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence.
- Agent: enable adaptive context pruning by default for tool-result trimming.
- Doctor: check config/state permissions and offer to tighten them. — thanks @steipete
- Doctor/Daemon: audit supervisor configs, add --repair/--force flows, surface service config audits in daemon status, and document user vs system services. — thanks @steipete
- Doctor: repair gateway service entrypoint when switching between npm and git installs; add Docker e2e coverage. — thanks @steipete
- Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c
- Daemon: add KillMode=process to systemd units to avoid podman restart hangs. (#541) — thanks @ogulcancelik
- Doctor: run legacy state migrations in non-interactive mode without prompts.
- Cron: parse Telegram topic targets for isolated delivery. (#478) — thanks @nachoiacovino
- Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos
- Cron: allow Telegram delivery targets with topic/thread IDs (e.g. `-100…:topic:123`). (#474) — thanks @mitschabaude-bot
- Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos
- Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123
- Agents: strip empty assistant text blocks from session history to avoid Claude API 400s. (#210)
- Agents: scrub unsupported JSON Schema keywords from tool schemas for Cloud Code Assist API compatibility. (#567) — thanks @erikpr1994
- Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123
- Auto-reply: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1
- Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess
- Auto-reply: preserve spacing when stripping inline directives. (#539) — thanks @joshp123
- Auto-reply: relax reply tag parsing to allow whitespace. (#560) — thanks @mcinteerj
- Auto-reply: fix /status usage summary filtering for the active provider.
- Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj
- Status: compact /status with session token usage + estimated cost, add `/cost` per-response usage lines (tokens-only for OAuth).
- Status: show active auth profile and key snippet in /status.
- Status: show provider usage windows when auth uses token-based OAuth (e.g. Claude setup-token).
- Agent: promote `<think>`/`<thinking>` tag reasoning into structured thinking blocks so `/reasoning` works consistently for OpenAI-compat providers.
- macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy
- WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj
- Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini).
- Control UI: logs tab opens at the newest entries (bottom).
- Control UI: default to relative paths for control UI assets. (#569) — thanks @bjesuiter
- Control UI: add Docs link, remove chat composer divider, and add New session button.
- Control UI: link sessions list to chat view. (#471) — thanks @HazAT
- Sessions: support session `label` in store/list/UI and allow `sessions_send` lookup by label. (#570) — thanks @azade-c
- Control UI: show/patch per-session reasoning level and render extracted reasoning in chat.
- Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos
- Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow).
- Telegram: retry long-polling conflicts with backoff to avoid fatal exits.
- Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos
- WhatsApp: resolve @lid JIDs via Baileys mapping to unblock inbound messages. (#415)
- Pairing: replies now include sender ids for Discord/Slack/Signal/iMessage/WhatsApp; pairing list labels them explicitly.
- Messages: default inbound/outbound prefixes from the routed agent’s `identity.name` when set. (#578) — thanks @p6l-richard
- Signal: accept UUID-only senders for pairing/allowlists/routing when sourceNumber is missing. (#523) — thanks @neist
- Agent system prompt: avoid automatic self-updates unless explicitly requested.
- Onboarding: tighten QuickStart hint copy for configuring later.
- Onboarding: set Gemini 3 Pro as the default model for Gemini API key auth. (#489) — thanks @jonasjancarik
- Onboarding: avoid “token expired” for Codex CLI when expiry is heuristic.
- Onboarding: QuickStart jumps straight into provider selection with Telegram preselected when unset.
- Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker).
- Onboarding: clarify WhatsApp owner number prompt and label pairing phone number.
- Auto-reply: normalize routed replies to drop NO_REPLY and apply response prefixes.
- Commands: add /debug for runtime config overrides (memory-only).
- Daemon runtime: remove Bun from selection options.
- CLI: restore hidden `gateway-daemon` alias for legacy launchd configs.
- Onboarding/Configure: add OpenAI API key flow that stores in shared `~/.clawdbot/.env` for launchd; simplify Anthropic token prompt order.
- Configure/Onboarding: show Control UI docs with gateway reachability status and only offer to open when a gateway is detected; default model prompt now prefers Opus 4.5 for Anthropic auth.
- Control UI: show skill install progress + per-skill results, hide install once binaries present. (#445) — thanks @pkrmf
- Providers/Doctor: surface Discord privileged intent (Message Content) misconfiguration with actionable warnings.
- Providers/Doctor: warn when Telegram config expects unmentioned group messages but Bot API privacy mode is likely enabled; surface WhatsApp login/disconnect hints.
- Providers/Doctor: add last inbound/outbound activity timestamps in `providers status` and extend `--probe` with Discord channel permission + Telegram group membership audits.
- Docs: add provider troubleshooting index (`/providers/troubleshooting`) and link it from the main troubleshooting guide.
- Docs: clarify model allowlist errors and add safety notes for verbose/reasoning in groups.
- Docs: add Ansible installation guide. (#545) — thanks @pasogott
- Telegram: include the user id in DM pairing messages and label it clearly in `clawdbot pairing list --provider telegram`.
- Apps: refresh iOS/Android/macOS app icons for Clawdbot branding. (#521) — thanks @fishfisher
- Docs: expand parameter descriptions for agent/wake hooks. (#532) — thanks @mcinteerj
- Docs: add community showcase entries from Discord. (#476) — thanks @gupsammy
- TUI: refresh status bar after think/verbose/reasoning changes. (#519) — thanks @jdrhyne
- Status: show Verbose/Elevated only when enabled.
- Status: filter usage summary to the active model provider.
- Status: map model providers to usage sources so unrelated usage doesn’t appear.
- Status: fix Claude usage snapshots when `anthropic:default` is a setup-token lacking `user:profile` by preferring `anthropic:claude-cli`; optional claude.ai fallback via `CLAUDE_AI_SESSION_KEY` / `CLAUDE_WEB_COOKIE`.
- Commands: allow /elevated off in groups without a mention; keep /elevated on mention-gated.
- Commands: keep multi-directive messages from clearing directive handling.
- Commands: warn when /elevated runs in direct (unsandboxed) runtime.
- Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond.
- Commands: return /status in directive-only multi-line messages.
- Models: fall back to configured models when the provider catalog is unavailable.
- Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) — thanks @neist
- Agent: bypass Anthropic OAuth tool-name blocks by capitalizing built-ins and keeping pruning tool matching case-insensitive. (#553) — thanks @andrewting19
- Commands/Tools: disable /restart and gateway restart tool by default (enable with commands.restart=true).
- Gateway/CLI: add `clawdbot gateway discover` (Bonjour scan on `local.` + `clawdbot.internal.`) with `--timeout` and `--json`. — thanks @steipete
- Gateway/CLI: make `clawdbot gateway status` human-readable by default, add `--json`, and probe localhost + configured remote (warn on multiple gateways). — thanks @steipete
- Gateway/CLI: support remote loopback gateways via SSH tunnel in `clawdbot gateway status` (`--ssh` / `--ssh-auto`). — thanks @steipete
- Gateway/Discovery: include `gatewayPort`/`sshPort`/`cliPath` in wide-area Bonjour records, and add a tailnet DNS fallback for `gateway discover` when split DNS isn’t configured. — thanks @steipete
- CLI: add global `--no-color` (and respect `NO_COLOR=1`) to disable ANSI output. — thanks @steipete
- CLI: centralize lobster palette + apply it to onboarding/config prompts. — thanks @steipete
- Gateway/CLI: add `clawdbot gateway --dev/--reset` to auto-create a dev config/workspace with a robot identity (no BOOTSTRAP.md), and reset wipes config/creds/sessions/workspace (subcommand --dev no longer collides with global --dev profile). — thanks @steipete
- Configure: stop prompting to open the Control UI (still shown in onboarding). — thanks @steipete
- Configure: add wizard mode to remove a provider config block. — thanks @steipete
- Onboarding/TUI: prompt to start TUI (best option) when BOOTSTRAP.md exists and add `tui --message` to auto-send the first prompt. — thanks @steipete
- Telegram: suppress grammY getUpdates stack traces; log concise retry message instead. — thanks @steipete
- Gateway/CLI: allow dev profile (`clawdbot --dev`) to auto-create the dev config + workspace. — thanks @steipete
- Dev templates: ship C-3PO dev workspace defaults as docs templates and use them for dev bootstrap. — thanks @steipete
- Config: fix Minimax hosted onboarding to write `agents.defaults` and allow `msteams` as a heartbeat target. — thanks @steipete
- Discord: add channel/category management actions (create/edit/move/delete + category removal). (#487) - thanks @NicholasSpisak
### Fixes
- Android: tapping the foreground service notification brings the app to the front. (#179) — thanks @Syhids
- Cron tool passes `id` to the gateway for update/remove/run/runs (keeps `jobId` input). (#180) — thanks @adamgall
## 2026.1.8
## 2026.1.4
### Highlights
- Security: DMs locked down by default across providers; pairing-first + allowlist guidance.
- Sandbox: per-agent scope defaults + workspace access controls; tool/session isolation tuned.
- Agent loop: compaction, pruning, streaming, and error handling hardened.
- Providers: Telegram/WhatsApp/Discord/Slack reliability, threading, reactions, media, and retries improved.
- Control UI: logs tab, streaming stability, focus mode, and large-output rendering fixes.
- CLI/Gateway/Doctor: daemon/logs/status, auth migration, and diagnostics significantly expanded.
- Rename completion: all CLIs, paths, bundle IDs, env vars, and docs standardized on **Clawdbot**.
- Agent-to-agent relay: `sessions_send` ping‑pong with `REPLY_SKIP` plus announce step with `ANNOUNCE_SKIP`.
- Gateway quality-of-life: config hot reload, port config support, and Control UI base paths.
- Sandbox additions: per-session Docker sandbox with hardened limits + optional sandboxed Chromium.
- New node capability: `location.get` across macOS/iOS/Android (CLI + tools).
- Models CLI: scan OpenRouter free models (tools/images), manage aliases/fallbacks, and show last-used model in status.
### Breaking
- **SECURITY (update ASAP):** inbound DMs are now **locked down by default** on Telegram/WhatsApp/Signal/iMessage/Discord/Slack.
- Previously, if you didn’t configure an allowlist, your bot could be **open to anyone** (especially discoverable Telegram bots).
- New default: DM pairing (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`).
- To keep old “open to everyone” behavior: set `dmPolicy="open"` and include `"*"` in the relevant `allowFrom` (Discord/Slack: `discord.dm.allowFrom` / `slack.dm.allowFrom`).
- Approve requests via `clawdbot pairing list --provider <provider>` + `clawdbot pairing approve --provider <provider> <code>`.
- Sandbox: default `agent.sandbox.scope` to `"agent"` (one container/workspace per agent). Use `"session"` for per-session isolation; `"shared"` disables cross-session isolation.
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only).
- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup.
- Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context.
- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior.
- Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides).
- CLI: remove `update`, `gateway-daemon`, `gateway {install|uninstall|start|stop|restart|daemon status|wake|send|agent}`, and `telegram` commands; move `login/logout` to `providers login/logout` (top-level aliases hidden); use `daemon` for service control, `send`/`agent`/`wake` for RPC, and `nodes canvas` for canvas ops.
- 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
- **CLI/Gateway/Doctor:** daemon runtime selection + improved logs/status/health/errors; auth/password handling for local CLI; richer close/timeout details; auto-migrate legacy config/sessions/state; integrity checks + repair prompts; `--yes`/`--non-interactive`; `--deep` gateway scans; better restart/service hints.
- **Agent loop + compaction:** compaction/pruning tuning, overflow handling, safer bootstrap context, and per-provider threading/confirmations; opt-in tool-result pruning + compact tracking.
- **Sandbox + tools:** per-agent sandbox overrides, workspaceAccess controls, session tool visibility, tool policy overrides, process isolation, and tool schema/timeout/reaction unification.
- **Providers (Telegram/WhatsApp/Discord/Slack/Signal/iMessage):** retry/backoff, threading, reactions, media groups/attachments, mention gating, typing behavior, and error/log stability; long polling + forum topic isolation for Telegram.
- **Gateway/CLI UX:** `clawdbot logs`, cron list colors/aliases, docs search, agents list/add/delete flows, status usage snapshots, runtime/auth source display, and `/status`/commands auth unification.
- **Control UI/Web:** logs tab, focus mode polish, config form resilience, streaming stability, tool output caps, windowed chat history, and reconnect/password URL auth.
- **macOS/Android/TUI/Build:** macOS gateway races, QR bundling, JSON5 config safety, Voice Wake hardening; Android EXIF rotation + APK naming/versioning; TUI key handling; tooling/bundling fixes.
- **Packaging/compat:** npm dist folder coverage, Node 25 qrcode-terminal import fixes, Bun/Playwright/WebSocket patches, and Docker Bun install.
- **Docs:** new FAQ/ClawdHub/config examples/showcase entries and clarified auth, sandbox, and systemd docs.
- Doctor migrates legacy Clawdis config/service installs and normalizes sandbox Docker names.
- Doctor checks sandbox image availability and offers to build or fall back to legacy images.
- Presence beacons keep node lists fresh; Instances view stays accurate.
- Block streaming/chunking reliability (Telegram/Discord ordering, fewer duplicates).
- WhatsApp GIF playback for MP4-based GIFs.
- Onboarding + Control UI basePath handling fixes and UI polish.
- Clearer tool summaries, reduced log noise, and safer watchdog/queue behavior.
- Canvas host watcher resilience; build and packaging edge cases cleaned up.
### Maintenance
- Skills additions (Himalaya email, CodexBar, 1Password).
- Dependency refreshes (pi-* stack, Slack SDK, discord-api-types, file-type, zod, Biome, Vite).
- Refactors: centralized group allowlist/mention policy; lint/import cleanup; switch tsx → bun for TS execution.
### Docs
- Sandbox setup, hot reload, port config, and session announce step coverage.
- Skills and onboarding clarifications + additional examples.
## 2026.1.5
## 2026.1.3 (beta 5)
### Breaking
- Skills config moved under `skills.*` (new `skills.entries`, `skills.allowBundled`).
- Group session keys now `surface:group:<id>` / `surface:channel:<id>`; legacy `group:*` removed.
- Discord config refactor; `discord.allowFrom` + `discord.requireMention` removed.
- Discord/Telegram require `enabled: true` in config when using env tokens.
- Routing `allowFrom`/mention settings moved to per-surface group settings.
### Highlights
- Models: add image-specific model config (`agent.imageModel` + fallbacks) and scan support.
- Agent tools: new `image` tool routed to the image model (when configured).
- Config: default model shorthands (`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`).
- Docs: document built-in model shorthands + precedence (user config wins).
- Bun: optional local install/build workflow without maintaining a Bun lockfile (see `docs/bun.md`).
- Talk Mode (continuous voice) with ElevenLabs TTS on macOS/iOS/Android.
- Discord: expanded tool actions, richer routing, and threaded reply tags.
- Auto-reply queue modes + session model overrides; TUI upgrades.
- Nix mode (declarative config) and Docker setup flow.
- Onboarding wizard + configure/doctor/update flows.
- Signal + iMessage providers; new skills (Trello, Things, Notes/Reminders, tmux coding).
- Browser tooling upgrades (remote CDP, no-sandbox, profiles).
### Fixes
- Control UI: render Markdown in tool result cards.
- Control UI: prevent overlapping action buttons in Discord guild rules on narrow layouts.
- Android: tapping the foreground service notification brings the app to the front. (#179) — thanks @Syhids
- Cron tool uses `id` for update/remove/run/runs (aligns with gateway params). (#180) — thanks @adamgall
- Control UI: chat view uses page scroll with sticky header/sidebar and fixed composer (no inner scroll frame).
- macOS: treat location permission as always-only to avoid iOS-only enums. (#165) — thanks @Nachx639
- macOS: make generated gateway protocol models `Sendable` for Swift 6 strict concurrency. (#195) — thanks @andranik-sahakyan
- macOS: bundle QR code renderer modules so DMG gateway boot doesn't crash on missing qrcode-terminal vendor files.
- macOS: parse JSON5 config safely to avoid wiping user settings when comments are present.
- WhatsApp: suppress typing indicator during heartbeat background tasks. (#190) — thanks @mcinteerj
- WhatsApp: mark offline history sync messages as read without auto-reply. (#193) — thanks @mcinteerj
- Discord: avoid duplicate replies when a provider emits late streaming `text_end` events (OpenAI/GPT).
- CLI: use tailnet IP for local gateway calls when bind is tailnet/auto (fixes #176).
- Env: load global `$CLAWDBOT_STATE_DIR/.env` (`~/.clawdbot/.env`) as a fallback after CWD `.env`.
- Env: optional login-shell env fallback (opt-in; imports expected keys without overriding existing env).
- Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas).
- Onboarding: when running from source, auto-build missing Control UI assets (`bun run ui:build`).
- Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed).
- Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off.
- Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events.
- Commands: unify /status (inline) and command auth across providers; group bypass for authorized control commands; remove Discord /clawd slash handler.
- CLI: run `clawdbot agent` via the Gateway by default; use `--local` to force embedded mode.
- macOS codesign/TCC hardening and menu/UI stability improvements.
- Streaming/typing fixes; per-provider chunk limit tuning.
- Remote gateway auth + token handling tightened.
- Camera capture reliability and media sizing fixes.
## 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
- Packaging fixes, heartbeat cleanup, WhatsApp reconnect reliability.
- macOS menu/Chat UI polish and presence reporting fixes.
## 2025.12.21 (beta 2)
### Highlights
- Bundled gateway packaging + DMG distribution pipeline.
- Skills platform (bundled/managed/workspace) with install gating + UI.
- Onboarding polish and agent UX improvements.
- Canvas host served from Gateway; browser control simplification.
## 2025.12.19 (beta 1)
### Highlights
- First Clawdbot release: Gateway WS control plane + optional Bridge.
- macOS menu bar companion app with Voice Wake + WebChat.
- iOS node pairing with Canvas surface.
- WhatsApp groups, thinking/verbose directives, health/status tooling.
### Breaking
- Switched to Pi-only agent runtime; legacy providers removed.
- Gateway became the single source of truth (no ad-hoc direct sends).
## 2025.12.05–2025.12.03 (pre-Clawdbot)
### Highlights
- Pi-only agent path and web-only gateway workflow.
- Thinking/verbose directives, group chat support, and heartbeat controls.
- `clawdbot agent` CLI added; session tables and health reporting.
## 2025.11.28–2025.11.25 (early web-only)
- Heartbeat CLI + interval handling.
- Media MIME sniffing, size caps, and timeout fallbacks.
- Web provider reconnects and early stability fixes.
-4
View File
@@ -1,9 +1,5 @@
FROM node:22-bookworm
# Install Bun (required for build scripts)
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}"
RUN corepack enable
WORKDIR /app
+147 -322
View File
@@ -16,310 +16,213 @@
</p>
**Clawdbot** is a *personal AI assistant* you run on your own devices.
It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, iMessage, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
[Website](https://clawdbot.com) · [Docs](https://docs.clawd.bot) · [Getting Started](https://docs.clawd.bot/getting-started) · [Updating](https://docs.clawd.bot/updating) · [Showcase](https://docs.clawd.bot/showcase) · [FAQ](https://docs.clawd.bot/faq) · [Wizard](https://docs.clawd.bot/wizard) · [Nix](https://github.com/clawdbot/nix-clawdbot) · [Docker](https://docs.clawd.bot/docker) · [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 (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
Works with npm, pnpm, or bun.
New install? Start here: [Getting started](https://docs.clawd.bot/getting-started)
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**.
**Subscriptions (OAuth):**
- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max)
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.clawd.bot/onboarding).
## Models (selection + auth)
- Models config + CLI: [Models](https://docs.clawd.bot/models)
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.clawd.bot/model-failover)
## Install (recommended)
Runtime: **Node ≥22**.
```bash
npm install -g clawdbot@latest
# or: pnpm add -g clawdbot@latest
clawdbot onboard --install-daemon
```
The wizard installs the Gateway daemon (launchd/systemd user service) so it stays running.
## Quick start (TL;DR)
Runtime: **Node ≥22**.
Full beginner guide (auth, pairing, providers): [Getting started](https://docs.clawd.bot/getting-started)
```bash
clawdbot onboard --install-daemon
clawdbot gateway --port 18789 --verbose
# Send a message
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
clawdbot agent --message "Ship checklist" --thinking high
```
Upgrading? [Updating guide](https://docs.clawd.bot/updating) (and run `clawdbot doctor`).
## From source (development)
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
```bash
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
pnpm install
pnpm ui:build # auto-installs UI deps on first run
pnpm build
pnpm clawdbot onboard --install-daemon
# Dev loop (auto-reload on TS changes)
pnpm gateway:watch
```
Note: `pnpm clawdbot ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `clawdbot` binary.
## Security defaults (DM access)
Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted input**.
Full security guide: [Security](https://docs.clawd.bot/security)
Default behavior on Telegram/WhatsApp/Signal/iMessage/Discord/Slack:
- **DM pairing** (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
- Approve with: `clawdbot pairing approve --provider <provider> <code>` (then the sender is added to a local allowlist store).
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the provider allowlist (`allowFrom` / `discord.dm.allowFrom` / `slack.dm.allowFrom`).
Run `clawdbot doctor` to surface risky/misconfigured DM policies.
Using Claude Pro/Max subscription? See `docs/onboarding.md` for the Anthropic OAuth setup.
## Highlights
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, providers, tools, and events.
- **[Multi-provider inbox](https://docs.clawd.bot/surface)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
- **[Multi-agent routing](https://docs.clawd.bot/configuration)** — route inbound providers/accounts/peers to isolated agents (workspaces + per-agent sessions).
- **[Voice Wake](https://docs.clawd.bot/voicewake) + [Talk Mode](https://docs.clawd.bot/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
- **[Live Canvas](https://docs.clawd.bot/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui).
- **[First-class tools](https://docs.clawd.bot/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
- **[Companion apps](https://docs.clawd.bot/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes).
- **[Onboarding](https://docs.clawd.bot/wizard) + [skills](https://docs.clawd.bot/skills)** — wizard-driven setup with bundled/managed/workspace skills.
- **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](https://docs.clawd.bot/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawd.bot/web), and [Canvas host](https://docs.clawd.bot/mac/canvas#canvas-a2ui).
- [CLI surface](https://docs.clawd.bot/agent-send): gateway, agent, send, [wizard](https://docs.clawd.bot/wizard), and [doctor](https://docs.clawd.bot/doctor).
- [Pi agent runtime](https://docs.clawd.bot/agent) in RPC mode with tool streaming and block streaming.
- [Session model](https://docs.clawd.bot/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawd.bot/groups).
- [Media pipeline](https://docs.clawd.bot/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/audio).
- 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.
### Providers
- [Providers](https://docs.clawd.bot/surface): [WhatsApp](https://docs.clawd.bot/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/telegram) (grammY), [Slack](https://docs.clawd.bot/slack) (Bolt), [Discord](https://docs.clawd.bot/discord) (discord.js), [Signal](https://docs.clawd.bot/signal) (signal-cli), [iMessage](https://docs.clawd.bot/imessage) (imsg), [WebChat](https://docs.clawd.bot/webchat).
- [Group routing](https://docs.clawd.bot/group-messages): mention gating, reply tags, per-provider chunking and routing. Provider rules: [Providers](https://docs.clawd.bot/surface).
### 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](https://docs.clawd.bot/macos): menu bar control plane, [Voice Wake](https://docs.clawd.bot/voicewake)/PTT, [Talk Mode](https://docs.clawd.bot/talk) overlay, [WebChat](https://docs.clawd.bot/webchat), debug tools, [remote gateway](https://docs.clawd.bot/remote) control.
- [iOS node](https://docs.clawd.bot/ios): [Canvas](https://docs.clawd.bot/mac/canvas), [Voice Wake](https://docs.clawd.bot/voicewake), [Talk Mode](https://docs.clawd.bot/talk), camera, screen recording, Bonjour pairing.
- [Android node](https://docs.clawd.bot/android): [Canvas](https://docs.clawd.bot/mac/canvas), [Talk Mode](https://docs.clawd.bot/talk), camera, screen recording, optional SMS.
- [macOS node mode](https://docs.clawd.bot/nodes): system.run/notify + canvas/camera exposure.
- 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](https://docs.clawd.bot/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
- [Canvas](https://docs.clawd.bot/mac/canvas): [A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
- [Nodes](https://docs.clawd.bot/nodes): camera snap/clip, screen record, [location.get](https://docs.clawd.bot/location-command), notifications.
- [Cron + wakeups](https://docs.clawd.bot/cron); [webhooks](https://docs.clawd.bot/webhook); [Gmail Pub/Sub](https://docs.clawd.bot/gmail-pubsub).
- [Skills platform](https://docs.clawd.bot/skills): bundled, managed, and workspace skills with install gating + UI.
### Runtime + safety
- [Provider routing](https://docs.clawd.bot/provider-routing), [retry policy](https://docs.clawd.bot/retry), and [streaming/chunking](https://docs.clawd.bot/streaming).
- [Presence](https://docs.clawd.bot/presence), [typing indicators](https://docs.clawd.bot/typing-indicators), and [usage tracking](https://docs.clawd.bot/usage-tracking).
- [Models](https://docs.clawd.bot/models), [model failover](https://docs.clawd.bot/model-failover), and [session pruning](https://docs.clawd.bot/session-pruning).
- [Security](https://docs.clawd.bot/security) and [troubleshooting](https://docs.clawd.bot/troubleshooting).
- 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](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/webchat) served directly from the Gateway.
- [Tailscale Serve/Funnel](https://docs.clawd.bot/tailscale) or [SSH tunnels](https://docs.clawd.bot/remote) with token/password auth.
- [Nix mode](https://docs.clawd.bot/nix) for declarative config; [Docker](https://docs.clawd.bot/docker)-based installs.
- [Doctor](https://docs.clawd.bot/doctor) migrations, [logging](https://docs.clawd.bot/logging).
- 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)
```
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
│
▼
Your surfaces
│
▼
┌───────────────────────────────┐
│ Gateway │ ws://127.0.0.1:18789
│ (control plane) │ bridge: tcp://0.0.0.0:18790
│ (control plane) │ tcp://0.0.0.0:18790 (optional Bridge)
└──────────────┬────────────────┘
│
├─ Pi agent (RPC)
├─ CLI (clawdbot …)
├─ WebChat UI
├─ macOS app
└─ iOS/Android nodes
├─ WebChat (browser)
├─ macOS app (Clawdbot.app)
└─ iOS node (Canvas + voice)
```
## Key subsystems
## Quick start (from source)
- **[Gateway WebSocket network](https://docs.clawd.bot/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawd.bot/gateway)).
- **[Tailscale exposure](https://docs.clawd.bot/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawd.bot/remote)).
- **[Browser control](https://docs.clawd.bot/browser)** — clawd‑managed Chrome/Chromium with CDP control.
- **[Canvas + A2UI](https://docs.clawd.bot/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui)).
- **[Voice Wake](https://docs.clawd.bot/voicewake) + [Talk Mode](https://docs.clawd.bot/talk)** — always‑on speech and continuous conversation.
- **[Nodes](https://docs.clawd.bot/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
Runtime: **Node ≥22** + **pnpm**.
## Tailscale access (Gateway dashboard)
```bash
pnpm install
pnpm build
pnpm ui:build
Clawdbot can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`:
# Recommended: run the onboarding wizard
pnpm clawdbot onboard
- `off`: no Tailscale automation (default).
- `serve`: tailnet-only HTTPS via `tailscale serve` (uses Tailscale identity headers by default).
- `funnel`: public HTTPS via `tailscale funnel` (requires shared password auth).
# Link WhatsApp (stores creds in ~/.clawdbot/credentials)
pnpm clawdbot login
Notes:
- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (Clawdbot enforces this).
- Serve can be forced to require a password by setting `gateway.auth.mode: "password"` or `gateway.auth.allowTailscale: false`.
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
# Start the gateway
pnpm clawdbot gateway --port 18789 --verbose
Details: [Tailscale guide](https://docs.clawd.bot/tailscale) · [Web surfaces](https://docs.clawd.bot/web)
# Dev loop (auto-reload on TS changes)
pnpm gateway:watch
## Remote Gateway (Linux is great)
# Send a message
pnpm clawdbot send --to +1234567890 --message "Hello from Clawdbot"
It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed.
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Discord)
pnpm clawdbot agent --message "Ship checklist" --thinking high
```
- **Gateway host** runs the bash tool and provider connections by default.
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
In short: bash runs where the Gateway lives; device actions run where the device lives.
Details: [Remote access](https://docs.clawd.bot/remote) · [Nodes](https://docs.clawd.bot/nodes) · [Security](https://docs.clawd.bot/security)
## macOS permissions via the Gateway protocol
The macOS app can run in **node mode** and advertises its capabilities + permission map over the Gateway WebSocket (`node.list` / `node.describe`). Clients can then execute local actions via `node.invoke`:
- `system.run` runs a local command and returns stdout/stderr/exit code; set `needsScreenRecording: true` to require screen-recording permission (otherwise you’ll get `PERMISSION_MISSING`).
- `system.notify` posts a user notification and fails if notifications are denied.
- `canvas.*`, `camera.*`, `screen.record`, and `location.get` are also routed via `node.invoke` and follow TCC permission status.
Elevated bash (host permissions) is separate from macOS TCC:
- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted.
- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd.bot/macos) · [Gateway protocol](https://docs.clawd.bot/architecture)
## Agent to Agent (sessions_* tools)
- Use these to coordinate work across sessions without jumping between chat surfaces.
- `sessions_list` — discover active sessions (agents) and their metadata.
- `sessions_history` — fetch transcript logs for a session.
- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
Details: [Session tools](https://docs.clawd.bot/session-tool)
## Skills registry (ClawdHub)
ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can search for skills automatically and pull in new ones as needed.
[ClawdHub](https://ClawdHub.com)
If you run from source, prefer `pnpm clawdbot …` (not global `clawdbot`).
## Chat commands
Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):
Send these in WhatsApp/Telegram/WebChat (group commands are owner-only):
- `/status` — compact session status (model + tokens, cost when available)
- `/status` — health + session info (group shows activation mode)
- `/new` or `/reset` — reset the session
- `/compact` — compact session context (summary)
- `/think <level>` — off|minimal|low|medium|high
- `/verbose on|off`
- `/cost on|off` — append per-response token/cost usage lines
- `/restart` — restart the gateway (owner-only in groups)
- `/activation mention|always` — group activation toggle (groups only)
## macOS app (optional)
## Architecture
The Gateway alone delivers a great experience. All apps are optional and add extra features.
### TypeScript Gateway (src/gateway/server.ts)
- **Single HTTP+WS server** on `ws://127.0.0.1:18789` (bind policy: loopback/lan/tailnet/auto). The first frame must be `connect`; AJV validates frames against TypeBox schemas (`src/gateway/protocol`).
- **Single source of truth** for sessions, providers, cron, voice wake, and presence. Methods cover `send`, `agent`, `chat.*`, `sessions.*`, `config.*`, `cron.*`, `voicewake.*`, `node.*`, `system-*`, `wake`.
- **Events + snapshot**: handshake returns a snapshot (presence/health) and declares event types; runtime events include `agent`, `chat`, `presence`, `tick`, `health`, `heartbeat`, `cron`, `node.pair.*`, `voicewake.changed`, `shutdown`.
- **Idempotency & safety**: `send`/`agent`/`chat.send` require idempotency keys with a TTL cache (5 min, cap 1000) to avoid double‑sends on reconnects; payload sizes are capped per connection.
- **Bridge for nodes**: optional TCP bridge (`src/infra/bridge/server.ts`) is newline‑delimited JSON frames (`hello`, pairing, RPC, `invoke`); node connect/disconnect is surfaced into presence.
- **Control UI + Canvas Host**: HTTP serves Control UI assets (default `/`, optional base path) and can host a live‑reload Canvas host for nodes (`src/canvas-host/server.ts`), injecting the A2UI postMessage bridge.
If you plan to build/run companion apps, initialize submodules first:
### iOS app (apps/ios)
- **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.
- **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 `clawdbot://` deep‑link interception (`ScreenController`).
- **Voice + deep links**: voice wake sends `voice.transcript` events; `clawdbot://agent` links emit `agent.request`. Voice wake triggers sync via `voicewake.get` + `voicewake.changed`.
```bash
git submodule update --init --recursive
./scripts/restart-mac.sh
```
## Companion apps
### macOS (Clawdbot.app) (optional)
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 (Clawdbot.app)
- Menu bar control for the Gateway and health.
- Voice Wake + push-to-talk overlay.
- WebChat + debug tools.
- Remote gateway control over SSH.
Note: signed builds required for macOS permissions to stick across rebuilds (see `docs/mac/permissions.md`).
Build/run: `./scripts/restart-mac.sh` (packages + launches).
### iOS node (optional)
### iOS node (internal)
- Pairs as a node via the Bridge.
- Voice trigger forwarding + Canvas surface.
- Controlled via `clawdbot nodes …`.
Runbook: [iOS connect](https://docs.clawd.bot/ios).
Runbook: `docs/ios/connect.md`.
### Android node (optional)
### Android node (internal)
- Pairs via the same Bridge + pairing flow as iOS.
- Exposes Canvas, Camera, and Screen capture commands.
- Runbook: [Android connect](https://docs.clawd.bot/android).
- Runbook: `docs/android/connect.md`.
## Agent workspace + skills
- Workspace root: `~/clawd` (configurable via `agents.defaults.workspace`).
- Workspace root: `~/clawd` (configurable via `agent.workspace`).
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
- Skills: `~/clawd/skills/<skill>/SKILL.md`.
## Configuration
Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
Minimal `~/.clawdbot/clawdbot.json`:
```json5
{
agent: {
model: "anthropic/claude-opus-4-5"
whatsapp: {
allowFrom: ["+1234567890"]
}
}
```
[Full configuration reference (all keys + examples).](https://docs.clawd.bot/configuration)
## Security model (important)
- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you.
- **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxing](https://docs.clawd.bot/docker) · [Sandbox config](https://docs.clawd.bot/configuration)
### [WhatsApp](https://docs.clawd.bot/whatsapp)
### WhatsApp
- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
- If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
### [Telegram](https://docs.clawd.bot/telegram)
### Telegram
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `telegram.allowFrom` or `telegram.webhookUrl` as needed.
- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`), `telegram.allowFrom`, or `telegram.webhookUrl` as needed.
```json5
{
@@ -329,14 +232,10 @@ Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxi
}
```
### [Slack](https://docs.clawd.bot/slack)
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `slack.botToken` + `slack.appToken`).
### [Discord](https://docs.clawd.bot/discord)
### Discord
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
- Optional: set `discord.slashCommand`, `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
```json5
{
@@ -346,19 +245,6 @@ Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxi
}
```
### [Signal](https://docs.clawd.bot/signal)
- Requires `signal-cli` and a `signal` config section.
### [iMessage](https://docs.clawd.bot/imessage)
- macOS only; Messages must be signed in.
- If `imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
### [WebChat](https://docs.clawd.bot/webchat)
- Uses the Gateway WebSocket; no separate WebChat port/config.
Browser control (optional):
```json5
@@ -373,100 +259,39 @@ Browser control (optional):
## Docs
Use these when you’re past the onboarding flow and want the deeper reference.
- [Start with the docs index for navigation and “what’s where.”](https://docs.clawd.bot)
- [Read the architecture overview for the gateway + protocol model.](https://docs.clawd.bot/architecture)
- [Use the full configuration reference when you need every key and example.](https://docs.clawd.bot/configuration)
- [Run the Gateway by the book with the operational runbook.](https://docs.clawd.bot/gateway)
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawd.bot/web)
- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawd.bot/remote)
- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawd.bot/wizard)
- [Wire external triggers via the webhook surface.](https://docs.clawd.bot/webhook)
- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/gmail-pubsub)
- [Learn the macOS menu bar companion details.](https://docs.clawd.bot/mac/menu-bar)
- [Platform guides: Windows (WSL2)](https://docs.clawd.bot/windows), [Linux](https://docs.clawd.bot/linux), [macOS](https://docs.clawd.bot/macos), [iOS](https://docs.clawd.bot/ios), [Android](https://docs.clawd.bot/android)
- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/troubleshooting)
- [Review security guidance before exposing anything.](https://docs.clawd.bot/security)
## Advanced docs (discovery + control)
- [Discovery + transports](https://docs.clawd.bot/discovery)
- [Bonjour/mDNS](https://docs.clawd.bot/bonjour)
- [Gateway pairing](https://docs.clawd.bot/gateway/pairing)
- [Remote gateway README](https://docs.clawd.bot/remote-gateway-readme)
- [Control UI](https://docs.clawd.bot/control-ui)
- [Dashboard](https://docs.clawd.bot/dashboard)
## Operations & troubleshooting
- [Health checks](https://docs.clawd.bot/health)
- [Gateway lock](https://docs.clawd.bot/gateway-lock)
- [Background process](https://docs.clawd.bot/background-process)
- [Browser troubleshooting (Linux)](https://docs.clawd.bot/browser-linux-troubleshooting)
- [Logging](https://docs.clawd.bot/logging)
## Deep dives
- [Agent loop](https://docs.clawd.bot/agent-loop)
- [Presence](https://docs.clawd.bot/presence)
- [TypeBox schemas](https://docs.clawd.bot/typebox)
- [RPC adapters](https://docs.clawd.bot/rpc)
- [Queue](https://docs.clawd.bot/queue)
## Workspace & skills
- [Skills config](https://docs.clawd.bot/skills-config)
- [Default AGENTS](https://docs.clawd.bot/AGENTS.default)
- [Templates: AGENTS](https://docs.clawd.bot/templates/AGENTS)
- [Templates: BOOTSTRAP](https://docs.clawd.bot/templates/BOOTSTRAP)
- [Templates: IDENTITY](https://docs.clawd.bot/templates/IDENTITY)
- [Templates: SOUL](https://docs.clawd.bot/templates/SOUL)
- [Templates: TOOLS](https://docs.clawd.bot/templates/TOOLS)
- [Templates: USER](https://docs.clawd.bot/templates/USER)
## Platform internals
- [macOS dev setup](https://docs.clawd.bot/mac/dev-setup)
- [macOS menu bar](https://docs.clawd.bot/mac/menu-bar)
- [macOS voice wake](https://docs.clawd.bot/mac/voicewake)
- [iOS node](https://docs.clawd.bot/ios)
- [Android node](https://docs.clawd.bot/android)
- [Windows (WSL2)](https://docs.clawd.bot/windows)
- [Linux app](https://docs.clawd.bot/linux)
- [`docs/index.md`](docs/index.md) (overview)
- [`docs/configuration.md`](docs/configuration.md)
- [`docs/group-messages.md`](docs/group-messages.md)
- [`docs/gateway.md`](docs/gateway.md)
- [`docs/web.md`](docs/web.md)
- [`docs/discovery.md`](docs/discovery.md)
- [`docs/agent.md`](docs/agent.md)
- [`docs/discord.md`](docs/discord.md)
- [`docs/wizard.md`](docs/wizard.md)
- Webhooks + external triggers: [`docs/webhook.md`](docs/webhook.md)
- Gmail hooks (email → wake): [`docs/gmail-pubsub.md`](docs/gmail-pubsub.md)
## Email hooks (Gmail)
- [docs.clawd.bot/gmail-pubsub](https://docs.clawd.bot/gmail-pubsub)
```bash
clawdbot hooks gmail setup --account you@gmail.com
clawdbot hooks gmail run
```
- [`docs/security.md`](docs/security.md)
- [`docs/troubleshooting.md`](docs/troubleshooting.md)
- [`docs/ios/connect.md`](docs/ios/connect.md)
- [`docs/clawdbot-mac.md`](docs/clawdbot-mac.md)
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
AI/vibe-coded PRs welcome! 🤖
## Clawd
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
by Peter Steinberger and the community.
Clawdbot was built for **Clawd**, a space lobster AI assistant.
- [clawd.me](https://clawd.me)
- [soul.md](https://soul.md)
- [steipete.me](https://steipete.me)
## Community
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
AI/vibe-coded PRs welcome! 🤖
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
Thanks to all clawtributors:
<p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a>
<a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
<a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a>
<a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a>
<a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
<a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a>
<a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a>
<a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a>
<a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a>
<a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/search?q=Tobias%20Bischoff"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a>
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a>
</p>
- https://clawd.me
- https://soul.md
- https://steipete.me
+11 -11
View File
@@ -13,7 +13,7 @@ public struct WakeWordSegment: Sendable, Equatable {
self.range = range
}
public var end: TimeInterval { start + duration }
public var end: TimeInterval { self.start + self.duration }
}
public struct WakeWordGateConfig: Sendable, Equatable {
@@ -62,10 +62,10 @@ public enum WakeWordGate {
segments: [WakeWordSegment],
config: WakeWordGateConfig)
-> WakeWordGateMatch? {
let triggerTokens = normalizeTriggers(config.triggers)
let triggerTokens = self.normalizeTriggers(config.triggers)
guard !triggerTokens.isEmpty else { return nil }
let tokens = normalizeSegments(segments)
let tokens = self.normalizeSegments(segments)
guard !tokens.isEmpty else { return nil }
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
@@ -89,7 +89,7 @@ public enum WakeWordGate {
}
guard let best else { return nil }
let command = commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
let command = self.commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
guard command.count >= config.minCommandLength else { return nil }
return WakeWordGateMatch(triggerEndTime: best.triggerEnd, postGap: best.gap, command: command)
@@ -111,7 +111,7 @@ public enum WakeWordGate {
}
let text = segments
.filter { $0.start >= threshold && !normalizeToken($0.text).isEmpty }
.filter { $0.start >= threshold && !self.normalizeToken($0.text).isEmpty }
.map(\.text)
.joined(separator: " ")
return text.trimmingCharacters(in: Self.whitespaceAndPunctuation)
@@ -121,7 +121,7 @@ public enum WakeWordGate {
guard !text.isEmpty else { return false }
let normalized = text.lowercased()
for trigger in triggers {
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation).lowercased()
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation).lowercased()
if token.isEmpty { continue }
if normalized.contains(token) { return true }
}
@@ -131,11 +131,11 @@ public enum WakeWordGate {
public static func stripWake(text: String, triggers: [String]) -> String {
var out = text
for trigger in triggers {
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation)
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation)
guard !token.isEmpty else { continue }
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
}
return out.trimmingCharacters(in: whitespaceAndPunctuation)
return out.trimmingCharacters(in: self.whitespaceAndPunctuation)
}
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
@@ -143,7 +143,7 @@ public enum WakeWordGate {
for trigger in triggers {
let tokens = trigger
.split(whereSeparator: { $0.isWhitespace })
.map { normalizeToken(String($0)) }
.map { self.normalizeToken(String($0)) }
.filter { !$0.isEmpty }
if tokens.isEmpty { continue }
output.append(TriggerTokens(tokens: tokens))
@@ -153,7 +153,7 @@ public enum WakeWordGate {
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
segments.compactMap { segment in
let normalized = normalizeToken(segment.text)
let normalized = self.normalizeToken(segment.text)
guard !normalized.isEmpty else { return nil }
return Token(
normalized: normalized,
@@ -166,7 +166,7 @@ public enum WakeWordGate {
private static func normalizeToken(_ token: String) -> String {
token
.trimmingCharacters(in: whitespaceAndPunctuation)
.trimmingCharacters(in: self.whitespaceAndPunctuation)
.lowercased()
}
@@ -1,6 +1,6 @@
import Foundation
import SwabbleKit
import Testing
import SwabbleKit
@Suite struct WakeWordGateTests {
@Test func matchRequiresGapAfterTrigger() {
+198 -41
View File
@@ -1,55 +1,212 @@
<?xml version="1.0" standalone="yes"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdis</title>
<title>Clawdbot</title>
<item>
<title>2026.1.5-3</title>
<pubDate>Mon, 05 Jan 2026 04:30:46 +0100</pubDate>
<title>2.0.0-beta5</title>
<pubDate>Sat, 03 Jan 2026 07:15:16 +0100</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>3095</sparkle:version>
<sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
<sparkle:version>2765</sparkle:version>
<sparkle:shortVersionString>2.0.0-beta5</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
<description><![CDATA[<h2>Clawdbot 2.0.0-beta5</h2>
<h3>Fixed</h3>
<ul>
<li>Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).</li>
<li>Agent runtime: update pi-mono dependencies to 0.31.1 (agent-core split).</li>
<li>Dependencies: bump to latest compatible versions (TypeBox, grammY, Zod, Rolldown, oxlint-tsgolint).</li>
<li>Tests: cover read tool image metadata + text output.</li>
<li>Tests: add queue mode coverage (collect/followup + directive parsing).</li>
</ul>
<h3>Breaking</h3>
<ul>
<li>Skills config schema moved under <code>skills.*</code>:</li>
</ul>
- <code>skillsLoad.extraDirs</code> → <code>skills.load.extraDirs</code>
- <code>skillsInstall.*</code> → <code>skills.install.*</code>
- per-skill config map moved to <code>skills.entries</code> (e.g. <code>skills.peekaboo.enabled</code> → <code>skills.entries.peekaboo.enabled</code>)
- new optional bundled allowlist: <code>skills.allowBundled</code> (only affects bundled skills)
<ul>
<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>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>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>clawdbot doctor</code> to migrate).</li>
</ul>
<h3>Features</h3>
<ul>
<li>Discord: expand <code>discord</code> tool actions (reactions, stickers, polls, threads, search, moderation gates) (#115) — thanks @thewilloftheshadow.</li>
<li>Discord/Telegram: add reply tags (<code>[[reply_to_current]]</code>, <code>[[reply_to:<id>]]</code>) with per-provider <code>replyToMode</code> (off|first|all) for native threaded replies.</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>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>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>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>Discord: add user-installed slash command handling with per-user sessions and auto-registration (#94) — thanks @thewilloftheshadow.</li>
<li>Discord: add DM enable/allowlist plus guild channel/user/guild allowlists with id/name matching.</li>
<li>Signal: add <code>signal-cli</code> JSON-RPC support for send/receive via the Signal provider.</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>UI: add Discord/Signal/iMessage connection panels in macOS + Control UI (thanks @thewilloftheshadow).</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: 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>Docker: add containerized gateway/CLI setup via Dockerfile, compose, and setup script (thanks @dan-dr).</li>
<li>Tests: add a Z.AI live test gate for smoke validation when keys are present.</li>
<li>macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs.</li>
<li>CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths.</li>
<li>CLI: add ASCII banner header to wizard entry points.</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 remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.</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>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 Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).</li>
<li>Tests: add a Docker-based onboarding E2E harness.</li>
<li>Tests: harden wizard E2E flows for reset, providers, skills, and remote non-interactive runs.</li>
<li>Browser tools: add remote CDP URL support, Linux launcher options (<code>executablePath</code>, <code>noSandbox</code>), and surface <code>cdpUrl</code> in status.</li>
<li>Skills: add tmux-first coding-agent skill + <code>requires.anyBins</code> gate for multi-CLI setup (thanks @sreekaransrinath).</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid <code>ERR_MODULE_NOT_FOUND</code> in Node 25 npx installs.</li>
<li>Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey).</li>
<li>macOS onboarding: add scrollable page gutter for overflowing content (#105) — thanks @thewilloftheshadow.</li>
<li>Chat UI: keep the chat scrolled to the latest message after switching sessions.</li>
<li>Chat UI: show rich session display names in Web Chat + SwiftUI + Android.</li>
<li>Auto-reply: stream completed reply blocks as soon as they finish (configurable default + break); skip empty tool-only blocks unless verbose.</li>
<li>Discord: avoid duplicate sends when block streaming is enabled (race with typing hook).</li>
<li>Providers: make outbound text chunk limits configurable via <code>*.textChunkLimit</code> (defaults remain 4000/Discord 2000).</li>
<li>CLI onboarding: persist gateway token in config so local CLI auth works; recommend auth Off unless you need multi-machine access.</li>
<li>Control UI: accept a <code>?token=</code> URL param to auto-fill Gateway auth; onboarding now opens the dashboard with token auth when configured.</li>
<li>Agent prompt: remove hardcoded user name in system prompt example.</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>WebChat: stream live updates for sessions even when runs start outside the chat UI.</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>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>Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines.</li>
<li>Logging/Signal: treat signal-cli "Failed …" lines as errors in gateway logs.</li>
<li>Discord: include recent guild context when replying to mentions and add <code>discord.historyLimit</code> to tune how many messages are captured.</li>
<li>Discord: include author tag + id in group context <code>[from:]</code> lines for ping-ready replies (thanks @thewilloftheshadow).</li>
<li>Discord: include replied-to message context when a Discord message references another message (thanks @thewilloftheshadow).</li>
<li>Discord: preserve newlines when stripping reply tags from agent output.</li>
<li>Gateway: fix TypeScript build by aligning hook mapping <code>channel</code> types and removing a dead Group DM branch in Discord monitor.</li>
<li>Skills: switch imsg installer to brew tap formula.</li>
<li>Skills: gate macOS-only skills by OS and surface block reasons in the Skills UI.</li>
<li>Onboarding: show skill descriptions in the macOS setup flow and surface clearer Gateway/skills error messages.</li>
<li>Onboarding: auto-verify Claude OAuth tokens, show “verified” when detected working, and avoid re-auth prompts unless verification fails.</li>
<li>CLI onboarding: include exit code + a useful one-line summary when skill dependency installs fail.</li>
<li>CLI onboarding: explain Tailscale exposure options (Off/Serve/Funnel) and colorize provider status (linked/configured/needs setup).</li>
<li>CLI onboarding: add provider primers (WhatsApp/Telegram/Discord/Signal) incl. Discord bot token setup steps.</li>
<li>CLI onboarding: allow skipping the “install missing skill dependencies” selection without canceling the wizard.</li>
<li>CLI onboarding: always prompt for WhatsApp <code>whatsapp.allowFrom</code> and print (optionally open) the Control UI URL when done.</li>
<li>CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode).</li>
<li>macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states.</li>
<li>macOS: keep config writes on the main actor to satisfy Swift concurrency rules.</li>
<li>macOS menu: show multi-line gateway error details, add an always-visible gateway row, avoid duplicate gateway status rows, suppress transient <code>cancelled</code> device refresh errors, and auto-recover the control channel on disconnect.</li>
<li>macOS menu: show session last-used timestamps in the list and add recent-message previews in session submenus.</li>
<li>macOS menu: tighten session row padding and time out session preview loading with cached fallback.</li>
<li>macOS: log health refresh failures and recovery to make gateway issues easier to diagnose.</li>
<li>macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b</li>
<li>macOS codesign: include camera entitlement so permission prompts work in the menu bar app.</li>
<li>Agent tools: bash tool supports real TTY via <code>stdinMode: "pty"</code> with node-pty, warning + fallback on load/start failure.</li>
<li>Agent tools: map <code>camera.snap</code> JPEG payloads to <code>image/jpeg</code> to avoid MIME mismatch errors.</li>
<li>Tests: cover <code>camera.snap</code> MIME mapping to prevent image/png vs image/jpeg mismatches.</li>
<li>macOS camera: wait for exposure/white balance to settle before capturing a snap to avoid dark images.</li>
<li>Camera snap: add <code>delayMs</code> parameter (default 2000ms on macOS) to improve exposure reliability.</li>
<li>Camera: add <code>camera.list</code> and optional <code>deviceId</code> selection for snaps/clips.</li>
<li>Tests: cover camera device selection params in CLI + agent tools.</li>
<li>macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b</li>
<li>macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b</li>
<li>macOS remote: route settings through gateway config and avoid local config reads in remote mode.</li>
<li>Telegram: align token resolution for cron/agent/CLI sends (env/config/tokenFile) to prevent isolated delivery failures (#76).</li>
<li>Telegram: honor per-group mention gating defaults/overrides via <code>telegram.groups</code> and <code>"*"</code> defaults (thanks @joshp123).</li>
<li>Chat UI: clear composer input immediately and allow clear while editing to prevent duplicate sends (#72) — thanks @hrdwdmrbl</li>
<li>Restart: use systemd on Linux (and report actual restart method) instead of always launchctl.</li>
<li>Gateway relay: detect Bun binaries via execPath to resolve packaged assets on macOS.</li>
<li>Cron: prevent <code>every</code> schedules without an anchor from firing in a tight loop (thanks @jamesgroat).</li>
<li>Docs: add manual OAuth setup for remote/headless deployments (#67) — thanks @wstock</li>
<li>Docs/agent tools: clarify that browser <code>wait</code> should be avoided by default and used only in exceptional cases.</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: 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>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>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>CLI: tighten onboarding prompt typing to keep bun builds green.</li>
<li>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.</li>
<li>macOS menu: add a Talk Mode action alongside the Open Dashboard/Chat/Canvas entries.</li>
<li>macOS Debug: hide “Restart Gateway” when the app won’t start a local gateway (remote mode / attach-only).</li>
<li>macOS Debug: add an icon for the App Logging submenu.</li>
<li>macOS Talk Mode: orb overlay refresh, ElevenLabs request logging, API key status in settings, and auto-select first voice when none is configured.</li>
<li>macOS Talk Mode: add hard timeout around ElevenLabs TTS synthesis to avoid getting stuck “speaking” forever on hung requests.</li>
<li>macOS Talk Mode: avoid stuck playback when the audio player never starts (fail-fast + watchdog).</li>
<li>macOS Talk Mode: fix audio stop ordering so disabling Talk Mode always stops in-flight playback.</li>
<li>macOS Talk Mode: throttle audio-level updates (avoid per-buffer task creation) to reduce CPU/task churn.</li>
<li>macOS Talk Mode: increase overlay window size so wave rings don’t clip; close button is hover-only and closer to the orb.</li>
<li>WebChat: preserve chat run ordering per session so concurrent runs don’t strand the typing indicator.</li>
<li>Talk Mode: fall back to system TTS when ElevenLabs is unavailable, returns non-audio, or playback fails (macOS/iOS/Android).</li>
<li>Talk Mode: stream PCM on macOS/iOS for lower latency (incremental playback); Android continues MP3 streaming.</li>
<li>Talk Mode: validate ElevenLabs v3 stability and latency tier directives before sending requests.</li>
<li>iOS/Android Talk Mode: auto-select the first ElevenLabs voice when none is configured.</li>
<li>ElevenLabs: add retry/backoff for 429/5xx and include content-type in errors for debugging.</li>
<li>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).</li>
<li>Talk Mode: treat history timestamps as seconds or milliseconds to avoid stale assistant picks (macOS/iOS/Android).</li>
<li>Chat UI: clear streaming/tool bubbles when external runs finish, preventing duplicate assistant bubbles.</li>
<li>Chat UI: user bubbles use <code>ui.seamColor</code> (fallback to a calmer default blue).</li>
<li>Android Chat UI: use <code>onPrimary</code> for user bubble text to preserve contrast (thanks @Syhids).</li>
<li>Control UI: sync sidebar navigation with the URL for deep-linking, and auto-scroll chat to the latest message.</li>
<li>Control UI: disable Web Chat + Talk when no iOS/Android node is connected; refreshed Web Chat styling and keyboard send.</li>
<li>Control UI: keep chat pinned to the latest message while typing/sending and restore drafts on send failures.</li>
<li>Control UI: soften chat bubble text opacity for calmer readability.</li>
<li>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.</li>
<li>macOS: bundle Control UI assets into the app relay so the packaged app can serve them (thanks @mbelinky).</li>
<li>Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android).</li>
<li>iOS Talk Mode: fix chat completion wait to time out even if no events arrive (prevents “Thinking…” hangs).</li>
<li>iOS Talk Mode: keep recognition running during playback to support interrupt-on-speech.</li>
<li>iOS Talk Mode: preserve directive voice/model overrides across config reloads and add ElevenLabs request timeouts.</li>
<li>iOS/Android Talk Mode: explicitly <code>chat.subscribe</code> when Talk Mode is active, so completion events arrive even if the Chat UI isn’t open.</li>
<li>Chat UI: refresh history when another client finishes a run in the same session, so Talk Mode + Voice Wake transcripts appear consistently.</li>
<li>Gateway: <code>voice.transcript</code> now also maps agent bus output to <code>chat</code> events, ensuring chat UIs refresh for voice-triggered runs.</li>
<li>Gateway: auto-migrate legacy config on startup (non-Nix); Nix mode hard-fails with a clear error when legacy keys are present.</li>
<li>iOS/Android: show a centered Talk Mode orb overlay while Talk Mode is enabled.</li>
<li>Gateway config: inject <code>talk.apiKey</code> from <code>ELEVENLABS_API_KEY</code>/shell profile so nodes can fetch it on demand.</li>
<li>Canvas A2UI: tag requests with <code>platform=android|ios|macos</code> and boost Android canvas background contrast.</li>
<li>iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first).</li>
<li>macOS menu: device list now uses <code>node.list</code> (devices only; no agent/tool presence entries).</li>
<li>macOS menu: device list now shows connected nodes only.</li>
<li>macOS menu: device rows now pack platform/version on the first line, and command lists wrap in submenus.</li>
<li>macOS menu: split device platform/version across first and second rows for better fit.</li>
<li>macOS Canvas: show remote control status in the debug overlay and log A2UI auto-nav decisions.</li>
<li>Canvas A2UI: polish the debug status HUD styling.</li>
<li>iOS node: fix ReplayKit screen recording crash caused by queue isolation assertions during capture.</li>
<li>iOS Talk Mode: avoid audio tap queue assertions when starting recognition.</li>
<li>macOS: use $HOME/Library/pnpm for SSH PATH exports (thanks @mbelinky).</li>
<li>macOS remote: harden SSH tunnel recovery/logging, honor <code>gateway.remote.url</code> port when forwarding, clarify gateway disconnect status, and add Debug menu tunnel reset.</li>
<li>iOS/Android nodes: bridge auto-connect refreshes stale tokens and settings now show richer bridge/device details.</li>
<li>macOS: bundle device model resources to prevent Instances crashes (thanks @mbelinky).</li>
<li>iOS/Android nodes: status pill now surfaces camera activity instead of overlay toasts.</li>
<li>iOS/Android/macOS nodes: camera snaps recompress to keep base64 payloads under 5 MB.</li>
<li>iOS/Android nodes: status pill now surfaces pairing, screen recording, voice wake, and foreground-required states.</li>
<li>iOS/Android nodes: avoid duplicating “Gateway reconnecting…” when the bridge is already connecting.</li>
<li>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.</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>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 Clawdbot sessions dir.</li>
<li>Tests: add Swift Testing coverage for camera errors and Kotest coverage for Android bridge endpoints.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-3/Clawdbot-2026.1.5-3.zip" length="160800596" type="application/octet-stream" sparkle:edSignature="P8U3nvIFpbGmRItT/NGPmJ/i370OMVvDHYQL/znYsLI0MrbGfXgMGEvR5A0uwW+cJevlX/hrJLiY51zo4rAMBg=="/>
</item>
<item>
<title>2026.1.5-3</title>
<pubDate>Mon, 05 Jan 2026 03:57:59 +0100</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>3091</sparkle:version>
<sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
<h3>Fixes</h3>
<ul>
<li>NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid <code>ERR_MODULE_NOT_FOUND</code> in Node 25 npx installs.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-3/Clawdbot-2026.1.5-3.zip" length="160797048" type="application/octet-stream" sparkle:edSignature="5KYFg0SW7liwLxLJbfzd2KsAxbX06gMH0rH/W3a4V0p4N48hjz4AsSrfFLdGZSnW+6XaJjC3MN6Ynh+l7kffDQ=="/>
</item>
<item>
<title>2026.1.5-2</title>
<pubDate>Mon, 05 Jan 2026 03:51:30 +0100</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>3089</sparkle:version>
<sparkle:shortVersionString>2026.1.5-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.5-2</h2>
<h3>Fixes</h3>
<ul>
<li>NPM package: include <code>dist/sessions</code> so <code>clawdbot agent</code> resolves session helpers in npx installs.</li>
<li>Node 25: avoid unsupported directory import by targeting <code>qrcode-terminal/vendor/QRCode/*.js</code> modules.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-2/Clawdbot-2026.1.5-2.zip" length="150250417" type="application/octet-stream" sparkle:edSignature="ntHNmwyHrv6cPk6NAKOT3AUkwdt5ZadrGU6mJK4GmVxi44uIMT3ZXluvnqK9SxXQwA0H0dXjiGMS/cg8NbgqDA=="/>
<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>
</channel>
</rss>
+4 -19
View File
@@ -1,5 +1,3 @@
import com.android.build.api.variant.impl.VariantOutputImpl
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
@@ -21,8 +19,8 @@ android {
applicationId = "com.clawdbot.android"
minSdk = 31
targetSdk = 36
versionCode = 20260109
versionName = "2026.1.9"
versionCode = 1
versionName = "2.0.0-beta3"
}
buildTypes {
@@ -56,19 +54,6 @@ android {
}
}
androidComponents {
onVariants { variant ->
variant.outputs
.filterIsInstance<VariantOutputImpl>()
.forEach { output ->
val versionName = output.versionName.orNull ?: "0"
val buildType = variant.buildType
val outputFileName = "clawdbot-${versionName}-${buildType}.apk"
output.outputFileName = outputFileName
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
@@ -83,7 +68,7 @@ dependencies {
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.activity:activity-compose:1.12.2")
implementation("androidx.webkit:webkit:1.15.0")
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
@@ -116,7 +101,7 @@ dependencies {
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
testImplementation("org.robolectric:robolectric:4.16")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.1")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.13.3")
}
tasks.withType<Test>().configureEach {
@@ -12,7 +12,7 @@
"element",
"node",
"nodeId",
"id",
"jobId",
"requestId",
"to",
"channelId",
@@ -136,10 +136,10 @@
"label": "add",
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
},
"update": { "label": "update", "detailKeys": ["id"] },
"remove": { "label": "remove", "detailKeys": ["id"] },
"run": { "label": "run", "detailKeys": ["id"] },
"runs": { "label": "runs", "detailKeys": ["id"] },
"update": { "label": "update", "detailKeys": ["jobId"] },
"remove": { "label": "remove", "detailKeys": ["jobId"] },
"run": { "label": "run", "detailKeys": ["jobId"] },
"runs": { "label": "runs", "detailKeys": ["jobId"] },
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
}
},
@@ -515,6 +515,7 @@ class NodeRuntime(context: Context) {
}
private fun hasBackgroundLocationPermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return true
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
@@ -5,10 +5,8 @@ import android.content.Context
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.util.Base64
import android.content.pm.PackageManager
import android.media.ExifInterface
import androidx.lifecycle.LifecycleOwner
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
@@ -88,19 +86,18 @@ class CameraCaptureManager(private val context: Context) {
provider.unbindAll()
provider.bindToLifecycle(owner, selector, capture)
val (bytes, orientation) = capture.takeJpegWithExif(context.mainExecutor())
val bytes = capture.takeJpegBytes(context.mainExecutor())
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
val rotated = rotateBitmapByExif(decoded, orientation)
val scaled =
if (maxWidth != null && maxWidth > 0 && rotated.width > maxWidth) {
if (maxWidth != null && maxWidth > 0 && decoded.width > maxWidth) {
val h =
(rotated.height.toDouble() * (maxWidth.toDouble() / rotated.width.toDouble()))
(decoded.height.toDouble() * (maxWidth.toDouble() / decoded.width.toDouble()))
.toInt()
.coerceAtLeast(1)
rotated.scale(maxWidth, h)
decoded.scale(maxWidth, h)
} else {
rotated
decoded
}
val maxPayloadBytes = 5 * 1024 * 1024
@@ -197,31 +194,6 @@ class CameraCaptureManager(private val context: Context) {
)
}
private fun rotateBitmapByExif(bitmap: Bitmap, orientation: Int): Bitmap {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(-90f)
matrix.postScale(-1f, 1f)
}
else -> return bitmap
}
val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
if (rotated !== bitmap) {
bitmap.recycle()
}
return rotated
}
private fun parseFacing(paramsJson: String?): String? =
when {
paramsJson?.contains("\"front\"") == true -> "front"
@@ -282,8 +254,7 @@ private suspend fun Context.cameraProvider(): ProcessCameraProvider =
)
}
/** Returns (jpegBytes, exifOrientation) so caller can rotate the decoded bitmap. */
private suspend fun ImageCapture.takeJpegWithExif(executor: Executor): Pair<ByteArray, Int> =
private suspend fun ImageCapture.takeJpegBytes(executor: Executor): ByteArray =
suspendCancellableCoroutine { cont ->
val file = File.createTempFile("clawdbot-snap-", ".jpg")
val options = ImageCapture.OutputFileOptions.Builder(file).build()
@@ -292,19 +263,13 @@ private suspend fun ImageCapture.takeJpegWithExif(executor: Executor): Pair<Byte
executor,
object : ImageCapture.OnImageSavedCallback {
override fun onError(exception: ImageCaptureException) {
file.delete()
cont.resumeWithException(exception)
}
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
try {
val exif = ExifInterface(file.absolutePath)
val orientation = exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
val bytes = file.readBytes()
cont.resume(Pair(bytes, orientation))
cont.resume(bytes)
} catch (e: Exception) {
cont.resumeWithException(e)
} finally {
@@ -1,12 +1,10 @@
package com.clawdbot.android.node
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.os.CancellationSignal
import androidx.core.content.ContextCompat
import java.time.Instant
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.Dispatchers
@@ -66,15 +64,6 @@ class LocationCaptureManager(private val context: Context) {
providers: List<String>,
maxAgeMs: Long?,
): Location? {
val fineOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val coarseOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (!fineOk && !coarseOk) {
throw IllegalStateException("LOCATION_PERMISSION_REQUIRED: grant Location permission")
}
val now = System.currentTimeMillis()
val candidates =
providers.mapNotNull { provider -> manager.getLastKnownLocation(provider) }
@@ -83,20 +72,12 @@ class LocationCaptureManager(private val context: Context) {
return freshest
}
@SuppressLint("MissingPermission")
private suspend fun requestCurrent(
manager: LocationManager,
providers: List<String>,
timeoutMs: Long,
): Location {
val fineOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val coarseOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (!fineOk && !coarseOk) {
throw IllegalStateException("LOCATION_PERMISSION_REQUIRED: grant Location permission")
}
val resolved =
providers.firstOrNull { manager.isProviderEnabled(it) }
?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available")
@@ -4,7 +4,6 @@ import android.content.Context
import android.hardware.display.DisplayManager
import android.media.MediaRecorder
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.util.Base64
import com.clawdbot.android.ScreenCaptureRequester
import kotlinx.coroutines.Dispatchers
@@ -66,7 +65,7 @@ class ScreenRecordManager(private val context: Context) {
val file = File.createTempFile("clawdbot-screen-", ".mp4")
if (includeAudio) ensureMicPermission()
val recorder = createMediaRecorder()
val recorder = MediaRecorder()
var virtualDisplay: android.hardware.display.VirtualDisplay? = null
try {
if (includeAudio) {
@@ -122,14 +121,6 @@ class ScreenRecordManager(private val context: Context) {
)
}
private fun createMediaRecorder(): MediaRecorder =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}
private suspend fun ensureMicPermission() {
val granted =
androidx.core.content.ContextCompat.checkSelfPermission(
@@ -39,7 +39,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ScreenShare
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
@@ -48,6 +47,7 @@ import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.RecordVoiceOver
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.ScreenShare
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -123,7 +123,7 @@ fun RootScreen(viewModel: MainViewModel) {
if (screenRecordActive) {
return@remember StatusActivity(
title = "Recording screen…",
icon = Icons.AutoMirrored.Filled.ScreenShare,
icon = Icons.Default.ScreenShare,
contentDescription = "Recording screen",
tint = androidx.compose.ui.graphics.Color.Red,
)
@@ -327,10 +327,11 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
// Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage.
settings.domStorageEnabled = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF)
}
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
} else {
disableForceDarkIfSupported(settings)
}
if (isDebuggable) {
Log.d("ClawdbotWebView", "userAgent: ${settings.userAgentString}")
@@ -413,12 +414,6 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
)
}
private fun disableForceDarkIfSupported(settings: WebSettings) {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) return
@Suppress("DEPRECATION")
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF)
}
private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
@JavascriptInterface
fun postMessage(payload: String?) {
@@ -133,7 +133,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
if (requestedMode != null) {
viewModel.setLocationMode(requestedMode)
if (requestedMode == LocationMode.Always) {
if (requestedMode == LocationMode.Always && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val backgroundOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
@@ -191,7 +191,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
PackageManager.PERMISSION_GRANTED
if (fineOk || coarseOk) {
viewModel.setLocationMode(targetMode)
if (targetMode == LocationMode.Always) {
if (targetMode == LocationMode.Always && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val backgroundOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
@@ -28,9 +28,9 @@ import androidx.compose.ui.unit.dp
fun StatusPill(
bridge: BridgeState,
voiceEnabled: Boolean,
activity: StatusActivity? = null,
onClick: () -> Unit,
modifier: Modifier = Modifier,
activity: StatusActivity? = null,
) {
Surface(
onClick = onClick,
@@ -714,7 +714,6 @@ class TalkModeManager(
systemTtsPendingId = null
}
@Suppress("OVERRIDE_DEPRECATION")
@Deprecated("Deprecated in Java")
override fun onError(utteranceId: String?) {
if (utteranceId == null) return
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 38 KiB

+2 -2
View File
@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.9</string>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>20260109</string>
<string>1</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
@@ -5,8 +5,7 @@ import Testing
@Test func errorDescriptionsAreStable() {
#expect(CameraController.CameraError.cameraUnavailable.errorDescription == "Camera unavailable")
#expect(CameraController.CameraError.microphoneUnavailable.errorDescription == "Microphone unavailable")
#expect(CameraController.CameraError.permissionDenied(kind: "Camera")
.errorDescription == "Camera permission denied")
#expect(CameraController.CameraError.permissionDenied(kind: "Camera").errorDescription == "Camera permission denied")
#expect(CameraController.CameraError.invalidParams("bad").errorDescription == "bad")
#expect(CameraController.CameraError.captureFailed("nope").errorDescription == "nope")
#expect(CameraController.CameraError.exportFailed("export").errorDescription == "export")
+2 -2
View File
@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.9</string>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>20260109</string>
<string>1</string>
</dict>
</plist>
@@ -20,3 +20,4 @@ import Testing
#expect(triggers == nil)
}
}
@@ -1,6 +1,6 @@
import Foundation
import SwabbleKit
import Testing
import SwabbleKit
@testable import Clawdbot
@Suite struct VoiceWakeManagerExtractCommandTests {
@@ -1,6 +1,6 @@
import Foundation
import SwabbleKit
import Testing
import SwabbleKit
@testable import Clawdbot
@Suite(.serialized) struct VoiceWakeManagerStateTests {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -1,4 +1,3 @@
import ClawdbotProtocol
import SwiftUI
@MainActor
@@ -0,0 +1,54 @@
import Foundation
/// Lightweight `Codable` wrapper that round-trips heterogeneous JSON payloads.
/// Marked `@unchecked Sendable` because it can hold reference types.
struct AnyCodable: Codable, @unchecked Sendable {
let value: Any
init(_ value: Any) { self.value = value }
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
if container.decodeNil() { self.value = NSNull(); return }
if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Unsupported type")
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self.value {
case let intVal as Int: try container.encode(intVal)
case let doubleVal as Double: try container.encode(doubleVal)
case let boolVal as Bool: try container.encode(boolVal)
case let stringVal as String: try container.encode(stringVal)
case is NSNull: try container.encodeNil()
case let dict as [String: AnyCodable]: try container.encode(dict)
case let array as [AnyCodable]: try container.encode(array)
case let dict as [String: Any]:
try container.encode(dict.mapValues { AnyCodable($0) })
case let array as [Any]:
try container.encode(array.map { AnyCodable($0) })
case let dict as NSDictionary:
var converted: [String: AnyCodable] = [:]
for (k, v) in dict {
guard let key = k as? String else { continue }
converted[key] = AnyCodable(v)
}
try container.encode(converted)
case let array as NSArray:
try container.encode(array.map { AnyCodable($0) })
default:
let context = EncodingError.Context(
codingPath: encoder.codingPath,
debugDescription: "Unsupported type")
throw EncodingError.invalidValue(self.value, context)
}
}
}
+32 -48
View File
@@ -100,10 +100,6 @@ final class AppState {
}
}
var voiceWakeMicName: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.voiceWakeMicName, forKey: voiceWakeMicNameKey) } }
}
var voiceWakeLocaleID: String {
didSet {
self.ifNotPreview {
@@ -233,7 +229,6 @@ final class AppState {
}
self.showDockIcon = UserDefaults.standard.bool(forKey: showDockIconKey)
self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? ""
self.voiceWakeMicName = UserDefaults.standard.string(forKey: voiceWakeMicNameKey) ?? ""
self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier
self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard
.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? []
@@ -406,9 +401,11 @@ final class AppState {
private func syncGatewayConfigIfNeeded() {
guard !self.isPreview, !self.isInitializing else { return }
let connectionMode = self.connectionMode
let remoteTarget = self.remoteTarget
let desiredMode: String? = switch connectionMode {
var root = ClawdbotConfigFile.loadDict()
var gateway = root["gateway"] as? [String: Any] ?? [:]
var changed = false
let desiredMode: String? = switch self.connectionMode {
case .local:
"local"
case .remote:
@@ -416,49 +413,37 @@ final class AppState {
case .unconfigured:
nil
}
let remoteHost = connectionMode == .remote
? CommandResolver.parseSSHTarget(remoteTarget)?.host
: nil
Task { @MainActor in
// Keep app-only connection settings local to avoid overwriting remote gateway config.
var root = ClawdbotConfigFile.loadDict()
var gateway = root["gateway"] as? [String: Any] ?? [:]
var changed = false
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let desiredMode {
if currentMode != desiredMode {
gateway["mode"] = desiredMode
changed = true
}
} else if currentMode != nil {
gateway.removeValue(forKey: "mode")
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let desiredMode {
if currentMode != desiredMode {
gateway["mode"] = desiredMode
changed = true
}
if connectionMode == .remote, let host = remoteHost {
var remote = gateway["remote"] as? [String: Any] ?? [:]
let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
gateway["remote"] = remote
changed = true
}
}
guard changed else { return }
if gateway.isEmpty {
root.removeValue(forKey: "gateway")
} else {
root["gateway"] = gateway
}
ClawdbotConfigFile.saveDict(root)
} else if currentMode != nil {
gateway.removeValue(forKey: "mode")
changed = true
}
if self.connectionMode == .remote,
let host = CommandResolver.parseSSHTarget(self.remoteTarget)?.host
{
var remote = gateway["remote"] as? [String: Any] ?? [:]
let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
gateway["remote"] = remote
changed = true
}
}
guard changed else { return }
root["gateway"] = gateway
ClawdbotConfigFile.saveDict(root)
}
func triggerVoiceEars(ttl: TimeInterval? = 5) {
@@ -588,7 +573,6 @@ extension AppState {
state.iconAnimationsEnabled = true
state.showDockIcon = true
state.voiceWakeMicID = "BuiltInMic"
state.voiceWakeMicName = "Built-in Microphone"
state.voiceWakeLocaleID = Locale.current.identifier
state.voiceWakeAdditionalLocaleIDs = ["en-US", "de-DE"]
state.voicePushToTalkEnabled = false
@@ -1,216 +0,0 @@
import CoreAudio
import Foundation
import OSLog
final class AudioInputDeviceObserver {
private let logger = Logger(subsystem: "com.clawdbot", category: "audio.devices")
private var isActive = false
private var devicesListener: AudioObjectPropertyListenerBlock?
private var defaultInputListener: AudioObjectPropertyListenerBlock?
static func defaultInputDeviceUID() -> String? {
let systemObject = AudioObjectID(kAudioObjectSystemObject)
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var deviceID = AudioObjectID(0)
var size = UInt32(MemoryLayout<AudioObjectID>.size)
let status = AudioObjectGetPropertyData(
systemObject,
&address,
0,
nil,
&size,
&deviceID)
guard status == noErr, deviceID != 0 else { return nil }
return self.deviceUID(for: deviceID)
}
static func aliveInputDeviceUIDs() -> Set<String> {
let systemObject = AudioObjectID(kAudioObjectSystemObject)
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
var status = AudioObjectGetPropertyDataSize(systemObject, &address, 0, nil, &size)
guard status == noErr, size > 0 else { return [] }
let count = Int(size) / MemoryLayout<AudioObjectID>.size
var deviceIDs = [AudioObjectID](repeating: 0, count: count)
status = AudioObjectGetPropertyData(systemObject, &address, 0, nil, &size, &deviceIDs)
guard status == noErr else { return [] }
var output = Set<String>()
for deviceID in deviceIDs {
guard self.deviceIsAlive(deviceID) else { continue }
guard self.deviceHasInput(deviceID) else { continue }
if let uid = self.deviceUID(for: deviceID) {
output.insert(uid)
}
}
return output
}
static func defaultInputDeviceSummary() -> String {
let systemObject = AudioObjectID(kAudioObjectSystemObject)
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var deviceID = AudioObjectID(0)
var size = UInt32(MemoryLayout<AudioObjectID>.size)
let status = AudioObjectGetPropertyData(
systemObject,
&address,
0,
nil,
&size,
&deviceID)
guard status == noErr, deviceID != 0 else {
return "defaultInput=unknown"
}
let uid = self.deviceUID(for: deviceID) ?? "unknown"
let name = self.deviceName(for: deviceID) ?? "unknown"
return "defaultInput=\(name) (\(uid))"
}
func start(onChange: @escaping @Sendable () -> Void) {
guard !self.isActive else { return }
self.isActive = true
let systemObject = AudioObjectID(kAudioObjectSystemObject)
let queue = DispatchQueue.main
var devicesAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
let devicesListener: AudioObjectPropertyListenerBlock = { _, _ in
self.logDefaultInputChange(reason: "devices")
onChange()
}
let devicesStatus = AudioObjectAddPropertyListenerBlock(
systemObject,
&devicesAddress,
queue,
devicesListener)
var defaultInputAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
let defaultInputListener: AudioObjectPropertyListenerBlock = { _, _ in
self.logDefaultInputChange(reason: "default")
onChange()
}
let defaultStatus = AudioObjectAddPropertyListenerBlock(
systemObject,
&defaultInputAddress,
queue,
defaultInputListener)
if devicesStatus != noErr || defaultStatus != noErr {
self.logger.error("audio device observer install failed devices=\(devicesStatus) default=\(defaultStatus)")
}
self.logger.info("audio device observer started (\(Self.defaultInputDeviceSummary(), privacy: .public))")
self.devicesListener = devicesListener
self.defaultInputListener = defaultInputListener
}
func stop() {
guard self.isActive else { return }
self.isActive = false
let systemObject = AudioObjectID(kAudioObjectSystemObject)
if let devicesListener {
var devicesAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
_ = AudioObjectRemovePropertyListenerBlock(
systemObject,
&devicesAddress,
DispatchQueue.main,
devicesListener)
}
if let defaultInputListener {
var defaultInputAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
_ = AudioObjectRemovePropertyListenerBlock(
systemObject,
&defaultInputAddress,
DispatchQueue.main,
defaultInputListener)
}
self.devicesListener = nil
self.defaultInputListener = nil
}
private static func deviceUID(for deviceID: AudioObjectID) -> String? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyDeviceUID,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var uid: Unmanaged<CFString>?
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &uid)
guard status == noErr, let uid else { return nil }
return uid.takeUnretainedValue() as String
}
private static func deviceName(for deviceID: AudioObjectID) -> String? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioObjectPropertyName,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var name: Unmanaged<CFString>?
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &name)
guard status == noErr, let name else { return nil }
return name.takeUnretainedValue() as String
}
private static func deviceIsAlive(_ deviceID: AudioObjectID) -> Bool {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyDeviceIsAlive,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var alive: UInt32 = 0
var size = UInt32(MemoryLayout<UInt32>.size)
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &alive)
return status == noErr && alive != 0
}
private static func deviceHasInput(_ deviceID: AudioObjectID) -> Bool {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreamConfiguration,
mScope: kAudioDevicePropertyScopeInput,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
var status = AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &size)
guard status == noErr, size > 0 else { return false }
let raw = UnsafeMutableRawPointer.allocate(
byteCount: Int(size),
alignment: MemoryLayout<AudioBufferList>.alignment)
defer { raw.deallocate() }
let bufferList = raw.bindMemory(to: AudioBufferList.self, capacity: 1)
status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, bufferList)
guard status == noErr else { return false }
let buffers = UnsafeMutableAudioBufferListPointer(bufferList)
return buffers.contains(where: { $0.mNumberChannels > 0 })
}
private func logDefaultInputChange(reason: StaticString) {
self.logger.info("audio input changed (\(reason)) (\(Self.defaultInputDeviceSummary(), privacy: .public))")
}
}
@@ -187,7 +187,7 @@ actor BridgeServer {
thinking: "low",
deliver: false,
to: nil,
provider: .last))
channel: .last))
case "agent.request":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
@@ -205,7 +205,7 @@ actor BridgeServer {
?? "node-\(nodeId)"
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let provider = GatewayAgentProvider(raw: link.channel)
let channel = GatewayAgentChannel(raw: link.channel)
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: message,
@@ -213,7 +213,7 @@ actor BridgeServer {
thinking: thinking,
deliver: link.deliver,
to: to,
provider: provider))
channel: channel))
default:
break
@@ -229,7 +229,7 @@ actor BridgeServer {
error: BridgeRPCError(code: "FORBIDDEN", message: "Method not allowed"))
}
let params: [String: ClawdbotProtocol.AnyCodable]?
let params: [String: AnyCodable]?
if let json = req.paramsJSON?.trimmingCharacters(in: .whitespacesAndNewlines), !json.isEmpty {
guard let data = json.data(using: .utf8) else {
return BridgeRPCResponse(
@@ -238,7 +238,7 @@ actor BridgeServer {
error: BridgeRPCError(code: "INVALID_REQUEST", message: "paramsJSON not UTF-8"))
}
do {
params = try JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
params = try JSONDecoder().decode([String: AnyCodable].self, from: data)
} catch {
return BridgeRPCResponse(
id: req.id,
@@ -360,16 +360,16 @@ actor BridgeServer {
"reason \(reason)",
].compactMap(\.self).joined(separator: " · ")
var params: [String: ClawdbotProtocol.AnyCodable] = [
"text": ClawdbotProtocol.AnyCodable(summary),
"instanceId": ClawdbotProtocol.AnyCodable(nodeId),
"host": ClawdbotProtocol.AnyCodable(host),
"mode": ClawdbotProtocol.AnyCodable("node"),
"reason": ClawdbotProtocol.AnyCodable(reason),
"tags": ClawdbotProtocol.AnyCodable(tags),
var params: [String: AnyCodable] = [
"text": AnyCodable(summary),
"instanceId": AnyCodable(nodeId),
"host": AnyCodable(host),
"mode": AnyCodable("node"),
"reason": AnyCodable(reason),
"tags": AnyCodable(tags),
]
if let ip { params["ip"] = ClawdbotProtocol.AnyCodable(ip) }
if let version { params["version"] = ClawdbotProtocol.AnyCodable(version) }
if let ip { params["ip"] = AnyCodable(ip) }
if let version { params["version"] = AnyCodable(version) }
await GatewayConnection.shared.sendSystemEvent(params)
}
@@ -79,15 +79,14 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
GatewayProcessManager.shared.setActive(true)
}
let result = await GatewayConnection.shared.sendAgent(
GatewayAgentInvocation(
message: text,
sessionKey: self.sessionKey,
thinking: "low",
deliver: false,
to: nil,
provider: .last,
idempotencyKey: actionId))
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: text,
sessionKey: self.sessionKey,
thinking: "low",
deliver: false,
to: nil,
channel: .last,
idempotencyKey: actionId))
await MainActor.run {
guard let webView else { return }
@@ -1,4 +1,3 @@
import ClawdbotProtocol
import Foundation
enum ClawdbotConfigFile {
@@ -21,7 +20,7 @@ enum ClawdbotConfigFile {
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
do {
let data = try Data(contentsOf: url)
guard let root = self.parseConfigData(data) else {
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
self.logger.warning("config JSON root invalid")
return [:]
}
@@ -33,8 +32,7 @@ enum ClawdbotConfigFile {
}
static func saveDict(_ dict: [String: Any]) {
// Nix mode disables config writes in production, but tests rely on saving temp configs.
if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return }
if ProcessInfo.processInfo.isNixMode { return }
do {
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
let url = self.url()
@@ -81,33 +79,22 @@ enum ClawdbotConfigFile {
static func agentWorkspace() -> String? {
let root = self.loadDict()
let agents = root["agents"] as? [String: Any]
let defaults = agents?["defaults"] as? [String: Any]
return defaults?["workspace"] as? String
let agent = root["agent"] as? [String: Any]
return agent?["workspace"] as? String
}
static func setAgentWorkspace(_ workspace: String?) {
var root = self.loadDict()
var agents = root["agents"] as? [String: Any] ?? [:]
var defaults = agents["defaults"] as? [String: Any] ?? [:]
var agent = root["agent"] as? [String: Any] ?? [:]
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
defaults.removeValue(forKey: "workspace")
agent.removeValue(forKey: "workspace")
} else {
defaults["workspace"] = trimmed
}
if defaults.isEmpty {
agents.removeValue(forKey: "defaults")
} else {
agents["defaults"] = defaults
}
if agents.isEmpty {
root.removeValue(forKey: "agents")
} else {
root["agents"] = agents
agent["workspace"] = trimmed
}
root["agent"] = agent
self.saveDict(root)
self.logger.debug("agents.defaults.workspace updated set=\(!trimmed.isEmpty)")
self.logger.debug("agent workspace updated set=\(!trimmed.isEmpty)")
}
static func gatewayPassword() -> String? {
@@ -135,83 +122,4 @@ enum ClawdbotConfigFile {
}
return nil
}
static func remoteGatewayPort() -> Int? {
guard let url = self.remoteGatewayUrl(),
let port = url.port,
port > 0
else { return nil }
return port
}
static func remoteGatewayPort(matchingHost sshHost: String) -> Int? {
let trimmedSshHost = sshHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedSshHost.isEmpty,
let url = self.remoteGatewayUrl(),
let port = url.port,
port > 0,
let urlHost = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
!urlHost.isEmpty
else {
return nil
}
let sshKey = Self.hostKey(trimmedSshHost)
let urlKey = Self.hostKey(urlHost)
guard !sshKey.isEmpty, !urlKey.isEmpty, sshKey == urlKey else { return nil }
return port
}
static func setRemoteGatewayUrl(host: String, port: Int?) {
guard let port, port > 0 else { return }
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedHost.isEmpty else { return }
self.updateGatewayDict { gateway in
var remote = gateway["remote"] as? [String: Any] ?? [:]
let existingUrl = (remote["url"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let scheme = URL(string: existingUrl)?.scheme ?? "ws"
remote["url"] = "\(scheme)://\(trimmedHost):\(port)"
gateway["remote"] = remote
}
}
private static func remoteGatewayUrl() -> URL? {
let root = self.loadDict()
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let raw = remote["url"] as? String
else {
return nil
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil }
return url
}
private static func hostKey(_ host: String) -> String {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !trimmed.isEmpty else { return "" }
if trimmed.contains(":") { return trimmed }
let digits = CharacterSet(charactersIn: "0123456789.")
if trimmed.rangeOfCharacter(from: digits.inverted) == nil {
return trimmed
}
return trimmed.split(separator: ".").first.map(String.init) ?? trimmed
}
private static func parseConfigData(_ data: Data) -> [String: Any]? {
if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
return root
}
let decoder = JSONDecoder()
if #available(macOS 12.0, *) {
decoder.allowsJSON5 = true
}
if let decoded = try? decoder.decode([String: AnyCodable].self, from: data) {
self.logger.notice("config parsed with JSON5 decoder")
return decoded.mapValues { $0.foundationValue }
}
return nil
}
}
@@ -3,9 +3,9 @@ import Foundation
enum ClawdbotEnv {
static func path(_ key: String) -> String? {
// Normalize env overrides once so UI + file IO stay consistent.
guard let raw = getenv(key) else { return nil }
let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty
guard let value = ProcessInfo.processInfo.environment[key]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty
else {
return nil
}
@@ -387,20 +387,13 @@ struct ConfigSettings: View {
private func loadConfig() async {
let parsed = await ConfigStore.load()
let agents = parsed["agents"] as? [String: Any]
let defaults = agents?["defaults"] as? [String: Any]
let heartbeat = defaults?["heartbeat"] as? [String: Any]
let heartbeatEvery = heartbeat?["every"] as? String
let heartbeatBody = heartbeat?["prompt"] as? String
let agent = parsed["agent"] as? [String: Any]
let heartbeatMinutes = agent?["heartbeatMinutes"] as? Int
let heartbeatBody = agent?["heartbeatBody"] as? String
let browser = parsed["browser"] as? [String: Any]
let talk = parsed["talk"] as? [String: Any]
let loadedModel: String = {
if let raw = defaults?["model"] as? String { return raw }
if let modelDict = defaults?["model"] as? [String: Any],
let primary = modelDict["primary"] as? String { return primary }
return ""
}()
let loadedModel = (agent?["model"] as? String) ?? ""
if !loadedModel.isEmpty {
self.configModel = loadedModel
self.customModel = loadedModel
@@ -409,13 +402,7 @@ struct ConfigSettings: View {
self.customModel = SessionLoader.fallbackModel
}
if let heartbeatEvery {
let digits = heartbeatEvery.trimmingCharacters(in: .whitespacesAndNewlines)
.prefix { $0.isNumber }
if let minutes = Int(digits) {
self.heartbeatMinutes = minutes
}
}
if let heartbeatMinutes { self.heartbeatMinutes = heartbeatMinutes }
if let heartbeatBody, !heartbeatBody.isEmpty { self.heartbeatBody = heartbeatBody }
if let browser {
@@ -493,49 +480,25 @@ struct ConfigSettings: View {
@MainActor
private static func buildAndSaveConfig(_ draft: ConfigDraft) async -> String? {
var root = await ConfigStore.load()
var agents = root["agents"] as? [String: Any] ?? [:]
var defaults = agents["defaults"] as? [String: Any] ?? [:]
var agent = root["agent"] as? [String: Any] ?? [:]
var browser = root["browser"] as? [String: Any] ?? [:]
var talk = root["talk"] as? [String: Any] ?? [:]
let chosenModel = (draft.configModel == "__custom__" ? draft.customModel : draft.configModel)
.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedModel = chosenModel
if !trimmedModel.isEmpty {
var model = defaults["model"] as? [String: Any] ?? [:]
model["primary"] = trimmedModel
defaults["model"] = model
var models = defaults["models"] as? [String: Any] ?? [:]
if models[trimmedModel] == nil {
models[trimmedModel] = [:]
}
defaults["models"] = models
}
if !trimmedModel.isEmpty { agent["model"] = trimmedModel }
if let heartbeatMinutes = draft.heartbeatMinutes {
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
heartbeat["every"] = "\(heartbeatMinutes)m"
defaults["heartbeat"] = heartbeat
agent["heartbeatMinutes"] = heartbeatMinutes
}
let trimmedBody = draft.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedBody.isEmpty {
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
heartbeat["prompt"] = trimmedBody
defaults["heartbeat"] = heartbeat
agent["heartbeatBody"] = trimmedBody
}
if defaults.isEmpty {
agents.removeValue(forKey: "defaults")
} else {
agents["defaults"] = defaults
}
if agents.isEmpty {
root.removeValue(forKey: "agents")
} else {
root["agents"] = agents
}
root["agent"] = agent
browser["enabled"] = draft.browserEnabled
let trimmedUrl = draft.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
+4 -12
View File
@@ -1,4 +1,3 @@
import ClawdbotProtocol
import Foundation
enum ConfigStore {
@@ -35,14 +34,11 @@ enum ConfigStore {
if let override = overrides.loadRemote {
return await override()
}
return await self.loadFromGateway() ?? [:]
return await self.loadFromGateway()
}
if let override = overrides.loadLocal {
return override()
}
if let gateway = await self.loadFromGateway() {
return gateway
}
return ClawdbotConfigFile.loadDict()
}
@@ -59,17 +55,13 @@ enum ConfigStore {
if let override = overrides.saveLocal {
override(root)
} else {
do {
try await self.saveToGateway(root)
} catch {
ClawdbotConfigFile.saveDict(root)
}
ClawdbotConfigFile.saveDict(root)
}
}
}
@MainActor
private static func loadFromGateway() async -> [String: Any]? {
private static func loadFromGateway() async -> [String: Any] {
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .configGet,
@@ -77,7 +69,7 @@ enum ConfigStore {
timeoutMs: 8000)
return snap.config?.mapValues { $0.foundationValue } ?? [:]
} catch {
return nil
return [:]
}
}
@@ -2,11 +2,11 @@ import SwiftUI
extension ConnectionsSettings {
var body: some View {
HStack(spacing: 0) {
NavigationSplitView {
self.sidebar
} detail: {
self.detail
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear {
self.store.start()
self.ensureSelection()
@@ -18,30 +18,27 @@ extension ConnectionsSettings {
}
private var sidebar: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
if !self.enabledProviders.isEmpty {
self.sidebarSectionHeader("Configured")
List(selection: self.$selectedProvider) {
if !self.enabledProviders.isEmpty {
Section("Configured") {
ForEach(self.enabledProviders) { provider in
self.sidebarRow(provider)
.tag(provider)
}
}
}
if !self.availableProviders.isEmpty {
self.sidebarSectionHeader("Available")
if !self.availableProviders.isEmpty {
Section("Available") {
ForEach(self.availableProviders) { provider in
self.sidebarRow(provider)
.tag(provider)
}
}
}
.padding(.vertical, 10)
.padding(.horizontal, 10)
}
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor)))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.listStyle(.sidebar)
.frame(minWidth: 210, idealWidth: 230, maxWidth: 260)
}
private var detail: some View {
@@ -52,7 +49,7 @@ extension ConnectionsSettings {
self.emptyDetail
}
}
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var emptyDetail: some View {
@@ -82,41 +79,18 @@ extension ConnectionsSettings {
}
private func sidebarRow(_ provider: ConnectionProvider) -> some View {
let isSelected = self.selectedProvider == provider
return Button {
self.selectedProvider = provider
} label: {
HStack(spacing: 8) {
Circle()
.fill(self.providerTint(provider))
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
Text(provider.title)
Text(self.providerSummary(provider))
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 8) {
Circle()
.fill(self.providerTint(provider))
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
Text(provider.title)
Text(self.providerSummary(provider))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
.padding(.horizontal, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.background(Color.clear) // ensure full-width hit test area
.contentShape(Rectangle())
}
.frame(maxWidth: .infinity, alignment: .leading)
.buttonStyle(.plain)
.contentShape(Rectangle())
}
private func sidebarSectionHeader(_ title: String) -> some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
.padding(.horizontal, 4)
.padding(.top, 2)
.padding(.vertical, 4)
}
private func detailHeader(for provider: ConnectionProvider) -> some View {
@@ -13,7 +13,6 @@ let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
let showDockIconKey = "clawdbot.showDockIcon"
let defaultVoiceWakeTriggers = ["clawd", "claude"]
let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName"
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"
let voiceWakeAdditionalLocalesKey = "clawdbot.voiceWakeAdditionalLocaleIDs"
let voicePushToTalkEnabledKey = "clawdbot.voicePushToTalkEnabled"
@@ -1,4 +1,3 @@
import ClawdbotProtocol
import Foundation
import SwiftUI
@@ -34,13 +33,13 @@ extension CronJobEditor {
case let .systemEvent(text):
self.payloadKind = .systemEvent
self.systemEventText = text
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver):
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
self.payloadKind = .agentTurn
self.agentMessage = message
self.thinking = thinking ?? ""
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
self.deliver = deliver ?? false
self.provider = GatewayAgentProvider(raw: provider)
self.channel = GatewayAgentChannel(raw: channel)
self.to = to ?? ""
self.bestEffortDeliver = bestEffortDeliver ?? false
}
@@ -167,7 +166,7 @@ extension CronJobEditor {
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
payload["deliver"] = self.deliver
if self.deliver {
payload["provider"] = self.provider.rawValue
payload["channel"] = self.channel.rawValue
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { payload["to"] = to }
payload["bestEffortDeliver"] = self.bestEffortDeliver
@@ -13,7 +13,7 @@ extension CronJobEditor {
self.payloadKind = .agentTurn
self.agentMessage = "Run diagnostic"
self.deliver = true
self.provider = .last
self.channel = .last
self.to = "+15551230000"
self.thinking = "low"
self.timeoutSeconds = "90"
@@ -1,4 +1,3 @@
import ClawdbotProtocol
import SwiftUI
struct CronJobEditor: View {
@@ -18,7 +17,7 @@ struct CronJobEditor: View {
static let scheduleKindNote =
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
static let isolatedPayloadNote =
"Isolated jobs always run an agent turn. The result can be delivered to a provider, "
"Isolated jobs always run an agent turn. The result can be delivered to a surface, "
+ "and a short summary is posted back to your main chat."
static let mainPayloadNote =
"System events are injected into the current main session. Agent turns require an isolated session target."
@@ -43,7 +42,7 @@ struct CronJobEditor: View {
@State var systemEventText: String = ""
@State var agentMessage: String = ""
@State var deliver: Bool = false
@State var provider: GatewayAgentProvider = .last
@State var channel: GatewayAgentChannel = .last
@State var to: String = ""
@State var thinking: String = ""
@State var timeoutSeconds: String = ""
@@ -310,7 +309,7 @@ struct CronJobEditor: View {
}
GridRow {
self.gridLabel("Deliver")
Toggle("Deliver result to a provider", isOn: self.$deliver)
Toggle("Deliver result to a surface", isOn: self.$deliver)
.toggleStyle(.switch)
}
}
@@ -318,15 +317,12 @@ struct CronJobEditor: View {
if self.deliver {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Provider")
Picker("", selection: self.$provider) {
Text("last").tag(GatewayAgentProvider.last)
Text("whatsapp").tag(GatewayAgentProvider.whatsapp)
Text("telegram").tag(GatewayAgentProvider.telegram)
Text("discord").tag(GatewayAgentProvider.discord)
Text("slack").tag(GatewayAgentProvider.slack)
Text("signal").tag(GatewayAgentProvider.signal)
Text("imessage").tag(GatewayAgentProvider.imessage)
self.gridLabel("Channel")
Picker("", selection: self.$channel) {
Text("last").tag(GatewayAgentChannel.last)
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
Text("telegram").tag(GatewayAgentChannel.telegram)
Text("discord").tag(GatewayAgentChannel.discord)
}
.labelsHidden()
.pickerStyle(.segmented)
+5 -5
View File
@@ -74,12 +74,12 @@ enum CronPayload: Codable, Equatable {
thinking: String?,
timeoutSeconds: Int?,
deliver: Bool?,
provider: String?,
channel: String?,
to: String?,
bestEffortDeliver: Bool?)
enum CodingKeys: String, CodingKey {
case kind, text, message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver
case kind, text, message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver
}
var kind: String {
@@ -101,7 +101,7 @@ enum CronPayload: Codable, Equatable {
thinking: container.decodeIfPresent(String.self, forKey: .thinking),
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver),
provider: container.decodeIfPresent(String.self, forKey: .provider),
channel: container.decodeIfPresent(String.self, forKey: .channel),
to: container.decodeIfPresent(String.self, forKey: .to),
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
default:
@@ -118,12 +118,12 @@ enum CronPayload: Codable, Equatable {
switch self {
case let .systemEvent(text):
try container.encode(text, forKey: .text)
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver):
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
try container.encode(message, forKey: .message)
try container.encodeIfPresent(thinking, forKey: .thinking)
try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
try container.encodeIfPresent(deliver, forKey: .deliver)
try container.encodeIfPresent(provider, forKey: .provider)
try container.encodeIfPresent(channel, forKey: .channel)
try container.encodeIfPresent(to, forKey: .to)
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
}
@@ -1,4 +1,3 @@
import ClawdbotProtocol
import Foundation
extension CronSettings {
@@ -206,7 +206,7 @@ extension CronSettings {
Text(text)
.font(.callout)
.textSelection(.enabled)
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, _):
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, _):
VStack(alignment: .leading, spacing: 4) {
Text(message)
.font(.callout)
@@ -216,7 +216,7 @@ extension CronSettings {
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
if deliver ?? false {
StatusPill(text: "deliver", tint: .secondary)
if let provider, !provider.isEmpty { StatusPill(text: provider, tint: .secondary) }
if let channel, !channel.isEmpty { StatusPill(text: channel, tint: .secondary) }
if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
}
}
@@ -20,7 +20,7 @@ struct CronSettings_Previews: PreviewProvider {
thinking: "low",
timeoutSeconds: 600,
deliver: true,
provider: "last",
channel: "last",
to: nil,
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"),
@@ -72,7 +72,7 @@ extension CronSettings {
thinking: "low",
timeoutSeconds: 120,
deliver: true,
provider: "whatsapp",
channel: "whatsapp",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "),
+3 -3
View File
@@ -59,7 +59,7 @@ final class DeepLinkHandler {
}
do {
let provider = GatewayAgentProvider(raw: link.channel)
let channel = GatewayAgentChannel(raw: link.channel)
let explicitSessionKey = link.sessionKey?
.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty
@@ -72,9 +72,9 @@ final class DeepLinkHandler {
message: messagePreview,
sessionKey: resolvedSessionKey,
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
deliver: provider.shouldDeliver(link.deliver),
deliver: channel.shouldDeliver(link.deliver),
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
provider: provider,
channel: channel,
timeoutSeconds: link.timeoutSeconds,
idempotencyKey: UUID().uuidString)
@@ -1,26 +0,0 @@
import Foundation
enum GatewayAgentChannel: String, CaseIterable, Sendable {
case last
case webchat
case whatsapp
case telegram
init(raw: String?) {
let trimmed = raw?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased() ?? ""
self = GatewayAgentChannel(rawValue: trimmed) ?? .last
}
func shouldDeliver(_ isLast: Bool) -> Bool {
switch self {
case .webchat:
false
case .last:
isLast
case .whatsapp, .telegram:
true
}
}
}
@@ -5,19 +5,16 @@ import OSLog
private let gatewayConnectionLogger = Logger(subsystem: "com.clawdbot", category: "gateway.connection")
enum GatewayAgentProvider: String, Codable, CaseIterable, Sendable {
enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
case last
case whatsapp
case telegram
case discord
case slack
case signal
case imessage
case webchat
init(raw: String?) {
let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
self = GatewayAgentProvider(rawValue: normalized) ?? .last
self = GatewayAgentChannel(rawValue: normalized) ?? .last
}
var isDeliverable: Bool { self != .webchat }
@@ -31,7 +28,7 @@ struct GatewayAgentInvocation: Sendable {
var thinking: String?
var deliver: Bool = false
var to: String?
var provider: GatewayAgentProvider = .last
var channel: GatewayAgentChannel = .last
var timeoutSeconds: Int?
var idempotencyKey: String = UUID().uuidString
}
@@ -368,7 +365,7 @@ extension GatewayConnection {
"thinking": AnyCodable(invocation.thinking ?? "default"),
"deliver": AnyCodable(invocation.deliver),
"to": AnyCodable(invocation.to ?? ""),
"provider": AnyCodable(invocation.provider.rawValue),
"channel": AnyCodable(invocation.channel.rawValue),
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
]
if let timeout = invocation.timeoutSeconds {
@@ -389,7 +386,7 @@ extension GatewayConnection {
sessionKey: String,
deliver: Bool,
to: String?,
provider: GatewayAgentProvider = .last,
channel: GatewayAgentChannel = .last,
timeoutSeconds: Int? = nil,
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
{
@@ -399,7 +396,7 @@ extension GatewayConnection {
thinking: thinking,
deliver: deliver,
to: to,
provider: provider,
channel: channel,
timeoutSeconds: timeoutSeconds,
idempotencyKey: idempotencyKey))
}
@@ -18,7 +18,6 @@ final class GatewayDiscoveryModel {
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var gatewayPort: Int?
var cliPath: String?
var stableID: String
var debugID: String
@@ -139,7 +138,6 @@ final class GatewayDiscoveryModel {
lanHost: parsedTXT.lanHost,
tailnetDns: parsedTXT.tailnetDns,
sshPort: parsedTXT.sshPort,
gatewayPort: parsedTXT.gatewayPort,
cliPath: parsedTXT.cliPath,
stableID: stableID,
debugID: BridgeEndpointID.prettyDescription(result.endpoint),
@@ -208,19 +206,12 @@ final class GatewayDiscoveryModel {
return merged
}
struct GatewayTXT: Equatable {
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var gatewayPort: Int?
var cliPath: String?
}
static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT {
static func parseGatewayTXT(_ txt: [String: String])
-> (lanHost: String?, tailnetDns: String?, sshPort: Int, cliPath: String?)
{
var lanHost: String?
var tailnetDns: String?
var sshPort = 22
var gatewayPort: Int?
var cliPath: String?
if let value = txt["lanHost"] {
@@ -237,23 +228,12 @@ final class GatewayDiscoveryModel {
{
sshPort = parsed
}
if let value = txt["gatewayPort"],
let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)),
parsed > 0
{
gatewayPort = parsed
}
if let value = txt["cliPath"] {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
cliPath = trimmed.isEmpty ? nil : trimmed
}
return GatewayTXT(
lanHost: lanHost,
tailnetDns: tailnetDns,
sshPort: sshPort,
gatewayPort: gatewayPort,
cliPath: cliPath)
return (lanHost, tailnetDns, sshPort, cliPath)
}
static func buildSSHTarget(user: String, host: String, port: Int) -> String {
@@ -13,14 +13,12 @@ enum GatewayEndpointState: Sendable, Equatable {
/// - The endpoint store owns observation + explicit "ensure tunnel" actions.
actor GatewayEndpointStore {
static let shared = GatewayEndpointStore()
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
struct Deps: Sendable {
let mode: @Sendable () async -> AppState.ConnectionMode
let token: @Sendable () -> String?
let password: @Sendable () -> String?
let localPort: @Sendable () -> Int
let localHost: @Sendable () async -> String
let remotePortIfRunning: @Sendable () async -> UInt16?
let ensureRemoteTunnel: @Sendable () async throws -> UInt16
@@ -35,16 +33,6 @@ actor GatewayEndpointStore {
env: ProcessInfo.processInfo.environment)
},
localPort: { GatewayEnvironment.gatewayPort() },
localHost: {
let root = ClawdbotConfigFile.loadDict()
let bind = GatewayEndpointStore.resolveGatewayBindMode(
root: root,
env: ProcessInfo.processInfo.environment)
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
return GatewayEndpointStore.resolveLocalGatewayHost(
bindMode: bind,
tailscaleIP: tailscaleIP)
},
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() })
}
@@ -101,17 +89,13 @@ actor GatewayEndpointStore {
}
let port = deps.localPort()
let bind = GatewayEndpointStore.resolveGatewayBindMode(
root: ClawdbotConfigFile.loadDict(),
env: ProcessInfo.processInfo.environment)
let host = GatewayEndpointStore.resolveLocalGatewayHost(bindMode: bind, tailscaleIP: nil)
let token = deps.token()
let password = deps.password()
switch initialMode {
case .local:
self.state = .ready(
mode: .local,
url: URL(string: "ws://\(host):\(port)")!,
url: URL(string: "ws://127.0.0.1:\(port)")!,
token: token,
password: password)
case .remote:
@@ -145,10 +129,9 @@ actor GatewayEndpointStore {
switch mode {
case .local:
let port = self.deps.localPort()
let host = await self.deps.localHost()
self.setState(.ready(
mode: .local,
url: URL(string: "ws://\(host):\(port)")!,
url: URL(string: "ws://127.0.0.1:\(port)")!,
token: token,
password: password))
case .remote:
@@ -235,39 +218,6 @@ actor GatewayEndpointStore {
"endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)")
}
}
private static func resolveGatewayBindMode(
root: [String: Any],
env: [String: String]) -> String?
{
if let envBind = env["CLAWDBOT_GATEWAY_BIND"] {
let trimmed = envBind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.supportedBindModes.contains(trimmed) {
return trimmed
}
}
if let gateway = root["gateway"] as? [String: Any],
let bind = gateway["bind"] as? String
{
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.supportedBindModes.contains(trimmed) {
return trimmed
}
}
return nil
}
private static func resolveLocalGatewayHost(
bindMode: String?,
tailscaleIP: String?) -> String
{
switch bindMode {
case "tailnet", "auto":
tailscaleIP ?? "127.0.0.1"
default:
"127.0.0.1"
}
}
}
#if DEBUG
@@ -279,19 +229,5 @@ extension GatewayEndpointStore {
{
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
}
static func _testResolveGatewayBindMode(
root: [String: Any],
env: [String: String]) -> String?
{
self.resolveGatewayBindMode(root: root, env: env)
}
static func _testResolveLocalGatewayHost(
bindMode: String?,
tailscaleIP: String?) -> String
{
self.resolveLocalGatewayHost(bindMode: bindMode, tailscaleIP: tailscaleIP)
}
}
#endif
@@ -43,52 +43,25 @@ enum GatewayLaunchAgentManager {
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
}
static func isLoaded() async -> Bool {
static func status() async -> Bool {
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
let result = await Launchctl.run(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
return result.status == 0
}
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
if enabled {
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.legacyPlistURL)
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
self.logger.error("launchd enable failed: gateway missing at \(gatewayBin)")
return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh"
}
let desiredBind = self.preferredGatewayBind() ?? "loopback"
let desiredToken = self.preferredGatewayToken()
let desiredPassword = self.preferredGatewayPassword()
let desiredConfig = DesiredConfig(
port: port,
bind: desiredBind,
token: desiredToken,
password: desiredPassword)
// If launchd already loaded the job (common on login), avoid `bootout` unless we must
// change the config. `bootout` can kill a just-started gateway and cause attach loops.
let loaded = await self.isLoaded()
if loaded,
let existing = self.readPlistConfig(),
existing.matches(desiredConfig)
{
self.logger.info("launchd job already loaded with desired config; skipping bootout")
await self.ensureEnabled()
_ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
return nil
}
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
self.logger.info("launchd enable requested port=\(port)")
self.writePlist(bundlePath: bundlePath, port: port)
await self.ensureEnabled()
if loaded {
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
}
let bootstrap = await Launchctl.run(["bootstrap", "gui/\(getuid())", self.plistURL.path])
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
let bootstrap = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
if bootstrap.status != 0 {
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
self.logger.error("launchd bootstrap failed: \(msg)")
@@ -96,19 +69,20 @@ enum GatewayLaunchAgentManager {
? "Failed to bootstrap gateway launchd job"
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
}
await self.ensureEnabled()
// Note: removed redundant `kickstart -k` that caused race condition.
// bootstrap already starts the job; kickstart -k would kill it immediately
// and with KeepAlive=true, cause a restart loop with port conflicts.
return nil
}
self.logger.info("launchd disable requested")
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
await self.ensureDisabled()
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.plistURL)
return nil
}
static func kickstart() async {
_ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
}
private static func writePlist(bundlePath: String, port: Int) {
@@ -234,57 +208,30 @@ enum GatewayLaunchAgentManager {
.replacingOccurrences(of: "'", with: "&apos;")
}
private struct DesiredConfig: Equatable {
let port: Int
let bind: String
let token: String?
let password: String?
private struct LaunchctlResult {
let status: Int32
let output: String
}
private struct InstalledConfig: Equatable {
let port: Int?
let bind: String?
let token: String?
let password: String?
func matches(_ desired: DesiredConfig) -> Bool {
guard self.port == desired.port else { return false }
guard (self.bind ?? "loopback") == desired.bind else { return false }
guard self.token == desired.token else { return false }
guard self.password == desired.password else { return false }
return true
}
}
private static func readPlistConfig() -> InstalledConfig? {
guard let snapshot = LaunchAgentPlist.snapshot(url: self.plistURL) else { return nil }
return InstalledConfig(
port: snapshot.port,
bind: snapshot.bind,
token: snapshot.token,
password: snapshot.password)
}
private static func ensureEnabled() async {
let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
guard result.status != 0 else { return }
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
if msg.isEmpty {
self.logger.warning("launchd enable failed")
} else {
self.logger.warning("launchd enable failed: \(msg)")
}
}
private static func ensureDisabled() async {
let result = await Launchctl.run(["disable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
guard result.status != 0 else { return }
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
if msg.isEmpty {
self.logger.warning("launchd disable failed")
} else {
self.logger.warning("launchd disable failed: \(msg)")
}
@discardableResult
private static func runLaunchctl(_ args: [String]) async -> LaunchctlResult {
await Task.detached(priority: .utility) { () -> LaunchctlResult in
let process = Process()
process.launchPath = "/bin/launchctl"
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readToEndSafely()
let output = String(data: data, encoding: .utf8) ?? ""
return LaunchctlResult(status: process.terminationStatus, output: output)
} catch {
return LaunchctlResult(status: -1, output: error.localizedDescription)
}
}.value
}
}
@@ -70,7 +70,7 @@ final class GatewayProcessManager {
func ensureLaunchAgentEnabledIfNeeded() async {
guard !CommandResolver.connectionModeIsRemote() else { return }
guard !AppStateStore.attachExistingGatewayOnly else { return }
let enabled = await GatewayLaunchAgentManager.isLoaded()
let enabled = await GatewayLaunchAgentManager.status()
guard !enabled else { return }
let bundlePath = Bundle.main.bundleURL.path
let port = GatewayEnvironment.gatewayPort()
@@ -0,0 +1,6 @@
import ClawdbotProtocol
// The generated gateway protocol models are value types, but they don't currently declare Sendable.
// We use them across actors via GatewayConnection's event stream, so mark them as unchecked.
extension HelloOk: @unchecked Sendable {}
extension EventFrame: @unchecked Sendable {}
@@ -151,7 +151,6 @@ struct GeneralSettings: View {
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
guard mode != .off else { return true }
let status = CLLocationManager().authorizationStatus
// Note: macOS only supports authorizedAlways, not authorizedWhenInUse (iOS only)
if status == .authorizedAlways {
return true
}
@@ -694,7 +693,6 @@ extension GeneralSettings {
host: host,
port: gateway.sshPort)
self.state.remoteCliPath = gateway.cliPath ?? ""
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
}
}
@@ -1,81 +0,0 @@
import Foundation
enum Launchctl {
struct Result: Sendable {
let status: Int32
let output: String
}
@discardableResult
static func run(_ args: [String]) async -> Result {
await Task.detached(priority: .utility) { () -> Result in
let process = Process()
process.launchPath = "/bin/launchctl"
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readToEndSafely()
let output = String(data: data, encoding: .utf8) ?? ""
return Result(status: process.terminationStatus, output: output)
} catch {
return Result(status: -1, output: error.localizedDescription)
}
}.value
}
}
struct LaunchAgentPlistSnapshot: Equatable, Sendable {
let programArguments: [String]
let environment: [String: String]
let port: Int?
let bind: String?
let token: String?
let password: String?
}
enum LaunchAgentPlist {
static func snapshot(url: URL) -> LaunchAgentPlistSnapshot? {
guard let data = try? Data(contentsOf: url) else { return nil }
let rootAny: Any
do {
rootAny = try PropertyListSerialization.propertyList(
from: data,
options: [],
format: nil)
} catch {
return nil
}
guard let root = rootAny as? [String: Any] else { return nil }
let programArguments = root["ProgramArguments"] as? [String] ?? []
let env = root["EnvironmentVariables"] as? [String: String] ?? [:]
let port = Self.extractFlagInt(programArguments, flag: "--port")
let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased()
let token = env["CLAWDBOT_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let password = env["CLAWDBOT_GATEWAY_PASSWORD"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
return LaunchAgentPlistSnapshot(
programArguments: programArguments,
environment: env,
port: port,
bind: bind,
token: token,
password: password)
}
private static func extractFlagInt(_ args: [String], flag: String) -> Int? {
guard let raw = self.extractFlagString(args, flag: flag) else { return nil }
return Int(raw)
}
private static func extractFlagString(_ args: [String], flag: String) -> String? {
guard let idx = args.firstIndex(of: flag) else { return nil }
let valueIdx = args.index(after: idx)
guard valueIdx < args.endIndex else { return nil }
let token = args[valueIdx].trimmingCharacters(in: .whitespacesAndNewlines)
return token.isEmpty ? nil : token
}
}
@@ -18,8 +18,6 @@ struct MenuContent: View {
@Environment(\.openSettings) private var openSettings
@State private var availableMics: [AudioInputDevice] = []
@State private var loadingMics = false
@State private var micObserver = AudioInputDeviceObserver()
@State private var micRefreshTask: Task<Void, Never>?
@State private var browserControlEnabled = true
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
@AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue
@@ -145,14 +143,6 @@ struct MenuContent: View {
.task(id: self.state.connectionMode) {
await self.loadBrowserControlEnabled()
}
.onAppear {
self.startMicObserver()
}
.onDisappear {
self.micRefreshTask?.cancel()
self.micRefreshTask = nil
self.micObserver.stop()
}
}
private var connectionLabel: String {
@@ -450,22 +440,13 @@ struct MenuContent: View {
if let match = self.availableMics.first(where: { $0.uid == self.state.voiceWakeMicID }) {
return match.name
}
if !self.state.voiceWakeMicName.isEmpty { return self.state.voiceWakeMicName }
return "Unavailable"
}
private var microphoneMenuItems: some View {
Group {
if self.isSelectedMicUnavailable {
Label("Disconnected (using System default)", systemImage: "exclamationmark.triangle")
.labelStyle(.titleAndIcon)
.foregroundStyle(.secondary)
.disabled(true)
Divider()
}
Button {
self.state.voiceWakeMicID = ""
self.state.voiceWakeMicName = ""
} label: {
Label(self.defaultMicLabel, systemImage: self.state.voiceWakeMicID.isEmpty ? "checkmark" : "")
.labelStyle(.titleAndIcon)
@@ -475,7 +456,6 @@ struct MenuContent: View {
ForEach(self.availableMics) { mic in
Button {
self.state.voiceWakeMicID = mic.uid
self.state.voiceWakeMicName = mic.name
} label: {
Label(mic.name, systemImage: self.state.voiceWakeMicID == mic.uid ? "checkmark" : "")
.labelStyle(.titleAndIcon)
@@ -485,12 +465,6 @@ struct MenuContent: View {
}
}
private var isSelectedMicUnavailable: Bool {
let selected = self.state.voiceWakeMicID
guard !selected.isEmpty else { return false }
return !self.availableMics.contains(where: { $0.uid == selected })
}
private var defaultMicLabel: String {
if let host = Host.current().localizedName, !host.isEmpty {
return "Auto-detect (\(host))"
@@ -526,53 +500,14 @@ struct MenuContent: View {
deviceTypes: [.external, .microphone],
mediaType: .audio,
position: .unspecified)
let connectedDevices = discovery.devices.filter(\.isConnected)
self.availableMics = connectedDevices
self.availableMics = discovery.devices
.sorted { lhs, rhs in
lhs.localizedName.localizedCaseInsensitiveCompare(rhs.localizedName) == .orderedAscending
}
.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) }
self.availableMics = self.filterAliveInputs(self.availableMics)
self.updateSelectedMicName()
self.loadingMics = false
}
private func startMicObserver() {
self.micObserver.start {
Task { @MainActor in
self.scheduleMicRefresh()
}
}
}
@MainActor
private func scheduleMicRefresh() {
self.micRefreshTask?.cancel()
self.micRefreshTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 300_000_000)
guard !Task.isCancelled else { return }
await self.loadMicrophones(force: true)
}
}
private func filterAliveInputs(_ inputs: [AudioInputDevice]) -> [AudioInputDevice] {
let aliveUIDs = AudioInputDeviceObserver.aliveInputDeviceUIDs()
guard !aliveUIDs.isEmpty else { return inputs }
return inputs.filter { aliveUIDs.contains($0.uid) }
}
@MainActor
private func updateSelectedMicName() {
let selected = self.state.voiceWakeMicID
if selected.isEmpty {
self.state.voiceWakeMicName = ""
return
}
if let match = self.availableMics.first(where: { $0.uid == selected }) {
self.state.voiceWakeMicName = match.name
}
}
private struct AudioInputDevice: Identifiable, Equatable {
let uid: String
let name: String
@@ -22,10 +22,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
private var cachedErrorText: String?
private var cacheUpdatedAt: Date?
private let refreshIntervalSeconds: TimeInterval = 12
private var cachedUsageSummary: GatewayUsageSummary?
private var cachedUsageErrorText: String?
private var usageCacheUpdatedAt: Date?
private let usageRefreshIntervalSeconds: TimeInterval = 30
private let nodesStore = NodesStore.shared
#if DEBUG
private var testControlChannelConnected: Bool?
@@ -62,7 +58,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
self.loadTask = Task { [weak self] in
guard let self else { return }
await self.refreshCache(force: forceRefresh)
await self.refreshUsageCache(force: forceRefresh)
await MainActor.run {
guard self.isMenuOpen else { return }
self.inject(into: menu)
@@ -110,86 +105,68 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
let width = self.initialWidth(for: menu)
let isConnected = self.isControlChannelConnected
var cursor = insertIndex
var headerView: NSView?
if let snapshot = self.cachedSnapshot {
let now = Date()
let rows = snapshot.rows.filter { row in
if row.key == "main" { return true }
guard let updatedAt = row.updatedAt else { return false }
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
}.sorted { lhs, rhs in
if lhs.key == "main" { return true }
if rhs.key == "main" { return false }
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
}
guard self.isControlChannelConnected else { return }
guard let snapshot = self.cachedSnapshot else {
let headerItem = NSMenuItem()
headerItem.tag = self.tag
headerItem.isEnabled = false
let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(
count: rows.count,
statusText: isConnected ? nil : "Gateway disconnected")),
width: width,
highlighted: false)
headerItem.view = hosted
headerView = hosted
menu.insertItem(headerItem, at: cursor)
cursor += 1
if rows.isEmpty {
menu.insertItem(
self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width),
at: cursor)
cursor += 1
} else {
for row in rows {
let item = NSMenuItem()
item.tag = self.tag
item.isEnabled = true
item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath)
item.view = self.makeHostedView(
rootView: AnyView(SessionMenuLabelView(row: row, width: width)),
width: width,
highlighted: true)
menu.insertItem(item, at: cursor)
cursor += 1
}
}
} else {
let headerItem = NSMenuItem()
headerItem.tag = self.tag
headerItem.isEnabled = false
let statusText = isConnected
? (self.cachedErrorText ?? "Loading sessions…")
: "Gateway disconnected"
let hosted = self.makeHostedView(
headerItem.view = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(
count: 0,
statusText: statusText)),
statusText: self.cachedErrorText ?? "Loading sessions…")),
width: width,
highlighted: false)
headerItem.view = hosted
headerView = hosted
menu.insertItem(headerItem, at: cursor)
cursor += 1
if !isConnected {
menu.insertItem(
self.makeMessageItem(
text: "Connect the gateway to see sessions",
symbolName: "bolt.slash",
width: width),
at: cursor)
cursor += 1
menu.insertItem(headerItem, at: insertIndex)
DispatchQueue.main.async { [weak self, weak view = headerItem.view] in
guard let self, let view else { return }
self.captureMenuWidthIfAvailable(from: view)
}
return
}
cursor = self.insertUsageSection(into: menu, at: cursor, width: width)
let now = Date()
let rows = snapshot.rows.filter { row in
if row.key == "main" { return true }
guard let updatedAt = row.updatedAt else { return false }
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
}.sorted { lhs, rhs in
if lhs.key == "main" { return true }
if rhs.key == "main" { return false }
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
}
let headerItem = NSMenuItem()
headerItem.tag = self.tag
headerItem.isEnabled = false
let headerView = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(count: rows.count, statusText: nil)),
width: width,
highlighted: false)
headerItem.view = headerView
menu.insertItem(headerItem, at: insertIndex)
var cursor = insertIndex + 1
if rows.isEmpty {
menu.insertItem(
self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width),
at: cursor)
return
}
for row in rows {
let item = NSMenuItem()
item.tag = self.tag
item.isEnabled = true
item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath)
item.view = self.makeHostedView(
rootView: AnyView(SessionMenuLabelView(row: row, width: width)),
width: width,
highlighted: true)
menu.insertItem(item, at: cursor)
cursor += 1
}
DispatchQueue.main.async { [weak self, weak headerView] in
guard let self, let headerView else { return }
@@ -263,55 +240,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
_ = cursor
}
private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
let rows = self.usageRows
let errorText = self.cachedUsageErrorText
if rows.isEmpty, errorText == nil {
return cursor
}
var cursor = cursor
let headerItem = NSMenuItem()
headerItem.tag = self.tag
headerItem.isEnabled = false
headerItem.view = self.makeHostedView(
rootView: AnyView(MenuUsageHeaderView(
count: rows.count,
statusText: errorText)),
width: width,
highlighted: false)
menu.insertItem(headerItem, at: cursor)
cursor += 1
if rows.isEmpty {
menu.insertItem(
self.makeMessageItem(text: errorText ?? "No usage available", symbolName: "minus", width: width),
at: cursor)
cursor += 1
return cursor
}
for row in rows {
let item = NSMenuItem()
item.tag = self.tag
item.isEnabled = false
item.view = self.makeHostedView(
rootView: AnyView(UsageMenuLabelView(row: row, width: width)),
width: width,
highlighted: false)
menu.insertItem(item, at: cursor)
cursor += 1
}
return cursor
}
private var usageRows: [UsageRow] {
guard let summary = self.cachedUsageSummary else { return [] }
return summary.primaryRows()
}
private var isControlChannelConnected: Bool {
#if DEBUG
if let override = self.testControlChannelConnected { return override }
@@ -436,40 +364,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
}
}
private func refreshUsageCache(force: Bool) async {
if !force,
let updated = self.usageCacheUpdatedAt,
Date().timeIntervalSince(updated) < self.usageRefreshIntervalSeconds
{
return
}
guard self.isControlChannelConnected else {
self.cachedUsageSummary = nil
self.cachedUsageErrorText = nil
self.usageCacheUpdatedAt = Date()
return
}
do {
self.cachedUsageSummary = try await UsageLoader.loadSummary()
self.cachedUsageErrorText = nil
self.usageCacheUpdatedAt = Date()
} catch {
if self.cachedUsageSummary == nil {
self.cachedUsageErrorText = self.compactUsageError(error)
}
self.usageCacheUpdatedAt = Date()
}
}
private func compactUsageError(_ error: Error) -> String {
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
if message.isEmpty { return "Usage unavailable" }
if message.count > 90 { return "\(message.prefix(87))…" }
return message
}
private func compactError(_ error: Error) -> String {
if let loadError = error as? SessionLoadError {
switch loadError {
@@ -1,44 +0,0 @@
import SwiftUI
struct MenuUsageHeaderView: View {
let count: Int
let statusText: String?
private let paddingTop: CGFloat = 8
private let paddingBottom: CGFloat = 6
private let paddingTrailing: CGFloat = 10
private let paddingLeading: CGFloat = 20
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline) {
Text("Usage")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer(minLength: 10)
Text(self.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
if let statusText, !statusText.isEmpty {
Text(statusText)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
.padding(.top, self.paddingTop)
.padding(.bottom, self.paddingBottom)
.padding(.leading, self.paddingLeading)
.padding(.trailing, self.paddingTrailing)
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
.transaction { txn in txn.animation = nil }
}
private var subtitle: String {
if self.count == 1 { return "1 provider" }
return "\(self.count) providers"
}
}
@@ -1,10 +1,8 @@
import AVFoundation
import OSLog
import SwiftUI
actor MicLevelMonitor {
private let logger = Logger(subsystem: "com.clawdbot", category: "voicewake.meter")
private var engine: AVAudioEngine?
private let engine = AVAudioEngine()
private var update: (@Sendable (Double) -> Void)?
private var running = false
private var smoothedLevel: Double = 0
@@ -12,37 +10,23 @@ actor MicLevelMonitor {
func start(onLevel: @Sendable @escaping (Double) -> Void) async throws {
self.update = onLevel
if self.running { return }
self.logger.info(
"mic level monitor start (\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public))")
let engine = AVAudioEngine()
self.engine = engine
let input = engine.inputNode
let input = self.engine.inputNode
let format = input.outputFormat(forBus: 0)
guard format.channelCount > 0, format.sampleRate > 0 else {
self.engine = nil
throw NSError(
domain: "MicLevelMonitor",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "No audio input available"])
}
input.removeTap(onBus: 0)
input.installTap(onBus: 0, bufferSize: 512, format: format) { [weak self] buffer, _ in
guard let self else { return }
let level = Self.normalizedLevel(from: buffer)
Task { await self.push(level: level) }
}
engine.prepare()
try engine.start()
self.engine.prepare()
try self.engine.start()
self.running = true
}
func stop() {
guard self.running else { return }
if let engine {
engine.inputNode.removeTap(onBus: 0)
engine.stop()
}
self.engine = nil
self.engine.inputNode.removeTap(onBus: 0)
self.engine.stop()
self.running = false
}
@@ -1,7 +1,6 @@
import ClawdbotKit
import Foundation
import Network
import OSLog
actor MacNodeBridgeSession {
private struct TimeoutError: LocalizedError {
@@ -16,18 +15,14 @@ actor MacNodeBridgeSession {
case failed(message: String)
}
private let logger = Logger(subsystem: "com.clawdbot", category: "node.bridge-session")
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private let clock = ContinuousClock()
private var connection: NWConnection?
private var queue: DispatchQueue?
private var buffer = Data()
private var pendingRPC: [String: CheckedContinuation<BridgeRPCResponse, Error>] = [:]
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:]
private var pingTask: Task<Void, Never>?
private var lastPongAt: ContinuousClock.Instant?
private(set) var state: State = .idle
@@ -43,12 +38,6 @@ actor MacNodeBridgeSession {
let params = NWParameters.tcp
params.includePeerToPeer = true
let tcpOptions = NWProtocolTCP.Options()
tcpOptions.enableKeepalive = true
tcpOptions.keepaliveIdle = 30
tcpOptions.keepaliveInterval = 15
tcpOptions.keepaliveCount = 3
params.defaultProtocolStack.transportProtocol = tcpOptions
let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session")
self.connection = connection
@@ -58,10 +47,6 @@ actor MacNodeBridgeSession {
connection.start(queue: queue)
try await Self.waitForReady(stateStream, timeoutSeconds: 6)
connection.stateUpdateHandler = { [weak self] state in
guard let self else { return }
Task { await self.handleConnectionState(state) }
}
try await AsyncTimeout.withTimeout(
seconds: 6,
@@ -92,7 +77,6 @@ actor MacNodeBridgeSession {
if base.type == "hello-ok" {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName)
self.startPingLoop()
await onConnected?(ok.serverName)
} else if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
@@ -129,10 +113,6 @@ actor MacNodeBridgeSession {
let ping = try self.decoder.decode(BridgePing.self, from: nextData)
try await self.send(BridgePong(type: "pong", id: ping.id))
case "pong":
let pong = try self.decoder.decode(BridgePong.self, from: nextData)
self.notePong(pong)
case "invoke":
let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData)
let res = await onInvoke(req)
@@ -202,10 +182,6 @@ actor MacNodeBridgeSession {
}
func disconnect() async {
self.pingTask?.cancel()
self.pingTask = nil
self.lastPongAt = nil
self.connection?.cancel()
self.connection = nil
self.queue = nil
@@ -263,17 +239,12 @@ actor MacNodeBridgeSession {
}
private func send(_ obj: some Encodable) async throws {
guard let connection = self.connection else {
throw NSError(domain: "Bridge", code: 15, userInfo: [
NSLocalizedDescriptionKey: "not connected",
])
}
let data = try self.encoder.encode(obj)
var line = Data()
line.append(data)
line.append(0x0A)
try await withCheckedThrowingContinuation(isolation: self) { (cont: CheckedContinuation<Void, Error>) in
connection.send(content: line, completion: .contentProcessed { err in
self.connection?.send(content: line, completion: .contentProcessed { err in
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
})
}
@@ -309,63 +280,6 @@ actor MacNodeBridgeSession {
}
}
private func startPingLoop() {
self.pingTask?.cancel()
self.lastPongAt = self.clock.now
self.pingTask = Task { [weak self] in
guard let self else { return }
await self.runPingLoop()
}
}
private func runPingLoop() async {
let interval: Duration = .seconds(15)
let timeout: Duration = .seconds(45)
while !Task.isCancelled {
try? await Task.sleep(for: interval)
guard self.connection != nil else { return }
if let last = self.lastPongAt {
let now = self.clock.now
if now > last.advanced(by: timeout) {
let age = last.duration(to: now)
self.logger.warning("Node bridge heartbeat timed out; disconnecting (age: \(String(describing: age), privacy: .public)).")
await self.disconnect()
return
}
}
let id = UUID().uuidString
do {
try await self.send(BridgePing(type: "ping", id: id))
} catch {
self.logger.warning("Node bridge ping send failed; disconnecting (error: \(String(describing: error), privacy: .public)).")
await self.disconnect()
return
}
}
}
private func notePong(_ pong: BridgePong) {
_ = pong
self.lastPongAt = self.clock.now
}
private func handleConnectionState(_ state: NWConnection.State) async {
switch state {
case let .failed(error):
self.logger.warning("Node bridge connection failed; disconnecting (error: \(String(describing: error), privacy: .public)).")
await self.disconnect()
case .cancelled:
self.logger.warning("Node bridge connection cancelled; disconnecting.")
await self.disconnect()
default:
break
}
}
private static func makeStateStream(
for connection: NWConnection) -> AsyncStream<NWConnection.State>
{
@@ -202,23 +202,6 @@ final class MacNodeModeCoordinator {
return 18790
}
static func remoteBridgePort() -> Int {
let fallback = Int(Self.loopbackBridgePort() ?? 18790)
let settings = CommandResolver.connectionSettings()
let sshHost = CommandResolver.parseSSHTarget(settings.target)?.host ?? ""
let base =
ClawdbotConfigFile.remoteGatewayPort(matchingHost: sshHost) ??
GatewayEnvironment.gatewayPort()
guard base > 0 else { return fallback }
return Self.derivePort(base: base, offset: 1, fallback: fallback)
}
private static func derivePort(base: Int, offset: Int, fallback: Int) -> Int {
let derived = base + offset
guard derived > 0, derived <= Int(UInt16.max) else { return fallback }
return derived
}
static func probeEndpoint(_ endpoint: NWEndpoint, timeoutSeconds: Double) async -> Bool {
let connection = NWConnection(to: endpoint, using: .tcp)
let stream = Self.makeStateStream(for: connection)
@@ -286,10 +269,7 @@ final class MacNodeModeCoordinator {
if mode == .remote {
do {
if self.tunnel == nil || self.tunnel?.process.isRunning == false {
let remotePort = Self.remoteBridgePort()
self.tunnel = try await RemotePortTunnel.create(
remotePort: remotePort,
allowRemoteUrlOverride: false)
self.tunnel = try await RemotePortTunnel.create(remotePort: 18790)
}
if let localPort = self.tunnel?.localPort,
let port = NWEndpoint.Port(rawValue: localPort)
@@ -5,16 +5,8 @@ import Foundation
actor MacNodeRuntime {
private let cameraCapture = CameraCaptureService()
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
init(
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
await MainActor.run { LiveMacNodeRuntimeMainActorServices() }
})
{
self.makeMainActorServices = makeMainActorServices
}
@MainActor private let screenRecorder = ScreenRecordService()
@MainActor private let locationService = MacNodeLocationService()
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
let command = req.command
@@ -220,8 +212,7 @@ actor MacNodeRuntime {
ClawdbotLocationGetParams()
let desired = params.desiredAccuracy ??
(Self.locationPreciseEnabled() ? .precise : .balanced)
let services = await self.mainActorServices()
let status = await services.locationAuthorizationStatus()
let status = await self.locationService.authorizationStatus()
if status != .authorizedAlways {
return BridgeInvokeResponse(
id: req.id,
@@ -231,11 +222,11 @@ actor MacNodeRuntime {
message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
}
do {
let location = try await services.currentLocation(
let location = try await self.locationService.currentLocation(
desiredAccuracy: desired,
maxAgeMs: params.maxAgeMs,
timeoutMs: params.timeoutMs)
let isPrecise = await services.locationAccuracyAuthorization() == .fullAccuracy
let isPrecise = await self.locationService.accuracyAuthorization() == .fullAccuracy
let payload = ClawdbotLocationPayload(
lat: location.coordinate.latitude,
lon: location.coordinate.longitude,
@@ -274,8 +265,7 @@ actor MacNodeRuntime {
code: .invalidRequest,
message: "INVALID_REQUEST: screen format must be mp4")
}
let services = await self.mainActorServices()
let res = try await services.recordScreen(
let res = try await self.screenRecorder.record(
screenIndex: params.screenIndex,
durationMs: params.durationMs,
fps: params.fps,
@@ -301,13 +291,6 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func mainActorServices() async -> any MacNodeRuntimeMainActorServices {
if let cachedMainActorServices { return cachedMainActorServices }
let services = await self.makeMainActorServices()
self.cachedMainActorServices = services
return services
}
private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
try await self.ensureA2UIHost()
@@ -1,60 +0,0 @@
import ClawdbotKit
import CoreLocation
import Foundation
@MainActor
protocol MacNodeRuntimeMainActorServices: Sendable {
func recordScreen(
screenIndex: Int?,
durationMs: Int?,
fps: Double?,
includeAudio: Bool?,
outPath: String?) async throws -> (path: String, hasAudio: Bool)
func locationAuthorizationStatus() -> CLAuthorizationStatus
func locationAccuracyAuthorization() -> CLAccuracyAuthorization
func currentLocation(
desiredAccuracy: ClawdbotLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
}
@MainActor
final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
private let screenRecorder = ScreenRecordService()
private let locationService = MacNodeLocationService()
func recordScreen(
screenIndex: Int?,
durationMs: Int?,
fps: Double?,
includeAudio: Bool?,
outPath: String?) async throws -> (path: String, hasAudio: Bool)
{
try await self.screenRecorder.record(
screenIndex: screenIndex,
durationMs: durationMs,
fps: fps,
includeAudio: includeAudio,
outPath: outPath)
}
func locationAuthorizationStatus() -> CLAuthorizationStatus {
self.locationService.authorizationStatus()
}
func locationAccuracyAuthorization() -> CLAccuracyAuthorization {
self.locationService.accuracyAuthorization()
}
func currentLocation(
desiredAccuracy: ClawdbotLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
{
try await self.locationService.currentLocation(
desiredAccuracy: desiredAccuracy,
maxAgeMs: maxAgeMs,
timeoutMs: timeoutMs)
}
}
@@ -29,7 +29,6 @@ extension OnboardingView {
user: user,
host: host,
port: gateway.sshPort)
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
}
self.state.remoteCliPath = gateway.cliPath ?? ""
@@ -607,7 +607,7 @@ extension OnboardingView {
let saved = await self.saveAgentWorkspace(AgentWorkspace.displayPath(for: url))
if saved {
self.workspaceStatus =
"Saved to ~/.clawdbot/clawdbot.json (agents.defaults.workspace)"
"Saved to ~/.clawdbot/clawdbot.json (agent.workspace)"
}
}
}
@@ -12,7 +12,6 @@ extension OnboardingView {
lanHost: "bridge.local",
tailnetDns: "bridge.ts.net",
sshPort: 2222,
gatewayPort: 18789,
cliPath: "/usr/local/bin/clawdbot",
stableID: "bridge-1",
debugID: "bridge-1",
@@ -69,9 +69,8 @@ extension OnboardingView {
private func loadAgentWorkspace() async -> String? {
let root = await ConfigStore.load()
let agents = root["agents"] as? [String: Any]
let defaults = agents?["defaults"] as? [String: Any]
return defaults?["workspace"] as? String
let agent = root["agent"] as? [String: Any]
return agent?["workspace"] as? String
}
@discardableResult
@@ -87,23 +86,17 @@ extension OnboardingView {
@MainActor
private static func buildAndSaveWorkspace(_ workspace: String?) async -> (Bool, String?) {
var root = await ConfigStore.load()
var agents = root["agents"] as? [String: Any] ?? [:]
var defaults = agents["defaults"] as? [String: Any] ?? [:]
var agent = root["agent"] as? [String: Any] ?? [:]
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
defaults.removeValue(forKey: "workspace")
agent.removeValue(forKey: "workspace")
} else {
defaults["workspace"] = trimmed
agent["workspace"] = trimmed
}
if defaults.isEmpty {
agents.removeValue(forKey: "defaults")
if agent.isEmpty {
root.removeValue(forKey: "agent")
} else {
agents["defaults"] = defaults
}
if agents.isEmpty {
root.removeValue(forKey: "agents")
} else {
root["agents"] = agents
root["agent"] = agent
}
do {
try await ConfigStore.save(root)
@@ -140,7 +140,6 @@ enum PermissionManager {
private static func ensureLocation(interactive: Bool) async -> Bool {
let status = CLLocationManager().authorizationStatus
switch status {
// Note: macOS only supports authorizedAlways, not authorizedWhenInUse (iOS only)
case .authorizedAlways:
return true
case .notDetermined:
@@ -202,7 +201,6 @@ enum PermissionManager {
case .location:
let status = CLLocationManager().authorizationStatus
// Note: macOS only supports authorizedAlways
results[cap] = status == .authorizedAlways
}
}
@@ -2,12 +2,11 @@ import Foundation
extension ProcessInfo {
var isPreview: Bool {
guard let raw = getenv("XCODE_RUNNING_FOR_PREVIEWS") else { return false }
return String(cString: raw) == "1"
self.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
}
var isNixMode: Bool {
if let raw = getenv("CLAWDBOT_NIX_MODE"), String(cString: raw) == "1" { return true }
if self.environment["CLAWDBOT_NIX_MODE"] == "1" { return true }
return UserDefaults.standard.bool(forKey: "clawdbot.nixMode")
}
@@ -38,11 +38,7 @@ final class RemotePortTunnel {
Task { await PortGuardian.shared.removeRecord(pid: pid) }
}
static func create(
remotePort: Int,
preferredLocalPort: UInt16? = nil,
allowRemoteUrlOverride: Bool = true) async throws -> RemotePortTunnel
{
static func create(remotePort: Int, preferredLocalPort: UInt16? = nil) async throws -> RemotePortTunnel {
let settings = CommandResolver.connectionSettings()
guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else {
throw NSError(
@@ -53,10 +49,7 @@ final class RemotePortTunnel {
let localPort = try await Self.findPort(preferred: preferredLocalPort)
let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines)
let remotePortOverride =
allowRemoteUrlOverride && remotePort == GatewayEnvironment.gatewayPort()
? Self.resolveRemotePortOverride(for: sshHost)
: nil
let remotePortOverride = Self.resolveRemotePortOverride(for: sshHost)
let resolvedRemotePort = remotePortOverride ?? remotePort
if let override = remotePortOverride {
Self.logger.info(
Loaded 100 of 1150 files, more files were not shown because too many files have changed in this diff. Show more