Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b5f672353 | ||
|
|
9c13819f39 | ||
|
|
971153f4de |
No files matched your search
@@ -1 +0,0 @@
|
||||
* text=auto eol=lf
|
||||
@@ -90,85 +90,6 @@ jobs:
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
checks-windows:
|
||||
runs-on: windows-latest
|
||||
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
|
||||
with:
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
bun -v
|
||||
|
||||
- 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 }}
|
||||
|
||||
macos-app:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-latest
|
||||
@@ -448,8 +369,6 @@ jobs:
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
accept-android-sdk-licenses: false
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
@@ -5,16 +5,8 @@
|
||||
- Tests: colocated `*.test.ts`.
|
||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||
|
||||
## Docs Linking (Mintlify)
|
||||
- Docs are hosted on Mintlify (docs.clawd.bot).
|
||||
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
||||
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
|
||||
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
||||
- Install deps: `pnpm install`
|
||||
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
|
||||
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
||||
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
||||
@@ -25,7 +17,6 @@
|
||||
## Coding Style & Naming Conventions
|
||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||
- Formatting/linting via Biome; run `pnpm lint` before commits.
|
||||
- Add brief code comments for tricky or non-obvious logic.
|
||||
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
|
||||
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
||||
|
||||
@@ -41,52 +32,41 @@
|
||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
||||
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
||||
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
|
||||
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
|
||||
- When working on a PR: add a changelog entry with the PR ID 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: leave a PR comment that explains exactly what we did.
|
||||
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
|
||||
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
|
||||
|
||||
### PR Workflow (Review vs Land)
|
||||
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
||||
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.
|
||||
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
|
||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||
|
||||
## Troubleshooting
|
||||
- Rebrand/migration issues (Clawdis → Clawdbot) or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
|
||||
|
||||
## Agent-Specific Notes
|
||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
|
||||
- Gateway currently runs only as the menubar app (launchctl shows `application.com.steipete.clawdbot.debug.*`), there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than expecting `com.steipete.clawdbot`. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdbot`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
|
||||
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
||||
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
|
||||
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/Clawdbot/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), `docs/platforms/mac/release.md` (APP_VERSION/APP_BUILD examples), Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
|
||||
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
|
||||
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
|
||||
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
|
||||
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
|
||||
- Release signing/notary keys are managed outside the repo; follow internal release docs.
|
||||
- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are expected in your environment (per internal release docs).
|
||||
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
||||
- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks.
|
||||
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested.
|
||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
|
||||
- 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 in `~/.profile`.
|
||||
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
||||
- **Multi-agent safety:** when Peter says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When Peter says "commit", scope to your changes only. When Peter says "commit all", commit everything in grouped chunks.
|
||||
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless Peter explicitly asks.
|
||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
|
||||
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
|
||||
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from Mac Studio, SSH via Tailscale and read the same path there.
|
||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||
- Voice wake forwarding tips:
|
||||
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||
- launchd PATH is minimal; ensure the app’s launch agent sets PATH to include `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/steipete/Library/pnpm` so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||
- For manual `clawdbot send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||
|
||||
## Exclamation Mark Escaping Workaround
|
||||
|
||||
+207
-32
@@ -1,48 +1,117 @@
|
||||
# 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
|
||||
|
||||
- (nothing yet)
|
||||
|
||||
## 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
|
||||
- **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>` (Telegram also supports `clawdbot telegram pairing ...`).
|
||||
- Sandbox: default `agent.sandbox.scope` to `"agent"` (one container/workspace per agent). Use `"session"` for per-session isolation; `"shared"` disables cross-session isolation.
|
||||
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only).
|
||||
- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup.
|
||||
- Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context.
|
||||
- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior.
|
||||
- Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides).
|
||||
- CLI: remove `update`, `gateway-daemon`, `gateway {install|uninstall|start|stop|restart|daemon status|wake|send|agent}`, and `telegram` commands; move `login/logout` to `providers login/logout` (top-level aliases hidden); use `daemon` for service control, `send`/`agent`/`wake` for RPC, and `nodes canvas` for canvas ops.
|
||||
|
||||
### Fixes
|
||||
- **CLI/Gateway/Doctor:** daemon runtime selection + improved logs/status/health/errors; auth/password handling for local CLI; richer close/timeout details; auto-migrate legacy config/sessions/state; integrity checks + repair prompts; `--yes`/`--non-interactive`; `--deep` gateway scans; better restart/service hints.
|
||||
- **Agent loop + compaction:** compaction/pruning tuning, overflow handling, safer bootstrap context, and per-provider threading/confirmations; opt-in tool-result pruning + compact tracking.
|
||||
- **Sandbox + tools:** per-agent sandbox overrides, workspaceAccess controls, session tool visibility, tool policy overrides, process isolation, and tool schema/timeout/reaction unification.
|
||||
- **Providers (Telegram/WhatsApp/Discord/Slack/Signal/iMessage):** retry/backoff, threading, reactions, media groups/attachments, mention gating, typing behavior, and error/log stability; long polling + forum topic isolation for Telegram.
|
||||
- **Gateway/CLI UX:** `clawdbot logs`, cron list colors/aliases, docs search, agents list/add/delete flows, status usage snapshots, runtime/auth source display, and `/status`/commands auth unification.
|
||||
- **Control UI/Web:** logs tab, focus mode polish, config form resilience, streaming stability, tool output caps, windowed chat history, and reconnect/password URL auth.
|
||||
- **macOS/Android/TUI/Build:** macOS gateway races, QR bundling, JSON5 config safety, Voice Wake hardening; Android EXIF rotation + APK naming/versioning; TUI key handling; tooling/bundling fixes.
|
||||
- **Packaging/compat:** npm dist folder coverage, Node 25 qrcode-terminal import fixes, Bun/Playwright/WebSocket patches, and Docker Bun install.
|
||||
- **Docs:** new FAQ/ClawdHub/config examples/showcase entries and clarified auth, sandbox, and systemd docs.
|
||||
- Messages: stop defaulting ack reactions to 👀 when identity emoji is missing.
|
||||
- Auto-reply: require slash for control commands to avoid false triggers in normal text.
|
||||
- Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes.
|
||||
- Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure.
|
||||
- Onboarding: prompt immediately for OpenAI Codex redirect URL on remote/headless logins.
|
||||
- Configure: add OpenAI Codex (ChatGPT OAuth) auth choice (align with onboarding).
|
||||
- Doctor: suggest adding the workspace memory system when missing (opt-out via `--no-workspace-suggestions`).
|
||||
- Build: fix duplicate protocol export, align Codex OAuth options, and add proper-lockfile typings.
|
||||
- Build: install Bun in the Dockerfile so `pnpm build` can run Bun scripts. Thanks @loukotal for PR #284.
|
||||
- Typing indicators: stop typing once the reply dispatcher drains to prevent stuck typing across Discord/Telegram/WhatsApp.
|
||||
- Typing indicators: fix a race that could keep the typing indicator stuck after quick replies. Thanks @thewilloftheshadow for PR #270.
|
||||
- Google: merge consecutive messages to satisfy strict role alternation for Google provider models. Thanks @Asleep123 for PR #266.
|
||||
- Postinstall: handle targetDir symlinks in the install script. Thanks @obviyus for PR #272.
|
||||
- WhatsApp/Telegram: add groupPolicy handling for group messages and normalize allowFrom matching (tg/telegram prefixes). Thanks @mneves75.
|
||||
- Auto-reply: add configurable ack reactions for inbound messages (default 👀 or `identity.emoji`) with scope controls. Thanks @obviyus for PR #178.
|
||||
- Polls: unify WhatsApp + Discord poll sends via the gateway + CLI (`clawdbot poll`). (#123) — thanks @dbhurley
|
||||
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
|
||||
- Onboarding: when OpenAI Codex OAuth is used, default to `openai-codex/gpt-5.2` and warn if the selected model lacks auth.
|
||||
- CLI: auto-migrate legacy config entries on command start (same behavior as gateway startup).
|
||||
- Gateway: add `gateway stop|restart` helpers and surface launchd/systemd/schtasks stop hints when the gateway is already running.
|
||||
- Gateway: honor `agent.timeoutSeconds` for `chat.send` and share timeout defaults across chat/cron/auto-reply. Thanks @MSch for PR #229.
|
||||
- Auth: prioritize OAuth profiles but fall back to API keys when refresh fails; stored profiles now load without explicit auth order.
|
||||
- Control UI: harden config Form view with schema normalization, map editing, and guardrails to prevent data loss on save.
|
||||
- Cron: normalize cron.add/update inputs, align channel enums/status fields across gateway/CLI/UI/macOS, and add protocol conformance tests. Thanks @mneves75 for PR #256.
|
||||
- Docs: add group chat participation guidance to the AGENTS template.
|
||||
- Gmail: stop restart loop when `gog gmail watch serve` fails to bind (address already in use).
|
||||
- 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.
|
||||
- TUI: migrate key handling to the updated pi-tui Key matcher API.
|
||||
- TUI: add `/elev` alias for `/elevated`.
|
||||
- Logging: redact sensitive tokens in verbose tool summaries by default (configurable patterns).
|
||||
- macOS: prefer gateway config reads/writes in local mode (fall back to disk if the gateway is unavailable).
|
||||
- macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`.
|
||||
- macOS: Connections settings now use a custom sidebar to avoid toolbar toggle issues, with rounded styling and full-width row hit targets.
|
||||
- macOS: drop deprecated `afterMs` from agent wait params to match gateway schema.
|
||||
- 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.
|
||||
- Auth: when `openai` has no API key but Codex OAuth exists, suggest `openai-codex/gpt-5.2` vs `OPENAI_API_KEY`.
|
||||
- Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding.
|
||||
- Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments.
|
||||
- Sandbox: enable session tools in sandboxed sessions with spawned-only visibility by default (opt-in `agent.sandbox.sessionToolsVisibility = "all"`).
|
||||
- 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).
|
||||
- Control UI: let config-form enums select empty-string values. Thanks @sreekaransrinath for PR #268.
|
||||
- Control UI: scroll chat to bottom on initial load. Thanks @kiranjd for PR #274.
|
||||
- Control UI: add Chat focus mode toggle to collapse header + sidebar.
|
||||
- Control UI: standardize UI build instructions on `bun run ui:*` (fallback supported).
|
||||
- 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: centralize tool/block/final dispatch across providers for consistent streaming + heartbeat/prefix handling. Thanks @MSch for PR #225.
|
||||
- Heartbeat: make HEARTBEAT_OK ack padding configurable across heartbeat and cron delivery. (#238) — thanks @jalehman
|
||||
- Skills: emit MEDIA token after Nano Banana Pro image generation. Thanks @Iamadig for PR #271.
|
||||
- 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.
|
||||
- Discord: surface missing-permission hints (muted/role overrides) when replies fail.
|
||||
- Discord: use channel IDs for DMs instead of user IDs. Thanks @VACInc for PR #261.
|
||||
- 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.
|
||||
- Auth: rotate across multiple OAuth profiles with cooldown tracking and email-based profile IDs. Thanks @mukhtharcm for PR #269.
|
||||
- Auth: fix multi-account OAuth rotation so round-robin alternates instead of pinning to lastGood. Thanks @mukhtharcm for PR #281.
|
||||
- Configure: stop auto-writing `auth.order` for newly added auth profiles (round-robin default unless explicitly pinned).
|
||||
- Telegram: honor routing.groupChat.mentionPatterns for group mention gating. Thanks Kevin Kern (@regenrek) for PR #242.
|
||||
- Telegram: gate groups via `telegram.groups` allowlist (align with WhatsApp/iMessage). Thanks @kitze for PR #241.
|
||||
- Telegram: support media groups (multi-image messages). Thanks @obviyus for PR #220.
|
||||
- Telegram/WhatsApp: parse shared locations (pins, places, live) and expose structured ctx fields. Thanks @nachoiacovino for PR #194.
|
||||
- Auto-reply: block unauthorized `/reset` and infer WhatsApp senders from E.164 inputs.
|
||||
- Auto-reply: track compaction count in session status; verbose mode announces auto-compactions.
|
||||
- Telegram: send GIF media as animations (auto-play) and improve filename sniffing.
|
||||
- Bash tool: inherit gateway PATH so Nix-provided tools resolve during commands. Thanks @joshp123 for PR #202.
|
||||
|
||||
### Maintenance
|
||||
- Skills additions (Himalaya email, CodexBar, 1Password).
|
||||
- Dependency refreshes (pi-* stack, Slack SDK, discord-api-types, file-type, zod, Biome, Vite).
|
||||
- Refactors: centralized group allowlist/mention policy; lint/import cleanup; switch tsx → bun for TS execution.
|
||||
- Tooling: replace tsx with bun for TypeScript execution. Thanks @obviyus for PR #278.
|
||||
- Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.
|
||||
- Skills: add CodexBar model usage helper with macOS requirement metadata.
|
||||
- Skills: add 1Password CLI skill with op examples.
|
||||
- Lint: organize imports and wrap long lines in reply commands.
|
||||
- Refactor: centralize group allowlist/mention policy across providers.
|
||||
- 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
|
||||
|
||||
@@ -76,3 +145,109 @@
|
||||
- Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events.
|
||||
- Commands: unify /status (inline) and command auth across providers; group bypass for authorized control commands; remove Discord /clawd slash handler.
|
||||
- CLI: run `clawdbot agent` via the Gateway by default; use `--local` to force embedded mode.
|
||||
|
||||
## 2026.1.5
|
||||
|
||||
### Fixes
|
||||
- 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.
|
||||
@@ -16,133 +16,117 @@
|
||||
</p>
|
||||
|
||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
It answers you on the surfaces you already use (WhatsApp, Telegram, 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.
|
||||
|
||||
[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) · [Docs](https://github.com/clawdbot/clawdbot/blob/main/docs/index.md) · Showcase: [https://github.com/clawdbot/clawdbot/blob/main/docs/showcase.md](https://github.com/clawdbot/clawdbot/blob/main/docs/showcase.md) · FAQ: [https://github.com/clawdbot/clawdbot/blob/main/docs/faq.md](https://github.com/clawdbot/clawdbot/blob/main/docs/faq.md) · Wizard: [https://github.com/clawdbot/clawdbot/blob/main/docs/wizard.md](https://github.com/clawdbot/clawdbot/blob/main/docs/wizard.md) · Nix: [https://github.com/clawdbot/nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) · Docker: [https://github.com/clawdbot/clawdbot/blob/main/docs/docker.md](https://github.com/clawdbot/clawdbot/blob/main/docs/docker.md) · 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.
|
||||
New install? Start here: [Getting started](https://docs.clawd.bot/getting-started)
|
||||
|
||||
**Subscriptions (OAuth):**
|
||||
- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max)
|
||||
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
|
||||
- **Anthropic** (Claude Pro/Max)
|
||||
- **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).
|
||||
|
||||
## Models (selection + auth)
|
||||
|
||||
- Models config + CLI: [Models](https://docs.clawd.bot/models)
|
||||
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.clawd.bot/model-failover)
|
||||
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://github.com/clawdbot/clawdbot/blob/main/docs/onboarding.md).
|
||||
|
||||
## Recommended setup (from source)
|
||||
|
||||
Do **not** download prebuilt binaries. Run from source.
|
||||
|
||||
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
||||
Do **not** download prebuilt binaries. Build from source.
|
||||
|
||||
```bash
|
||||
# Clone this repo
|
||||
git clone https://github.com/clawdbot/clawdbot.git
|
||||
cd clawdbot
|
||||
|
||||
pnpm install
|
||||
pnpm ui:install
|
||||
pnpm ui:build
|
||||
pnpm build
|
||||
pnpm clawdbot onboard
|
||||
bun install
|
||||
bun run build
|
||||
bun run ui:build
|
||||
bun run clawdbot onboard
|
||||
```
|
||||
|
||||
Note: `pnpm clawdbot ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `clawdbot` binary.
|
||||
|
||||
## Quick start (TL;DR)
|
||||
## Quick start (from source)
|
||||
|
||||
Runtime: **Node ≥22**.
|
||||
|
||||
Full beginner guide (auth, pairing, providers): [Getting started](https://docs.clawd.bot/getting-started)
|
||||
From source, **pnpm** is the default workflow. Bun is supported as an optional local workflow; see [`docs/bun.md`](docs/bun.md).
|
||||
|
||||
```bash
|
||||
pnpm clawdbot onboard
|
||||
# Install deps (no Bun lockfile)
|
||||
bun install --no-save
|
||||
|
||||
pnpm clawdbot gateway --port 18789 --verbose
|
||||
# Build TypeScript
|
||||
bun run build
|
||||
|
||||
# Build Control UI
|
||||
bun install --cwd ui --no-save
|
||||
bun run --cwd ui build
|
||||
|
||||
# Recommended: run the onboarding wizard
|
||||
bun run clawdbot onboard
|
||||
|
||||
# Link WhatsApp (stores creds in ~/.clawdbot/credentials)
|
||||
bun run clawdbot login
|
||||
|
||||
# Start the gateway
|
||||
bun run clawdbot gateway --port 18789 --verbose
|
||||
|
||||
# Dev loop (auto-reload on TS changes)
|
||||
pnpm gateway:watch
|
||||
bun run gateway:watch
|
||||
|
||||
# Send a message
|
||||
pnpm clawdbot send --to +1234567890 --message "Hello from Clawdbot"
|
||||
bun run 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
|
||||
bun run clawdbot agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
Upgrading? [Updating guide](https://docs.clawd.bot/updating) (and run `clawdbot doctor`).
|
||||
Upgrading? `clawdbot doctor`.
|
||||
|
||||
If you run from source, prefer `pnpm clawdbot …` (not global `clawdbot`).
|
||||
|
||||
## Security defaults (DM access)
|
||||
|
||||
Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted input**.
|
||||
|
||||
Full security guide: [Security](https://docs.clawd.bot/security)
|
||||
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Discord/Slack:
|
||||
- **DM pairing** (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||
- Approve with: `clawdbot pairing approve --provider <provider> <code>` (then the sender is added to a local allowlist store).
|
||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the provider allowlist (`allowFrom` / `discord.dm.allowFrom` / `slack.dm.allowFrom`).
|
||||
|
||||
Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
If you run from source, prefer `bun run clawdbot …` or `pnpm clawdbot …` (not global `clawdbot`).
|
||||
|
||||
## Highlights
|
||||
|
||||
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, providers, tools, and events.
|
||||
- **[Multi-provider inbox](https://docs.clawd.bot/surface)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.clawd.bot/configuration)** — route inbound providers/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.clawd.bot/voicewake) + [Talk Mode](https://docs.clawd.bot/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://docs.clawd.bot/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui).
|
||||
- **[First-class tools](https://docs.clawd.bot/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
||||
- **[Companion apps](https://docs.clawd.bot/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes).
|
||||
- **[Onboarding](https://docs.clawd.bot/wizard) + [skills](https://docs.clawd.bot/skills)** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
- **[Local-first Gateway](https://github.com/clawdbot/clawdbot/blob/main/docs/gateway.md)** — single control plane for sessions, providers, tools, and events.
|
||||
- **[Multi-surface inbox](https://github.com/clawdbot/clawdbot/blob/main/docs/surface.md)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
|
||||
- **[Voice Wake](https://github.com/clawdbot/clawdbot/blob/main/docs/voicewake.md) + [Talk Mode](https://github.com/clawdbot/clawdbot/blob/main/docs/talk.md)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md)** — agent-driven visual workspace with [A2UI](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md#canvas-a2ui).
|
||||
- **[First-class tools](https://github.com/clawdbot/clawdbot/blob/main/docs/tools.md)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
||||
- **[Companion apps](https://github.com/clawdbot/clawdbot/blob/main/docs/macos.md)** — macOS menu bar app + iOS/Android [nodes](https://github.com/clawdbot/clawdbot/blob/main/docs/nodes.md).
|
||||
- **[Onboarding](https://github.com/clawdbot/clawdbot/blob/main/docs/wizard.md) + [skills](https://github.com/clawdbot/clawdbot/blob/main/docs/skills.md)** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
## Everything we built so far
|
||||
|
||||
### Core platform
|
||||
- [Gateway WS control plane](https://docs.clawd.bot/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawd.bot/web), and [Canvas host](https://docs.clawd.bot/mac/canvas#canvas-a2ui).
|
||||
- [CLI surface](https://docs.clawd.bot/agent-send): gateway, agent, send, [wizard](https://docs.clawd.bot/wizard), and [doctor](https://docs.clawd.bot/doctor).
|
||||
- [Pi agent runtime](https://docs.clawd.bot/agent) in RPC mode with tool streaming and block streaming.
|
||||
- [Session model](https://docs.clawd.bot/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawd.bot/groups).
|
||||
- [Media pipeline](https://docs.clawd.bot/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/audio).
|
||||
- [Gateway WS control plane](https://github.com/clawdbot/clawdbot/blob/main/docs/gateway.md) with sessions, presence, config, cron, webhooks, [Control UI](https://github.com/clawdbot/clawdbot/blob/main/docs/web.md), and [Canvas host](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md#canvas-a2ui).
|
||||
- [CLI surface](https://github.com/clawdbot/clawdbot/blob/main/docs/agent-send.md): gateway, agent, send, [wizard](https://github.com/clawdbot/clawdbot/blob/main/docs/wizard.md), and [doctor](https://github.com/clawdbot/clawdbot/blob/main/docs/doctor.md).
|
||||
- [Pi agent runtime](https://github.com/clawdbot/clawdbot/blob/main/docs/agent.md) in RPC mode with tool streaming and block streaming.
|
||||
- [Session model](https://github.com/clawdbot/clawdbot/blob/main/docs/session.md): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://github.com/clawdbot/clawdbot/blob/main/docs/groups.md).
|
||||
- [Media pipeline](https://github.com/clawdbot/clawdbot/blob/main/docs/images.md): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://github.com/clawdbot/clawdbot/blob/main/docs/audio.md).
|
||||
|
||||
### Providers
|
||||
- [Providers](https://docs.clawd.bot/surface): [WhatsApp](https://docs.clawd.bot/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/telegram) (grammY), [Slack](https://docs.clawd.bot/slack) (Bolt), [Discord](https://docs.clawd.bot/discord) (discord.js), [Signal](https://docs.clawd.bot/signal) (signal-cli), [iMessage](https://docs.clawd.bot/imessage) (imsg), [WebChat](https://docs.clawd.bot/webchat).
|
||||
- [Group routing](https://docs.clawd.bot/group-messages): mention gating, reply tags, per-provider chunking and routing. Provider rules: [Providers](https://docs.clawd.bot/surface).
|
||||
### Surfaces + providers
|
||||
- [Providers](https://github.com/clawdbot/clawdbot/blob/main/docs/surface.md): [WhatsApp](https://github.com/clawdbot/clawdbot/blob/main/docs/whatsapp.md) (Baileys), [Telegram](https://github.com/clawdbot/clawdbot/blob/main/docs/telegram.md) (grammY), [Slack](https://github.com/clawdbot/clawdbot/blob/main/docs/slack.md) (Bolt), [Discord](https://github.com/clawdbot/clawdbot/blob/main/docs/discord.md) (discord.js), [Signal](https://github.com/clawdbot/clawdbot/blob/main/docs/signal.md) (signal-cli), [iMessage](https://github.com/clawdbot/clawdbot/blob/main/docs/imessage.md) (imsg), [WebChat](https://github.com/clawdbot/clawdbot/blob/main/docs/webchat.md).
|
||||
- [Group routing](https://github.com/clawdbot/clawdbot/blob/main/docs/group-messages.md): mention gating, reply tags, per-surface chunking and routing. Surface rules: [Surface routing](https://github.com/clawdbot/clawdbot/blob/main/docs/surface.md).
|
||||
|
||||
### Apps + nodes
|
||||
- [macOS app](https://docs.clawd.bot/macos): menu bar control plane, [Voice Wake](https://docs.clawd.bot/voicewake)/PTT, [Talk Mode](https://docs.clawd.bot/talk) overlay, [WebChat](https://docs.clawd.bot/webchat), debug tools, [remote gateway](https://docs.clawd.bot/remote) control.
|
||||
- [iOS node](https://docs.clawd.bot/ios): [Canvas](https://docs.clawd.bot/mac/canvas), [Voice Wake](https://docs.clawd.bot/voicewake), [Talk Mode](https://docs.clawd.bot/talk), camera, screen recording, Bonjour pairing.
|
||||
- [Android node](https://docs.clawd.bot/android): [Canvas](https://docs.clawd.bot/mac/canvas), [Talk Mode](https://docs.clawd.bot/talk), camera, screen recording, optional SMS.
|
||||
- [macOS node mode](https://docs.clawd.bot/nodes): system.run/notify + canvas/camera exposure.
|
||||
- [macOS app](https://github.com/clawdbot/clawdbot/blob/main/docs/macos.md): menu bar control plane, [Voice Wake](https://github.com/clawdbot/clawdbot/blob/main/docs/voicewake.md)/PTT, [Talk Mode](https://github.com/clawdbot/clawdbot/blob/main/docs/talk.md) overlay, [WebChat](https://github.com/clawdbot/clawdbot/blob/main/docs/webchat.md), debug tools, [remote gateway](https://github.com/clawdbot/clawdbot/blob/main/docs/remote.md) control.
|
||||
- [iOS node](https://github.com/clawdbot/clawdbot/blob/main/docs/ios.md): [Canvas](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md), [Voice Wake](https://github.com/clawdbot/clawdbot/blob/main/docs/voicewake.md), [Talk Mode](https://github.com/clawdbot/clawdbot/blob/main/docs/talk.md), camera, screen recording, Bonjour pairing.
|
||||
- [Android node](https://github.com/clawdbot/clawdbot/blob/main/docs/android.md): [Canvas](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md), [Talk Mode](https://github.com/clawdbot/clawdbot/blob/main/docs/talk.md), camera, screen recording, optional SMS.
|
||||
- [macOS node mode](https://github.com/clawdbot/clawdbot/blob/main/docs/nodes.md): system.run/notify + canvas/camera exposure.
|
||||
|
||||
### Tools + automation
|
||||
- [Browser control](https://docs.clawd.bot/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- [Canvas](https://docs.clawd.bot/mac/canvas): [A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
|
||||
- [Nodes](https://docs.clawd.bot/nodes): camera snap/clip, screen record, [location.get](https://docs.clawd.bot/location-command), notifications.
|
||||
- [Cron + wakeups](https://docs.clawd.bot/cron); [webhooks](https://docs.clawd.bot/webhook); [Gmail Pub/Sub](https://docs.clawd.bot/gmail-pubsub).
|
||||
- [Skills platform](https://docs.clawd.bot/skills): bundled, managed, and workspace skills with install gating + UI.
|
||||
|
||||
### Runtime + safety
|
||||
- [Provider routing](https://docs.clawd.bot/provider-routing), [retry policy](https://docs.clawd.bot/retry), and [streaming/chunking](https://docs.clawd.bot/streaming).
|
||||
- [Presence](https://docs.clawd.bot/presence), [typing indicators](https://docs.clawd.bot/typing-indicators), and [usage tracking](https://docs.clawd.bot/usage-tracking).
|
||||
- [Models](https://docs.clawd.bot/models), [model failover](https://docs.clawd.bot/model-failover), and [session pruning](https://docs.clawd.bot/session-pruning).
|
||||
- [Security](https://docs.clawd.bot/security) and [troubleshooting](https://docs.clawd.bot/troubleshooting).
|
||||
- [Browser control](https://github.com/clawdbot/clawdbot/blob/main/docs/browser.md): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- [Canvas](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md): [A2UI](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md#canvas-a2ui) push/reset, eval, snapshot.
|
||||
- [Nodes](https://github.com/clawdbot/clawdbot/blob/main/docs/nodes.md): camera snap/clip, screen record, [location.get](https://github.com/clawdbot/clawdbot/blob/main/docs/location-command.md), notifications.
|
||||
- [Cron + wakeups](https://github.com/clawdbot/clawdbot/blob/main/docs/cron.md); [webhooks](https://github.com/clawdbot/clawdbot/blob/main/docs/webhook.md); [Gmail Pub/Sub](https://github.com/clawdbot/clawdbot/blob/main/docs/gmail-pubsub.md).
|
||||
- [Skills platform](https://github.com/clawdbot/clawdbot/blob/main/docs/skills.md): bundled, managed, and workspace skills with install gating + UI.
|
||||
|
||||
### Ops + packaging
|
||||
- [Control UI](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/webchat) served directly from the Gateway.
|
||||
- [Tailscale Serve/Funnel](https://docs.clawd.bot/tailscale) or [SSH tunnels](https://docs.clawd.bot/remote) with token/password auth.
|
||||
- [Nix mode](https://docs.clawd.bot/nix) for declarative config; [Docker](https://docs.clawd.bot/docker)-based installs.
|
||||
- [Doctor](https://docs.clawd.bot/doctor) migrations, [logging](https://docs.clawd.bot/logging).
|
||||
- [Control UI](https://github.com/clawdbot/clawdbot/blob/main/docs/web.md) + [WebChat](https://github.com/clawdbot/clawdbot/blob/main/docs/webchat.md) served directly from the Gateway.
|
||||
- [Tailscale Serve/Funnel](https://github.com/clawdbot/clawdbot/blob/main/docs/tailscale.md) or [SSH tunnels](https://github.com/clawdbot/clawdbot/blob/main/docs/remote.md) with token/password auth.
|
||||
- [Nix mode](https://github.com/clawdbot/clawdbot/blob/main/docs/nix.md) for declarative config; [Docker](https://github.com/clawdbot/clawdbot/blob/main/docs/docker.md)-based installs.
|
||||
- [Doctor](https://github.com/clawdbot/clawdbot/blob/main/docs/doctor.md) migrations, [logging](https://github.com/clawdbot/clawdbot/blob/main/docs/logging.md).
|
||||
|
||||
## How it works (short)
|
||||
|
||||
@@ -164,12 +148,12 @@ WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
|
||||
|
||||
## 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)).
|
||||
- **[Tailscale exposure](https://docs.clawd.bot/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawd.bot/remote)).
|
||||
- **[Browser control](https://docs.clawd.bot/browser)** — clawd‑managed Chrome/Chromium with CDP control.
|
||||
- **[Canvas + A2UI](https://docs.clawd.bot/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui)).
|
||||
- **[Voice Wake](https://docs.clawd.bot/voicewake) + [Talk Mode](https://docs.clawd.bot/talk)** — always‑on speech and continuous conversation.
|
||||
- **[Nodes](https://docs.clawd.bot/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
|
||||
- **[Gateway WebSocket network](https://github.com/clawdbot/clawdbot/blob/main/docs/architecture.md)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://github.com/clawdbot/clawdbot/blob/main/docs/gateway.md)).
|
||||
- **[Tailscale exposure](https://github.com/clawdbot/clawdbot/blob/main/docs/tailscale.md)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://github.com/clawdbot/clawdbot/blob/main/docs/remote.md)).
|
||||
- **[Browser control](https://github.com/clawdbot/clawdbot/blob/main/docs/browser.md)** — clawd‑managed Chrome/Chromium with CDP control.
|
||||
- **[Canvas + A2UI](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md#canvas-a2ui)).
|
||||
- **[Voice Wake](https://github.com/clawdbot/clawdbot/blob/main/docs/voicewake.md) + [Talk Mode](https://github.com/clawdbot/clawdbot/blob/main/docs/talk.md)** — always‑on speech and continuous conversation.
|
||||
- **[Nodes](https://github.com/clawdbot/clawdbot/blob/main/docs/nodes.md)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
|
||||
|
||||
## Tailscale access (Gateway dashboard)
|
||||
|
||||
@@ -185,7 +169,7 @@ Notes:
|
||||
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
|
||||
- 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://github.com/clawdbot/clawdbot/blob/main/docs/tailscale.md) · [Web surfaces](https://github.com/clawdbot/clawdbot/blob/main/docs/web.md)
|
||||
|
||||
## Remote Gateway (Linux is great)
|
||||
|
||||
@@ -195,7 +179,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`.
|
||||
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://github.com/clawdbot/clawdbot/blob/main/docs/remote.md) · [Nodes](https://github.com/clawdbot/clawdbot/blob/main/docs/nodes.md) · [Security](https://github.com/clawdbot/clawdbot/blob/main/docs/security.md)
|
||||
|
||||
## macOS permissions via the Gateway protocol
|
||||
|
||||
@@ -210,7 +194,7 @@ Elevated bash (host permissions) is separate from macOS TCC:
|
||||
- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted.
|
||||
- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
|
||||
|
||||
Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd.bot/macos) · [Gateway protocol](https://docs.clawd.bot/architecture)
|
||||
Details: [Nodes](https://github.com/clawdbot/clawdbot/blob/main/docs/nodes.md) · [macOS app](https://github.com/clawdbot/clawdbot/blob/main/docs/macos.md) · [Gateway protocol](https://github.com/clawdbot/clawdbot/blob/main/docs/architecture.md)
|
||||
|
||||
## Agent to Agent (sessions_* tools)
|
||||
|
||||
@@ -219,13 +203,13 @@ Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd
|
||||
- `sessions_history` — fetch transcript logs for a session.
|
||||
- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
|
||||
|
||||
Details: [Session tools](https://docs.clawd.bot/session-tool)
|
||||
Details: [Session tools](https://github.com/clawdbot/clawdbot/blob/main/docs/session-tool.md)
|
||||
|
||||
## Skills registry (ClawdHub)
|
||||
|
||||
ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can search for skills automatically and pull in new ones as needed.
|
||||
|
||||
[ClawdHub](https://ClawdHub.com)
|
||||
https://ClawdHub.com
|
||||
|
||||
## Chat commands
|
||||
|
||||
@@ -265,13 +249,13 @@ Note: signed builds required for macOS permissions to stick across rebuilds (see
|
||||
- Voice trigger forwarding + Canvas surface.
|
||||
- Controlled via `clawdbot nodes …`.
|
||||
|
||||
Runbook: [iOS connect](https://docs.clawd.bot/ios).
|
||||
Runbook: [iOS connect](https://github.com/clawdbot/clawdbot/blob/main/docs/ios.md).
|
||||
|
||||
### Android node (optional)
|
||||
|
||||
- Pairs via the same Bridge + pairing flow as iOS.
|
||||
- Exposes Canvas, Camera, and Screen capture commands.
|
||||
- Runbook: [Android connect](https://docs.clawd.bot/android).
|
||||
- Runbook: [Android connect](https://github.com/clawdbot/clawdbot/blob/main/docs/android.md).
|
||||
|
||||
## Agent workspace + skills
|
||||
|
||||
@@ -291,23 +275,23 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
|
||||
}
|
||||
```
|
||||
|
||||
[Full configuration reference (all keys + examples).](https://docs.clawd.bot/configuration)
|
||||
[Full configuration reference (all keys + examples).](https://github.com/clawdbot/clawdbot/blob/main/docs/configuration.md)
|
||||
|
||||
## Security model (important)
|
||||
|
||||
- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you.
|
||||
- **Group/channel safety:** set `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://github.com/clawdbot/clawdbot/blob/main/docs/security.md) · [Docker + sandboxing](https://github.com/clawdbot/clawdbot/blob/main/docs/docker.md) · [Sandbox config](https://github.com/clawdbot/clawdbot/blob/main/docs/configuration.md)
|
||||
|
||||
### [WhatsApp](https://docs.clawd.bot/whatsapp)
|
||||
### [WhatsApp](https://github.com/clawdbot/clawdbot/blob/main/docs/whatsapp.md)
|
||||
|
||||
- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
|
||||
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
|
||||
- If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Telegram](https://docs.clawd.bot/telegram)
|
||||
### [Telegram](https://github.com/clawdbot/clawdbot/blob/main/docs/telegram.md)
|
||||
|
||||
- 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.
|
||||
@@ -320,14 +304,14 @@ Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxi
|
||||
}
|
||||
```
|
||||
|
||||
### [Slack](https://docs.clawd.bot/slack)
|
||||
### [Slack](https://github.com/clawdbot/clawdbot/blob/main/docs/slack.md)
|
||||
|
||||
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `slack.botToken` + `slack.appToken`).
|
||||
|
||||
### [Discord](https://docs.clawd.bot/discord)
|
||||
### [Discord](https://github.com/clawdbot/clawdbot/blob/main/docs/discord.md)
|
||||
|
||||
- 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
|
||||
{
|
||||
@@ -337,16 +321,16 @@ Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxi
|
||||
}
|
||||
```
|
||||
|
||||
### [Signal](https://docs.clawd.bot/signal)
|
||||
### [Signal](https://github.com/clawdbot/clawdbot/blob/main/docs/signal.md)
|
||||
|
||||
- Requires `signal-cli` and a `signal` config section.
|
||||
|
||||
### [iMessage](https://docs.clawd.bot/imessage)
|
||||
### [iMessage](https://github.com/clawdbot/clawdbot/blob/main/docs/imessage.md)
|
||||
|
||||
- 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://github.com/clawdbot/clawdbot/blob/main/docs/webchat.md)
|
||||
|
||||
- Uses the Gateway WebSocket; no separate WebChat port/config.
|
||||
|
||||
@@ -365,78 +349,85 @@ Browser control (optional):
|
||||
## Docs
|
||||
|
||||
Use these when you’re past the onboarding flow and want the deeper reference.
|
||||
- [Start with the docs index for navigation and “what’s where.”](https://docs.clawd.bot)
|
||||
- [Read the architecture overview for the gateway + protocol model.](https://docs.clawd.bot/architecture)
|
||||
- [Use the full configuration reference when you need every key and example.](https://docs.clawd.bot/configuration)
|
||||
- [Run the Gateway by the book with the operational runbook.](https://docs.clawd.bot/gateway)
|
||||
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawd.bot/web)
|
||||
- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawd.bot/remote)
|
||||
- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawd.bot/wizard)
|
||||
- [Wire external triggers via the webhook surface.](https://docs.clawd.bot/webhook)
|
||||
- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/gmail-pubsub)
|
||||
- [Learn the macOS menu bar companion details.](https://docs.clawd.bot/mac/menu-bar)
|
||||
- [Platform guides: Windows (WSL2)](https://docs.clawd.bot/windows), [Linux](https://docs.clawd.bot/linux), [macOS](https://docs.clawd.bot/macos), [iOS](https://docs.clawd.bot/ios), [Android](https://docs.clawd.bot/android)
|
||||
- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/troubleshooting)
|
||||
- [Review security guidance before exposing anything.](https://docs.clawd.bot/security)
|
||||
- [Start with the docs index for navigation and “what’s where.”](https://github.com/clawdbot/clawdbot/blob/main/docs/index.md)
|
||||
- [Read the architecture overview for the gateway + protocol model.](https://github.com/clawdbot/clawdbot/blob/main/docs/architecture.md)
|
||||
- [Use the full configuration reference when you need every key and example.](https://github.com/clawdbot/clawdbot/blob/main/docs/configuration.md)
|
||||
- [Run the Gateway by the book with the operational runbook.](https://github.com/clawdbot/clawdbot/blob/main/docs/gateway.md)
|
||||
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://github.com/clawdbot/clawdbot/blob/main/docs/web.md)
|
||||
- [Understand remote access over SSH tunnels or tailnets.](https://github.com/clawdbot/clawdbot/blob/main/docs/remote.md)
|
||||
- [Follow the onboarding wizard flow for a guided setup.](https://github.com/clawdbot/clawdbot/blob/main/docs/wizard.md)
|
||||
- [Wire external triggers via the webhook surface.](https://github.com/clawdbot/clawdbot/blob/main/docs/webhook.md)
|
||||
- [Set up Gmail Pub/Sub triggers.](https://github.com/clawdbot/clawdbot/blob/main/docs/gmail-pubsub.md)
|
||||
- [Learn the macOS menu bar companion details.](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/menu-bar.md)
|
||||
- [Platform guides: Windows](https://github.com/clawdbot/clawdbot/blob/main/docs/windows.md), [Linux](https://github.com/clawdbot/clawdbot/blob/main/docs/linux.md), [macOS](https://github.com/clawdbot/clawdbot/blob/main/docs/macos.md), [iOS](https://github.com/clawdbot/clawdbot/blob/main/docs/ios.md), [Android](https://github.com/clawdbot/clawdbot/blob/main/docs/android.md)
|
||||
- [Debug common failures with the troubleshooting guide.](https://github.com/clawdbot/clawdbot/blob/main/docs/troubleshooting.md)
|
||||
- [Review security guidance before exposing anything.](https://github.com/clawdbot/clawdbot/blob/main/docs/security.md)
|
||||
|
||||
## Advanced docs (discovery + control)
|
||||
|
||||
- [Discovery + transports](https://docs.clawd.bot/discovery)
|
||||
- [Bonjour/mDNS](https://docs.clawd.bot/bonjour)
|
||||
- [Gateway pairing](https://docs.clawd.bot/gateway/pairing)
|
||||
- [Remote gateway README](https://docs.clawd.bot/remote-gateway-readme)
|
||||
- [Control UI](https://docs.clawd.bot/control-ui)
|
||||
- [Dashboard](https://docs.clawd.bot/dashboard)
|
||||
- [Discovery + transports](https://github.com/clawdbot/clawdbot/blob/main/docs/discovery.md)
|
||||
- [Bonjour/mDNS](https://github.com/clawdbot/clawdbot/blob/main/docs/bonjour.md)
|
||||
- [Gateway pairing](https://github.com/clawdbot/clawdbot/blob/main/docs/gateway/pairing.md)
|
||||
- [Remote gateway README](https://github.com/clawdbot/clawdbot/blob/main/docs/remote-gateway-readme.md)
|
||||
- [Control UI](https://github.com/clawdbot/clawdbot/blob/main/docs/control-ui.md)
|
||||
- [Dashboard](https://github.com/clawdbot/clawdbot/blob/main/docs/dashboard.md)
|
||||
|
||||
## Operations & troubleshooting
|
||||
|
||||
- [Health checks](https://docs.clawd.bot/health)
|
||||
- [Gateway lock](https://docs.clawd.bot/gateway-lock)
|
||||
- [Background process](https://docs.clawd.bot/background-process)
|
||||
- [Browser troubleshooting (Linux)](https://docs.clawd.bot/browser-linux-troubleshooting)
|
||||
- [Logging](https://docs.clawd.bot/logging)
|
||||
- [Health checks](https://github.com/clawdbot/clawdbot/blob/main/docs/health.md)
|
||||
- [Gateway lock](https://github.com/clawdbot/clawdbot/blob/main/docs/gateway-lock.md)
|
||||
- [Background process](https://github.com/clawdbot/clawdbot/blob/main/docs/background-process.md)
|
||||
- [Browser troubleshooting (Linux)](https://github.com/clawdbot/clawdbot/blob/main/docs/browser-linux-troubleshooting.md)
|
||||
- [Logging](https://github.com/clawdbot/clawdbot/blob/main/docs/logging.md)
|
||||
|
||||
## Deep dives
|
||||
|
||||
- [Agent loop](https://docs.clawd.bot/agent-loop)
|
||||
- [Presence](https://docs.clawd.bot/presence)
|
||||
- [TypeBox schemas](https://docs.clawd.bot/typebox)
|
||||
- [RPC adapters](https://docs.clawd.bot/rpc)
|
||||
- [Queue](https://docs.clawd.bot/queue)
|
||||
- [Agent loop](https://github.com/clawdbot/clawdbot/blob/main/docs/agent-loop.md)
|
||||
- [Presence](https://github.com/clawdbot/clawdbot/blob/main/docs/presence.md)
|
||||
- [TypeBox schemas](https://github.com/clawdbot/clawdbot/blob/main/docs/typebox.md)
|
||||
- [RPC adapters](https://github.com/clawdbot/clawdbot/blob/main/docs/rpc.md)
|
||||
- [Queue](https://github.com/clawdbot/clawdbot/blob/main/docs/queue.md)
|
||||
|
||||
## Workspace & skills
|
||||
|
||||
- [Skills config](https://docs.clawd.bot/skills-config)
|
||||
- [Default AGENTS](https://docs.clawd.bot/AGENTS.default)
|
||||
- [Templates: AGENTS](https://docs.clawd.bot/templates/AGENTS)
|
||||
- [Templates: BOOTSTRAP](https://docs.clawd.bot/templates/BOOTSTRAP)
|
||||
- [Templates: IDENTITY](https://docs.clawd.bot/templates/IDENTITY)
|
||||
- [Templates: SOUL](https://docs.clawd.bot/templates/SOUL)
|
||||
- [Templates: TOOLS](https://docs.clawd.bot/templates/TOOLS)
|
||||
- [Templates: USER](https://docs.clawd.bot/templates/USER)
|
||||
- [Skills config](https://github.com/clawdbot/clawdbot/blob/main/docs/skills-config.md)
|
||||
- [Default AGENTS](https://github.com/clawdbot/clawdbot/blob/main/docs/AGENTS.default.md)
|
||||
- [Templates: AGENTS](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/AGENTS.md)
|
||||
- [Templates: BOOTSTRAP](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/BOOTSTRAP.md)
|
||||
- [Templates: IDENTITY](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/IDENTITY.md)
|
||||
- [Templates: SOUL](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/SOUL.md)
|
||||
- [Templates: TOOLS](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/TOOLS.md)
|
||||
- [Templates: USER](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/USER.md)
|
||||
|
||||
## Platform internals
|
||||
|
||||
- [macOS dev setup](https://docs.clawd.bot/mac/dev-setup)
|
||||
- [macOS menu bar](https://docs.clawd.bot/mac/menu-bar)
|
||||
- [macOS voice wake](https://docs.clawd.bot/mac/voicewake)
|
||||
- [iOS node](https://docs.clawd.bot/ios)
|
||||
- [Android node](https://docs.clawd.bot/android)
|
||||
- [Windows (WSL2)](https://docs.clawd.bot/windows)
|
||||
- [Linux app](https://docs.clawd.bot/linux)
|
||||
- [macOS dev setup](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/dev-setup.md)
|
||||
- [macOS menu bar](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/menu-bar.md)
|
||||
- [macOS voice wake](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/voicewake.md)
|
||||
- [iOS node](https://github.com/clawdbot/clawdbot/blob/main/docs/ios.md)
|
||||
- [Android node](https://github.com/clawdbot/clawdbot/blob/main/docs/android.md)
|
||||
- [Windows app](https://github.com/clawdbot/clawdbot/blob/main/docs/windows.md)
|
||||
- [Linux app](https://github.com/clawdbot/clawdbot/blob/main/docs/linux.md)
|
||||
|
||||
## 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://github.com/clawdbot/clawdbot/blob/main/docs/gmail-pubsub.md)
|
||||
|
||||
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
|
||||
|
||||
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
|
||||
by Peter Steinberger and the community.
|
||||
|
||||
- [clawd.me](https://clawd.me)
|
||||
- [soul.md](https://soul.md)
|
||||
- [steipete.me](https://steipete.me)
|
||||
- https://clawd.me
|
||||
- https://soul.md
|
||||
- https://steipete.me
|
||||
|
||||
## Community
|
||||
|
||||
@@ -446,14 +437,10 @@ AI/vibe-coded PRs welcome! 🤖
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/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/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/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a>
|
||||
<a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/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/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/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/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/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/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a>
|
||||
<a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/adamgall"><img src="https://avatars.githubusercontent.com/u/706929?v=4&s=48" width="48" height="48" alt="adamgall" title="adamgall"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/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/regenrek"><img src="https://avatars.githubusercontent.com/u/5182020?v=4&s=48" width="48" height="48" alt="regenrek" title="regenrek"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="tobiasbischoff" title="tobiasbischoff"/></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/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/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/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/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/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/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/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/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/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/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/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/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>
|
||||
</p>
|
||||
@@ -13,7 +13,7 @@ public struct WakeWordSegment: Sendable, Equatable {
|
||||
self.range = range
|
||||
}
|
||||
|
||||
public var end: TimeInterval { start + duration }
|
||||
public var end: TimeInterval { self.start + self.duration }
|
||||
}
|
||||
|
||||
public struct WakeWordGateConfig: Sendable, Equatable {
|
||||
@@ -62,10 +62,10 @@ public enum WakeWordGate {
|
||||
segments: [WakeWordSegment],
|
||||
config: WakeWordGateConfig)
|
||||
-> WakeWordGateMatch? {
|
||||
let triggerTokens = normalizeTriggers(config.triggers)
|
||||
let triggerTokens = self.normalizeTriggers(config.triggers)
|
||||
guard !triggerTokens.isEmpty else { return nil }
|
||||
|
||||
let tokens = normalizeSegments(segments)
|
||||
let tokens = self.normalizeSegments(segments)
|
||||
guard !tokens.isEmpty else { return nil }
|
||||
|
||||
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
|
||||
@@ -89,7 +89,7 @@ public enum WakeWordGate {
|
||||
}
|
||||
|
||||
guard let best else { return nil }
|
||||
let command = commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
|
||||
let command = self.commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
|
||||
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||
guard command.count >= config.minCommandLength else { return nil }
|
||||
return WakeWordGateMatch(triggerEndTime: best.triggerEnd, postGap: best.gap, command: command)
|
||||
@@ -111,7 +111,7 @@ public enum WakeWordGate {
|
||||
}
|
||||
|
||||
let text = segments
|
||||
.filter { $0.start >= threshold && !normalizeToken($0.text).isEmpty }
|
||||
.filter { $0.start >= threshold && !self.normalizeToken($0.text).isEmpty }
|
||||
.map(\.text)
|
||||
.joined(separator: " ")
|
||||
return text.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||
@@ -121,7 +121,7 @@ public enum WakeWordGate {
|
||||
guard !text.isEmpty else { return false }
|
||||
let normalized = text.lowercased()
|
||||
for trigger in triggers {
|
||||
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation).lowercased()
|
||||
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation).lowercased()
|
||||
if token.isEmpty { continue }
|
||||
if normalized.contains(token) { return true }
|
||||
}
|
||||
@@ -131,11 +131,11 @@ public enum WakeWordGate {
|
||||
public static func stripWake(text: String, triggers: [String]) -> String {
|
||||
var out = text
|
||||
for trigger in triggers {
|
||||
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation)
|
||||
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||
guard !token.isEmpty else { continue }
|
||||
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
|
||||
}
|
||||
return out.trimmingCharacters(in: whitespaceAndPunctuation)
|
||||
return out.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||
}
|
||||
|
||||
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
|
||||
@@ -143,7 +143,7 @@ public enum WakeWordGate {
|
||||
for trigger in triggers {
|
||||
let tokens = trigger
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
.map { normalizeToken(String($0)) }
|
||||
.map { self.normalizeToken(String($0)) }
|
||||
.filter { !$0.isEmpty }
|
||||
if tokens.isEmpty { continue }
|
||||
output.append(TriggerTokens(tokens: tokens))
|
||||
@@ -153,7 +153,7 @@ public enum WakeWordGate {
|
||||
|
||||
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
|
||||
segments.compactMap { segment in
|
||||
let normalized = normalizeToken(segment.text)
|
||||
let normalized = self.normalizeToken(segment.text)
|
||||
guard !normalized.isEmpty else { return nil }
|
||||
return Token(
|
||||
normalized: normalized,
|
||||
@@ -166,7 +166,7 @@ public enum WakeWordGate {
|
||||
|
||||
private static func normalizeToken(_ token: String) -> String {
|
||||
token
|
||||
.trimmingCharacters(in: whitespaceAndPunctuation)
|
||||
.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||
.lowercased()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
import SwabbleKit
|
||||
import Testing
|
||||
import SwabbleKit
|
||||
|
||||
@Suite struct WakeWordGateTests {
|
||||
@Test func matchRequiresGapAfterTrigger() {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import com.android.build.api.variant.impl.VariantOutputImpl
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
@@ -21,8 +19,8 @@ android {
|
||||
applicationId = "com.clawdbot.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 20260108
|
||||
versionName = "2026.1.8"
|
||||
versionCode = 1
|
||||
versionName = "2.0.0-beta3"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -56,19 +54,6 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants { variant ->
|
||||
variant.outputs
|
||||
.filterIsInstance<VariantOutputImpl>()
|
||||
.forEach { output ->
|
||||
val versionName = output.versionName.orNull ?: "0"
|
||||
val buildType = variant.buildType
|
||||
|
||||
val outputFileName = "clawdbot-${versionName}-${buildType}.apk"
|
||||
output.outputFileName = outputFileName
|
||||
}
|
||||
}
|
||||
}
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
|
||||
File renamed without changes.
@@ -5,10 +5,8 @@ import android.content.Context
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import android.util.Base64
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.ExifInterface
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageCapture
|
||||
@@ -88,19 +86,18 @@ class CameraCaptureManager(private val context: Context) {
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(owner, selector, capture)
|
||||
|
||||
val (bytes, orientation) = capture.takeJpegWithExif(context.mainExecutor())
|
||||
val bytes = capture.takeJpegBytes(context.mainExecutor())
|
||||
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
|
||||
val rotated = rotateBitmapByExif(decoded, orientation)
|
||||
val scaled =
|
||||
if (maxWidth != null && maxWidth > 0 && rotated.width > maxWidth) {
|
||||
if (maxWidth != null && maxWidth > 0 && decoded.width > maxWidth) {
|
||||
val h =
|
||||
(rotated.height.toDouble() * (maxWidth.toDouble() / rotated.width.toDouble()))
|
||||
(decoded.height.toDouble() * (maxWidth.toDouble() / decoded.width.toDouble()))
|
||||
.toInt()
|
||||
.coerceAtLeast(1)
|
||||
rotated.scale(maxWidth, h)
|
||||
decoded.scale(maxWidth, h)
|
||||
} else {
|
||||
rotated
|
||||
decoded
|
||||
}
|
||||
|
||||
val maxPayloadBytes = 5 * 1024 * 1024
|
||||
@@ -197,31 +194,6 @@ class CameraCaptureManager(private val context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
private fun rotateBitmapByExif(bitmap: Bitmap, orientation: Int): Bitmap {
|
||||
val matrix = Matrix()
|
||||
when (orientation) {
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
|
||||
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
|
||||
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||
matrix.postRotate(90f)
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||
matrix.postRotate(-90f)
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
else -> return bitmap
|
||||
}
|
||||
val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||
if (rotated !== bitmap) {
|
||||
bitmap.recycle()
|
||||
}
|
||||
return rotated
|
||||
}
|
||||
|
||||
private fun parseFacing(paramsJson: String?): String? =
|
||||
when {
|
||||
paramsJson?.contains("\"front\"") == true -> "front"
|
||||
@@ -282,8 +254,7 @@ private suspend fun Context.cameraProvider(): ProcessCameraProvider =
|
||||
)
|
||||
}
|
||||
|
||||
/** Returns (jpegBytes, exifOrientation) so caller can rotate the decoded bitmap. */
|
||||
private suspend fun ImageCapture.takeJpegWithExif(executor: Executor): Pair<ByteArray, Int> =
|
||||
private suspend fun ImageCapture.takeJpegBytes(executor: Executor): ByteArray =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val file = File.createTempFile("clawdbot-snap-", ".jpg")
|
||||
val options = ImageCapture.OutputFileOptions.Builder(file).build()
|
||||
@@ -292,19 +263,13 @@ private suspend fun ImageCapture.takeJpegWithExif(executor: Executor): Pair<Byte
|
||||
executor,
|
||||
object : ImageCapture.OnImageSavedCallback {
|
||||
override fun onError(exception: ImageCaptureException) {
|
||||
file.delete()
|
||||
cont.resumeWithException(exception)
|
||||
}
|
||||
|
||||
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
|
||||
try {
|
||||
val exif = ExifInterface(file.absolutePath)
|
||||
val orientation = exif.getAttributeInt(
|
||||
ExifInterface.TAG_ORIENTATION,
|
||||
ExifInterface.ORIENTATION_NORMAL,
|
||||
)
|
||||
val bytes = file.readBytes()
|
||||
cont.resume(Pair(bytes, orientation))
|
||||
cont.resume(bytes)
|
||||
} catch (e: Exception) {
|
||||
cont.resumeWithException(e)
|
||||
} finally {
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.media.MediaRecorder
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Build
|
||||
import android.util.Base64
|
||||
import com.clawdbot.android.ScreenCaptureRequester
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -66,7 +65,7 @@ class ScreenRecordManager(private val context: Context) {
|
||||
val file = File.createTempFile("clawdbot-screen-", ".mp4")
|
||||
if (includeAudio) ensureMicPermission()
|
||||
|
||||
val recorder = createMediaRecorder()
|
||||
val recorder = MediaRecorder()
|
||||
var virtualDisplay: android.hardware.display.VirtualDisplay? = null
|
||||
try {
|
||||
if (includeAudio) {
|
||||
@@ -122,14 +121,6 @@ class ScreenRecordManager(private val context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
private fun createMediaRecorder(): MediaRecorder =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(context)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
MediaRecorder()
|
||||
}
|
||||
|
||||
private suspend fun ensureMicPermission() {
|
||||
val granted =
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
|
||||
@@ -39,7 +39,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ScreenShare
|
||||
import androidx.compose.material.icons.filled.ChatBubble
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
@@ -48,6 +47,7 @@ import androidx.compose.material.icons.filled.PhotoCamera
|
||||
import androidx.compose.material.icons.filled.RecordVoiceOver
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Report
|
||||
import androidx.compose.material.icons.filled.ScreenShare
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -123,7 +123,7 @@ fun RootScreen(viewModel: MainViewModel) {
|
||||
if (screenRecordActive) {
|
||||
return@remember StatusActivity(
|
||||
title = "Recording screen…",
|
||||
icon = Icons.AutoMirrored.Filled.ScreenShare,
|
||||
icon = Icons.Default.ScreenShare,
|
||||
contentDescription = "Recording screen",
|
||||
tint = androidx.compose.ui.graphics.Color.Red,
|
||||
)
|
||||
@@ -327,10 +327,11 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
||||
// Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage.
|
||||
settings.domStorageEnabled = true
|
||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
|
||||
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF)
|
||||
}
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
|
||||
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
|
||||
} else {
|
||||
disableForceDarkIfSupported(settings)
|
||||
}
|
||||
if (isDebuggable) {
|
||||
Log.d("ClawdbotWebView", "userAgent: ${settings.userAgentString}")
|
||||
@@ -413,12 +414,6 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
||||
)
|
||||
}
|
||||
|
||||
private fun disableForceDarkIfSupported(settings: WebSettings) {
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) return
|
||||
@Suppress("DEPRECATION")
|
||||
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF)
|
||||
}
|
||||
|
||||
private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
|
||||
@JavascriptInterface
|
||||
fun postMessage(payload: String?) {
|
||||
|
||||
@@ -714,7 +714,6 @@ class TalkModeManager(
|
||||
systemTtsPendingId = null
|
||||
}
|
||||
|
||||
@Suppress("OVERRIDE_DEPRECATION")
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onError(utteranceId: String?) {
|
||||
if (utteranceId == null) return
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.8</string>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260108</string>
|
||||
<string>1</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
|
||||
@@ -5,8 +5,7 @@ import Testing
|
||||
@Test func errorDescriptionsAreStable() {
|
||||
#expect(CameraController.CameraError.cameraUnavailable.errorDescription == "Camera unavailable")
|
||||
#expect(CameraController.CameraError.microphoneUnavailable.errorDescription == "Microphone unavailable")
|
||||
#expect(CameraController.CameraError.permissionDenied(kind: "Camera")
|
||||
.errorDescription == "Camera permission denied")
|
||||
#expect(CameraController.CameraError.permissionDenied(kind: "Camera").errorDescription == "Camera permission denied")
|
||||
#expect(CameraController.CameraError.invalidParams("bad").errorDescription == "bad")
|
||||
#expect(CameraController.CameraError.captureFailed("nope").errorDescription == "nope")
|
||||
#expect(CameraController.CameraError.exportFailed("export").errorDescription == "export")
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.8</string>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260108</string>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -20,3 +20,4 @@ import Testing
|
||||
#expect(triggers == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
import SwabbleKit
|
||||
import Testing
|
||||
import SwabbleKit
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite struct VoiceWakeManagerExtractCommandTests {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
import SwabbleKit
|
||||
import Testing
|
||||
import SwabbleKit
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite(.serialized) struct VoiceWakeManagerStateTests {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ClawdbotProtocol
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
/// Lightweight `Codable` wrapper that round-trips heterogeneous JSON payloads.
|
||||
/// Marked `@unchecked Sendable` because it can hold reference types.
|
||||
struct AnyCodable: Codable, @unchecked Sendable {
|
||||
let value: Any
|
||||
|
||||
init(_ value: Any) { self.value = value }
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
|
||||
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
|
||||
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
|
||||
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
|
||||
if container.decodeNil() { self.value = NSNull(); return }
|
||||
if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
|
||||
if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
|
||||
throw DecodingError.dataCorruptedError(
|
||||
in: container,
|
||||
debugDescription: "Unsupported type")
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self.value {
|
||||
case let intVal as Int: try container.encode(intVal)
|
||||
case let doubleVal as Double: try container.encode(doubleVal)
|
||||
case let boolVal as Bool: try container.encode(boolVal)
|
||||
case let stringVal as String: try container.encode(stringVal)
|
||||
case is NSNull: try container.encodeNil()
|
||||
case let dict as [String: AnyCodable]: try container.encode(dict)
|
||||
case let array as [AnyCodable]: try container.encode(array)
|
||||
case let dict as [String: Any]:
|
||||
try container.encode(dict.mapValues { AnyCodable($0) })
|
||||
case let array as [Any]:
|
||||
try container.encode(array.map { AnyCodable($0) })
|
||||
case let dict as NSDictionary:
|
||||
var converted: [String: AnyCodable] = [:]
|
||||
for (k, v) in dict {
|
||||
guard let key = k as? String else { continue }
|
||||
converted[key] = AnyCodable(v)
|
||||
}
|
||||
try container.encode(converted)
|
||||
case let array as NSArray:
|
||||
try container.encode(array.map { AnyCodable($0) })
|
||||
default:
|
||||
let context = EncodingError.Context(
|
||||
codingPath: encoder.codingPath,
|
||||
debugDescription: "Unsupported type")
|
||||
throw EncodingError.invalidValue(self.value, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,10 +100,6 @@ final class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
var voiceWakeMicName: String {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.voiceWakeMicName, forKey: voiceWakeMicNameKey) } }
|
||||
}
|
||||
|
||||
var voiceWakeLocaleID: String {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
@@ -233,7 +229,6 @@ final class AppState {
|
||||
}
|
||||
self.showDockIcon = UserDefaults.standard.bool(forKey: showDockIconKey)
|
||||
self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? ""
|
||||
self.voiceWakeMicName = UserDefaults.standard.string(forKey: voiceWakeMicNameKey) ?? ""
|
||||
self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier
|
||||
self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard
|
||||
.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? []
|
||||
@@ -421,8 +416,7 @@ final class AppState {
|
||||
: nil
|
||||
|
||||
Task { @MainActor in
|
||||
// Keep app-only connection settings local to avoid overwriting remote gateway config.
|
||||
var root = ClawdbotConfigFile.loadDict()
|
||||
var root = await ConfigStore.load()
|
||||
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||
var changed = false
|
||||
|
||||
@@ -452,12 +446,8 @@ final class AppState {
|
||||
}
|
||||
|
||||
guard changed else { return }
|
||||
if gateway.isEmpty {
|
||||
root.removeValue(forKey: "gateway")
|
||||
} else {
|
||||
root["gateway"] = gateway
|
||||
}
|
||||
ClawdbotConfigFile.saveDict(root)
|
||||
root["gateway"] = gateway
|
||||
try? await ConfigStore.save(root)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,7 +578,6 @@ extension AppState {
|
||||
state.iconAnimationsEnabled = true
|
||||
state.showDockIcon = true
|
||||
state.voiceWakeMicID = "BuiltInMic"
|
||||
state.voiceWakeMicName = "Built-in Microphone"
|
||||
state.voiceWakeLocaleID = Locale.current.identifier
|
||||
state.voiceWakeAdditionalLocaleIDs = ["en-US", "de-DE"]
|
||||
state.voicePushToTalkEnabled = false
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
final class AudioInputDeviceObserver {
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "audio.devices")
|
||||
private var isActive = false
|
||||
private var devicesListener: AudioObjectPropertyListenerBlock?
|
||||
private var defaultInputListener: AudioObjectPropertyListenerBlock?
|
||||
|
||||
static func defaultInputDeviceUID() -> String? {
|
||||
let systemObject = AudioObjectID(kAudioObjectSystemObject)
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var deviceID = AudioObjectID(0)
|
||||
var size = UInt32(MemoryLayout<AudioObjectID>.size)
|
||||
let status = AudioObjectGetPropertyData(
|
||||
systemObject,
|
||||
&address,
|
||||
0,
|
||||
nil,
|
||||
&size,
|
||||
&deviceID)
|
||||
guard status == noErr, deviceID != 0 else { return nil }
|
||||
return self.deviceUID(for: deviceID)
|
||||
}
|
||||
|
||||
static func aliveInputDeviceUIDs() -> Set<String> {
|
||||
let systemObject = AudioObjectID(kAudioObjectSystemObject)
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDevices,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var size: UInt32 = 0
|
||||
var status = AudioObjectGetPropertyDataSize(systemObject, &address, 0, nil, &size)
|
||||
guard status == noErr, size > 0 else { return [] }
|
||||
|
||||
let count = Int(size) / MemoryLayout<AudioObjectID>.size
|
||||
var deviceIDs = [AudioObjectID](repeating: 0, count: count)
|
||||
status = AudioObjectGetPropertyData(systemObject, &address, 0, nil, &size, &deviceIDs)
|
||||
guard status == noErr else { return [] }
|
||||
|
||||
var output = Set<String>()
|
||||
for deviceID in deviceIDs {
|
||||
guard self.deviceIsAlive(deviceID) else { continue }
|
||||
guard self.deviceHasInput(deviceID) else { continue }
|
||||
if let uid = self.deviceUID(for: deviceID) {
|
||||
output.insert(uid)
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
static func defaultInputDeviceSummary() -> String {
|
||||
let systemObject = AudioObjectID(kAudioObjectSystemObject)
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var deviceID = AudioObjectID(0)
|
||||
var size = UInt32(MemoryLayout<AudioObjectID>.size)
|
||||
let status = AudioObjectGetPropertyData(
|
||||
systemObject,
|
||||
&address,
|
||||
0,
|
||||
nil,
|
||||
&size,
|
||||
&deviceID)
|
||||
guard status == noErr, deviceID != 0 else {
|
||||
return "defaultInput=unknown"
|
||||
}
|
||||
let uid = self.deviceUID(for: deviceID) ?? "unknown"
|
||||
let name = self.deviceName(for: deviceID) ?? "unknown"
|
||||
return "defaultInput=\(name) (\(uid))"
|
||||
}
|
||||
|
||||
func start(onChange: @escaping @Sendable () -> Void) {
|
||||
guard !self.isActive else { return }
|
||||
self.isActive = true
|
||||
|
||||
let systemObject = AudioObjectID(kAudioObjectSystemObject)
|
||||
let queue = DispatchQueue.main
|
||||
|
||||
var devicesAddress = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDevices,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
let devicesListener: AudioObjectPropertyListenerBlock = { _, _ in
|
||||
self.logDefaultInputChange(reason: "devices")
|
||||
onChange()
|
||||
}
|
||||
let devicesStatus = AudioObjectAddPropertyListenerBlock(
|
||||
systemObject,
|
||||
&devicesAddress,
|
||||
queue,
|
||||
devicesListener)
|
||||
|
||||
var defaultInputAddress = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
let defaultInputListener: AudioObjectPropertyListenerBlock = { _, _ in
|
||||
self.logDefaultInputChange(reason: "default")
|
||||
onChange()
|
||||
}
|
||||
let defaultStatus = AudioObjectAddPropertyListenerBlock(
|
||||
systemObject,
|
||||
&defaultInputAddress,
|
||||
queue,
|
||||
defaultInputListener)
|
||||
|
||||
if devicesStatus != noErr || defaultStatus != noErr {
|
||||
self.logger.error("audio device observer install failed devices=\(devicesStatus) default=\(defaultStatus)")
|
||||
}
|
||||
|
||||
self.logger.info("audio device observer started (\(Self.defaultInputDeviceSummary(), privacy: .public))")
|
||||
|
||||
self.devicesListener = devicesListener
|
||||
self.defaultInputListener = defaultInputListener
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard self.isActive else { return }
|
||||
self.isActive = false
|
||||
let systemObject = AudioObjectID(kAudioObjectSystemObject)
|
||||
|
||||
if let devicesListener {
|
||||
var devicesAddress = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDevices,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
_ = AudioObjectRemovePropertyListenerBlock(
|
||||
systemObject,
|
||||
&devicesAddress,
|
||||
DispatchQueue.main,
|
||||
devicesListener)
|
||||
}
|
||||
|
||||
if let defaultInputListener {
|
||||
var defaultInputAddress = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
_ = AudioObjectRemovePropertyListenerBlock(
|
||||
systemObject,
|
||||
&defaultInputAddress,
|
||||
DispatchQueue.main,
|
||||
defaultInputListener)
|
||||
}
|
||||
|
||||
self.devicesListener = nil
|
||||
self.defaultInputListener = nil
|
||||
}
|
||||
|
||||
private static func deviceUID(for deviceID: AudioObjectID) -> String? {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyDeviceUID,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var uid: Unmanaged<CFString>?
|
||||
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
|
||||
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &uid)
|
||||
guard status == noErr, let uid else { return nil }
|
||||
return uid.takeUnretainedValue() as String
|
||||
}
|
||||
|
||||
private static func deviceName(for deviceID: AudioObjectID) -> String? {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioObjectPropertyName,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var name: Unmanaged<CFString>?
|
||||
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
|
||||
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &name)
|
||||
guard status == noErr, let name else { return nil }
|
||||
return name.takeUnretainedValue() as String
|
||||
}
|
||||
|
||||
private static func deviceIsAlive(_ deviceID: AudioObjectID) -> Bool {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyDeviceIsAlive,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var alive: UInt32 = 0
|
||||
var size = UInt32(MemoryLayout<UInt32>.size)
|
||||
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &alive)
|
||||
return status == noErr && alive != 0
|
||||
}
|
||||
|
||||
private static func deviceHasInput(_ deviceID: AudioObjectID) -> Bool {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||
mScope: kAudioDevicePropertyScopeInput,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var size: UInt32 = 0
|
||||
var status = AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &size)
|
||||
guard status == noErr, size > 0 else { return false }
|
||||
|
||||
let raw = UnsafeMutableRawPointer.allocate(
|
||||
byteCount: Int(size),
|
||||
alignment: MemoryLayout<AudioBufferList>.alignment)
|
||||
defer { raw.deallocate() }
|
||||
let bufferList = raw.bindMemory(to: AudioBufferList.self, capacity: 1)
|
||||
status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, bufferList)
|
||||
guard status == noErr else { return false }
|
||||
|
||||
let buffers = UnsafeMutableAudioBufferListPointer(bufferList)
|
||||
return buffers.contains(where: { $0.mNumberChannels > 0 })
|
||||
}
|
||||
|
||||
private func logDefaultInputChange(reason: StaticString) {
|
||||
self.logger.info("audio input changed (\(reason)) (\(Self.defaultInputDeviceSummary(), privacy: .public))")
|
||||
}
|
||||
}
|
||||
@@ -187,7 +187,7 @@ actor BridgeServer {
|
||||
thinking: "low",
|
||||
deliver: false,
|
||||
to: nil,
|
||||
provider: .last))
|
||||
channel: .last))
|
||||
|
||||
case "agent.request":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
||||
@@ -205,7 +205,7 @@ actor BridgeServer {
|
||||
?? "node-\(nodeId)"
|
||||
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let provider = GatewayAgentProvider(raw: link.channel)
|
||||
let channel = GatewayAgentChannel(raw: link.channel)
|
||||
|
||||
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||
message: message,
|
||||
@@ -213,7 +213,7 @@ actor BridgeServer {
|
||||
thinking: thinking,
|
||||
deliver: link.deliver,
|
||||
to: to,
|
||||
provider: provider))
|
||||
channel: channel))
|
||||
|
||||
default:
|
||||
break
|
||||
@@ -229,7 +229,7 @@ actor BridgeServer {
|
||||
error: BridgeRPCError(code: "FORBIDDEN", message: "Method not allowed"))
|
||||
}
|
||||
|
||||
let params: [String: ClawdbotProtocol.AnyCodable]?
|
||||
let params: [String: AnyCodable]?
|
||||
if let json = req.paramsJSON?.trimmingCharacters(in: .whitespacesAndNewlines), !json.isEmpty {
|
||||
guard let data = json.data(using: .utf8) else {
|
||||
return BridgeRPCResponse(
|
||||
@@ -238,7 +238,7 @@ actor BridgeServer {
|
||||
error: BridgeRPCError(code: "INVALID_REQUEST", message: "paramsJSON not UTF-8"))
|
||||
}
|
||||
do {
|
||||
params = try JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
|
||||
params = try JSONDecoder().decode([String: AnyCodable].self, from: data)
|
||||
} catch {
|
||||
return BridgeRPCResponse(
|
||||
id: req.id,
|
||||
@@ -360,16 +360,16 @@ actor BridgeServer {
|
||||
"reason \(reason)",
|
||||
].compactMap(\.self).joined(separator: " · ")
|
||||
|
||||
var params: [String: ClawdbotProtocol.AnyCodable] = [
|
||||
"text": ClawdbotProtocol.AnyCodable(summary),
|
||||
"instanceId": ClawdbotProtocol.AnyCodable(nodeId),
|
||||
"host": ClawdbotProtocol.AnyCodable(host),
|
||||
"mode": ClawdbotProtocol.AnyCodable("node"),
|
||||
"reason": ClawdbotProtocol.AnyCodable(reason),
|
||||
"tags": ClawdbotProtocol.AnyCodable(tags),
|
||||
var params: [String: AnyCodable] = [
|
||||
"text": AnyCodable(summary),
|
||||
"instanceId": AnyCodable(nodeId),
|
||||
"host": AnyCodable(host),
|
||||
"mode": AnyCodable("node"),
|
||||
"reason": AnyCodable(reason),
|
||||
"tags": AnyCodable(tags),
|
||||
]
|
||||
if let ip { params["ip"] = ClawdbotProtocol.AnyCodable(ip) }
|
||||
if let version { params["version"] = ClawdbotProtocol.AnyCodable(version) }
|
||||
if let ip { params["ip"] = AnyCodable(ip) }
|
||||
if let version { params["version"] = AnyCodable(version) }
|
||||
await GatewayConnection.shared.sendSystemEvent(params)
|
||||
}
|
||||
|
||||
|
||||
@@ -79,15 +79,14 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
GatewayProcessManager.shared.setActive(true)
|
||||
}
|
||||
|
||||
let result = await GatewayConnection.shared.sendAgent(
|
||||
GatewayAgentInvocation(
|
||||
message: text,
|
||||
sessionKey: self.sessionKey,
|
||||
thinking: "low",
|
||||
deliver: false,
|
||||
to: nil,
|
||||
provider: .last,
|
||||
idempotencyKey: actionId))
|
||||
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||
message: text,
|
||||
sessionKey: self.sessionKey,
|
||||
thinking: "low",
|
||||
deliver: false,
|
||||
to: nil,
|
||||
channel: .last,
|
||||
idempotencyKey: actionId))
|
||||
|
||||
await MainActor.run {
|
||||
guard let webView else { return }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
enum ClawdbotConfigFile {
|
||||
@@ -33,8 +32,7 @@ enum ClawdbotConfigFile {
|
||||
}
|
||||
|
||||
static func saveDict(_ dict: [String: Any]) {
|
||||
// Nix mode disables config writes in production, but tests rely on saving temp configs.
|
||||
if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return }
|
||||
if ProcessInfo.processInfo.isNixMode { return }
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
||||
let url = self.url()
|
||||
@@ -125,70 +123,6 @@ enum ClawdbotConfigFile {
|
||||
return nil
|
||||
}
|
||||
|
||||
static func remoteGatewayPort() -> Int? {
|
||||
guard let url = self.remoteGatewayUrl(),
|
||||
let port = url.port,
|
||||
port > 0
|
||||
else { return nil }
|
||||
return port
|
||||
}
|
||||
|
||||
static func remoteGatewayPort(matchingHost sshHost: String) -> Int? {
|
||||
let trimmedSshHost = sshHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedSshHost.isEmpty,
|
||||
let url = self.remoteGatewayUrl(),
|
||||
let port = url.port,
|
||||
port > 0,
|
||||
let urlHost = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!urlHost.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let sshKey = Self.hostKey(trimmedSshHost)
|
||||
let urlKey = Self.hostKey(urlHost)
|
||||
guard !sshKey.isEmpty, !urlKey.isEmpty, sshKey == urlKey else { return nil }
|
||||
return port
|
||||
}
|
||||
|
||||
static func setRemoteGatewayUrl(host: String, port: Int?) {
|
||||
guard let port, port > 0 else { return }
|
||||
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedHost.isEmpty else { return }
|
||||
self.updateGatewayDict { gateway in
|
||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||
let existingUrl = (remote["url"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let scheme = URL(string: existingUrl)?.scheme ?? "ws"
|
||||
remote["url"] = "\(scheme)://\(trimmedHost):\(port)"
|
||||
gateway["remote"] = remote
|
||||
}
|
||||
}
|
||||
|
||||
private static func remoteGatewayUrl() -> URL? {
|
||||
let root = self.loadDict()
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let raw = remote["url"] as? String
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil }
|
||||
return url
|
||||
}
|
||||
|
||||
private static func hostKey(_ host: String) -> String {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !trimmed.isEmpty else { return "" }
|
||||
if trimmed.contains(":") { return trimmed }
|
||||
let digits = CharacterSet(charactersIn: "0123456789.")
|
||||
if trimmed.rangeOfCharacter(from: digits.inverted) == nil {
|
||||
return trimmed
|
||||
}
|
||||
return trimmed.split(separator: ".").first.map(String.init) ?? trimmed
|
||||
}
|
||||
|
||||
private static func parseConfigData(_ data: Data) -> [String: Any]? {
|
||||
if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
return root
|
||||
|
||||
@@ -3,9 +3,9 @@ import Foundation
|
||||
enum ClawdbotEnv {
|
||||
static func path(_ key: String) -> String? {
|
||||
// Normalize env overrides once so UI + file IO stay consistent.
|
||||
guard let raw = getenv(key) else { return nil }
|
||||
let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty
|
||||
guard let value = ProcessInfo.processInfo.environment[key]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!value.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
enum ConfigStore {
|
||||
|
||||
@@ -13,7 +13,6 @@ let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
|
||||
let showDockIconKey = "clawdbot.showDockIcon"
|
||||
let defaultVoiceWakeTriggers = ["clawd", "claude"]
|
||||
let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
|
||||
let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName"
|
||||
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"
|
||||
let voiceWakeAdditionalLocalesKey = "clawdbot.voiceWakeAdditionalLocaleIDs"
|
||||
let voicePushToTalkEnabledKey = "clawdbot.voicePushToTalkEnabled"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@@ -34,13 +33,13 @@ extension CronJobEditor {
|
||||
case let .systemEvent(text):
|
||||
self.payloadKind = .systemEvent
|
||||
self.systemEventText = text
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver):
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
|
||||
self.payloadKind = .agentTurn
|
||||
self.agentMessage = message
|
||||
self.thinking = thinking ?? ""
|
||||
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
||||
self.deliver = deliver ?? false
|
||||
self.provider = GatewayAgentProvider(raw: provider)
|
||||
self.channel = GatewayAgentChannel(raw: channel)
|
||||
self.to = to ?? ""
|
||||
self.bestEffortDeliver = bestEffortDeliver ?? false
|
||||
}
|
||||
@@ -167,7 +166,7 @@ extension CronJobEditor {
|
||||
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
|
||||
payload["deliver"] = self.deliver
|
||||
if self.deliver {
|
||||
payload["provider"] = self.provider.rawValue
|
||||
payload["channel"] = self.channel.rawValue
|
||||
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !to.isEmpty { payload["to"] = to }
|
||||
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
||||
|
||||
@@ -13,7 +13,7 @@ extension CronJobEditor {
|
||||
self.payloadKind = .agentTurn
|
||||
self.agentMessage = "Run diagnostic"
|
||||
self.deliver = true
|
||||
self.provider = .last
|
||||
self.channel = .last
|
||||
self.to = "+15551230000"
|
||||
self.thinking = "low"
|
||||
self.timeoutSeconds = "90"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ClawdbotProtocol
|
||||
import SwiftUI
|
||||
|
||||
struct CronJobEditor: View {
|
||||
@@ -18,7 +17,7 @@ struct CronJobEditor: View {
|
||||
static let scheduleKindNote =
|
||||
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
||||
static let isolatedPayloadNote =
|
||||
"Isolated jobs always run an agent turn. The result can be delivered to a provider, "
|
||||
"Isolated jobs always run an agent turn. The result can be delivered to a surface, "
|
||||
+ "and a short summary is posted back to your main chat."
|
||||
static let mainPayloadNote =
|
||||
"System events are injected into the current main session. Agent turns require an isolated session target."
|
||||
@@ -43,7 +42,7 @@ struct CronJobEditor: View {
|
||||
@State var systemEventText: String = ""
|
||||
@State var agentMessage: String = ""
|
||||
@State var deliver: Bool = false
|
||||
@State var provider: GatewayAgentProvider = .last
|
||||
@State var channel: GatewayAgentChannel = .last
|
||||
@State var to: String = ""
|
||||
@State var thinking: String = ""
|
||||
@State var timeoutSeconds: String = ""
|
||||
@@ -310,7 +309,7 @@ struct CronJobEditor: View {
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Deliver")
|
||||
Toggle("Deliver result to a provider", isOn: self.$deliver)
|
||||
Toggle("Deliver result to a surface", isOn: self.$deliver)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
}
|
||||
@@ -318,15 +317,15 @@ struct CronJobEditor: View {
|
||||
if self.deliver {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Provider")
|
||||
Picker("", selection: self.$provider) {
|
||||
Text("last").tag(GatewayAgentProvider.last)
|
||||
Text("whatsapp").tag(GatewayAgentProvider.whatsapp)
|
||||
Text("telegram").tag(GatewayAgentProvider.telegram)
|
||||
Text("discord").tag(GatewayAgentProvider.discord)
|
||||
Text("slack").tag(GatewayAgentProvider.slack)
|
||||
Text("signal").tag(GatewayAgentProvider.signal)
|
||||
Text("imessage").tag(GatewayAgentProvider.imessage)
|
||||
self.gridLabel("Channel")
|
||||
Picker("", selection: self.$channel) {
|
||||
Text("last").tag(GatewayAgentChannel.last)
|
||||
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
|
||||
Text("telegram").tag(GatewayAgentChannel.telegram)
|
||||
Text("discord").tag(GatewayAgentChannel.discord)
|
||||
Text("slack").tag(GatewayAgentChannel.slack)
|
||||
Text("signal").tag(GatewayAgentChannel.signal)
|
||||
Text("imessage").tag(GatewayAgentChannel.imessage)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
@@ -74,12 +74,12 @@ enum CronPayload: Codable, Equatable {
|
||||
thinking: String?,
|
||||
timeoutSeconds: Int?,
|
||||
deliver: Bool?,
|
||||
provider: String?,
|
||||
channel: String?,
|
||||
to: String?,
|
||||
bestEffortDeliver: Bool?)
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case kind, text, message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver
|
||||
case kind, text, message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver
|
||||
}
|
||||
|
||||
var kind: String {
|
||||
@@ -101,7 +101,7 @@ enum CronPayload: Codable, Equatable {
|
||||
thinking: container.decodeIfPresent(String.self, forKey: .thinking),
|
||||
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
|
||||
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver),
|
||||
provider: container.decodeIfPresent(String.self, forKey: .provider),
|
||||
channel: container.decodeIfPresent(String.self, forKey: .channel),
|
||||
to: container.decodeIfPresent(String.self, forKey: .to),
|
||||
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
|
||||
default:
|
||||
@@ -118,12 +118,12 @@ enum CronPayload: Codable, Equatable {
|
||||
switch self {
|
||||
case let .systemEvent(text):
|
||||
try container.encode(text, forKey: .text)
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver):
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
|
||||
try container.encode(message, forKey: .message)
|
||||
try container.encodeIfPresent(thinking, forKey: .thinking)
|
||||
try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
|
||||
try container.encodeIfPresent(deliver, forKey: .deliver)
|
||||
try container.encodeIfPresent(provider, forKey: .provider)
|
||||
try container.encodeIfPresent(channel, forKey: .channel)
|
||||
try container.encodeIfPresent(to, forKey: .to)
|
||||
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
extension CronSettings {
|
||||
|
||||
@@ -206,7 +206,7 @@ extension CronSettings {
|
||||
Text(text)
|
||||
.font(.callout)
|
||||
.textSelection(.enabled)
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, _):
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, _):
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
@@ -216,7 +216,7 @@ extension CronSettings {
|
||||
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
|
||||
if deliver ?? false {
|
||||
StatusPill(text: "deliver", tint: .secondary)
|
||||
if let provider, !provider.isEmpty { StatusPill(text: provider, tint: .secondary) }
|
||||
if let channel, !channel.isEmpty { StatusPill(text: channel, tint: .secondary) }
|
||||
if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ struct CronSettings_Previews: PreviewProvider {
|
||||
thinking: "low",
|
||||
timeoutSeconds: 600,
|
||||
deliver: true,
|
||||
provider: "last",
|
||||
channel: "last",
|
||||
to: nil,
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
||||
@@ -72,7 +72,7 @@ extension CronSettings {
|
||||
thinking: "low",
|
||||
timeoutSeconds: 120,
|
||||
deliver: true,
|
||||
provider: "whatsapp",
|
||||
channel: "whatsapp",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
||||
|
||||
@@ -59,7 +59,7 @@ final class DeepLinkHandler {
|
||||
}
|
||||
|
||||
do {
|
||||
let provider = GatewayAgentProvider(raw: link.channel)
|
||||
let channel = GatewayAgentChannel(raw: link.channel)
|
||||
let explicitSessionKey = link.sessionKey?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.nonEmpty
|
||||
@@ -72,9 +72,9 @@ final class DeepLinkHandler {
|
||||
message: messagePreview,
|
||||
sessionKey: resolvedSessionKey,
|
||||
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
||||
deliver: provider.shouldDeliver(link.deliver),
|
||||
deliver: channel.shouldDeliver(link.deliver),
|
||||
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
||||
provider: provider,
|
||||
channel: channel,
|
||||
timeoutSeconds: link.timeoutSeconds,
|
||||
idempotencyKey: UUID().uuidString)
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum GatewayAgentChannel: String, CaseIterable, Sendable {
|
||||
case last
|
||||
case webchat
|
||||
case whatsapp
|
||||
case telegram
|
||||
|
||||
init(raw: String?) {
|
||||
let trimmed = raw?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased() ?? ""
|
||||
self = GatewayAgentChannel(rawValue: trimmed) ?? .last
|
||||
}
|
||||
|
||||
func shouldDeliver(_ isLast: Bool) -> Bool {
|
||||
switch self {
|
||||
case .webchat:
|
||||
false
|
||||
case .last:
|
||||
isLast
|
||||
case .whatsapp, .telegram:
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import OSLog
|
||||
|
||||
private let gatewayConnectionLogger = Logger(subsystem: "com.clawdbot", category: "gateway.connection")
|
||||
|
||||
enum GatewayAgentProvider: String, Codable, CaseIterable, Sendable {
|
||||
enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
||||
case last
|
||||
case whatsapp
|
||||
case telegram
|
||||
@@ -17,7 +17,7 @@ enum GatewayAgentProvider: String, Codable, CaseIterable, Sendable {
|
||||
|
||||
init(raw: String?) {
|
||||
let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
self = GatewayAgentProvider(rawValue: normalized) ?? .last
|
||||
self = GatewayAgentChannel(rawValue: normalized) ?? .last
|
||||
}
|
||||
|
||||
var isDeliverable: Bool { self != .webchat }
|
||||
@@ -31,7 +31,7 @@ struct GatewayAgentInvocation: Sendable {
|
||||
var thinking: String?
|
||||
var deliver: Bool = false
|
||||
var to: String?
|
||||
var provider: GatewayAgentProvider = .last
|
||||
var channel: GatewayAgentChannel = .last
|
||||
var timeoutSeconds: Int?
|
||||
var idempotencyKey: String = UUID().uuidString
|
||||
}
|
||||
@@ -368,7 +368,7 @@ extension GatewayConnection {
|
||||
"thinking": AnyCodable(invocation.thinking ?? "default"),
|
||||
"deliver": AnyCodable(invocation.deliver),
|
||||
"to": AnyCodable(invocation.to ?? ""),
|
||||
"provider": AnyCodable(invocation.provider.rawValue),
|
||||
"channel": AnyCodable(invocation.channel.rawValue),
|
||||
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
|
||||
]
|
||||
if let timeout = invocation.timeoutSeconds {
|
||||
@@ -389,7 +389,7 @@ extension GatewayConnection {
|
||||
sessionKey: String,
|
||||
deliver: Bool,
|
||||
to: String?,
|
||||
provider: GatewayAgentProvider = .last,
|
||||
channel: GatewayAgentChannel = .last,
|
||||
timeoutSeconds: Int? = nil,
|
||||
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
|
||||
{
|
||||
@@ -399,7 +399,7 @@ extension GatewayConnection {
|
||||
thinking: thinking,
|
||||
deliver: deliver,
|
||||
to: to,
|
||||
provider: provider,
|
||||
channel: channel,
|
||||
timeoutSeconds: timeoutSeconds,
|
||||
idempotencyKey: idempotencyKey))
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ final class GatewayDiscoveryModel {
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort: Int
|
||||
var gatewayPort: Int?
|
||||
var cliPath: String?
|
||||
var stableID: String
|
||||
var debugID: String
|
||||
@@ -139,7 +138,6 @@ final class GatewayDiscoveryModel {
|
||||
lanHost: parsedTXT.lanHost,
|
||||
tailnetDns: parsedTXT.tailnetDns,
|
||||
sshPort: parsedTXT.sshPort,
|
||||
gatewayPort: parsedTXT.gatewayPort,
|
||||
cliPath: parsedTXT.cliPath,
|
||||
stableID: stableID,
|
||||
debugID: BridgeEndpointID.prettyDescription(result.endpoint),
|
||||
@@ -208,19 +206,12 @@ final class GatewayDiscoveryModel {
|
||||
return merged
|
||||
}
|
||||
|
||||
struct GatewayTXT: Equatable {
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort: Int
|
||||
var gatewayPort: Int?
|
||||
var cliPath: String?
|
||||
}
|
||||
|
||||
static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT {
|
||||
static func parseGatewayTXT(_ txt: [String: String])
|
||||
-> (lanHost: String?, tailnetDns: String?, sshPort: Int, cliPath: String?)
|
||||
{
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort = 22
|
||||
var gatewayPort: Int?
|
||||
var cliPath: String?
|
||||
|
||||
if let value = txt["lanHost"] {
|
||||
@@ -237,23 +228,12 @@ final class GatewayDiscoveryModel {
|
||||
{
|
||||
sshPort = parsed
|
||||
}
|
||||
if let value = txt["gatewayPort"],
|
||||
let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
parsed > 0
|
||||
{
|
||||
gatewayPort = parsed
|
||||
}
|
||||
if let value = txt["cliPath"] {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
cliPath = trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
return GatewayTXT(
|
||||
lanHost: lanHost,
|
||||
tailnetDns: tailnetDns,
|
||||
sshPort: sshPort,
|
||||
gatewayPort: gatewayPort,
|
||||
cliPath: cliPath)
|
||||
return (lanHost, tailnetDns, sshPort, cliPath)
|
||||
}
|
||||
|
||||
static func buildSSHTarget(user: String, host: String, port: Int) -> String {
|
||||
|
||||
@@ -43,52 +43,25 @@ enum GatewayLaunchAgentManager {
|
||||
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
||||
}
|
||||
|
||||
static func isLoaded() async -> Bool {
|
||||
static func status() async -> Bool {
|
||||
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
|
||||
let result = await Launchctl.run(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
return result.status == 0
|
||||
}
|
||||
|
||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||
if enabled {
|
||||
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
|
||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
|
||||
self.logger.error("launchd enable failed: gateway missing at \(gatewayBin)")
|
||||
return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh"
|
||||
}
|
||||
|
||||
let desiredBind = self.preferredGatewayBind() ?? "loopback"
|
||||
let desiredToken = self.preferredGatewayToken()
|
||||
let desiredPassword = self.preferredGatewayPassword()
|
||||
let desiredConfig = DesiredConfig(
|
||||
port: port,
|
||||
bind: desiredBind,
|
||||
token: desiredToken,
|
||||
password: desiredPassword)
|
||||
|
||||
// If launchd already loaded the job (common on login), avoid `bootout` unless we must
|
||||
// change the config. `bootout` can kill a just-started gateway and cause attach loops.
|
||||
let loaded = await self.isLoaded()
|
||||
if loaded,
|
||||
let existing = self.readPlistConfig(),
|
||||
existing.matches(desiredConfig)
|
||||
{
|
||||
self.logger.info("launchd job already loaded with desired config; skipping bootout")
|
||||
await self.ensureEnabled()
|
||||
_ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
return nil
|
||||
}
|
||||
|
||||
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
|
||||
self.logger.info("launchd enable requested port=\(port)")
|
||||
self.writePlist(bundlePath: bundlePath, port: port)
|
||||
|
||||
await self.ensureEnabled()
|
||||
if loaded {
|
||||
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
}
|
||||
let bootstrap = await Launchctl.run(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
let bootstrap = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
||||
if bootstrap.status != 0 {
|
||||
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.logger.error("launchd bootstrap failed: \(msg)")
|
||||
@@ -96,19 +69,20 @@ enum GatewayLaunchAgentManager {
|
||||
? "Failed to bootstrap gateway launchd job"
|
||||
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
await self.ensureEnabled()
|
||||
// Note: removed redundant `kickstart -k` that caused race condition.
|
||||
// bootstrap already starts the job; kickstart -k would kill it immediately
|
||||
// and with KeepAlive=true, cause a restart loop with port conflicts.
|
||||
return nil
|
||||
}
|
||||
|
||||
self.logger.info("launchd disable requested")
|
||||
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
await self.ensureDisabled()
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
try? FileManager.default.removeItem(at: self.plistURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
static func kickstart() async {
|
||||
_ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
}
|
||||
|
||||
private static func writePlist(bundlePath: String, port: Int) {
|
||||
@@ -234,57 +208,30 @@ enum GatewayLaunchAgentManager {
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
}
|
||||
|
||||
private struct DesiredConfig: Equatable {
|
||||
let port: Int
|
||||
let bind: String
|
||||
let token: String?
|
||||
let password: String?
|
||||
private struct LaunchctlResult {
|
||||
let status: Int32
|
||||
let output: String
|
||||
}
|
||||
|
||||
private struct InstalledConfig: Equatable {
|
||||
let port: Int?
|
||||
let bind: String?
|
||||
let token: String?
|
||||
let password: String?
|
||||
|
||||
func matches(_ desired: DesiredConfig) -> Bool {
|
||||
guard self.port == desired.port else { return false }
|
||||
guard (self.bind ?? "loopback") == desired.bind else { return false }
|
||||
guard self.token == desired.token else { return false }
|
||||
guard self.password == desired.password else { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private static func readPlistConfig() -> InstalledConfig? {
|
||||
guard let snapshot = LaunchAgentPlist.snapshot(url: self.plistURL) else { return nil }
|
||||
return InstalledConfig(
|
||||
port: snapshot.port,
|
||||
bind: snapshot.bind,
|
||||
token: snapshot.token,
|
||||
password: snapshot.password)
|
||||
}
|
||||
|
||||
private static func ensureEnabled() async {
|
||||
let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
guard result.status != 0 else { return }
|
||||
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if msg.isEmpty {
|
||||
self.logger.warning("launchd enable failed")
|
||||
} else {
|
||||
self.logger.warning("launchd enable failed: \(msg)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureDisabled() async {
|
||||
let result = await Launchctl.run(["disable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
guard result.status != 0 else { return }
|
||||
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if msg.isEmpty {
|
||||
self.logger.warning("launchd disable failed")
|
||||
} else {
|
||||
self.logger.warning("launchd disable failed: \(msg)")
|
||||
}
|
||||
@discardableResult
|
||||
private static func runLaunchctl(_ args: [String]) async -> LaunchctlResult {
|
||||
await Task.detached(priority: .utility) { () -> LaunchctlResult in
|
||||
let process = Process()
|
||||
process.launchPath = "/bin/launchctl"
|
||||
process.arguments = args
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
let output = String(data: data, encoding: .utf8) ?? ""
|
||||
return LaunchctlResult(status: process.terminationStatus, output: output)
|
||||
} catch {
|
||||
return LaunchctlResult(status: -1, output: error.localizedDescription)
|
||||
}
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ final class GatewayProcessManager {
|
||||
func ensureLaunchAgentEnabledIfNeeded() async {
|
||||
guard !CommandResolver.connectionModeIsRemote() else { return }
|
||||
guard !AppStateStore.attachExistingGatewayOnly else { return }
|
||||
let enabled = await GatewayLaunchAgentManager.isLoaded()
|
||||
let enabled = await GatewayLaunchAgentManager.status()
|
||||
guard !enabled else { return }
|
||||
let bundlePath = Bundle.main.bundleURL.path
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
|
||||
@@ -694,7 +694,6 @@ extension GeneralSettings {
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
||||
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum Launchctl {
|
||||
struct Result: Sendable {
|
||||
let status: Int32
|
||||
let output: String
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func run(_ args: [String]) async -> Result {
|
||||
await Task.detached(priority: .utility) { () -> Result in
|
||||
let process = Process()
|
||||
process.launchPath = "/bin/launchctl"
|
||||
process.arguments = args
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
let output = String(data: data, encoding: .utf8) ?? ""
|
||||
return Result(status: process.terminationStatus, output: output)
|
||||
} catch {
|
||||
return Result(status: -1, output: error.localizedDescription)
|
||||
}
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
struct LaunchAgentPlistSnapshot: Equatable, Sendable {
|
||||
let programArguments: [String]
|
||||
let environment: [String: String]
|
||||
|
||||
let port: Int?
|
||||
let bind: String?
|
||||
let token: String?
|
||||
let password: String?
|
||||
}
|
||||
|
||||
enum LaunchAgentPlist {
|
||||
static func snapshot(url: URL) -> LaunchAgentPlistSnapshot? {
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
let rootAny: Any
|
||||
do {
|
||||
rootAny = try PropertyListSerialization.propertyList(
|
||||
from: data,
|
||||
options: [],
|
||||
format: nil)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
guard let root = rootAny as? [String: Any] else { return nil }
|
||||
let programArguments = root["ProgramArguments"] as? [String] ?? []
|
||||
let env = root["EnvironmentVariables"] as? [String: String] ?? [:]
|
||||
let port = Self.extractFlagInt(programArguments, flag: "--port")
|
||||
let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased()
|
||||
let token = env["CLAWDBOT_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let password = env["CLAWDBOT_GATEWAY_PASSWORD"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
return LaunchAgentPlistSnapshot(
|
||||
programArguments: programArguments,
|
||||
environment: env,
|
||||
port: port,
|
||||
bind: bind,
|
||||
token: token,
|
||||
password: password)
|
||||
}
|
||||
|
||||
private static func extractFlagInt(_ args: [String], flag: String) -> Int? {
|
||||
guard let raw = self.extractFlagString(args, flag: flag) else { return nil }
|
||||
return Int(raw)
|
||||
}
|
||||
|
||||
private static func extractFlagString(_ args: [String], flag: String) -> String? {
|
||||
guard let idx = args.firstIndex(of: flag) else { return nil }
|
||||
let valueIdx = args.index(after: idx)
|
||||
guard valueIdx < args.endIndex else { return nil }
|
||||
let token = args[valueIdx].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return token.isEmpty ? nil : token
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,6 @@ struct MenuContent: View {
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
@State private var availableMics: [AudioInputDevice] = []
|
||||
@State private var loadingMics = false
|
||||
@State private var micObserver = AudioInputDeviceObserver()
|
||||
@State private var micRefreshTask: Task<Void, Never>?
|
||||
@State private var browserControlEnabled = true
|
||||
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
|
||||
@AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue
|
||||
@@ -145,14 +143,6 @@ struct MenuContent: View {
|
||||
.task(id: self.state.connectionMode) {
|
||||
await self.loadBrowserControlEnabled()
|
||||
}
|
||||
.onAppear {
|
||||
self.startMicObserver()
|
||||
}
|
||||
.onDisappear {
|
||||
self.micRefreshTask?.cancel()
|
||||
self.micRefreshTask = nil
|
||||
self.micObserver.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionLabel: String {
|
||||
@@ -450,22 +440,13 @@ struct MenuContent: View {
|
||||
if let match = self.availableMics.first(where: { $0.uid == self.state.voiceWakeMicID }) {
|
||||
return match.name
|
||||
}
|
||||
if !self.state.voiceWakeMicName.isEmpty { return self.state.voiceWakeMicName }
|
||||
return "Unavailable"
|
||||
}
|
||||
|
||||
private var microphoneMenuItems: some View {
|
||||
Group {
|
||||
if self.isSelectedMicUnavailable {
|
||||
Label("Disconnected (using System default)", systemImage: "exclamationmark.triangle")
|
||||
.labelStyle(.titleAndIcon)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(true)
|
||||
Divider()
|
||||
}
|
||||
Button {
|
||||
self.state.voiceWakeMicID = ""
|
||||
self.state.voiceWakeMicName = ""
|
||||
} label: {
|
||||
Label(self.defaultMicLabel, systemImage: self.state.voiceWakeMicID.isEmpty ? "checkmark" : "")
|
||||
.labelStyle(.titleAndIcon)
|
||||
@@ -475,7 +456,6 @@ struct MenuContent: View {
|
||||
ForEach(self.availableMics) { mic in
|
||||
Button {
|
||||
self.state.voiceWakeMicID = mic.uid
|
||||
self.state.voiceWakeMicName = mic.name
|
||||
} label: {
|
||||
Label(mic.name, systemImage: self.state.voiceWakeMicID == mic.uid ? "checkmark" : "")
|
||||
.labelStyle(.titleAndIcon)
|
||||
@@ -485,12 +465,6 @@ struct MenuContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var isSelectedMicUnavailable: Bool {
|
||||
let selected = self.state.voiceWakeMicID
|
||||
guard !selected.isEmpty else { return false }
|
||||
return !self.availableMics.contains(where: { $0.uid == selected })
|
||||
}
|
||||
|
||||
private var defaultMicLabel: String {
|
||||
if let host = Host.current().localizedName, !host.isEmpty {
|
||||
return "Auto-detect (\(host))"
|
||||
@@ -526,53 +500,14 @@ struct MenuContent: View {
|
||||
deviceTypes: [.external, .microphone],
|
||||
mediaType: .audio,
|
||||
position: .unspecified)
|
||||
let connectedDevices = discovery.devices.filter(\.isConnected)
|
||||
self.availableMics = connectedDevices
|
||||
self.availableMics = discovery.devices
|
||||
.sorted { lhs, rhs in
|
||||
lhs.localizedName.localizedCaseInsensitiveCompare(rhs.localizedName) == .orderedAscending
|
||||
}
|
||||
.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) }
|
||||
self.availableMics = self.filterAliveInputs(self.availableMics)
|
||||
self.updateSelectedMicName()
|
||||
self.loadingMics = false
|
||||
}
|
||||
|
||||
private func startMicObserver() {
|
||||
self.micObserver.start {
|
||||
Task { @MainActor in
|
||||
self.scheduleMicRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func scheduleMicRefresh() {
|
||||
self.micRefreshTask?.cancel()
|
||||
self.micRefreshTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
await self.loadMicrophones(force: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func filterAliveInputs(_ inputs: [AudioInputDevice]) -> [AudioInputDevice] {
|
||||
let aliveUIDs = AudioInputDeviceObserver.aliveInputDeviceUIDs()
|
||||
guard !aliveUIDs.isEmpty else { return inputs }
|
||||
return inputs.filter { aliveUIDs.contains($0.uid) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateSelectedMicName() {
|
||||
let selected = self.state.voiceWakeMicID
|
||||
if selected.isEmpty {
|
||||
self.state.voiceWakeMicName = ""
|
||||
return
|
||||
}
|
||||
if let match = self.availableMics.first(where: { $0.uid == selected }) {
|
||||
self.state.voiceWakeMicName = match.name
|
||||
}
|
||||
}
|
||||
|
||||
private struct AudioInputDevice: Identifiable, Equatable {
|
||||
let uid: String
|
||||
let name: String
|
||||
|
||||
@@ -22,10 +22,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
private var cachedErrorText: String?
|
||||
private var cacheUpdatedAt: Date?
|
||||
private let refreshIntervalSeconds: TimeInterval = 12
|
||||
private var cachedUsageSummary: GatewayUsageSummary?
|
||||
private var cachedUsageErrorText: String?
|
||||
private var usageCacheUpdatedAt: Date?
|
||||
private let usageRefreshIntervalSeconds: TimeInterval = 30
|
||||
private let nodesStore = NodesStore.shared
|
||||
#if DEBUG
|
||||
private var testControlChannelConnected: Bool?
|
||||
@@ -62,7 +58,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
self.loadTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refreshCache(force: forceRefresh)
|
||||
await self.refreshUsageCache(force: forceRefresh)
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen else { return }
|
||||
self.inject(into: menu)
|
||||
@@ -110,86 +105,68 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
|
||||
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
|
||||
let width = self.initialWidth(for: menu)
|
||||
let isConnected = self.isControlChannelConnected
|
||||
|
||||
var cursor = insertIndex
|
||||
var headerView: NSView?
|
||||
|
||||
if let snapshot = self.cachedSnapshot {
|
||||
let now = Date()
|
||||
let rows = snapshot.rows.filter { row in
|
||||
if row.key == "main" { return true }
|
||||
guard let updatedAt = row.updatedAt else { return false }
|
||||
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
|
||||
}.sorted { lhs, rhs in
|
||||
if lhs.key == "main" { return true }
|
||||
if rhs.key == "main" { return false }
|
||||
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
||||
}
|
||||
guard self.isControlChannelConnected else { return }
|
||||
|
||||
guard let snapshot = self.cachedSnapshot else {
|
||||
let headerItem = NSMenuItem()
|
||||
headerItem.tag = self.tag
|
||||
headerItem.isEnabled = false
|
||||
let hosted = self.makeHostedView(
|
||||
rootView: AnyView(MenuSessionsHeaderView(
|
||||
count: rows.count,
|
||||
statusText: isConnected ? nil : "Gateway disconnected")),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
headerItem.view = hosted
|
||||
headerView = hosted
|
||||
menu.insertItem(headerItem, at: cursor)
|
||||
cursor += 1
|
||||
|
||||
if rows.isEmpty {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width),
|
||||
at: cursor)
|
||||
cursor += 1
|
||||
} else {
|
||||
for row in rows {
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.tag
|
||||
item.isEnabled = true
|
||||
item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath)
|
||||
item.view = self.makeHostedView(
|
||||
rootView: AnyView(SessionMenuLabelView(row: row, width: width)),
|
||||
width: width,
|
||||
highlighted: true)
|
||||
menu.insertItem(item, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let headerItem = NSMenuItem()
|
||||
headerItem.tag = self.tag
|
||||
headerItem.isEnabled = false
|
||||
let statusText = isConnected
|
||||
? (self.cachedErrorText ?? "Loading sessions…")
|
||||
: "Gateway disconnected"
|
||||
let hosted = self.makeHostedView(
|
||||
headerItem.view = self.makeHostedView(
|
||||
rootView: AnyView(MenuSessionsHeaderView(
|
||||
count: 0,
|
||||
statusText: statusText)),
|
||||
statusText: self.cachedErrorText ?? "Loading sessions…")),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
headerItem.view = hosted
|
||||
headerView = hosted
|
||||
menu.insertItem(headerItem, at: cursor)
|
||||
cursor += 1
|
||||
|
||||
if !isConnected {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(
|
||||
text: "Connect the gateway to see sessions",
|
||||
symbolName: "bolt.slash",
|
||||
width: width),
|
||||
at: cursor)
|
||||
cursor += 1
|
||||
menu.insertItem(headerItem, at: insertIndex)
|
||||
DispatchQueue.main.async { [weak self, weak view = headerItem.view] in
|
||||
guard let self, let view else { return }
|
||||
self.captureMenuWidthIfAvailable(from: view)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
cursor = self.insertUsageSection(into: menu, at: cursor, width: width)
|
||||
let now = Date()
|
||||
let rows = snapshot.rows.filter { row in
|
||||
if row.key == "main" { return true }
|
||||
guard let updatedAt = row.updatedAt else { return false }
|
||||
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
|
||||
}.sorted { lhs, rhs in
|
||||
if lhs.key == "main" { return true }
|
||||
if rhs.key == "main" { return false }
|
||||
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
||||
}
|
||||
|
||||
let headerItem = NSMenuItem()
|
||||
headerItem.tag = self.tag
|
||||
headerItem.isEnabled = false
|
||||
let headerView = self.makeHostedView(
|
||||
rootView: AnyView(MenuSessionsHeaderView(count: rows.count, statusText: nil)),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
headerItem.view = headerView
|
||||
menu.insertItem(headerItem, at: insertIndex)
|
||||
|
||||
var cursor = insertIndex + 1
|
||||
if rows.isEmpty {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width),
|
||||
at: cursor)
|
||||
return
|
||||
}
|
||||
|
||||
for row in rows {
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.tag
|
||||
item.isEnabled = true
|
||||
item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath)
|
||||
item.view = self.makeHostedView(
|
||||
rootView: AnyView(SessionMenuLabelView(row: row, width: width)),
|
||||
width: width,
|
||||
highlighted: true)
|
||||
menu.insertItem(item, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self, weak headerView] in
|
||||
guard let self, let headerView else { return }
|
||||
@@ -263,55 +240,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
_ = cursor
|
||||
}
|
||||
|
||||
private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
|
||||
let rows = self.usageRows
|
||||
let errorText = self.cachedUsageErrorText
|
||||
|
||||
if rows.isEmpty, errorText == nil {
|
||||
return cursor
|
||||
}
|
||||
|
||||
var cursor = cursor
|
||||
let headerItem = NSMenuItem()
|
||||
headerItem.tag = self.tag
|
||||
headerItem.isEnabled = false
|
||||
headerItem.view = self.makeHostedView(
|
||||
rootView: AnyView(MenuUsageHeaderView(
|
||||
count: rows.count,
|
||||
statusText: errorText)),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
menu.insertItem(headerItem, at: cursor)
|
||||
cursor += 1
|
||||
|
||||
if rows.isEmpty {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(text: errorText ?? "No usage available", symbolName: "minus", width: width),
|
||||
at: cursor)
|
||||
cursor += 1
|
||||
return cursor
|
||||
}
|
||||
|
||||
for row in rows {
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.tag
|
||||
item.isEnabled = false
|
||||
item.view = self.makeHostedView(
|
||||
rootView: AnyView(UsageMenuLabelView(row: row, width: width)),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
menu.insertItem(item, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
return cursor
|
||||
}
|
||||
|
||||
private var usageRows: [UsageRow] {
|
||||
guard let summary = self.cachedUsageSummary else { return [] }
|
||||
return summary.primaryRows()
|
||||
}
|
||||
|
||||
private var isControlChannelConnected: Bool {
|
||||
#if DEBUG
|
||||
if let override = self.testControlChannelConnected { return override }
|
||||
@@ -436,40 +364,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshUsageCache(force: Bool) async {
|
||||
if !force,
|
||||
let updated = self.usageCacheUpdatedAt,
|
||||
Date().timeIntervalSince(updated) < self.usageRefreshIntervalSeconds
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
self.cachedUsageSummary = nil
|
||||
self.cachedUsageErrorText = nil
|
||||
self.usageCacheUpdatedAt = Date()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
self.cachedUsageSummary = try await UsageLoader.loadSummary()
|
||||
self.cachedUsageErrorText = nil
|
||||
self.usageCacheUpdatedAt = Date()
|
||||
} catch {
|
||||
if self.cachedUsageSummary == nil {
|
||||
self.cachedUsageErrorText = self.compactUsageError(error)
|
||||
}
|
||||
self.usageCacheUpdatedAt = Date()
|
||||
}
|
||||
}
|
||||
|
||||
private func compactUsageError(_ error: Error) -> String {
|
||||
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if message.isEmpty { return "Usage unavailable" }
|
||||
if message.count > 90 { return "\(message.prefix(87))…" }
|
||||
return message
|
||||
}
|
||||
|
||||
private func compactError(_ error: Error) -> String {
|
||||
if let loadError = error as? SessionLoadError {
|
||||
switch loadError {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MenuUsageHeaderView: View {
|
||||
let count: Int
|
||||
let statusText: String?
|
||||
|
||||
private let paddingTop: CGFloat = 8
|
||||
private let paddingBottom: CGFloat = 6
|
||||
private let paddingTrailing: CGFloat = 10
|
||||
private let paddingLeading: CGFloat = 20
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Usage")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 10)
|
||||
Text(self.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let statusText, !statusText.isEmpty {
|
||||
Text(statusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.padding(.top, self.paddingTop)
|
||||
.padding(.bottom, self.paddingBottom)
|
||||
.padding(.leading, self.paddingLeading)
|
||||
.padding(.trailing, self.paddingTrailing)
|
||||
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
|
||||
.transaction { txn in txn.animation = nil }
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
if self.count == 1 { return "1 provider" }
|
||||
return "\(self.count) providers"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import AVFoundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
actor MicLevelMonitor {
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "voicewake.meter")
|
||||
private var engine: AVAudioEngine?
|
||||
private let engine = AVAudioEngine()
|
||||
private var update: (@Sendable (Double) -> Void)?
|
||||
private var running = false
|
||||
private var smoothedLevel: Double = 0
|
||||
@@ -12,37 +10,23 @@ actor MicLevelMonitor {
|
||||
func start(onLevel: @Sendable @escaping (Double) -> Void) async throws {
|
||||
self.update = onLevel
|
||||
if self.running { return }
|
||||
self.logger.info(
|
||||
"mic level monitor start (\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public))")
|
||||
let engine = AVAudioEngine()
|
||||
self.engine = engine
|
||||
let input = engine.inputNode
|
||||
let input = self.engine.inputNode
|
||||
let format = input.outputFormat(forBus: 0)
|
||||
guard format.channelCount > 0, format.sampleRate > 0 else {
|
||||
self.engine = nil
|
||||
throw NSError(
|
||||
domain: "MicLevelMonitor",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No audio input available"])
|
||||
}
|
||||
input.removeTap(onBus: 0)
|
||||
input.installTap(onBus: 0, bufferSize: 512, format: format) { [weak self] buffer, _ in
|
||||
guard let self else { return }
|
||||
let level = Self.normalizedLevel(from: buffer)
|
||||
Task { await self.push(level: level) }
|
||||
}
|
||||
engine.prepare()
|
||||
try engine.start()
|
||||
self.engine.prepare()
|
||||
try self.engine.start()
|
||||
self.running = true
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard self.running else { return }
|
||||
if let engine {
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
}
|
||||
self.engine = nil
|
||||
self.engine.inputNode.removeTap(onBus: 0)
|
||||
self.engine.stop()
|
||||
self.running = false
|
||||
}
|
||||
|
||||
|
||||
@@ -202,23 +202,6 @@ final class MacNodeModeCoordinator {
|
||||
return 18790
|
||||
}
|
||||
|
||||
static func remoteBridgePort() -> Int {
|
||||
let fallback = Int(Self.loopbackBridgePort() ?? 18790)
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
let sshHost = CommandResolver.parseSSHTarget(settings.target)?.host ?? ""
|
||||
let base =
|
||||
ClawdbotConfigFile.remoteGatewayPort(matchingHost: sshHost) ??
|
||||
GatewayEnvironment.gatewayPort()
|
||||
guard base > 0 else { return fallback }
|
||||
return Self.derivePort(base: base, offset: 1, fallback: fallback)
|
||||
}
|
||||
|
||||
private static func derivePort(base: Int, offset: Int, fallback: Int) -> Int {
|
||||
let derived = base + offset
|
||||
guard derived > 0, derived <= Int(UInt16.max) else { return fallback }
|
||||
return derived
|
||||
}
|
||||
|
||||
static func probeEndpoint(_ endpoint: NWEndpoint, timeoutSeconds: Double) async -> Bool {
|
||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||
let stream = Self.makeStateStream(for: connection)
|
||||
@@ -286,10 +269,7 @@ final class MacNodeModeCoordinator {
|
||||
if mode == .remote {
|
||||
do {
|
||||
if self.tunnel == nil || self.tunnel?.process.isRunning == false {
|
||||
let remotePort = Self.remoteBridgePort()
|
||||
self.tunnel = try await RemotePortTunnel.create(
|
||||
remotePort: remotePort,
|
||||
allowRemoteUrlOverride: false)
|
||||
self.tunnel = try await RemotePortTunnel.create(remotePort: 18790)
|
||||
}
|
||||
if let localPort = self.tunnel?.localPort,
|
||||
let port = NWEndpoint.Port(rawValue: localPort)
|
||||
|
||||
@@ -5,16 +5,8 @@ import Foundation
|
||||
|
||||
actor MacNodeRuntime {
|
||||
private let cameraCapture = CameraCaptureService()
|
||||
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
|
||||
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
|
||||
|
||||
init(
|
||||
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
|
||||
await MainActor.run { LiveMacNodeRuntimeMainActorServices() }
|
||||
})
|
||||
{
|
||||
self.makeMainActorServices = makeMainActorServices
|
||||
}
|
||||
@MainActor private let screenRecorder = ScreenRecordService()
|
||||
@MainActor private let locationService = MacNodeLocationService()
|
||||
|
||||
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||
let command = req.command
|
||||
@@ -220,8 +212,7 @@ actor MacNodeRuntime {
|
||||
ClawdbotLocationGetParams()
|
||||
let desired = params.desiredAccuracy ??
|
||||
(Self.locationPreciseEnabled() ? .precise : .balanced)
|
||||
let services = await self.mainActorServices()
|
||||
let status = await services.locationAuthorizationStatus()
|
||||
let status = await self.locationService.authorizationStatus()
|
||||
if status != .authorizedAlways {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
@@ -231,11 +222,11 @@ actor MacNodeRuntime {
|
||||
message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
|
||||
}
|
||||
do {
|
||||
let location = try await services.currentLocation(
|
||||
let location = try await self.locationService.currentLocation(
|
||||
desiredAccuracy: desired,
|
||||
maxAgeMs: params.maxAgeMs,
|
||||
timeoutMs: params.timeoutMs)
|
||||
let isPrecise = await services.locationAccuracyAuthorization() == .fullAccuracy
|
||||
let isPrecise = await self.locationService.accuracyAuthorization() == .fullAccuracy
|
||||
let payload = ClawdbotLocationPayload(
|
||||
lat: location.coordinate.latitude,
|
||||
lon: location.coordinate.longitude,
|
||||
@@ -274,8 +265,7 @@ actor MacNodeRuntime {
|
||||
code: .invalidRequest,
|
||||
message: "INVALID_REQUEST: screen format must be mp4")
|
||||
}
|
||||
let services = await self.mainActorServices()
|
||||
let res = try await services.recordScreen(
|
||||
let res = try await self.screenRecorder.record(
|
||||
screenIndex: params.screenIndex,
|
||||
durationMs: params.durationMs,
|
||||
fps: params.fps,
|
||||
@@ -301,13 +291,6 @@ actor MacNodeRuntime {
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
}
|
||||
|
||||
private func mainActorServices() async -> any MacNodeRuntimeMainActorServices {
|
||||
if let cachedMainActorServices { return cachedMainActorServices }
|
||||
let services = await self.makeMainActorServices()
|
||||
self.cachedMainActorServices = services
|
||||
return services
|
||||
}
|
||||
|
||||
private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
try await self.ensureA2UIHost()
|
||||
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import ClawdbotKit
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
protocol MacNodeRuntimeMainActorServices: Sendable {
|
||||
func recordScreen(
|
||||
screenIndex: Int?,
|
||||
durationMs: Int?,
|
||||
fps: Double?,
|
||||
includeAudio: Bool?,
|
||||
outPath: String?) async throws -> (path: String, hasAudio: Bool)
|
||||
|
||||
func locationAuthorizationStatus() -> CLAuthorizationStatus
|
||||
func locationAccuracyAuthorization() -> CLAccuracyAuthorization
|
||||
func currentLocation(
|
||||
desiredAccuracy: ClawdbotLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
|
||||
private let screenRecorder = ScreenRecordService()
|
||||
private let locationService = MacNodeLocationService()
|
||||
|
||||
func recordScreen(
|
||||
screenIndex: Int?,
|
||||
durationMs: Int?,
|
||||
fps: Double?,
|
||||
includeAudio: Bool?,
|
||||
outPath: String?) async throws -> (path: String, hasAudio: Bool)
|
||||
{
|
||||
try await self.screenRecorder.record(
|
||||
screenIndex: screenIndex,
|
||||
durationMs: durationMs,
|
||||
fps: fps,
|
||||
includeAudio: includeAudio,
|
||||
outPath: outPath)
|
||||
}
|
||||
|
||||
func locationAuthorizationStatus() -> CLAuthorizationStatus {
|
||||
self.locationService.authorizationStatus()
|
||||
}
|
||||
|
||||
func locationAccuracyAuthorization() -> CLAccuracyAuthorization {
|
||||
self.locationService.accuracyAuthorization()
|
||||
}
|
||||
|
||||
func currentLocation(
|
||||
desiredAccuracy: ClawdbotLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
{
|
||||
try await self.locationService.currentLocation(
|
||||
desiredAccuracy: desiredAccuracy,
|
||||
maxAgeMs: maxAgeMs,
|
||||
timeoutMs: timeoutMs)
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@ extension OnboardingView {
|
||||
user: user,
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
||||
}
|
||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ extension OnboardingView {
|
||||
lanHost: "bridge.local",
|
||||
tailnetDns: "bridge.ts.net",
|
||||
sshPort: 2222,
|
||||
gatewayPort: 18789,
|
||||
cliPath: "/usr/local/bin/clawdbot",
|
||||
stableID: "bridge-1",
|
||||
debugID: "bridge-1",
|
||||
|
||||
@@ -2,12 +2,11 @@ import Foundation
|
||||
|
||||
extension ProcessInfo {
|
||||
var isPreview: Bool {
|
||||
guard let raw = getenv("XCODE_RUNNING_FOR_PREVIEWS") else { return false }
|
||||
return String(cString: raw) == "1"
|
||||
self.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
||||
}
|
||||
|
||||
var isNixMode: Bool {
|
||||
if let raw = getenv("CLAWDBOT_NIX_MODE"), String(cString: raw) == "1" { return true }
|
||||
if self.environment["CLAWDBOT_NIX_MODE"] == "1" { return true }
|
||||
return UserDefaults.standard.bool(forKey: "clawdbot.nixMode")
|
||||
}
|
||||
|
||||
|
||||
@@ -38,11 +38,7 @@ final class RemotePortTunnel {
|
||||
Task { await PortGuardian.shared.removeRecord(pid: pid) }
|
||||
}
|
||||
|
||||
static func create(
|
||||
remotePort: Int,
|
||||
preferredLocalPort: UInt16? = nil,
|
||||
allowRemoteUrlOverride: Bool = true) async throws -> RemotePortTunnel
|
||||
{
|
||||
static func create(remotePort: Int, preferredLocalPort: UInt16? = nil) async throws -> RemotePortTunnel {
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else {
|
||||
throw NSError(
|
||||
@@ -53,10 +49,7 @@ final class RemotePortTunnel {
|
||||
|
||||
let localPort = try await Self.findPort(preferred: preferredLocalPort)
|
||||
let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let remotePortOverride =
|
||||
allowRemoteUrlOverride && remotePort == GatewayEnvironment.gatewayPort()
|
||||
? Self.resolveRemotePortOverride(for: sshHost)
|
||||
: nil
|
||||
let remotePortOverride = Self.resolveRemotePortOverride(for: sshHost)
|
||||
let resolvedRemotePort = remotePortOverride ?? remotePort
|
||||
if let override = remotePortOverride {
|
||||
Self.logger.info(
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.8</string>
|
||||
<string>0.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260108</string>
|
||||
<string>0.0.0</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Clawdbot</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -9,7 +9,7 @@ struct GatewaySessionDefaultsRecord: Codable {
|
||||
struct GatewaySessionEntryRecord: Codable {
|
||||
let key: String
|
||||
let displayName: String?
|
||||
let provider: String?
|
||||
let surface: String?
|
||||
let subject: String?
|
||||
let room: String?
|
||||
let space: String?
|
||||
@@ -71,7 +71,7 @@ struct SessionRow: Identifiable {
|
||||
let key: String
|
||||
let kind: SessionKind
|
||||
let displayName: String?
|
||||
let provider: String?
|
||||
let surface: String?
|
||||
let subject: String?
|
||||
let room: String?
|
||||
let space: String?
|
||||
@@ -141,7 +141,7 @@ extension SessionRow {
|
||||
key: "user@example.com",
|
||||
kind: .direct,
|
||||
displayName: nil,
|
||||
provider: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
@@ -158,7 +158,7 @@ extension SessionRow {
|
||||
key: "discord:channel:release-squad",
|
||||
kind: .group,
|
||||
displayName: "discord:#release-squad",
|
||||
provider: "discord",
|
||||
surface: "discord",
|
||||
subject: nil,
|
||||
room: "#release-squad",
|
||||
space: nil,
|
||||
@@ -175,7 +175,7 @@ extension SessionRow {
|
||||
key: "global",
|
||||
kind: .global,
|
||||
displayName: nil,
|
||||
provider: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
@@ -298,7 +298,7 @@ enum SessionLoader {
|
||||
key: entry.key,
|
||||
kind: SessionKind.from(key: entry.key),
|
||||
displayName: entry.displayName,
|
||||
provider: entry.provider,
|
||||
surface: entry.surface,
|
||||
subject: entry.subject,
|
||||
room: entry.room,
|
||||
space: entry.space,
|
||||
|
||||
@@ -31,7 +31,7 @@ struct SettingsRootView: View {
|
||||
.tabItem { Label("Connections", systemImage: "link") }
|
||||
.tag(SettingsTab.connections)
|
||||
|
||||
VoiceWakeSettings(state: self.state, isActive: self.selectedTab == .voiceWake)
|
||||
VoiceWakeSettings(state: self.state)
|
||||
.tabItem { Label("Voice Wake", systemImage: "waveform.circle") }
|
||||
.tag(SettingsTab.voiceWake)
|
||||
|
||||
|
||||
@@ -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 deliver: Bool = true
|
||||
var to: String?
|
||||
var provider: GatewayAgentProvider = .last
|
||||
var channel: GatewayAgentChannel = .last
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -46,14 +46,14 @@ enum VoiceWakeForwarder {
|
||||
options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError>
|
||||
{
|
||||
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(
|
||||
message: payload,
|
||||
sessionKey: options.sessionKey,
|
||||
thinking: options.thinking,
|
||||
deliver: deliver,
|
||||
to: options.to,
|
||||
provider: options.provider))
|
||||
channel: options.channel))
|
||||
|
||||
if result.ok {
|
||||
self.logger.info("voice wake forward ok")
|
||||
|
||||
@@ -38,15 +38,6 @@ actor VoiceWakeRuntime {
|
||||
private var overlayToken: UUID?
|
||||
private var activeTriggerEndTime: TimeInterval?
|
||||
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
|
||||
// Silence threshold once we've captured user speech (post-trigger).
|
||||
@@ -59,8 +50,6 @@ actor VoiceWakeRuntime {
|
||||
// Voice activity detection parameters (RMS-based).
|
||||
private let minSpeechRMS: Double = 1e-3
|
||||
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.
|
||||
private func haltRecognitionPipeline() {
|
||||
@@ -117,19 +106,6 @@ actor VoiceWakeRuntime {
|
||||
|
||||
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 {
|
||||
return
|
||||
}
|
||||
@@ -139,11 +115,6 @@ actor VoiceWakeRuntime {
|
||||
}
|
||||
|
||||
private func start(with config: RuntimeConfig) async {
|
||||
if self.isStarting {
|
||||
return
|
||||
}
|
||||
self.isStarting = true
|
||||
defer { self.isStarting = false }
|
||||
do {
|
||||
self.recognitionGeneration &+= 1
|
||||
let generation = self.recognitionGeneration
|
||||
@@ -157,7 +128,6 @@ actor VoiceWakeRuntime {
|
||||
|
||||
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||
self.recognitionRequest?.shouldReportPartialResults = true
|
||||
self.recognitionRequest?.taskHint = .dictation
|
||||
guard let request = self.recognitionRequest else { return }
|
||||
|
||||
// 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 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.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak self, weak request] buffer, _ in
|
||||
request?.append(buffer)
|
||||
guard let rms = Self.rmsLevel(buffer: buffer) else { return }
|
||||
Task.detached { [weak self] in
|
||||
await self?.noteAudioLevel(rms: rms)
|
||||
await self?.noteAudioTap(rms: rms)
|
||||
if let rms = Self.rmsLevel(buffer: buffer) {
|
||||
Task.detached { [weak self] in
|
||||
await self?.noteAudioLevel(rms: rms)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +163,6 @@ actor VoiceWakeRuntime {
|
||||
.map { WakeWordSpeechSegments.from(transcription: result.bestTranscription, transcript: $0) }
|
||||
} ?? []
|
||||
let isFinal = result?.isFinal ?? false
|
||||
Task { await self.noteRecognitionCallback(transcript: transcript, isFinal: isFinal, error: error) }
|
||||
let update = RecognitionUpdate(
|
||||
transcript: transcript,
|
||||
segments: segments,
|
||||
@@ -209,10 +172,6 @@ actor VoiceWakeRuntime {
|
||||
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")
|
||||
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "started", fields: [
|
||||
"locale": config.localeID ?? "",
|
||||
@@ -235,12 +194,6 @@ actor VoiceWakeRuntime {
|
||||
self.capturedTranscript = ""
|
||||
self.captureStartedAt = nil
|
||||
self.triggerChimePlayed = false
|
||||
self.lastTranscript = nil
|
||||
self.lastTranscriptAt = nil
|
||||
self.preDetectTask?.cancel()
|
||||
self.preDetectTask = nil
|
||||
self.triggerOnlyTask?.cancel()
|
||||
self.triggerOnlyTask = nil
|
||||
self.haltRecognitionPipeline()
|
||||
self.recognizer = nil
|
||||
self.currentConfig = nil
|
||||
@@ -264,7 +217,6 @@ actor VoiceWakeRuntime {
|
||||
private func configureSession(localeID: String?) {
|
||||
let locale = localeID.flatMap { Locale(identifier: $0) } ?? Locale(identifier: Locale.current.identifier)
|
||||
self.recognizer = SFSpeechRecognizer(locale: locale)
|
||||
self.recognizer?.defaultTaskHint = .dictation
|
||||
}
|
||||
|
||||
private func handleRecognition(_ update: RecognitionUpdate, config: RuntimeConfig) async {
|
||||
@@ -280,19 +232,7 @@ actor VoiceWakeRuntime {
|
||||
let now = Date()
|
||||
if !transcript.isEmpty {
|
||||
self.lastHeard = now
|
||||
if !self.isCapturing {
|
||||
self.lastTranscript = transcript
|
||||
self.lastTranscriptAt = now
|
||||
}
|
||||
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(
|
||||
transcript: transcript,
|
||||
segments: update.segments,
|
||||
@@ -326,214 +266,15 @@ actor VoiceWakeRuntime {
|
||||
if self.isCapturing { return }
|
||||
|
||||
let gateConfig = WakeWordGateConfig(triggers: config.triggers)
|
||||
var usedFallback = false
|
||||
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 match = WakeWordGate.match(transcript: transcript, segments: update.segments, config: gateConfig) {
|
||||
if let cooldown = cooldownUntil, now < cooldown {
|
||||
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)
|
||||
} 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(
|
||||
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 {
|
||||
private func beginCapture(command: String, triggerEndTime: TimeInterval, config: RuntimeConfig) async {
|
||||
self.listeningState = .voiceWake
|
||||
self.isCapturing = true
|
||||
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "beginCapture")
|
||||
@@ -545,10 +286,6 @@ actor VoiceWakeRuntime {
|
||||
self.heardBeyondTrigger = !command.isEmpty
|
||||
self.triggerChimePlayed = false
|
||||
self.activeTriggerEndTime = triggerEndTime
|
||||
self.preDetectTask?.cancel()
|
||||
self.preDetectTask = nil
|
||||
self.triggerOnlyTask?.cancel()
|
||||
self.triggerOnlyTask = nil
|
||||
|
||||
if config.triggerChime != .none, !self.triggerChimePlayed {
|
||||
self.triggerChimePlayed = true
|
||||
@@ -621,12 +358,6 @@ actor VoiceWakeRuntime {
|
||||
self.heardBeyondTrigger = false
|
||||
self.triggerChimePlayed = false
|
||||
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() }
|
||||
if let token = self.overlayToken {
|
||||
@@ -710,15 +441,10 @@ actor VoiceWakeRuntime {
|
||||
let nanos = UInt64(max(0, delay) * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: nanos)
|
||||
guard let self else { return }
|
||||
await self.consumeScheduledRestart()
|
||||
await self.restartRecognizerIfIdleAndOverlayHidden()
|
||||
}
|
||||
}
|
||||
|
||||
private func consumeScheduledRestart() {
|
||||
self.scheduledRestartTask = nil
|
||||
}
|
||||
|
||||
func applyPushToTalkCooldown() {
|
||||
self.cooldownUntil = Date().addingTimeInterval(self.debounceAfterSend)
|
||||
}
|
||||
|
||||
@@ -2,24 +2,19 @@ import AppKit
|
||||
import AVFoundation
|
||||
import Observation
|
||||
import Speech
|
||||
import SwabbleKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct VoiceWakeSettings: View {
|
||||
@Bindable var state: AppState
|
||||
let isActive: Bool
|
||||
@State private var testState: VoiceWakeTestState = .idle
|
||||
@State private var tester = VoiceWakeTester()
|
||||
@State private var isTesting = false
|
||||
@State private var testTimeoutTask: Task<Void, Never>?
|
||||
@State private var availableMics: [AudioInputDevice] = []
|
||||
@State private var loadingMics = false
|
||||
@State private var meterLevel: Double = 0
|
||||
@State private var meterError: String?
|
||||
private let meter = MicLevelMonitor()
|
||||
@State private var micObserver = AudioInputDeviceObserver()
|
||||
@State private var micRefreshTask: Task<Void, Never>?
|
||||
@State private var availableLocales: [Locale] = []
|
||||
private let fieldLabelWidth: CGFloat = 140
|
||||
private let controlWidth: CGFloat = 240
|
||||
@@ -102,39 +97,12 @@ struct VoiceWakeSettings: View {
|
||||
guard !self.isPreview else { return }
|
||||
await self.restartMeter()
|
||||
}
|
||||
.onAppear {
|
||||
guard !self.isPreview else { return }
|
||||
self.startMicObserver()
|
||||
}
|
||||
.onChange(of: self.state.voiceWakeMicID) { _, _ in
|
||||
guard !self.isPreview else { return }
|
||||
self.updateSelectedMicName()
|
||||
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 {
|
||||
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() }
|
||||
}
|
||||
}
|
||||
@@ -237,23 +205,13 @@ struct VoiceWakeSettings: View {
|
||||
return
|
||||
}
|
||||
if self.isTesting {
|
||||
self.tester.finalize()
|
||||
self.tester.stop()
|
||||
self.isTesting = false
|
||||
self.testState = .finalizing
|
||||
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()
|
||||
self.testState = .idle
|
||||
return
|
||||
}
|
||||
|
||||
let triggers = self.sanitizedTriggers()
|
||||
self.tester.stop()
|
||||
self.testTimeoutTask?.cancel()
|
||||
self.isTesting = true
|
||||
self.testState = .requesting
|
||||
Task { @MainActor in
|
||||
@@ -267,31 +225,18 @@ struct VoiceWakeSettings: View {
|
||||
self.testState = newState
|
||||
if case .detected = 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()
|
||||
self.testTimeoutTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 10 * 1_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
if self.isTesting {
|
||||
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
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 10 * 1_000_000_000)
|
||||
if self.isTesting {
|
||||
self.tester.stop()
|
||||
self.testState = .failed("Timeout: no trigger heard")
|
||||
self.isTesting = false
|
||||
}
|
||||
} catch {
|
||||
self.tester.stop()
|
||||
self.testState = .failed(error.localizedDescription)
|
||||
self.isTesting = false
|
||||
self.testTimeoutTask?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -369,14 +314,6 @@ struct VoiceWakeSettings: View {
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
@@ -385,10 +322,6 @@ struct VoiceWakeSettings: View {
|
||||
.frame(width: self.fieldLabelWidth, alignment: .leading)
|
||||
Picker("Microphone", selection: self.$state.voiceWakeMicID) {
|
||||
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
|
||||
Text(mic.name).tag(mic.uid)
|
||||
}
|
||||
@@ -396,15 +329,6 @@ struct VoiceWakeSettings: View {
|
||||
.labelsHidden()
|
||||
.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 {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
@@ -497,60 +421,17 @@ struct VoiceWakeSettings: View {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadMicsIfNeeded(force: Bool = false) async {
|
||||
guard force || self.availableMics.isEmpty, !self.loadingMics else { return }
|
||||
private func loadMicsIfNeeded() async {
|
||||
guard self.availableMics.isEmpty, !self.loadingMics else { return }
|
||||
self.loadingMics = true
|
||||
let discovery = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: [.external, .microphone],
|
||||
mediaType: .audio,
|
||||
position: .unspecified)
|
||||
let aliveUIDs = AudioInputDeviceObserver.aliveInputDeviceUIDs()
|
||||
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.availableMics = discovery.devices.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) }
|
||||
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
|
||||
private func loadLocalesIfNeeded() async {
|
||||
guard self.availableLocales.isEmpty else { return }
|
||||
@@ -625,7 +506,7 @@ struct VoiceWakeSettings: View {
|
||||
#if DEBUG
|
||||
struct VoiceWakeSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VoiceWakeSettings(state: .preview, isActive: true)
|
||||
VoiceWakeSettings(state: .preview)
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
@@ -638,7 +519,7 @@ extension VoiceWakeSettings {
|
||||
state.voicePushToTalkEnabled = true
|
||||
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.availableLocales = [Locale(identifier: "en_US")]
|
||||
view.meterLevel = 0.42
|
||||
|
||||
@@ -57,9 +57,6 @@ struct VoiceWakeTestCard: View {
|
||||
.symbolEffect(.pulse)
|
||||
.foregroundStyle(Color.accentColor))
|
||||
|
||||
case .finalizing:
|
||||
AnyView(ProgressView().controlSize(.small))
|
||||
|
||||
case .detected:
|
||||
AnyView(Image(systemName: "checkmark.circle.fill").foregroundStyle(.green))
|
||||
|
||||
@@ -82,9 +79,6 @@ struct VoiceWakeTestCard: View {
|
||||
case let .hearing(text):
|
||||
"Heard: \(text)"
|
||||
|
||||
case .finalizing:
|
||||
"Finalizing…"
|
||||
|
||||
case .detected:
|
||||
"Voice wake detected!"
|
||||
|
||||
|
||||
@@ -8,26 +8,18 @@ enum VoiceWakeTestState: Equatable {
|
||||
case requesting
|
||||
case listening
|
||||
case hearing(String)
|
||||
case finalizing
|
||||
case detected(String)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
final class VoiceWakeTester {
|
||||
private let recognizer: SFSpeechRecognizer?
|
||||
private var audioEngine: AVAudioEngine?
|
||||
private let audioEngine = AVAudioEngine()
|
||||
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
|
||||
private var recognitionTask: SFSpeechRecognitionTask?
|
||||
private var isStopping = false
|
||||
private var isFinalizing = false
|
||||
private var detectionStart: Date?
|
||||
private var lastHeard: Date?
|
||||
private var lastLoggedText: String?
|
||||
private var lastLoggedAt: Date?
|
||||
private var lastTranscript: String?
|
||||
private var lastTranscriptAt: Date?
|
||||
private var silenceTask: Task<Void, Never>?
|
||||
private var currentTriggers: [String] = []
|
||||
private var holdingAfterDetect = false
|
||||
private var detectedText: String?
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "voicewake")
|
||||
@@ -45,17 +37,6 @@ final class VoiceWakeTester {
|
||||
{
|
||||
guard self.recognitionTask == nil else { return }
|
||||
self.isStopping = false
|
||||
self.isFinalizing = false
|
||||
self.holdingAfterDetect = false
|
||||
self.detectedText = nil
|
||||
self.lastHeard = nil
|
||||
self.lastLoggedText = nil
|
||||
self.lastLoggedAt = nil
|
||||
self.lastTranscript = nil
|
||||
self.lastTranscriptAt = nil
|
||||
self.silenceTask?.cancel()
|
||||
self.silenceTask = nil
|
||||
self.currentTriggers = triggers
|
||||
let chosenLocale = localeID.flatMap { Locale(identifier: $0) } ?? Locale.current
|
||||
let recognizer = SFSpeechRecognizer(locale: chosenLocale)
|
||||
guard let recognizer, recognizer.isAvailable else {
|
||||
@@ -64,7 +45,6 @@ final class VoiceWakeTester {
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Speech recognition unavailable"])
|
||||
}
|
||||
recognizer.defaultTaskHint = .dictation
|
||||
|
||||
guard Self.hasPrivacyStrings else {
|
||||
throw NSError(
|
||||
@@ -86,33 +66,21 @@ final class VoiceWakeTester {
|
||||
userInfo: [NSLocalizedDescriptionKey: "Microphone or speech permission denied"])
|
||||
}
|
||||
|
||||
self.logInputSelection(preferredMicID: micID)
|
||||
self.configureSession(preferredMicID: micID)
|
||||
|
||||
let engine = AVAudioEngine()
|
||||
self.audioEngine = engine
|
||||
|
||||
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||
self.recognitionRequest?.shouldReportPartialResults = true
|
||||
self.recognitionRequest?.taskHint = .dictation
|
||||
let request = self.recognitionRequest
|
||||
|
||||
let inputNode = engine.inputNode
|
||||
let inputNode = self.audioEngine.inputNode
|
||||
let format = inputNode.outputFormat(forBus: 0)
|
||||
guard format.channelCount > 0, format.sampleRate > 0 else {
|
||||
self.audioEngine = nil
|
||||
throw NSError(
|
||||
domain: "VoiceWakeTester",
|
||||
code: 4,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No audio input available"])
|
||||
}
|
||||
inputNode.removeTap(onBus: 0)
|
||||
inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request] buffer, _ in
|
||||
request?.append(buffer)
|
||||
}
|
||||
|
||||
engine.prepare()
|
||||
try engine.start()
|
||||
self.audioEngine.prepare()
|
||||
try self.audioEngine.start()
|
||||
DispatchQueue.main.async {
|
||||
onUpdate(.listening)
|
||||
}
|
||||
@@ -128,21 +96,9 @@ final class VoiceWakeTester {
|
||||
let segments = result.map { WakeWordSpeechSegments.from(
|
||||
transcription: $0.bestTranscription,
|
||||
transcript: text) } ?? []
|
||||
let isFinal = result?.isFinal ?? false
|
||||
let gateConfig = WakeWordGateConfig(triggers: triggers)
|
||||
var match = WakeWordGate.match(transcript: text, segments: segments, config: gateConfig)
|
||||
if match == nil, isFinal {
|
||||
match = self.textOnlyFallbackMatch(
|
||||
transcript: text,
|
||||
triggers: triggers,
|
||||
config: gateConfig)
|
||||
}
|
||||
self.maybeLogDebug(
|
||||
transcript: text,
|
||||
segments: segments,
|
||||
triggers: triggers,
|
||||
match: match,
|
||||
isFinal: isFinal)
|
||||
let match = WakeWordGate.match(transcript: text, segments: segments, config: gateConfig)
|
||||
let isFinal = result?.isFinal ?? false
|
||||
let errorMessage = error?.localizedDescription
|
||||
|
||||
Task { [weak self] in
|
||||
@@ -158,52 +114,13 @@ final class VoiceWakeTester {
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.stop(force: true)
|
||||
}
|
||||
|
||||
func finalize(timeout: TimeInterval = 1.5) {
|
||||
guard self.recognitionTask != nil else {
|
||||
self.stop(force: true)
|
||||
return
|
||||
}
|
||||
self.isFinalizing = true
|
||||
self.recognitionRequest?.endAudio()
|
||||
if let engine = self.audioEngine {
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
}
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
|
||||
if !self.isStopping {
|
||||
self.stop(force: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stop(force: Bool) {
|
||||
if force { self.isStopping = true }
|
||||
self.isFinalizing = false
|
||||
self.isStopping = true
|
||||
self.audioEngine.stop()
|
||||
self.recognitionRequest?.endAudio()
|
||||
self.recognitionTask?.cancel()
|
||||
self.recognitionTask = nil
|
||||
self.recognitionRequest = nil
|
||||
if let engine = self.audioEngine {
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
}
|
||||
self.audioEngine = nil
|
||||
self.holdingAfterDetect = false
|
||||
self.detectedText = nil
|
||||
self.lastHeard = nil
|
||||
self.detectionStart = nil
|
||||
self.lastLoggedText = nil
|
||||
self.lastLoggedAt = nil
|
||||
self.lastTranscript = nil
|
||||
self.lastTranscriptAt = nil
|
||||
self.silenceTask?.cancel()
|
||||
self.silenceTask = nil
|
||||
self.currentTriggers = []
|
||||
self.audioEngine.inputNode.removeTap(onBus: 0)
|
||||
}
|
||||
|
||||
private func handleResult(
|
||||
@@ -215,39 +132,26 @@ final class VoiceWakeTester {
|
||||
{
|
||||
if !text.isEmpty {
|
||||
self.lastHeard = Date()
|
||||
self.lastTranscript = text
|
||||
self.lastTranscriptAt = Date()
|
||||
}
|
||||
if self.holdingAfterDetect {
|
||||
return
|
||||
}
|
||||
if let match, !match.command.isEmpty {
|
||||
self.holdingAfterDetect = true
|
||||
self.detectedText = match.command
|
||||
self.logger.info("voice wake detected (test) (len=\(match.command.count))")
|
||||
self.logger.info("voice wake detected; forwarding (len=\(match.command.count))")
|
||||
await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) }
|
||||
self.stop()
|
||||
await MainActor.run {
|
||||
AppStateStore.shared.stopVoiceEars()
|
||||
onUpdate(.detected(match.command))
|
||||
Task.detached {
|
||||
await VoiceWakeForwarder.forward(transcript: match.command)
|
||||
}
|
||||
Task { @MainActor in onUpdate(.detected(match.command)) }
|
||||
self.holdUntilSilence(onUpdate: onUpdate)
|
||||
return
|
||||
}
|
||||
if !isFinal, !text.isEmpty {
|
||||
self.scheduleSilenceCheck(
|
||||
triggers: self.currentTriggers,
|
||||
onUpdate: onUpdate)
|
||||
}
|
||||
if self.isFinalizing {
|
||||
Task { @MainActor in onUpdate(.finalizing) }
|
||||
}
|
||||
if let errorMessage {
|
||||
self.stop(force: true)
|
||||
self.stop()
|
||||
Task { @MainActor in onUpdate(.failed(errorMessage)) }
|
||||
return
|
||||
}
|
||||
if isFinal {
|
||||
self.stop(force: true)
|
||||
self.stop()
|
||||
let state: VoiceWakeTestState = text.isEmpty
|
||||
? .failed("No speech detected")
|
||||
: .failed("No trigger heard: “\(text)”")
|
||||
@@ -258,116 +162,6 @@ final class VoiceWakeTester {
|
||||
}
|
||||
}
|
||||
|
||||
private func maybeLogDebug(
|
||||
transcript: String,
|
||||
segments: [WakeWordSegment],
|
||||
triggers: [String],
|
||||
match: WakeWordGateMatch?,
|
||||
isFinal: 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 gaps = Self.debugCandidateGaps(triggers: triggers, segments: segments)
|
||||
let segmentSummary = Self.debugSegments(segments)
|
||||
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"
|
||||
|
||||
self.logger.debug(
|
||||
"voicewake test transcript='\(transcript, privacy: .private)' textOnly=\(textOnly) " +
|
||||
"isFinal=\(isFinal) timing=\(timingCount)/\(segments.count) " +
|
||||
"\(matchSummary) gaps=[\(gaps, privacy: .private)] segments=[\(segmentSummary, privacy: .private)]")
|
||||
}
|
||||
|
||||
private static func debugSegments(_ segments: [WakeWordSegment]) -> String {
|
||||
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: ", ")
|
||||
}
|
||||
|
||||
private static func debugCandidateGaps(triggers: [String], segments: [WakeWordSegment]) -> String {
|
||||
let tokens = self.normalizeSegments(segments)
|
||||
guard !tokens.isEmpty else { return "" }
|
||||
let triggerTokens = self.normalizeTriggers(triggers)
|
||||
var gaps: [String] = []
|
||||
|
||||
for trigger in triggerTokens {
|
||||
let count = trigger.tokens.count
|
||||
guard count > 0, tokens.count > count else { continue }
|
||||
for i in 0...(tokens.count - count - 1) {
|
||||
let matched = (0..<count).allSatisfy { tokens[i + $0].normalized == trigger.tokens[$0] }
|
||||
if !matched { continue }
|
||||
let triggerEnd = tokens[i + count - 1].end
|
||||
let nextToken = tokens[i + count]
|
||||
let gap = nextToken.start - triggerEnd
|
||||
let formatted = String(format: "%.2f", gap)
|
||||
gaps.append("\(trigger.tokens.joined(separator: " ")):\(formatted)s")
|
||||
}
|
||||
}
|
||||
return gaps.joined(separator: ", ")
|
||||
}
|
||||
|
||||
private struct DebugToken {
|
||||
let normalized: String
|
||||
let start: TimeInterval
|
||||
let end: TimeInterval
|
||||
}
|
||||
|
||||
private struct DebugTriggerTokens {
|
||||
let tokens: [String]
|
||||
}
|
||||
|
||||
private static func normalizeTriggers(_ triggers: [String]) -> [DebugTriggerTokens] {
|
||||
var output: [DebugTriggerTokens] = []
|
||||
for trigger in triggers {
|
||||
let tokens = trigger
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
.map { VoiceWakeTextUtils.normalizeToken(String($0)) }
|
||||
.filter { !$0.isEmpty }
|
||||
if tokens.isEmpty { continue }
|
||||
output.append(DebugTriggerTokens(tokens: tokens))
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [DebugToken] {
|
||||
segments.compactMap { segment in
|
||||
let normalized = VoiceWakeTextUtils.normalizeToken(segment.text)
|
||||
guard !normalized.isEmpty else { return nil }
|
||||
return DebugToken(
|
||||
normalized: normalized,
|
||||
start: segment.start,
|
||||
end: segment.end)
|
||||
}
|
||||
}
|
||||
|
||||
private func textOnlyFallbackMatch(
|
||||
transcript: String,
|
||||
triggers: [String],
|
||||
config: WakeWordGateConfig) -> WakeWordGateMatch?
|
||||
{
|
||||
guard let command = VoiceWakeTextUtils.textOnlyCommand(
|
||||
transcript: transcript,
|
||||
triggers: triggers,
|
||||
minCommandLength: config.minCommandLength,
|
||||
trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) })
|
||||
else { return nil }
|
||||
return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
|
||||
}
|
||||
|
||||
private func holdUntilSilence(onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) {
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -393,47 +187,10 @@ final class VoiceWakeTester {
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleSilenceCheck(
|
||||
triggers: [String],
|
||||
onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void)
|
||||
{
|
||||
self.silenceTask?.cancel()
|
||||
let lastSeenAt = self.lastTranscriptAt
|
||||
let lastText = self.lastTranscript
|
||||
self.silenceTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
try? await Task.sleep(nanoseconds: UInt64(self.silenceWindow * 1_000_000_000))
|
||||
guard !Task.isCancelled else { return }
|
||||
guard !self.isStopping, !self.holdingAfterDetect 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: WakeWordGateConfig(triggers: triggers)) else { return }
|
||||
self.holdingAfterDetect = true
|
||||
self.detectedText = match.command
|
||||
self.logger.info("voice wake detected (test, silence) (len=\(match.command.count))")
|
||||
await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) }
|
||||
self.stop()
|
||||
await MainActor.run {
|
||||
AppStateStore.shared.stopVoiceEars()
|
||||
onUpdate(.detected(match.command))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configureSession(preferredMicID: String?) {
|
||||
_ = preferredMicID
|
||||
}
|
||||
|
||||
private func logInputSelection(preferredMicID: String?) {
|
||||
let preferred = (preferredMicID?.isEmpty == false) ? preferredMicID! : "system-default"
|
||||
self.logger.info(
|
||||
"voicewake test input preferred=\(preferred, privacy: .public) " +
|
||||
"\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public)")
|
||||
}
|
||||
|
||||
private nonisolated static func ensurePermissions() async throws -> Bool {
|
||||
let speechStatus = SFSpeechRecognizer.authorizationStatus()
|
||||
if speechStatus == .notDetermined {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import Foundation
|
||||
import SwabbleKit
|
||||
|
||||
enum VoiceWakeTextUtils {
|
||||
private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines
|
||||
.union(.punctuationCharacters)
|
||||
typealias TrimWake = (String, [String]) -> String
|
||||
|
||||
static func normalizeToken(_ token: String) -> String {
|
||||
token
|
||||
.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||
.lowercased()
|
||||
}
|
||||
|
||||
static func startsWithTrigger(transcript: String, triggers: [String]) -> Bool {
|
||||
let tokens = transcript
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
.map { self.normalizeToken(String($0)) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard !tokens.isEmpty else { return false }
|
||||
for trigger in triggers {
|
||||
let triggerTokens = trigger
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
.map { self.normalizeToken(String($0)) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard !triggerTokens.isEmpty, tokens.count >= triggerTokens.count else { continue }
|
||||
if zip(triggerTokens, tokens.prefix(triggerTokens.count)).allSatisfy({ $0 == $1 }) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static func textOnlyCommand(
|
||||
transcript: String,
|
||||
triggers: [String],
|
||||
minCommandLength: Int,
|
||||
trimWake: TrimWake) -> String?
|
||||
{
|
||||
guard !transcript.isEmpty else { return nil }
|
||||
guard !self.normalizeToken(transcript).isEmpty else { return nil }
|
||||
guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return nil }
|
||||
guard self.startsWithTrigger(transcript: transcript, triggers: triggers) else { return nil }
|
||||
let trimmed = trimWake(transcript, triggers)
|
||||
guard trimmed.count >= minCommandLength else { return nil }
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
@@ -54,7 +53,7 @@ final class WorkActivityStore {
|
||||
phase: String,
|
||||
name: String?,
|
||||
meta: String?,
|
||||
args: [String: ClawdbotProtocol.AnyCodable]?)
|
||||
args: [String: AnyCodable]?)
|
||||
{
|
||||
let toolKind = Self.mapToolKind(name)
|
||||
let label = Self.buildLabel(name: name, meta: meta, args: args)
|
||||
@@ -212,7 +211,7 @@ final class WorkActivityStore {
|
||||
private static func buildLabel(
|
||||
name: String?,
|
||||
meta: String?,
|
||||
args: [String: ClawdbotProtocol.AnyCodable]?) -> String
|
||||
args: [String: AnyCodable]?) -> String
|
||||
{
|
||||
let wrappedArgs = self.wrapToolArgs(args)
|
||||
let display = ToolDisplayRegistry.resolve(name: name ?? "tool", args: wrappedArgs, meta: meta)
|
||||
@@ -222,17 +221,17 @@ final class WorkActivityStore {
|
||||
return display.label
|
||||
}
|
||||
|
||||
private static func wrapToolArgs(_ args: [String: ClawdbotProtocol.AnyCodable]?) -> ClawdbotKit.AnyCodable? {
|
||||
private static func wrapToolArgs(_ args: [String: AnyCodable]?) -> ClawdbotKit.AnyCodable? {
|
||||
guard let args else { return nil }
|
||||
let converted: [String: Any] = args.mapValues { self.unwrapJSONValue($0.value) }
|
||||
return ClawdbotKit.AnyCodable(converted)
|
||||
}
|
||||
|
||||
private static func unwrapJSONValue(_ value: Any) -> Any {
|
||||
if let dict = value as? [String: ClawdbotProtocol.AnyCodable] {
|
||||
if let dict = value as? [String: AnyCodable] {
|
||||
return dict.mapValues { self.unwrapJSONValue($0.value) }
|
||||
}
|
||||
if let array = value as? [ClawdbotProtocol.AnyCodable] {
|
||||
if let array = value as? [AnyCodable] {
|
||||
return array.map { self.unwrapJSONValue($0.value) }
|
||||
}
|
||||
if let dict = value as? [String: Any] {
|
||||
|
||||
@@ -342,7 +342,6 @@ public struct SendParams: Codable, Sendable {
|
||||
public let mediaurl: String?
|
||||
public let gifplayback: Bool?
|
||||
public let provider: String?
|
||||
public let accountid: String?
|
||||
public let idempotencykey: String
|
||||
|
||||
public init(
|
||||
@@ -351,7 +350,6 @@ public struct SendParams: Codable, Sendable {
|
||||
mediaurl: String?,
|
||||
gifplayback: Bool?,
|
||||
provider: String?,
|
||||
accountid: String?,
|
||||
idempotencykey: String
|
||||
) {
|
||||
self.to = to
|
||||
@@ -359,7 +357,6 @@ public struct SendParams: Codable, Sendable {
|
||||
self.mediaurl = mediaurl
|
||||
self.gifplayback = gifplayback
|
||||
self.provider = provider
|
||||
self.accountid = accountid
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -368,7 +365,6 @@ public struct SendParams: Codable, Sendable {
|
||||
case mediaurl = "mediaUrl"
|
||||
case gifplayback = "gifPlayback"
|
||||
case provider
|
||||
case accountid = "accountId"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
@@ -380,7 +376,6 @@ public struct PollParams: Codable, Sendable {
|
||||
public let maxselections: Int?
|
||||
public let durationhours: Int?
|
||||
public let provider: String?
|
||||
public let accountid: String?
|
||||
public let idempotencykey: String
|
||||
|
||||
public init(
|
||||
@@ -390,7 +385,6 @@ public struct PollParams: Codable, Sendable {
|
||||
maxselections: Int?,
|
||||
durationhours: Int?,
|
||||
provider: String?,
|
||||
accountid: String?,
|
||||
idempotencykey: String
|
||||
) {
|
||||
self.to = to
|
||||
@@ -399,7 +393,6 @@ public struct PollParams: Codable, Sendable {
|
||||
self.maxselections = maxselections
|
||||
self.durationhours = durationhours
|
||||
self.provider = provider
|
||||
self.accountid = accountid
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -409,7 +402,6 @@ public struct PollParams: Codable, Sendable {
|
||||
case maxselections = "maxSelections"
|
||||
case durationhours = "durationHours"
|
||||
case provider
|
||||
case accountid = "accountId"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
@@ -421,7 +413,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let sessionkey: String?
|
||||
public let thinking: String?
|
||||
public let deliver: Bool?
|
||||
public let provider: String?
|
||||
public let channel: String?
|
||||
public let timeout: Int?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
@@ -434,7 +426,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
sessionkey: String?,
|
||||
thinking: String?,
|
||||
deliver: Bool?,
|
||||
provider: String?,
|
||||
channel: String?,
|
||||
timeout: Int?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
@@ -446,7 +438,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.sessionkey = sessionkey
|
||||
self.thinking = thinking
|
||||
self.deliver = deliver
|
||||
self.provider = provider
|
||||
self.channel = channel
|
||||
self.timeout = timeout
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
@@ -459,7 +451,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
case sessionkey = "sessionKey"
|
||||
case thinking
|
||||
case deliver
|
||||
case provider
|
||||
case channel
|
||||
case timeout
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
@@ -691,7 +683,6 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let thinkinglevel: AnyCodable?
|
||||
public let verboselevel: AnyCodable?
|
||||
public let reasoninglevel: AnyCodable?
|
||||
public let elevatedlevel: AnyCodable?
|
||||
public let model: AnyCodable?
|
||||
public let spawnedby: AnyCodable?
|
||||
@@ -702,7 +693,6 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
key: String,
|
||||
thinkinglevel: AnyCodable?,
|
||||
verboselevel: AnyCodable?,
|
||||
reasoninglevel: AnyCodable?,
|
||||
elevatedlevel: AnyCodable?,
|
||||
model: AnyCodable?,
|
||||
spawnedby: AnyCodable?,
|
||||
@@ -712,7 +702,6 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
self.key = key
|
||||
self.thinkinglevel = thinkinglevel
|
||||
self.verboselevel = verboselevel
|
||||
self.reasoninglevel = reasoninglevel
|
||||
self.elevatedlevel = elevatedlevel
|
||||
self.model = model
|
||||
self.spawnedby = spawnedby
|
||||
@@ -723,7 +712,6 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
case key
|
||||
case thinkinglevel = "thinkingLevel"
|
||||
case verboselevel = "verboseLevel"
|
||||
case reasoninglevel = "reasoningLevel"
|
||||
case elevatedlevel = "elevatedLevel"
|
||||
case model
|
||||
case spawnedby = "spawnedBy"
|
||||
@@ -795,31 +783,6 @@ public struct ConfigSetParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConfigApplyParams: Codable, Sendable {
|
||||
public let raw: String
|
||||
public let sessionkey: String?
|
||||
public let note: String?
|
||||
public let restartdelayms: Int?
|
||||
|
||||
public init(
|
||||
raw: String,
|
||||
sessionkey: String?,
|
||||
note: String?,
|
||||
restartdelayms: Int?
|
||||
) {
|
||||
self.raw = raw
|
||||
self.sessionkey = sessionkey
|
||||
self.note = note
|
||||
self.restartdelayms = restartdelayms
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case raw
|
||||
case sessionkey = "sessionKey"
|
||||
case note
|
||||
case restartdelayms = "restartDelayMs"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConfigSchemaParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
@@ -1062,41 +1025,33 @@ public struct WebLoginStartParams: Codable, Sendable {
|
||||
public let force: Bool?
|
||||
public let timeoutms: Int?
|
||||
public let verbose: Bool?
|
||||
public let accountid: String?
|
||||
|
||||
public init(
|
||||
force: Bool?,
|
||||
timeoutms: Int?,
|
||||
verbose: Bool?,
|
||||
accountid: String?
|
||||
verbose: Bool?
|
||||
) {
|
||||
self.force = force
|
||||
self.timeoutms = timeoutms
|
||||
self.verbose = verbose
|
||||
self.accountid = accountid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case force
|
||||
case timeoutms = "timeoutMs"
|
||||
case verbose
|
||||
case accountid = "accountId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct WebLoginWaitParams: Codable, Sendable {
|
||||
public let timeoutms: Int?
|
||||
public let accountid: String?
|
||||
|
||||
public init(
|
||||
timeoutms: Int?,
|
||||
accountid: String?
|
||||
timeoutms: Int?
|
||||
) {
|
||||
self.timeoutms = timeoutms
|
||||
self.accountid = accountid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case timeoutms = "timeoutMs"
|
||||
case accountid = "accountId"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1417,60 +1372,6 @@ public struct CronRunLogEntry: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct LogsTailParams: Codable, Sendable {
|
||||
public let cursor: Int?
|
||||
public let limit: Int?
|
||||
public let maxbytes: Int?
|
||||
|
||||
public init(
|
||||
cursor: Int?,
|
||||
limit: Int?,
|
||||
maxbytes: Int?
|
||||
) {
|
||||
self.cursor = cursor
|
||||
self.limit = limit
|
||||
self.maxbytes = maxbytes
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case cursor
|
||||
case limit
|
||||
case maxbytes = "maxBytes"
|
||||
}
|
||||
}
|
||||
|
||||
public struct LogsTailResult: Codable, Sendable {
|
||||
public let file: String
|
||||
public let cursor: Int
|
||||
public let size: Int
|
||||
public let lines: [String]
|
||||
public let truncated: Bool?
|
||||
public let reset: Bool?
|
||||
|
||||
public init(
|
||||
file: String,
|
||||
cursor: Int,
|
||||
size: Int,
|
||||
lines: [String],
|
||||
truncated: Bool?,
|
||||
reset: Bool?
|
||||
) {
|
||||
self.file = file
|
||||
self.cursor = cursor
|
||||
self.size = size
|
||||
self.lines = lines
|
||||
self.truncated = truncated
|
||||
self.reset = reset
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case file
|
||||
case cursor
|
||||
case size
|
||||
case lines
|
||||
case truncated
|
||||
case reset
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatHistoryParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let limit: Int?
|
||||
@@ -1583,31 +1484,6 @@ public struct ChatEvent: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct UpdateRunParams: Codable, Sendable {
|
||||
public let sessionkey: String?
|
||||
public let note: String?
|
||||
public let restartdelayms: Int?
|
||||
public let timeoutms: Int?
|
||||
|
||||
public init(
|
||||
sessionkey: String?,
|
||||
note: String?,
|
||||
restartdelayms: Int?,
|
||||
timeoutms: Int?
|
||||
) {
|
||||
self.sessionkey = sessionkey
|
||||
self.note = note
|
||||
self.restartdelayms = restartdelayms
|
||||
self.timeoutms = timeoutms
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case note
|
||||
case restartdelayms = "restartDelayMs"
|
||||
case timeoutms = "timeoutMs"
|
||||
}
|
||||
}
|
||||
|
||||
public struct TickEvent: Codable, Sendable {
|
||||
public let ts: Int
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
@@ -16,7 +15,7 @@ struct AgentEventStoreTests {
|
||||
seq: 1,
|
||||
stream: "test",
|
||||
ts: 0,
|
||||
data: [:] as [String: ClawdbotProtocol.AnyCodable],
|
||||
data: [:] as [String: AnyCodable],
|
||||
summary: nil))
|
||||
#expect(store.events.count == 1)
|
||||
|
||||
@@ -33,7 +32,7 @@ struct AgentEventStoreTests {
|
||||
seq: i,
|
||||
stream: "test",
|
||||
ts: Double(i),
|
||||
data: [:] as [String: ClawdbotProtocol.AnyCodable],
|
||||
data: [:] as [String: AnyCodable],
|
||||
summary: nil))
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ struct AgentWorkspaceTests {
|
||||
case .unsafe:
|
||||
break
|
||||
case .safe:
|
||||
#expect(Bool(false), "Expected unsafe bootstrap safety result.")
|
||||
#expect(false, "Expected unsafe bootstrap safety result.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ struct AgentWorkspaceTests {
|
||||
case .safe:
|
||||
break
|
||||
case .unsafe:
|
||||
#expect(Bool(false), "Expected safe bootstrap safety result.")
|
||||
#expect(false, "Expected safe bootstrap safety result.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,3 +29,4 @@ struct AnthropicOAuthCodeStateTests {
|
||||
#expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import Testing
|
||||
"null": NSNull(),
|
||||
]
|
||||
|
||||
let data = try JSONEncoder().encode(ClawdbotProtocol.AnyCodable(payload))
|
||||
let data = try JSONEncoder().encode(Clawdbot.AnyCodable(payload))
|
||||
let obj = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
|
||||
#expect(obj["tags"] as? [String] == ["node", "ios"])
|
||||
|
||||
@@ -19,3 +19,4 @@ import Testing
|
||||
#expect(high.quality == 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,3 +59,4 @@ import Testing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ import Testing
|
||||
@MainActor
|
||||
struct CanvasWindowSmokeTests {
|
||||
@Test func panelControllerShowsAndHides() async throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)")
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
@@ -31,8 +30,7 @@ struct CanvasWindowSmokeTests {
|
||||
}
|
||||
|
||||
@Test func windowControllerShowsAndCloses() async throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)")
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
|
||||
@@ -2,78 +2,48 @@ import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite(.serialized)
|
||||
@Suite
|
||||
struct ClawdbotConfigFileTests {
|
||||
@Test
|
||||
func configPathRespectsEnvOverride() async {
|
||||
func configPathRespectsEnvOverride() {
|
||||
let override = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||
.appendingPathComponent("clawdbot.json")
|
||||
.path
|
||||
|
||||
await TestIsolation.withEnvValues(["CLAWDBOT_CONFIG_PATH": override]) {
|
||||
self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) {
|
||||
#expect(ClawdbotConfigFile.url().path == override)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func remoteGatewayPortParsesAndMatchesHost() async {
|
||||
let override = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||
.appendingPathComponent("clawdbot.json")
|
||||
.path
|
||||
|
||||
await TestIsolation.withEnvValues(["CLAWDBOT_CONFIG_PATH": override]) {
|
||||
ClawdbotConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": "ws://bridge.ts.net:19999",
|
||||
],
|
||||
],
|
||||
])
|
||||
#expect(ClawdbotConfigFile.remoteGatewayPort() == 19999)
|
||||
#expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "bridge.ts.net") == 19999)
|
||||
#expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "bridge") == 19999)
|
||||
#expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "other.ts.net") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func setRemoteGatewayUrlPreservesScheme() async {
|
||||
let override = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||
.appendingPathComponent("clawdbot.json")
|
||||
.path
|
||||
|
||||
await TestIsolation.withEnvValues(["CLAWDBOT_CONFIG_PATH": override]) {
|
||||
ClawdbotConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": "wss://old-host:111",
|
||||
],
|
||||
],
|
||||
])
|
||||
ClawdbotConfigFile.setRemoteGatewayUrl(host: "new-host", port: 2222)
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
let url = ((root["gateway"] as? [String: Any])?["remote"] as? [String: Any])?["url"] as? String
|
||||
#expect(url == "wss://new-host:2222")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func stateDirOverrideSetsConfigPath() async {
|
||||
func stateDirOverrideSetsConfigPath() {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-state-\(UUID().uuidString)", isDirectory: true)
|
||||
.path
|
||||
|
||||
await TestIsolation.withEnvValues([
|
||||
"CLAWDBOT_CONFIG_PATH": nil,
|
||||
"CLAWDBOT_STATE_DIR": dir,
|
||||
]) {
|
||||
#expect(ClawdbotConfigFile.stateDirURL().path == dir)
|
||||
#expect(ClawdbotConfigFile.url().path == "\(dir)/clawdbot.json")
|
||||
self.withEnv("CLAWDBOT_CONFIG_PATH", value: nil) {
|
||||
self.withEnv("CLAWDBOT_STATE_DIR", value: dir) {
|
||||
#expect(ClawdbotConfigFile.stateDirURL().path == dir)
|
||||
#expect(ClawdbotConfigFile.url().path == "\(dir)/clawdbot.json")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func withEnv(_ key: String, value: String?, _ body: () -> Void) {
|
||||
let previous = ProcessInfo.processInfo.environment[key]
|
||||
if let value {
|
||||
setenv(key, value, 1)
|
||||
} else {
|
||||
unsetenv(key)
|
||||
}
|
||||
defer {
|
||||
if let previous {
|
||||
setenv(key, previous, 1)
|
||||
} else {
|
||||
unsetenv(key)
|
||||
}
|
||||
}
|
||||
body()
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@ struct ConfigStoreTests {
|
||||
await ConfigStore._testSetOverrides(.init(
|
||||
isRemoteMode: { true },
|
||||
loadLocal: { localHit = true; return ["local": true] },
|
||||
loadRemote: { remoteHit = true; return ["remote": true] }))
|
||||
loadRemote: { remoteHit = true; return ["remote": true] }
|
||||
))
|
||||
|
||||
let result = await ConfigStore.load()
|
||||
|
||||
@@ -26,7 +27,8 @@ struct ConfigStoreTests {
|
||||
await ConfigStore._testSetOverrides(.init(
|
||||
isRemoteMode: { false },
|
||||
loadLocal: { localHit = true; return ["local": true] },
|
||||
loadRemote: { remoteHit = true; return ["remote": true] }))
|
||||
loadRemote: { remoteHit = true; return ["remote": true] }
|
||||
))
|
||||
|
||||
let result = await ConfigStore.load()
|
||||
|
||||
@@ -42,7 +44,8 @@ struct ConfigStoreTests {
|
||||
await ConfigStore._testSetOverrides(.init(
|
||||
isRemoteMode: { true },
|
||||
saveLocal: { _ in localHit = true },
|
||||
saveRemote: { _ in remoteHit = true }))
|
||||
saveRemote: { _ in remoteHit = true }
|
||||
))
|
||||
|
||||
try await ConfigStore.save(["remote": true])
|
||||
|
||||
@@ -57,7 +60,8 @@ struct ConfigStoreTests {
|
||||
await ConfigStore._testSetOverrides(.init(
|
||||
isRemoteMode: { false },
|
||||
saveLocal: { _ in localHit = true },
|
||||
saveRemote: { _ in remoteHit = true }))
|
||||
saveRemote: { _ in remoteHit = true }
|
||||
))
|
||||
|
||||
try await ConfigStore.save(["local": true])
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ struct CronJobEditorSmokeTests {
|
||||
thinking: "low",
|
||||
timeoutSeconds: 120,
|
||||
deliver: true,
|
||||
provider: "whatsapp",
|
||||
channel: "whatsapp",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
||||
|
||||
@@ -12,7 +12,7 @@ struct CronModelsTests {
|
||||
}
|
||||
|
||||
@Test func scheduleEveryEncodesAndDecodesWithAnchor() throws {
|
||||
let schedule = CronSchedule.every(everyMs: 5000, anchorMs: 10000)
|
||||
let schedule = CronSchedule.every(everyMs: 5_000, anchorMs: 10_000)
|
||||
let data = try JSONEncoder().encode(schedule)
|
||||
let decoded = try JSONDecoder().decode(CronSchedule.self, from: data)
|
||||
#expect(decoded == schedule)
|
||||
@@ -31,7 +31,7 @@ struct CronModelsTests {
|
||||
thinking: "low",
|
||||
timeoutSeconds: 15,
|
||||
deliver: true,
|
||||
provider: "whatsapp",
|
||||
channel: "whatsapp",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: false)
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
|
||||
@@ -5,10 +5,8 @@ import Testing
|
||||
struct DeviceModelCatalogTests {
|
||||
@Test
|
||||
func symbolPrefersModelIdentifierPrefixes() {
|
||||
#expect(DeviceModelCatalog
|
||||
.symbol(deviceFamily: "iPad", modelIdentifier: "iPad16,6", friendlyName: nil) == "ipad")
|
||||
#expect(DeviceModelCatalog
|
||||
.symbol(deviceFamily: "iPhone", modelIdentifier: "iPhone17,3", friendlyName: nil) == "iphone")
|
||||
#expect(DeviceModelCatalog.symbol(deviceFamily: "iPad", modelIdentifier: "iPad16,6", friendlyName: nil) == "ipad")
|
||||
#expect(DeviceModelCatalog.symbol(deviceFamily: "iPhone", modelIdentifier: "iPhone17,3", friendlyName: nil) == "iphone")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -21,3 +21,4 @@ import Testing
|
||||
#expect(GatewayAgentChannel(raw: "unknown") == .last)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,3 +29,4 @@ struct GatewayAutostartPolicyTests {
|
||||
attachExistingOnly: false))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ import Testing
|
||||
let url = URL(string: "ws://example.invalid")!
|
||||
let cfg = ConfigSource(token: nil)
|
||||
let conn = GatewayConnection(
|
||||
configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
|
||||
configProvider: { (url, cfg.snapshotToken()) },
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
|
||||
_ = try await conn.request(method: "status", params: nil)
|
||||
@@ -186,7 +186,7 @@ import Testing
|
||||
let url = URL(string: "ws://example.invalid")!
|
||||
let cfg = ConfigSource(token: "a")
|
||||
let conn = GatewayConnection(
|
||||
configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
|
||||
configProvider: { (url, cfg.snapshotToken()) },
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
|
||||
_ = try await conn.request(method: "status", params: nil)
|
||||
@@ -203,7 +203,7 @@ import Testing
|
||||
let url = URL(string: "ws://example.invalid")!
|
||||
let cfg = ConfigSource(token: nil)
|
||||
let conn = GatewayConnection(
|
||||
configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
|
||||
configProvider: { (url, cfg.snapshotToken()) },
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
|
||||
async let r1: Data = conn.request(method: "status", params: nil)
|
||||
@@ -218,7 +218,7 @@ import Testing
|
||||
let url = URL(string: "ws://example.invalid")!
|
||||
let cfg = ConfigSource(token: nil)
|
||||
let conn = GatewayConnection(
|
||||
configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
|
||||
configProvider: { (url, cfg.snapshotToken()) },
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
|
||||
_ = try await conn.request(method: "status", params: nil)
|
||||
@@ -239,7 +239,7 @@ import Testing
|
||||
let url = URL(string: "ws://example.invalid")!
|
||||
let cfg = ConfigSource(token: nil)
|
||||
let conn = GatewayConnection(
|
||||
configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
|
||||
configProvider: { (url, cfg.snapshotToken()) },
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
|
||||
let stream = await conn.subscribe(bufferingNewest: 10)
|
||||
|
||||
@@ -69,13 +69,11 @@ struct GatewayDiscoveryModelTests {
|
||||
"lanHost": " studio.local ",
|
||||
"tailnetDns": " peters-mac-studio-1.ts.net ",
|
||||
"sshPort": " 2222 ",
|
||||
"gatewayPort": " 18799 ",
|
||||
"cliPath": " /opt/clawdbot ",
|
||||
"cliPath": " /opt/clawdbot "
|
||||
])
|
||||
#expect(parsed.lanHost == "studio.local")
|
||||
#expect(parsed.tailnetDns == "peters-mac-studio-1.ts.net")
|
||||
#expect(parsed.sshPort == 2222)
|
||||
#expect(parsed.gatewayPort == 18799)
|
||||
#expect(parsed.cliPath == "/opt/clawdbot")
|
||||
}
|
||||
|
||||
@@ -83,13 +81,11 @@ struct GatewayDiscoveryModelTests {
|
||||
let parsed = GatewayDiscoveryModel.parseGatewayTXT([
|
||||
"lanHost": " ",
|
||||
"tailnetDns": "\n",
|
||||
"gatewayPort": "nope",
|
||||
"sshPort": "nope",
|
||||
"sshPort": "nope"
|
||||
])
|
||||
#expect(parsed.lanHost == nil)
|
||||
#expect(parsed.tailnetDns == nil)
|
||||
#expect(parsed.sshPort == 22)
|
||||
#expect(parsed.gatewayPort == nil)
|
||||
#expect(parsed.cliPath == nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,19 +19,13 @@ import Testing
|
||||
#expect(Semver(major: 1, minor: 9, patch: 9).compatible(with: required) == false)
|
||||
}
|
||||
|
||||
@Test func gatewayPortDefaultsAndRespectsOverride() async {
|
||||
let configPath = TestIsolation.tempConfigPath()
|
||||
await TestIsolation.withIsolatedState(
|
||||
env: ["CLAWDBOT_CONFIG_PATH": configPath],
|
||||
defaults: ["gatewayPort": nil])
|
||||
{
|
||||
let defaultPort = GatewayEnvironment.gatewayPort()
|
||||
#expect(defaultPort == 18789)
|
||||
@Test func gatewayPortDefaultsAndRespectsOverride() {
|
||||
let defaultPort = GatewayEnvironment.gatewayPort()
|
||||
#expect(defaultPort == 18789)
|
||||
|
||||
UserDefaults.standard.set(19999, forKey: "gatewayPort")
|
||||
defer { UserDefaults.standard.removeObject(forKey: "gatewayPort") }
|
||||
#expect(GatewayEnvironment.gatewayPort() == 19999)
|
||||
}
|
||||
UserDefaults.standard.set(19999, forKey: "gatewayPort")
|
||||
defer { UserDefaults.standard.removeObject(forKey: "gatewayPort") }
|
||||
#expect(GatewayEnvironment.gatewayPort() == 19999)
|
||||
}
|
||||
|
||||
@Test func expectedGatewayVersionFromStringUsesParser() {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite struct GatewayLaunchAgentManagerTests {
|
||||
@Test func launchAgentPlistSnapshotParsesArgsAndEnv() throws {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
||||
let plist: [String: Any] = [
|
||||
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"],
|
||||
"EnvironmentVariables": [
|
||||
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
|
||||
"CLAWDBOT_GATEWAY_PASSWORD": "pw",
|
||||
],
|
||||
]
|
||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
|
||||
#expect(snapshot.port == 18789)
|
||||
#expect(snapshot.bind == "loopback")
|
||||
#expect(snapshot.token == "secret")
|
||||
#expect(snapshot.password == "pw")
|
||||
}
|
||||
|
||||
@Test func launchAgentPlistSnapshotAllowsMissingBind() throws {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
||||
let plist: [String: Any] = [
|
||||
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"],
|
||||
]
|
||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
|
||||
#expect(snapshot.port == 18789)
|
||||
#expect(snapshot.bind == nil)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import AppKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@@ -24,7 +23,7 @@ struct LowCoverageHelperTests {
|
||||
#expect(dict["list"]?.arrayValue?.count == 2)
|
||||
|
||||
let foundation = any.foundationValue as? [String: Any]
|
||||
#expect((foundation?["title"] as? String) == "Hello")
|
||||
#expect(foundation?["title"] as? String == "Hello")
|
||||
}
|
||||
|
||||
@Test func attributedStringStripsForegroundColor() {
|
||||
@@ -93,22 +92,34 @@ struct LowCoverageHelperTests {
|
||||
_ = PresenceReporter._testPrimaryIPv4Address()
|
||||
}
|
||||
|
||||
@Test func gatewayLaunchAgentHelpers() async throws {
|
||||
await TestIsolation.withEnvValues(
|
||||
[
|
||||
"CLAWDBOT_GATEWAY_BIND": "Lan",
|
||||
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
|
||||
]) {
|
||||
#expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan")
|
||||
#expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret")
|
||||
#expect(
|
||||
GatewayLaunchAgentManager._testEscapePlistValue("a&b<c>\"'") ==
|
||||
"a&b<c>"'")
|
||||
|
||||
#expect(GatewayLaunchAgentManager
|
||||
._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdbot")
|
||||
#expect(GatewayLaunchAgentManager._testRelayDir(bundlePath: "/App") == "/App/Contents/Resources/Relay")
|
||||
@Test func gatewayLaunchAgentHelpers() {
|
||||
let keyBind = "CLAWDBOT_GATEWAY_BIND"
|
||||
let keyToken = "CLAWDBOT_GATEWAY_TOKEN"
|
||||
let previousBind = ProcessInfo.processInfo.environment[keyBind]
|
||||
let previousToken = ProcessInfo.processInfo.environment[keyToken]
|
||||
defer {
|
||||
if let previousBind {
|
||||
setenv(keyBind, previousBind, 1)
|
||||
} else {
|
||||
unsetenv(keyBind)
|
||||
}
|
||||
if let previousToken {
|
||||
setenv(keyToken, previousToken, 1)
|
||||
} else {
|
||||
unsetenv(keyToken)
|
||||
}
|
||||
}
|
||||
|
||||
setenv(keyBind, "Lan", 1)
|
||||
setenv(keyToken, " secret ", 1)
|
||||
#expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan")
|
||||
#expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret")
|
||||
#expect(
|
||||
GatewayLaunchAgentManager._testEscapePlistValue("a&b<c>\"'") ==
|
||||
"a&b<c>"'")
|
||||
|
||||
#expect(GatewayLaunchAgentManager._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdbot")
|
||||
#expect(GatewayLaunchAgentManager._testRelayDir(bundlePath: "/App") == "/App/Contents/Resources/Relay")
|
||||
}
|
||||
|
||||
@Test func portGuardianParsesListenersAndBuildsReports() {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import AppKit
|
||||
import ClawdbotProtocol
|
||||
import SwiftUI
|
||||
import Testing
|
||||
|
||||
|
||||
@@ -45,70 +45,6 @@ import Testing
|
||||
let ok = await MacNodeModeCoordinator.probeEndpoint(endpoint, timeoutSeconds: 0.4)
|
||||
#expect(ok == false)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func remoteBridgePortUsesMatchingRemoteUrlPort() {
|
||||
let configPath = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||
.appendingPathComponent("clawdbot.json")
|
||||
.path
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
let prevTarget = defaults.string(forKey: remoteTargetKey)
|
||||
defer {
|
||||
if let prevTarget {
|
||||
defaults.set(prevTarget, forKey: remoteTargetKey)
|
||||
} else {
|
||||
defaults.removeObject(forKey: remoteTargetKey)
|
||||
}
|
||||
}
|
||||
|
||||
withEnv("CLAWDBOT_CONFIG_PATH", value: configPath) {
|
||||
withEnv("CLAWDBOT_GATEWAY_PORT", value: "20000") {
|
||||
defaults.set("user@bridge.ts.net", forKey: remoteTargetKey)
|
||||
ClawdbotConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": "ws://bridge.ts.net:25000",
|
||||
],
|
||||
],
|
||||
])
|
||||
#expect(MacNodeModeCoordinator.remoteBridgePort() == 25001)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func remoteBridgePortFallsBackWhenRemoteUrlHostMismatch() {
|
||||
let configPath = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||
.appendingPathComponent("clawdbot.json")
|
||||
.path
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
let prevTarget = defaults.string(forKey: remoteTargetKey)
|
||||
defer {
|
||||
if let prevTarget {
|
||||
defaults.set(prevTarget, forKey: remoteTargetKey)
|
||||
} else {
|
||||
defaults.removeObject(forKey: remoteTargetKey)
|
||||
}
|
||||
}
|
||||
|
||||
withEnv("CLAWDBOT_CONFIG_PATH", value: configPath) {
|
||||
withEnv("CLAWDBOT_GATEWAY_PORT", value: "20000") {
|
||||
defaults.set("user@other.ts.net", forKey: remoteTargetKey)
|
||||
ClawdbotConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": "ws://bridge.ts.net:25000",
|
||||
],
|
||||
],
|
||||
])
|
||||
#expect(MacNodeModeCoordinator.remoteBridgePort() == 20001)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TestError: Error {
|
||||
@@ -138,7 +74,7 @@ private func waitForListenerReady(_ listener: NWListener, timeoutSeconds: Double
|
||||
switch state {
|
||||
case .ready:
|
||||
finish(.success(()))
|
||||
case let .failed(err):
|
||||
case .failed(let err):
|
||||
finish(.failure(err))
|
||||
case .cancelled:
|
||||
finish(.failure(ListenerTimeoutError()))
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import ClawdbotKit
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite(.serialized)
|
||||
struct MacNodeRuntimeTests {
|
||||
@Test func handleInvokeRejectsUnknownCommand() async {
|
||||
let runtime = MacNodeRuntime()
|
||||
@@ -15,7 +15,7 @@ struct MacNodeRuntimeTests {
|
||||
@Test func handleInvokeRejectsEmptySystemRun() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = ClawdbotSystemRunParams(command: [])
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let json = String(data: try JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-2", command: ClawdbotSystemCommand.run.rawValue, paramsJSON: json))
|
||||
#expect(response.ok == false)
|
||||
@@ -24,65 +24,28 @@ struct MacNodeRuntimeTests {
|
||||
@Test func handleInvokeRejectsEmptyNotification() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = ClawdbotSystemNotifyParams(title: "", body: "")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let json = String(data: try JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-3", command: ClawdbotSystemCommand.notify.rawValue, paramsJSON: json))
|
||||
#expect(response.ok == false)
|
||||
}
|
||||
|
||||
@Test func handleInvokeCameraListRequiresEnabledCamera() async {
|
||||
await TestIsolation.withUserDefaultsValues([cameraEnabledKey: false]) {
|
||||
let runtime = MacNodeRuntime()
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-4", command: ClawdbotCameraCommand.list.rawValue))
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.message.contains("CAMERA_DISABLED") == true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func handleInvokeScreenRecordUsesInjectedServices() async throws {
|
||||
@MainActor
|
||||
final class FakeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
|
||||
func recordScreen(
|
||||
screenIndex: Int?,
|
||||
durationMs: Int?,
|
||||
fps: Double?,
|
||||
includeAudio: Bool?,
|
||||
outPath: String?) async throws -> (path: String, hasAudio: Bool)
|
||||
{
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-test-screen-record-\(UUID().uuidString).mp4")
|
||||
try Data("ok".utf8).write(to: url)
|
||||
return (path: url.path, hasAudio: false)
|
||||
}
|
||||
|
||||
func locationAuthorizationStatus() -> CLAuthorizationStatus { .authorizedAlways }
|
||||
func locationAccuracyAuthorization() -> CLAccuracyAuthorization { .fullAccuracy }
|
||||
func currentLocation(
|
||||
desiredAccuracy: ClawdbotLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
{
|
||||
CLLocation(latitude: 0, longitude: 0)
|
||||
let defaults = UserDefaults.standard
|
||||
let previous = defaults.object(forKey: cameraEnabledKey)
|
||||
defaults.set(false, forKey: cameraEnabledKey)
|
||||
defer {
|
||||
if let previous {
|
||||
defaults.set(previous, forKey: cameraEnabledKey)
|
||||
} else {
|
||||
defaults.removeObject(forKey: cameraEnabledKey)
|
||||
}
|
||||
}
|
||||
|
||||
let services = await MainActor.run { FakeMainActorServices() }
|
||||
let runtime = MacNodeRuntime(makeMainActorServices: { services })
|
||||
|
||||
let params = MacNodeScreenRecordParams(durationMs: 250)
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let runtime = MacNodeRuntime()
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-5", command: MacNodeScreenCommand.record.rawValue, paramsJSON: json))
|
||||
#expect(response.ok == true)
|
||||
let payloadJSON = try #require(response.payloadJSON)
|
||||
|
||||
struct Payload: Decodable {
|
||||
var format: String
|
||||
var base64: String
|
||||
}
|
||||
let payload = try JSONDecoder().decode(Payload.self, from: Data(payloadJSON.utf8))
|
||||
#expect(payload.format == "mp4")
|
||||
#expect(!payload.base64.isEmpty)
|
||||
BridgeInvokeRequest(id: "req-4", command: ClawdbotCameraCommand.list.rawValue))
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.message.contains("CAMERA_DISABLED") == true)
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ struct MenuSessionsInjectorTests {
|
||||
key: "main",
|
||||
kind: .direct,
|
||||
displayName: nil,
|
||||
provider: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
@@ -47,7 +47,7 @@ struct MenuSessionsInjectorTests {
|
||||
key: "discord:group:alpha",
|
||||
kind: .group,
|
||||
displayName: nil,
|
||||
provider: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
|
||||
@@ -51,3 +51,4 @@ struct ModelCatalogLoaderTests {
|
||||
#expect(choices.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,3 +43,4 @@ import Testing
|
||||
#expect(!bins.contains(missingNodeBin.path))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,6 @@ import Testing
|
||||
}
|
||||
|
||||
@Test func policyUsesSlowSafetyInterval() {
|
||||
#expect(NodePairingReconcilePolicy.activeIntervalMs >= 10000)
|
||||
#expect(NodePairingReconcilePolicy.activeIntervalMs >= 10_000)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import ClawdbotProtocol
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import ClawdbotProtocol
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite(.serialized)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Testing
|
||||
import ClawdbotIPC
|
||||
import CoreLocation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite(.serialized)
|
||||
|
||||
@@ -19,3 +19,4 @@ struct ScreenshotSizeTests {
|
||||
#expect(ScreenshotSize.readPNGSize(data: Data("nope".utf8)) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ struct SessionDataTests {
|
||||
@Test func sessionTokenStatsFormatKTokensRoundsAsExpected() {
|
||||
#expect(SessionTokenStats.formatKTokens(999) == "999")
|
||||
#expect(SessionTokenStats.formatKTokens(1000) == "1.0k")
|
||||
#expect(SessionTokenStats.formatKTokens(12340) == "12k")
|
||||
#expect(SessionTokenStats.formatKTokens(12_340) == "12k")
|
||||
}
|
||||
|
||||
@Test func sessionTokenStatsPercentUsedClampsTo100() {
|
||||
@@ -28,7 +28,7 @@ struct SessionDataTests {
|
||||
key: "user@example.com",
|
||||
kind: .direct,
|
||||
displayName: nil,
|
||||
provider: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
|
||||
@@ -45,7 +45,7 @@ struct SettingsViewSmokeTests {
|
||||
thinking: "low",
|
||||
timeoutSeconds: 30,
|
||||
deliver: true,
|
||||
provider: "sms",
|
||||
channel: "sms",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
||||
@@ -150,7 +150,7 @@ struct SettingsViewSmokeTests {
|
||||
|
||||
@Test func voiceWakeSettingsBuildsBody() {
|
||||
let state = AppState(preview: true)
|
||||
let view = VoiceWakeSettings(state: state, isActive: false)
|
||||
let view = VoiceWakeSettings(state: state)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ClawdbotProtocol
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
|
||||
Loaded 100 of 783 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user