Compare commits

..
Author SHA1 Message Date
Peter Steinberger 3ad11051c3 docs: expand Slack scope notes 2026-01-06 01:52:08 +01:00
Peter Steinberger 3b6b8e7696 Merge branch 'main' into docs/fix-slack-scopes 2026-01-06 00:39:12 +00:00
Manuel Hettich f1b023dc6c docs(slack): add missing scopes for DM replies
The manifest was missing scopes required for conversations.open API,
which is used to get DM channel IDs for replies.

Added scopes:
- im:write (required for DM replies)
- im:read (list DM conversations)
- mpim:write (reply to multi-person DMs)
- mpim:read (list MPDMs)
- groups:write (private channel interactions)
- groups:read (list private channels)

Without im:write, the example config (dm.enabled: true) cannot
actually reply to DMs - fails with missing_scope error.
2026-01-05 19:14:31 +00:00
1092 changed files with 21522 additions and 115690 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: jobs:
checks: checks:
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -52,100 +52,32 @@ jobs:
exit 1 exit 1
- name: Setup Node.js - name: Setup Node.js
if: matrix.runtime == 'node'
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 24
check-latest: true check-latest: true
- name: Setup Bun - name: Setup Bun
if: matrix.runtime == 'bun'
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
with: 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 - name: Setup Node.js (tooling for bun)
run: | if: matrix.runtime == 'bun'
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
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 24
check-latest: true check-latest: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Runtime versions - name: Runtime versions
run: | run: |
node -v node -v
npm -v npm -v
bun -v if [ "${{ matrix.runtime }}" = "bun" ]; then bun -v; fi
- name: Capture node path - name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV" run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
@@ -412,7 +344,7 @@ jobs:
PY PY
android: android:
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -448,8 +380,6 @@ jobs:
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
with:
accept-android-sdk-licenses: false
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v4 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 dist
*.bun-build *.bun-build
pnpm-lock.yaml pnpm-lock.yaml
bun.lock
bun.lockb
coverage coverage
.pnpm-store .pnpm-store
.worktrees/ .worktrees/
@@ -50,7 +48,3 @@ apps/ios/*.dSYM.zip
# provisioning profiles (local) # provisioning profiles (local)
apps/ios/*.mobileprovision apps/ios/*.mobileprovision
.env .env
# Local untracked files
.local/
.vscode/
+16 -49
View File
@@ -5,19 +5,9 @@
- Tests: colocated `*.test.ts`. - Tests: colocated `*.test.ts`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`. - 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 ## Build, Test, and Development Commands
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
- Install deps: `pnpm install` - Install deps: `pnpm install`
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches). - Run CLI in dev: `pnpm clawdbot ...` (tsx entry) or `pnpm dev` for `src/index.ts`.
- 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.
- Type-check/build: `pnpm build` (tsc) - Type-check/build: `pnpm build` (tsc)
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format) - Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage` - Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
@@ -25,10 +15,8 @@
## Coding Style & Naming Conventions ## Coding Style & Naming Conventions
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`. - Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
- Formatting/linting via Biome; run `pnpm lint` before commits. - 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`. - 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. - 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 ## Testing Guidelines
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements). - Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
@@ -42,69 +30,48 @@
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`). - Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- Group related changes; avoid bundling unrelated refactors. - Group related changes; avoid bundling unrelated refactors.
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags. - PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
- 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 ## Security & Configuration Tips
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out. - 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. - Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples. - Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
## Troubleshooting
- Rebrand/migration issues (Clawdis → Clawdbot) or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
## Agent-Specific Notes ## Agent-Specific Notes
- Vocabulary: "makeup" = "mac app". - 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.**
- When answering questions, respond with high-confidence answers only: verify in code; do not guess. - 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`.
- 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.** - 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.
- 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.
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code. - SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync. - Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
- 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. - **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. - **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`. - 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. - 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 key file lives at `~/Library/CloudStorage/Dropbox/Backup/AppStore/AuthKey_NJF3NFGTS3.p8` (Sparkle keys live under `~/Library/CloudStorage/Dropbox/Backup/Sparkle`).
- 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). - Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are in `~/.profile`.
- **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:** 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 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:** 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 explicitly requested. - **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 explicitly requested. - **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session. - **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those. - **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. - 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.
- **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.
- 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. - Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac. - Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel. - Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
- Voice wake forwarding tips: - Voice wake forwarding tips:
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes. - Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`. - launchd PATH is minimal; ensure the app’s launch agent sets PATH to include `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/steipete/Library/pnpm` so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping. - For manual `clawdbot send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
## Exclamation Mark Escaping Workaround ## Exclamation Mark Escaping Workaround
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `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 ```bash
# WRONG - will send "Hello\\!" with backslash # 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 # CORRECT - use heredoc to avoid escaping
clawdbot message send --to "+1234" --message "$(cat <<'EOF' clawdbot send --to "+1234" --message "$(cat <<'EOF'
Hello! Hello!
EOF EOF
)" )"
+156 -157
View File
@@ -1,167 +1,66 @@
# Changelog # Changelog
**Why this looks different:** the project was renamed from **Clawdis → Clawdbot**. To make the transition clear, releases now use **date-based versions** (`YYYY.M.D`) and the changelog is **compressed** into milestone summaries. Full detail still lives in git history and the docs.
## Unreleased ## Unreleased
- 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
## 2026.1.8
### 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.
### Breaking ### 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). - 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.
### Fixes ### 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. - Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
- **Agent loop + compaction:** compaction/pruning tuning, overflow handling, safer bootstrap context, and per-provider threading/confirmations; opt-in tool-result pruning + compact tracking. - Linux: auto-attempt lingering during onboarding (try without sudo, fallback to sudo) and prompt on install/restart to keep the gateway alive after logout/idle. Thanks @tobiasbischoff for PR #237.
- **Sandbox + tools:** per-agent sandbox overrides, workspaceAccess controls, session tool visibility, tool policy overrides, process isolation, and tool schema/timeout/reaction unification. - TUI: migrate key handling to the updated pi-tui Key matcher API.
- **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. - Logging: redact sensitive tokens in verbose tool summaries by default (configurable patterns).
- **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. - macOS: prefer gateway config reads/writes in local mode (fall back to disk if the gateway is unavailable).
- **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: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`.
- **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. - macOS: Connections settings now use a custom sidebar to avoid toolbar toggle issues, with rounded styling and full-width row hit targets.
- **Packaging/compat:** npm dist folder coverage, Node 25 qrcode-terminal import fixes, Bun/Playwright/WebSocket patches, and Docker Bun install. - macOS: drop deprecated `afterMs` from agent wait params to match gateway schema.
- **Docs:** new FAQ/ClawdHub/config examples/showcase entries and clarified auth, sandbox, and systemd docs. - Auth: add OpenAI Codex OAuth support and migrate legacy oauth.json into auth.json.
- Model: `/model` list shows auth source (masked key or OAuth email) per provider.
- Model: `/model list` is an alias for `/model`.
- Model: `/model` output now includes auth source location (env/auth.json/models.json).
- Model: avoid duplicate `missing (missing)` auth labels in `/model` list output.
- Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding.
- Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments.
- Control UI: show a reading indicator bubble while the assistant is responding.
- Control UI: animate reading indicator dots (honors reduced-motion).
- Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping).
- Status: show runtime (docker/direct) and move shortcuts to `/help`.
- Status: show model auth source (api-key/oauth).
- Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split.
- Block streaming: preserve leading indentation in block replies (lists, indented fences).
- Docs: document systemd lingering and logged-in session requirements on macOS/Windows.
- Auto-reply: unify tool/block/final delivery across providers and apply consistent heartbeat/prefix handling. Thanks @MSch for PR #225 (superseded commit 92c953d0749143eb2a3f31f3cd6ad0e8eabf48c3).
- Heartbeat: make HEARTBEAT_OK ack padding configurable across heartbeat and cron delivery. (#238) — thanks @jalehman
- WhatsApp: set sender E.164 for direct chats so owner commands work in DMs.
- Slack: keep auto-replies in the original thread when responding to thread messages. Thanks @scald for PR #251.
- Docs: clarify Slack manifest scopes (current vs optional) with references. Thanks @jarvis-medmatic for PR #235.
- Control UI: avoid Slack config ReferenceError by reading slack config snapshots. Thanks @sreekaransrinath for PR #249.
### Maintenance ### Maintenance
- Skills additions (Himalaya email, CodexBar, 1Password). - Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.
- Dependency refreshes (pi-* stack, Slack SDK, discord-api-types, file-type, zod, Biome, Vite). - Skills: add CodexBar model usage helper with macOS requirement metadata.
- Refactors: centralized group allowlist/mention policy; lint/import cleanup; switch tsx → bun for TS execution. - Skills: add 1Password CLI skill with op examples.
- Lint: organize imports and wrap long lines in reply commands.
- Deps: update to latest across the repo.
## 2026.1.5-3
### Fixes
- NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid `ERR_MODULE_NOT_FOUND` in Node 25 npx installs.
## 2026.1.5-2
### Fixes
- NPM package: include `dist/sessions` so `clawdbot agent` resolves session helpers in npx installs.
- Node 25: avoid unsupported directory import by targeting `qrcode-terminal/vendor/QRCode/*.js` modules.
## 2026.1.5-1
### Fixes
- NPM package: include `dist/sessions` so `clawdbot agent` resolves session helpers in npx installs.
- Node 25: avoid unsupported directory import by targeting `qrcode-terminal/vendor/QRCode/index.js`.
## 2026.1.5 ## 2026.1.5
@@ -170,7 +69,6 @@
- Agent tools: new `image` tool routed to the image model (when configured). - Agent tools: new `image` tool routed to the image model (when configured).
- Config: default model shorthands (`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`). - Config: default model shorthands (`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`).
- Docs: document built-in model shorthands + precedence (user config wins). - 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`).
### Fixes ### Fixes
- Control UI: render Markdown in tool result cards. - Control UI: render Markdown in tool result cards.
@@ -189,9 +87,110 @@
- Env: load global `$CLAWDBOT_STATE_DIR/.env` (`~/.clawdbot/.env`) as a fallback after CWD `.env`. - 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). - 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). - 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`). - Onboarding: when running from source, auto-build missing Control UI assets (`pnpm ui:build`).
- Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed). - 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. - Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off.
- Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events. - 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. - 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. - Control UI: render Markdown in chat messages (sanitized).
## 2026.1.4
### Highlights
- Rename completion: all CLIs, paths, bundle IDs, env vars, and docs standardized on **Clawdbot**.
- Agent-to-agent relay: `sessions_send` 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
- 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
- Doctor migrates legacy Clawdis config/service installs and normalizes sandbox Docker names.
- Doctor checks sandbox image availability and offers to build or fall back to legacy images.
- Presence beacons keep node lists fresh; Instances view stays accurate.
- Block streaming/chunking reliability (Telegram/Discord ordering, fewer duplicates).
- WhatsApp GIF playback for MP4-based GIFs.
- Onboarding + Control UI basePath handling fixes and UI polish.
- Clearer tool summaries, reduced log noise, and safer watchdog/queue behavior.
- Canvas host watcher resilience; build and packaging edge cases cleaned up.
### Docs
- Sandbox setup, hot reload, port config, and session announce step coverage.
- Skills and onboarding clarifications + additional examples.
## 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
- 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
- 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 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 RUN corepack enable
WORKDIR /app WORKDIR /app
+152 -191
View File
@@ -16,141 +16,109 @@
</p> </p>
**Clawdbot** is a *personal AI assistant* you run on your own devices. **Clawdbot** is a *personal AI assistant* you run on your own devices.
It answers you on the 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, 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.
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it. If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
[Website](https://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://clawdbot.com](https://clawdbot.com) · Docs: [https://docs.clawdbot.com](https://docs.clawdbot.com/) · Showcase: [https://docs.clawdbot.com/showcase](https://docs.clawdbot.com/showcase) · FAQ: [https://docs.clawdbot.com/faq](https://docs.clawdbot.com/faq) · Wizard: [https://docs.clawdbot.com/wizard](https://docs.clawdbot.com/wizard) · Nix: [https://github.com/clawdbot/nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) · Docker: [https://docs.clawdbot.com/docker](https://docs.clawdbot.com/docker) · Discord: [https://discord.gg/clawd](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)**. 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**.
Works with npm, pnpm, or bun. Works with npm, pnpm, or bun.
New install? Start here: [Getting started](https://docs.clawd.bot/getting-started)
**Subscriptions (OAuth):** **Subscriptions (OAuth):**
- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max) - **Anthropic** (Claude Pro/Max)
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex) - **OpenAI** (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). 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.clawdbot.com/onboarding).
## Models (selection + auth) ## Recommended setup (from source)
- Models config + CLI: [Models](https://docs.clawd.bot/models) Do **not** download prebuilt binaries. Build from source.
- 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 ```bash
# Clone this repo
git clone https://github.com/clawdbot/clawdbot.git git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot cd clawdbot
pnpm install pnpm install
pnpm ui:build # auto-installs UI deps on first run
pnpm build pnpm build
pnpm ui:build
pnpm clawdbot onboard
```
pnpm clawdbot onboard --install-daemon ## Quick start (from source)
Runtime: **Node ≥22** + **pnpm**.
```bash
pnpm install
pnpm build
pnpm ui:build
# Recommended: run the onboarding wizard
pnpm clawdbot onboard
# Link WhatsApp (stores creds in ~/.clawdbot/credentials)
pnpm clawdbot login
# Start the gateway
pnpm clawdbot gateway --port 18789 --verbose
# Dev loop (auto-reload on TS changes) # Dev loop (auto-reload on TS changes)
pnpm gateway:watch pnpm gateway:watch
# Send a message
pnpm clawdbot send --to +1234567890 --message "Hello from Clawdbot"
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
pnpm clawdbot agent --message "Ship checklist" --thinking high
``` ```
Note: `pnpm clawdbot ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `clawdbot` binary. Upgrading? `clawdbot doctor`.
## Security defaults (DM access) If you run from source, prefer `pnpm clawdbot …` (not global `clawdbot`).
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.
## Highlights ## Highlights
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, providers, tools, and events. - **[Local-first Gateway](https://docs.clawdbot.com/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-surface inbox](https://docs.clawdbot.com/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.clawdbot.com/voicewake) + [Talk Mode](https://docs.clawdbot.com/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
- **[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.clawdbot.com/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawdbot.com/refactor/canvas-a2ui).
- **[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.clawdbot.com/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
- **[First-class tools](https://docs.clawd.bot/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions. - **[Companion apps](https://docs.clawdbot.com/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawdbot.com/nodes).
- **[Companion apps](https://docs.clawd.bot/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes). - **[Onboarding](https://docs.clawdbot.com/wizard) + [skills](https://docs.clawdbot.com/skills)** — wizard-driven setup with bundled/managed/workspace skills.
- **[Onboarding](https://docs.clawd.bot/wizard) + [skills](https://docs.clawd.bot/skills)** — wizard-driven setup with bundled/managed/workspace skills.
## Everything we built so far ## Everything we built so far
### Core platform ### 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). - [Gateway WS control plane](https://docs.clawdbot.com/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawdbot.com/web), and [Canvas host](https://docs.clawdbot.com/refactor/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). - [CLI surface](https://docs.clawdbot.com/agent-send): gateway, agent, send, [wizard](https://docs.clawdbot.com/wizard), and [doctor](https://docs.clawdbot.com/doctor).
- [Pi agent runtime](https://docs.clawd.bot/agent) in RPC mode with tool streaming and block streaming. - [Pi agent runtime](https://docs.clawdbot.com/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). - [Session model](https://docs.clawdbot.com/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawdbot.com/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). - [Media pipeline](https://docs.clawdbot.com/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawdbot.com/audio).
### Providers ### Surfaces + 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). - [Providers](https://docs.clawdbot.com/surface): [WhatsApp](https://docs.clawdbot.com/whatsapp) (Baileys), [Telegram](https://docs.clawdbot.com/telegram) (grammY), [Slack](https://docs.clawdbot.com/slack) (Bolt), [Discord](https://docs.clawdbot.com/discord) (discord.js), [Signal](https://docs.clawdbot.com/signal) (signal-cli), [iMessage](https://docs.clawdbot.com/imessage) (imsg), [WebChat](https://docs.clawdbot.com/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). - [Group routing](https://docs.clawdbot.com/group-messages): mention gating, reply tags, per-surface chunking and routing. Surface rules: [Surface routing](https://docs.clawdbot.com/surface).
### Apps + nodes ### 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. - [macOS app](https://docs.clawdbot.com/macos): menu bar control plane, [Voice Wake](https://docs.clawdbot.com/voicewake)/PTT, [Talk Mode](https://docs.clawdbot.com/talk) overlay, [WebChat](https://docs.clawdbot.com/webchat), debug tools, [remote gateway](https://docs.clawdbot.com/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. - [iOS node](https://docs.clawdbot.com/ios): [Canvas](https://docs.clawdbot.com/mac/canvas), [Voice Wake](https://docs.clawdbot.com/voicewake), [Talk Mode](https://docs.clawdbot.com/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. - [Android node](https://docs.clawdbot.com/android): [Canvas](https://docs.clawdbot.com/mac/canvas), [Talk Mode](https://docs.clawdbot.com/talk), camera, screen recording, optional SMS.
- [macOS node mode](https://docs.clawd.bot/nodes): system.run/notify + canvas/camera exposure. - [macOS node mode](https://docs.clawdbot.com/nodes): system.run/notify + canvas/camera exposure.
### Tools + automation ### Tools + automation
- [Browser control](https://docs.clawd.bot/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles. - [Browser control](https://docs.clawdbot.com/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. - [Canvas](https://docs.clawdbot.com/mac/canvas): [A2UI](https://docs.clawdbot.com/refactor/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. - [Nodes](https://docs.clawdbot.com/nodes): camera snap/clip, screen record, [location.get](https://docs.clawdbot.com/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). - [Cron + wakeups](https://docs.clawdbot.com/cron); [webhooks](https://docs.clawdbot.com/webhook); [Gmail Pub/Sub](https://docs.clawdbot.com/gmail-pubsub).
- [Skills platform](https://docs.clawd.bot/skills): bundled, managed, and workspace skills with install gating + UI. - [Skills platform](https://docs.clawdbot.com/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).
### Ops + packaging ### Ops + packaging
- [Control UI](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/webchat) served directly from the Gateway. - [Control UI](https://docs.clawdbot.com/web) + [WebChat](https://docs.clawdbot.com/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. - [Tailscale Serve/Funnel](https://docs.clawdbot.com/tailscale) or [SSH tunnels](https://docs.clawdbot.com/remote) with token/password auth.
- [Nix mode](https://docs.clawd.bot/nix) for declarative config; [Docker](https://docs.clawd.bot/docker)-based installs. - [Nix mode](https://docs.clawdbot.com/nix) for declarative config; [Docker](https://docs.clawdbot.com/docker)-based installs.
- [Doctor](https://docs.clawd.bot/doctor) migrations, [logging](https://docs.clawd.bot/logging). - [Doctor](https://docs.clawdbot.com/doctor) migrations, [logging](https://docs.clawdbot.com/logging).
## How it works (short) ## How it works (short)
@@ -172,12 +140,12 @@ WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
## Key subsystems ## Key subsystems
- **[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)). - **[Gateway WebSocket network](https://docs.clawdbot.com/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawdbot.com/gateway)).
- **[Tailscale exposure](https://docs.clawd.bot/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawd.bot/remote)). - **[Tailscale exposure](https://docs.clawdbot.com/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawdbot.com/remote)).
- **[Browser control](https://docs.clawd.bot/browser)** — clawd‑managed Chrome/Chromium with CDP control. - **[Browser control](https://docs.clawdbot.com/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)). - **[Canvas + A2UI](https://docs.clawdbot.com/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawdbot.com/refactor/canvas-a2ui)).
- **[Voice Wake](https://docs.clawd.bot/voicewake) + [Talk Mode](https://docs.clawd.bot/talk)** — always‑on speech and continuous conversation. - **[Voice Wake](https://docs.clawdbot.com/voicewake) + [Talk Mode](https://docs.clawdbot.com/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`. - **[Nodes](https://docs.clawdbot.com/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
## Tailscale access (Gateway dashboard) ## Tailscale access (Gateway dashboard)
@@ -193,7 +161,7 @@ Notes:
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set. - Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown. - Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
Details: [Tailscale guide](https://docs.clawd.bot/tailscale) · [Web surfaces](https://docs.clawd.bot/web) Details: [Tailscale guide](https://docs.clawdbot.com/tailscale) · [Web surfaces](https://docs.clawdbot.com/web)
## Remote Gateway (Linux is great) ## Remote Gateway (Linux is great)
@@ -203,7 +171,7 @@ It’s perfectly fine to run the Gateway on a small Linux instance. Clients (mac
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`. - **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. 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) Details: [Remote access](https://docs.clawdbot.com/remote) · [Nodes](https://docs.clawdbot.com/nodes) · [Security](https://docs.clawdbot.com/security)
## macOS permissions via the Gateway protocol ## macOS permissions via the Gateway protocol
@@ -218,7 +186,7 @@ Elevated bash (host permissions) is separate from macOS TCC:
- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted. - 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`. - 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) Details: [Nodes](https://docs.clawdbot.com/nodes) · [macOS app](https://docs.clawdbot.com/macos) · [Gateway protocol](https://docs.clawdbot.com/architecture)
## Agent to Agent (sessions_* tools) ## Agent to Agent (sessions_* tools)
@@ -227,24 +195,22 @@ Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd
- `sessions_history` — fetch transcript logs for a session. - `sessions_history` — fetch transcript logs for a session.
- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`). - `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) Details: [Session tools](https://docs.clawdbot.com/session-tool)
## Skills registry (ClawdHub) ## 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 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) https://ClawdHub.com
## Chat commands ## Chat commands
Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only): Send these in WhatsApp/Telegram/Slack/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 - `/new` or `/reset` — reset the session
- `/compact` — compact session context (summary)
- `/think <level>` — off|minimal|low|medium|high - `/think <level>` — off|minimal|low|medium|high
- `/verbose on|off` - `/verbose on|off`
- `/cost on|off` — append per-response token/cost usage lines
- `/restart` — restart the gateway (owner-only in groups) - `/restart` — restart the gateway (owner-only in groups)
- `/activation mention|always` — group activation toggle (groups only) - `/activation mention|always` — group activation toggle (groups only)
@@ -274,17 +240,17 @@ Note: signed builds required for macOS permissions to stick across rebuilds (see
- Voice trigger forwarding + Canvas surface. - Voice trigger forwarding + Canvas surface.
- Controlled via `clawdbot nodes …`. - Controlled via `clawdbot nodes …`.
Runbook: [iOS connect](https://docs.clawd.bot/ios). Runbook: [iOS connect](https://docs.clawdbot.com/ios).
### Android node (optional) ### Android node (optional)
- Pairs via the same Bridge + pairing flow as iOS. - Pairs via the same Bridge + pairing flow as iOS.
- Exposes Canvas, Camera, and Screen capture commands. - Exposes Canvas, Camera, and Screen capture commands.
- Runbook: [Android connect](https://docs.clawd.bot/android). - Runbook: [Android connect](https://docs.clawdbot.com/android).
## Agent workspace + skills ## 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`. - Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
- Skills: `~/clawd/skills/<skill>/SKILL.md`. - Skills: `~/clawd/skills/<skill>/SKILL.md`.
@@ -300,26 +266,25 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
} }
``` ```
[Full configuration reference (all keys + examples).](https://docs.clawd.bot/configuration) [Full configuration reference (all keys + examples).](https://docs.clawdbot.com/configuration)
## Security model (important) ## Security model (important)
- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you. - **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. - **Group/channel safety:** set `agent.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`. - **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`; 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) Details: [Security guide](https://docs.clawdbot.com/security) · [Docker + sandboxing](https://docs.clawdbot.com/docker) · [Sandbox config](https://docs.clawdbot.com/configuration)
### [WhatsApp](https://docs.clawd.bot/whatsapp) ### [WhatsApp](https://docs.clawdbot.com/whatsapp)
- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`). - Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`. - Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
- If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
### [Telegram](https://docs.clawd.bot/telegram) ### [Telegram](https://docs.clawdbot.com/telegram)
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins). - 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 ```json5
{ {
@@ -329,14 +294,14 @@ Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxi
} }
``` ```
### [Slack](https://docs.clawd.bot/slack) ### [Slack](https://docs.clawdbot.com/slack)
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `slack.botToken` + `slack.appToken`). - Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `slack.botToken` + `slack.appToken`).
### [Discord](https://docs.clawd.bot/discord) ### [Discord](https://docs.clawdbot.com/discord)
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins). - 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 ```json5
{ {
@@ -346,16 +311,15 @@ Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxi
} }
``` ```
### [Signal](https://docs.clawd.bot/signal) ### [Signal](https://docs.clawdbot.com/signal)
- Requires `signal-cli` and a `signal` config section. - Requires `signal-cli` and a `signal` config section.
### [iMessage](https://docs.clawd.bot/imessage) ### [iMessage](https://docs.clawdbot.com/imessage)
- macOS only; Messages must be signed in. - 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) ### [WebChat](https://docs.clawdbot.com/webchat)
- Uses the Gateway WebSocket; no separate WebChat port/config. - Uses the Gateway WebSocket; no separate WebChat port/config.
@@ -374,99 +338,96 @@ Browser control (optional):
## Docs ## Docs
Use these when you’re past the onboarding flow and want the deeper reference. 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) - [Start with the docs index for navigation and “what’s where.”](https://docs.clawdbot.com/)
- [Read the architecture overview for the gateway + protocol model.](https://docs.clawd.bot/architecture) - [Read the architecture overview for the gateway + protocol model.](https://docs.clawdbot.com/architecture)
- [Use the full configuration reference when you need every key and example.](https://docs.clawd.bot/configuration) - [Use the full configuration reference when you need every key and example.](https://docs.clawdbot.com/configuration)
- [Run the Gateway by the book with the operational runbook.](https://docs.clawd.bot/gateway) - [Run the Gateway by the book with the operational runbook.](https://docs.clawdbot.com/gateway)
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawd.bot/web) - [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawdbot.com/web)
- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawd.bot/remote) - [Understand remote access over SSH tunnels or tailnets.](https://docs.clawdbot.com/remote)
- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawd.bot/wizard) - [Follow the onboarding wizard flow for a guided setup.](https://docs.clawdbot.com/wizard)
- [Wire external triggers via the webhook surface.](https://docs.clawd.bot/webhook) - [Wire external triggers via the webhook surface.](https://docs.clawdbot.com/webhook)
- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/gmail-pubsub) - [Set up Gmail Pub/Sub triggers.](https://docs.clawdbot.com/gmail-pubsub)
- [Learn the macOS menu bar companion details.](https://docs.clawd.bot/mac/menu-bar) - [Learn the macOS menu bar companion details.](https://docs.clawdbot.com/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) - [Platform guides: Windows](https://docs.clawdbot.com/windows), [Linux](https://docs.clawdbot.com/linux), [macOS](https://docs.clawdbot.com/macos), [iOS](https://docs.clawdbot.com/ios), [Android](https://docs.clawdbot.com/android)
- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/troubleshooting) - [Debug common failures with the troubleshooting guide.](https://docs.clawdbot.com/troubleshooting)
- [Review security guidance before exposing anything.](https://docs.clawd.bot/security) - [Review security guidance before exposing anything.](https://docs.clawdbot.com/security)
## Advanced docs (discovery + control) ## Advanced docs (discovery + control)
- [Discovery + transports](https://docs.clawd.bot/discovery) - [Discovery + transports](https://docs.clawdbot.com/discovery)
- [Bonjour/mDNS](https://docs.clawd.bot/bonjour) - [Bonjour/mDNS](https://docs.clawdbot.com/bonjour)
- [Gateway pairing](https://docs.clawd.bot/gateway/pairing) - [Gateway pairing](https://docs.clawdbot.com/gateway/pairing)
- [Remote gateway README](https://docs.clawd.bot/remote-gateway-readme) - [Remote gateway README](https://docs.clawdbot.com/remote-gateway-readme)
- [Control UI](https://docs.clawd.bot/control-ui) - [Control UI](https://docs.clawdbot.com/control-ui)
- [Dashboard](https://docs.clawd.bot/dashboard) - [Dashboard](https://docs.clawdbot.com/dashboard)
## Operations & troubleshooting ## Operations & troubleshooting
- [Health checks](https://docs.clawd.bot/health) - [Health checks](https://docs.clawdbot.com/health)
- [Gateway lock](https://docs.clawd.bot/gateway-lock) - [Gateway lock](https://docs.clawdbot.com/gateway-lock)
- [Background process](https://docs.clawd.bot/background-process) - [Background process](https://docs.clawdbot.com/background-process)
- [Browser troubleshooting (Linux)](https://docs.clawd.bot/browser-linux-troubleshooting) - [Browser troubleshooting (Linux)](https://docs.clawdbot.com/browser-linux-troubleshooting)
- [Logging](https://docs.clawd.bot/logging) - [Logging](https://docs.clawdbot.com/logging)
## Deep dives ## Deep dives
- [Agent loop](https://docs.clawd.bot/agent-loop) - [Agent loop](https://docs.clawdbot.com/agent-loop)
- [Presence](https://docs.clawd.bot/presence) - [Presence](https://docs.clawdbot.com/presence)
- [TypeBox schemas](https://docs.clawd.bot/typebox) - [TypeBox schemas](https://docs.clawdbot.com/typebox)
- [RPC adapters](https://docs.clawd.bot/rpc) - [RPC adapters](https://docs.clawdbot.com/rpc)
- [Queue](https://docs.clawd.bot/queue) - [Queue](https://docs.clawdbot.com/queue)
## Workspace & skills ## Workspace & skills
- [Skills config](https://docs.clawd.bot/skills-config) - [Skills config](https://docs.clawdbot.com/skills-config)
- [Default AGENTS](https://docs.clawd.bot/AGENTS.default) - [Default AGENTS](https://docs.clawdbot.com/AGENTS.default)
- [Templates: AGENTS](https://docs.clawd.bot/templates/AGENTS) - [Templates: AGENTS](https://docs.clawdbot.com/templates/AGENTS)
- [Templates: BOOTSTRAP](https://docs.clawd.bot/templates/BOOTSTRAP) - [Templates: BOOTSTRAP](https://docs.clawdbot.com/templates/BOOTSTRAP)
- [Templates: IDENTITY](https://docs.clawd.bot/templates/IDENTITY) - [Templates: IDENTITY](https://docs.clawdbot.com/templates/IDENTITY)
- [Templates: SOUL](https://docs.clawd.bot/templates/SOUL) - [Templates: SOUL](https://docs.clawdbot.com/templates/SOUL)
- [Templates: TOOLS](https://docs.clawd.bot/templates/TOOLS) - [Templates: TOOLS](https://docs.clawdbot.com/templates/TOOLS)
- [Templates: USER](https://docs.clawd.bot/templates/USER) - [Templates: USER](https://docs.clawdbot.com/templates/USER)
## Platform internals ## Platform internals
- [macOS dev setup](https://docs.clawd.bot/mac/dev-setup) - [macOS dev setup](https://docs.clawdbot.com/mac/dev-setup)
- [macOS menu bar](https://docs.clawd.bot/mac/menu-bar) - [macOS menu bar](https://docs.clawdbot.com/mac/menu-bar)
- [macOS voice wake](https://docs.clawd.bot/mac/voicewake) - [macOS voice wake](https://docs.clawdbot.com/mac/voicewake)
- [iOS node](https://docs.clawd.bot/ios) - [iOS node](https://docs.clawdbot.com/ios)
- [Android node](https://docs.clawd.bot/android) - [Android node](https://docs.clawdbot.com/android)
- [Windows (WSL2)](https://docs.clawd.bot/windows) - [Windows app](https://docs.clawdbot.com/windows)
- [Linux app](https://docs.clawd.bot/linux) - [Linux app](https://docs.clawdbot.com/linux)
## Email hooks (Gmail) ## Email hooks (Gmail)
- [docs.clawd.bot/gmail-pubsub](https://docs.clawd.bot/gmail-pubsub) [Gmail Pub/Sub wiring (gcloud + gogcli), hook tokens, and auto-watch behavior are documented here.](https://docs.clawdbot.com/gmail-pubsub)
Gateway auto-starts the watcher when `hooks.enabled=true` and `hooks.gmail.account` is set; `clawdbot hooks gmail run` is the manual daemon wrapper if you don’t want auto-start.
```bash
clawdbot hooks gmail setup --account you@gmail.com
clawdbot hooks gmail run
```
## Clawd ## Clawd
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞 Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
by Peter Steinberger and the community. by Peter Steinberger and the community.
- [clawd.me](https://clawd.me) - https://clawd.me
- [soul.md](https://soul.md) - https://soul.md
- [steipete.me](https://steipete.me) - https://steipete.me
## Community ## Community
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
AI/vibe-coded PRs welcome! 🤖 AI/vibe-coded PRs welcome! 🤖
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
Thanks to all clawtributors: Thanks to all clawtributors:
<p align="left"> <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/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/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/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/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/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/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/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/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/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/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/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/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="mbelinky" title="mbelinky"/></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/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/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="omniwired" title="omniwired"/></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/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/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/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/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/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="vsabavat" title="vsabavat"/></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/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/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/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/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/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/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/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/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/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/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="djangonavarro220" title="djangonavarro220"/></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/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/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/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> </p>
+11 -11
View File
@@ -13,7 +13,7 @@ public struct WakeWordSegment: Sendable, Equatable {
self.range = range self.range = range
} }
public var end: TimeInterval { start + duration } public var end: TimeInterval { self.start + self.duration }
} }
public struct WakeWordGateConfig: Sendable, Equatable { public struct WakeWordGateConfig: Sendable, Equatable {
@@ -62,10 +62,10 @@ public enum WakeWordGate {
segments: [WakeWordSegment], segments: [WakeWordSegment],
config: WakeWordGateConfig) config: WakeWordGateConfig)
-> WakeWordGateMatch? { -> WakeWordGateMatch? {
let triggerTokens = normalizeTriggers(config.triggers) let triggerTokens = self.normalizeTriggers(config.triggers)
guard !triggerTokens.isEmpty else { return nil } guard !triggerTokens.isEmpty else { return nil }
let tokens = normalizeSegments(segments) let tokens = self.normalizeSegments(segments)
guard !tokens.isEmpty else { return nil } guard !tokens.isEmpty else { return nil }
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)? var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
@@ -89,7 +89,7 @@ public enum WakeWordGate {
} }
guard let best else { return nil } 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) .trimmingCharacters(in: Self.whitespaceAndPunctuation)
guard command.count >= config.minCommandLength else { return nil } guard command.count >= config.minCommandLength else { return nil }
return WakeWordGateMatch(triggerEndTime: best.triggerEnd, postGap: best.gap, command: command) return WakeWordGateMatch(triggerEndTime: best.triggerEnd, postGap: best.gap, command: command)
@@ -111,7 +111,7 @@ public enum WakeWordGate {
} }
let text = segments let text = segments
.filter { $0.start >= threshold && !normalizeToken($0.text).isEmpty } .filter { $0.start >= threshold && !self.normalizeToken($0.text).isEmpty }
.map(\.text) .map(\.text)
.joined(separator: " ") .joined(separator: " ")
return text.trimmingCharacters(in: Self.whitespaceAndPunctuation) return text.trimmingCharacters(in: Self.whitespaceAndPunctuation)
@@ -121,7 +121,7 @@ public enum WakeWordGate {
guard !text.isEmpty else { return false } guard !text.isEmpty else { return false }
let normalized = text.lowercased() let normalized = text.lowercased()
for trigger in triggers { for trigger in triggers {
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation).lowercased() let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation).lowercased()
if token.isEmpty { continue } if token.isEmpty { continue }
if normalized.contains(token) { return true } if normalized.contains(token) { return true }
} }
@@ -131,11 +131,11 @@ public enum WakeWordGate {
public static func stripWake(text: String, triggers: [String]) -> String { public static func stripWake(text: String, triggers: [String]) -> String {
var out = text var out = text
for trigger in triggers { for trigger in triggers {
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation) let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation)
guard !token.isEmpty else { continue } guard !token.isEmpty else { continue }
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive]) 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] { private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
@@ -143,7 +143,7 @@ public enum WakeWordGate {
for trigger in triggers { for trigger in triggers {
let tokens = trigger let tokens = trigger
.split(whereSeparator: { $0.isWhitespace }) .split(whereSeparator: { $0.isWhitespace })
.map { normalizeToken(String($0)) } .map { self.normalizeToken(String($0)) }
.filter { !$0.isEmpty } .filter { !$0.isEmpty }
if tokens.isEmpty { continue } if tokens.isEmpty { continue }
output.append(TriggerTokens(tokens: tokens)) output.append(TriggerTokens(tokens: tokens))
@@ -153,7 +153,7 @@ public enum WakeWordGate {
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] { private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
segments.compactMap { segment in segments.compactMap { segment in
let normalized = normalizeToken(segment.text) let normalized = self.normalizeToken(segment.text)
guard !normalized.isEmpty else { return nil } guard !normalized.isEmpty else { return nil }
return Token( return Token(
normalized: normalized, normalized: normalized,
@@ -166,7 +166,7 @@ public enum WakeWordGate {
private static func normalizeToken(_ token: String) -> String { private static func normalizeToken(_ token: String) -> String {
token token
.trimmingCharacters(in: whitespaceAndPunctuation) .trimmingCharacters(in: self.whitespaceAndPunctuation)
.lowercased() .lowercased()
} }
@@ -1,6 +1,6 @@
import Foundation import Foundation
import SwabbleKit
import Testing import Testing
import SwabbleKit
@Suite struct WakeWordGateTests { @Suite struct WakeWordGateTests {
@Test func matchRequiresGapAfterTrigger() { @Test func matchRequiresGapAfterTrigger() {
+2 -17
View File
@@ -1,5 +1,3 @@
import com.android.build.api.variant.impl.VariantOutputImpl
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
@@ -21,8 +19,8 @@ android {
applicationId = "com.clawdbot.android" applicationId = "com.clawdbot.android"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 20260109 versionCode = 1
versionName = "2026.1.9" versionName = "2.0.0-beta3"
} }
buildTypes { 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 { kotlin {
compilerOptions { compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
@@ -12,7 +12,7 @@
"element", "element",
"node", "node",
"nodeId", "nodeId",
"id", "jobId",
"requestId", "requestId",
"to", "to",
"channelId", "channelId",
@@ -136,10 +136,10 @@
"label": "add", "label": "add",
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"] "detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
}, },
"update": { "label": "update", "detailKeys": ["id"] }, "update": { "label": "update", "detailKeys": ["jobId"] },
"remove": { "label": "remove", "detailKeys": ["id"] }, "remove": { "label": "remove", "detailKeys": ["jobId"] },
"run": { "label": "run", "detailKeys": ["id"] }, "run": { "label": "run", "detailKeys": ["jobId"] },
"runs": { "label": "runs", "detailKeys": ["id"] }, "runs": { "label": "runs", "detailKeys": ["jobId"] },
"wake": { "label": "wake", "detailKeys": ["text", "mode"] } "wake": { "label": "wake", "detailKeys": ["text", "mode"] }
} }
}, },
@@ -5,10 +5,8 @@ import android.content.Context
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.util.Base64 import android.util.Base64
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.ExifInterface
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCapture
@@ -88,19 +86,18 @@ class CameraCaptureManager(private val context: Context) {
provider.unbindAll() provider.unbindAll()
provider.bindToLifecycle(owner, selector, capture) 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) val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image") ?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
val rotated = rotateBitmapByExif(decoded, orientation)
val scaled = val scaled =
if (maxWidth != null && maxWidth > 0 && rotated.width > maxWidth) { if (maxWidth != null && maxWidth > 0 && decoded.width > maxWidth) {
val h = val h =
(rotated.height.toDouble() * (maxWidth.toDouble() / rotated.width.toDouble())) (decoded.height.toDouble() * (maxWidth.toDouble() / decoded.width.toDouble()))
.toInt() .toInt()
.coerceAtLeast(1) .coerceAtLeast(1)
rotated.scale(maxWidth, h) decoded.scale(maxWidth, h)
} else { } else {
rotated decoded
} }
val maxPayloadBytes = 5 * 1024 * 1024 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? = private fun parseFacing(paramsJson: String?): String? =
when { when {
paramsJson?.contains("\"front\"") == true -> "front" 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.takeJpegBytes(executor: Executor): ByteArray =
private suspend fun ImageCapture.takeJpegWithExif(executor: Executor): Pair<ByteArray, Int> =
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
val file = File.createTempFile("clawdbot-snap-", ".jpg") val file = File.createTempFile("clawdbot-snap-", ".jpg")
val options = ImageCapture.OutputFileOptions.Builder(file).build() val options = ImageCapture.OutputFileOptions.Builder(file).build()
@@ -292,19 +263,13 @@ private suspend fun ImageCapture.takeJpegWithExif(executor: Executor): Pair<Byte
executor, executor,
object : ImageCapture.OnImageSavedCallback { object : ImageCapture.OnImageSavedCallback {
override fun onError(exception: ImageCaptureException) { override fun onError(exception: ImageCaptureException) {
file.delete()
cont.resumeWithException(exception) cont.resumeWithException(exception)
} }
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
try { try {
val exif = ExifInterface(file.absolutePath)
val orientation = exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
val bytes = file.readBytes() val bytes = file.readBytes()
cont.resume(Pair(bytes, orientation)) cont.resume(bytes)
} catch (e: Exception) { } catch (e: Exception) {
cont.resumeWithException(e) cont.resumeWithException(e)
} finally { } finally {
@@ -4,7 +4,6 @@ import android.content.Context
import android.hardware.display.DisplayManager import android.hardware.display.DisplayManager
import android.media.MediaRecorder import android.media.MediaRecorder
import android.media.projection.MediaProjectionManager import android.media.projection.MediaProjectionManager
import android.os.Build
import android.util.Base64 import android.util.Base64
import com.clawdbot.android.ScreenCaptureRequester import com.clawdbot.android.ScreenCaptureRequester
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -66,7 +65,7 @@ class ScreenRecordManager(private val context: Context) {
val file = File.createTempFile("clawdbot-screen-", ".mp4") val file = File.createTempFile("clawdbot-screen-", ".mp4")
if (includeAudio) ensureMicPermission() if (includeAudio) ensureMicPermission()
val recorder = createMediaRecorder() val recorder = MediaRecorder()
var virtualDisplay: android.hardware.display.VirtualDisplay? = null var virtualDisplay: android.hardware.display.VirtualDisplay? = null
try { try {
if (includeAudio) { 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() { private suspend fun ensureMicPermission() {
val granted = val granted =
androidx.core.content.ContextCompat.checkSelfPermission( androidx.core.content.ContextCompat.checkSelfPermission(
@@ -39,7 +39,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material.icons.Icons 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.ChatBubble
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error 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.RecordVoiceOver
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Report import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.ScreenShare
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -123,7 +123,7 @@ fun RootScreen(viewModel: MainViewModel) {
if (screenRecordActive) { if (screenRecordActive) {
return@remember StatusActivity( return@remember StatusActivity(
title = "Recording screen…", title = "Recording screen…",
icon = Icons.AutoMirrored.Filled.ScreenShare, icon = Icons.Default.ScreenShare,
contentDescription = "Recording screen", contentDescription = "Recording screen",
tint = androidx.compose.ui.graphics.Color.Red, 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. // Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage.
settings.domStorageEnabled = true settings.domStorageEnabled = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE 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)) { if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false) WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
} else {
disableForceDarkIfSupported(settings)
} }
if (isDebuggable) { if (isDebuggable) {
Log.d("ClawdbotWebView", "userAgent: ${settings.userAgentString}") 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) { private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
@JavascriptInterface @JavascriptInterface
fun postMessage(payload: String?) { fun postMessage(payload: String?) {
@@ -714,7 +714,6 @@ class TalkModeManager(
systemTtsPendingId = null systemTtsPendingId = null
} }
@Suppress("OVERRIDE_DEPRECATION")
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
override fun onError(utteranceId: String?) { override fun onError(utteranceId: String?) {
if (utteranceId == null) return 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> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.9</string> <string>1.0</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>20260109</string> <string>1</string>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoadsInWebContent</key> <key>NSAllowsArbitraryLoadsInWebContent</key>
@@ -5,8 +5,7 @@ import Testing
@Test func errorDescriptionsAreStable() { @Test func errorDescriptionsAreStable() {
#expect(CameraController.CameraError.cameraUnavailable.errorDescription == "Camera unavailable") #expect(CameraController.CameraError.cameraUnavailable.errorDescription == "Camera unavailable")
#expect(CameraController.CameraError.microphoneUnavailable.errorDescription == "Microphone unavailable") #expect(CameraController.CameraError.microphoneUnavailable.errorDescription == "Microphone unavailable")
#expect(CameraController.CameraError.permissionDenied(kind: "Camera") #expect(CameraController.CameraError.permissionDenied(kind: "Camera").errorDescription == "Camera permission denied")
.errorDescription == "Camera permission denied")
#expect(CameraController.CameraError.invalidParams("bad").errorDescription == "bad") #expect(CameraController.CameraError.invalidParams("bad").errorDescription == "bad")
#expect(CameraController.CameraError.captureFailed("nope").errorDescription == "nope") #expect(CameraController.CameraError.captureFailed("nope").errorDescription == "nope")
#expect(CameraController.CameraError.exportFailed("export").errorDescription == "export") #expect(CameraController.CameraError.exportFailed("export").errorDescription == "export")
+2 -2
View File
@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>BNDL</string> <string>BNDL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.9</string> <string>1.0</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>20260109</string> <string>1</string>
</dict> </dict>
</plist> </plist>
@@ -20,3 +20,4 @@ import Testing
#expect(triggers == nil) #expect(triggers == nil)
} }
} }
@@ -1,6 +1,6 @@
import Foundation import Foundation
import SwabbleKit
import Testing import Testing
import SwabbleKit
@testable import Clawdbot @testable import Clawdbot
@Suite struct VoiceWakeManagerExtractCommandTests { @Suite struct VoiceWakeManagerExtractCommandTests {
@@ -1,6 +1,6 @@
import Foundation import Foundation
import SwabbleKit
import Testing import Testing
import SwabbleKit
@testable import Clawdbot @testable import Clawdbot
@Suite(.serialized) struct VoiceWakeManagerStateTests { @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 import SwiftUI
@MainActor @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)
}
}
}
+3 -14
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 { var voiceWakeLocaleID: String {
didSet { didSet {
self.ifNotPreview { self.ifNotPreview {
@@ -233,7 +229,6 @@ final class AppState {
} }
self.showDockIcon = UserDefaults.standard.bool(forKey: showDockIconKey) self.showDockIcon = UserDefaults.standard.bool(forKey: showDockIconKey)
self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? "" 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.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier
self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard
.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? [] .stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? []
@@ -421,8 +416,7 @@ final class AppState {
: nil : nil
Task { @MainActor in Task { @MainActor in
// Keep app-only connection settings local to avoid overwriting remote gateway config. var root = await ConfigStore.load()
var root = ClawdbotConfigFile.loadDict()
var gateway = root["gateway"] as? [String: Any] ?? [:] var gateway = root["gateway"] as? [String: Any] ?? [:]
var changed = false var changed = false
@@ -452,12 +446,8 @@ final class AppState {
} }
guard changed else { return } guard changed else { return }
if gateway.isEmpty { root["gateway"] = gateway
root.removeValue(forKey: "gateway") try? await ConfigStore.save(root)
} else {
root["gateway"] = gateway
}
ClawdbotConfigFile.saveDict(root)
} }
} }
@@ -588,7 +578,6 @@ extension AppState {
state.iconAnimationsEnabled = true state.iconAnimationsEnabled = true
state.showDockIcon = true state.showDockIcon = true
state.voiceWakeMicID = "BuiltInMic" state.voiceWakeMicID = "BuiltInMic"
state.voiceWakeMicName = "Built-in Microphone"
state.voiceWakeLocaleID = Locale.current.identifier state.voiceWakeLocaleID = Locale.current.identifier
state.voiceWakeAdditionalLocaleIDs = ["en-US", "de-DE"] state.voiceWakeAdditionalLocaleIDs = ["en-US", "de-DE"]
state.voicePushToTalkEnabled = false 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", thinking: "low",
deliver: false, deliver: false,
to: nil, to: nil,
provider: .last)) channel: .last))
case "agent.request": case "agent.request":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
@@ -205,7 +205,7 @@ actor BridgeServer {
?? "node-\(nodeId)" ?? "node-\(nodeId)"
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let to = link.to?.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( _ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: message, message: message,
@@ -213,7 +213,7 @@ actor BridgeServer {
thinking: thinking, thinking: thinking,
deliver: link.deliver, deliver: link.deliver,
to: to, to: to,
provider: provider)) channel: channel))
default: default:
break break
@@ -229,7 +229,7 @@ actor BridgeServer {
error: BridgeRPCError(code: "FORBIDDEN", message: "Method not allowed")) 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 { if let json = req.paramsJSON?.trimmingCharacters(in: .whitespacesAndNewlines), !json.isEmpty {
guard let data = json.data(using: .utf8) else { guard let data = json.data(using: .utf8) else {
return BridgeRPCResponse( return BridgeRPCResponse(
@@ -238,7 +238,7 @@ actor BridgeServer {
error: BridgeRPCError(code: "INVALID_REQUEST", message: "paramsJSON not UTF-8")) error: BridgeRPCError(code: "INVALID_REQUEST", message: "paramsJSON not UTF-8"))
} }
do { do {
params = try JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data) params = try JSONDecoder().decode([String: AnyCodable].self, from: data)
} catch { } catch {
return BridgeRPCResponse( return BridgeRPCResponse(
id: req.id, id: req.id,
@@ -360,16 +360,16 @@ actor BridgeServer {
"reason \(reason)", "reason \(reason)",
].compactMap(\.self).joined(separator: " · ") ].compactMap(\.self).joined(separator: " · ")
var params: [String: ClawdbotProtocol.AnyCodable] = [ var params: [String: AnyCodable] = [
"text": ClawdbotProtocol.AnyCodable(summary), "text": AnyCodable(summary),
"instanceId": ClawdbotProtocol.AnyCodable(nodeId), "instanceId": AnyCodable(nodeId),
"host": ClawdbotProtocol.AnyCodable(host), "host": AnyCodable(host),
"mode": ClawdbotProtocol.AnyCodable("node"), "mode": AnyCodable("node"),
"reason": ClawdbotProtocol.AnyCodable(reason), "reason": AnyCodable(reason),
"tags": ClawdbotProtocol.AnyCodable(tags), "tags": AnyCodable(tags),
] ]
if let ip { params["ip"] = ClawdbotProtocol.AnyCodable(ip) } if let ip { params["ip"] = AnyCodable(ip) }
if let version { params["version"] = ClawdbotProtocol.AnyCodable(version) } if let version { params["version"] = AnyCodable(version) }
await GatewayConnection.shared.sendSystemEvent(params) await GatewayConnection.shared.sendSystemEvent(params)
} }
@@ -79,15 +79,14 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
GatewayProcessManager.shared.setActive(true) GatewayProcessManager.shared.setActive(true)
} }
let result = await GatewayConnection.shared.sendAgent( let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
GatewayAgentInvocation( message: text,
message: text, sessionKey: self.sessionKey,
sessionKey: self.sessionKey, thinking: "low",
thinking: "low", deliver: false,
deliver: false, to: nil,
to: nil, channel: .last,
provider: .last, idempotencyKey: actionId))
idempotencyKey: actionId))
await MainActor.run { await MainActor.run {
guard let webView else { return } guard let webView else { return }
@@ -1,4 +1,3 @@
import ClawdbotProtocol
import Foundation import Foundation
enum ClawdbotConfigFile { enum ClawdbotConfigFile {
@@ -33,8 +32,7 @@ enum ClawdbotConfigFile {
} }
static func saveDict(_ dict: [String: Any]) { static func saveDict(_ dict: [String: Any]) {
// Nix mode disables config writes in production, but tests rely on saving temp configs. if ProcessInfo.processInfo.isNixMode { return }
if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return }
do { do {
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys]) let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
let url = self.url() let url = self.url()
@@ -81,33 +79,22 @@ enum ClawdbotConfigFile {
static func agentWorkspace() -> String? { static func agentWorkspace() -> String? {
let root = self.loadDict() let root = self.loadDict()
let agents = root["agents"] as? [String: Any] let agent = root["agent"] as? [String: Any]
let defaults = agents?["defaults"] as? [String: Any] return agent?["workspace"] as? String
return defaults?["workspace"] as? String
} }
static func setAgentWorkspace(_ workspace: String?) { static func setAgentWorkspace(_ workspace: String?) {
var root = self.loadDict() var root = self.loadDict()
var agents = root["agents"] as? [String: Any] ?? [:] var agent = root["agent"] as? [String: Any] ?? [:]
var defaults = agents["defaults"] as? [String: Any] ?? [:]
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty { if trimmed.isEmpty {
defaults.removeValue(forKey: "workspace") agent.removeValue(forKey: "workspace")
} else { } else {
defaults["workspace"] = trimmed agent["workspace"] = trimmed
}
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
self.saveDict(root) 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? { static func gatewayPassword() -> String? {
@@ -136,70 +123,6 @@ enum ClawdbotConfigFile {
return nil 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]? { private static func parseConfigData(_ data: Data) -> [String: Any]? {
if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
return root return root
@@ -3,9 +3,9 @@ import Foundation
enum ClawdbotEnv { enum ClawdbotEnv {
static func path(_ key: String) -> String? { static func path(_ key: String) -> String? {
// Normalize env overrides once so UI + file IO stay consistent. // Normalize env overrides once so UI + file IO stay consistent.
guard let raw = getenv(key) else { return nil } guard let value = ProcessInfo.processInfo.environment[key]?
let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines),
guard !value.isEmpty !value.isEmpty
else { else {
return nil return nil
} }
@@ -387,20 +387,13 @@ struct ConfigSettings: View {
private func loadConfig() async { private func loadConfig() async {
let parsed = await ConfigStore.load() let parsed = await ConfigStore.load()
let agents = parsed["agents"] as? [String: Any] let agent = parsed["agent"] as? [String: Any]
let defaults = agents?["defaults"] as? [String: Any] let heartbeatMinutes = agent?["heartbeatMinutes"] as? Int
let heartbeat = defaults?["heartbeat"] as? [String: Any] let heartbeatBody = agent?["heartbeatBody"] as? String
let heartbeatEvery = heartbeat?["every"] as? String
let heartbeatBody = heartbeat?["prompt"] as? String
let browser = parsed["browser"] as? [String: Any] let browser = parsed["browser"] as? [String: Any]
let talk = parsed["talk"] as? [String: Any] let talk = parsed["talk"] as? [String: Any]
let loadedModel: String = { let loadedModel = (agent?["model"] as? String) ?? ""
if let raw = defaults?["model"] as? String { return raw }
if let modelDict = defaults?["model"] as? [String: Any],
let primary = modelDict["primary"] as? String { return primary }
return ""
}()
if !loadedModel.isEmpty { if !loadedModel.isEmpty {
self.configModel = loadedModel self.configModel = loadedModel
self.customModel = loadedModel self.customModel = loadedModel
@@ -409,13 +402,7 @@ struct ConfigSettings: View {
self.customModel = SessionLoader.fallbackModel self.customModel = SessionLoader.fallbackModel
} }
if let heartbeatEvery { if let heartbeatMinutes { self.heartbeatMinutes = heartbeatMinutes }
let digits = heartbeatEvery.trimmingCharacters(in: .whitespacesAndNewlines)
.prefix { $0.isNumber }
if let minutes = Int(digits) {
self.heartbeatMinutes = minutes
}
}
if let heartbeatBody, !heartbeatBody.isEmpty { self.heartbeatBody = heartbeatBody } if let heartbeatBody, !heartbeatBody.isEmpty { self.heartbeatBody = heartbeatBody }
if let browser { if let browser {
@@ -493,49 +480,25 @@ struct ConfigSettings: View {
@MainActor @MainActor
private static func buildAndSaveConfig(_ draft: ConfigDraft) async -> String? { private static func buildAndSaveConfig(_ draft: ConfigDraft) async -> String? {
var root = await ConfigStore.load() var root = await ConfigStore.load()
var agents = root["agents"] as? [String: Any] ?? [:] var agent = root["agent"] as? [String: Any] ?? [:]
var defaults = agents["defaults"] as? [String: Any] ?? [:]
var browser = root["browser"] as? [String: Any] ?? [:] var browser = root["browser"] as? [String: Any] ?? [:]
var talk = root["talk"] as? [String: Any] ?? [:] var talk = root["talk"] as? [String: Any] ?? [:]
let chosenModel = (draft.configModel == "__custom__" ? draft.customModel : draft.configModel) let chosenModel = (draft.configModel == "__custom__" ? draft.customModel : draft.configModel)
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedModel = chosenModel let trimmedModel = chosenModel
if !trimmedModel.isEmpty { if !trimmedModel.isEmpty { agent["model"] = trimmedModel }
var model = defaults["model"] as? [String: Any] ?? [:]
model["primary"] = trimmedModel
defaults["model"] = model
var models = defaults["models"] as? [String: Any] ?? [:]
if models[trimmedModel] == nil {
models[trimmedModel] = [:]
}
defaults["models"] = models
}
if let heartbeatMinutes = draft.heartbeatMinutes { if let heartbeatMinutes = draft.heartbeatMinutes {
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:] agent["heartbeatMinutes"] = heartbeatMinutes
heartbeat["every"] = "\(heartbeatMinutes)m"
defaults["heartbeat"] = heartbeat
} }
let trimmedBody = draft.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedBody = draft.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedBody.isEmpty { if !trimmedBody.isEmpty {
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:] agent["heartbeatBody"] = trimmedBody
heartbeat["prompt"] = trimmedBody
defaults["heartbeat"] = heartbeat
} }
if defaults.isEmpty { root["agent"] = agent
agents.removeValue(forKey: "defaults")
} else {
agents["defaults"] = defaults
}
if agents.isEmpty {
root.removeValue(forKey: "agents")
} else {
root["agents"] = agents
}
browser["enabled"] = draft.browserEnabled browser["enabled"] = draft.browserEnabled
let trimmedUrl = draft.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedUrl = draft.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1,4 +1,3 @@
import ClawdbotProtocol
import Foundation import Foundation
enum ConfigStore { enum ConfigStore {
@@ -13,7 +13,6 @@ let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
let showDockIconKey = "clawdbot.showDockIcon" let showDockIconKey = "clawdbot.showDockIcon"
let defaultVoiceWakeTriggers = ["clawd", "claude"] let defaultVoiceWakeTriggers = ["clawd", "claude"]
let voiceWakeMicKey = "clawdbot.voiceWakeMicID" let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName"
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID" let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"
let voiceWakeAdditionalLocalesKey = "clawdbot.voiceWakeAdditionalLocaleIDs" let voiceWakeAdditionalLocalesKey = "clawdbot.voiceWakeAdditionalLocaleIDs"
let voicePushToTalkEnabledKey = "clawdbot.voicePushToTalkEnabled" let voicePushToTalkEnabledKey = "clawdbot.voicePushToTalkEnabled"
@@ -1,4 +1,3 @@
import ClawdbotProtocol
import Foundation import Foundation
import SwiftUI import SwiftUI
@@ -34,13 +33,13 @@ extension CronJobEditor {
case let .systemEvent(text): case let .systemEvent(text):
self.payloadKind = .systemEvent self.payloadKind = .systemEvent
self.systemEventText = text 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.payloadKind = .agentTurn
self.agentMessage = message self.agentMessage = message
self.thinking = thinking ?? "" self.thinking = thinking ?? ""
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? "" self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
self.deliver = deliver ?? false self.deliver = deliver ?? false
self.provider = GatewayAgentProvider(raw: provider) self.channel = GatewayAgentChannel(raw: channel)
self.to = to ?? "" self.to = to ?? ""
self.bestEffortDeliver = bestEffortDeliver ?? false self.bestEffortDeliver = bestEffortDeliver ?? false
} }
@@ -167,7 +166,7 @@ extension CronJobEditor {
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n } if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
payload["deliver"] = self.deliver payload["deliver"] = self.deliver
if self.deliver { if self.deliver {
payload["provider"] = self.provider.rawValue payload["channel"] = self.channel.rawValue
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines) let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { payload["to"] = to } if !to.isEmpty { payload["to"] = to }
payload["bestEffortDeliver"] = self.bestEffortDeliver payload["bestEffortDeliver"] = self.bestEffortDeliver
@@ -13,7 +13,7 @@ extension CronJobEditor {
self.payloadKind = .agentTurn self.payloadKind = .agentTurn
self.agentMessage = "Run diagnostic" self.agentMessage = "Run diagnostic"
self.deliver = true self.deliver = true
self.provider = .last self.channel = .last
self.to = "+15551230000" self.to = "+15551230000"
self.thinking = "low" self.thinking = "low"
self.timeoutSeconds = "90" self.timeoutSeconds = "90"
@@ -1,4 +1,3 @@
import ClawdbotProtocol
import SwiftUI import SwiftUI
struct CronJobEditor: View { struct CronJobEditor: View {
@@ -18,7 +17,7 @@ struct CronJobEditor: View {
static let scheduleKindNote = static let scheduleKindNote =
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression." "“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
static let isolatedPayloadNote = 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." + "and a short summary is posted back to your main chat."
static let mainPayloadNote = static let mainPayloadNote =
"System events are injected into the current main session. Agent turns require an isolated session target." "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 systemEventText: String = ""
@State var agentMessage: String = "" @State var agentMessage: String = ""
@State var deliver: Bool = false @State var deliver: Bool = false
@State var provider: GatewayAgentProvider = .last @State var channel: GatewayAgentChannel = .last
@State var to: String = "" @State var to: String = ""
@State var thinking: String = "" @State var thinking: String = ""
@State var timeoutSeconds: String = "" @State var timeoutSeconds: String = ""
@@ -310,7 +309,7 @@ struct CronJobEditor: View {
} }
GridRow { GridRow {
self.gridLabel("Deliver") self.gridLabel("Deliver")
Toggle("Deliver result to a provider", isOn: self.$deliver) Toggle("Deliver result to a surface", isOn: self.$deliver)
.toggleStyle(.switch) .toggleStyle(.switch)
} }
} }
@@ -318,15 +317,12 @@ struct CronJobEditor: View {
if self.deliver { if self.deliver {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow { GridRow {
self.gridLabel("Provider") self.gridLabel("Channel")
Picker("", selection: self.$provider) { Picker("", selection: self.$channel) {
Text("last").tag(GatewayAgentProvider.last) Text("last").tag(GatewayAgentChannel.last)
Text("whatsapp").tag(GatewayAgentProvider.whatsapp) Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
Text("telegram").tag(GatewayAgentProvider.telegram) Text("telegram").tag(GatewayAgentChannel.telegram)
Text("discord").tag(GatewayAgentProvider.discord) Text("discord").tag(GatewayAgentChannel.discord)
Text("slack").tag(GatewayAgentProvider.slack)
Text("signal").tag(GatewayAgentProvider.signal)
Text("imessage").tag(GatewayAgentProvider.imessage)
} }
.labelsHidden() .labelsHidden()
.pickerStyle(.segmented) .pickerStyle(.segmented)
+5 -5
View File
@@ -74,12 +74,12 @@ enum CronPayload: Codable, Equatable {
thinking: String?, thinking: String?,
timeoutSeconds: Int?, timeoutSeconds: Int?,
deliver: Bool?, deliver: Bool?,
provider: String?, channel: String?,
to: String?, to: String?,
bestEffortDeliver: Bool?) bestEffortDeliver: Bool?)
enum CodingKeys: String, CodingKey { 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 { var kind: String {
@@ -101,7 +101,7 @@ enum CronPayload: Codable, Equatable {
thinking: container.decodeIfPresent(String.self, forKey: .thinking), thinking: container.decodeIfPresent(String.self, forKey: .thinking),
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds), timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver), 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), to: container.decodeIfPresent(String.self, forKey: .to),
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver)) bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
default: default:
@@ -118,12 +118,12 @@ enum CronPayload: Codable, Equatable {
switch self { switch self {
case let .systemEvent(text): case let .systemEvent(text):
try container.encode(text, forKey: .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.encode(message, forKey: .message)
try container.encodeIfPresent(thinking, forKey: .thinking) try container.encodeIfPresent(thinking, forKey: .thinking)
try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds) try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
try container.encodeIfPresent(deliver, forKey: .deliver) 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(to, forKey: .to)
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver) try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
} }
@@ -1,4 +1,3 @@
import ClawdbotProtocol
import Foundation import Foundation
extension CronSettings { extension CronSettings {
@@ -206,7 +206,7 @@ extension CronSettings {
Text(text) Text(text)
.font(.callout) .font(.callout)
.textSelection(.enabled) .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) { VStack(alignment: .leading, spacing: 4) {
Text(message) Text(message)
.font(.callout) .font(.callout)
@@ -216,7 +216,7 @@ extension CronSettings {
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) } if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
if deliver ?? false { if deliver ?? false {
StatusPill(text: "deliver", tint: .secondary) 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) } if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
} }
} }
@@ -20,7 +20,7 @@ struct CronSettings_Previews: PreviewProvider {
thinking: "low", thinking: "low",
timeoutSeconds: 600, timeoutSeconds: 600,
deliver: true, deliver: true,
provider: "last", channel: "last",
to: nil, to: nil,
bestEffortDeliver: true), bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"), isolation: CronIsolation(postToMainPrefix: "Cron"),
@@ -72,7 +72,7 @@ extension CronSettings {
thinking: "low", thinking: "low",
timeoutSeconds: 120, timeoutSeconds: 120,
deliver: true, deliver: true,
provider: "whatsapp", channel: "whatsapp",
to: "+15551234567", to: "+15551234567",
bestEffortDeliver: true), bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "), isolation: CronIsolation(postToMainPrefix: "[cron] "),
+3 -3
View File
@@ -59,7 +59,7 @@ final class DeepLinkHandler {
} }
do { do {
let provider = GatewayAgentProvider(raw: link.channel) let channel = GatewayAgentChannel(raw: link.channel)
let explicitSessionKey = link.sessionKey? let explicitSessionKey = link.sessionKey?
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty .nonEmpty
@@ -72,9 +72,9 @@ final class DeepLinkHandler {
message: messagePreview, message: messagePreview,
sessionKey: resolvedSessionKey, sessionKey: resolvedSessionKey,
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
deliver: provider.shouldDeliver(link.deliver), deliver: channel.shouldDeliver(link.deliver),
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
provider: provider, channel: channel,
timeoutSeconds: link.timeoutSeconds, timeoutSeconds: link.timeoutSeconds,
idempotencyKey: UUID().uuidString) 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") 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 last
case whatsapp case whatsapp
case telegram case telegram
case discord case discord
case slack
case signal
case imessage
case webchat case webchat
init(raw: String?) { init(raw: String?) {
let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
self = GatewayAgentProvider(rawValue: normalized) ?? .last self = GatewayAgentChannel(rawValue: normalized) ?? .last
} }
var isDeliverable: Bool { self != .webchat } var isDeliverable: Bool { self != .webchat }
@@ -31,7 +28,7 @@ struct GatewayAgentInvocation: Sendable {
var thinking: String? var thinking: String?
var deliver: Bool = false var deliver: Bool = false
var to: String? var to: String?
var provider: GatewayAgentProvider = .last var channel: GatewayAgentChannel = .last
var timeoutSeconds: Int? var timeoutSeconds: Int?
var idempotencyKey: String = UUID().uuidString var idempotencyKey: String = UUID().uuidString
} }
@@ -368,7 +365,7 @@ extension GatewayConnection {
"thinking": AnyCodable(invocation.thinking ?? "default"), "thinking": AnyCodable(invocation.thinking ?? "default"),
"deliver": AnyCodable(invocation.deliver), "deliver": AnyCodable(invocation.deliver),
"to": AnyCodable(invocation.to ?? ""), "to": AnyCodable(invocation.to ?? ""),
"provider": AnyCodable(invocation.provider.rawValue), "channel": AnyCodable(invocation.channel.rawValue),
"idempotencyKey": AnyCodable(invocation.idempotencyKey), "idempotencyKey": AnyCodable(invocation.idempotencyKey),
] ]
if let timeout = invocation.timeoutSeconds { if let timeout = invocation.timeoutSeconds {
@@ -389,7 +386,7 @@ extension GatewayConnection {
sessionKey: String, sessionKey: String,
deliver: Bool, deliver: Bool,
to: String?, to: String?,
provider: GatewayAgentProvider = .last, channel: GatewayAgentChannel = .last,
timeoutSeconds: Int? = nil, timeoutSeconds: Int? = nil,
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?) idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
{ {
@@ -399,7 +396,7 @@ extension GatewayConnection {
thinking: thinking, thinking: thinking,
deliver: deliver, deliver: deliver,
to: to, to: to,
provider: provider, channel: channel,
timeoutSeconds: timeoutSeconds, timeoutSeconds: timeoutSeconds,
idempotencyKey: idempotencyKey)) idempotencyKey: idempotencyKey))
} }
@@ -18,7 +18,6 @@ final class GatewayDiscoveryModel {
var lanHost: String? var lanHost: String?
var tailnetDns: String? var tailnetDns: String?
var sshPort: Int var sshPort: Int
var gatewayPort: Int?
var cliPath: String? var cliPath: String?
var stableID: String var stableID: String
var debugID: String var debugID: String
@@ -139,7 +138,6 @@ final class GatewayDiscoveryModel {
lanHost: parsedTXT.lanHost, lanHost: parsedTXT.lanHost,
tailnetDns: parsedTXT.tailnetDns, tailnetDns: parsedTXT.tailnetDns,
sshPort: parsedTXT.sshPort, sshPort: parsedTXT.sshPort,
gatewayPort: parsedTXT.gatewayPort,
cliPath: parsedTXT.cliPath, cliPath: parsedTXT.cliPath,
stableID: stableID, stableID: stableID,
debugID: BridgeEndpointID.prettyDescription(result.endpoint), debugID: BridgeEndpointID.prettyDescription(result.endpoint),
@@ -208,19 +206,12 @@ final class GatewayDiscoveryModel {
return merged return merged
} }
struct GatewayTXT: Equatable { static func parseGatewayTXT(_ txt: [String: String])
var lanHost: String? -> (lanHost: String?, tailnetDns: String?, sshPort: Int, cliPath: String?)
var tailnetDns: String? {
var sshPort: Int
var gatewayPort: Int?
var cliPath: String?
}
static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT {
var lanHost: String? var lanHost: String?
var tailnetDns: String? var tailnetDns: String?
var sshPort = 22 var sshPort = 22
var gatewayPort: Int?
var cliPath: String? var cliPath: String?
if let value = txt["lanHost"] { if let value = txt["lanHost"] {
@@ -237,23 +228,12 @@ final class GatewayDiscoveryModel {
{ {
sshPort = parsed sshPort = parsed
} }
if let value = txt["gatewayPort"],
let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)),
parsed > 0
{
gatewayPort = parsed
}
if let value = txt["cliPath"] { if let value = txt["cliPath"] {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
cliPath = trimmed.isEmpty ? nil : trimmed cliPath = trimmed.isEmpty ? nil : trimmed
} }
return GatewayTXT( return (lanHost, tailnetDns, sshPort, cliPath)
lanHost: lanHost,
tailnetDns: tailnetDns,
sshPort: sshPort,
gatewayPort: gatewayPort,
cliPath: cliPath)
} }
static func buildSSHTarget(user: String, host: String, port: Int) -> String { static func buildSSHTarget(user: String, host: String, port: Int) -> String {
@@ -43,52 +43,25 @@ enum GatewayLaunchAgentManager {
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind] 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 } 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 return result.status == 0
} }
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? { static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
if enabled { 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) try? FileManager.default.removeItem(at: self.legacyPlistURL)
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath) let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else { guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
self.logger.error("launchd enable failed: gateway missing at \(gatewayBin)") self.logger.error("launchd enable failed: gateway missing at \(gatewayBin)")
return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh" return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh"
} }
self.logger.info("launchd enable requested port=\(port)")
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.writePlist(bundlePath: bundlePath, port: port) self.writePlist(bundlePath: bundlePath, port: port)
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
await self.ensureEnabled() let bootstrap = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
if loaded {
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
}
let bootstrap = await Launchctl.run(["bootstrap", "gui/\(getuid())", self.plistURL.path])
if bootstrap.status != 0 { if bootstrap.status != 0 {
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines) let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
self.logger.error("launchd bootstrap failed: \(msg)") self.logger.error("launchd bootstrap failed: \(msg)")
@@ -96,19 +69,20 @@ enum GatewayLaunchAgentManager {
? "Failed to bootstrap gateway launchd job" ? "Failed to bootstrap gateway launchd job"
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines) : 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 return nil
} }
self.logger.info("launchd disable requested") self.logger.info("launchd disable requested")
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
await self.ensureDisabled()
try? FileManager.default.removeItem(at: self.plistURL) try? FileManager.default.removeItem(at: self.plistURL)
return nil return nil
} }
static func kickstart() async { 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) { private static func writePlist(bundlePath: String, port: Int) {
@@ -234,57 +208,30 @@ enum GatewayLaunchAgentManager {
.replacingOccurrences(of: "'", with: "&apos;") .replacingOccurrences(of: "'", with: "&apos;")
} }
private struct DesiredConfig: Equatable { private struct LaunchctlResult {
let port: Int let status: Int32
let bind: String let output: String
let token: String?
let password: String?
} }
private struct InstalledConfig: Equatable { @discardableResult
let port: Int? private static func runLaunchctl(_ args: [String]) async -> LaunchctlResult {
let bind: String? await Task.detached(priority: .utility) { () -> LaunchctlResult in
let token: String? let process = Process()
let password: String? process.launchPath = "/bin/launchctl"
process.arguments = args
func matches(_ desired: DesiredConfig) -> Bool { let pipe = Pipe()
guard self.port == desired.port else { return false } process.standardOutput = pipe
guard (self.bind ?? "loopback") == desired.bind else { return false } process.standardError = pipe
guard self.token == desired.token else { return false } do {
guard self.password == desired.password else { return false } try process.run()
return true process.waitUntilExit()
} let data = pipe.fileHandleForReading.readToEndSafely()
} let output = String(data: data, encoding: .utf8) ?? ""
return LaunchctlResult(status: process.terminationStatus, output: output)
private static func readPlistConfig() -> InstalledConfig? { } catch {
guard let snapshot = LaunchAgentPlist.snapshot(url: self.plistURL) else { return nil } return LaunchctlResult(status: -1, output: error.localizedDescription)
return InstalledConfig( }
port: snapshot.port, }.value
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)")
}
} }
} }
@@ -70,7 +70,7 @@ final class GatewayProcessManager {
func ensureLaunchAgentEnabledIfNeeded() async { func ensureLaunchAgentEnabledIfNeeded() async {
guard !CommandResolver.connectionModeIsRemote() else { return } guard !CommandResolver.connectionModeIsRemote() else { return }
guard !AppStateStore.attachExistingGatewayOnly else { return } guard !AppStateStore.attachExistingGatewayOnly else { return }
let enabled = await GatewayLaunchAgentManager.isLoaded() let enabled = await GatewayLaunchAgentManager.status()
guard !enabled else { return } guard !enabled else { return }
let bundlePath = Bundle.main.bundleURL.path let bundlePath = Bundle.main.bundleURL.path
let port = GatewayEnvironment.gatewayPort() let port = GatewayEnvironment.gatewayPort()
@@ -694,7 +694,6 @@ extension GeneralSettings {
host: host, host: host,
port: gateway.sshPort) port: gateway.sshPort)
self.state.remoteCliPath = gateway.cliPath ?? "" 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 @Environment(\.openSettings) private var openSettings
@State private var availableMics: [AudioInputDevice] = [] @State private var availableMics: [AudioInputDevice] = []
@State private var loadingMics = false @State private var loadingMics = false
@State private var micObserver = AudioInputDeviceObserver()
@State private var micRefreshTask: Task<Void, Never>?
@State private var browserControlEnabled = true @State private var browserControlEnabled = true
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false @AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
@AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue @AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue
@@ -145,14 +143,6 @@ struct MenuContent: View {
.task(id: self.state.connectionMode) { .task(id: self.state.connectionMode) {
await self.loadBrowserControlEnabled() await self.loadBrowserControlEnabled()
} }
.onAppear {
self.startMicObserver()
}
.onDisappear {
self.micRefreshTask?.cancel()
self.micRefreshTask = nil
self.micObserver.stop()
}
} }
private var connectionLabel: String { private var connectionLabel: String {
@@ -450,22 +440,13 @@ struct MenuContent: View {
if let match = self.availableMics.first(where: { $0.uid == self.state.voiceWakeMicID }) { if let match = self.availableMics.first(where: { $0.uid == self.state.voiceWakeMicID }) {
return match.name return match.name
} }
if !self.state.voiceWakeMicName.isEmpty { return self.state.voiceWakeMicName }
return "Unavailable" return "Unavailable"
} }
private var microphoneMenuItems: some View { private var microphoneMenuItems: some View {
Group { Group {
if self.isSelectedMicUnavailable {
Label("Disconnected (using System default)", systemImage: "exclamationmark.triangle")
.labelStyle(.titleAndIcon)
.foregroundStyle(.secondary)
.disabled(true)
Divider()
}
Button { Button {
self.state.voiceWakeMicID = "" self.state.voiceWakeMicID = ""
self.state.voiceWakeMicName = ""
} label: { } label: {
Label(self.defaultMicLabel, systemImage: self.state.voiceWakeMicID.isEmpty ? "checkmark" : "") Label(self.defaultMicLabel, systemImage: self.state.voiceWakeMicID.isEmpty ? "checkmark" : "")
.labelStyle(.titleAndIcon) .labelStyle(.titleAndIcon)
@@ -475,7 +456,6 @@ struct MenuContent: View {
ForEach(self.availableMics) { mic in ForEach(self.availableMics) { mic in
Button { Button {
self.state.voiceWakeMicID = mic.uid self.state.voiceWakeMicID = mic.uid
self.state.voiceWakeMicName = mic.name
} label: { } label: {
Label(mic.name, systemImage: self.state.voiceWakeMicID == mic.uid ? "checkmark" : "") Label(mic.name, systemImage: self.state.voiceWakeMicID == mic.uid ? "checkmark" : "")
.labelStyle(.titleAndIcon) .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 { private var defaultMicLabel: String {
if let host = Host.current().localizedName, !host.isEmpty { if let host = Host.current().localizedName, !host.isEmpty {
return "Auto-detect (\(host))" return "Auto-detect (\(host))"
@@ -526,53 +500,14 @@ struct MenuContent: View {
deviceTypes: [.external, .microphone], deviceTypes: [.external, .microphone],
mediaType: .audio, mediaType: .audio,
position: .unspecified) position: .unspecified)
let connectedDevices = discovery.devices.filter(\.isConnected) self.availableMics = discovery.devices
self.availableMics = connectedDevices
.sorted { lhs, rhs in .sorted { lhs, rhs in
lhs.localizedName.localizedCaseInsensitiveCompare(rhs.localizedName) == .orderedAscending lhs.localizedName.localizedCaseInsensitiveCompare(rhs.localizedName) == .orderedAscending
} }
.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) } .map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) }
self.availableMics = self.filterAliveInputs(self.availableMics)
self.updateSelectedMicName()
self.loadingMics = false 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 { private struct AudioInputDevice: Identifiable, Equatable {
let uid: String let uid: String
let name: String let name: String
@@ -22,10 +22,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
private var cachedErrorText: String? private var cachedErrorText: String?
private var cacheUpdatedAt: Date? private var cacheUpdatedAt: Date?
private let refreshIntervalSeconds: TimeInterval = 12 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 private let nodesStore = NodesStore.shared
#if DEBUG #if DEBUG
private var testControlChannelConnected: Bool? private var testControlChannelConnected: Bool?
@@ -62,7 +58,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
self.loadTask = Task { [weak self] in self.loadTask = Task { [weak self] in
guard let self else { return } guard let self else { return }
await self.refreshCache(force: forceRefresh) await self.refreshCache(force: forceRefresh)
await self.refreshUsageCache(force: forceRefresh)
await MainActor.run { await MainActor.run {
guard self.isMenuOpen else { return } guard self.isMenuOpen else { return }
self.inject(into: menu) self.inject(into: menu)
@@ -110,86 +105,68 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
guard let insertIndex = self.findInsertIndex(in: menu) else { return } guard let insertIndex = self.findInsertIndex(in: menu) else { return }
let width = self.initialWidth(for: menu) let width = self.initialWidth(for: menu)
let isConnected = self.isControlChannelConnected
var cursor = insertIndex guard self.isControlChannelConnected else { return }
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 let snapshot = self.cachedSnapshot else {
let headerItem = NSMenuItem() let headerItem = NSMenuItem()
headerItem.tag = self.tag headerItem.tag = self.tag
headerItem.isEnabled = false headerItem.isEnabled = false
let hosted = self.makeHostedView( headerItem.view = 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(
rootView: AnyView(MenuSessionsHeaderView( rootView: AnyView(MenuSessionsHeaderView(
count: 0, count: 0,
statusText: statusText)), statusText: self.cachedErrorText ?? "Loading sessions…")),
width: width, width: width,
highlighted: false) highlighted: false)
headerItem.view = hosted menu.insertItem(headerItem, at: insertIndex)
headerView = hosted DispatchQueue.main.async { [weak self, weak view = headerItem.view] in
menu.insertItem(headerItem, at: cursor) guard let self, let view else { return }
cursor += 1 self.captureMenuWidthIfAvailable(from: view)
if !isConnected {
menu.insertItem(
self.makeMessageItem(
text: "Connect the gateway to see sessions",
symbolName: "bolt.slash",
width: width),
at: cursor)
cursor += 1
} }
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 DispatchQueue.main.async { [weak self, weak headerView] in
guard let self, let headerView else { return } guard let self, let headerView else { return }
@@ -263,55 +240,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
_ = cursor _ = 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 { private var isControlChannelConnected: Bool {
#if DEBUG #if DEBUG
if let override = self.testControlChannelConnected { return override } 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 { private func compactError(_ error: Error) -> String {
if let loadError = error as? SessionLoadError { if let loadError = error as? SessionLoadError {
switch loadError { 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 AVFoundation
import OSLog
import SwiftUI import SwiftUI
actor MicLevelMonitor { actor MicLevelMonitor {
private let logger = Logger(subsystem: "com.clawdbot", category: "voicewake.meter") private let engine = AVAudioEngine()
private var engine: AVAudioEngine?
private var update: (@Sendable (Double) -> Void)? private var update: (@Sendable (Double) -> Void)?
private var running = false private var running = false
private var smoothedLevel: Double = 0 private var smoothedLevel: Double = 0
@@ -12,37 +10,23 @@ actor MicLevelMonitor {
func start(onLevel: @Sendable @escaping (Double) -> Void) async throws { func start(onLevel: @Sendable @escaping (Double) -> Void) async throws {
self.update = onLevel self.update = onLevel
if self.running { return } if self.running { return }
self.logger.info( let input = self.engine.inputNode
"mic level monitor start (\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public))")
let engine = AVAudioEngine()
self.engine = engine
let input = engine.inputNode
let format = input.outputFormat(forBus: 0) 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.removeTap(onBus: 0)
input.installTap(onBus: 0, bufferSize: 512, format: format) { [weak self] buffer, _ in input.installTap(onBus: 0, bufferSize: 512, format: format) { [weak self] buffer, _ in
guard let self else { return } guard let self else { return }
let level = Self.normalizedLevel(from: buffer) let level = Self.normalizedLevel(from: buffer)
Task { await self.push(level: level) } Task { await self.push(level: level) }
} }
engine.prepare() self.engine.prepare()
try engine.start() try self.engine.start()
self.running = true self.running = true
} }
func stop() { func stop() {
guard self.running else { return } guard self.running else { return }
if let engine { self.engine.inputNode.removeTap(onBus: 0)
engine.inputNode.removeTap(onBus: 0) self.engine.stop()
engine.stop()
}
self.engine = nil
self.running = false self.running = false
} }
@@ -1,7 +1,6 @@
import ClawdbotKit import ClawdbotKit
import Foundation import Foundation
import Network import Network
import OSLog
actor MacNodeBridgeSession { actor MacNodeBridgeSession {
private struct TimeoutError: LocalizedError { private struct TimeoutError: LocalizedError {
@@ -16,18 +15,14 @@ actor MacNodeBridgeSession {
case failed(message: String) case failed(message: String)
} }
private let logger = Logger(subsystem: "com.clawdbot", category: "node.bridge-session")
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private let clock = ContinuousClock()
private var connection: NWConnection? private var connection: NWConnection?
private var queue: DispatchQueue? private var queue: DispatchQueue?
private var buffer = Data() private var buffer = Data()
private var pendingRPC: [String: CheckedContinuation<BridgeRPCResponse, Error>] = [:] private var pendingRPC: [String: CheckedContinuation<BridgeRPCResponse, Error>] = [:]
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:] private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:]
private var pingTask: Task<Void, Never>?
private var lastPongAt: ContinuousClock.Instant?
private(set) var state: State = .idle private(set) var state: State = .idle
@@ -43,12 +38,6 @@ actor MacNodeBridgeSession {
let params = NWParameters.tcp let params = NWParameters.tcp
params.includePeerToPeer = true 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 connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session") let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session")
self.connection = connection self.connection = connection
@@ -58,10 +47,6 @@ actor MacNodeBridgeSession {
connection.start(queue: queue) connection.start(queue: queue)
try await Self.waitForReady(stateStream, timeoutSeconds: 6) 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( try await AsyncTimeout.withTimeout(
seconds: 6, seconds: 6,
@@ -92,7 +77,6 @@ actor MacNodeBridgeSession {
if base.type == "hello-ok" { if base.type == "hello-ok" {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data) let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName) self.state = .connected(serverName: ok.serverName)
self.startPingLoop()
await onConnected?(ok.serverName) await onConnected?(ok.serverName)
} else if base.type == "error" { } else if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data) 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) let ping = try self.decoder.decode(BridgePing.self, from: nextData)
try await self.send(BridgePong(type: "pong", id: ping.id)) 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": case "invoke":
let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData) let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData)
let res = await onInvoke(req) let res = await onInvoke(req)
@@ -202,10 +182,6 @@ actor MacNodeBridgeSession {
} }
func disconnect() async { func disconnect() async {
self.pingTask?.cancel()
self.pingTask = nil
self.lastPongAt = nil
self.connection?.cancel() self.connection?.cancel()
self.connection = nil self.connection = nil
self.queue = nil self.queue = nil
@@ -263,17 +239,12 @@ actor MacNodeBridgeSession {
} }
private func send(_ obj: some Encodable) async throws { 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) let data = try self.encoder.encode(obj)
var line = Data() var line = Data()
line.append(data) line.append(data)
line.append(0x0A) line.append(0x0A)
try await withCheckedThrowingContinuation(isolation: self) { (cont: CheckedContinuation<Void, Error>) in 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: ()) } 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( private static func makeStateStream(
for connection: NWConnection) -> AsyncStream<NWConnection.State> for connection: NWConnection) -> AsyncStream<NWConnection.State>
{ {
@@ -202,23 +202,6 @@ final class MacNodeModeCoordinator {
return 18790 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 { static func probeEndpoint(_ endpoint: NWEndpoint, timeoutSeconds: Double) async -> Bool {
let connection = NWConnection(to: endpoint, using: .tcp) let connection = NWConnection(to: endpoint, using: .tcp)
let stream = Self.makeStateStream(for: connection) let stream = Self.makeStateStream(for: connection)
@@ -286,10 +269,7 @@ final class MacNodeModeCoordinator {
if mode == .remote { if mode == .remote {
do { do {
if self.tunnel == nil || self.tunnel?.process.isRunning == false { if self.tunnel == nil || self.tunnel?.process.isRunning == false {
let remotePort = Self.remoteBridgePort() self.tunnel = try await RemotePortTunnel.create(remotePort: 18790)
self.tunnel = try await RemotePortTunnel.create(
remotePort: remotePort,
allowRemoteUrlOverride: false)
} }
if let localPort = self.tunnel?.localPort, if let localPort = self.tunnel?.localPort,
let port = NWEndpoint.Port(rawValue: localPort) let port = NWEndpoint.Port(rawValue: localPort)
@@ -5,16 +5,8 @@ import Foundation
actor MacNodeRuntime { actor MacNodeRuntime {
private let cameraCapture = CameraCaptureService() private let cameraCapture = CameraCaptureService()
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices @MainActor private let screenRecorder = ScreenRecordService()
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)? @MainActor private let locationService = MacNodeLocationService()
init(
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
await MainActor.run { LiveMacNodeRuntimeMainActorServices() }
})
{
self.makeMainActorServices = makeMainActorServices
}
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
let command = req.command let command = req.command
@@ -220,8 +212,7 @@ actor MacNodeRuntime {
ClawdbotLocationGetParams() ClawdbotLocationGetParams()
let desired = params.desiredAccuracy ?? let desired = params.desiredAccuracy ??
(Self.locationPreciseEnabled() ? .precise : .balanced) (Self.locationPreciseEnabled() ? .precise : .balanced)
let services = await self.mainActorServices() let status = await self.locationService.authorizationStatus()
let status = await services.locationAuthorizationStatus()
if status != .authorizedAlways { if status != .authorizedAlways {
return BridgeInvokeResponse( return BridgeInvokeResponse(
id: req.id, id: req.id,
@@ -231,11 +222,11 @@ actor MacNodeRuntime {
message: "LOCATION_PERMISSION_REQUIRED: grant Location permission")) message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
} }
do { do {
let location = try await services.currentLocation( let location = try await self.locationService.currentLocation(
desiredAccuracy: desired, desiredAccuracy: desired,
maxAgeMs: params.maxAgeMs, maxAgeMs: params.maxAgeMs,
timeoutMs: params.timeoutMs) timeoutMs: params.timeoutMs)
let isPrecise = await services.locationAccuracyAuthorization() == .fullAccuracy let isPrecise = await self.locationService.accuracyAuthorization() == .fullAccuracy
let payload = ClawdbotLocationPayload( let payload = ClawdbotLocationPayload(
lat: location.coordinate.latitude, lat: location.coordinate.latitude,
lon: location.coordinate.longitude, lon: location.coordinate.longitude,
@@ -274,8 +265,7 @@ actor MacNodeRuntime {
code: .invalidRequest, code: .invalidRequest,
message: "INVALID_REQUEST: screen format must be mp4") message: "INVALID_REQUEST: screen format must be mp4")
} }
let services = await self.mainActorServices() let res = try await self.screenRecorder.record(
let res = try await services.recordScreen(
screenIndex: params.screenIndex, screenIndex: params.screenIndex,
durationMs: params.durationMs, durationMs: params.durationMs,
fps: params.fps, fps: params.fps,
@@ -301,13 +291,6 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) 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 { private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
try await self.ensureA2UIHost() 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, user: user,
host: host, host: host,
port: gateway.sshPort) port: gateway.sshPort)
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
} }
self.state.remoteCliPath = gateway.cliPath ?? "" self.state.remoteCliPath = gateway.cliPath ?? ""
@@ -607,7 +607,7 @@ extension OnboardingView {
let saved = await self.saveAgentWorkspace(AgentWorkspace.displayPath(for: url)) let saved = await self.saveAgentWorkspace(AgentWorkspace.displayPath(for: url))
if saved { if saved {
self.workspaceStatus = 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", lanHost: "bridge.local",
tailnetDns: "bridge.ts.net", tailnetDns: "bridge.ts.net",
sshPort: 2222, sshPort: 2222,
gatewayPort: 18789,
cliPath: "/usr/local/bin/clawdbot", cliPath: "/usr/local/bin/clawdbot",
stableID: "bridge-1", stableID: "bridge-1",
debugID: "bridge-1", debugID: "bridge-1",
@@ -69,9 +69,8 @@ extension OnboardingView {
private func loadAgentWorkspace() async -> String? { private func loadAgentWorkspace() async -> String? {
let root = await ConfigStore.load() let root = await ConfigStore.load()
let agents = root["agents"] as? [String: Any] let agent = root["agent"] as? [String: Any]
let defaults = agents?["defaults"] as? [String: Any] return agent?["workspace"] as? String
return defaults?["workspace"] as? String
} }
@discardableResult @discardableResult
@@ -87,23 +86,17 @@ extension OnboardingView {
@MainActor @MainActor
private static func buildAndSaveWorkspace(_ workspace: String?) async -> (Bool, String?) { private static func buildAndSaveWorkspace(_ workspace: String?) async -> (Bool, String?) {
var root = await ConfigStore.load() var root = await ConfigStore.load()
var agents = root["agents"] as? [String: Any] ?? [:] var agent = root["agent"] as? [String: Any] ?? [:]
var defaults = agents["defaults"] as? [String: Any] ?? [:]
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty { if trimmed.isEmpty {
defaults.removeValue(forKey: "workspace") agent.removeValue(forKey: "workspace")
} else { } else {
defaults["workspace"] = trimmed agent["workspace"] = trimmed
} }
if defaults.isEmpty { if agent.isEmpty {
agents.removeValue(forKey: "defaults") root.removeValue(forKey: "agent")
} else { } else {
agents["defaults"] = defaults root["agent"] = agent
}
if agents.isEmpty {
root.removeValue(forKey: "agents")
} else {
root["agents"] = agents
} }
do { do {
try await ConfigStore.save(root) try await ConfigStore.save(root)
@@ -2,12 +2,11 @@ import Foundation
extension ProcessInfo { extension ProcessInfo {
var isPreview: Bool { var isPreview: Bool {
guard let raw = getenv("XCODE_RUNNING_FOR_PREVIEWS") else { return false } self.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
return String(cString: raw) == "1"
} }
var isNixMode: Bool { 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") return UserDefaults.standard.bool(forKey: "clawdbot.nixMode")
} }
@@ -38,11 +38,7 @@ final class RemotePortTunnel {
Task { await PortGuardian.shared.removeRecord(pid: pid) } Task { await PortGuardian.shared.removeRecord(pid: pid) }
} }
static func create( static func create(remotePort: Int, preferredLocalPort: UInt16? = nil) async throws -> RemotePortTunnel {
remotePort: Int,
preferredLocalPort: UInt16? = nil,
allowRemoteUrlOverride: Bool = true) async throws -> RemotePortTunnel
{
let settings = CommandResolver.connectionSettings() let settings = CommandResolver.connectionSettings()
guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else { guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else {
throw NSError( throw NSError(
@@ -53,10 +49,7 @@ final class RemotePortTunnel {
let localPort = try await Self.findPort(preferred: preferredLocalPort) let localPort = try await Self.findPort(preferred: preferredLocalPort)
let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines) let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines)
let remotePortOverride = let remotePortOverride = Self.resolveRemotePortOverride(for: sshHost)
allowRemoteUrlOverride && remotePort == GatewayEnvironment.gatewayPort()
? Self.resolveRemotePortOverride(for: sshHost)
: nil
let resolvedRemotePort = remotePortOverride ?? remotePort let resolvedRemotePort = remotePortOverride ?? remotePort
if let override = remotePortOverride { if let override = remotePortOverride {
Self.logger.info( Self.logger.info(
Binary file not shown.
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.9</string> <string>0.0.0</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>20260109</string> <string>0.0.0</string>
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>
<string>Clawdbot</string> <string>Clawdbot</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -9,7 +9,7 @@ struct GatewaySessionDefaultsRecord: Codable {
struct GatewaySessionEntryRecord: Codable { struct GatewaySessionEntryRecord: Codable {
let key: String let key: String
let displayName: String? let displayName: String?
let provider: String? let surface: String?
let subject: String? let subject: String?
let room: String? let room: String?
let space: String? let space: String?
@@ -71,7 +71,7 @@ struct SessionRow: Identifiable {
let key: String let key: String
let kind: SessionKind let kind: SessionKind
let displayName: String? let displayName: String?
let provider: String? let surface: String?
let subject: String? let subject: String?
let room: String? let room: String?
let space: String? let space: String?
@@ -141,7 +141,7 @@ extension SessionRow {
key: "user@example.com", key: "user@example.com",
kind: .direct, kind: .direct,
displayName: nil, displayName: nil,
provider: nil, surface: nil,
subject: nil, subject: nil,
room: nil, room: nil,
space: nil, space: nil,
@@ -158,7 +158,7 @@ extension SessionRow {
key: "discord:channel:release-squad", key: "discord:channel:release-squad",
kind: .group, kind: .group,
displayName: "discord:#release-squad", displayName: "discord:#release-squad",
provider: "discord", surface: "discord",
subject: nil, subject: nil,
room: "#release-squad", room: "#release-squad",
space: nil, space: nil,
@@ -175,7 +175,7 @@ extension SessionRow {
key: "global", key: "global",
kind: .global, kind: .global,
displayName: nil, displayName: nil,
provider: nil, surface: nil,
subject: nil, subject: nil,
room: nil, room: nil,
space: nil, space: nil,
@@ -298,7 +298,7 @@ enum SessionLoader {
key: entry.key, key: entry.key,
kind: SessionKind.from(key: entry.key), kind: SessionKind.from(key: entry.key),
displayName: entry.displayName, displayName: entry.displayName,
provider: entry.provider, surface: entry.surface,
subject: entry.subject, subject: entry.subject,
room: entry.room, room: entry.room,
space: entry.space, space: entry.space,
@@ -31,7 +31,7 @@ struct SettingsRootView: View {
.tabItem { Label("Connections", systemImage: "link") } .tabItem { Label("Connections", systemImage: "link") }
.tag(SettingsTab.connections) .tag(SettingsTab.connections)
VoiceWakeSettings(state: self.state, isActive: self.selectedTab == .voiceWake) VoiceWakeSettings(state: self.state)
.tabItem { Label("Voice Wake", systemImage: "waveform.circle") } .tabItem { Label("Voice Wake", systemImage: "waveform.circle") }
.tag(SettingsTab.voiceWake) .tag(SettingsTab.voiceWake)
-109
View File
@@ -1,109 +0,0 @@
import Foundation
struct GatewayUsageWindow: Codable {
let label: String
let usedPercent: Double
let resetAt: Double?
}
struct GatewayUsageProvider: Codable {
let provider: String
let displayName: String
let windows: [GatewayUsageWindow]
let plan: String?
let error: String?
}
struct GatewayUsageSummary: Codable {
let updatedAt: Double
let providers: [GatewayUsageProvider]
}
struct UsageRow: Identifiable {
let id: String
let displayName: String
let plan: String?
let windowLabel: String?
let usedPercent: Double?
let resetAt: Date?
let error: String?
var titleText: String {
if let plan, !plan.isEmpty { return "\(self.displayName) (\(plan))" }
return self.displayName
}
var remainingPercent: Int? {
guard let usedPercent, usedPercent.isFinite else { return nil }
let remaining = max(0, min(100, Int(round(100 - usedPercent))))
return remaining
}
func detailText(now: Date = .init()) -> String {
if let error, !error.isEmpty { return error }
guard let remaining = self.remainingPercent else { return "No data" }
var parts = ["\(remaining)% left"]
if let windowLabel, !windowLabel.isEmpty { parts.append(windowLabel) }
if let resetAt {
let reset = UsageRow.formatResetRemaining(target: resetAt, now: now)
if let reset { parts.append("⏱\(reset)") }
}
return parts.joined(separator: " · ")
}
private static func formatResetRemaining(target: Date, now: Date) -> String? {
let diff = target.timeIntervalSince(now)
if diff <= 0 { return "now" }
let minutes = Int(floor(diff / 60))
if minutes < 60 { return "\(minutes)m" }
let hours = minutes / 60
let mins = minutes % 60
if hours < 24 { return mins > 0 ? "\(hours)h \(mins)m" : "\(hours)h" }
let days = hours / 24
if days < 7 { return "\(days)d \(hours % 24)h" }
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
return formatter.string(from: target)
}
}
extension GatewayUsageSummary {
func primaryRows() -> [UsageRow] {
self.providers.compactMap { provider in
if let error = provider.error, provider.windows.isEmpty {
return UsageRow(
id: provider.provider,
displayName: provider.displayName,
plan: provider.plan,
windowLabel: nil,
usedPercent: nil,
resetAt: nil,
error: error)
}
guard let window = provider.windows.max(by: { $0.usedPercent < $1.usedPercent }) else {
return nil
}
return UsageRow(
id: "\(provider.provider)-\(window.label)",
displayName: provider.displayName,
plan: provider.plan,
windowLabel: window.label,
usedPercent: window.usedPercent,
resetAt: window.resetAt.map { Date(timeIntervalSince1970: $0 / 1000) },
error: provider.error)
}
}
}
@MainActor
enum UsageLoader {
static func loadSummary() async throws -> GatewayUsageSummary {
let data = try await ControlChannel.shared.request(
method: "usage.status",
params: nil,
timeoutMs: 5000)
return try JSONDecoder().decode(GatewayUsageSummary.self, from: data)
}
}
@@ -1,45 +0,0 @@
import SwiftUI
struct UsageMenuLabelView: View {
let row: UsageRow
let width: CGFloat
private let paddingLeading: CGFloat = 22
private let paddingTrailing: CGFloat = 14
private let barHeight: CGFloat = 6
private var primaryTextColor: Color { .primary }
private var secondaryTextColor: Color { .secondary }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let used = row.usedPercent {
ContextUsageBar(
usedTokens: Int(round(used)),
contextTokens: 100,
width: max(1, self.width - (self.paddingLeading + self.paddingTrailing)),
height: self.barHeight)
}
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(self.row.titleText)
.font(.caption.weight(.semibold))
.foregroundStyle(self.primaryTextColor)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer(minLength: 4)
Text(self.row.detailText())
.font(.caption.monospacedDigit())
.foregroundStyle(self.secondaryTextColor)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(2)
}
}
.padding(.vertical, 10)
.padding(.leading, self.paddingLeading)
.padding(.trailing, self.paddingTrailing)
}
}
@@ -37,7 +37,7 @@ enum VoiceWakeForwarder {
var thinking: String = "low" var thinking: String = "low"
var deliver: Bool = true var deliver: Bool = true
var to: String? var to: String?
var provider: GatewayAgentProvider = .last var channel: GatewayAgentChannel = .last
} }
@discardableResult @discardableResult
@@ -46,14 +46,14 @@ enum VoiceWakeForwarder {
options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError> options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError>
{ {
let payload = Self.prefixedTranscript(transcript) let payload = Self.prefixedTranscript(transcript)
let deliver = options.provider.shouldDeliver(options.deliver) let deliver = options.channel.shouldDeliver(options.deliver)
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: payload, message: payload,
sessionKey: options.sessionKey, sessionKey: options.sessionKey,
thinking: options.thinking, thinking: options.thinking,
deliver: deliver, deliver: deliver,
to: options.to, to: options.to,
provider: options.provider)) channel: options.channel))
if result.ok { if result.ok {
self.logger.info("voice wake forward ok") self.logger.info("voice wake forward ok")
@@ -38,15 +38,6 @@ actor VoiceWakeRuntime {
private var overlayToken: UUID? private var overlayToken: UUID?
private var activeTriggerEndTime: TimeInterval? private var activeTriggerEndTime: TimeInterval?
private var scheduledRestartTask: Task<Void, Never>? private var scheduledRestartTask: Task<Void, Never>?
private var lastLoggedText: String?
private var lastLoggedAt: Date?
private var lastTapLogAt: Date?
private var lastCallbackLogAt: Date?
private var lastTranscript: String?
private var lastTranscriptAt: Date?
private var preDetectTask: Task<Void, Never>?
private var isStarting: Bool = false
private var triggerOnlyTask: Task<Void, Never>?
// Tunables // Tunables
// Silence threshold once we've captured user speech (post-trigger). // Silence threshold once we've captured user speech (post-trigger).
@@ -59,8 +50,6 @@ actor VoiceWakeRuntime {
// Voice activity detection parameters (RMS-based). // Voice activity detection parameters (RMS-based).
private let minSpeechRMS: Double = 1e-3 private let minSpeechRMS: Double = 1e-3
private let speechBoostFactor: Double = 6.0 // how far above noise floor we require to mark speech private let speechBoostFactor: Double = 6.0 // how far above noise floor we require to mark speech
private let preDetectSilenceWindow: TimeInterval = 1.0
private let triggerPauseWindow: TimeInterval = 0.55
/// Stops the active Speech pipeline without clearing the stored config, so we can restart cleanly. /// Stops the active Speech pipeline without clearing the stored config, so we can restart cleanly.
private func haltRecognitionPipeline() { private func haltRecognitionPipeline() {
@@ -117,19 +106,6 @@ actor VoiceWakeRuntime {
let config = snapshot.1 let config = snapshot.1
if self.isStarting {
return
}
if self.scheduledRestartTask != nil, config == self.currentConfig, self.recognitionTask == nil {
return
}
if self.scheduledRestartTask != nil {
self.scheduledRestartTask?.cancel()
self.scheduledRestartTask = nil
}
if config == self.currentConfig, self.recognitionTask != nil { if config == self.currentConfig, self.recognitionTask != nil {
return return
} }
@@ -139,11 +115,6 @@ actor VoiceWakeRuntime {
} }
private func start(with config: RuntimeConfig) async { private func start(with config: RuntimeConfig) async {
if self.isStarting {
return
}
self.isStarting = true
defer { self.isStarting = false }
do { do {
self.recognitionGeneration &+= 1 self.recognitionGeneration &+= 1
let generation = self.recognitionGeneration let generation = self.recognitionGeneration
@@ -157,7 +128,6 @@ actor VoiceWakeRuntime {
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
self.recognitionRequest?.shouldReportPartialResults = true self.recognitionRequest?.shouldReportPartialResults = true
self.recognitionRequest?.taskHint = .dictation
guard let request = self.recognitionRequest else { return } guard let request = self.recognitionRequest else { return }
// Lazily create the engine here so app launch doesn't grab audio resources / trigger Bluetooth HFP. // Lazily create the engine here so app launch doesn't grab audio resources / trigger Bluetooth HFP.
@@ -168,19 +138,13 @@ actor VoiceWakeRuntime {
let input = audioEngine.inputNode let input = audioEngine.inputNode
let format = input.outputFormat(forBus: 0) let format = input.outputFormat(forBus: 0)
guard format.channelCount > 0, format.sampleRate > 0 else {
throw NSError(
domain: "VoiceWakeRuntime",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "No audio input available"])
}
input.removeTap(onBus: 0) input.removeTap(onBus: 0)
input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak self, weak request] buffer, _ in input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak self, weak request] buffer, _ in
request?.append(buffer) request?.append(buffer)
guard let rms = Self.rmsLevel(buffer: buffer) else { return } if let rms = Self.rmsLevel(buffer: buffer) {
Task.detached { [weak self] in Task.detached { [weak self] in
await self?.noteAudioLevel(rms: rms) await self?.noteAudioLevel(rms: rms)
await self?.noteAudioTap(rms: rms) }
} }
} }
@@ -199,7 +163,6 @@ actor VoiceWakeRuntime {
.map { WakeWordSpeechSegments.from(transcription: result.bestTranscription, transcript: $0) } .map { WakeWordSpeechSegments.from(transcription: result.bestTranscription, transcript: $0) }
} ?? [] } ?? []
let isFinal = result?.isFinal ?? false let isFinal = result?.isFinal ?? false
Task { await self.noteRecognitionCallback(transcript: transcript, isFinal: isFinal, error: error) }
let update = RecognitionUpdate( let update = RecognitionUpdate(
transcript: transcript, transcript: transcript,
segments: segments, segments: segments,
@@ -209,10 +172,6 @@ actor VoiceWakeRuntime {
Task { await self.handleRecognition(update, config: config) } Task { await self.handleRecognition(update, config: config) }
} }
let preferred = config.micID?.isEmpty == false ? config.micID! : "system-default"
self.logger.info(
"voicewake runtime input preferred=\(preferred, privacy: .public) " +
"\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public)")
self.logger.info("voicewake runtime started") self.logger.info("voicewake runtime started")
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "started", fields: [ DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "started", fields: [
"locale": config.localeID ?? "", "locale": config.localeID ?? "",
@@ -235,12 +194,6 @@ actor VoiceWakeRuntime {
self.capturedTranscript = "" self.capturedTranscript = ""
self.captureStartedAt = nil self.captureStartedAt = nil
self.triggerChimePlayed = false self.triggerChimePlayed = false
self.lastTranscript = nil
self.lastTranscriptAt = nil
self.preDetectTask?.cancel()
self.preDetectTask = nil
self.triggerOnlyTask?.cancel()
self.triggerOnlyTask = nil
self.haltRecognitionPipeline() self.haltRecognitionPipeline()
self.recognizer = nil self.recognizer = nil
self.currentConfig = nil self.currentConfig = nil
@@ -264,7 +217,6 @@ actor VoiceWakeRuntime {
private func configureSession(localeID: String?) { private func configureSession(localeID: String?) {
let locale = localeID.flatMap { Locale(identifier: $0) } ?? Locale(identifier: Locale.current.identifier) let locale = localeID.flatMap { Locale(identifier: $0) } ?? Locale(identifier: Locale.current.identifier)
self.recognizer = SFSpeechRecognizer(locale: locale) self.recognizer = SFSpeechRecognizer(locale: locale)
self.recognizer?.defaultTaskHint = .dictation
} }
private func handleRecognition(_ update: RecognitionUpdate, config: RuntimeConfig) async { private func handleRecognition(_ update: RecognitionUpdate, config: RuntimeConfig) async {
@@ -280,19 +232,7 @@ actor VoiceWakeRuntime {
let now = Date() let now = Date()
if !transcript.isEmpty { if !transcript.isEmpty {
self.lastHeard = now self.lastHeard = now
if !self.isCapturing {
self.lastTranscript = transcript
self.lastTranscriptAt = now
}
if self.isCapturing { if self.isCapturing {
self.maybeLogRecognition(
transcript: transcript,
segments: update.segments,
triggers: config.triggers,
isFinal: update.isFinal,
match: nil,
usedFallback: false,
capturing: true)
let trimmed = Self.commandAfterTrigger( let trimmed = Self.commandAfterTrigger(
transcript: transcript, transcript: transcript,
segments: update.segments, segments: update.segments,
@@ -326,214 +266,15 @@ actor VoiceWakeRuntime {
if self.isCapturing { return } if self.isCapturing { return }
let gateConfig = WakeWordGateConfig(triggers: config.triggers) let gateConfig = WakeWordGateConfig(triggers: config.triggers)
var usedFallback = false if let match = WakeWordGate.match(transcript: transcript, segments: update.segments, config: gateConfig) {
var match = WakeWordGate.match(transcript: transcript, segments: update.segments, config: gateConfig)
if match == nil, update.isFinal {
match = self.textOnlyFallbackMatch(
transcript: transcript,
triggers: config.triggers,
config: gateConfig)
usedFallback = match != nil
}
self.maybeLogRecognition(
transcript: transcript,
segments: update.segments,
triggers: config.triggers,
isFinal: update.isFinal,
match: match,
usedFallback: usedFallback,
capturing: false)
if let match {
if let cooldown = cooldownUntil, now < cooldown { if let cooldown = cooldownUntil, now < cooldown {
return return
} }
if usedFallback {
self.logger.info("voicewake runtime detected (text-only fallback) len=\(match.command.count)")
} else {
self.logger.info("voicewake runtime detected len=\(match.command.count)")
}
await self.beginCapture(command: match.command, triggerEndTime: match.triggerEndTime, config: config) await self.beginCapture(command: match.command, triggerEndTime: match.triggerEndTime, config: config)
} else if !transcript.isEmpty, update.error == nil {
if self.isTriggerOnly(transcript: transcript, triggers: config.triggers) {
self.preDetectTask?.cancel()
self.preDetectTask = nil
self.scheduleTriggerOnlyPauseCheck(triggers: config.triggers, config: config)
} else {
self.triggerOnlyTask?.cancel()
self.triggerOnlyTask = nil
self.schedulePreDetectSilenceCheck(
triggers: config.triggers,
gateConfig: gateConfig,
config: config)
}
} }
} }
private func maybeLogRecognition( private func beginCapture(command: String, triggerEndTime: TimeInterval, config: RuntimeConfig) async {
transcript: String,
segments: [WakeWordSegment],
triggers: [String],
isFinal: Bool,
match: WakeWordGateMatch?,
usedFallback: Bool,
capturing: Bool)
{
guard !transcript.isEmpty else { return }
let level = self.logger.logLevel
guard level == .debug || level == .trace else { return }
if transcript == self.lastLoggedText, !isFinal {
if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 {
return
}
}
self.lastLoggedText = transcript
self.lastLoggedAt = Date()
let textOnly = WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers)
let timingCount = segments.count(where: { $0.start > 0 || $0.duration > 0 })
let matchSummary = match.map {
"match=true gap=\(String(format: "%.2f", $0.postGap))s cmdLen=\($0.command.count)"
} ?? "match=false"
let segmentSummary = segments.map { seg in
let start = String(format: "%.2f", seg.start)
let end = String(format: "%.2f", seg.end)
return "\(seg.text)@\(start)-\(end)"
}.joined(separator: ", ")
self.logger.debug(
"voicewake runtime transcript='\(transcript, privacy: .private)' textOnly=\(textOnly) " +
"isFinal=\(isFinal) timing=\(timingCount)/\(segments.count) " +
"capturing=\(capturing) fallback=\(usedFallback) " +
"\(matchSummary) segments=[\(segmentSummary, privacy: .private)]")
}
private func noteAudioTap(rms: Double) {
let now = Date()
if let last = self.lastTapLogAt, now.timeIntervalSince(last) < 1.0 {
return
}
self.lastTapLogAt = now
let db = 20 * log10(max(rms, 1e-7))
self.logger.debug(
"voicewake runtime audio tap rms=\(String(format: "%.6f", rms)) " +
"db=\(String(format: "%.1f", db)) capturing=\(self.isCapturing)")
}
private func noteRecognitionCallback(transcript: String?, isFinal: Bool, error: Error?) {
guard transcript?.isEmpty ?? true else { return }
let now = Date()
if let last = self.lastCallbackLogAt, now.timeIntervalSince(last) < 1.0 {
return
}
self.lastCallbackLogAt = now
let errorSummary = error?.localizedDescription ?? "none"
self.logger.debug(
"voicewake runtime callback empty transcript isFinal=\(isFinal) error=\(errorSummary, privacy: .public)")
}
private func scheduleTriggerOnlyPauseCheck(triggers: [String], config: RuntimeConfig) {
self.triggerOnlyTask?.cancel()
let lastSeenAt = self.lastTranscriptAt
let lastText = self.lastTranscript
let windowNanos = UInt64(self.triggerPauseWindow * 1_000_000_000)
self.triggerOnlyTask = Task { [weak self, lastSeenAt, lastText] in
try? await Task.sleep(nanoseconds: windowNanos)
guard let self else { return }
await self.triggerOnlyPauseCheck(
lastSeenAt: lastSeenAt,
lastText: lastText,
triggers: triggers,
config: config)
}
}
private func schedulePreDetectSilenceCheck(
triggers: [String],
gateConfig: WakeWordGateConfig,
config: RuntimeConfig)
{
self.preDetectTask?.cancel()
let lastSeenAt = self.lastTranscriptAt
let lastText = self.lastTranscript
let windowNanos = UInt64(self.preDetectSilenceWindow * 1_000_000_000)
self.preDetectTask = Task { [weak self, lastSeenAt, lastText] in
try? await Task.sleep(nanoseconds: windowNanos)
guard let self else { return }
await self.preDetectSilenceCheck(
lastSeenAt: lastSeenAt,
lastText: lastText,
triggers: triggers,
gateConfig: gateConfig,
config: config)
}
}
private func triggerOnlyPauseCheck(
lastSeenAt: Date?,
lastText: String?,
triggers: [String],
config: RuntimeConfig) async
{
guard !Task.isCancelled else { return }
guard !self.isCapturing else { return }
guard let lastSeenAt, let lastText else { return }
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
guard self.isTriggerOnly(transcript: lastText, triggers: triggers) else { return }
if let cooldown = self.cooldownUntil, Date() < cooldown {
return
}
self.logger.info("voicewake runtime detected (trigger-only pause)")
await self.beginCapture(command: "", triggerEndTime: nil, config: config)
}
private func textOnlyFallbackMatch(
transcript: String,
triggers: [String],
config: WakeWordGateConfig) -> WakeWordGateMatch?
{
guard let command = VoiceWakeTextUtils.textOnlyCommand(
transcript: transcript,
triggers: triggers,
minCommandLength: config.minCommandLength,
trimWake: Self.trimmedAfterTrigger)
else { return nil }
return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
}
private func isTriggerOnly(transcript: String, triggers: [String]) -> Bool {
guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return false }
guard VoiceWakeTextUtils.startsWithTrigger(transcript: transcript, triggers: triggers) else { return false }
return Self.trimmedAfterTrigger(transcript, triggers: triggers).isEmpty
}
private func preDetectSilenceCheck(
lastSeenAt: Date?,
lastText: String?,
triggers: [String],
gateConfig: WakeWordGateConfig,
config: RuntimeConfig) async
{
guard !Task.isCancelled else { return }
guard !self.isCapturing else { return }
guard let lastSeenAt, let lastText else { return }
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
guard let match = self.textOnlyFallbackMatch(
transcript: lastText,
triggers: triggers,
config: gateConfig)
else { return }
if let cooldown = self.cooldownUntil, Date() < cooldown {
return
}
self.logger.info("voicewake runtime detected (silence fallback) len=\(match.command.count)")
await self.beginCapture(
command: match.command,
triggerEndTime: match.triggerEndTime,
config: config)
}
private func beginCapture(command: String, triggerEndTime: TimeInterval?, config: RuntimeConfig) async {
self.listeningState = .voiceWake self.listeningState = .voiceWake
self.isCapturing = true self.isCapturing = true
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "beginCapture") DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "beginCapture")
@@ -545,10 +286,6 @@ actor VoiceWakeRuntime {
self.heardBeyondTrigger = !command.isEmpty self.heardBeyondTrigger = !command.isEmpty
self.triggerChimePlayed = false self.triggerChimePlayed = false
self.activeTriggerEndTime = triggerEndTime self.activeTriggerEndTime = triggerEndTime
self.preDetectTask?.cancel()
self.preDetectTask = nil
self.triggerOnlyTask?.cancel()
self.triggerOnlyTask = nil
if config.triggerChime != .none, !self.triggerChimePlayed { if config.triggerChime != .none, !self.triggerChimePlayed {
self.triggerChimePlayed = true self.triggerChimePlayed = true
@@ -621,12 +358,6 @@ actor VoiceWakeRuntime {
self.heardBeyondTrigger = false self.heardBeyondTrigger = false
self.triggerChimePlayed = false self.triggerChimePlayed = false
self.activeTriggerEndTime = nil self.activeTriggerEndTime = nil
self.lastTranscript = nil
self.lastTranscriptAt = nil
self.preDetectTask?.cancel()
self.preDetectTask = nil
self.triggerOnlyTask?.cancel()
self.triggerOnlyTask = nil
await MainActor.run { AppStateStore.shared.stopVoiceEars() } await MainActor.run { AppStateStore.shared.stopVoiceEars() }
if let token = self.overlayToken { if let token = self.overlayToken {
@@ -710,15 +441,10 @@ actor VoiceWakeRuntime {
let nanos = UInt64(max(0, delay) * 1_000_000_000) let nanos = UInt64(max(0, delay) * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanos) try? await Task.sleep(nanoseconds: nanos)
guard let self else { return } guard let self else { return }
await self.consumeScheduledRestart()
await self.restartRecognizerIfIdleAndOverlayHidden() await self.restartRecognizerIfIdleAndOverlayHidden()
} }
} }
private func consumeScheduledRestart() {
self.scheduledRestartTask = nil
}
func applyPushToTalkCooldown() { func applyPushToTalkCooldown() {
self.cooldownUntil = Date().addingTimeInterval(self.debounceAfterSend) self.cooldownUntil = Date().addingTimeInterval(self.debounceAfterSend)
} }
@@ -2,24 +2,19 @@ import AppKit
import AVFoundation import AVFoundation
import Observation import Observation
import Speech import Speech
import SwabbleKit
import SwiftUI import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
struct VoiceWakeSettings: View { struct VoiceWakeSettings: View {
@Bindable var state: AppState @Bindable var state: AppState
let isActive: Bool
@State private var testState: VoiceWakeTestState = .idle @State private var testState: VoiceWakeTestState = .idle
@State private var tester = VoiceWakeTester() @State private var tester = VoiceWakeTester()
@State private var isTesting = false @State private var isTesting = false
@State private var testTimeoutTask: Task<Void, Never>?
@State private var availableMics: [AudioInputDevice] = [] @State private var availableMics: [AudioInputDevice] = []
@State private var loadingMics = false @State private var loadingMics = false
@State private var meterLevel: Double = 0 @State private var meterLevel: Double = 0
@State private var meterError: String? @State private var meterError: String?
private let meter = MicLevelMonitor() private let meter = MicLevelMonitor()
@State private var micObserver = AudioInputDeviceObserver()
@State private var micRefreshTask: Task<Void, Never>?
@State private var availableLocales: [Locale] = [] @State private var availableLocales: [Locale] = []
private let fieldLabelWidth: CGFloat = 140 private let fieldLabelWidth: CGFloat = 140
private let controlWidth: CGFloat = 240 private let controlWidth: CGFloat = 240
@@ -102,39 +97,12 @@ struct VoiceWakeSettings: View {
guard !self.isPreview else { return } guard !self.isPreview else { return }
await self.restartMeter() await self.restartMeter()
} }
.onAppear {
guard !self.isPreview else { return }
self.startMicObserver()
}
.onChange(of: self.state.voiceWakeMicID) { _, _ in .onChange(of: self.state.voiceWakeMicID) { _, _ in
guard !self.isPreview else { return } guard !self.isPreview else { return }
self.updateSelectedMicName()
Task { await self.restartMeter() } Task { await self.restartMeter() }
} }
.onChange(of: self.isActive) { _, active in
guard !self.isPreview else { return }
if !active {
self.tester.stop()
self.isTesting = false
self.testState = .idle
self.testTimeoutTask?.cancel()
self.micRefreshTask?.cancel()
self.micRefreshTask = nil
Task { await self.meter.stop() }
self.micObserver.stop()
} else {
self.startMicObserver()
}
}
.onDisappear { .onDisappear {
guard !self.isPreview else { return } guard !self.isPreview else { return }
self.tester.stop()
self.isTesting = false
self.testState = .idle
self.testTimeoutTask?.cancel()
self.micRefreshTask?.cancel()
self.micRefreshTask = nil
self.micObserver.stop()
Task { await self.meter.stop() } Task { await self.meter.stop() }
} }
} }
@@ -237,23 +205,13 @@ struct VoiceWakeSettings: View {
return return
} }
if self.isTesting { if self.isTesting {
self.tester.finalize() self.tester.stop()
self.isTesting = false self.isTesting = false
self.testState = .finalizing self.testState = .idle
Task { @MainActor in
try? await Task.sleep(nanoseconds: 2_000_000_000)
if self.testState == .finalizing {
self.tester.stop()
self.testState = .failed("Stopped")
}
}
self.testTimeoutTask?.cancel()
return return
} }
let triggers = self.sanitizedTriggers() let triggers = self.sanitizedTriggers()
self.tester.stop()
self.testTimeoutTask?.cancel()
self.isTesting = true self.isTesting = true
self.testState = .requesting self.testState = .requesting
Task { @MainActor in Task { @MainActor in
@@ -267,31 +225,18 @@ struct VoiceWakeSettings: View {
self.testState = newState self.testState = newState
if case .detected = newState { self.isTesting = false } if case .detected = newState { self.isTesting = false }
if case .failed = newState { self.isTesting = false } if case .failed = newState { self.isTesting = false }
if case .detected = newState { self.testTimeoutTask?.cancel() }
if case .failed = newState { self.testTimeoutTask?.cancel() }
} }
}) })
self.testTimeoutTask?.cancel() try await Task.sleep(nanoseconds: 10 * 1_000_000_000)
self.testTimeoutTask = Task { @MainActor in if self.isTesting {
try? await Task.sleep(nanoseconds: 10 * 1_000_000_000) self.tester.stop()
guard !Task.isCancelled else { return } self.testState = .failed("Timeout: no trigger heard")
if self.isTesting { self.isTesting = false
self.tester.stop()
if case let .hearing(text) = self.testState,
let command = Self.textOnlyCommand(from: text, triggers: triggers)
{
self.testState = .detected(command)
} else {
self.testState = .failed("Timeout: no trigger heard")
}
self.isTesting = false
}
} }
} catch { } catch {
self.tester.stop() self.tester.stop()
self.testState = .failed(error.localizedDescription) self.testState = .failed(error.localizedDescription)
self.isTesting = false self.isTesting = false
self.testTimeoutTask?.cancel()
} }
} }
} }
@@ -369,14 +314,6 @@ struct VoiceWakeSettings: View {
sanitizeVoiceWakeTriggers(self.state.swabbleTriggerWords) sanitizeVoiceWakeTriggers(self.state.swabbleTriggerWords)
} }
private static func textOnlyCommand(from transcript: String, triggers: [String]) -> String? {
VoiceWakeTextUtils.textOnlyCommand(
transcript: transcript,
triggers: triggers,
minCommandLength: 1,
trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) })
}
private var micPicker: some View { private var micPicker: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 10) { HStack(alignment: .firstTextBaseline, spacing: 10) {
@@ -385,10 +322,6 @@ struct VoiceWakeSettings: View {
.frame(width: self.fieldLabelWidth, alignment: .leading) .frame(width: self.fieldLabelWidth, alignment: .leading)
Picker("Microphone", selection: self.$state.voiceWakeMicID) { Picker("Microphone", selection: self.$state.voiceWakeMicID) {
Text("System default").tag("") Text("System default").tag("")
if self.isSelectedMicUnavailable {
Text(self.state.voiceWakeMicName.isEmpty ? "Unavailable" : self.state.voiceWakeMicName)
.tag(self.state.voiceWakeMicID)
}
ForEach(self.availableMics) { mic in ForEach(self.availableMics) { mic in
Text(mic.name).tag(mic.uid) Text(mic.name).tag(mic.uid)
} }
@@ -396,15 +329,6 @@ struct VoiceWakeSettings: View {
.labelsHidden() .labelsHidden()
.frame(width: self.controlWidth) .frame(width: self.controlWidth)
} }
if self.isSelectedMicUnavailable {
HStack(spacing: 10) {
Color.clear.frame(width: self.fieldLabelWidth, height: 1)
Text("Disconnected (using System default)")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
if self.loadingMics { if self.loadingMics {
ProgressView().controlSize(.small) ProgressView().controlSize(.small)
} }
@@ -497,60 +421,17 @@ struct VoiceWakeSettings: View {
} }
@MainActor @MainActor
private func loadMicsIfNeeded(force: Bool = false) async { private func loadMicsIfNeeded() async {
guard force || self.availableMics.isEmpty, !self.loadingMics else { return } guard self.availableMics.isEmpty, !self.loadingMics else { return }
self.loadingMics = true self.loadingMics = true
let discovery = AVCaptureDevice.DiscoverySession( let discovery = AVCaptureDevice.DiscoverySession(
deviceTypes: [.external, .microphone], deviceTypes: [.external, .microphone],
mediaType: .audio, mediaType: .audio,
position: .unspecified) position: .unspecified)
let aliveUIDs = AudioInputDeviceObserver.aliveInputDeviceUIDs() self.availableMics = discovery.devices.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) }
let connectedDevices = discovery.devices.filter(\.isConnected)
let devices = aliveUIDs.isEmpty
? connectedDevices
: connectedDevices.filter { aliveUIDs.contains($0.uniqueID) }
self.availableMics = devices.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) }
self.updateSelectedMicName()
self.loadingMics = false self.loadingMics = false
} }
private var isSelectedMicUnavailable: Bool {
let selected = self.state.voiceWakeMicID
guard !selected.isEmpty else { return false }
return !self.availableMics.contains(where: { $0.uid == selected })
}
@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 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.loadMicsIfNeeded(force: true)
await self.restartMeter()
}
}
@MainActor @MainActor
private func loadLocalesIfNeeded() async { private func loadLocalesIfNeeded() async {
guard self.availableLocales.isEmpty else { return } guard self.availableLocales.isEmpty else { return }
@@ -625,7 +506,7 @@ struct VoiceWakeSettings: View {
#if DEBUG #if DEBUG
struct VoiceWakeSettings_Previews: PreviewProvider { struct VoiceWakeSettings_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
VoiceWakeSettings(state: .preview, isActive: true) VoiceWakeSettings(state: .preview)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
} }
} }
@@ -638,7 +519,7 @@ extension VoiceWakeSettings {
state.voicePushToTalkEnabled = true state.voicePushToTalkEnabled = true
state.swabbleTriggerWords = ["Claude", "Hey"] state.swabbleTriggerWords = ["Claude", "Hey"]
let view = VoiceWakeSettings(state: state, isActive: true) let view = VoiceWakeSettings(state: state)
view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")] view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")]
view.availableLocales = [Locale(identifier: "en_US")] view.availableLocales = [Locale(identifier: "en_US")]
view.meterLevel = 0.42 view.meterLevel = 0.42
@@ -57,9 +57,6 @@ struct VoiceWakeTestCard: View {
.symbolEffect(.pulse) .symbolEffect(.pulse)
.foregroundStyle(Color.accentColor)) .foregroundStyle(Color.accentColor))
case .finalizing:
AnyView(ProgressView().controlSize(.small))
case .detected: case .detected:
AnyView(Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)) AnyView(Image(systemName: "checkmark.circle.fill").foregroundStyle(.green))
@@ -82,9 +79,6 @@ struct VoiceWakeTestCard: View {
case let .hearing(text): case let .hearing(text):
"Heard: \(text)" "Heard: \(text)"
case .finalizing:
"Finalizing…"
case .detected: case .detected:
"Voice wake detected!" "Voice wake detected!"
Loaded 100 of 1092 files, more files were not shown because too many files have changed in this diff. Show more