Compare commits

..
Author SHA1 Message Date
Peter Steinberger d148c71368 ci: run Windows job in pwsh (#569) (thanks @bjesuiter) 2026-01-09 14:57:23 +01:00
Peter Steinberger bdb2a660d7 ci: pin Windows runner image (#569) (thanks @bjesuiter) 2026-01-09 14:51:08 +01:00
Peter Steinberger 81ab281b51 ci: silence vitest on Windows only (#569) (thanks @bjesuiter) 2026-01-09 14:45:57 +01:00
Peter Steinberger 81271f1b1c ci: silence vitest on Windows (#569) (thanks @bjesuiter) 2026-01-09 14:44:53 +01:00
Peter Steinberger 49a2db78c9 ci: fix Windows runner encoding (#569) (thanks @bjesuiter) 2026-01-09 14:39:24 +01:00
Peter Steinberger 6640b43876 ci: move UTF-8 step to windows job 2026-01-09 14:28:52 +01:00
Peter Steinberger 64f50e5c49 ci: force UTF-8 on Windows runners (#569) (thanks @bjesuiter) 2026-01-09 14:27:04 +01:00
Peter Steinberger bd6e881eba fix: sanitize Windows CI output safely (#569) (thanks @bjesuiter) 2026-01-09 14:20:53 +01:00
Peter Steinberger 22b224a343 fix: sanitize Windows CI test output (#569) (thanks @bjesuiter) 2026-01-09 14:16:39 +01:00
Peter Steinberger ce9769ab09 fix: land #569 and restore gate (thanks @bjesuiter) 2026-01-09 14:03:33 +01:00
Benjamin Jesuiter 71ecfb0b86 fix(ui): default to relative paths for control UI assets
Changes the default base path from "/" to "./" so the control UI works
correctly when served under a custom basePath (e.g., /jbclawd/).

Previously, assets were referenced with absolute paths like /assets/...,
which failed when the UI was served under a subpath. With relative paths
(./assets/...), the browser resolves them relative to the HTML location,
making the UI work regardless of the configured basePath.
2026-01-09 13:53:17 +01:00
740 changed files with 9571 additions and 57504 deletions

No files matched your search

+28 -132
View File
@@ -5,59 +5,8 @@ on:
pull_request:
jobs:
install-check:
runs-on: blacksmith-4vcpu-ubuntu-2404
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: 22.x
check-latest: true
- name: Runtime versions
run: |
node -v
npm -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 (frozen)
env:
CI: true
run: |
export PATH="$NODE_BIN:$PATH"
which node
node -v
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
checks:
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
@@ -105,7 +54,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
node-version: 24
check-latest: true
- name: Setup Bun
@@ -136,16 +85,16 @@ jobs:
which node
node -v
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --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 || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
checks-windows:
runs-on: blacksmith-4vcpu-windows-2025
runs-on: windows-2022
defaults:
run:
shell: bash
shell: pwsh
strategy:
fail-fast: false
matrix:
@@ -155,7 +104,7 @@ jobs:
command: pnpm lint
- runtime: node
task: test
command: pnpm test
command: pnpm test -- --run --silent
- runtime: node
task: build
command: pnpm build
@@ -168,23 +117,28 @@ jobs:
with:
submodules: false
- name: Force UTF-8 output (Windows)
run: |
chcp.com 65001
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
"LANG=en_US.UTF-8" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"LC_ALL=en_US.UTF-8" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- 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
for ($attempt = 1; $attempt -le 5; $attempt++) {
git -c protocol.version=2 submodule update --init --force --depth=1 --recursive
if ($LASTEXITCODE -eq 0) { exit 0 }
Write-Host "Submodule update failed (attempt $attempt/5). Retrying..."
Start-Sleep -Seconds ($attempt * 10)
}
exit 1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
node-version: 24
check-latest: true
- name: Setup Bun
@@ -199,7 +153,9 @@ jobs:
bun -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
run: |
$nodeBin = Split-Path -Parent (node -p "process.execPath")
"NODE_BIN=$nodeBin" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Enable corepack and pin pnpm
run: |
@@ -211,75 +167,15 @@ jobs:
env:
CI: true
run: |
export PATH="$NODE_BIN:$PATH"
which node
$env:PATH = "$env:NODE_BIN;$env:PATH"
Get-Command node | Format-List
node -v
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --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 || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
checks-macos:
if: github.event_name == 'pull_request'
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
include:
- task: test
command: pnpm test
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: 22.x
check-latest: true
- name: Runtime versions
run: |
node -v
npm -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 --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Run ${{ matrix.task }}
run: ${{ matrix.command }}
${{ matrix.command }}
macos-app:
if: github.event_name == 'pull_request'
@@ -524,7 +420,7 @@ jobs:
PY
android:
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
-37
View File
@@ -1,37 +0,0 @@
name: Workflow Sanity
on:
pull_request:
push:
jobs:
no-tabs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fail on tabs in workflow files
run: |
python - <<'PY'
from __future__ import annotations
import pathlib
import sys
root = pathlib.Path(".github/workflows")
bad: list[str] = []
for path in sorted(root.rglob("*.yml")):
if b"\t" in path.read_bytes():
bad.append(str(path))
for path in sorted(root.rglob("*.yaml")):
if b"\t" in path.read_bytes():
bad.append(str(path))
if bad:
print("Tabs found in workflow file(s):")
for path in bad:
print(f"- {path}")
sys.exit(1)
PY
-2
View File
@@ -1,6 +1,5 @@
node_modules
.env
docker-compose.extra.yml
dist
*.bun-build
pnpm-lock.yaml
@@ -54,4 +53,3 @@ apps/ios/*.mobileprovision
# Local untracked files
.local/
.vscode/
+1 -1
View File
@@ -48,4 +48,4 @@
--allman false
# Exclusions
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/ClawdisProtocol,apps/macos/Sources/ClawdbotProtocol
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol,apps/macos/Sources/ClawdbotProtocol
-2
View File
@@ -17,8 +17,6 @@ excluded:
- dist
- coverage
- "*.playground"
# Generated (protocol-gen-swift.ts)
- apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift
analyzer_rules:
- unused_declaration
+1 -8
View File
@@ -9,7 +9,6 @@
- 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)`).
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
## Build, Test, and Development Commands
@@ -35,8 +34,6 @@
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (Clawdbot-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
- Full kit + what’s covered: `docs/testing.md`.
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
- Mobile: before using a simulator, check for connected real devices (iOS + Android) and prefer them when available.
@@ -44,7 +41,6 @@
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- Group related changes; avoid bundling unrelated refactors.
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
- 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`.
@@ -69,9 +65,6 @@
## Agent-Specific Notes
- Vocabulary: "makeup" = "mac app".
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency.
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
- 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.
@@ -94,7 +87,7 @@
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/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 another machine, SSH via Tailscale and read the same path there.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- 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.
+98 -150
View File
@@ -1,151 +1,99 @@
# Changelog
## 2026.1.10
## Unreleased
### Highlights
- CLI: `clawdbot status` now table-based + shows OS/update/gateway/daemon/agents/sessions; `status --all` adds a full read-only debug report (tables, log tails, Tailscale summary, and scan progress via OSC-9 + spinner).
- CLI Backends: add Codex CLI fallback with resume support (text output) and JSONL parsing for new runs, plus a live CLI resume probe.
- CLI: add `clawdbot update` (safe-ish git checkout update) + `--update` shorthand. (#673) — thanks @fm1randa.
- Gateway: add OpenAI-compatible `/v1/chat/completions` HTTP endpoint (auth, SSE streaming, per-agent routing). (#680) — thanks @steipete.
### Changes
- Onboarding/Models: add first-class Z.AI (GLM) auth choice (`zai-api-key`) + `--zai-api-key` flag.
- CLI/Onboarding: add OpenRouter API key auth option in configure/onboard. (#703) — thanks @mteam88.
- Agents: add human-delay pacing between block replies (modes: off/natural/custom, per-agent configurable). (#446) — thanks @tony-freedomology.
- Agents/Browser: add `browser.target` (sandbox/host/custom) with sandbox host-control gating via `agents.defaults.sandbox.browser.allowHostControl`, allowlists for custom control URLs/hosts/ports, and expand browser tool docs (remote control, profiles, internals).
- Onboarding/Models: add catalog-backed default model picker to onboarding + configure. (#611) — thanks @jonasjancarik.
- Agents/OpenCode Zen: update fallback models + defaults, keep legacy alias mappings. (#669) — thanks @magimetal.
- Providers: unify group history context wrappers across providers with per-provider/per-account `historyLimit` overrides (fallback to `messages.groupChat.historyLimit`). Set `0` to disable. (#672) — thanks @steipete.
- Gateway/Heartbeat: optionally deliver heartbeat `Reasoning:` output (`agents.defaults.heartbeat.includeReasoning`). (#690)
- Docker: allow optional home volume + extra bind mounts in `docker-setup.sh`. (#679) — thanks @gabriel-trigo.
### Fixes
- CLI/Status: expand tables to full terminal width; clarify provider setup vs runtime warnings; richer per-provider detail; token previews in `status` while keeping `status --all` redacted; add troubleshooting link footer; keep log tails pasteable; show gateway auth used when reachable; surface provider runtime errors (Signal/iMessage/Slack); harden `tailscale status --json` parsing; make `status --all` scan progress determinate; and replace the footer with a 3-line “Next steps” recommendation (share/debug/probe).
- CLI/Gateway: clarify that `clawdbot gateway status` reports RPC health (connect + RPC) and shows RPC failures separately from connect failures.
- CLI/Update: gate progress spinner on stdout TTY and align clean-check step label. (#701) — thanks @bjesuiter.
- Telegram: add `/whoami` + `/id` commands to reveal sender id for allowlists; allow `@username` and prefixed ids in `allowFrom` prompts (with stability warning).
- Control UI: stop auto-writing `telegram.groups["*"]` and warn/confirm before enabling wildcard groups.
- WhatsApp: send ack reactions only for handled messages and ignore legacy `messages.ackReaction` (doctor copies to `whatsapp.ackReaction`). (#629) — thanks @pasogott.
- Sandbox/Skills: mirror skills into sandbox workspaces for read-only mounts so SKILL.md stays accessible.
- Terminal/Table: ANSI-safe wrapping to prevent table clipping/color loss; add regression coverage.
- Docker: allow optional apt packages during image build and document the build arg. (#697) — thanks @gabriel-trigo.
- Gateway/Heartbeat: deliver reasoning even when the main heartbeat reply is `HEARTBEAT_OK`. (#694) — thanks @antons.
- macOS: clear unsigned launchd overrides on signed restarts and warn via doctor when attach-only/disable markers are set. (#695) — thanks @jeffersonwarrior.
- Agents: enforce single-writer session locks and drop orphan tool results to prevent tool-call ID failures (MiniMax/Anthropic-compatible APIs).
- Docs: make `clawdbot status` the first diagnostic step, clarify `status --deep` behavior, and document `/whoami` + `/id`.
- Docs/Testing: clarify live tool+image probes and how to list your testable `provider/model` ids.
- Tests/Live: make gateway bash+read probes resilient to provider formatting while still validating real tool calls.
- WhatsApp: detect @lid mentions in groups using authDir reverse mapping + resolve self JID E.164 for mention gating. (#692) — thanks @peschee.
- Gateway/Auth: default to token auth on loopback during onboarding, add doctor token generation flow, and tighten audio transcription config to Whisper-only.
- Providers: dedupe inbound messages across providers to avoid duplicate LLM runs on redeliveries/reconnects. (#689) — thanks @adam91holt.
- Agents: strip `<thought>`/`<antthinking>` tags from hidden reasoning output and cover tag variants in tests. (#688) — thanks @theglove44.
- macOS: save model picker selections as normalized provider/model IDs and keep manual entries aligned. (#683) — thanks @benithors.
- Agents: recognize "usage limit" errors as rate limits for failover. (#687) — thanks @evalexpr.
- CLI: avoid success message when daemon restart is skipped. (#685) — thanks @carlulsoe.
- Commands: disable `/config` + `/debug` by default; gate via `commands.config`/`commands.debug` and hide from native registration/help output.
- Agents/System: clarify that sub-agents remain sandboxed and cannot use elevated host access.
- Gateway: disable the OpenAI-compatible `/v1/chat/completions` endpoint by default; enable via `gateway.http.endpoints.chatCompletions.enabled=true`.
- macOS: stabilize bridge tunnels, guard invoke senders on disconnect, and drain stdout/stderr to avoid deadlocks. (#676) — thanks @ngutman.
- Agents/System: clarify sandboxed runtime in system prompt and surface elevated availability when sandboxed.
- Auto-reply: prefer `RawBody` for command/directive parsing (WhatsApp + Discord) and prevent fallback runs from clobbering concurrent session updates. (#643) — thanks @mcinteerj.
- WhatsApp: fix group reactions by preserving message IDs and sender JIDs in history; normalize participant phone numbers to JIDs in outbound reactions. (#640) — thanks @mcinteerj.
- WhatsApp: expose group participant IDs to the model so reactions can target the right sender.
- Cron: `wakeMode: "now"` waits for heartbeat completion (and retries when the main lane is busy). (#666) — thanks @roshanasingh4.
- Agents/OpenAI: fix Responses tool-only → follow-up turn handling (avoid standalone `reasoning` items that trigger 400 “required following item”) and replay reasoning items in Responses/Codex Responses history for tool-call-only turns.
- Sandbox: add `clawdbot sandbox explain` (effective policy inspector + fix-it keys); improve “sandbox jail” tool-policy/elevated errors with actionable config key paths; link to docs.
- Hooks/Gmail: keep Tailscale serve path at `/` while preserving the public path. (#668) — thanks @antons.
- Hooks/Gmail: allow Tailscale target URLs to preserve internal serve paths.
- Auth: update Claude Code keychain credentials in-place during refresh sync; share JSON file helpers; add CLI fallback coverage.
- Auth: throttle external CLI credential syncs (Claude/Codex), reduce Keychain reads, and skip sync when cached credentials are still fresh.
- CLI: respect `CLAWDBOT_STATE_DIR` for node pairing + voice wake settings storage. (#664) — thanks @azade-c.
- Onboarding/Gateway: persist non-interactive gateway token auth in config; add WS wizard + gateway tool-calling regression coverage.
- Gateway/Control UI: make `chat.send` non-blocking, wire Stop to `chat.abort`, and treat `/stop` as an out-of-band abort. (#653)
- Gateway/Control UI: allow `chat.abort` without `runId` (abort active runs), suppress post-abort chat streaming, and prune stuck chat runs. (#653)
- Gateway/Control UI: sniff image attachments for chat.send, drop non-images, and log mismatches. (#670) — thanks @cristip73.
- macOS: force `restart-mac.sh --sign` to require identities and keep bundled Node signed for relay verification. (#580) — thanks @jeffersonwarrior.
- Gateway/Agent: accept image attachments on `agent` (multimodal message) and add live gateway image probe (`CLAWDBOT_LIVE_GATEWAY_IMAGE_PROBE=1`).
- CLI: `clawdbot sessions` now includes `elev:*` + `usage:*` flags in the table output.
- CLI/Pairing: accept positional provider for `pairing list|approve` (npm-run compatible); update docs/bot hints.
- Branding: normalize user-facing “ClawdBot”/“CLAWDBOT” → “Clawdbot” (CLI, status, docs).
- Auto-reply: fix native `/model` not updating the actual chat session (Telegram/Slack/Discord). (#646)
- Doctor: offer to run `clawdbot update` first on git installs (keeps doctor output aligned with latest).
- Doctor: avoid false legacy workspace warning when install dir is `~/clawdbot`. (#660)
- iMessage: fix reasoning persistence across DMs; avoid partial/duplicate replies when reasoning is enabled. (#655) — thanks @antons.
- Models/Auth: allow MiniMax API configs without `models.providers.minimax.apiKey` (auth profiles / `MINIMAX_API_KEY`). (#656) — thanks @mneves75.
- Agents: avoid duplicate replies when the message tool sends. (#659) — thanks @mickahouan.
- Agents: harden Cloud Code Assist tool ID sanitization (toolUse/toolCall/toolResult) and scrub extra JSON Schema constraints. (#665) — thanks @sebslight.
- Agents: sanitize tool results + Cloud Code Assist tool IDs at context-build time (prevents mid-run strict-provider request rejects).
- Agents/Tools: resolve workspace-relative Read/Write/Edit paths; align bash default cwd. (#642) — thanks @mukhtharcm.
- Discord: include forwarded message snapshots in agent session context. (#667) — thanks @rubyrunsstuff.
- Telegram: add `telegram.draftChunk` to tune draft streaming chunking for `streamMode: "block"`. (#667) — thanks @rubyrunsstuff.
- Tests/Agents: add regression coverage for workspace tool path resolution and bash cwd defaults.
- iOS/Android: enable stricter concurrency/lint checks; fix Swift 6 strict concurrency issues + Android lint errors (ExifInterface, obsolete SDK check). (#662) — thanks @KristijanJovanovski.
- iOS/macOS: share `AsyncTimeout`, require explicit `bridgeStableID` on connect, and harden tool display defaults (avoids missing-resource label fallbacks).
- Telegram: serialize media-group processing to avoid missed albums under load.
- Signal: handle `dataMessage.reaction` events (signal-cli SSE) to avoid broken attachment errors. (#637) — thanks @neist.
- Docs: showcase entries for ParentPay, R2 Upload, iOS TestFlight, and Oura Health. (#650) — thanks @henrino3.
- Agents: repair session transcripts by dropping duplicate tool results across the whole history (unblocks Anthropic-compatible APIs after retries).
- Tests/Live: reset the gateway session between model runs to avoid cross-provider transcript incompatibilities (notably OpenAI Responses reasoning replay rules).
## 2026.1.9
### Highlights
- Microsoft Teams provider: polling, attachments, outbound CLI send, per-channel policy.
- Models/Auth expansion: OpenCode Zen + MiniMax API onboarding; token auth profiles + auth order; OAuth health in doctor/status.
- CLI/Gateway UX: message subcommands, gateway discover/status/SSH, /config + /debug, sandbox CLI.
- Provider reliability sweep: WhatsApp contact cards/targets, Telegram audio-as-voice + streaming, Signal reactions, Slack threading, Discord stability.
- Auto-reply + status: block-streaming controls, reasoning handling, usage/cost reporting.
- Control UI/TUI: queued messages, session links, reasoning view, mobile polish, logs UX.
### Breaking
- CLI: `clawdbot message` now subcommands (`message send|poll|...`) and requires `--provider` unless only one provider configured.
- Commands/Tools: `/restart` and gateway restart tool disabled by default; enable with `commands.restart=true`.
### New Features and Changes
- Models/Auth: OpenCode Zen onboarding (#623) — thanks @magimetal; MiniMax Anthropic-compatible API + hosted onboarding (#590, #495) — thanks @mneves75, @tobiasbischoff.
- Models/Auth: setup-token + token auth profiles; `clawdbot models auth order {get,set,clear}`; per-agent auth candidates in `/model status`; OAuth expiry checks in doctor/status.
- Agent/System: claude-cli runner; `session_status` tool (and sandbox allow); adaptive context pruning default; system prompt messaging guidance + no auto self-update; eligible skills list injection; sub-agent context trimmed.
- Commands: `/commands` list; `/models` alias; `/usage` alias; `/debug` runtime overrides + effective config view; `/config` chat updates + `/config get`; `config --section`.
- CLI/Gateway: unified message tool + message subcommands; gateway discover (local + wide-area DNS-SD) with JSON/timeout; gateway status human-readable + JSON + SSH loopback; wide-area records include gatewayPort/sshPort/cliPath + tailnet DNS fallback.
- CLI UX: logs output modes (pretty/plain/JSONL) + colorized health/daemon output; global `--no-color`; lobster palette in onboarding/config.
- Dev ergonomics: gateway `--dev/--reset` + dev profile auto-config; C-3PO dev templates; dev gateway/TUI helper scripts.
- Sandbox/Workspace: sandbox list/recreate commands; sync skills into sandbox workspace; sandbox browser auto-start.
- Config/Onboarding: inline env vars; OpenAI API key flow to shared `~/.clawdbot/.env`; Opus 4.5 default prompt for Anthropic auth; QuickStart auto-install gateway (Node-only) + provider picker tweaks + skip-systemd flags; TUI bootstrap prompt (`tui --message`); remove Bun runtime choice.
- Providers: Microsoft Teams provider (polling, attachments, outbound sends, requireMention, config reload/DM policy). (#404) — thanks @onutc
- Providers: WhatsApp broadcast groups for multi-agent replies (#547) — thanks @pasogott; inbound media size cap configurable (#505) — thanks @koala73; identity-based message prefixes (#578) — thanks @p6l-richard.
- Providers: Telegram inline keyboard buttons + callback payload routing (#491) — thanks @azade-c; cron topic delivery targets (#474/#478) — thanks @mitschabaude-bot, @nachoiacovino; `[[audio_as_voice]]` tag support (#490) — thanks @jarvis-medmatic.
- Providers: Signal reactions + notifications with allowlist support.
- Status/Usage: /status cost reporting + `/cost` lines; auth profile snippet; provider usage windows.
- Control UI: mobile responsiveness (#558) — thanks @carlulsoe; queued messages + Enter-to-send (#527) — thanks @YuriNachos; session links (#471) — thanks @HazAT; reasoning view; skill install feedback (#445) — thanks @pkrmf; chat layout refresh (#475) — thanks @rahthakor; docs link + new session button; drop explicit `ui:install`.
- TUI: agent picker + agents list RPC; improved status line.
- Doctor/Daemon: audit/repair flows, permissions checks, supervisor config audits; provider status probes + warnings for Discord intents and Telegram privacy; last activity timestamps; gateway restart guidance.
- Docs: Hetzner Docker VPS guide + cross-links (#556/#592) — thanks @Iamadig; Ansible guide (#545) — thanks @pasogott; provider troubleshooting index; hook parameter expansion (#532) — thanks @mcinteerj; model allowlist notes; OAuth deep dive; showcase refresh.
- Apps/Branding: refreshed iOS/Android/macOS icons (#521) — thanks @fishfisher.
### Fixes
- Packaging: include MS Teams send module in npm tarball.
- Sandbox/Browser: auto-start CDP endpoint; proxy CDP out of container for attachOnly; relax Bun fetch typing; align sandbox list output with config images.
- Agents/Runtime: gate heartbeat prompt to default sessions; /stop aborts between tool calls; require explicit system-event session keys; guard small context windows; fix model fallback stringification; sessions_spawn inherits provider; failover on billing/credits; respect auth cooldown ordering; restore Anthropic OAuth tool dispatch + tool-name bypass; avoid OpenAI invalid reasoning replay; harden Gmail hook model defaults.
- Agent history/schema: strip/skip empty assistant/error blocks to prevent session corruption/Claude 400s; scrub unsupported JSON Schema keywords + sanitize tool call IDs for Cloud Code Assist; simplify Gemini-compatible tool/session schemas; require raw for config.apply.
- Auto-reply/Streaming: default audioAsVoice false; preserve audio_as_voice propagation + buffer audio blocks + guard voice notes; block reply ordering (timeout) + forced-block fence-safe; avoid chunk splits inside parentheses + fence-close breaks + invalid UTF-16 truncation; preserve inline directive spacing + allow whitespace in reply tags; filter NO_REPLY prefixes + normalize routed replies; suppress <think> leakage with separate Reasoning; block streaming defaults (off by default, minChars/idle tuning) + coalesced blocks; dedupe followup queue; restore explicit responsePrefix default.
- Status/Commands: provider prefix in /status model display; usage filtering + provider mapping; auth label + usage snapshots (claude-cli fallback + optional claude.ai); show Verbose/Elevated only when enabled; compact usage/cost line + restore emoji-rich status; /status in directive-only + multi-directive handling; mention-bypass elevated handling; surface provider usage errors; wire /usage to /status; restore hidden gateway-daemon alias; fallback /model list when catalog unavailable.
- WhatsApp: vCard/contact cards (prefer FN, include numbers, show all contacts, keep summary counts, better empty summaries); preserve group JIDs + normalize targets; resolve @lid mappings/JIDs (Baileys/auth-dir) + inbound mapping; route queued replies to sender; improve web listener errors + remove provider name from errors; record outbound activity account id; fix web media fetch errors; broadcast group history consistency.
- Telegram: keep streamMode draft-only; long-poll conflict retries + update dedupe; grammY fetch mismatch fixes + restrict native fetch to Bun; suppress getUpdates stack traces; include user id in pairing; audio_as_voice handling fixes.
- Discord/Slack: thread context helpers + forum thread starters; avoid category parent overrides; gateway reconnect logs + HELLO timeout + stop provider after reconnect exhaustion; DM recipient parsing for numeric IDs; remove incorrect limited warning; reply threading + mrkdwn edge cases; remove ack reactions after reply; gateway debug event visibility.
- Signal: reaction handling safety; own-reaction matching (uuid+phone); UUID-only senders accepted; ignore reaction-only messages.
- MS Teams: download image attachments reliably; fix top-level replies; stop on shutdown + honor chunk limits; normalize poll providers/deps; pairing label fixes.
- iMessage: isolate group-ish threads by chat_id.
- Gateway/Daemon/Doctor: atomic config writes; repair gateway service entrypoint + install switches; non-interactive legacy migrations; systemd unit alignment + KillMode=process; node bridge keepalive/pings; Launch at Login persistence; bundle ClawdbotKit resources + Swift 6.2 compat dylib; relay version check + remove smoke test; regen Swift GatewayModels + keep agent provider string; cron jobId alias + channel alias migration + main session key normalization; heartbeat Telegram accountId resolution; avoid WhatsApp fallback for internal runs; gateway listener error wording; serveBaseUrl param; honor gateway --dev; fix wide-area discovery updates; align agents.defaults schema; provider account metadata in daemon status; refresh Carbon patch for gateway fixes; restore doctor prompter initialValue handling.
- Control UI/TUI: persist per-session verbose off + hide tool cards; logs tab opens at bottom; relative asset paths + landing cleanup; session labels lookup/persistence; stop pinning main session in recents; start logs at bottom; TUI status bar refresh + timeout handling + hide reasoning label when off.
- Onboarding/Configure: QuickStart single-select provider picker; avoid Codex CLI false-expiry warnings; clarify WhatsApp owner prompt; fix Minimax hosted onboarding (agents.defaults + msteams heartbeat target); remove configure Control UI prompt; honor gateway --dev flag.
### Maintenance
- Dependencies: bump pi-* stack to 0.42.2.
- Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj.
- Build: Docker build cache layer (#605) — thanks @zknicker.
- Auth: enable OAuth token refresh for Claude CLI credentials (`anthropic:claude-cli`) with bidirectional sync back to Claude Code storage (file on Linux/Windows, Keychain on macOS). This allows long-running agents to operate autonomously without manual re-authentication (#654 — thanks @radek-paclt).
- CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
- Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc
- Commands: accept /models as an alias for /model.
- Debugging: add raw model stream logging flags and document gateway watch mode.
- Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled).
- Auth: respect cooldown tracking even with explicit `auth.order` (avoid repeatedly trying known-bad keys). — thanks @steipete
- CLI: move `clawdbot message` to subcommands (`message send|poll|…`), fold Discord/Slack/Telegram/WhatsApp tools into `message`, and require `--provider` unless only one provider is configured.
- CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging.
- WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj
- Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223
- Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj
- Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles.
- Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence.
- Config: migrate routing/agent config into agents.list/agents.defaults and messages/tools/audio with default agent selection and per-agent identity config.
- Agent: enable adaptive context pruning by default for tool-result trimming.
- Doctor: check config/state permissions and offer to tighten them. — thanks @steipete
- Doctor/Daemon: audit supervisor configs, add --repair/--force flows, surface service config audits in daemon status, and document user vs system services. — thanks @steipete
- Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c
- Daemon: add KillMode=process to systemd units to avoid podman restart hangs. (#541) — thanks @ogulcancelik
- Doctor: run legacy state migrations in non-interactive mode without prompts.
- Cron: parse Telegram topic targets for isolated delivery. (#478) — thanks @nachoiacovino
- Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos
- Cron: allow Telegram delivery targets with topic/thread IDs (e.g. `-100…:topic:123`). (#474) — thanks @mitschabaude-bot
- Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos
- Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123
- Agents: strip empty assistant text blocks from session history to avoid Claude API 400s. (#210)
- Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123
- Auto-reply: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1
- Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess
- Auto-reply: preserve spacing when stripping inline directives. (#539) — thanks @joshp123
- Auto-reply: fix /status usage summary filtering for the active provider.
- Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj
- Status: compact /status with session token usage + estimated cost, add `/cost` per-response usage lines (tokens-only for OAuth).
- Status: show active auth profile and key snippet in /status.
- Agent: promote `<think>`/`<thinking>` tag reasoning into structured thinking blocks so `/reasoning` works consistently for OpenAI-compat providers.
- macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy
- WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj
- Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini).
- Control UI: logs tab opens at the newest entries (bottom).
- Control UI: add Docs link, remove chat composer divider, and add New session button.
- Control UI: link sessions list to chat view. (#471) — thanks @HazAT
- Control UI: show/patch per-session reasoning level and render extracted reasoning in chat.
- Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos
- Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow).
- Control UI: default control UI asset base to relative paths for subpath hosting. (#569) — thanks @bjesuiter
- Telegram: retry long-polling conflicts with backoff to avoid fatal exits.
- Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos
- WhatsApp: resolve @lid JIDs via Baileys mapping to unblock inbound messages. (#415)
- Pairing: replies now include sender ids for Discord/Slack/Signal/iMessage/WhatsApp; pairing list labels them explicitly.
- Signal: accept UUID-only senders for pairing/allowlists/routing when sourceNumber is missing. (#523) — thanks @neist
- Agent system prompt: avoid automatic self-updates unless explicitly requested.
- Onboarding: tighten QuickStart hint copy for configuring later.
- Onboarding: set Gemini 3 Pro as the default model for Gemini API key auth. (#489) — thanks @jonasjancarik
- Onboarding: avoid “token expired” for Codex CLI when expiry is heuristic.
- Onboarding: QuickStart jumps straight into provider selection with Telegram preselected when unset.
- Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker).
- Onboarding: clarify WhatsApp owner number prompt and label pairing phone number.
- Onboarding: add hosted MiniMax M2.1 API key flow + config. (#495) — thanks @tobiasbischoff
- Daemon runtime: remove Bun from selection options.
- CLI: restore hidden `gateway-daemon` alias for legacy launchd configs.
- Onboarding/Configure: add OpenAI API key flow that stores in shared `~/.clawdbot/.env` for launchd; simplify Anthropic token prompt order.
- Configure/Onboarding: show Control UI docs with gateway reachability status and only offer to open when a gateway is detected; default model prompt now prefers Opus 4.5 for Anthropic auth.
- Control UI: show skill install progress + per-skill results, hide install once binaries present. (#445) — thanks @pkrmf
- Providers/Doctor: surface Discord privileged intent (Message Content) misconfiguration with actionable warnings.
- Providers/Doctor: warn when Telegram config expects unmentioned group messages but Bot API privacy mode is likely enabled; surface WhatsApp login/disconnect hints.
- Providers/Doctor: add last inbound/outbound activity timestamps in `providers status` and extend `--probe` with Discord channel permission + Telegram group membership audits.
- Docs: add provider troubleshooting index (`/providers/troubleshooting`) and link it from the main troubleshooting guide.
- Docs: clarify model allowlist errors and add safety notes for verbose/reasoning in groups.
- Docs: add Ansible installation guide. (#545) — thanks @pasogott
- Telegram: include the user id in DM pairing messages and label it clearly in `clawdbot pairing list --provider telegram`.
- Apps: refresh iOS/Android/macOS app icons for Clawdbot branding. (#521) — thanks @fishfisher
- Docs: expand parameter descriptions for agent/wake hooks. (#532) — thanks @mcinteerj
- Docs: add community showcase entries from Discord. (#476) — thanks @gupsammy
- TUI: refresh status bar after think/verbose/reasoning changes. (#519) — thanks @jdrhyne
- Status: show Verbose/Elevated only when enabled.
- Status: filter usage summary to the active model provider.
- Status: map model providers to usage sources so unrelated usage doesn’t appear.
- Commands: allow /elevated off in groups without a mention; keep /elevated on mention-gated.
- Commands: keep multi-directive messages from clearing directive handling.
- Commands: warn when /elevated runs in direct (unsandboxed) runtime.
- Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond.
- Commands: return /status in directive-only multi-line messages.
- Models: fall back to configured models when the provider catalog is unavailable.
- Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) — thanks @neist
- Agent: bypass Anthropic OAuth tool-name blocks by capitalizing built-ins and keeping pruning tool matching case-insensitive. (#553) — thanks @andrewting19
- Commands/Tools: disable /restart and gateway restart tool by default (enable with commands.restart=true).
- Gateway/CLI: add `clawdbot gateway discover` (Bonjour scan on `local.` + `clawdbot.internal.`) with `--timeout` and `--json`. — thanks @steipete
- Gateway/CLI: make `clawdbot gateway status` human-readable by default, add `--json`, and probe localhost + configured remote (warn on multiple gateways). — thanks @steipete
- Gateway/CLI: support remote loopback gateways via SSH tunnel in `clawdbot gateway status` (`--ssh` / `--ssh-auto`). — thanks @steipete
- CLI: add global `--no-color` (and respect `NO_COLOR=1`) to disable ANSI output. — thanks @steipete
- CLI: centralize lobster palette + apply it to onboarding/config prompts. — thanks @steipete
- Gateway/CLI: add `clawdbot gateway --dev/--reset` to auto-create a dev config/workspace with a robot identity (no BOOTSTRAP.md). — thanks @steipete
## 2026.1.8
@@ -162,9 +110,9 @@
- 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>` + `clawdbot pairing approve <provider> <code>`.
- Sandbox: default `agent.sandbox.scope` to `"agent"` (one container/workspace per agent). Use `"session"` for per-session isolation; `"shared"` disables cross-session isolation.
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only).
- Approve requests via `clawdbot pairing list --provider <provider>` + `clawdbot pairing approve --provider <provider> <code>`.
- Sandbox: default `agents.defaults.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 `agents.defaults.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.
@@ -190,7 +138,7 @@
## 2026.1.5
### Highlights
- Models: add image-specific model config (`agent.imageModel` + fallbacks) and scan support.
- Models: add image-specific model config (`agents.defaults.imageModel` + fallbacks) and scan support.
- Agent tools: new `image` tool routed to the image model (when configured).
- Config: default model shorthands (`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`).
- Docs: document built-in model shorthands + precedence (user config wins).
@@ -215,7 +163,7 @@
- Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas).
- Onboarding: when running from source, auto-build missing Control UI assets (`bun run ui:build`).
- Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed).
- Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off.
- Agent tools: honor `tools.allow` / `tools.deny` policy even when sandbox is off.
- Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events.
- Commands: unify /status (inline) and command auth across providers; group bypass for authorized control commands; remove Discord /clawd slash handler.
- CLI: run `clawdbot agent` via the Gateway by default; use `--local` to force embedded mode.
+1 -14
View File
@@ -8,22 +8,9 @@ RUN corepack enable
WORKDIR /app
ARG CLAWDBOT_DOCKER_APT_PACKAGES=""
RUN if [ -n "$CLAWDBOT_DOCKER_APT_PACKAGES" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $CLAWDBOT_DOCKER_APT_PACKAGES && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
fi
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY ui/package.json ./ui/package.json
COPY patches ./patches
COPY scripts ./scripts
COPY . .
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
RUN pnpm ui:install
RUN pnpm ui:build
-1
View File
@@ -14,7 +14,6 @@ RUN apt-get update \
jq \
novnc \
python3 \
socat \
websockify \
x11vnc \
xvfb \
+19 -23
View File
@@ -1,7 +1,7 @@
# 🦞 Clawdbot — Personal AI Assistant
# 🦞 CLAWDBOT — Personal AI Assistant
<p align="center">
<img src="https://raw.githubusercontent.com/clawdbot/clawdbot/main/docs/whatsapp-clawd.jpg" alt="Clawdbot" width="400">
<img src="https://raw.githubusercontent.com/clawdbot/clawdbot/main/docs/whatsapp-clawd.jpg" alt="CLAWDBOT" width="400">
</p>
<p align="center">
@@ -98,7 +98,7 @@ 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> <code>` (then the sender is added to a local allowlist store).
- 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.
@@ -159,16 +159,15 @@ WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
│
▼
┌───────────────────────────────┐
│ Gateway │
│ (control plane) │
│ ws://127.0.0.1:18789 │
│ Gateway │ ws://127.0.0.1:18789
│ (control plane) │ bridge: tcp://0.0.0.0:18790
└──────────────┬────────────────┘
│
├─ Pi agent (RPC)
├─ CLI (clawdbot …)
├─ WebChat UI
├─ macOS app
└─ iOS / Android nodes
└─ iOS/Android nodes
```
## Key subsystems
@@ -313,7 +312,7 @@ Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxi
### [WhatsApp](https://docs.clawd.bot/whatsapp)
- Link the device: `pnpm clawdbot providers login` (stores creds in `~/.clawdbot/credentials`).
- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
- If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
@@ -458,19 +457,16 @@ Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
Thanks to all clawtributors:
<p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></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/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></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/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></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/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a>
<a href="https://github.com/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/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a>
<a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/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/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></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/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></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/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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></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/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/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a>
<a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></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/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/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></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/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/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/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></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/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></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/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/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/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/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/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a>
<a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></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/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/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/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/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a>
<a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></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/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></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/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></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/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/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/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></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/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></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/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a>
<a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></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/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a>
<a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a>
<a href="https://github.com/search?q=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/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a>
<a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
<a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a>
<a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a>
<a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
<a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a>
<a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a>
<a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a>
<a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a>
<a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/search?q=Tobias%20Bischoff"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a>
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a>
</p>
+41 -64
View File
@@ -1,78 +1,55 @@
<?xml version="1.0" standalone="yes"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdbot</title>
<title>Clawdis</title>
<item>
<title>2026.1.9</title>
<pubDate>Sat, 10 Jan 2026 05:23:02 +0100</pubDate>
<title>2026.1.5-3</title>
<pubDate>Mon, 05 Jan 2026 04:30:46 +0100</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>4607</sparkle:version>
<sparkle:shortVersionString>2026.1.9</sparkle:shortVersionString>
<sparkle:version>3095</sparkle:version>
<sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.9</h2>
<h3>Highlights</h3>
<ul>
<li>Microsoft Teams provider: polling, attachments, outbound CLI send, per-channel policy.</li>
<li>Models/Auth expansion: OpenCode Zen + MiniMax API onboarding; token auth profiles + auth order; OAuth health in doctor/status.</li>
<li>CLI/Gateway UX: message subcommands, gateway discover/status/SSH, /config + /debug, sandbox CLI.</li>
<li>Provider reliability sweep: WhatsApp contact cards/targets, Telegram audio-as-voice + streaming, Signal reactions, Slack threading, Discord stability.</li>
<li>Auto-reply + status: block-streaming controls, reasoning handling, usage/cost reporting.</li>
<li>Control UI/TUI: queued messages, session links, reasoning view, mobile polish, logs UX.</li>
</ul>
<h3>Breaking</h3>
<ul>
<li>CLI: <code>clawdbot message</code> now subcommands (<code>message send|poll|...</code>) and requires <code>--provider</code> unless only one provider configured.</li>
<li>Commands/Tools: <code>/restart</code> and gateway restart tool disabled by default; enable with <code>commands.restart=true</code>.</li>
</ul>
<h3>New Features and Changes</h3>
<ul>
<li>Models/Auth: OpenCode Zen onboarding (#623) — thanks @magimetal; MiniMax Anthropic-compatible API + hosted onboarding (#590, #495) — thanks @mneves75, @tobiasbischoff.</li>
<li>Models/Auth: setup-token + token auth profiles; <code>clawdbot models auth order {get,set,clear}</code>; per-agent auth candidates in <code>/model status</code>; OAuth expiry checks in doctor/status.</li>
<li>Agent/System: claude-cli runner; <code>session_status</code> tool (and sandbox allow); adaptive context pruning default; system prompt messaging guidance + no auto self-update; eligible skills list injection; sub-agent context trimmed.</li>
<li>Commands: <code>/commands</code> list; <code>/models</code> alias; <code>/usage</code> alias; <code>/debug</code> runtime overrides + effective config view; <code>/config</code> chat updates + <code>/config get</code>; <code>config --section</code>.</li>
<li>CLI/Gateway: unified message tool + message subcommands; gateway discover (local + wide-area DNS-SD) with JSON/timeout; gateway status human-readable + JSON + SSH loopback; wide-area records include gatewayPort/sshPort/cliPath + tailnet DNS fallback.</li>
<li>CLI UX: logs output modes (pretty/plain/JSONL) + colorized health/daemon output; global <code>--no-color</code>; lobster palette in onboarding/config.</li>
<li>Dev ergonomics: gateway <code>--dev/--reset</code> + dev profile auto-config; C-3PO dev templates; dev gateway/TUI helper scripts.</li>
<li>Sandbox/Workspace: sandbox list/recreate commands; sync skills into sandbox workspace; sandbox browser auto-start.</li>
<li>Config/Onboarding: inline env vars; OpenAI API key flow to shared <code>~/.clawdbot/.env</code>; Opus 4.5 default prompt for Anthropic auth; QuickStart auto-install gateway (Node-only) + provider picker tweaks + skip-systemd flags; TUI bootstrap prompt (<code>tui --message</code>); remove Bun runtime choice.</li>
<li>Providers: Microsoft Teams provider (polling, attachments, outbound sends, requireMention, config reload/DM policy). (#404) — thanks @onutc</li>
<li>Providers: WhatsApp broadcast groups for multi-agent replies (#547) — thanks @pasogott; inbound media size cap configurable (#505) — thanks @koala73; identity-based message prefixes (#578) — thanks @p6l-richard.</li>
<li>Providers: Telegram inline keyboard buttons + callback payload routing (#491) — thanks @azade-c; cron topic delivery targets (#474/#478) — thanks @mitschabaude-bot, @nachoiacovino; <code>[[audio_as_voice]]</code> tag support (#490) — thanks @jarvis-medmatic.</li>
<li>Providers: Signal reactions + notifications with allowlist support.</li>
<li>Status/Usage: /status cost reporting + <code>/cost</code> lines; auth profile snippet; provider usage windows.</li>
<li>Control UI: mobile responsiveness (#558) — thanks @carlulsoe; queued messages + Enter-to-send (#527) — thanks @YuriNachos; session links (#471) — thanks @HazAT; reasoning view; skill install feedback (#445) — thanks @pkrmf; chat layout refresh (#475) — thanks @rahthakor; docs link + new session button; drop explicit <code>ui:install</code>.</li>
<li>TUI: agent picker + agents list RPC; improved status line.</li>
<li>Doctor/Daemon: audit/repair flows, permissions checks, supervisor config audits; provider status probes + warnings for Discord intents and Telegram privacy; last activity timestamps; gateway restart guidance.</li>
<li>Docs: Hetzner Docker VPS guide + cross-links (#556/#592) — thanks @Iamadig; Ansible guide (#545) — thanks @pasogott; provider troubleshooting index; hook parameter expansion (#532) — thanks @mcinteerj; model allowlist notes; OAuth deep dive; showcase refresh.</li>
<li>Apps/Branding: refreshed iOS/Android/macOS icons (#521) — thanks @fishfisher.</li>
</ul>
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
<h3>Fixes</h3>
<ul>
<li>Packaging: include MS Teams send module in npm tarball.</li>
<li>Sandbox/Browser: auto-start CDP endpoint; proxy CDP out of container for attachOnly; relax Bun fetch typing; align sandbox list output with config images.</li>
<li>Agents/Runtime: gate heartbeat prompt to default sessions; /stop aborts between tool calls; require explicit system-event session keys; guard small context windows; fix model fallback stringification; sessions_spawn inherits provider; failover on billing/credits; respect auth cooldown ordering; restore Anthropic OAuth tool dispatch + tool-name bypass; avoid OpenAI invalid reasoning replay; harden Gmail hook model defaults.</li>
<li>Agent history/schema: strip/skip empty assistant/error blocks to prevent session corruption/Claude 400s; scrub unsupported JSON Schema keywords + sanitize tool call IDs for Cloud Code Assist; simplify Gemini-compatible tool/session schemas; require raw for config.apply.</li>
<li>Auto-reply/Streaming: default audioAsVoice false; preserve audio_as_voice propagation + buffer audio blocks + guard voice notes; block reply ordering (timeout) + forced-block fence-safe; avoid chunk splits inside parentheses + fence-close breaks + invalid UTF-16 truncation; preserve inline directive spacing + allow whitespace in reply tags; filter NO_REPLY prefixes + normalize routed replies; suppress <think> leakage with separate Reasoning; block streaming defaults (off by default, minChars/idle tuning) + coalesced blocks; dedupe followup queue; restore explicit responsePrefix default.</li>
<li>Status/Commands: provider prefix in /status model display; usage filtering + provider mapping; auth label + usage snapshots (claude-cli fallback + optional claude.ai); show Verbose/Elevated only when enabled; compact usage/cost line + restore emoji-rich status; /status in directive-only + multi-directive handling; mention-bypass elevated handling; surface provider usage errors; wire /usage to /status; restore hidden gateway-daemon alias; fallback /model list when catalog unavailable.</li>
<li>WhatsApp: vCard/contact cards (prefer FN, include numbers, show all contacts, keep summary counts, better empty summaries); preserve group JIDs + normalize targets; resolve @lid mappings/JIDs (Baileys/auth-dir) + inbound mapping; route queued replies to sender; improve web listener errors + remove provider name from errors; record outbound activity account id; fix web media fetch errors; broadcast group history consistency.</li>
<li>Telegram: keep streamMode draft-only; long-poll conflict retries + update dedupe; grammY fetch mismatch fixes + restrict native fetch to Bun; suppress getUpdates stack traces; include user id in pairing; audio_as_voice handling fixes.</li>
<li>Discord/Slack: thread context helpers + forum thread starters; avoid category parent overrides; gateway reconnect logs + HELLO timeout + stop provider after reconnect exhaustion; DM recipient parsing for numeric IDs; remove incorrect limited warning; reply threading + mrkdwn edge cases; remove ack reactions after reply; gateway debug event visibility.</li>
<li>Signal: reaction handling safety; own-reaction matching (uuid+phone); UUID-only senders accepted; ignore reaction-only messages.</li>
<li>MS Teams: download image attachments reliably; fix top-level replies; stop on shutdown + honor chunk limits; normalize poll providers/deps; pairing label fixes.</li>
<li>iMessage: isolate group-ish threads by chat_id.</li>
<li>Gateway/Daemon/Doctor: atomic config writes; repair gateway service entrypoint + install switches; non-interactive legacy migrations; systemd unit alignment + KillMode=process; node bridge keepalive/pings; Launch at Login persistence; bundle ClawdbotKit resources + Swift 6.2 compat dylib; relay version check + remove smoke test; regen Swift GatewayModels + keep agent provider string; cron jobId alias + channel alias migration + main session key normalization; heartbeat Telegram accountId resolution; avoid WhatsApp fallback for internal runs; gateway listener error wording; serveBaseUrl param; honor gateway --dev; fix wide-area discovery updates; align agents.defaults schema; provider account metadata in daemon status; refresh Carbon patch for gateway fixes; restore doctor prompter initialValue handling.</li>
<li>Control UI/TUI: persist per-session verbose off + hide tool cards; logs tab opens at bottom; relative asset paths + landing cleanup; session labels lookup/persistence; stop pinning main session in recents; start logs at bottom; TUI status bar refresh + timeout handling + hide reasoning label when off.</li>
<li>Onboarding/Configure: QuickStart single-select provider picker; avoid Codex CLI false-expiry warnings; clarify WhatsApp owner prompt; fix Minimax hosted onboarding (agents.defaults + msteams heartbeat target); remove configure Control UI prompt; honor gateway --dev flag.</li>
</ul>
<h3>Maintenance</h3>
<ul>
<li>Dependencies: bump pi-* stack to 0.42.2.</li>
<li>Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj.</li>
<li>Build: Docker build cache layer (#605) — thanks @zknicker.</li>
<li>NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid <code>ERR_MODULE_NOT_FOUND</code> in Node 25 npx installs.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.9/Clawdbot-2026.1.9.zip" length="164165451" type="application/octet-stream" sparkle:edSignature="jlfk60gLuMIEOru5kFe7K7lLwsx1RBZGdUnkOR7FR3GAfLum7ocUb+QUO0ZtllUoGPEK0N1yc5BLB702LgLtAQ=="/>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-3/Clawdbot-2026.1.5-3.zip" length="160800596" type="application/octet-stream" sparkle:edSignature="P8U3nvIFpbGmRItT/NGPmJ/i370OMVvDHYQL/znYsLI0MrbGfXgMGEvR5A0uwW+cJevlX/hrJLiY51zo4rAMBg=="/>
</item>
<item>
<title>2026.1.5-3</title>
<pubDate>Mon, 05 Jan 2026 03:57:59 +0100</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>3091</sparkle:version>
<sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
<h3>Fixes</h3>
<ul>
<li>NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid <code>ERR_MODULE_NOT_FOUND</code> in Node 25 npx installs.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-3/Clawdbot-2026.1.5-3.zip" length="160797048" type="application/octet-stream" sparkle:edSignature="5KYFg0SW7liwLxLJbfzd2KsAxbX06gMH0rH/W3a4V0p4N48hjz4AsSrfFLdGZSnW+6XaJjC3MN6Ynh+l7kffDQ=="/>
</item>
<item>
<title>2026.1.5-2</title>
<pubDate>Mon, 05 Jan 2026 03:51:30 +0100</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>3089</sparkle:version>
<sparkle:shortVersionString>2026.1.5-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.5-2</h2>
<h3>Fixes</h3>
<ul>
<li>NPM package: include <code>dist/sessions</code> so <code>clawdbot agent</code> resolves session helpers in npx installs.</li>
<li>Node 25: avoid unsupported directory import by targeting <code>qrcode-terminal/vendor/QRCode/*.js</code> modules.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-2/Clawdbot-2026.1.5-2.zip" length="150250417" type="application/octet-stream" sparkle:edSignature="ntHNmwyHrv6cPk6NAKOT3AUkwdt5ZadrGU6mJK4GmVxi44uIMT3ZXluvnqK9SxXQwA0H0dXjiGMS/cg8NbgqDA=="/>
</item>
</channel>
</rss>
-3
View File
@@ -49,7 +49,6 @@ android {
lint {
disable += setOf("IconLauncherShape")
warningsAsErrors = true
}
testOptions {
@@ -73,7 +72,6 @@ androidComponents {
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
}
@@ -102,7 +100,6 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
implementation("androidx.security:security-crypto:1.1.0")
implementation("androidx.exifinterface:exifinterface:1.4.2")
// CameraX (for node.invoke camera.* parity)
implementation("androidx.camera:camera-core:1.5.2")
@@ -1,26 +1,8 @@
package com.clawdbot.android
import android.app.Application
import android.os.StrictMode
class NodeApp : Application() {
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
.build(),
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectAll()
.penaltyLog()
.build(),
)
}
}
}
@@ -700,8 +700,8 @@ class NodeRuntime(context: Context) {
val ui = config?.get("ui").asObjectOrNull()
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
val sessionCfg = config?.get("session").asObjectOrNull()
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
_mainSessionKey.value = mainKey
val rawMainKey = sessionCfg?.get("mainKey").asStringOrNull()?.trim()
_mainSessionKey.value = rawMainKey?.takeIf { it.isNotEmpty() } ?: "main"
val parsed = parseHexColorArgb(raw)
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
@@ -1,6 +0,0 @@
package com.clawdbot.android
internal fun normalizeMainKey(raw: String?): String {
val trimmed = raw?.trim()
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
}
@@ -8,7 +8,7 @@ import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.util.Base64
import android.content.pm.PackageManager
import androidx.exifinterface.media.ExifInterface
import android.media.ExifInterface
import androidx.lifecycle.LifecycleOwner
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
@@ -122,7 +122,13 @@ class ScreenRecordManager(private val context: Context) {
)
}
private fun createMediaRecorder(): MediaRecorder = MediaRecorder(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 =
@@ -21,7 +21,6 @@ import android.speech.tts.UtteranceProgressListener
import android.util.Log
import androidx.core.content.ContextCompat
import com.clawdbot.android.bridge.BridgeSession
import com.clawdbot.android.normalizeMainKey
import java.net.HttpURLConnection
import java.net.URL
import java.util.UUID
@@ -815,7 +814,7 @@ class TalkModeManager(
val config = root?.get("config").asObjectOrNull()
val talk = config?.get("talk").asObjectOrNull()
val sessionCfg = config?.get("session").asObjectOrNull()
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
val mainKey = sessionCfg?.get("mainKey").asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: "main"
val voice = talk?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val aliases =
talk?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) ->
+12 -4
View File
@@ -161,10 +161,18 @@ actor BridgeClient {
purpose: String,
_ op: @escaping @Sendable () async throws -> T) async throws -> T
{
try await AsyncTimeout.withTimeout(
seconds: Double(seconds),
onTimeout: { TimeoutError(purpose: purpose, seconds: seconds) },
operation: op)
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await op()
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000)
throw TimeoutError(purpose: purpose, seconds: seconds)
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
private func startAndWaitForReady(_ connection: NWConnection, queue: DispatchQueue) async throws {
@@ -115,11 +115,7 @@ final class BridgeConnectionController {
self.didAutoConnect = true
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
self.startAutoConnect(
endpoint: endpoint,
bridgeStableID: BridgeEndpointID.stableID(endpoint),
token: token,
instanceId: instanceId)
self.startAutoConnect(endpoint: endpoint, token: token, instanceId: instanceId)
return
}
@@ -136,11 +132,7 @@ final class BridgeConnectionController {
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
self.didAutoConnect = true
self.startAutoConnect(
endpoint: target.endpoint,
bridgeStableID: target.stableID,
token: token,
instanceId: instanceId)
self.startAutoConnect(endpoint: target.endpoint, token: token, instanceId: instanceId)
}
private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
@@ -179,12 +171,7 @@ final class BridgeConnectionController {
"bridge-token.\(instanceId)"
}
private func startAutoConnect(
endpoint: NWEndpoint,
bridgeStableID: String,
token: String,
instanceId: String)
{
private func startAutoConnect(endpoint: NWEndpoint, token: String, instanceId: String) {
guard let appModel else { return }
Task { [weak self] in
guard let self else { return }
@@ -205,10 +192,7 @@ final class BridgeConnectionController {
service: "com.clawdbot.bridge",
account: self.keychainAccount(instanceId: instanceId))
}
appModel.connectToBridge(
endpoint: endpoint,
bridgeStableID: bridgeStableID,
hello: self.makeHello(token: resolvedToken))
appModel.connectToBridge(endpoint: endpoint, hello: self.makeHello(token: resolvedToken))
} catch {
await MainActor.run {
appModel.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
+14 -4
View File
@@ -321,10 +321,20 @@ actor BridgeSession {
seconds: Double,
operation: @escaping @Sendable () async throws -> T) async throws -> T
{
try await AsyncTimeout.withTimeout(
seconds: seconds,
onTimeout: { TimeoutError(message: "UNAVAILABLE: connection timeout") },
operation: operation)
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw TimeoutError(message: "UNAVAILABLE: connection timeout")
}
guard let first = try await group.next() else {
throw TimeoutError(message: "UNAVAILABLE: connection timeout")
}
group.cancelAll()
return first
}
}
private static func makeStateStream(for connection: NWConnection) -> AsyncStream<NWConnection.State> {
+2 -2
View File
@@ -35,10 +35,10 @@
<string>Clawdbot can capture photos or short video clips when requested via the bridge.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Clawdbot discovers and connects to your Clawdbot bridge on the local network.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Clawdbot can share your location in the background when you enable Always.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Clawdbot uses your location when you allow location sharing.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Clawdbot can share your location in the background when you enable Always.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Clawdbot needs microphone access for voice wake.</string>
<key>NSSpeechRecognitionUsageDescription</key>
+31 -27
View File
@@ -86,11 +86,24 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
}
}
private func withTimeout<T: Sendable>(
private func withTimeout<T>(
timeoutMs: Int,
operation: @escaping @Sendable () async throws -> T) async throws -> T
operation: @escaping () async throws -> T) async throws -> T
{
try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: { Error.timeout }, operation: operation)
if timeoutMs == 0 {
return try await operation()
}
return try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
throw Error.timeout
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
private static func accuracyValue(_ accuracy: ClawdbotLocationAccuracy) -> CLLocationAccuracy {
@@ -104,35 +117,26 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
}
}
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
Task { @MainActor in
if let cont = self.authContinuation {
self.authContinuation = nil
cont.resume(returning: status)
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
if let cont = self.authContinuation {
self.authContinuation = nil
cont.resume(returning: manager.authorizationStatus)
}
}
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
let locs = locations
Task { @MainActor in
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
if let latest = locs.last {
cont.resume(returning: latest)
} else {
cont.resume(throwing: Error.unavailable)
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
if let latest = locations.last {
cont.resume(returning: latest)
} else {
cont.resume(throwing: Error.unavailable)
}
}
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
let err = error
Task { @MainActor in
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
cont.resume(throwing: err)
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
cont.resume(throwing: error)
}
}
+3 -4
View File
@@ -204,14 +204,12 @@ final class NodeAppModel {
func connectToBridge(
endpoint: NWEndpoint,
bridgeStableID: String,
hello: BridgeHello)
{
self.bridgeTask?.cancel()
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
let id = bridgeStableID.trimmingCharacters(in: .whitespacesAndNewlines)
self.connectedBridgeID = id.isEmpty ? BridgeEndpointID.stableID(endpoint) : id
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
@@ -332,7 +330,8 @@ final class NodeAppModel {
let ui = config["ui"] as? [String: Any]
let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let session = config["session"] as? [String: Any]
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
let rawMainKey = (session?["mainKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let mainKey = rawMainKey.isEmpty ? "main" : rawMainKey
await MainActor.run {
self.seamColorHex = raw.isEmpty ? nil : raw
self.mainSessionKey = mainKey
-8
View File
@@ -1,8 +0,0 @@
import Foundation
enum SessionKey {
static func normalizeMainKey(_ raw: String?) -> String {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "main" : trimmed
}
}
@@ -425,7 +425,6 @@ struct SettingsTab: View {
self.appModel.connectToBridge(
endpoint: bridge.endpoint,
bridgeStableID: bridge.stableID,
hello: BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
@@ -500,7 +499,6 @@ struct SettingsTab: View {
self.appModel.connectToBridge(
endpoint: endpoint,
bridgeStableID: BridgeEndpointID.stableID(endpoint),
hello: BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
+8 -5
View File
@@ -288,8 +288,9 @@ final class TalkModeManager: NSObject {
self.chatSubscribedSessionKeys.insert(key)
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
} catch {
let err = error.localizedDescription
self.logger.warning("chat.subscribe failed key=\(key, privacy: .public) err=\(err, privacy: .public)")
self.logger.warning(
"chat.subscribe failed sessionKey=\(key, privacy: .public) " +
"err=\(error.localizedDescription, privacy: .public)")
}
}
@@ -527,8 +528,9 @@ final class TalkModeManager: NSObject {
self.lastPlaybackWasPCM = false
result = await self.mp3Player.play(stream: stream)
}
let duration = Date().timeIntervalSince(started)
self.logger.info("elevenlabs stream finished=\(result.finished, privacy: .public) dur=\(duration, privacy: .public)s")
self.logger.info(
"elevenlabs stream finished=\(result.finished, privacy: .public) " +
"dur=\(Date().timeIntervalSince(started), privacy: .public)s")
if !result.finished, let interruptedAt = result.interruptedAt {
self.lastInterruptedAtSeconds = interruptedAt
}
@@ -649,7 +651,8 @@ final class TalkModeManager: NSObject {
guard let config = json["config"] as? [String: Any] else { return }
let talk = config["talk"] as? [String: Any]
let session = config["session"] as? [String: Any]
self.mainSessionKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
let rawMainKey = (session?["mainKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
self.mainSessionKey = rawMainKey.isEmpty ? "main" : rawMainKey
self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let aliases = talk?["voiceAliases"] as? [String: Any] {
var resolved: [String: String] = [:]
@@ -175,6 +175,7 @@ private func withKeychainValues<T>(
}
@Test @MainActor func makeHelloBuildsCapsAndCommands() {
let defaults = UserDefaults.standard
let voiceWakeKey = VoiceWakePreferences.enabledKey
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
-24
View File
@@ -5,10 +5,6 @@ options:
iOS: "17.0"
xcodeVersion: "16.0"
settings:
base:
SWIFT_VERSION: "6.0"
packages:
ClawdbotKit:
path: ../shared/ClawdbotKit
@@ -72,15 +68,11 @@ targets:
PRODUCT_BUNDLE_IDENTIFIER: com.clawdbot.ios
PROVISIONING_PROFILE_SPECIFIER: "com.clawdbot.ios Development"
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
ENABLE_APPINTENTS_METADATA: NO
info:
path: Sources/Info.plist
properties:
CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.1.9"
CFBundleVersion: "20260109"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -92,20 +84,8 @@ targets:
NSBonjourServices:
- _clawdbot-bridge._tcp
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the bridge.
NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing.
NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always.
NSMicrophoneUsageDescription: Clawdbot needs microphone access for voice wake.
NSSpeechRecognitionUsageDescription: Clawdbot uses on-device speech recognition for voice wake.
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
ClawdbotTests:
type: bundle.unit-test
@@ -116,17 +96,13 @@ targets:
- target: Clawdbot
- package: Swabble
product: SwabbleKit
- sdk: AppIntents.framework
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.clawdbot.ios.tests
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Clawdbot.app/Clawdbot"
BUNDLE_LOADER: "$(TEST_HOST)"
info:
path: Tests/Info.plist
properties:
CFBundleDisplayName: ClawdbotTests
CFBundleShortVersionString: "2026.1.9"
CFBundleVersion: "20260109"
-22
View File
@@ -10,9 +10,7 @@ let package = Package(
],
products: [
.library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]),
.library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]),
.executable(name: "Clawdbot", targets: ["Clawdbot"]),
.executable(name: "clawdbot-mac-discovery", targets: ["ClawdbotDiscoveryCLI"]),
],
dependencies: [
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
@@ -38,20 +36,10 @@ let package = Package(
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.target(
name: "ClawdbotDiscovery",
dependencies: [
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
],
path: "Sources/ClawdbotDiscovery",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "Clawdbot",
dependencies: [
"ClawdbotIPC",
"ClawdbotDiscovery",
"ClawdbotProtocol",
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
.product(name: "ClawdbotChatUI", package: "ClawdbotKit"),
@@ -73,21 +61,11 @@ let package = Package(
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "ClawdbotDiscoveryCLI",
dependencies: [
"ClawdbotDiscovery",
],
path: "Sources/ClawdbotDiscoveryCLI",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.testTarget(
name: "ClawdbotIPCTests",
dependencies: [
"ClawdbotIPC",
"Clawdbot",
"ClawdbotDiscovery",
"ClawdbotProtocol",
.product(name: "SwabbleKit", package: "swabble"),
],
+1 -4
View File
@@ -29,10 +29,7 @@ final class AppState {
}
var launchAtLogin: Bool {
didSet {
guard !self.isInitializing else { return }
self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } }
}
didSet { self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } } }
}
var onboardingSeen: Bool {
@@ -1,16 +1,12 @@
import Foundation
public enum AsyncTimeout {
public static func withTimeout<T: Sendable>(
enum AsyncTimeout {
static func withTimeout<T: Sendable>(
seconds: Double,
onTimeout: @escaping @Sendable () -> Error,
operation: @escaping @Sendable () async throws -> T) async throws -> T
{
let clamped = max(0, seconds)
if clamped == 0 {
return try await operation()
}
return try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
@@ -23,14 +19,4 @@ public enum AsyncTimeout {
throw onTimeout()
}
}
public static func withTimeoutMs<T: Sendable>(
timeoutMs: Int,
onTimeout: @escaping @Sendable () -> Error,
operation: @escaping @Sendable () async throws -> T) async throws -> T
{
let clamped = max(0, timeoutMs)
let seconds = Double(clamped) / 1000.0
return try await self.withTimeout(seconds: seconds, onTimeout: onTimeout, operation: operation)
}
}
@@ -2,8 +2,8 @@ import ClawdbotKit
import Foundation
import Network
public enum BridgeEndpointID {
public static func stableID(_ endpoint: NWEndpoint) -> String {
enum BridgeEndpointID {
static func stableID(_ endpoint: NWEndpoint) -> String {
switch endpoint {
case let .service(name, type, domain, _):
// Keep stable across encoded/decoded differences (e.g. \032 for spaces).
@@ -14,7 +14,7 @@ public enum BridgeEndpointID {
}
}
public static func prettyDescription(_ endpoint: NWEndpoint) -> String {
static func prettyDescription(_ endpoint: NWEndpoint) -> String {
BonjourEscapes.decode(String(describing: endpoint))
}
+45 -222
View File
@@ -12,12 +12,11 @@ struct ConfigSettings: View {
"Clawd uses a separate Chrome profile and ports (default 18791/18792) "
+ "so it won’t interfere with your daily browser."
@State private var configModel: String = ""
@State private var customModel: String = ""
@State private var configSaving = false
@State private var hasLoaded = false
@State private var models: [ModelChoice] = []
@State private var modelsLoading = false
@State private var modelSearchQuery: String = ""
@State private var isModelPickerOpen = false
@State private var modelError: String?
@State private var modelsSourceLabel: String?
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
@@ -37,10 +36,10 @@ struct ConfigSettings: View {
@State private var talkInterruptOnSpeech: Bool = true
@State private var talkApiKey: String = ""
@State private var gatewayApiKeyFound = false
@FocusState private var modelSearchFocused: Bool
private struct ConfigDraft {
let configModel: String
let customModel: String
let heartbeatMinutes: Int?
let heartbeatBody: String
let browserEnabled: Bool
@@ -107,7 +106,8 @@ struct ConfigSettings: View {
GridRow {
self.gridLabel("Model")
VStack(alignment: .leading, spacing: 6) {
self.modelPickerField
self.modelPicker
self.customModelField
self.modelMetaLabels
}
}
@@ -116,114 +116,37 @@ struct ConfigSettings: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
private var modelPickerField: some View {
Button {
guard !self.modelsLoading else { return }
self.isModelPickerOpen = true
} label: {
HStack(spacing: 8) {
Text(self.modelPickerLabel)
.foregroundStyle(self.modelPickerLabelIsPlaceholder ? .secondary : .primary)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 8)
Image(systemName: "chevron.up.chevron.down")
.foregroundStyle(.secondary)
private var modelPicker: some View {
Picker("Model", selection: self.$configModel) {
ForEach(self.models) { choice in
Text("\(choice.name) — \(choice.provider.uppercased())")
.tag(choice.id)
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 6)
.fill(
Color(nsColor: .textBackgroundColor)))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(
Color.secondary.opacity(0.25),
lineWidth: 1))
.popover(isPresented: self.$isModelPickerOpen, arrowEdge: .bottom) {
self.modelPickerPopover
Text("Manual entry…").tag("__custom__")
}
.labelsHidden()
.frame(maxWidth: .infinity)
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
.onChange(of: self.isModelPickerOpen) { _, isOpen in
if isOpen {
self.modelSearchQuery = ""
self.modelSearchFocused = true
}
.onChange(of: self.configModel) { _, _ in
self.autosaveConfig()
}
}
private var modelPickerPopover: some View {
VStack(alignment: .leading, spacing: 10) {
TextField("Search models", text: self.$modelSearchQuery)
@ViewBuilder
private var customModelField: some View {
if self.configModel == "__custom__" {
TextField("Enter model ID", text: self.$customModel)
.textFieldStyle(.roundedBorder)
.focused(self.$modelSearchFocused)
.controlSize(.small)
.onSubmit {
if let exact = self.exactMatchForQuery() {
self.selectModel(exact)
return
}
if let manual = self.manualEntryCandidate {
self.selectManualModel(manual)
return
}
if self.modelSearchMatches.count == 1 {
self.selectModel(self.modelSearchMatches[0])
}
.frame(maxWidth: .infinity)
.onChange(of: self.customModel) { _, newValue in
self.configModel = newValue
self.autosaveConfig()
}
List {
if self.modelSearchMatches.isEmpty {
Text("No models match \"\(self.modelSearchQuery)\"")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
ForEach(self.modelSearchMatches) { choice in
Button {
self.selectModel(choice)
} label: {
HStack(spacing: 8) {
Text(choice.name)
.lineLimit(1)
Spacer(minLength: 8)
Text(choice.provider.uppercased())
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.vertical, 2)
.padding(.horizontal, 6)
.background(Color.secondary.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 4))
}
.padding(.vertical, 2)
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
}
}
if let manual = self.manualEntryCandidate {
Button("Use \"\(manual)\"") {
self.selectManualModel(manual)
}
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
}
}
.listStyle(.inset)
}
.frame(width: 340, height: 260)
.padding(8)
}
@ViewBuilder
private var modelMetaLabels: some View {
if self.shouldShowProviderHintForSelection {
self.statusLine(label: "Tip: prefer provider/model (e.g. openai-codex/gpt-5.2)", color: .orange)
}
if let contextLabel = self.selectedContextLabel {
Text(contextLabel)
.font(.footnote)
@@ -480,8 +403,10 @@ struct ConfigSettings: View {
}()
if !loadedModel.isEmpty {
self.configModel = loadedModel
self.customModel = loadedModel
} else {
self.configModel = SessionLoader.fallbackModel
self.customModel = SessionLoader.fallbackModel
}
if let heartbeatEvery {
@@ -534,6 +459,7 @@ struct ConfigSettings: View {
defer { self.configSaving = false }
let configModel = self.configModel
let customModel = self.customModel
let heartbeatMinutes = self.heartbeatMinutes
let heartbeatBody = self.heartbeatBody
let browserEnabled = self.browserEnabled
@@ -546,6 +472,7 @@ struct ConfigSettings: View {
let draft = ConfigDraft(
configModel: configModel,
customModel: customModel,
heartbeatMinutes: heartbeatMinutes,
heartbeatBody: heartbeatBody,
browserEnabled: browserEnabled,
@@ -571,7 +498,8 @@ struct ConfigSettings: View {
var browser = root["browser"] as? [String: Any] ?? [:]
var talk = root["talk"] as? [String: Any] ?? [:]
let chosenModel = draft.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
let chosenModel = (draft.configModel == "__custom__" ? draft.customModel : draft.configModel)
.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedModel = chosenModel
if !trimmedModel.isEmpty {
var model = defaults["model"] as? [String: Any] ?? [:]
@@ -750,11 +678,23 @@ struct ConfigSettings: View {
timeoutMs: 15000)
self.models = res.models
self.modelsSourceLabel = "gateway"
if !self.configModel.isEmpty,
!res.models.contains(where: { $0.id == self.configModel })
{
self.customModel = self.configModel
self.configModel = "__custom__"
}
} catch {
do {
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
self.models = loaded
self.modelsSourceLabel = "local fallback"
if !self.configModel.isEmpty,
!loaded.contains(where: { $0.id == self.configModel })
{
self.customModel = self.configModel
self.configModel = "__custom__"
}
} catch {
self.modelError = error.localizedDescription
self.models = []
@@ -767,129 +707,11 @@ struct ConfigSettings: View {
let models: [ModelChoice]
}
private var modelSearchMatches: [ModelChoice] {
let raw = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !raw.isEmpty else { return self.models }
let tokens = raw
.split(whereSeparator: { $0.isWhitespace })
.map { token in
token.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
}
.filter { !$0.isEmpty }
guard !tokens.isEmpty else { return self.models }
return self.models.filter { choice in
let haystack = [
choice.id,
choice.name,
choice.provider,
self.modelRef(for: choice),
]
.joined(separator: " ")
.lowercased()
return tokens.allSatisfy { haystack.contains($0) }
}
}
private var selectedModelChoice: ModelChoice? {
guard !self.configModel.isEmpty else { return nil }
return self.models.first(where: { self.matchesConfigModel($0) })
}
private var modelPickerLabel: String {
if let choice = self.selectedModelChoice {
return "\(choice.name) — \(choice.provider.uppercased())"
}
if !self.configModel.isEmpty { return self.configModel }
return "Select model"
}
private var modelPickerLabelIsPlaceholder: Bool {
self.configModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var manualEntryCandidate: String? {
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
guard !cleaned.isEmpty else { return nil }
guard !self.isKnownModelRef(cleaned) else { return nil }
return cleaned
}
private func isKnownModelRef(_ value: String) -> Bool {
let needle = value.lowercased()
return self.models.contains { choice in
choice.id.lowercased() == needle
|| self.modelRef(for: choice).lowercased() == needle
}
}
private func modelRef(for choice: ModelChoice) -> String {
let id = choice.id.trimmingCharacters(in: .whitespacesAndNewlines)
let provider = choice.provider.trimmingCharacters(in: .whitespacesAndNewlines)
guard !provider.isEmpty else { return id }
let normalizedProvider = provider.lowercased()
if id.lowercased().hasPrefix("\(normalizedProvider)/") {
return id
}
return "\(normalizedProvider)/\(id)"
}
private func matchesConfigModel(_ choice: ModelChoice) -> Bool {
let configured = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
guard !configured.isEmpty else { return false }
if configured.caseInsensitiveCompare(choice.id) == .orderedSame { return true }
let ref = self.modelRef(for: choice)
return configured.caseInsensitiveCompare(ref) == .orderedSame
}
private func exactMatchForQuery() -> ModelChoice? {
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%")).lowercased()
guard !cleaned.isEmpty else { return nil }
return self.models.first(where: { choice in
let id = choice.id.lowercased()
if id == cleaned { return true }
return self.modelRef(for: choice).lowercased() == cleaned
})
}
private var shouldShowProviderHint: Bool {
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
return !cleaned.contains("/")
}
private var shouldShowProviderHintForSelection: Bool {
let trimmed = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
return !trimmed.contains("/")
}
private func selectModel(_ choice: ModelChoice) {
self.configModel = self.modelRef(for: choice)
self.autosaveConfig()
self.isModelPickerOpen = false
}
private func selectManualModel(_ value: String) {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let slash = trimmed.firstIndex(of: "/") {
let provider = trimmed[..<slash].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let model = trimmed[trimmed.index(after: slash)...].trimmingCharacters(in: .whitespacesAndNewlines)
self.configModel = provider.isEmpty ? String(model) : "\(provider)/\(model)"
} else {
self.configModel = trimmed
}
self.autosaveConfig()
self.isModelPickerOpen = false
}
private var selectedContextLabel: String? {
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
guard
let choice = self.selectedModelChoice,
!chosenId.isEmpty,
let choice = self.models.first(where: { $0.id == chosenId }),
let context = choice.contextWindow
else {
return nil
@@ -900,7 +722,8 @@ struct ConfigSettings: View {
}
private var selectedAnthropicAuthMode: AnthropicAuthMode? {
guard let choice = self.selectedModelChoice else { return nil }
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
guard !chosenId.isEmpty, let choice = self.models.first(where: { $0.id == chosenId }) else { return nil }
guard choice.provider.lowercased() == "anthropic" else { return nil }
return AnthropicAuthResolver.resolve()
}
@@ -108,7 +108,6 @@ final class ControlChannel {
self.logger.info(
"control channel configure mode=remote " +
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
self.state = .connecting
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
await self.configure()
} catch {
@@ -183,7 +182,7 @@ final class ControlChannel {
{
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
return
"Gateway rejected token; set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) " +
"Gateway rejected token; set CLAWDBOT_GATEWAY_TOKEN in the mac app environment " +
"or clear it on the gateway. " +
"Reason: \(reason)"
}
@@ -5,7 +5,6 @@ import SwiftUI
enum DebugActions {
private static let verboseDefaultsKey = "clawdbot.debug.verboseMain"
private static let sessionMenuLimit = 12
private static let onboardingSeenKey = "clawdbot.onboardingSeen"
@MainActor
static func openAgentEventsWindow() {
@@ -184,14 +183,6 @@ enum DebugActions {
NSApp.terminate(nil)
}
@MainActor
static func restartOnboarding() {
UserDefaults.standard.set(false, forKey: self.onboardingSeenKey)
UserDefaults.standard.set(0, forKey: onboardingVersionKey)
AppStateStore.shared.onboardingSeen = false
OnboardingController.shared.restart()
}
@MainActor
private static func resolveSessionStorePath() -> String {
let defaultPath = SessionLoader.defaultStorePath
@@ -28,6 +28,7 @@ struct DebugSettings: View {
@State private var tunnelResetInFlight = false
@State private var tunnelResetStatus: String?
@State private var pendingKill: DebugActions.PortListener?
@AppStorage(attachExistingGatewayOnlyKey) private var attachExistingGatewayOnly: Bool = false
@AppStorage(debugFileLogEnabledKey) private var diagnosticsFileLogEnabled: Bool = false
@AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue
@@ -144,6 +145,16 @@ struct DebugSettings: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Attach only")
Toggle("", isOn: self.$attachExistingGatewayOnly)
.labelsHidden()
.toggleStyle(.checkbox)
.help(
"When enabled in local mode, the mac app will only connect " +
"to an already-running gateway " +
"and will not start one itself.")
}
}
let key = DeepLinkHandler.currentKey()
@@ -486,7 +497,6 @@ struct DebugSettings: View {
HStack(spacing: 8) {
Button("Restart app") { DebugActions.restartApp() }
Button("Restart onboarding") { DebugActions.restartOnboarding() }
Button("Reveal app in Finder") { self.revealApp() }
Spacer(minLength: 0)
}
@@ -772,7 +782,7 @@ struct DebugSettings: View {
}
private var canRestartGateway: Bool {
self.state.connectionMode == .local && !self.state.attachExistingGatewayOnly
self.state.connectionMode == .local && !self.attachExistingGatewayOnly
}
private func configURL() -> URL {
@@ -1,4 +1,3 @@
import ClawdbotDiscovery
import SwiftUI
struct GatewayDiscoveryInlineList: View {
@@ -6,79 +6,43 @@ import OSLog
@MainActor
@Observable
public final class GatewayDiscoveryModel {
public struct LocalIdentity: Equatable, Sendable {
public var hostTokens: Set<String>
public var displayTokens: Set<String>
public init(hostTokens: Set<String>, displayTokens: Set<String>) {
self.hostTokens = hostTokens
self.displayTokens = displayTokens
}
final class GatewayDiscoveryModel {
struct LocalIdentity: Equatable {
var hostTokens: Set<String>
var displayTokens: Set<String>
}
public struct DiscoveredGateway: Identifiable, Equatable, Sendable {
public var id: String { self.stableID }
public var displayName: String
public var lanHost: String?
public var tailnetDns: String?
public var sshPort: Int
public var gatewayPort: Int?
public var cliPath: String?
public var stableID: String
public var debugID: String
public var isLocal: Bool
public init(
displayName: String,
lanHost: String? = nil,
tailnetDns: String? = nil,
sshPort: Int,
gatewayPort: Int? = nil,
cliPath: String? = nil,
stableID: String,
debugID: String,
isLocal: Bool)
{
self.displayName = displayName
self.lanHost = lanHost
self.tailnetDns = tailnetDns
self.sshPort = sshPort
self.gatewayPort = gatewayPort
self.cliPath = cliPath
self.stableID = stableID
self.debugID = debugID
self.isLocal = isLocal
}
struct DiscoveredGateway: Identifiable, Equatable {
var id: String { self.stableID }
var displayName: String
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var gatewayPort: Int?
var cliPath: String?
var stableID: String
var debugID: String
var isLocal: Bool
}
public var gateways: [DiscoveredGateway] = []
public var statusText: String = "Idle"
var gateways: [DiscoveredGateway] = []
var statusText: String = "Idle"
private var browsers: [String: NWBrowser] = [:]
private var resultsByDomain: [String: Set<NWBrowser.Result>] = [:]
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
private var statesByDomain: [String: NWBrowser.State] = [:]
private var localIdentity: LocalIdentity
private let localDisplayName: String?
private let filterLocalGateways: Bool
private var resolvedTXTByID: [String: [String: String]] = [:]
private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:]
private var wideAreaFallbackTask: Task<Void, Never>?
private var wideAreaFallbackGateways: [DiscoveredGateway] = []
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-discovery")
public init(
localDisplayName: String? = nil,
filterLocalGateways: Bool = true)
{
self.localDisplayName = localDisplayName
self.filterLocalGateways = filterLocalGateways
self.localIdentity = Self.buildLocalIdentityFast(displayName: localDisplayName)
init() {
self.localIdentity = Self.buildLocalIdentityFast()
self.refreshLocalIdentity()
}
public func start() {
func start() {
if !self.browsers.isEmpty { return }
for domain in ClawdbotBonjour.bridgeServiceDomains {
@@ -108,24 +72,9 @@ public final class GatewayDiscoveryModel {
self.browsers[domain] = browser
browser.start(queue: DispatchQueue(label: "com.clawdbot.macos.gateway-discovery.\(domain)"))
}
self.scheduleWideAreaFallback()
}
public func refreshWideAreaFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
Task.detached(priority: .utility) { [weak self] in
guard let self else { return }
let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: timeoutSeconds)
await MainActor.run { [weak self] in
guard let self else { return }
self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain)
self.recomputeGateways()
}
}
}
public func stop() {
func stop() {
for browser in self.browsers.values {
browser.cancel()
}
@@ -136,52 +85,15 @@ public final class GatewayDiscoveryModel {
self.resolvedTXTByID = [:]
self.pendingTXTResolvers.values.forEach { $0.cancel() }
self.pendingTXTResolvers = [:]
self.wideAreaFallbackTask?.cancel()
self.wideAreaFallbackTask = nil
self.wideAreaFallbackGateways = []
self.gateways = []
self.statusText = "Stopped"
}
private func mapWideAreaBeacons(_ beacons: [WideAreaGatewayBeacon], domain: String) -> [DiscoveredGateway] {
beacons.map { beacon in
let stableID = "wide-area|\(domain)|\(beacon.instanceName)"
let isLocal = Self.isLocalGateway(
lanHost: beacon.lanHost,
tailnetDns: beacon.tailnetDns,
displayName: beacon.displayName,
serviceName: beacon.instanceName,
local: self.localIdentity)
return DiscoveredGateway(
displayName: beacon.displayName,
lanHost: beacon.lanHost,
tailnetDns: beacon.tailnetDns,
sshPort: beacon.sshPort ?? 22,
gatewayPort: beacon.gatewayPort,
cliPath: beacon.cliPath,
stableID: stableID,
debugID: "\(beacon.instanceName)@\(beacon.host):\(beacon.port)",
isLocal: isLocal)
}
}
private func recomputeGateways() {
let primary = self.sortedDeduped(gateways: self.gatewaysByDomain.values.flatMap(\.self))
let primaryFiltered = self.filterLocalGateways ? primary.filter { !$0.isLocal } : primary
if !primaryFiltered.isEmpty {
self.gateways = primaryFiltered
return
}
// Bonjour can return only "local" results for the wide-area domain (or no results at all),
// which makes onboarding look empty even though Tailscale DNS-SD can already see bridges.
guard !self.wideAreaFallbackGateways.isEmpty else {
self.gateways = primaryFiltered
return
}
let combined = self.sortedDeduped(gateways: primary + self.wideAreaFallbackGateways)
self.gateways = self.filterLocalGateways ? combined.filter { !$0.isLocal } : combined
self.gateways = self.gatewaysByDomain.values
.flatMap(\.self)
.filter { !$0.isLocal }
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
}
private func updateGateways(for domain: String) {
@@ -234,74 +146,6 @@ public final class GatewayDiscoveryModel {
isLocal: isLocal)
}
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
if domain == ClawdbotBonjour.wideAreaBridgeServiceDomain,
self.hasUsableWideAreaResults
{
self.wideAreaFallbackGateways = []
}
}
private func scheduleWideAreaFallback() {
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
if Self.isRunningTests { return }
guard self.wideAreaFallbackTask == nil else { return }
self.wideAreaFallbackTask = Task.detached(priority: .utility) { [weak self] in
guard let self else { return }
var attempt = 0
let startedAt = Date()
while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 {
let hasResults = await MainActor.run {
self.hasUsableWideAreaResults
}
if hasResults { return }
// Wide-area discovery can be racy (Tailscale not yet up, DNS zone not
// published yet). Retry with a short backoff while onboarding is open.
let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: 2.0)
if !beacons.isEmpty {
await MainActor.run { [weak self] in
guard let self else { return }
self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain)
self.recomputeGateways()
}
return
}
attempt += 1
let backoff = min(8.0, 0.6 + (Double(attempt) * 0.7))
try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000))
}
}
}
private var hasUsableWideAreaResults: Bool {
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false }
if !self.filterLocalGateways { return true }
return gateways.contains(where: { !$0.isLocal })
}
private func sortedDeduped(gateways: [DiscoveredGateway]) -> [DiscoveredGateway] {
var seen = Set<String>()
let deduped = gateways.filter { gateway in
if seen.contains(gateway.stableID) { return false }
seen.insert(gateway.stableID)
return true
}
return deduped.sorted {
$0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending
}
}
private nonisolated static var isRunningTests: Bool {
// Keep discovery background work from running forever during SwiftPM test runs.
if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true }
let env = ProcessInfo.processInfo.environment
return env["XCTestConfigurationFilePath"] != nil
|| env["XCTestBundlePath"] != nil
|| env["XCTestSessionIdentifier"] != nil
}
private func updateGatewaysForAllDomains() {
@@ -364,15 +208,15 @@ public final class GatewayDiscoveryModel {
return merged
}
public struct GatewayTXT: Equatable {
public var lanHost: String?
public var tailnetDns: String?
public var sshPort: Int
public var gatewayPort: Int?
public var cliPath: String?
struct GatewayTXT: Equatable {
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var gatewayPort: Int?
var cliPath: String?
}
public static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT {
static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT {
var lanHost: String?
var tailnetDns: String?
var sshPort = 22
@@ -412,7 +256,7 @@ public final class GatewayDiscoveryModel {
cliPath: cliPath)
}
public static func buildSSHTarget(user: String, host: String, port: Int) -> String {
static func buildSSHTarget(user: String, host: String, port: Int) -> String {
var target = "\(user)@\(host)"
if port != 22 {
target += ":\(port)"
@@ -480,7 +324,7 @@ public final class GatewayDiscoveryModel {
return titled.isEmpty ? normalized : titled
}
public nonisolated static func isLocalGateway(
nonisolated static func isLocalGateway(
lanHost: String?,
tailnetDns: String?,
displayName: String?,
@@ -502,19 +346,18 @@ public final class GatewayDiscoveryModel {
{
return true
}
if let serviceHost = normalizeServiceHostToken(serviceName),
local.hostTokens.contains(serviceHost)
{
return true
if let service = normalizeServiceToken(serviceName) {
for token in local.hostTokens where service.contains(token) {
return true
}
}
return false
}
private func refreshLocalIdentity() {
let fastIdentity = self.localIdentity
let displayName = self.localDisplayName
Task.detached(priority: .utility) {
let slowIdentity = Self.buildLocalIdentitySlow(displayName: displayName)
let slowIdentity = Self.buildLocalIdentitySlow()
let merged = Self.mergeLocalIdentity(fast: fastIdentity, slow: slowIdentity)
await MainActor.run { [weak self] in
guard let self else { return }
@@ -534,7 +377,7 @@ public final class GatewayDiscoveryModel {
displayTokens: fast.displayTokens.union(slow.displayTokens))
}
private nonisolated static func buildLocalIdentityFast(displayName: String?) -> LocalIdentity {
private nonisolated static func buildLocalIdentityFast() -> LocalIdentity {
var hostTokens: Set<String> = []
var displayTokens: Set<String> = []
@@ -543,14 +386,14 @@ public final class GatewayDiscoveryModel {
hostTokens.insert(token)
}
if let token = normalizeDisplayToken(displayName) {
if let token = normalizeDisplayToken(InstanceIdentity.displayName) {
displayTokens.insert(token)
}
return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens)
}
private nonisolated static func buildLocalIdentitySlow(displayName: String?) -> LocalIdentity {
private nonisolated static func buildLocalIdentitySlow() -> LocalIdentity {
var hostTokens: Set<String> = []
var displayTokens: Set<String> = []
@@ -560,10 +403,6 @@ public final class GatewayDiscoveryModel {
hostTokens.insert(token)
}
if let token = normalizeDisplayToken(displayName) {
displayTokens.insert(token)
}
if let token = normalizeDisplayToken(Host.current().localizedName) {
displayTokens.insert(token)
}
@@ -595,14 +434,11 @@ public final class GatewayDiscoveryModel {
return trimmed.lowercased()
}
private nonisolated static func normalizeServiceHostToken(_ raw: String?) -> String? {
private nonisolated static func normalizeServiceToken(_ raw: String?) -> String? {
guard let raw else { return nil }
let prettified = Self.prettifyInstanceName(raw)
let strippedBridge = prettified.replacingOccurrences(
of: #"\s*-?\s*bridge$"#,
with: "",
options: .regularExpression)
return normalizeHostToken(strippedBridge)
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
return trimmed.lowercased()
}
}
@@ -3,7 +3,6 @@ import OSLog
enum GatewayEndpointState: Sendable, Equatable {
case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?)
case connecting(mode: AppState.ConnectionMode, detail: String)
case unavailable(mode: AppState.ConnectionMode, reason: String)
}
@@ -15,7 +14,6 @@ enum GatewayEndpointState: Sendable, Equatable {
actor GatewayEndpointStore {
static let shared = GatewayEndpointStore()
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
private static let remoteConnectingDetail = "Connecting to remote gateway…"
struct Deps: Sendable {
let mode: @Sendable () async -> AppState.ConnectionMode
@@ -28,13 +26,7 @@ actor GatewayEndpointStore {
static let live = Deps(
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
token: {
let root = ClawdbotConfigFile.loadDict()
return GatewayEndpointStore.resolveGatewayToken(
isRemote: CommandResolver.connectionModeIsRemote(),
root: root,
env: ProcessInfo.processInfo.environment)
},
token: { ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] },
password: {
let root = ClawdbotConfigFile.loadDict()
return GatewayEndpointStore.resolveGatewayPassword(
@@ -91,46 +83,11 @@ actor GatewayEndpointStore {
return nil
}
private static func resolveGatewayToken(
isRemote: Bool,
root: [String: Any],
env: [String: String]) -> String?
{
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
if isRemote {
if let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let token = remote["token"] as? String
{
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return value
}
}
return nil
}
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any],
let token = auth["token"] as? String
{
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return value
}
}
return nil
}
private let deps: Deps
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
private var state: GatewayEndpointState
private var subscribers: [UUID: AsyncStream<GatewayEndpointState>.Continuation] = [:]
private var remoteEnsure: (token: UUID, task: Task<UInt16, Error>)?
init(deps: Deps = .live) {
self.deps = deps
@@ -158,8 +115,7 @@ actor GatewayEndpointStore {
token: token,
password: password)
case .remote:
self.state = .connecting(mode: .remote, detail: Self.remoteConnectingDetail)
Task { await self.setMode(.remote) }
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
case .unconfigured:
self.state = .unavailable(mode: .unconfigured, reason: "Gateway not configured")
}
@@ -188,7 +144,6 @@ actor GatewayEndpointStore {
let password = self.deps.password()
switch mode {
case .local:
self.cancelRemoteEnsure()
let port = self.deps.localPort()
let host = await self.deps.localHost()
self.setState(.ready(
@@ -199,18 +154,15 @@ actor GatewayEndpointStore {
case .remote:
let port = await self.deps.remotePortIfRunning()
guard let port else {
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
self.kickRemoteEnsureIfNeeded(detail: Self.remoteConnectingDetail)
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
return
}
self.cancelRemoteEnsure()
self.setState(.ready(
mode: .remote,
url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
token: token,
password: password))
case .unconfigured:
self.cancelRemoteEnsure()
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
}
}
@@ -224,10 +176,8 @@ actor GatewayEndpointStore {
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Missing tunnel port"])
}
let port = try await self.deps.ensureRemoteTunnel()
await self.setMode(.remote)
return port
}
@@ -236,11 +186,6 @@ actor GatewayEndpointStore {
switch self.state {
case let .ready(_, url, token, password):
return (url, token, password)
case let .connecting(mode, _):
guard mode == .remote else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
}
return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
case let .unavailable(mode, reason):
guard mode == .remote else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason])
@@ -248,73 +193,21 @@ actor GatewayEndpointStore {
// Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet),
// recreate it on demand so callers can recover without a manual reconnect.
self.logger.info(
"endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)")
return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
}
}
private func cancelRemoteEnsure() {
self.remoteEnsure?.task.cancel()
self.remoteEnsure = nil
}
private func kickRemoteEnsureIfNeeded(detail: String) {
if self.remoteEnsure != nil {
self.setState(.connecting(mode: .remote, detail: detail))
return
}
let deps = self.deps
let token = UUID()
let task = Task.detached(priority: .utility) { try await deps.ensureRemoteTunnel() }
self.remoteEnsure = (token: token, task: task)
self.setState(.connecting(mode: .remote, detail: detail))
}
private func ensureRemoteConfig(detail: String) async throws -> GatewayConnection.Config {
let mode = await self.deps.mode()
guard mode == .remote else {
throw NSError(
domain: "RemoteTunnel",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
self.kickRemoteEnsureIfNeeded(detail: detail)
guard let ensure = self.remoteEnsure else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
}
do {
let forwarded = try await ensure.task.value
let stillRemote = await self.deps.mode() == .remote
guard stillRemote else {
throw NSError(domain: "RemoteTunnel", code: 1, userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
do {
self.logger.info(
"endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)")
let forwarded = try await self.deps.ensureRemoteTunnel()
let token = self.deps.token()
let password = self.deps.password()
let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
return (url, token, password)
} catch {
let msg = "\(reason) (\(error.localizedDescription))"
self.setState(.unavailable(mode: .remote, reason: msg))
self.logger.error("remote control tunnel ensure failed \(msg, privacy: .public)")
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg])
}
if self.remoteEnsure?.token == ensure.token {
self.remoteEnsure = nil
}
let token = self.deps.token()
let password = self.deps.password()
let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
return (url, token, password)
} catch let err as CancellationError {
if self.remoteEnsure?.token == ensure.token {
self.remoteEnsure = nil
}
throw err
} catch {
if self.remoteEnsure?.token == ensure.token {
self.remoteEnsure = nil
}
let msg = "Remote control tunnel failed (\(error.localizedDescription))"
self.setState(.unavailable(mode: .remote, reason: msg))
self.logger.error("remote control tunnel ensure failed \(msg, privacy: .public)")
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg])
}
}
@@ -335,11 +228,6 @@ actor GatewayEndpointStore {
self.logger
.debug(
"resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)")
case let .connecting(mode, detail):
let modeDesc = String(describing: mode)
self.logger
.debug(
"endpoint connecting mode=\(modeDesc, privacy: .public) detail=\(detail, privacy: .public)")
case let .unavailable(mode, reason):
let modeDesc = String(describing: mode)
self.logger
@@ -64,7 +64,6 @@ struct GatewayCommandResolution {
enum GatewayEnvironment {
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.env")
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
private static let bundledGatewayLabel = "Bundled gateway"
static func bundledGatewayExecutable() -> String? {
guard let res = Bundle.main.resourceURL else { return nil }
@@ -72,12 +71,6 @@ enum GatewayEnvironment {
return FileManager.default.isExecutableFile(atPath: path) ? path : nil
}
static func bundledNodeExecutable() -> String? {
guard let res = Bundle.main.resourceURL else { return nil }
let path = res.appendingPathComponent("Relay/node").path
return FileManager.default.isExecutableFile(atPath: path) ? path : nil
}
static func gatewayPort() -> Int {
if let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PORT"] {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -114,15 +107,13 @@ enum GatewayEnvironment {
if let bundled = self.bundledGatewayExecutable() {
let installed = self.readGatewayVersion(binary: bundled)
let bundledNode = self.bundledNodeExecutable()
let bundledNodeVersion = bundledNode.flatMap { self.readNodeVersion(binary: $0) }
if let expected, let installed, !installed.compatible(with: expected) {
let message = self.bundledGatewayIncompatibleMessage(
installed: installed,
expected: expected)
let message =
"Bundled gateway \(installed.description) is incompatible with app " +
"\(expected.description); rebuild the app bundle."
return GatewayEnvironmentStatus(
kind: .incompatible(found: installed.description, required: expected.description),
nodeVersion: bundledNodeVersion,
nodeVersion: nil,
gatewayVersion: installed.description,
requiredGateway: expected.description,
message: message)
@@ -130,12 +121,10 @@ enum GatewayEnvironment {
let gatewayVersionText = installed?.description ?? "unknown"
return GatewayEnvironmentStatus(
kind: .ok,
nodeVersion: bundledNodeVersion,
nodeVersion: nil,
gatewayVersion: gatewayVersionText,
requiredGateway: expected?.description,
message: self.bundledGatewayStatusMessage(
gatewayVersion: gatewayVersionText,
nodeVersion: bundledNodeVersion))
message: "Bundled gateway \(gatewayVersionText) (bun)")
}
let projectRoot = CommandResolver.projectRoot()
@@ -362,41 +351,4 @@ enum GatewayEnvironment {
else { return nil }
return Semver.parse(version)
}
private static func readNodeVersion(binary: String) -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: binary)
process.arguments = ["--version"]
process.environment = ["PATH": CommandResolver.preferredPaths().joined(separator: ":")]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readToEndSafely()
let raw = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "^v", with: "", options: .regularExpression)
return raw?.isEmpty == false ? raw : nil
} catch {
return nil
}
}
private static func bundledGatewayStatusMessage(
gatewayVersion: String,
nodeVersion: String?) -> String
{
"\(self.bundledGatewayLabel) \(gatewayVersion) (node \(nodeVersion ?? "unknown"))"
}
private static func bundledGatewayIncompatibleMessage(
installed: Semver,
expected: Semver) -> String
{
"\(self.bundledGatewayLabel) \(installed.description) is incompatible with app " +
"\(expected.description); rebuild the app bundle."
}
}
@@ -4,7 +4,6 @@ enum GatewayLaunchAgentManager {
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway"
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
private static var plistURL: URL {
FileManager.default.homeDirectoryForCurrentUser
@@ -51,10 +50,6 @@ enum GatewayLaunchAgentManager {
}
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
if enabled, self.isLaunchAgentWriteDisabled() {
self.logger.info("launchd enable skipped (attach-only or disable marker set)")
return nil
}
if enabled {
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.legacyPlistURL)
@@ -209,20 +204,7 @@ enum GatewayLaunchAgentManager {
private static func preferredGatewayToken() -> String? {
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
let root = ClawdbotConfigFile.loadDict()
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any],
let token = auth["token"] as? String
{
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return value
}
}
return nil
return trimmed.isEmpty ? nil : trimmed
}
private static func preferredGatewayPassword() -> String? {
@@ -306,17 +288,6 @@ enum GatewayLaunchAgentManager {
}
}
extension GatewayLaunchAgentManager {
private static func isLaunchAgentWriteDisabled() -> Bool {
if UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey) {
return true
}
let marker = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(self.disableLaunchAgentMarker)
return FileManager.default.fileExists(atPath: marker.path)
}
}
#if DEBUG
extension GatewayLaunchAgentManager {
static func _testGatewayExecutablePath(bundlePath: String) -> String {
@@ -239,7 +239,7 @@ final class GatewayProcessManager {
let lower = message.lowercased()
if self.isGatewayAuthFailure(error) {
return """
Gateway on port \(port) rejected auth. Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) \
Gateway on port \(port) rejected auth. Set CLAWDBOT_GATEWAY_TOKEN in the app \
to match the running gateway (or clear it on the gateway) and retry.
"""
}
@@ -1,5 +1,4 @@
import AppKit
import ClawdbotDiscovery
import ClawdbotIPC
import ClawdbotKit
import CoreLocation
@@ -13,8 +12,7 @@ struct GeneralSettings: View {
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
private let healthStore = HealthStore.shared
private let gatewayManager = GatewayProcessManager.shared
@State private var gatewayDiscovery = GatewayDiscoveryModel(
localDisplayName: InstanceIdentity.displayName)
@State private var gatewayDiscovery = GatewayDiscoveryModel()
@State private var isInstallingCLI = false
@State private var cliStatus: String?
@State private var cliInstalled = false
@@ -30,22 +28,10 @@ struct GeneralSettings: View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 18) {
if !self.state.onboardingSeen {
Button {
DebugActions.restartOnboarding()
} label: {
HStack(spacing: 8) {
Label("Complete onboarding to finish setup", systemImage: "arrow.counterclockwise")
.font(.callout.weight(.semibold))
.foregroundStyle(Color.accentColor)
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.bottom, 2)
Text("Complete onboarding to finish setup")
.font(.callout.weight(.semibold))
.foregroundColor(.accentColor)
.padding(.bottom, 2)
}
VStack(alignment: .leading, spacing: 12) {
@@ -164,18 +150,13 @@ struct GeneralSettings: View {
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
guard mode != .off else { return true }
guard CLLocationManager.locationServicesEnabled() else {
await MainActor.run { LocationPermissionHelper.openSettings() }
return false
}
let status = CLLocationManager().authorizationStatus
let requireAlways = mode == .always
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
// Note: macOS only supports authorizedAlways, not authorizedWhenInUse (iOS only)
if status == .authorizedAlways {
return true
}
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
let updated = await LocationPermissionRequester.shared.request(always: mode == .always)
return updated == .authorizedAlways
}
private var connectionSection: some View {
@@ -204,11 +185,6 @@ struct GeneralSettings: View {
if !self.isNixMode {
self.gatewayInstallerCard
}
SettingsToggleRow(
title: "Attach only",
subtitle: "Use this when the gateway runs externally; the mac app will only attach " +
"to an already-running gateway and won't start one locally.",
binding: self.$state.attachExistingGatewayOnly)
TailscaleIntegrationSection(
connectionMode: self.state.connectionMode,
isPaused: self.state.isPaused)
@@ -283,11 +283,6 @@ struct MenuContent: View {
Label("Restart Gateway", systemImage: "arrow.clockwise")
}
}
Button {
DebugActions.restartOnboarding()
} label: {
Label("Restart Onboarding", systemImage: "arrow.counterclockwise")
}
Button {
DebugActions.restartApp()
} label: {
@@ -111,7 +111,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
let width = self.initialWidth(for: menu)
let isConnected = self.isControlChannelConnected
let channelState = ControlChannel.shared.state
var cursor = insertIndex
var headerView: NSView?
@@ -134,7 +133,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(
count: rows.count,
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
statusText: isConnected ? nil : "Gateway disconnected")),
width: width,
highlighted: false)
headerItem.view = hosted
@@ -167,7 +166,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
headerItem.isEnabled = false
let statusText = isConnected
? (self.cachedErrorText ?? "Loading sessions…")
: self.controlChannelStatusText(for: channelState)
: "Gateway disconnected"
let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(
count: 0,
@@ -219,14 +218,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
cursor += 1
}
if case .connecting = ControlChannel.shared.state {
menu.insertItem(
self.makeMessageItem(text: "Connecting…", symbolName: "circle.dashed", width: width),
at: cursor)
cursor += 1
return
}
guard self.isControlChannelConnected else { return }
if let error = self.nodesStore.lastError?.nonEmpty {
@@ -281,36 +272,18 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
}
var cursor = cursor
if cursor > 0, !menu.items[cursor - 1].isSeparatorItem {
let separator = NSMenuItem.separator()
separator.tag = self.tag
menu.insertItem(separator, at: cursor)
cursor += 1
}
let headerItem = NSMenuItem()
headerItem.tag = self.tag
headerItem.isEnabled = false
headerItem.view = self.makeHostedView(
rootView: AnyView(MenuUsageHeaderView(
count: rows.count)),
count: rows.count,
statusText: errorText)),
width: width,
highlighted: false)
menu.insertItem(headerItem, at: cursor)
cursor += 1
if let errorText = errorText?.nonEmpty, !rows.isEmpty {
menu.insertItem(
self.makeMessageItem(
text: errorText,
symbolName: "exclamationmark.triangle",
width: width,
maxLines: 2),
at: cursor)
cursor += 1
}
if rows.isEmpty {
menu.insertItem(
self.makeMessageItem(text: errorText ?? "No usage available", symbolName: "minus", width: width),
@@ -319,28 +292,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
return cursor
}
if let selectedProvider = self.selectedUsageProviderId,
let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }),
rows.count > 1
{
let others = rows.filter { $0.providerId.lowercased() != selectedProvider }
let item = NSMenuItem()
item.tag = self.tag
item.isEnabled = true
if !others.isEmpty {
item.submenu = self.buildUsageOverflowMenu(rows: others, width: width)
}
item.view = self.makeHostedView(
rootView: AnyView(UsageMenuLabelView(row: primary, width: width, showsChevron: !others.isEmpty)),
width: width,
highlighted: true)
menu.insertItem(item, at: cursor)
cursor += 1
return cursor
}
for row in rows {
let item = NSMenuItem()
item.tag = self.tag
@@ -356,34 +307,11 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
return cursor
}
private var selectedUsageProviderId: String? {
guard let model = self.cachedSnapshot?.defaults.model.nonEmpty else { return nil }
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
guard let slash = trimmed.firstIndex(of: "/") else { return nil }
let provider = trimmed[..<slash].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return provider.nonEmpty
}
private var usageRows: [UsageRow] {
guard let summary = self.cachedUsageSummary else { return [] }
return summary.primaryRows()
}
private func buildUsageOverflowMenu(rows: [UsageRow], width: CGFloat) -> NSMenu {
let menu = NSMenu()
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.addItem(item)
}
return menu
}
private var isControlChannelConnected: Bool {
#if DEBUG
if let override = self.testControlChannelConnected { return override }
@@ -392,19 +320,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
return false
}
private func controlChannelStatusText(for state: ControlChannel.ConnectionState) -> String {
switch state {
case .connected:
return "Loading sessions…"
case .connecting:
return "Connecting…"
case let .degraded(message):
return message.nonEmpty ?? "Gateway disconnected"
case .disconnected:
return "Gateway disconnected"
}
}
private func gatewayEntry() -> NodeInfo? {
let mode = AppStateStore.shared.connectionMode
let isConnected = self.isControlChannelConnected
@@ -476,31 +391,18 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
return item
}
private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem {
private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem {
let view = AnyView(
HStack(alignment: .top, spacing: 8) {
Image(systemName: symbolName)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 14, alignment: .leading)
.padding(.top, 1)
Text(text)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
.lineLimit(maxLines)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: true)
.layoutPriority(1)
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 0)
}
.padding(.leading, 18)
.padding(.trailing, 12)
.padding(.vertical, 6)
.frame(width: max(1, width), alignment: .leading))
Label(text, systemImage: symbolName)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 18)
.padding(.trailing, 12)
.padding(.vertical, 6)
.frame(minWidth: 300, alignment: .leading))
let item = NSMenuItem()
item.tag = self.tag
@@ -1036,12 +938,6 @@ extension MenuSessionsInjector {
self.cacheUpdatedAt = Date()
}
func setTestingUsageSummary(_ summary: GatewayUsageSummary?, errorText: String? = nil) {
self.cachedUsageSummary = summary
self.cachedUsageErrorText = errorText
self.usageCacheUpdatedAt = Date()
}
func injectForTesting(into menu: NSMenu) {
self.inject(into: menu)
}
@@ -2,6 +2,7 @@ import SwiftUI
struct MenuUsageHeaderView: View {
let count: Int
let statusText: String?
private let paddingTop: CGFloat = 8
private let paddingBottom: CGFloat = 6
@@ -19,6 +20,14 @@ struct MenuUsageHeaderView: View {
.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)
@@ -1,7 +1,6 @@
import ClawdbotKit
import Foundation
import Network
import OSLog
actor MacNodeBridgeSession {
private struct TimeoutError: LocalizedError {
@@ -16,20 +15,14 @@ actor MacNodeBridgeSession {
case failed(message: String)
}
private let logger = Logger(subsystem: "com.clawdbot", category: "node.bridge-session")
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private let clock = ContinuousClock()
private var disconnectHandler: (@Sendable (String) async -> Void)?
private var connection: NWConnection?
private var queue: DispatchQueue?
private var buffer = Data()
private var pendingRPC: [String: CheckedContinuation<BridgeRPCResponse, Error>] = [:]
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:]
private var invokeTasks: [UUID: Task<Void, Never>] = [:]
private var pingTask: Task<Void, Never>?
private var lastPongAt: ContinuousClock.Instant?
private(set) var state: State = .idle
@@ -37,22 +30,14 @@ actor MacNodeBridgeSession {
endpoint: NWEndpoint,
hello: BridgeHello,
onConnected: (@Sendable (String) async -> Void)? = nil,
onDisconnected: (@Sendable (String) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
async throws
{
await self.disconnect()
self.disconnectHandler = onDisconnected
self.state = .connecting
let params = NWParameters.tcp
params.includePeerToPeer = true
let tcpOptions = NWProtocolTCP.Options()
tcpOptions.enableKeepalive = true
tcpOptions.keepaliveIdle = 30
tcpOptions.keepaliveInterval = 15
tcpOptions.keepaliveCount = 3
params.defaultProtocolStack.transportProtocol = tcpOptions
let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session")
self.connection = connection
@@ -62,10 +47,6 @@ actor MacNodeBridgeSession {
connection.start(queue: queue)
try await Self.waitForReady(stateStream, timeoutSeconds: 6)
connection.stateUpdateHandler = { [weak self] state in
guard let self else { return }
Task { await self.handleConnectionState(state) }
}
try await AsyncTimeout.withTimeout(
seconds: 6,
@@ -87,7 +68,6 @@ actor MacNodeBridgeSession {
let data = line.data(using: .utf8),
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
else {
self.logger.error("node bridge hello failed (unexpected response)")
await self.disconnect()
throw NSError(domain: "Bridge", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response",
@@ -97,72 +77,53 @@ actor MacNodeBridgeSession {
if base.type == "hello-ok" {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName)
self.startPingLoop()
await onConnected?(ok.serverName)
} else if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
self.state = .failed(message: "\(err.code): \(err.message)")
self.logger.error("node bridge hello error: \(err.code, privacy: .public)")
await self.disconnect()
throw NSError(domain: "Bridge", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
])
} else {
self.state = .failed(message: "Unexpected bridge response")
self.logger.error("node bridge hello failed (unexpected frame)")
await self.disconnect()
throw NSError(domain: "Bridge", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response",
])
}
do {
while true {
guard let next = try await self.receiveLine() else { break }
guard let nextData = next.data(using: .utf8) else { continue }
guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue }
while true {
guard let next = try await self.receiveLine() else { break }
guard let nextData = next.data(using: .utf8) else { continue }
guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue }
switch nextBase.type {
case "res":
let res = try self.decoder.decode(BridgeRPCResponse.self, from: nextData)
if let cont = self.pendingRPC.removeValue(forKey: res.id) {
cont.resume(returning: res)
}
case "event":
let evt = try self.decoder.decode(BridgeEventFrame.self, from: nextData)
self.broadcastServerEvent(evt)
case "ping":
let ping = try self.decoder.decode(BridgePing.self, from: nextData)
try await self.send(BridgePong(type: "pong", id: ping.id))
case "pong":
let pong = try self.decoder.decode(BridgePong.self, from: nextData)
self.notePong(pong)
case "invoke":
let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData)
let taskID = UUID()
let task = Task { [weak self] in
let res = await onInvoke(req)
guard let self else { return }
await self.sendInvokeResponse(res, taskID: taskID)
}
self.invokeTasks[taskID] = task
default:
continue
switch nextBase.type {
case "res":
let res = try self.decoder.decode(BridgeRPCResponse.self, from: nextData)
if let cont = self.pendingRPC.removeValue(forKey: res.id) {
cont.resume(returning: res)
}
}
await self.handleDisconnect(reason: "connection closed")
} catch {
self.logger.error(
"node bridge receive failed: \(error.localizedDescription, privacy: .public)")
await self.handleDisconnect(reason: "receive failed")
throw error
case "event":
let evt = try self.decoder.decode(BridgeEventFrame.self, from: nextData)
self.broadcastServerEvent(evt)
case "ping":
let ping = try self.decoder.decode(BridgePing.self, from: nextData)
try await self.send(BridgePong(type: "pong", id: ping.id))
case "invoke":
let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData)
let res = await onInvoke(req)
try await self.send(res)
default:
continue
}
}
await self.disconnect()
}
func sendEvent(event: String, payloadJSON: String?) async throws {
@@ -221,12 +182,6 @@ actor MacNodeBridgeSession {
}
func disconnect() async {
self.pingTask?.cancel()
self.pingTask = nil
self.lastPongAt = nil
self.disconnectHandler = nil
self.cancelInvokeTasks()
self.connection?.cancel()
self.connection = nil
self.queue = nil
@@ -284,17 +239,12 @@ actor MacNodeBridgeSession {
}
private func send(_ obj: some Encodable) async throws {
guard let connection = self.connection else {
throw NSError(domain: "Bridge", code: 15, userInfo: [
NSLocalizedDescriptionKey: "not connected",
])
}
let data = try self.encoder.encode(obj)
var line = Data()
line.append(data)
line.append(0x0A)
try await withCheckedThrowingContinuation(isolation: self) { (cont: CheckedContinuation<Void, Error>) in
connection.send(content: line, completion: .contentProcessed { err in
self.connection?.send(content: line, completion: .contentProcessed { err in
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
})
}
@@ -330,106 +280,6 @@ actor MacNodeBridgeSession {
}
}
private func startPingLoop() {
self.pingTask?.cancel()
self.lastPongAt = self.clock.now
self.logger.debug("node bridge ping loop started")
self.pingTask = Task { [weak self] in
guard let self else { return }
await self.runPingLoop()
}
}
private func runPingLoop() async {
let interval: Duration = .seconds(15)
let timeout: Duration = .seconds(45)
while !Task.isCancelled {
try? await Task.sleep(for: interval)
guard self.connection != nil else { return }
if let last = self.lastPongAt {
let now = self.clock.now
if now > last.advanced(by: timeout) {
let age = last.duration(to: now)
let ageDescription = String(describing: age)
let message =
"Node bridge heartbeat timed out; disconnecting " +
"(age: \(ageDescription, privacy: .public))."
self.logger.warning(message)
await self.handleDisconnect(reason: "ping timeout")
return
}
}
let id = UUID().uuidString
do {
try await self.send(BridgePing(type: "ping", id: id))
} catch {
let errorDescription = String(describing: error)
let message =
"Node bridge ping send failed; disconnecting " +
"(error: \(errorDescription, privacy: .public))."
self.logger.warning(message)
await self.handleDisconnect(reason: "ping send failed")
return
}
}
}
private func notePong(_ pong: BridgePong) {
_ = pong
self.lastPongAt = self.clock.now
}
private func handleConnectionState(_ state: NWConnection.State) async {
switch state {
case let .failed(error):
let errorDescription = String(describing: error)
let message =
"Node bridge connection failed; disconnecting " +
"(error: \(errorDescription, privacy: .public))."
self.logger.warning(message)
await self.handleDisconnect(reason: "connection failed")
case .cancelled:
self.logger.warning("Node bridge connection cancelled; disconnecting.")
await self.handleDisconnect(reason: "connection cancelled")
default:
break
}
}
private func handleDisconnect(reason: String) async {
self.logger.info("node bridge disconnect reason=\(reason, privacy: .public)")
if let handler = self.disconnectHandler {
await handler(reason)
}
await self.disconnect()
}
private func logInvokeSendFailure(_ error: Error) {
self.logger.error(
"node bridge invoke response send failed: \(error.localizedDescription, privacy: .public)")
}
private func sendInvokeResponse(_ response: BridgeInvokeResponse, taskID: UUID) async {
defer { self.invokeTasks[taskID] = nil }
if Task.isCancelled { return }
do {
try await self.send(response)
} catch {
await self.logInvokeSendFailure(error)
}
}
private func cancelInvokeTasks() {
for task in self.invokeTasks.values {
task.cancel()
}
self.invokeTasks.removeAll()
}
private static func makeStateStream(
for connection: NWConnection) -> AsyncStream<NWConnection.State>
{
@@ -1,4 +1,3 @@
import ClawdbotDiscovery
import ClawdbotKit
import Foundation
import Network
@@ -62,17 +61,12 @@ final class MacNodeModeCoordinator {
retryDelay = 1_000_000_000
do {
let hello = await self.makeHello()
self.logger.info(
"mac node bridge connecting endpoint=\(endpoint, privacy: .public)")
try await self.session.connect(
endpoint: endpoint,
hello: hello,
onConnected: { [weak self] serverName in
self?.logger.info("mac node connected to \(serverName, privacy: .public)")
},
onDisconnected: { reason in
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
},
onInvoke: { [weak self] req in
guard let self else {
return BridgeInvokeResponse(
@@ -86,8 +80,7 @@ final class MacNodeModeCoordinator {
if await self.tryPair(endpoint: endpoint, error: error) {
continue
}
self.logger.error(
"mac node bridge connect failed: \(error.localizedDescription, privacy: .public)")
self.logger.error("mac node bridge connect failed: \(error.localizedDescription, privacy: .public)")
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
retryDelay = min(retryDelay * 2, 10_000_000_000)
}
@@ -292,33 +285,15 @@ final class MacNodeModeCoordinator {
let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode })
if mode == .remote {
do {
if let tunnel = self.tunnel,
tunnel.process.isRunning,
let localPort = tunnel.localPort
{
let healthy = await self.bridgeTunnelHealthy(localPort: localPort, timeoutSeconds: 1.0)
if healthy, let port = NWEndpoint.Port(rawValue: localPort) {
self.logger.info(
"reusing mac node bridge tunnel localPort=\(localPort, privacy: .public)")
return .hostPort(host: "127.0.0.1", port: port)
}
self.logger.error(
"mac node bridge tunnel unhealthy localPort=\(localPort, privacy: .public); restarting")
tunnel.terminate()
self.tunnel = nil
if self.tunnel == nil || self.tunnel?.process.isRunning == false {
let remotePort = Self.remoteBridgePort()
self.tunnel = try await RemotePortTunnel.create(
remotePort: remotePort,
allowRemoteUrlOverride: false)
}
let remotePort = Self.remoteBridgePort()
self.tunnel = try await RemotePortTunnel.create(
remotePort: remotePort,
allowRemoteUrlOverride: false)
if let localPort = self.tunnel?.localPort,
let port = NWEndpoint.Port(rawValue: localPort)
{
self.logger.info(
"mac node bridge tunnel ready " +
"localPort=\(localPort, privacy: .public) " +
"remotePort=\(remotePort, privacy: .public)")
return .hostPort(host: "127.0.0.1", port: port)
}
} catch {
@@ -336,21 +311,6 @@ final class MacNodeModeCoordinator {
return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds)
}
@MainActor
private static func handleBridgeDisconnect(reason: String) async {
guard reason.localizedCaseInsensitiveContains("ping") else { return }
let coordinator = MacNodeModeCoordinator.shared
coordinator.logger.error(
"mac node bridge disconnected (\(reason, privacy: .public)); resetting tunnel")
coordinator.tunnel?.terminate()
coordinator.tunnel = nil
}
private func bridgeTunnelHealthy(localPort: UInt16, timeoutSeconds: Double) async -> Bool {
guard let port = NWEndpoint.Port(rawValue: localPort) else { return false }
return await Self.probeEndpoint(.hostPort(host: "127.0.0.1", port: port), timeoutSeconds: timeoutSeconds)
}
private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
final class DiscoveryState: @unchecked Sendable {
let lock = NSLock()
@@ -222,15 +222,7 @@ actor MacNodeRuntime {
(Self.locationPreciseEnabled() ? .precise : .balanced)
let services = await self.mainActorServices()
let status = await services.locationAuthorizationStatus()
let hasPermission = switch mode {
case .always:
status == .authorizedAlways
case .whileUsing:
status == .authorizedAlways
case .off:
false
}
if !hasPermission {
if status != .authorizedAlways {
return BridgeInvokeResponse(
id: req.id,
ok: false,
@@ -1,5 +1,4 @@
import AppKit
import ClawdbotDiscovery
import ClawdbotIPC
import ClawdbotProtocol
import Foundation
@@ -534,7 +533,7 @@ final class NodePairingApprovalPrompter {
return SSHTarget(host: host, port: port)
}
let model = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
let model = GatewayDiscoveryModel()
model.start()
defer { model.stop() }
+3 -14
View File
@@ -1,6 +1,5 @@
import AppKit
import ClawdbotChatUI
import ClawdbotDiscovery
import ClawdbotIPC
import Combine
import Observation
@@ -32,7 +31,7 @@ final class OnboardingController {
let hosting = NSHostingController(rootView: OnboardingView())
let window = NSWindow(contentViewController: hosting)
window.title = UIStrings.welcomeTitle
window.setContentSize(NSSize(width: OnboardingView.windowWidth, height: OnboardingView.windowHeight))
window.setContentSize(NSSize(width: 630, height: 684))
window.styleMask = [.titled, .closable, .fullSizeContentView]
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
@@ -48,11 +47,6 @@ final class OnboardingController {
self.window?.close()
self.window = nil
}
func restart() {
self.close()
self.show()
}
}
struct OnboardingView: View {
@@ -98,10 +92,7 @@ struct OnboardingView: View {
@Bindable var state: AppState
var permissionMonitor: PermissionMonitor
static let windowWidth: CGFloat = 630
static let windowHeight: CGFloat = 752 // ~+10% to fit full onboarding content
let pageWidth: CGFloat = Self.windowWidth
let pageWidth: CGFloat = 630
let contentHeight: CGFloat = 460
let connectionPageIndex = 1
let anthropicAuthPageIndex = 2
@@ -165,9 +156,7 @@ struct OnboardingView: View {
init(
state: AppState = AppStateStore.shared,
permissionMonitor: PermissionMonitor = .shared,
discoveryModel: GatewayDiscoveryModel = GatewayDiscoveryModel(
localDisplayName: InstanceIdentity.displayName,
filterLocalGateways: false))
discoveryModel: GatewayDiscoveryModel = GatewayDiscoveryModel())
{
self.state = state
self.permissionMonitor = permissionMonitor
@@ -1,5 +1,4 @@
import AppKit
import ClawdbotDiscovery
import ClawdbotIPC
import SwiftUI
@@ -27,7 +27,7 @@ extension OnboardingView {
Spacer(minLength: 0)
self.navigationBar
}
.frame(width: self.pageWidth, height: Self.windowHeight)
.frame(width: self.pageWidth, height: 684)
.background(Color(NSColor.windowBackgroundColor))
.onAppear {
self.currentPage = 0
@@ -173,22 +173,6 @@ extension OnboardingView {
.shadow(color: .black.opacity(0.06), radius: 8, y: 3))
}
func onboardingGlassCard(
spacing: CGFloat = 12,
padding: CGFloat = 16,
@ViewBuilder _ content: () -> some View) -> some View
{
let shape = RoundedRectangle(cornerRadius: 16, style: .continuous)
return VStack(alignment: .leading, spacing: spacing) {
content()
}
.padding(padding)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.clear)
.clipShape(shape)
.overlay(shape.strokeBorder(Color.white.opacity(0.10), lineWidth: 1))
}
func featureRow(title: String, subtitle: String, systemImage: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: systemImage)
@@ -33,7 +33,7 @@ extension OnboardingView {
if shouldMonitor, !self.monitoringDiscovery {
self.monitoringDiscovery = true
Task { @MainActor in
try? await Task.sleep(nanoseconds: 150_000_000)
try? await Task.sleep(nanoseconds: 550_000_000)
guard self.monitoringDiscovery else { return }
self.gatewayDiscovery.start()
await self.refreshLocalGatewayProbe()
@@ -1,6 +1,5 @@
import AppKit
import ClawdbotChatUI
import ClawdbotDiscovery
import ClawdbotIPC
import SwiftUI
@@ -116,11 +115,6 @@ extension OnboardingView {
.foregroundStyle(.secondary)
if self.gatewayDiscovery.gateways.isEmpty {
ProgressView().controlSize(.small)
Button("Refresh") {
self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0)
}
.buttonStyle(.link)
.help("Retry Tailscale discovery (DNS-SD).")
}
Spacer(minLength: 0)
}
@@ -654,7 +648,7 @@ extension OnboardingView {
.frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true)
self.onboardingGlassCard(padding: 8) {
self.onboardingCard(padding: 8) {
ClawdbotChatView(viewModel: self.onboardingChatModel, style: .onboarding)
.frame(maxHeight: .infinity)
}
@@ -1,4 +1,3 @@
import ClawdbotDiscovery
import SwiftUI
#if DEBUG
@@ -6,7 +5,7 @@ import SwiftUI
extension OnboardingView {
static func exerciseForTesting() {
let state = AppState(preview: true)
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
let discovery = GatewayDiscoveryModel()
discovery.statusText = "Searching..."
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
displayName: "Test Bridge",
@@ -10,18 +10,6 @@ import Speech
import UserNotifications
enum PermissionManager {
static func isLocationAuthorized(status: CLAuthorizationStatus, requireAlways: Bool) -> Bool {
if requireAlways { return status == .authorizedAlways }
switch status {
case .authorizedAlways, .authorizedWhenInUse:
return true
case .authorized: // deprecated, but still shows up on some macOS versions
return true
default:
return false
}
}
static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] {
var results: [Capability: Bool] = [:]
for cap in caps {
@@ -150,23 +138,18 @@ enum PermissionManager {
}
private static func ensureLocation(interactive: Bool) async -> Bool {
guard CLLocationManager.locationServicesEnabled() else {
if interactive {
await MainActor.run { LocationPermissionHelper.openSettings() }
}
return false
}
let status = CLLocationManager().authorizationStatus
switch status {
case .authorizedAlways, .authorizedWhenInUse, .authorized:
// Note: macOS only supports authorizedAlways, not authorizedWhenInUse (iOS only)
case .authorizedAlways:
return true
case .notDetermined:
guard interactive else { return false }
let updated = await LocationPermissionRequester.shared.request(always: false)
return self.isLocationAuthorized(status: updated, requireAlways: false)
return updated == .authorizedAlways
case .denied, .restricted:
if interactive {
await MainActor.run { LocationPermissionHelper.openSettings() }
LocationPermissionHelper.openSettings()
}
return false
@unknown default:
@@ -219,8 +202,8 @@ enum PermissionManager {
case .location:
let status = CLLocationManager().authorizationStatus
results[cap] = CLLocationManager.locationServicesEnabled()
&& self.isLocationAuthorized(status: status, requireAlways: false)
// Note: macOS only supports authorizedAlways
results[cap] = status == .authorizedAlways
}
}
return results
@@ -292,7 +275,6 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
static let shared = LocationPermissionRequester()
private let manager = CLLocationManager()
private var continuation: CheckedContinuation<CLAuthorizationStatus, Never>?
private var timeoutTask: Task<Void, Never>?
override init() {
super.init()
@@ -300,71 +282,23 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
}
func request(always: Bool) async -> CLAuthorizationStatus {
let current = self.manager.authorizationStatus
if PermissionManager.isLocationAuthorized(status: current, requireAlways: always) {
return current
if always {
self.manager.requestAlwaysAuthorization()
} else {
self.manager.requestWhenInUseAuthorization()
}
return await withCheckedContinuation { cont in
self.continuation = cont
self.timeoutTask?.cancel()
self.timeoutTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 3_000_000_000)
await MainActor.run { [weak self] in
guard let self else { return }
guard self.continuation != nil else { return }
LocationPermissionHelper.openSettings()
self.finish(status: self.manager.authorizationStatus)
}
}
if always {
self.manager.requestAlwaysAuthorization()
} else {
self.manager.requestWhenInUseAuthorization()
}
// On macOS, requesting an actual fix makes the prompt more reliable.
self.manager.requestLocation()
}
}
private func finish(status: CLAuthorizationStatus) {
self.timeoutTask?.cancel()
self.timeoutTask = nil
guard let cont = self.continuation else { return }
self.continuation = nil
cont.resume(returning: status)
}
// nonisolated for Swift 6 strict concurrency compatibility
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
Task { @MainActor in
self.finish(status: status)
}
}
// Legacy callback (still used on some macOS versions / configurations).
nonisolated func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
Task { @MainActor in
self.finish(status: status)
}
}
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
let status = manager.authorizationStatus
Task { @MainActor in
if status == .denied || status == .restricted {
LocationPermissionHelper.openSettings()
}
self.finish(status: status)
}
}
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
let status = manager.authorizationStatus
Task { @MainActor in
self.finish(status: status)
guard let cont = self.continuation else { return }
self.continuation = nil
cont.resume(returning: status)
}
}
}
@@ -15,7 +15,7 @@ struct PermissionsSettings: View {
.padding(.horizontal, 2)
.padding(.vertical, 6)
Button("Restart onboarding") { self.showOnboarding() }
Button("Show onboarding") { self.showOnboarding() }
.buttonStyle(.bordered)
Spacer()
}
@@ -41,8 +41,7 @@ final class RemotePortTunnel {
static func create(
remotePort: Int,
preferredLocalPort: UInt16? = nil,
allowRemoteUrlOverride: Bool = true,
allowRandomLocalPort: Bool = true) async throws -> RemotePortTunnel
allowRemoteUrlOverride: Bool = true) async throws -> RemotePortTunnel
{
let settings = CommandResolver.connectionSettings()
guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else {
@@ -52,9 +51,7 @@ final class RemotePortTunnel {
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not configured"])
}
let localPort = try await Self.findPort(
preferred: preferredLocalPort,
allowRandom: allowRandomLocalPort)
let localPort = try await Self.findPort(preferred: preferredLocalPort)
let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines)
let remotePortOverride =
allowRemoteUrlOverride && remotePort == GatewayEnvironment.gatewayPort()
@@ -175,16 +172,8 @@ final class RemotePortTunnel {
return trimmed.split(separator: ".").first.map(String.init) ?? trimmed
}
private static func findPort(preferred: UInt16?, allowRandom: Bool) async throws -> UInt16 {
private static func findPort(preferred: UInt16?) async throws -> UInt16 {
if let preferred, self.portIsFree(preferred) { return preferred }
if let preferred, !allowRandom {
throw NSError(
domain: "RemotePortTunnel",
code: 5,
userInfo: [
NSLocalizedDescriptionKey: "Local port \(preferred) is unavailable",
])
}
return try await withCheckedThrowingContinuation { cont in
let queue = DispatchQueue(label: "com.clawdbot.remote.tunnel.port", qos: .utility)
@@ -7,15 +7,8 @@ actor RemoteTunnelManager {
private let logger = Logger(subsystem: "com.clawdbot", category: "remote-tunnel")
private var controlTunnel: RemotePortTunnel?
private var restartInFlight = false
private var lastRestartAt: Date?
private let restartBackoffSeconds: TimeInterval = 2.0
func controlTunnelPortIfRunning() async -> UInt16? {
if self.restartInFlight {
self.logger.info("control tunnel restart in flight; skipping reuse check")
return nil
}
if let tunnel = self.controlTunnel,
tunnel.process.isRunning,
let local = tunnel.localPort
@@ -25,7 +18,6 @@ actor RemoteTunnelManager {
return local
}
self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting")
await self.beginRestart()
tunnel.terminate()
self.controlTunnel = nil
}
@@ -42,11 +34,6 @@ actor RemoteTunnelManager {
"pid=\(desc.pid, privacy: .public)")
return desiredPort
}
if self.restartInFlight {
self.logger.info("control tunnel restart in flight; skip stale tunnel cleanup")
return nil
}
await self.beginRestart()
await self.cleanupStaleTunnel(desc: desc, port: desiredPort)
}
return nil
@@ -69,15 +56,12 @@ actor RemoteTunnelManager {
"identitySet=\(identitySet, privacy: .public)")
if let local = await self.controlTunnelPortIfRunning() { return local }
await self.waitForRestartBackoffIfNeeded()
let desiredPort = UInt16(GatewayEnvironment.gatewayPort())
let tunnel = try await RemotePortTunnel.create(
remotePort: GatewayEnvironment.gatewayPort(),
preferredLocalPort: desiredPort,
allowRandomLocalPort: false)
preferredLocalPort: desiredPort)
self.controlTunnel = tunnel
self.endRestart()
let resolvedPort = tunnel.localPort ?? desiredPort
self.logger.info("ssh tunnel ready localPort=\(resolvedPort, privacy: .public)")
return tunnel.localPort ?? desiredPort
@@ -99,35 +83,6 @@ actor RemoteTunnelManager {
return false
}
private func beginRestart() async {
guard !self.restartInFlight else { return }
self.restartInFlight = true
self.lastRestartAt = Date()
self.logger.info("control tunnel restart started")
Task { [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(self.restartBackoffSeconds * 1_000_000_000))
await self.endRestart()
}
}
private func endRestart() {
if self.restartInFlight {
self.restartInFlight = false
self.logger.info("control tunnel restart finished")
}
}
private func waitForRestartBackoffIfNeeded() async {
guard let last = self.lastRestartAt else { return }
let elapsed = Date().timeIntervalSince(last)
let remaining = self.restartBackoffSeconds - elapsed
guard remaining > 0 else { return }
self.logger.info(
"control tunnel restart backoff \(remaining, privacy: .public)s")
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
}
private func cleanupStaleTunnel(desc: PortGuardian.Descriptor, port: UInt16) async {
let pid = desc.pid
self.logger.error(
@@ -47,14 +47,10 @@
<string>Clawdbot captures the screen when the agent needs screenshots for context.</string>
<key>NSCameraUsageDescription</key>
<string>Clawdbot can capture photos or short video clips when requested by the agent.</string>
<key>NSLocationUsageDescription</key>
<string>Clawdbot can share your location when requested by the agent.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Clawdbot can share your location when requested by the agent.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Clawdbot can share your location when requested by the agent.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Clawdbot needs the mic for Voice Wake tests and agent audio capture.</string>
<key>NSLocationUsageDescription</key>
<string>Clawdbot can share your location when requested by the agent.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Clawdbot needs the mic for Voice Wake tests and agent audio capture.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Clawdbot uses speech recognition to detect your Voice Wake trigger phrase.</string>
<key>NSAppleEventsUsageDescription</key>
@@ -58,7 +58,7 @@ struct SettingsRootView: View {
PermissionsSettings(
status: self.permissionMonitor.status,
refresh: self.refreshPerms,
showOnboarding: { DebugActions.restartOnboarding() })
showOnboarding: { OnboardingController.shared.show() })
.tabItem { Label("Permissions", systemImage: "lock.shield") }
.tag(SettingsTab.permissions)
@@ -50,13 +50,10 @@ enum ShellExecutor {
errorMessage: "failed to start: \(error.localizedDescription)")
}
let outTask = Task { stdoutPipe.fileHandleForReading.readToEndSafely() }
let errTask = Task { stderrPipe.fileHandleForReading.readToEndSafely() }
let waitTask = Task { () -> ShellResult in
process.waitUntilExit()
let out = await outTask.value
let err = await errTask.value
let out = stdoutPipe.fileHandleForReading.readToEndSafely()
let err = stderrPipe.fileHandleForReading.readToEndSafely()
let status = Int(process.terminationStatus)
return ShellResult(
stdout: String(bytes: out, encoding: .utf8) ?? "",
+1 -8
View File
@@ -21,7 +21,6 @@ struct GatewayUsageSummary: Codable {
struct UsageRow: Identifiable {
let id: String
let providerId: String
let displayName: String
let plan: String?
let windowLabel: String?
@@ -29,11 +28,6 @@ struct UsageRow: Identifiable {
let resetAt: Date?
let error: String?
var hasError: Bool {
if let error, !error.isEmpty { return true }
return false
}
var titleText: String {
if let plan, !plan.isEmpty { return "\(self.displayName) (\(plan))" }
return self.displayName
@@ -46,6 +40,7 @@ struct UsageRow: Identifiable {
}
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) }
@@ -78,7 +73,6 @@ extension GatewayUsageSummary {
if let error = provider.error, provider.windows.isEmpty {
return UsageRow(
id: provider.provider,
providerId: provider.provider,
displayName: provider.displayName,
plan: provider.plan,
windowLabel: nil,
@@ -93,7 +87,6 @@ extension GatewayUsageSummary {
return UsageRow(
id: "\(provider.provider)-\(window.label)",
providerId: provider.provider,
displayName: provider.displayName,
plan: provider.plan,
windowLabel: window.label,
@@ -3,19 +3,12 @@ import SwiftUI
struct UsageMenuLabelView: View {
let row: UsageRow
let width: CGFloat
var showsChevron: Bool = false
@Environment(\.menuItemHighlighted) private var isHighlighted
private let paddingLeading: CGFloat = 22
private let paddingTrailing: CGFloat = 14
private let barHeight: CGFloat = 6
private var primaryTextColor: Color {
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
}
private var secondaryTextColor: Color {
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
}
private var primaryTextColor: Color { .primary }
private var secondaryTextColor: Color { .secondary }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
@@ -37,31 +30,12 @@ struct UsageMenuLabelView: View {
Spacer(minLength: 4)
if !self.row.hasError {
Text(self.row.detailText())
.font(.caption.monospacedDigit())
.foregroundStyle(self.secondaryTextColor)
.lineLimit(1)
.truncationMode(.tail)
.layoutPriority(2)
}
if self.showsChevron {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(self.secondaryTextColor)
.padding(.leading, 2)
}
}
if let error = self.row.error?.nonEmpty {
Text(error)
.font(.caption)
Text(self.row.detailText())
.font(.caption.monospacedDigit())
.foregroundStyle(self.secondaryTextColor)
.multilineTextAlignment(.leading)
.lineLimit(2)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(2)
}
}
.padding(.vertical, 10)
@@ -219,7 +219,6 @@ final class WorkActivityStore {
if let detail = display.detailLine, !detail.isEmpty {
return "\(display.label): \(detail)"
}
return display.label
}
@@ -1,376 +0,0 @@
import ClawdbotKit
import Foundation
struct WideAreaGatewayBeacon: Sendable, Equatable {
var instanceName: String
var displayName: String
var host: String
var port: Int
var lanHost: String?
var tailnetDns: String?
var gatewayPort: Int?
var bridgePort: Int?
var sshPort: Int?
var cliPath: String?
}
enum WideAreaGatewayDiscovery {
private static let maxCandidates = 40
private static let digPath = "/usr/bin/dig"
private static let defaultTimeoutSeconds: TimeInterval = 0.2
private static let nameserverProbeConcurrency = 6
struct DiscoveryContext: Sendable {
var tailscaleStatus: @Sendable () -> String?
var dig: @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?
static let live = DiscoveryContext(
tailscaleStatus: { readTailscaleStatus() },
dig: { args, timeout in
runDig(args: args, timeout: timeout)
})
}
static func discover(
timeoutSeconds: TimeInterval = 2.0,
context: DiscoveryContext = .live) -> [WideAreaGatewayBeacon]
{
let startedAt = Date()
let remaining = {
timeoutSeconds - Date().timeIntervalSince(startedAt)
}
guard let ips = collectTailnetIPv4s(
statusJson: context.tailscaleStatus()).nonEmpty else { return [] }
var candidates = Array(ips.prefix(self.maxCandidates))
guard let nameserver = findNameserver(
candidates: &candidates,
remaining: remaining,
dig: context.dig)
else {
return []
}
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)"
guard let ptrLines = context.dig(
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
!ptrLines.isEmpty
else {
return []
}
var beacons: [WideAreaGatewayBeacon] = []
for raw in ptrLines {
let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if ptr.isEmpty { continue }
let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr
let suffix = "._clawdbot-bridge._tcp.\(domainTrimmed)"
let rawInstanceName = ptrName.hasSuffix(suffix)
? String(ptrName.dropLast(suffix.count))
: ptrName
let instanceName = self.decodeDnsSdEscapes(rawInstanceName)
guard let srv = context.dig(
["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "SRV"],
min(defaultTimeoutSeconds, remaining()))
else { continue }
guard let (host, port) = parseSrv(srv) else { continue }
let txtRaw = context.dig(
["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "TXT"],
min(self.defaultTimeoutSeconds, remaining()))
let txtTokens = txtRaw.map(self.parseTxtTokens) ?? []
let txt = self.mapTxt(tokens: txtTokens)
let displayName = txt["displayName"] ?? instanceName
let beacon = WideAreaGatewayBeacon(
instanceName: instanceName,
displayName: displayName,
host: host,
port: port,
lanHost: txt["lanHost"],
tailnetDns: txt["tailnetDns"],
gatewayPort: parseInt(txt["gatewayPort"]),
bridgePort: parseInt(txt["bridgePort"]),
sshPort: parseInt(txt["sshPort"]),
cliPath: txt["cliPath"])
beacons.append(beacon)
}
return beacons
}
private static func collectTailnetIPv4s(statusJson: String?) -> [String] {
guard let statusJson else { return [] }
let decoder = JSONDecoder()
guard let data = statusJson.data(using: .utf8),
let status = try? decoder.decode(TailscaleStatus.self, from: data)
else { return [] }
var ips: [String] = []
ips.append(contentsOf: status.selfNode?.resolvedIPs ?? [])
if let peers = status.peer {
for peer in peers.values {
ips.append(contentsOf: peer.resolvedIPs)
}
}
var seen = Set<String>()
let ordered = ips.filter { value in
guard self.isTailnetIPv4(value) else { return false }
if seen.contains(value) { return false }
seen.insert(value)
return true
}
return ordered
}
private static func readTailscaleStatus() -> String? {
let candidates = [
"/usr/local/bin/tailscale",
"/opt/homebrew/bin/tailscale",
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
"tailscale",
]
var output: String?
for candidate in candidates {
if let result = run(
path: candidate,
args: ["status", "--json"],
timeout: 0.7)
{
output = result
break
}
}
return output
}
private static func findNameserver(
candidates: inout [String],
remaining: () -> TimeInterval,
dig: @escaping @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?) -> String?
{
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)"
let ips = candidates
candidates.removeAll(keepingCapacity: true)
if ips.isEmpty { return nil }
final class ProbeState: @unchecked Sendable {
let lock = NSLock()
var nextIndex = 0
var found: String?
}
let state = ProbeState()
let deadline = Date().addingTimeInterval(max(0, remaining()))
let workerCount = min(self.nameserverProbeConcurrency, ips.count)
let group = DispatchGroup()
for _ in 0..<workerCount {
group.enter()
DispatchQueue.global(qos: .utility).async {
defer { group.leave() }
while Date() < deadline {
state.lock.lock()
if state.found != nil {
state.lock.unlock()
return
}
let i = state.nextIndex
state.nextIndex += 1
state.lock.unlock()
if i >= ips.count { return }
let ip = ips[i]
let budget = deadline.timeIntervalSinceNow
if budget <= 0 { return }
if let stdout = dig(
["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"],
min(defaultTimeoutSeconds, budget)),
stdout.split(whereSeparator: \.isNewline).isEmpty == false
{
state.lock.lock()
if state.found == nil {
state.found = ip
}
state.lock.unlock()
return
}
}
}
}
_ = group.wait(timeout: .now() + max(0.0, remaining()))
return state.found
}
private static func runDig(args: [String], timeout: TimeInterval) -> String? {
self.run(path: self.digPath, args: args, timeout: timeout)
}
private static func run(path: String, args: [String], timeout: TimeInterval) -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: path)
process.arguments = args
let outPipe = Pipe()
let errPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
do {
try process.run()
} catch {
return nil
}
let deadline = Date().addingTimeInterval(timeout)
while process.isRunning, Date() < deadline {
Thread.sleep(forTimeInterval: 0.02)
}
if process.isRunning {
process.terminate()
}
process.waitUntilExit()
let data = (try? outPipe.fileHandleForReading.readToEnd()) ?? Data()
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
return output?.isEmpty == false ? output : nil
}
private static func parseSrv(_ stdout: String) -> (String, Int)? {
let line = stdout
.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.first(where: { !$0.isEmpty })
guard let line else { return nil }
let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
guard parts.count >= 4 else { return nil }
guard let port = Int(parts[2]), port > 0 else { return nil }
let host = parts[3].hasSuffix(".") ? String(parts[3].dropLast()) : parts[3]
return (host, port)
}
private static func parseTxtTokens(_ stdout: String) -> [String] {
let lines = stdout.split(whereSeparator: \.isNewline)
var tokens: [String] = []
for raw in lines {
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if line.isEmpty { continue }
let matches = line.matches(of: /"([^"]*)"/)
for match in matches {
tokens.append(self.unescapeTxt(String(match.1)))
}
}
return tokens
}
private static func unescapeTxt(_ value: String) -> String {
value
.replacingOccurrences(of: "\\\\", with: "\\")
.replacingOccurrences(of: "\\\"", with: "\"")
.replacingOccurrences(of: "\\n", with: "\n")
}
private static func mapTxt(tokens: [String]) -> [String: String] {
var out: [String: String] = [:]
for token in tokens {
guard let idx = token.firstIndex(of: "=") else { continue }
let key = String(token[..<idx]).trimmingCharacters(in: .whitespacesAndNewlines)
let rawValue = String(token[token.index(after: idx)...])
.trimmingCharacters(in: .whitespacesAndNewlines)
let value = self.decodeDnsSdEscapes(rawValue)
if !key.isEmpty { out[key] = value }
}
return out
}
private static func parseInt(_ value: String?) -> Int? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return Int(trimmed)
}
private static func isTailnetIPv4(_ value: String) -> Bool {
let parts = value.split(separator: ".")
if parts.count != 4 { return false }
let octets = parts.compactMap { Int($0) }
if octets.count != 4 { return false }
let a = octets[0]
let b = octets[1]
return a == 100 && b >= 64 && b <= 127
}
private static func decodeDnsSdEscapes(_ value: String) -> String {
var bytes: [UInt8] = []
var pending = ""
func flushPending() {
guard !pending.isEmpty else { return }
bytes.append(contentsOf: pending.utf8)
pending = ""
}
let chars = Array(value)
var i = 0
while i < chars.count {
let ch = chars[i]
if ch == "\\", i + 3 < chars.count {
let digits = String(chars[(i + 1)...(i + 3)])
if digits.allSatisfy(\.isNumber),
let byte = UInt8(digits)
{
flushPending()
bytes.append(byte)
i += 4
continue
}
}
pending.append(ch)
i += 1
}
flushPending()
if bytes.isEmpty { return value }
if let decoded = String(bytes: bytes, encoding: .utf8) {
return decoded
}
return value
}
}
private struct TailscaleStatus: Decodable {
struct Node: Decodable {
let tailscaleIPs: [String]?
var resolvedIPs: [String] {
self.tailscaleIPs ?? []
}
private enum CodingKeys: String, CodingKey {
case tailscaleIPs = "TailscaleIPs"
}
}
let selfNode: Node?
let peer: [String: Node]?
private enum CodingKeys: String, CodingKey {
case selfNode = "Self"
case peer = "Peer"
}
}
extension Collection {
fileprivate var nonEmpty: Self? { isEmpty ? nil : self }
}
@@ -1,150 +0,0 @@
import ClawdbotDiscovery
import Foundation
struct DiscoveryOptions {
var timeoutMs: Int = 2000
var json: Bool = false
var includeLocal: Bool = false
var help: Bool = false
static func parse(_ args: [String]) -> DiscoveryOptions {
var opts = DiscoveryOptions()
var i = 0
while i < args.count {
let arg = args[i]
switch arg {
case "-h", "--help":
opts.help = true
case "--json":
opts.json = true
case "--include-local":
opts.includeLocal = true
case "--timeout":
let next = (i + 1 < args.count) ? args[i + 1] : nil
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
opts.timeoutMs = max(100, parsed)
i += 1
}
default:
break
}
i += 1
}
return opts
}
}
struct DiscoveryOutput: Encodable {
struct Gateway: Encodable {
var displayName: String
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var gatewayPort: Int?
var cliPath: String?
var stableID: String
var debugID: String
var isLocal: Bool
}
var status: String
var timeoutMs: Int
var includeLocal: Bool
var count: Int
var gateways: [Gateway]
}
@main
struct ClawdbotDiscoveryCLI {
static func main() async {
let opts = DiscoveryOptions.parse(Array(CommandLine.arguments.dropFirst()))
if opts.help {
print("""
clawdbot-mac-discovery
Usage:
clawdbot-mac-discovery [--timeout <ms>] [--json] [--include-local]
Options:
--timeout <ms> Discovery window in milliseconds (default: 2000)
--json Emit JSON
--include-local Include gateways considered local
-h, --help Show help
""")
return
}
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
let model = GatewayDiscoveryModel(
localDisplayName: displayName,
filterLocalGateways: !opts.includeLocal)
await MainActor.run {
model.start()
}
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
try? await Task.sleep(nanoseconds: nanos)
let gateways = await MainActor.run { model.gateways }
let status = await MainActor.run { model.statusText }
await MainActor.run {
model.stop()
}
if opts.json {
let payload = DiscoveryOutput(
status: status,
timeoutMs: opts.timeoutMs,
includeLocal: opts.includeLocal,
count: gateways.count,
gateways: gateways.map {
DiscoveryOutput.Gateway(
displayName: $0.displayName,
lanHost: $0.lanHost,
tailnetDns: $0.tailnetDns,
sshPort: $0.sshPort,
gatewayPort: $0.gatewayPort,
cliPath: $0.cliPath,
stableID: $0.stableID,
debugID: $0.debugID,
isLocal: $0.isLocal)
})
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(payload),
let json = String(data: data, encoding: .utf8)
{
print(json)
} else {
print("{\"error\":\"failed to encode JSON\"}")
}
return
}
print("Gateway Discovery (macOS NWBrowser)")
print("Status: \(status)")
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
if gateways.isEmpty { return }
for gateway in gateways {
let hosts = [gateway.tailnetDns, gateway.lanHost]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: ", ")
print("- \(gateway.displayName)")
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
print(" ssh: \(gateway.sshPort)")
if let port = gateway.gatewayPort {
print(" gatewayPort: \(port)")
}
if let cliPath = gateway.cliPath {
print(" cliPath: \(cliPath)")
}
print(" isLocal: \(gateway.isLocal)")
print(" stableID: \(gateway.stableID)")
print(" debugID: \(gateway.debugID)")
}
}
}
@@ -421,14 +421,11 @@ public struct AgentParams: Codable, Sendable {
public let sessionkey: String?
public let thinking: String?
public let deliver: Bool?
public let attachments: [AnyCodable]?
public let provider: String?
public let timeout: Int?
public let lane: String?
public let extrasystemprompt: String?
public let idempotencykey: String
public let label: String?
public let spawnedby: String?
public init(
message: String,
@@ -437,14 +434,11 @@ public struct AgentParams: Codable, Sendable {
sessionkey: String?,
thinking: String?,
deliver: Bool?,
attachments: [AnyCodable]?,
provider: String?,
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
idempotencykey: String,
label: String?,
spawnedby: String?
idempotencykey: String
) {
self.message = message
self.to = to
@@ -452,14 +446,11 @@ public struct AgentParams: Codable, Sendable {
self.sessionkey = sessionkey
self.thinking = thinking
self.deliver = deliver
self.attachments = attachments
self.provider = provider
self.timeout = timeout
self.lane = lane
self.extrasystemprompt = extrasystemprompt
self.idempotencykey = idempotencykey
self.label = label
self.spawnedby = spawnedby
}
private enum CodingKeys: String, CodingKey {
case message
@@ -468,14 +459,11 @@ public struct AgentParams: Codable, Sendable {
case sessionkey = "sessionKey"
case thinking
case deliver
case attachments
case provider
case timeout
case lane
case extrasystemprompt = "extraSystemPrompt"
case idempotencykey = "idempotencyKey"
case label
case spawnedby = "spawnedBy"
}
}
@@ -675,7 +663,6 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int?
public let includeglobal: Bool?
public let includeunknown: Bool?
public let label: String?
public let spawnedby: String?
public let agentid: String?
@@ -684,7 +671,6 @@ public struct SessionsListParams: Codable, Sendable {
activeminutes: Int?,
includeglobal: Bool?,
includeunknown: Bool?,
label: String?,
spawnedby: String?,
agentid: String?
) {
@@ -692,7 +678,6 @@ public struct SessionsListParams: Codable, Sendable {
self.activeminutes = activeminutes
self.includeglobal = includeglobal
self.includeunknown = includeunknown
self.label = label
self.spawnedby = spawnedby
self.agentid = agentid
}
@@ -701,48 +686,13 @@ public struct SessionsListParams: Codable, Sendable {
case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown"
case label
case spawnedby = "spawnedBy"
case agentid = "agentId"
}
}
public struct SessionsResolveParams: Codable, Sendable {
public let key: String?
public let label: String?
public let agentid: String?
public let spawnedby: String?
public let includeglobal: Bool?
public let includeunknown: Bool?
public init(
key: String?,
label: String?,
agentid: String?,
spawnedby: String?,
includeglobal: Bool?,
includeunknown: Bool?
) {
self.key = key
self.label = label
self.agentid = agentid
self.spawnedby = spawnedby
self.includeglobal = includeglobal
self.includeunknown = includeunknown
}
private enum CodingKeys: String, CodingKey {
case key
case label
case agentid = "agentId"
case spawnedby = "spawnedBy"
case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown"
}
}
public struct SessionsPatchParams: Codable, Sendable {
public let key: String
public let label: AnyCodable?
public let thinkinglevel: AnyCodable?
public let verboselevel: AnyCodable?
public let reasoninglevel: AnyCodable?
@@ -755,7 +705,6 @@ public struct SessionsPatchParams: Codable, Sendable {
public init(
key: String,
label: AnyCodable?,
thinkinglevel: AnyCodable?,
verboselevel: AnyCodable?,
reasoninglevel: AnyCodable?,
@@ -767,7 +716,6 @@ public struct SessionsPatchParams: Codable, Sendable {
groupactivation: AnyCodable?
) {
self.key = key
self.label = label
self.thinkinglevel = thinkinglevel
self.verboselevel = verboselevel
self.reasoninglevel = reasoninglevel
@@ -780,7 +728,6 @@ public struct SessionsPatchParams: Codable, Sendable {
}
private enum CodingKeys: String, CodingKey {
case key
case label
case thinkinglevel = "thinkingLevel"
case verboselevel = "verboseLevel"
case reasoninglevel = "reasoningLevel"
@@ -1633,11 +1580,11 @@ public struct ChatSendParams: Codable, Sendable {
public struct ChatAbortParams: Codable, Sendable {
public let sessionkey: String
public let runid: String?
public let runid: String
public init(
sessionkey: String,
runid: String?
runid: String
) {
self.sessionkey = sessionkey
self.runid = runid
@@ -1,5 +1,5 @@
import ClawdbotDiscovery
import Testing
@testable import Clawdbot
@Suite
@MainActor
@@ -64,24 +64,6 @@ struct GatewayDiscoveryModelTests {
local: local))
}
@Test func serviceNameDoesNotFalsePositiveOnSubstringHostToken() {
let local = GatewayDiscoveryModel.LocalIdentity(
hostTokens: ["steipete"],
displayTokens: [])
#expect(!GatewayDiscoveryModel.isLocalGateway(
lanHost: nil,
tailnetDns: nil,
displayName: nil,
serviceName: "steipetacstudio (Clawdbot)",
local: local))
#expect(GatewayDiscoveryModel.isLocalGateway(
lanHost: nil,
tailnetDns: nil,
displayName: nil,
serviceName: "steipete (Clawdbot)",
local: local))
}
@Test func parsesGatewayTXTFields() {
let parsed = GatewayDiscoveryModel.parseGatewayTXT([
"lanHost": " studio.local ",
@@ -57,25 +57,6 @@ struct LowCoverageHelperTests {
#expect(result.timedOut == true)
}
@Test func shellExecutorDrainsStdoutAndStderr() async {
let script = """
i=0
while [ $i -lt 2000 ]; do
echo "stdout-$i"
echo "stderr-$i" 1>&2
i=$((i+1))
done
"""
let result = await ShellExecutor.runDetailed(
command: ["/bin/sh", "-c", script],
cwd: nil,
env: nil,
timeout: 2)
#expect(result.success == true)
#expect(result.stdout.contains("stdout-1999"))
#expect(result.stderr.contains("stderr-1999"))
}
@Test func pairedNodesStorePersists() async throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true)
@@ -1,19 +0,0 @@
import Foundation
import Testing
@testable import Clawdbot
@Suite
struct MacNodeBridgeSessionTests {
@Test func sendEventThrowsWhenNotConnected() async {
let session = MacNodeBridgeSession()
do {
try await session.sendEvent(event: "test", payloadJSON: "{}")
Issue.record("Expected sendEvent to throw when disconnected")
} catch {
let ns = error as NSError
#expect(ns.domain == "Bridge")
#expect(ns.code == 15)
}
}
}
@@ -1,4 +1,3 @@
import ClawdbotDiscovery
import SwiftUI
import Testing
@testable import Clawdbot
@@ -7,7 +6,7 @@ import Testing
@MainActor
struct MasterDiscoveryMenuSmokeTests {
@Test func inlineListBuildsBodyWhenEmpty() {
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
let discovery = GatewayDiscoveryModel()
discovery.statusText = "Searching…"
discovery.gateways = []
@@ -16,7 +15,7 @@ struct MasterDiscoveryMenuSmokeTests {
}
@Test func inlineListBuildsBodyWithMasterAndSelection() {
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
let discovery = GatewayDiscoveryModel()
discovery.statusText = "Found 1"
discovery.gateways = [
GatewayDiscoveryModel.DiscoveredGateway(
@@ -24,8 +23,6 @@ struct MasterDiscoveryMenuSmokeTests {
lanHost: "office.local",
tailnetDns: "office.tailnet-123.ts.net",
sshPort: 2222,
gatewayPort: nil,
cliPath: nil,
stableID: "office",
debugID: "office",
isLocal: false),
@@ -37,7 +34,7 @@ struct MasterDiscoveryMenuSmokeTests {
}
@Test func menuBuildsBodyWithMasters() {
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
let discovery = GatewayDiscoveryModel()
discovery.statusText = "Found 2"
discovery.gateways = [
GatewayDiscoveryModel.DiscoveredGateway(
@@ -45,8 +42,6 @@ struct MasterDiscoveryMenuSmokeTests {
lanHost: "a.local",
tailnetDns: nil,
sshPort: 22,
gatewayPort: nil,
cliPath: nil,
stableID: "a",
debugID: "a",
isLocal: false),
@@ -55,8 +50,6 @@ struct MasterDiscoveryMenuSmokeTests {
lanHost: nil,
tailnetDns: "b.ts.net",
sshPort: 22,
gatewayPort: nil,
cliPath: nil,
stableID: "b",
debugID: "b",
isLocal: false),
@@ -23,7 +23,7 @@ struct MenuSessionsInjectorTests {
let injector = MenuSessionsInjector()
injector.setTestingControlChannelConnected(true)
let defaults = SessionDefaults(model: "anthropic/claude-opus-4-5", contextTokens: 200_000)
let defaults = SessionDefaults(model: "claude-opus-4-5", contextTokens: 200_000)
let rows = [
SessionRow(
id: "main",
@@ -66,24 +66,6 @@ struct MenuSessionsInjectorTests {
rows: rows)
injector.setTestingSnapshot(snapshot, errorText: nil)
let usage = GatewayUsageSummary(
updatedAt: Date().timeIntervalSince1970 * 1000,
providers: [
GatewayUsageProvider(
provider: "anthropic",
displayName: "Claude",
windows: [GatewayUsageWindow(label: "5h", usedPercent: 12, resetAt: nil)],
plan: "Pro",
error: nil),
GatewayUsageProvider(
provider: "openai-codex",
displayName: "Codex",
windows: [GatewayUsageWindow(label: "day", usedPercent: 3, resetAt: nil)],
plan: nil,
error: nil),
])
injector.setTestingUsageSummary(usage, errorText: nil)
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: ""))
menu.addItem(.separator())
@@ -91,6 +73,5 @@ struct MenuSessionsInjectorTests {
injector.injectForTesting(into: menu)
#expect(menu.items.contains { $0.tag == 9_415_557 })
#expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem })
}
}
@@ -1,4 +1,3 @@
import ClawdbotDiscovery
import SwiftUI
import Testing
@testable import Clawdbot
@@ -11,7 +10,7 @@ struct OnboardingViewSmokeTests {
let view = OnboardingView(
state: state,
permissionMonitor: PermissionMonitor.shared,
discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName))
discoveryModel: GatewayDiscoveryModel())
_ = view.body
}
@@ -1,20 +0,0 @@
import CoreLocation
import Testing
@testable import Clawdbot
@Suite("PermissionManager Location")
struct PermissionManagerLocationTests {
@Test("authorizedAlways counts for both modes")
func authorizedAlwaysCountsForBothModes() {
#expect(PermissionManager.isLocationAuthorized(status: .authorizedAlways, requireAlways: false))
#expect(PermissionManager.isLocationAuthorized(status: .authorizedAlways, requireAlways: true))
}
@Test("other statuses not authorized")
func otherStatusesNotAuthorized() {
#expect(!PermissionManager.isLocationAuthorized(status: .notDetermined, requireAlways: false))
#expect(!PermissionManager.isLocationAuthorized(status: .denied, requireAlways: false))
#expect(!PermissionManager.isLocationAuthorized(status: .restricted, requireAlways: false))
}
}
@@ -1,49 +0,0 @@
import Testing
@testable import ClawdbotDiscovery
@Suite
struct WideAreaGatewayDiscoveryTests {
@Test func discoversBeaconFromTailnetDnsSdFallback() {
let statusJson = """
{
"Self": { "TailscaleIPs": ["100.69.232.64"] },
"Peer": {
"peer-1": { "TailscaleIPs": ["100.123.224.76"] }
}
}
"""
let context = WideAreaGatewayDiscovery.DiscoveryContext(
tailscaleStatus: { statusJson },
dig: { args, _ in
let recordType = args.last ?? ""
let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? ""
if recordType == "PTR" {
if nameserver == "@100.123.224.76" {
return "steipetacstudio-bridge._clawdbot-bridge._tcp.clawdbot.internal.\n"
}
return ""
}
if recordType == "SRV" {
return "0 0 18790 steipetacstudio.clawdbot.internal."
}
if recordType == "TXT" {
return "\"displayName=Peter\\226\\128\\153s Mac Studio (Clawdbot)\" \"transport=bridge\" \"bridgePort=18790\" \"gatewayPort=18789\" \"tailnetDns=peters-mac-studio-1.sheep-coho.ts.net\" \"cliPath=/Users/steipete/clawdbot/src/entry.ts\""
}
return ""
})
let beacons = WideAreaGatewayDiscovery.discover(
timeoutSeconds: 2.0,
context: context)
#expect(beacons.count == 1)
let beacon = beacons[0]
let expectedDisplay = "Peter\u{2019}s Mac Studio (Clawdbot)"
#expect(beacon.displayName == expectedDisplay)
#expect(beacon.bridgePort == 18790)
#expect(beacon.gatewayPort == 18789)
#expect(beacon.tailnetDns == "peters-mac-studio-1.sheep-coho.ts.net")
#expect(beacon.cliPath == "/Users/steipete/clawdbot/src/entry.ts")
}
}
@@ -52,10 +52,8 @@ public struct ClawdbotChatView: View {
public var body: some View {
ZStack {
if self.style == .standard {
ClawdbotChatTheme.background
.ignoresSafeArea()
}
ClawdbotChatTheme.background
.ignoresSafeArea()
VStack(spacing: Layout.stackSpacing) {
self.messageList
@@ -112,19 +112,24 @@ public final class ClawdbotChatViewModel {
recent.append(entry)
}
let mainKey = "main"
var result: [ClawdbotChatSessionEntry] = []
var included = Set<String>()
if let main = sorted.first(where: { $0.key == mainKey }) {
result.append(main)
included.insert(mainKey)
} else if self.sessionKey == mainKey {
result.append(self.placeholderSession(key: mainKey))
included.insert(mainKey)
}
for entry in recent where !included.contains(entry.key) {
result.append(entry)
included.insert(entry.key)
}
if !included.contains(self.sessionKey) {
if let current = sorted.first(where: { $0.key == self.sessionKey }) {
result.append(current)
} else {
result.append(self.placeholderSession(key: self.sessionKey))
}
result.append(self.placeholderSession(key: self.sessionKey))
}
return result
@@ -91,85 +91,16 @@ public enum ToolDisplayRegistry {
private static func loadConfig() -> ToolDisplayConfig {
guard let url = ClawdbotKitResources.bundle.url(forResource: "tool-display", withExtension: "json") else {
return self.defaultConfig()
return ToolDisplayConfig(version: nil, fallback: nil, tools: nil)
}
do {
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(ToolDisplayConfig.self, from: data)
} catch {
return self.defaultConfig()
return ToolDisplayConfig(version: nil, fallback: nil, tools: nil)
}
}
private static func defaultConfig() -> ToolDisplayConfig {
ToolDisplayConfig(
version: 1,
fallback: ToolDisplaySpec(
emoji: "🧩",
title: nil,
label: nil,
detailKeys: [
"command",
"path",
"url",
"targetUrl",
"targetId",
"ref",
"element",
"node",
"nodeId",
"id",
"requestId",
"to",
"channelId",
"guildId",
"userId",
"name",
"query",
"pattern",
"messageId",
],
actions: nil),
tools: [
"bash": ToolDisplaySpec(
emoji: "🛠️",
title: "Bash",
label: nil,
detailKeys: ["command"],
actions: nil),
"read": ToolDisplaySpec(
emoji: "📖",
title: "Read",
label: nil,
detailKeys: ["path"],
actions: nil),
"write": ToolDisplaySpec(
emoji: "✍️",
title: "Write",
label: nil,
detailKeys: ["path"],
actions: nil),
"edit": ToolDisplaySpec(
emoji: "📝",
title: "Edit",
label: nil,
detailKeys: ["path"],
actions: nil),
"attach": ToolDisplaySpec(
emoji: "📎",
title: "Attach",
label: nil,
detailKeys: ["path", "url", "fileName"],
actions: nil),
"process": ToolDisplaySpec(
emoji: "🧰",
title: "Process",
label: nil,
detailKeys: ["sessionId"],
actions: nil),
])
}
private static func titleFromName(_ name: String) -> String {
let cleaned = name.replacingOccurrences(of: "_", with: " ").trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return "Tool" }
@@ -191,10 +122,8 @@ public enum ToolDisplayRegistry {
private static func readDetail(_ args: AnyCodable?) -> String? {
guard let path = valueForKeyPath(args, path: "path") as? String else { return nil }
let offsetAny = self.valueForKeyPath(args, path: "offset")
let limitAny = self.valueForKeyPath(args, path: "limit")
let offset = (offsetAny as? Double) ?? (offsetAny as? Int).map(Double.init)
let limit = (limitAny as? Double) ?? (limitAny as? Int).map(Double.init)
let offset = self.valueForKeyPath(args, path: "offset") as? Double
let limit = self.valueForKeyPath(args, path: "limit") as? Double
if let offset, let limit {
let end = offset + limit
return "\(path):\(Int(offset))-\(Int(end))"
-6
View File
@@ -5,9 +5,6 @@ services:
HOME: /home/node
TERM: xterm-256color
CLAWDBOT_GATEWAY_TOKEN: ${CLAWDBOT_GATEWAY_TOKEN}
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY}
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY}
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE}
volumes:
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd
@@ -33,9 +30,6 @@ services:
HOME: /home/node
TERM: xterm-256color
BROWSER: echo
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY}
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY}
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE}
volumes:
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd
+9 -92
View File
@@ -3,10 +3,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_FILE="$ROOT_DIR/docker-compose.yml"
EXTRA_COMPOSE_FILE="$ROOT_DIR/docker-compose.extra.yml"
IMAGE_NAME="${CLAWDBOT_IMAGE:-clawdbot:local}"
EXTRA_MOUNTS="${CLAWDBOT_EXTRA_MOUNTS:-}"
HOME_VOLUME_NAME="${CLAWDBOT_HOME_VOLUME:-}"
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
@@ -30,7 +27,6 @@ export CLAWDBOT_GATEWAY_PORT="${CLAWDBOT_GATEWAY_PORT:-18789}"
export CLAWDBOT_BRIDGE_PORT="${CLAWDBOT_BRIDGE_PORT:-18790}"
export CLAWDBOT_GATEWAY_BIND="${CLAWDBOT_GATEWAY_BIND:-lan}"
export CLAWDBOT_IMAGE="$IMAGE_NAME"
export CLAWDBOT_DOCKER_APT_PACKAGES="${CLAWDBOT_DOCKER_APT_PACKAGES:-}"
if [[ -z "${CLAWDBOT_GATEWAY_TOKEN:-}" ]]; then
if command -v openssl >/dev/null 2>&1; then
@@ -45,78 +41,6 @@ PY
fi
export CLAWDBOT_GATEWAY_TOKEN
COMPOSE_FILES=("$COMPOSE_FILE")
COMPOSE_ARGS=()
write_extra_compose() {
local home_volume="$1"
shift
local -a mounts=("$@")
local mount
cat >"$EXTRA_COMPOSE_FILE" <<'YAML'
services:
clawdbot-gateway:
volumes:
YAML
if [[ -n "$home_volume" ]]; then
printf ' - %s:/home/node\n' "$home_volume" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s:/home/node/.clawdbot\n' "$CLAWDBOT_CONFIG_DIR" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s:/home/node/clawd\n' "$CLAWDBOT_WORKSPACE_DIR" >>"$EXTRA_COMPOSE_FILE"
fi
for mount in "${mounts[@]}"; do
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
done
cat >>"$EXTRA_COMPOSE_FILE" <<'YAML'
clawdbot-cli:
volumes:
YAML
if [[ -n "$home_volume" ]]; then
printf ' - %s:/home/node\n' "$home_volume" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s:/home/node/.clawdbot\n' "$CLAWDBOT_CONFIG_DIR" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s:/home/node/clawd\n' "$CLAWDBOT_WORKSPACE_DIR" >>"$EXTRA_COMPOSE_FILE"
fi
for mount in "${mounts[@]}"; do
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
done
if [[ -n "$home_volume" && "$home_volume" != *"/"* ]]; then
cat >>"$EXTRA_COMPOSE_FILE" <<YAML
volumes:
${home_volume}:
YAML
fi
}
VALID_MOUNTS=()
if [[ -n "$EXTRA_MOUNTS" ]]; then
IFS=',' read -r -a mounts <<<"$EXTRA_MOUNTS"
for mount in "${mounts[@]}"; do
mount="${mount#"${mount%%[![:space:]]*}"}"
mount="${mount%"${mount##*[![:space:]]}"}"
if [[ -n "$mount" ]]; then
VALID_MOUNTS+=("$mount")
fi
done
fi
if [[ -n "$HOME_VOLUME_NAME" || ${#VALID_MOUNTS[@]} -gt 0 ]]; then
write_extra_compose "$HOME_VOLUME_NAME" "${VALID_MOUNTS[@]}"
COMPOSE_FILES+=("$EXTRA_COMPOSE_FILE")
fi
for compose_file in "${COMPOSE_FILES[@]}"; do
COMPOSE_ARGS+=("-f" "$compose_file")
done
COMPOSE_HINT="docker compose"
for compose_file in "${COMPOSE_FILES[@]}"; do
COMPOSE_HINT+=" -f ${compose_file}"
done
ENV_FILE="$ROOT_DIR/.env"
upsert_env() {
local file="$1"
@@ -160,17 +84,10 @@ upsert_env "$ENV_FILE" \
CLAWDBOT_BRIDGE_PORT \
CLAWDBOT_GATEWAY_BIND \
CLAWDBOT_GATEWAY_TOKEN \
CLAWDBOT_IMAGE \
CLAWDBOT_EXTRA_MOUNTS \
CLAWDBOT_HOME_VOLUME \
CLAWDBOT_DOCKER_APT_PACKAGES
CLAWDBOT_IMAGE
echo "==> Building Docker image: $IMAGE_NAME"
docker build \
--build-arg "CLAWDBOT_DOCKER_APT_PACKAGES=${CLAWDBOT_DOCKER_APT_PACKAGES}" \
-t "$IMAGE_NAME" \
-f "$ROOT_DIR/Dockerfile" \
"$ROOT_DIR"
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/Dockerfile" "$ROOT_DIR"
echo ""
echo "==> Onboarding (interactive)"
@@ -181,21 +98,21 @@ echo " - Gateway token: $CLAWDBOT_GATEWAY_TOKEN"
echo " - Tailscale exposure: Off"
echo " - Install Gateway daemon: No"
echo ""
docker compose "${COMPOSE_ARGS[@]}" run --rm clawdbot-cli onboard --no-install-daemon
docker compose -f "$COMPOSE_FILE" run --rm clawdbot-cli onboard
echo ""
echo "==> Provider setup (optional)"
echo "WhatsApp (QR):"
echo " ${COMPOSE_HINT} run --rm clawdbot-cli providers login"
echo " docker compose -f $COMPOSE_FILE run --rm clawdbot-cli providers login"
echo "Telegram (bot token):"
echo " ${COMPOSE_HINT} run --rm clawdbot-cli providers add --provider telegram --token <token>"
echo " docker compose -f $COMPOSE_FILE run --rm clawdbot-cli providers add --provider telegram --token <token>"
echo "Discord (bot token):"
echo " ${COMPOSE_HINT} run --rm clawdbot-cli providers add --provider discord --token <token>"
echo " docker compose -f $COMPOSE_FILE run --rm clawdbot-cli providers add --provider discord --token <token>"
echo "Docs: https://docs.clawd.bot/providers"
echo ""
echo "==> Starting gateway"
docker compose "${COMPOSE_ARGS[@]}" up -d clawdbot-gateway
docker compose -f "$COMPOSE_FILE" up -d clawdbot-gateway
echo ""
echo "Gateway running with host port mapping."
@@ -205,5 +122,5 @@ echo "Workspace: $CLAWDBOT_WORKSPACE_DIR"
echo "Token: $CLAWDBOT_GATEWAY_TOKEN"
echo ""
echo "Commands:"
echo " ${COMPOSE_HINT} logs -f clawdbot-gateway"
echo " ${COMPOSE_HINT} exec clawdbot-gateway node dist/index.js health --token \"$CLAWDBOT_GATEWAY_TOKEN\""
echo " docker compose -f $COMPOSE_FILE logs -f clawdbot-gateway"
echo " docker compose -f $COMPOSE_FILE exec clawdbot-gateway node dist/index.js health --token \"$CLAWDBOT_GATEWAY_TOKEN\""
+1 -1
View File
@@ -1,4 +1,4 @@
title: "Clawdbot Docs"
title: "CLAWDBOT Docs"
description: "A TypeScript/Node gateway + macOS/iOS/Android companions for WhatsApp (web) and Telegram (bot)."
markdown: kramdown
highlighter: rouge
+2 -2
View File
@@ -35,7 +35,7 @@
<header class="shell">
<div class="shell__frame" role="banner">
<div class="shell__titlebar">
<div class="brand" aria-label="Clawdbot docs terminal">
<div class="brand" aria-label="CLAWDBOT docs terminal">
<img
class="brand__logo"
src="{{ "/assets/pixel-lobster.svg" | relative_url }}"
@@ -45,7 +45,7 @@
decoding="async"
/>
<div class="brand__text">
<div class="brand__name">Clawdbot</div>
<div class="brand__name">CLAWDBOT</div>
<div class="brand__hint">docs // lobster terminal</div>
</div>
</div>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 798 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

+2 -24
View File
@@ -13,7 +13,7 @@ Goal: Gmail watch -> Pub/Sub push -> `gog gmail watch serve` -> Clawdbot webhook
- `gcloud` installed and logged in ([install guide](https://docs.cloud.google.com/sdk/docs/install-sdk)).
- `gog` (gogcli) installed and authorized for the Gmail account ([gogcli.sh](https://gogcli.sh/)).
- Clawdbot hooks enabled (see [Webhooks](/automation/webhook)).
- Clawdbot hooks enabled (see [`docs/webhook.md`](https://docs.clawd.bot/automation/webhook)).
- `tailscale` logged in ([tailscale.com](https://tailscale.com/)). Supported setup uses Tailscale Funnel for the public HTTPS endpoint.
Other tunnel services can work, but are DIY/unsupported and require manual wiring.
Right now, Tailscale is what we support.
@@ -65,27 +65,8 @@ uses the last delivery route (falls back to WhatsApp).
To force a cheaper model for Gmail runs, set `model` in the mapping
(`provider/model` or alias). If you enforce `agents.defaults.models`, include it there.
To set a default model and thinking level specifically for Gmail hooks, add
`hooks.gmail.model` / `hooks.gmail.thinking` in your config:
```json5
{
hooks: {
gmail: {
model: "openrouter/meta-llama/llama-3.3-70b-instruct:free",
thinking: "off"
}
}
}
```
Notes:
- Per-hook `model`/`thinking` in the mapping still overrides these defaults.
- Fallback order: `hooks.gmail.model` → `agents.defaults.model.fallbacks` → primary (auth/rate-limit/timeouts).
- If `agents.defaults.models` is set, the Gmail model must be in the allowlist.
To customize payload handling further, add `hooks.mappings` or a JS/TS transform module
under `hooks.transformsDir` (see [Webhooks](/automation/webhook)).
under `hooks.transformsDir` (see [`docs/webhook.md`](https://docs.clawd.bot/automation/webhook)).
## Wizard (recommended)
@@ -105,9 +86,6 @@ Path note: when `tailscale.mode` is enabled, Clawdbot automatically sets
`hooks.gmail.serve.path` to `/` and keeps the public path at
`hooks.gmail.tailscale.path` (default `/gmail-pubsub`) because Tailscale
strips the set-path prefix before proxying.
If you need the backend to receive the prefixed path, set
`hooks.gmail.tailscale.target` (or `--tailscale-target`) to a full URL like
`http://127.0.0.1:8788/gmail-pubsub` and match `hooks.gmail.serve.path`.
Want a custom endpoint? Use `--push-endpoint <url>` or `--tailscale off`.
+5 -5
View File
@@ -57,7 +57,7 @@ Payload:
"name": "Email",
"sessionKey": "hook:email:msg-123",
"wakeMode": "now",
"deliver": true,
"deliver": false,
"provider": "last",
"to": "+15551234567",
"model": "openai/gpt-5.2-mini",
@@ -70,9 +70,9 @@ Payload:
- `name` optional (string): Human-readable name for the hook (e.g., "GitHub"), used as a prefix in session summaries.
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context.
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging provider. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
- `provider` optional (string): The messaging service for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `msteams`. Defaults to `last`.
- `to` optional (string): The recipient identifier for the provider (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack, conversation ID for MS Teams). Defaults to the last recipient in the main session.
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging provider. Defaults to `false`. Responses that are only heartbeat acknowledgments are automatically skipped.
- `provider` optional (string): The messaging service for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`. Defaults to `last`.
- `to` optional (string): The recipient identifier for the provider (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack). Defaults to the last recipient in the main session.
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
- `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds.
@@ -97,7 +97,7 @@ Mapping options (summary):
- Set `deliver: true` + `provider`/`to` on mappings to route replies to a chat surface
(`provider` defaults to `last` and falls back to WhatsApp).
- `clawdbot hooks gmail setup` writes `hooks.gmail` config for `clawdbot hooks gmail run`.
See [Gmail Pub/Sub](/automation/gmail-pubsub) for the full Gmail watch flow.
See [`docs/gmail-pubsub.md`](https://docs.clawd.bot/automation/gmail-pubsub) for the full Gmail watch flow.
## Responses
-407
View File
@@ -1,407 +0,0 @@
---
summary: "Broadcast a WhatsApp message to multiple agents"
read_when:
- Configuring broadcast groups
- Debugging multi-agent replies in WhatsApp
status: experimental
---
# Broadcast Groups
**Status:** Experimental
**Version:** Added in 2026.1.9
## Overview
Broadcast Groups enable multiple agents to process and respond to the same message simultaneously. This allows you to create specialized agent teams that work together in a single WhatsApp group or DM — all using one phone number.
Current scope: **WhatsApp only** (web provider).
Broadcast groups are evaluated after provider allowlists and group activation rules. In WhatsApp groups, this means broadcasts happen when Clawdbot would normally reply (for example: on mention, depending on your group settings).
## Use Cases
### 1. Specialized Agent Teams
Deploy multiple agents with atomic, focused responsibilities:
```
Group: "Development Team"
Agents:
- CodeReviewer (reviews code snippets)
- DocumentationBot (generates docs)
- SecurityAuditor (checks for vulnerabilities)
- TestGenerator (suggests test cases)
```
Each agent processes the same message and provides its specialized perspective.
### 2. Multi-Language Support
```
Group: "International Support"
Agents:
- Agent_EN (responds in English)
- Agent_DE (responds in German)
- Agent_ES (responds in Spanish)
```
### 3. Quality Assurance Workflows
```
Group: "Customer Support"
Agents:
- SupportAgent (provides answer)
- QAAgent (reviews quality, only responds if issues found)
```
### 4. Task Automation
```
Group: "Project Management"
Agents:
- TaskTracker (updates task database)
- TimeLogger (logs time spent)
- ReportGenerator (creates summaries)
```
## Configuration
### Basic Setup
Add a top-level `broadcast` section (next to `bindings`). Keys are WhatsApp peer ids:
- group chats: group JID (e.g. `120363403215116621@g.us`)
- DMs: E.164 phone number (e.g. `+15551234567`)
```json
{
"broadcast": {
"120363403215116621@g.us": ["alfred", "baerbel", "assistant3"]
}
}
```
**Result:** When Clawdbot would reply in this chat, it will run all three agents.
### Processing Strategy
Control how agents process messages:
#### Parallel (Default)
All agents process simultaneously:
```json
{
"broadcast": {
"strategy": "parallel",
"120363403215116621@g.us": ["alfred", "baerbel"]
}
}
```
#### Sequential
Agents process in order (one waits for previous to finish):
```json
{
"broadcast": {
"strategy": "sequential",
"120363403215116621@g.us": ["alfred", "baerbel"]
}
}
```
### Complete Example
```json
{
"agents": {
"list": [
{
"id": "code-reviewer",
"name": "Code Reviewer",
"workspace": "/path/to/code-reviewer",
"sandbox": { "mode": "all" }
},
{
"id": "security-auditor",
"name": "Security Auditor",
"workspace": "/path/to/security-auditor",
"sandbox": { "mode": "all" }
},
{
"id": "docs-generator",
"name": "Documentation Generator",
"workspace": "/path/to/docs-generator",
"sandbox": { "mode": "all" }
}
]
},
"broadcast": {
"strategy": "parallel",
"120363403215116621@g.us": ["code-reviewer", "security-auditor", "docs-generator"],
"120363424282127706@g.us": ["support-en", "support-de"],
"+15555550123": ["assistant", "logger"]
}
}
```
## How It Works
### Message Flow
1. **Incoming message** arrives in a WhatsApp group
2. **Broadcast check**: System checks if peer ID is in `broadcast`
3. **If in broadcast list**:
- All listed agents process the message
- Each agent has its own session key and isolated context
- Agents process in parallel (default) or sequentially
4. **If not in broadcast list**:
- Normal routing applies (first matching binding)
Note: broadcast groups do not bypass provider allowlists or group activation rules (mentions/commands/etc). They only change *which agents run* when a message is eligible for processing.
### Session Isolation
Each agent in a broadcast group maintains completely separate:
- **Session keys** (`agent:alfred:whatsapp:group:120363...` vs `agent:baerbel:whatsapp:group:120363...`)
- **Conversation history** (agent doesn't see other agents' messages)
- **Workspace** (separate sandboxes if configured)
- **Tool access** (different allow/deny lists)
- **Memory/context** (separate IDENTITY.md, SOUL.md, etc.)
- **Group context buffer** (recent group messages used for context) is shared per peer, so all broadcast agents see the same context when triggered
This allows each agent to have:
- Different personalities
- Different tool access (e.g., read-only vs. read-write)
- Different models (e.g., opus vs. sonnet)
- Different skills installed
### Example: Isolated Sessions
In group `120363403215116621@g.us` with agents `["alfred", "baerbel"]`:
**Alfred's context:**
```
Session: agent:alfred:whatsapp:group:120363403215116621@g.us
History: [user message, alfred's previous responses]
Workspace: /Users/pascal/clawd-alfred/
Tools: read, write, bash
```
**Bärbel's context:**
```
Session: agent:baerbel:whatsapp:group:120363403215116621@g.us
History: [user message, baerbel's previous responses]
Workspace: /Users/pascal/clawd-baerbel/
Tools: read only
```
## Best Practices
### 1. Keep Agents Focused
Design each agent with a single, clear responsibility:
```json
{
"broadcast": {
"DEV_GROUP": ["formatter", "linter", "tester"]
}
}
```
✅ **Good:** Each agent has one job
❌ **Bad:** One generic "dev-helper" agent
### 2. Use Descriptive Names
Make it clear what each agent does:
```json
{
"agents": {
"security-scanner": { "name": "Security Scanner" },
"code-formatter": { "name": "Code Formatter" },
"test-generator": { "name": "Test Generator" }
}
}
```
### 3. Configure Different Tool Access
Give agents only the tools they need:
```json
{
"agents": {
"reviewer": {
"tools": { "allow": ["read", "bash"] } // Read-only
},
"fixer": {
"tools": { "allow": ["read", "write", "edit", "bash"] } // Read-write
}
}
}
```
### 4. Monitor Performance
With many agents, consider:
- Using `"strategy": "parallel"` (default) for speed
- Limiting broadcast groups to 5-10 agents
- Using faster models for simpler agents
### 5. Handle Failures Gracefully
Agents fail independently. One agent's error doesn't block others:
```
Message → [Agent A ✓, Agent B ✗ error, Agent C ✓]
Result: Agent A and C respond, Agent B logs error
```
## Compatibility
### Providers
Broadcast groups currently work with:
- ✅ WhatsApp (implemented)
- 🚧 Telegram (planned)
- 🚧 Discord (planned)
- 🚧 Slack (planned)
### Routing
Broadcast groups work alongside existing routing:
```json
{
"bindings": [
{ "match": { "provider": "whatsapp", "peer": { "kind": "group", "id": "GROUP_A" } }, "agentId": "alfred" }
],
"broadcast": {
"GROUP_B": ["agent1", "agent2"]
}
}
```
- `GROUP_A`: Only alfred responds (normal routing)
- `GROUP_B`: agent1 AND agent2 respond (broadcast)
**Precedence:** `broadcast` takes priority over `bindings`.
## Troubleshooting
### Agents Not Responding
**Check:**
1. Agent IDs exist in `agents.list`
2. Peer ID format is correct (e.g., `120363403215116621@g.us`)
3. Agents are not in deny lists
**Debug:**
```bash
tail -f ~/.clawdbot/logs/gateway.log | grep broadcast
```
### Only One Agent Responding
**Cause:** Peer ID might be in `bindings` but not `broadcast`.
**Fix:** Add to broadcast config or remove from bindings.
### Performance Issues
**If slow with many agents:**
- Reduce number of agents per group
- Use lighter models (sonnet instead of opus)
- Check sandbox startup time
## Examples
### Example 1: Code Review Team
```json
{
"broadcast": {
"strategy": "parallel",
"120363403215116621@g.us": [
"code-formatter",
"security-scanner",
"test-coverage",
"docs-checker"
]
},
"agents": {
"list": [
{ "id": "code-formatter", "workspace": "~/agents/formatter", "tools": { "allow": ["read", "write"] } },
{ "id": "security-scanner", "workspace": "~/agents/security", "tools": { "allow": ["read", "bash"] } },
{ "id": "test-coverage", "workspace": "~/agents/testing", "tools": { "allow": ["read", "bash"] } },
{ "id": "docs-checker", "workspace": "~/agents/docs", "tools": { "allow": ["read"] } }
]
}
}
```
**User sends:** Code snippet
**Responses:**
- code-formatter: "Fixed indentation and added type hints"
- security-scanner: "⚠️ SQL injection vulnerability in line 12"
- test-coverage: "Coverage is 45%, missing tests for error cases"
- docs-checker: "Missing docstring for function `process_data`"
### Example 2: Multi-Language Support
```json
{
"broadcast": {
"strategy": "sequential",
"+15555550123": ["detect-language", "translator-en", "translator-de"]
},
"agents": {
"list": [
{ "id": "detect-language", "workspace": "~/agents/lang-detect" },
{ "id": "translator-en", "workspace": "~/agents/translate-en" },
{ "id": "translator-de", "workspace": "~/agents/translate-de" }
]
}
}
```
## API Reference
### Config Schema
```typescript
interface ClawdbotConfig {
broadcast?: {
strategy?: "parallel" | "sequential";
[peerId: string]: string[];
};
}
```
### Fields
- `strategy` (optional): How to process agents
- `"parallel"` (default): All agents process simultaneously
- `"sequential"`: Agents process in array order
- `[peerId]`: WhatsApp group JID, E.164 number, or other peer ID
- Value: Array of agent IDs that should process messages
## Limitations
1. **Max agents:** No hard limit, but 10+ agents may be slow
2. **Shared context:** Agents don't see each other's responses (by design)
3. **Message ordering:** Parallel responses may arrive in any order
4. **Rate limits:** All agents count toward WhatsApp rate limits
## Future Enhancements
Planned features:
- [ ] Shared context mode (agents see each other's responses)
- [ ] Agent coordination (agents can signal each other)
- [ ] Dynamic agent selection (choose agents based on message content)
- [ ] Agent priorities (some agents respond before others)
## See Also
- [Multi-Agent Configuration](/multi-agent-sandbox-tools)
- [Routing Configuration](/concepts/provider-routing)
- [Session Management](/concepts/sessions)
+2 -9
View File
@@ -39,9 +39,8 @@ Notes:
- `--password <password>`: password override (also sets `CLAWDBOT_GATEWAY_PASSWORD` for the process).
- `--tailscale <off|serve|funnel>`: expose the Gateway via Tailscale.
- `--tailscale-reset-on-exit`: reset Tailscale serve/funnel config on shutdown.
- `--allow-unconfigured`: allow gateway start without `gateway.mode=local` in config.
- `--dev`: create a dev config + workspace if missing (skips BOOTSTRAP.md).
- `--reset`: reset dev config + credentials + sessions + workspace (requires `--dev`).
- `--reset`: recreate the dev config (requires `--dev`).
- `--force`: kill any existing listener on the selected port before starting.
- `--verbose`: verbose logs.
- `--claude-cli-logs`: only show claude-cli logs in the console (and enable its stdout/stderr).
@@ -78,7 +77,7 @@ clawdbot gateway health --url ws://127.0.0.1:18789
- your configured remote gateway (if set), and
- localhost (loopback) **even if remote is configured**.
If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use profiles for redundancy, but most installs still run a single gateway.
If multiple gateways are reachable, it prints all of them and warns this is an unconventional setup (usually you want only one gateway).
```bash
clawdbot gateway status
@@ -122,12 +121,6 @@ clawdbot gateway call logs.tail --params '{"sinceMs": 60000}'
Only gateways with the **bridge enabled** will advertise the discovery beacon.
Wide-Area discovery records include (TXT):
- `gatewayPort` (WebSocket port, usually `18789`)
- `sshPort` (SSH port; defaults to `22` if not present)
- `tailnetDns` (MagicDNS hostname, when available)
- `cliPath` (optional hint for remote installs)
### `gateway discover`
```bash
+12 -87
View File
@@ -14,7 +14,6 @@ This page describes the current CLI behavior. If commands change, update this do
- `--dev`: isolate state under `~/.clawdbot-dev` and shift default ports.
- `--profile <name>`: isolate state under `~/.clawdbot-<name>`.
- `--no-color`: disable ANSI colors.
- `--update`: shorthand for `clawdbot update` (source installs only).
- `-V`, `--version`, `-v`: print version and exit.
## Output styling
@@ -48,11 +47,9 @@ clawdbot [--dev] [--profile <name>] <command>
onboard
configure (alias: config)
doctor
update
providers
list
status
logs
add
remove
login
@@ -75,14 +72,6 @@ clawdbot [--dev] [--profile <name>] <command>
health
status
discover
daemon
status
install
uninstall
start
stop
restart
logs
models
list
status
@@ -92,12 +81,6 @@ clawdbot [--dev] [--profile <name>] <command>
fallbacks list|add|remove|clear
image-fallbacks list|add|remove|clear
scan
auth add|setup-token|paste-token
auth order get|set|clear
sandbox
list
recreate
explain
wake
cron
status
@@ -121,8 +104,7 @@ clawdbot [--dev] [--profile <name>] <command>
run
notify
camera list|snap|clip
canvas snapshot|present|hide|navigate|eval
canvas a2ui push|reset
canvas snapshot
screen record
location get
browser
@@ -165,15 +147,6 @@ clawdbot [--dev] [--profile <name>] <command>
tui
```
## Chat slash commands
Chat messages support `/...` commands (text and native). See [/tools/slash-commands](/tools/slash-commands).
Highlights:
- `/status` for quick diagnostics.
- `/config` for persisted config changes.
- `/debug` for runtime-only config overrides (memory, not disk; requires `commands.debug: true`).
## Setup + onboarding
### `setup`
@@ -194,21 +167,13 @@ Interactive wizard to set up gateway, workspace, and skills.
Options:
- `--workspace <dir>`
- `--reset` (reset config + credentials + sessions + workspace before wizard)
- `--non-interactive`
- `--mode <local|remote>`
- `--flow <quickstart|advanced>`
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|zai-api-key|apiKey|minimax-cloud|minimax-api|minimax|opencode-zen|skip>`
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
- `--token <token>` (non-interactive; used with `--auth-choice token`)
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
- `--token-expires-in <duration>` (non-interactive; e.g. `365d`, `12h`)
- `--auth-choice <oauth|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax|skip>`
- `--anthropic-api-key <key>`
- `--openai-api-key <key>`
- `--gemini-api-key <key>`
- `--zai-api-key <key>`
- `--minimax-api-key <key>`
- `--opencode-zen-api-key <key>`
- `--gateway-port <port>`
- `--gateway-bind <loopback|lan|tailnet|auto>`
- `--gateway-auth <off|token|password>`
@@ -219,12 +184,9 @@ Options:
- `--tailscale <off|serve|funnel>`
- `--tailscale-reset-on-exit`
- `--install-daemon`
- `--no-install-daemon` (alias: `--skip-daemon`)
- `--daemon-runtime <node|bun>`
- `--skip-providers`
- `--skip-skills`
- `--skip-health`
- `--skip-ui`
- `--node-manager <npm|pnpm|bun>`
- `--json`
@@ -243,20 +205,19 @@ Options:
## Provider helpers
### `providers`
Manage chat provider accounts (WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams).
Manage chat provider accounts (WhatsApp/Telegram/Discord/Slack/Signal/iMessage).
Subcommands:
- `providers list`: show configured chat providers and auth profiles (Claude Code + Codex CLI OAuth sync included).
- `providers status`: check gateway reachability and provider health (`--probe` runs extra checks; use `clawdbot health` or `clawdbot status --deep` for gateway health probes).
- `providers status`: check gateway reachability and provider health (`--probe` to verify credentials and run small provider audits; use `status --deep` for local-only probes).
- Tip: `providers status` prints warnings with suggested fixes when it can detect common misconfigurations (then points you to `clawdbot doctor`).
- `providers logs`: show recent provider logs from the gateway log file.
- `providers add`: wizard-style setup when no flags are passed; flags switch to non-interactive mode.
- `providers remove`: disable by default; pass `--delete` to remove config entries without prompts.
- `providers login`: interactive provider login (WhatsApp Web only).
- `providers logout`: log out of a provider session (WhatsApp Web only).
Common options:
- `--provider <name>`: `whatsapp|telegram|discord|slack|signal|imessage|msteams`
- `--provider <name>`: `whatsapp|telegram|discord|slack|signal|imessage`
- `--account <id>`: provider account id (default `default`)
- `--name <label>`: display name for the account
@@ -273,11 +234,6 @@ Common options:
- `--no-usage`: skip provider usage/quota snapshots (OAuth/API-backed only).
- `--json`: output JSON (includes usage unless `--no-usage` is set).
`providers logs` options:
- `--provider <name|all>` (default `all`)
- `--lines <n>` (default `200`)
- `--json`
OAuth sync sources:
- Claude Code → `anthropic:claude-cli`
- macOS: Keychain item "Claude Code-credentials" (choose "Always Allow" to avoid launchd prompts)
@@ -314,14 +270,14 @@ Tip: use `npx clawdhub` to search, install, and sync skills.
Approve DM pairing requests across providers.
Subcommands:
- `pairing list <provider> [--json]`
- `pairing approve <provider> <code> [--notify]`
- `pairing list --provider <telegram|signal|imessage|discord|slack|whatsapp> [--json]`
- `pairing approve --provider <...> <code> [--notify]`
### `hooks gmail`
Gmail Pub/Sub hook setup + runner. See [/automation/gmail-pubsub](/automation/gmail-pubsub).
Subcommands:
- `hooks gmail setup` (requires `--account <email>`; supports `--project`, `--topic`, `--subscription`, `--label`, `--hook-url`, `--hook-token`, `--push-token`, `--bind`, `--port`, `--path`, `--include-body`, `--max-bytes`, `--renew-minutes`, `--tailscale`, `--tailscale-path`, `--tailscale-target`, `--push-endpoint`, `--json`)
- `hooks gmail setup` (requires `--account <email>`; supports `--project`, `--topic`, `--subscription`, `--label`, `--hook-url`, `--hook-token`, `--push-token`, `--bind`, `--port`, `--path`, `--include-body`, `--max-bytes`, `--renew-minutes`, `--tailscale`, `--tailscale-path`, `--push-endpoint`, `--json`)
- `hooks gmail run` (runtime overrides for the same flags)
### `dns setup`
@@ -404,18 +360,16 @@ Show linked session health and recent recipients.
Options:
- `--json`
- `--all` (full diagnosis; read-only, pasteable)
- `--deep` (probe providers)
- `--usage` (show provider usage/quota)
- `--timeout <ms>`
- `--verbose`
- `--debug` (alias for `--verbose`)
### Usage tracking
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
Surfaces:
- `/status` (alias: `/usage`; adds a short usage line when available)
- `/status` (adds a short usage line when available)
- `clawdbot status --usage` (prints full provider breakdown)
- macOS menu bar (Usage section under Context)
@@ -457,14 +411,11 @@ Options:
- `--tailscale-reset-on-exit`
- `--allow-unconfigured`
- `--dev`
- `--reset` (reset dev config + credentials + sessions + workspace)
- `--reset`
- `--force` (kill existing listener on port)
- `--verbose`
- `--claude-cli-logs`
- `--ws-log <auto|full|compact>`
- `--compact` (alias for `--ws-log compact`)
- `--raw-stream`
- `--raw-stream-path <path>`
### `daemon`
Manage the Gateway service (launchd/systemd/schtasks).
@@ -480,7 +431,7 @@ Subcommands:
Notes:
- `daemon status` probes the Gateway RPC by default using the daemon’s resolved port/config (override with `--url/--token/--password`).
- `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting.
- `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named Clawdbot services are treated as first-class and aren't flagged as "extra".
- `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans).
- `daemon status` prints which config path the CLI uses vs which config the daemon likely uses (service env), plus the resolved probe target URL.
- `daemon install` defaults to Node runtime; use `--runtime bun` only when WhatsApp is disabled.
- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`.
@@ -508,7 +459,6 @@ Subcommands:
- `gateway call <method> [--params <json>]`
- `gateway health`
- `gateway status`
- `gateway discover`
Common RPCs:
- `config.apply` (validate + write config + restart + wake)
@@ -518,20 +468,9 @@ Common RPCs:
See [/concepts/models](/concepts/models) for fallback behavior and scanning strategy.
Preferred Anthropic auth (CLI token, not API key):
```bash
claude setup-token
clawdbot models status
```
### `models` (root)
`clawdbot models` is an alias for `models status`.
Root options:
- `--status-json` (alias for `models status --json`)
- `--status-plain` (alias for `models status --plain`)
### `models list`
Options:
- `--all`
@@ -582,25 +521,12 @@ Options:
- `--max-candidates <n>`
- `--timeout <ms>`
- `--concurrency <n>`
- `--no-probe`
- `--yes`
- `--no-input`
- `--set-default`
- `--set-image`
- `--json`
### `models auth add|setup-token|paste-token`
Options:
- `add`: interactive auth helper
- `setup-token`: `--provider <name>` (default `anthropic`), `--yes`
- `paste-token`: `--provider <name>`, `--profile-id <id>`, `--expires-in <duration>`
### `models auth order get|set|clear`
Options:
- `get`: `--provider <name>`, `--agent <id>`, `--json`
- `set`: `--provider <name>`, `--agent <id>`, `<profileIds...>`
- `clear`: `--provider <name>`, `--agent <id>`
## Cron + wake
### `wake`
@@ -727,6 +653,5 @@ Options:
- `--session <key>`
- `--deliver`
- `--thinking <level>`
- `--message <text>`
- `--timeout-ms <ms>` (defaults to `agents.defaults.timeoutSeconds`)
- `--timeout-ms <ms>`
- `--history-limit <n>`
+16 -50
View File
@@ -24,10 +24,9 @@ Provider selection:
Target formats (`--to`):
- WhatsApp: E.164 or group JID
- Telegram: chat id or `@username`
- Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are rejected)
- Slack: `channel:<id>` or `user:<id>` (raw channel id is accepted)
- Signal: `+E.164`, `group:<id>`, `signal:+E.164`, `signal:group:<id>`, or `username:<name>`/`u:<name>`
- iMessage: handle, `chat_id:<id>`, `chat_guid:<guid>`, or `chat_identifier:<id>`
- Discord/Slack: `channel:<id>` or `user:<id>` (raw id ok)
- Signal: E.164, `group:<id>`, or `signal:+E.164`
- iMessage: handle or `chat_id:<id>`
- MS Teams: conversation id (`19:...@thread.tacv2`) or `conversation:<id>` or `user:<aad-object-id>`
## Common flags
@@ -43,82 +42,60 @@ Target formats (`--to`):
### Core
- `send`
- Providers: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
- Required: `--to`, `--message`
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
- Telegram only: `--buttons-json` (requires `"inlineButtons"` in `telegram.capabilities` or `telegram.accounts.<id>.capabilities`)
- Telegram only: `--thread-id` (forum topic id)
- Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field)
- WhatsApp only: `--gif-playback`
- `poll`
- Providers: WhatsApp/Discord/MS Teams
- Required: `--to`, `--poll-question`, `--poll-option` (repeat)
- Optional: `--poll-multi`
- Discord only: `--poll-duration-hours`, `--message`
- Optional: `--poll-multi`, `--poll-duration-hours`, `--message`
- `react`
- Providers: Discord/Slack/Telegram/WhatsApp
- Required: `--message-id`, `--to` or `--channel-id`
- Required: `--to`, `--message-id`
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--channel-id`
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
- WhatsApp only: `--participant`, `--from-me`
- `reactions`
- Providers: Discord/Slack
- Required: `--message-id`, `--to` or `--channel-id`
- Required: `--to`, `--message-id`
- Optional: `--limit`, `--channel-id`
- `read`
- Providers: Discord/Slack
- Required: `--to` or `--channel-id`
- Optional: `--limit`, `--before`, `--after`, `--channel-id`
- Discord only: `--around`
- Required: `--to`
- Optional: `--limit`, `--before`, `--after`, `--around`, `--channel-id`
- `edit`
- Providers: Discord/Slack
- Required: `--message-id`, `--message`, `--to` or `--channel-id`
- Required: `--to`, `--message-id`, `--message`
- Optional: `--channel-id`
- `delete`
- Providers: Discord/Slack
- Required: `--message-id`, `--to` or `--channel-id`
- Required: `--to`, `--message-id`
- Optional: `--channel-id`
- `pin` / `unpin`
- Providers: Discord/Slack
- Required: `--message-id`, `--to` or `--channel-id`
- Required: `--to`, `--message-id`
- Optional: `--channel-id`
- `pins` (list)
- Providers: Discord/Slack
- Required: `--to` or `--channel-id`
- Required: `--to`
- Optional: `--channel-id`
- `permissions`
- Providers: Discord
- Required: `--to` or `--channel-id`
- Required: `--to`
- Optional: `--channel-id`
- `search`
- Providers: Discord
- Required: `--guild-id`, `--query`
- Optional: `--channel-id`, `--channel-ids` (repeat), `--author-id`, `--author-ids` (repeat), `--limit`
### Threads
- `thread create`
- Providers: Discord
- Required: `--thread-name`, `--to` (channel id) or `--channel-id`
- Optional: `--message-id`, `--auto-archive-min`
- `thread list`
- Providers: Discord
- Required: `--guild-id`
- Optional: `--channel-id`, `--include-archived`, `--before`, `--limit`
- `thread reply`
- Providers: Discord
- Required: `--to` (thread id), `--message`
- Optional: `--media`, `--reply-to`
@@ -126,22 +103,18 @@ Target formats (`--to`):
- `emoji list`
- Discord: `--guild-id`
- Slack: no extra flags
- `emoji upload`
- Providers: Discord
- Required: `--guild-id`, `--emoji-name`, `--media`
- Optional: `--role-ids` (repeat)
### Stickers
- `sticker send`
- Providers: Discord
- Required: `--to`, `--sticker-id` (repeat)
- Optional: `--message`
- `sticker upload`
- Providers: Discord
- Required: `--guild-id`, `--sticker-name`, `--sticker-desc`, `--sticker-tags`, `--media`
### Roles / Channels / Members / Voice
@@ -161,10 +134,9 @@ Target formats (`--to`):
### Moderation (Discord)
- `timeout`: `--guild-id`, `--user-id` (optional `--duration-min` or `--until`; omit both to clear timeout)
- `kick`: `--guild-id`, `--user-id` (+ `--reason`)
- `ban`: `--guild-id`, `--user-id` (+ `--delete-days`, `--reason`)
- `timeout` also supports `--reason`
- `timeout`: `--guild-id`, `--user-id` (+ `--duration-min` or `--until`)
- `kick`: `--guild-id`, `--user-id`
- `ban`: `--guild-id`, `--user-id` (+ `--delete-days`)
## Examples
@@ -202,9 +174,3 @@ React in Slack:
clawdbot message react --provider slack \
--to C123 --message-id 456 --emoji "✅"
```
Send Telegram inline buttons:
```
clawdbot message send --provider telegram --to @mychat --message "Choose:" \
--buttons-json '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]'
```
Loaded 100 of 740 files, more files were not shown because too many files have changed in this diff. Show more