Compare commits

..
Author SHA1 Message Date
Peter Steinberger 7a5e3662a3 fix(web): show all WhatsApp shared contacts 2026-01-10 00:16:54 +00:00
Peter Steinberger d3d6d80f4a fix(agents): require raw for gateway config.apply (#566) (thanks @sircrumpet) 2026-01-10 00:16:17 +00:00
Eugene (via Claudius) a51948a528 fix(tools): flatten gateway tool schema for Vertex AI compatibility
Claude API on Vertex AI (Cloud Code Assist / Antigravity) enforces strict
JSON Schema 2020-12 validation and rejects root-level anyOf without a
top-level type field.

TypeBox Type.Union compiles to { anyOf: [...] } which Anthropic's direct
API accepts but Vertex rejects with:
  tools.11.custom.input_schema: JSON schema is invalid

This follows the same pattern used in browser-tool.ts which has the same
fix with an explanatory comment.

Flatten the schema to Type.Object with an action enum, matching how
browser tool handles this constraint.
2026-01-10 00:16:17 +00:00
2279 changed files with 128138 additions and 223406 deletions

No files matched your search

-26
View File
@@ -1,26 +0,0 @@
# detect-secrets exclusion patterns (regex)
#
# Note: detect-secrets does not read this file by default. If you want these
# applied, wire them into your scan command (e.g. translate to --exclude-files
# / --exclude-lines) or into a baseline's filters_used.
[exclude-files]
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
pattern = (^|/)pnpm-lock\.yaml$
[exclude-lines]
# Fastlane checks for private key marker; not a real key.
pattern = key_content\.include\?\("BEGIN PRIVATE KEY"\)
# UI label string for Anthropic auth mode.
pattern = case \.apiKeyEnv: "API key \(env var\)"
# CodingKeys mapping uses apiKey literal.
pattern = case apikey = "apiKey"
# Schema labels referencing password fields (not actual secrets).
pattern = "gateway\.remote\.password"
pattern = "gateway\.auth\.password"
# Schema label for talk API key (label text only).
pattern = "talk\.apiKey"
# checking for typeof is not something we care about.
pattern = === "string"
# specific optional-chaining password check that didn't match the line above.
pattern = typeof remote\?\.password === "string"
+9 -85
View File
@@ -5,57 +5,6 @@ on:
pull_request: pull_request:
jobs: 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: checks:
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
strategy: strategy:
@@ -74,9 +23,9 @@ jobs:
- runtime: node - runtime: node
task: protocol task: protocol
command: pnpm protocol:check command: pnpm protocol:check
- runtime: node - runtime: bun
task: format task: lint
command: pnpm format command: bunx biome check src
- runtime: bun - runtime: bun
task: test task: test
command: bunx vitest run command: bunx vitest run
@@ -105,7 +54,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22.x node-version: 24
check-latest: true check-latest: true
- name: Setup Bun - name: Setup Bun
@@ -136,36 +85,11 @@ jobs:
which node which node
node -v node -v
pnpm -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 }}) - name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }} run: ${{ matrix.command }}
secrets:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install detect-secrets
run: |
python -m pip install --upgrade pip
python -m pip install detect-secrets==1.5.0
- name: Detect secrets
run: |
if ! detect-secrets scan --baseline .secrets.baseline; then
echo "::error::Secret scanning failed. See docs/gateway/security.md#secret-scanning-detect-secrets"
exit 1
fi
checks-windows: checks-windows:
runs-on: blacksmith-4vcpu-windows-2025 runs-on: blacksmith-4vcpu-windows-2025
defaults: defaults:
@@ -209,7 +133,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22.x node-version: 24
check-latest: true check-latest: true
- name: Setup Bun - name: Setup Bun
@@ -240,7 +164,7 @@ jobs:
which node which node
node -v node -v
pnpm -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 }}) - name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }} run: ${{ matrix.command }}
@@ -276,7 +200,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22.x node-version: 24
check-latest: true check-latest: true
- name: Runtime versions - name: Runtime versions
@@ -301,7 +225,7 @@ jobs:
which node which node
node -v node -v
pnpm -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 }} - name: Run ${{ matrix.task }}
run: ${{ matrix.command }} run: ${{ matrix.command }}
-33
View File
@@ -1,33 +0,0 @@
name: Install Smoke
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
install-smoke:
runs-on: ubuntu-latest
steps:
- name: Checkout CLI
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Enable Corepack
run: corepack enable
- name: Install pnpm deps (minimal)
run: pnpm install --ignore-scripts --frozen-lockfile
- name: Run installer docker tests
env:
CLAWDBOT_INSTALL_URL: https://clawd.bot/install.sh
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
CLAWDBOT_NO_ONBOARD: "1"
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
run: pnpm test:install:smoke
-1
View File
@@ -1,6 +1,5 @@
node_modules node_modules
.env .env
docker-compose.extra.yml
dist dist
*.bun-build *.bun-build
pnpm-lock.yaml pnpm-lock.yaml
-5
View File
@@ -1,5 +0,0 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"indentWidth": 2,
"printWidth": 100
}
-4
View File
@@ -1,4 +0,0 @@
{
"$schema": "https://json.schemastore.org/oxlintrc",
"extends": ["recommended"]
}
-518
View File
@@ -1,518 +0,0 @@
{
"version": "1.5.0",
"plugins_used": [
{
"name": "ArtifactoryDetector"
},
{
"name": "AWSKeyDetector"
},
{
"name": "AzureStorageKeyDetector"
},
{
"name": "Base64HighEntropyString",
"limit": 4.5
},
{
"name": "BasicAuthDetector"
},
{
"name": "CloudantDetector"
},
{
"name": "DiscordBotTokenDetector"
},
{
"name": "GitHubTokenDetector"
},
{
"name": "GitLabTokenDetector"
},
{
"name": "HexHighEntropyString",
"limit": 3.0
},
{
"name": "IbmCloudIamDetector"
},
{
"name": "IbmCosHmacDetector"
},
{
"name": "IPPublicDetector"
},
{
"name": "JwtTokenDetector"
},
{
"name": "KeywordDetector",
"keyword_exclude": ""
},
{
"name": "MailchimpDetector"
},
{
"name": "NpmDetector"
},
{
"name": "OpenAIDetector"
},
{
"name": "PrivateKeyDetector"
},
{
"name": "PypiTokenDetector"
},
{
"name": "SendGridDetector"
},
{
"name": "SlackDetector"
},
{
"name": "SoftlayerDetector"
},
{
"name": "SquareOAuthDetector"
},
{
"name": "StripeDetector"
},
{
"name": "TelegramBotTokenDetector"
},
{
"name": "TwilioKeyDetector"
}
],
"filters_used": [
{
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
},
{
"path": "detect_secrets.filters.common.is_baseline_file",
"filename": ".secrets.baseline"
},
{
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
"min_level": 2
},
{
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
},
{
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
},
{
"path": "detect_secrets.filters.heuristic.is_lock_file"
},
{
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
},
{
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
},
{
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
},
{
"path": "detect_secrets.filters.heuristic.is_sequential_string"
},
{
"path": "detect_secrets.filters.heuristic.is_swagger_file"
},
{
"path": "detect_secrets.filters.heuristic.is_templated_secret"
},
{
"path": "detect_secrets.filters.regex.should_exclude_file",
"pattern": [
"(^|/)pnpm-lock\\.yaml$"
]
},
{
"path": "detect_secrets.filters.regex.should_exclude_line",
"pattern": [
"key_content\\.include\\?\\(\"BEGIN PRIVATE KEY\"\\)",
"case \\.apiKeyEnv: \"API key \\(env var\\)\"",
"case apikey = \"apiKey\"",
"\"gateway\\.remote\\.password\"",
"\"gateway\\.auth\\.password\"",
"\"talk\\.apiKey\"",
"=== \"string\"",
"typeof remote\\?\\.password === \"string\""
]
}
],
"results": {
".env.example": [
{
"type": "Twilio API Key",
"filename": ".env.example",
"hashed_secret": "3c7206eff845bc69cf12d904d0f95f9aec15535e",
"is_verified": false,
"line_number": 2
}
],
"appcast.xml": [
{
"type": "Base64 High Entropy String",
"filename": "appcast.xml",
"hashed_secret": "1b1c2b73eca84e441a823c37a06c71c9fadcfe24",
"is_verified": false,
"line_number": 19
},
{
"type": "Base64 High Entropy String",
"filename": "appcast.xml",
"hashed_secret": "5c47736fee5151b26b3bb61bb38955da0e8937c6",
"is_verified": false,
"line_number": 35
},
{
"type": "Base64 High Entropy String",
"filename": "appcast.xml",
"hashed_secret": "bbbca47179268f154c63affa0ca441c6e49e650f",
"is_verified": false,
"line_number": 52
}
],
"apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift": [
{
"type": "Secret Keyword",
"filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift",
"hashed_secret": "e761624445731fcb8b15da94343c6b92e507d190",
"is_verified": false,
"line_number": 26
},
{
"type": "Secret Keyword",
"filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift",
"hashed_secret": "a23c8630c8a5fbaa21f095e0269c135c20d21689",
"is_verified": false,
"line_number": 42
}
],
"apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift": [
{
"type": "Secret Keyword",
"filename": "apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 83
}
],
"apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift": [
{
"type": "Secret Keyword",
"filename": "apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 27
}
],
"docs/configuration.md": [
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 268
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
"is_verified": false,
"line_number": 465
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27",
"is_verified": false,
"line_number": 718
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
"is_verified": false,
"line_number": 760
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
"is_verified": false,
"line_number": 859
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6",
"is_verified": false,
"line_number": 982
}
],
"docs/faq.md": [
{
"type": "Secret Keyword",
"filename": "docs/faq.md",
"hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281",
"is_verified": false,
"line_number": 593
},
{
"type": "Secret Keyword",
"filename": "docs/faq.md",
"hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0",
"is_verified": false,
"line_number": 650
}
],
"docs/skills-config.md": [
{
"type": "Secret Keyword",
"filename": "docs/skills-config.md",
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
"is_verified": false,
"line_number": 28
}
],
"docs/skills.md": [
{
"type": "Secret Keyword",
"filename": "docs/skills.md",
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
"is_verified": false,
"line_number": 97
}
],
"docs/tailscale.md": [
{
"type": "Secret Keyword",
"filename": "docs/tailscale.md",
"hashed_secret": "9cb0dc5383312aa15b9dc6745645bde18ff5ade9",
"is_verified": false,
"line_number": 52
}
],
"docs/talk.md": [
{
"type": "Secret Keyword",
"filename": "docs/talk.md",
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
"is_verified": false,
"line_number": 50
}
],
"docs/telegram.md": [
{
"type": "Secret Keyword",
"filename": "docs/telegram.md",
"hashed_secret": "e9fe51f94eadabf54dbf2fbbd57188b9abee436e",
"is_verified": false,
"line_number": 57
}
],
"skills/local-places/SERVER_README.md": [
{
"type": "Secret Keyword",
"filename": "skills/local-places/SERVER_README.md",
"hashed_secret": "6d9c68c603e465077bdd49c62347fe54717f83a3",
"is_verified": false,
"line_number": 28
}
],
"skills/openai-whisper-api/SKILL.md": [
{
"type": "Secret Keyword",
"filename": "skills/openai-whisper-api/SKILL.md",
"hashed_secret": "1077361f94d70e1ddcc7c6dc581a489532a81d03",
"is_verified": false,
"line_number": 39
}
],
"skills/trello/SKILL.md": [
{
"type": "Secret Keyword",
"filename": "skills/trello/SKILL.md",
"hashed_secret": "11fa7c37d697f30e6aee828b4426a10f83ab2380",
"is_verified": false,
"line_number": 18
}
],
"src/agents/models-config.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/agents/models-config.test.ts",
"hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d",
"is_verified": false,
"line_number": 25
},
{
"type": "Secret Keyword",
"filename": "src/agents/models-config.test.ts",
"hashed_secret": "3a81eb091f80c845232225be5663d270e90dacb7",
"is_verified": false,
"line_number": 90
}
],
"src/agents/skills.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/agents/skills.test.ts",
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
"is_verified": false,
"line_number": 158
},
{
"type": "Secret Keyword",
"filename": "src/agents/skills.test.ts",
"hashed_secret": "7a85f4764bbd6daf1c3545efbbf0f279a6dc0beb",
"is_verified": false,
"line_number": 265
},
{
"type": "Secret Keyword",
"filename": "src/agents/skills.test.ts",
"hashed_secret": "5df3a673d724e8a1eb673a8baf623e183940804d",
"is_verified": false,
"line_number": 462
},
{
"type": "Secret Keyword",
"filename": "src/agents/skills.test.ts",
"hashed_secret": "8921daaa546693e52bc1f9c40bdcf15e816e0448",
"is_verified": false,
"line_number": 490
}
],
"src/browser/target-id.test.ts": [
{
"type": "Hex High Entropy String",
"filename": "src/browser/target-id.test.ts",
"hashed_secret": "4e126c049580d66ca1549fa534d95a7263f27f46",
"is_verified": false,
"line_number": 16
}
],
"src/commands/antigravity-oauth.ts": [
{
"type": "Base64 High Entropy String",
"filename": "src/commands/antigravity-oauth.ts",
"hashed_secret": "709d0f232b6ac4f8d24dec3e4fabfdb14257174f",
"is_verified": false,
"line_number": 17
},
{
"type": "Base64 High Entropy String",
"filename": "src/commands/antigravity-oauth.ts",
"hashed_secret": "3848603b8e866f62d07c206ff622279b9dcb0238",
"is_verified": false,
"line_number": 20
}
],
"src/commands/onboard-auth.ts": [
{
"type": "Secret Keyword",
"filename": "src/commands/onboard-auth.ts",
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
"is_verified": false,
"line_number": 50
}
],
"src/config/config.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/config/config.test.ts",
"hashed_secret": "bea2f7b64fab8d1d414d0449530b1e088d36d5b1",
"is_verified": false,
"line_number": 520
}
],
"src/gateway/server.auth.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/gateway/server.auth.test.ts",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 89
},
{
"type": "Secret Keyword",
"filename": "src/gateway/server.auth.test.ts",
"hashed_secret": "a4b48a81cdab1e1a5dd37907d6c85ca1c61ddc7c",
"is_verified": false,
"line_number": 109
}
],
"src/infra/env.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/infra/env.test.ts",
"hashed_secret": "df98a117ddabf85991b9fe0e268214dc0e1254dc",
"is_verified": false,
"line_number": 10
},
{
"type": "Secret Keyword",
"filename": "src/infra/env.test.ts",
"hashed_secret": "6d811dc1f59a55ca1a3d38b5042a062b9f79e8ec",
"is_verified": false,
"line_number": 25
}
],
"src/infra/shell-env.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/infra/shell-env.test.ts",
"hashed_secret": "65c10dc3549fe07424148a8a4790a3341ecbc253",
"is_verified": false,
"line_number": 35
},
{
"type": "Base64 High Entropy String",
"filename": "src/infra/shell-env.test.ts",
"hashed_secret": "64db6bf7f0e5a0491df4419f0eb1bbcc402989e8",
"is_verified": false,
"line_number": 56
},
{
"type": "Secret Keyword",
"filename": "src/infra/shell-env.test.ts",
"hashed_secret": "e013ffda590d2178607c16d11b1ea42f75ceb0e7",
"is_verified": false,
"line_number": 73
},
{
"type": "Base64 High Entropy String",
"filename": "src/infra/shell-env.test.ts",
"hashed_secret": "be6ee9a6bf9f2dad84a5a67d6c0576a5bacc391e",
"is_verified": false,
"line_number": 75
}
],
"src/web/qr-image.test.ts": [
{
"type": "Hex High Entropy String",
"filename": "src/web/qr-image.test.ts",
"hashed_secret": "564666dc1ca6e7318b2d5feeb1ce7b5bf717411e",
"is_verified": false,
"line_number": 12
}
],
"vendor/a2ui/README.md": [
{
"type": "Secret Keyword",
"filename": "vendor/a2ui/README.md",
"hashed_secret": "2619a5397a5d054dab3fe24e6a8da1fbd76ec3a6",
"is_verified": false,
"line_number": 123
}
]
},
"generated_at": "2026-01-05T13:01:00Z"
}
+2 -16
View File
@@ -1,21 +1,15 @@
# Repository Guidelines # Repository Guidelines
- Repo: https://github.com/clawdbot/clawdbot
- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
## Project Structure & Module Organization ## Project Structure & Module Organization
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`). - Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
- Tests: colocated `*.test.ts`. - Tests: colocated `*.test.ts`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`. - Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
## Docs Linking (Mintlify) ## Docs Linking (Mintlify)
- Docs are hosted on Mintlify (docs.clawd.bot). - Docs are hosted on Mintlify (docs.clawd.bot).
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`). - 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)`). - 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).
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub. - README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- Runtime baseline: Node **22+** (keep Node + Bun paths working). - Runtime baseline: Node **22+** (keep Node + Bun paths working).
@@ -25,12 +19,12 @@
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`. - Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
- Node remains supported for running built output (`dist/*`) and production installs. - Node remains supported for running built output (`dist/*`) and production installs.
- Type-check/build: `pnpm build` (tsc) - Type-check/build: `pnpm build` (tsc)
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt) - Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage` - Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
## Coding Style & Naming Conventions ## Coding Style & Naming Conventions
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`. - Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
- Formatting/linting via Oxlint and Oxfmt; run `pnpm lint` before commits. - Formatting/linting via Biome; run `pnpm lint` before commits.
- Add brief code comments for tricky or non-obvious logic. - Add brief code comments for tricky or non-obvious logic.
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`. - Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability. - Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
@@ -40,8 +34,6 @@
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements). - Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`. - 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. - 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. - 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. - Mobile: before using a simulator, check for connected real devices (iOS + Android) and prefer them when available.
@@ -49,7 +41,6 @@
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped. - 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`). - Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- Group related changes; avoid bundling unrelated refactors. - Group related changes; avoid bundling unrelated refactors.
- 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. - 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 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`. - 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`.
@@ -66,7 +57,6 @@
## Security & Configuration Tips ## Security & Configuration Tips
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out. - Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable. - Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
- Environment variables: see `~/.profile`.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples. - Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
## Troubleshooting ## Troubleshooting
@@ -76,9 +66,6 @@
- Vocabulary: "makeup" = "mac app". - Vocabulary: "makeup" = "mac app".
- When answering questions, respond with high-confidence answers only: verify in code; do not guess. - When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency. - Never update the Carbon dependency.
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
- 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.** - 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`. - 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. - If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
@@ -101,7 +88,6 @@
- **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. - **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. - 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). - Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
- 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/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.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item. - Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac. - Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
+180 -355
View File
@@ -1,360 +1,185 @@
# Changelog # Changelog
## 2026.1.15 ## Unreleased
### Changes - Models/Auth: add OpenCode Zen (multi-model proxy) onboarding. (#623) — thanks @magimetal
- Security: add `clawdbot security audit` (`--deep`) and surface it in `status --all` and `doctor`. - WhatsApp: refactor vCard parsing helper and improve empty contact card summaries. (#624) — thanks @steipete
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`. - WhatsApp: include phone numbers when multiple contacts are shared. (#625) — thanks @mahmoudashraf93
- Docs: expand gateway security hardening guidance and incident response checklist. - Agents: warn on small context windows (<32k) and block unusable ones (<16k). — thanks @steipete
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf. - Pairing: cap pending DM pairing requests at 3 per provider and avoid pairing replies for outbound DMs. — thanks @steipete
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia. - macOS: replace relay smoke test with version check in packaging script. (#615) — thanks @YuriNachos
- macOS: avoid clearing Launch at Login during app initialization. (#607) — thanks @wes-davis
### Fixes - Onboarding: skip systemd checks/daemon installs when systemd user services are unavailable; add onboarding flags to skip flow steps and stabilize Docker E2E. (#573) — thanks @steipete
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06. - Onboarding: QuickStart provider picker uses single-select to avoid accidental Telegram token prompts when choosing WhatsApp. (#485) — thanks @frankstallone
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06. - macOS: add node bridge heartbeat pings to detect half-open sockets and reconnect cleanly. (#572) — thanks @ngutman
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight. - Node bridge: harden keepalive + heartbeat handling (TCP keepalive, better disconnects, and keepalive config tests). (#577) — thanks @steipete
- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman. - Control UI: improve mobile responsiveness. (#558) — thanks @carlulsoe
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06. - Control UI: persist per-session verbose off and hide tool cards unless verbose is on. (#262) — thanks @steipete
- Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer. - Gateway: centralize verbose overrides and gate tool stream events at the server. (#262) — thanks @steipete
- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer. - CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr. - Sandbox: allow `session_status` tool in sandboxed sessions by default. — thanks @steipete
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”). - CLI: add `clawdbot config --section <name>` to jump straight into a wizard section (repeatable).
- Docs: add Hetzner Docker VPS guide. (#556) — thanks @Iamadig
## 2026.1.14 - Docs: link Hetzner guide from install + platforms docs. (#592) — thanks @steipete
- Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc
### Changes - Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro
- Usage: add MiniMax coding plan usage tracking. - Slack: configurable reply threading (`slack.replyToMode`) + proper mrkdwn formatting for outbound messages. (#464) — thanks @austinm911
- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR. - Discord: avoid category parent overrides for channel allowlists and refactor thread context helpers. (#588) — thanks @steipete
- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915) - Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow
- Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko. - Discord: log gateway disconnect/reconnect events at info and add verbose gateway metrics. (#595) — thanks @steipete
- Config: add `channels.<provider>.configWrites` gating for channel-initiated config writes; migrate Slack channel IDs. - Commands: accept /models as an alias for /model.
- Commands: add `/usage` as an alias for `/status`. (#492) — thanks @lc0rp
### Fixes - Models/Auth: add MiniMax Anthropic-compatible API onboarding (minimax-api). (#590) — thanks @mneves75
- Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor. - Models: centralize model override validation + hooks Gmail warnings in doctor. (#602) — thanks @steipete
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor. - Agents: avoid base-to-string error stringification in model fallback. (#604) — thanks @steipete
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank. - Agents: `sessions_spawn` inherits the requester's provider for child runs (avoid WhatsApp fallback). (#528) — thanks @rlmestre
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states. - Agents: sub-agent context now injects only AGENTS.md + TOOLS.md (omits identity/user/soul/heartbeat/bootstrap). — thanks @steipete
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`). - Gateway/CLI: harden agent provider routing + validation (Slack/MS Teams + aliases). (follow-up #528) — thanks @steipete
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis. - Agents: treat billing/insufficient-credits errors as failover-worthy so model fallbacks kick in. (#486) — thanks @steipete
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver. - Auth: default billing disable backoff to 5h (doubling, 24h cap) and surface disabled/cooldown profiles in `models list` + doctor. (#486) — thanks @steipete
- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman. - Commands: harden slash command registry and list text-only commands in `/commands`.
- Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko. - Models/Auth: show per-agent auth candidates in `/model status`, and add `clawdbot models auth order {get,set,clear}` (per-agent auth rotation overrides). — thanks @steipete
- Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose. - Telegram: keep streamMode draft-only; avoid forcing block streaming. (#619) — thanks @rubyrunsstuff
- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4. - Debugging: add raw model stream logging flags and document gateway watch mode.
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman. - Gateway: decode dns-sd escaped UTF-8 in discovery output and show scan progress immediately. — thanks @steipete
- WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester. - Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled).
- WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee. - 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.
## 2026.1.13 - Hooks: default hook agent delivery to true. (#533) — thanks @mcinteerj
- Hooks: normalize hook agent providers (aliases + msteams support).
### Fixes - WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj
- Postinstall: treat already-applied pnpm patches as no-ops to avoid npm/bun install failures. - WhatsApp: improve "no active web listener" errors (include account + relink hint). (#612) — thanks @YuriNachos
- Packaging: pin `@mariozechner/pi-ai` to 0.45.7 and refresh patched dependency to match npm resolution. - WhatsApp: add broadcast groups for multi-agent replies. (#547) — thanks @pasogott
- WhatsApp: resolve @lid inbound senders via auth-dir mapping fallback + shared resolver. (#365)
## 2026.1.12-2 - WhatsApp: treat shared contact cards as inbound messages (prefer vCard FN). (#622) — thanks @mahmoudashraf93
- iMessage: isolate group-ish threads by chat_id. (#535) — thanks @mdahmann
### Fixes - Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`). - Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj
- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4. - Agent: skip empty error assistant messages when rebuilding session context to avoid tool-chain corruption. (#561) — thanks @mukhtharcm
- Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles.
## 2026.1.12-1 - Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence.
- Config: write `clawdbot.json` atomically (temp file + replace) and keep a best-effort `.bak` backup.
### Fixes - Agent: enable adaptive context pruning by default for tool-result trimming.
- Packaging: include `dist/channels/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/channels/registry.js`). - Agent: drop empty error assistant messages when sanitizing session history. (#591) — thanks @steipete
- Agent: inject eligible skills list into the system prompt so bundled skills load from their actual locations. (#551) — thanks @gabriel-trigo
## 2026.1.12 - 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
### Highlights - Doctor: repair gateway service entrypoint when switching between npm and git installs; add Docker e2e coverage. — thanks @steipete
- **BREAKING:** rename chat “providers” (Slack/Telegram/WhatsApp/…) to **channels** across CLI/RPC/config; legacy config keys auto-migrate on load (and are written back as `channels.*`). - Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c
- Memory: add vector search for agent memories (Markdown-only) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback. - Daemon: add KillMode=process to systemd units to avoid podman restart hangs. (#541) — thanks @ogulcancelik
- Plugins: restore full voice-call plugin parity (Telnyx/Twilio, streaming, inbound policies, tools/CLI). - WhatsApp: make inbound media size cap configurable (default 50 MB). (#505) — thanks @koala73
- Models: add Synthetic provider plus Moonshot Kimi K2 0905 + turbo/thinking variants (with docs). (#811) — thanks @siraht; (#818) — thanks @mickahouan. - Doctor: run legacy state migrations in non-interactive mode without prompts.
- Cron: one-shot schedules accept ISO timestamps (UTC) with optional delete-after-run; cron jobs can target a specific agent (CLI + macOS/Control UI). - Cron: parse Telegram topic targets for isolated delivery. (#478) — thanks @nachoiacovino
- Agents: add compaction mode config with optional safeguard summarization and per-agent model fallbacks. (#700) — thanks @thewilloftheshadow; (#583) — thanks @mitschabaude-bot. - Cron: enqueue main-session system events under the resolved main session key. (#510)
- Mobile: centralize main session key normalization for iOS/Android runtime helpers. — thanks @steipete
### New & Improved - Chat UI: stop pinning hardcoded `main` session in the recent list; prefer active session if missing. — thanks @steipete
- Memory: add custom OpenAI-compatible embedding endpoints; support OpenAI/local `node-llama-cpp` embeddings with per-agent overrides and provider metadata in tools/CLI. (#819) — thanks @mukhtharcm. - Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos
- Memory: new `clawdbot memory` CLI plus `memory_search`/`memory_get` tools with snippets + line ranges; index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default. - Cron: allow Telegram delivery targets with topic/thread IDs (e.g. `-100…:topic:123`). (#474) — thanks @mitschabaude-bot
- Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config. - Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos
- Tools/Sandbox: add tool profiles + group shorthands; support tool-policy groups in `tools.sandbox.tools`; drop legacy `memory` shorthand; allow Docker bind mounts via `docker.binds`. (#790) — thanks @akonyer. - Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123
- Tools: add browser `scrollintoview` action; allow Claude/Gemini tool param aliases; allow thinking `xhigh` for GPT-5.2/Codex with safe downgrades. (#793) — thanks @hsrvc; (#444) — thanks @grp06. - Agents: strip empty assistant text blocks from session history to avoid Claude API 400s. (#210)
- Gateway/CLI: add Tailscale binary discovery, custom bind mode, and probe auth retry; add `clawdbot dashboard` auto-open flow; default native slash commands to `"auto"` with per-provider overrides. (#740) — thanks @jeffersonwarrior. - Agents: scrub unsupported JSON Schema keywords from tool schemas for Cloud Code Assist API compatibility. (#567) — thanks @erikpr1994
- Auth/Onboarding: add Chutes OAuth (PKCE + refresh + onboarding choice); normalize API key inputs; default TUI onboarding to `deliver: false`. (#726) — thanks @FrieSei; (#791) — thanks @roshanasingh4. - Agents: sanitize Cloud Code Assist tool call IDs and detect format/quota errors for failover. (#544) — thanks @jeffersonwarrior
- Providers: add `discord.allowBots`; trim legacy MiniMax M2 from default catalogs; route MiniMax vision to the Coding Plan VLM endpoint (also accepts `@/path/to/file.png` inputs). (#802) — thanks @zknicker. - Agents: simplify session tool schemas for Gemini compatibility. (#599) — thanks @mcinteerj
- Gateway: allow Tailscale Serve identity headers to satisfy token auth; rebuild Control UI assets when protocol schema is newer. (#823) — thanks @roshanasingh4; (#786) — thanks @meaningfool. - Agents: require `raw` for gateway `config.apply` tool calls while keeping schema 2020-12 compatible. (#566) — thanks @sircrumpet
- Heartbeat: default `ackMaxChars` to 300 so short `HEARTBEAT_OK` replies stay internal. - Agents: add `session_status` agent tool for `/status`-equivalent status (incl. usage/cost) + per-session model overrides. — thanks @steipete
- Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123
### Installer - Auto-reply: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1
- Install: run `clawdbot doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected. - Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess
- Auto-reply: preserve spacing when stripping inline directives. (#539) — thanks @joshp123
### Fixes - Auto-reply: relax reply tag parsing to allow whitespace. (#560) — thanks @mcinteerj
- Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds. - Auto-reply: add per-provider block streaming toggles and coalesce streamed blocks to reduce line spam. (#536) — thanks @mcinteerj
- Models/Providers: treat credential validation failures as auth errors to trigger fallback; normalize `${ENV_VAR}` apiKey values and auto-fill missing provider keys; preserve explicit GitHub Copilot provider config + agent-dir auth profiles. (#822) — thanks @sebslight; (#705) — thanks @TAGOOZ. - Auto-reply: suppress `<think>` leakage in block streaming and emit `/reasoning` as a separate `Reasoning:` message. (#614) — thanks @zknicker
- Auth: drop invalid auth profiles from ordering so environment keys can still be used for providers like MiniMax. - Auto-reply: default block streaming off for non-Telegram providers unless explicitly enabled, and avoid splitting on forced flushes below max.
- Gemini: normalize Gemini 3 ids to preview variants; strip Gemini CLI tool call/response ids; downgrade missing `thought_signature`; strip Claude `msg_*` thought_signature fields to avoid base64 decode errors. (#795) — thanks @thewilloftheshadow; (#783) — thanks @ananth-vardhan-cn; (#793) — thanks @hsrvc; (#805) — thanks @marcmarg. - Auto-reply: raise default coalesce minChars for Signal/Slack/Discord and clarify streaming vs draft streaming in docs.
- Agents: auto-recover from compaction context overflow by resetting the session and retrying; propagate overflow details from embedded runs so callers can recover. - Auto-reply: default block streaming coalesce idle to 1s to reduce tiny chunks. — thanks @steipete
- MiniMax: strip malformed tool invocation XML; include `MiniMax-VL-01` in implicit provider for image pairing. (#809) — thanks @latitudeki5223. - Auto-reply: fix /status usage summary filtering for the active provider.
- Onboarding/Auth: honor `CLAWDBOT_AGENT_DIR` / `PI_CODING_AGENT_DIR` when writing auth profiles (MiniMax). (#829) — thanks @roshanasingh4. - Auto-reply: deduplicate followup queue entries using message id/routing to avoid duplicate replies. (#600) — thanks @samratjha96
- Anthropic: handle `overloaded_error` with a friendly message and failover classification. (#832) — thanks @danielz1z. - Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj
- Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid incorrect role errors. (#804) — thanks @ThomsenDrake. - Status: compact /status with session token usage + estimated cost, add `/cost` per-response usage lines (tokens-only for OAuth).
- Messaging: enforce context isolation for message tool sends; keep typing indicators alive during tool execution. (#793) — thanks @hsrvc; (#450, #447) — thanks @thewilloftheshadow. - Status: show active auth profile and key snippet in /status.
- Auto-reply: `/status` allowlist behavior, reasoning-tag enforcement on fallback, and system-event enqueueing for elevated/reasoning toggles. (#810) — thanks @mcinteerj. - Status: show provider usage windows when auth uses token-based OAuth (e.g. Claude setup-token).
- System events: include local timestamps when events are injected into prompts. (#245) — thanks @thewilloftheshadow. - Agent: promote `<think>`/`<thinking>` tag reasoning into structured thinking blocks so `/reasoning` works consistently for OpenAI-compat providers.
- Auto-reply: resolve ambiguous `/model` matches; fix streaming block reply media handling; keep >300 char heartbeat replies instead of dropping. - macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy
- Discord/Slack: centralize reply-thread planning; fix autoThread routing + add per-channel autoThread; avoid duplicate listeners; keep reasoning italics intact; allow clearing channel parents via message tool. (#800, #807) — thanks @davidguttman; (#744) — thanks @thewilloftheshadow. - WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj
- Telegram: preserve forum topic thread ids, persist polling offsets, respect account bindings in webhook mode, and show typing indicator in General topics. (#727, #739) — thanks @thewilloftheshadow; (#821) — thanks @gumadeiras; (#779) — thanks @azade-c. - Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini).
- Slack: accept slash commands with or without leading `/` for custom command configs. (#798) — thanks @thewilloftheshadow. - Control UI: logs tab opens at the newest entries (bottom).
- Cron: persist disabled jobs correctly; accept `jobId` aliases for update/run/remove params. (#205, #252) — thanks @thewilloftheshadow. - Control UI: default to relative paths for control UI assets. (#569) — thanks @bjesuiter
- Gateway/CLI: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides; `agents.list` respects explicit config; reduce noisy loopback WS logs during tests; run `clawdbot doctor --non-interactive` during updates. (#781) — thanks @ronyrus. - Control UI: add Docs link, remove chat composer divider, and add New session button.
- Onboarding/Control UI: refuse invalid configs (run doctor first); quote Windows browser URLs for OAuth; keep chat scroll position unless the user is near the bottom. (#764) — thanks @mukhtharcm; (#794) — thanks @roshanasingh4; (#217) — thanks @thewilloftheshadow. - Control UI: link sessions list to chat view. (#471) — thanks @HazAT
- Tools/UI: harden tool input schemas for strict providers; drop null-only union variants for Gemini schema cleanup; treat `maxChars: 0` as unlimited; keep TUI last streamed response instead of "(no output)". (#782) — thanks @AbhisekBasu1; (#796) — thanks @gabriel-trigo; (#747) — thanks @thewilloftheshadow. - Sessions: support session `label` in store/list/UI and allow `sessions_send` lookup by label. (#570) — thanks @azade-c
- Connections UI: polish multi-account account cards. (#816) — thanks @steipete. - Sessions: clarify `sessions_send` delivery semantics, log announce failures, and enforce Discord request timeouts. (#507) — thanks @steipete
- Control UI: show/patch per-session reasoning level and render extracted reasoning in chat.
### Maintenance - Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos
- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai. - Control UI: refactor chat layout with tool sidebar, grouped messages, and nav improvements. (#475) — thanks @rahthakor
- Testing: update Vitest + browser-playwright to 4.0.17. - Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow).
- Docs: add Amazon Bedrock provider notes and link from models/FAQ. - Telegram: retry long-polling conflicts with backoff to avoid fatal exits.
- Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos
## 2026.1.11 - Telegram: add inline keyboard buttons (capability-gated) and route callback query payloads as messages. (#491) — thanks @azade-c
- WhatsApp: resolve @lid JIDs via Baileys mapping to unblock inbound messages. (#415)
### Highlights - Pairing: replies now include sender ids for Discord/Slack/Signal/iMessage/WhatsApp; pairing list labels them explicitly.
- Plugins are now first-class: loader + CLI management, plus the new Voice Call plugin. - Messages: default inbound/outbound prefixes from the routed agent’s `identity.name` when set. (#578) — thanks @p6l-richard
- Config: modular `$include` support for split config files. (#731) — thanks @pasogott. - Signal: accept UUID-only senders for pairing/allowlists/routing when sourceNumber is missing. (#523) — thanks @neist
- Agents/Pi: reserve compaction headroom so pre-compaction memory writes can run before auto-compaction. - Signal: ignore reaction-only messages so they don't surface as unknown media. (#616) — thanks @neist
- Agents: automatic pre-compaction memory flush turn to store durable memories before compaction. - Signal: add reaction notifications with allowlist support. — thanks @steipete
- Agent system prompt: avoid automatic self-updates unless explicitly requested.
### Changes - Onboarding: tighten QuickStart hint copy for configuring later.
- CLI/Onboarding: simplify MiniMax auth choice to a single M2.1 option. - Onboarding: set Gemini 3 Pro as the default model for Gemini API key auth. (#489) — thanks @jonasjancarik
- CLI: configure section selection now loops until Continue. - Onboarding: avoid “token expired” for Codex CLI when expiry is heuristic.
- Docs: explain MiniMax vs MiniMax Lightning (speed vs cost) and restore LM Studio example. - Onboarding: QuickStart jumps straight into provider selection with Telegram preselected when unset.
- Docs: add Cerebras GLM 4.6/4.7 config example (OpenAI-compatible endpoint). - Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker).
- Onboarding/CLI: group model/auth choice by provider and label Z.AI as GLM 4.7. - Onboarding: clarify WhatsApp owner number prompt and label pairing phone number.
- Onboarding/Docs: add Moonshot AI (Kimi K2) auth choice + config example. - Auto-reply: normalize routed replies to drop NO_REPLY and apply response prefixes.
- CLI/Onboarding: prompt to reuse detected API keys for Moonshot/MiniMax/Z.AI/Gemini/Anthropic/OpenCode. - Commands: add /debug for runtime config overrides (memory-only).
- Auto-reply: add compact `/model` picker (models + available providers) and show provider endpoints in `/model status`. - Daemon runtime: remove Bun from selection options.
- Control UI: add Config tab model presets (MiniMax M2.1, GLM 4.7, Kimi) for one-click setup. - CLI: restore hidden `gateway-daemon` alias for legacy launchd configs.
- Plugins: add extension loader (tools/RPC/CLI/services), discovery paths, and config schema + Control UI labels (uiHints). - Onboarding/Configure: add OpenAI API key flow that stores in shared `~/.clawdbot/.env` for launchd; simplify Anthropic token prompt order.
- Plugins: add `clawdbot plugins install` (path/tgz/npm), plus `list|info|enable|disable|doctor` UX. - 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.
- Plugins: voice-call plugin now real (Twilio/log), adds start/status RPC/CLI/tool + tests. - Control UI: show skill install progress + per-skill results, hide install once binaries present. (#445) — thanks @pkrmf
- Docs: add plugins doc + cross-links from tools/skills/gateway config. - Providers/Doctor: surface Discord privileged intent (Message Content) misconfiguration with actionable warnings.
- Docs: add beginner-friendly plugin quick start + expand Voice Call plugin docs. - Providers/Doctor: warn when Telegram config expects unmentioned group messages but Bot API privacy mode is likely enabled; surface WhatsApp login/disconnect hints.
- Tests: add Docker plugin loader + tgz-install smoke test. - Providers/Doctor: add last inbound/outbound activity timestamps in `providers status` and extend `--probe` with Discord channel permission + Telegram group membership audits.
- Tests: extend Docker plugin E2E to cover installing from local folders (`plugins.load.paths`) and `file:` npm specs. - Docs: add provider troubleshooting index (`/providers/troubleshooting`) and link it from the main troubleshooting guide.
- Tests: add coverage for pre-compaction memory flush settings. - Docs: clarify model allowlist errors and add safety notes for verbose/reasoning in groups.
- Tests: modernize live model smoke selection for current releases and enforce tools/images/thinking-high coverage. (#769) — thanks @steipete. - Docs: add Ansible installation guide. (#545) — thanks @pasogott
- Agents/Tools: add `apply_patch` tool for multi-file edits (experimental; gated by tools.exec.applyPatch; OpenAI-only). - Telegram: include the user id in DM pairing messages and label it clearly in `clawdbot pairing list --provider telegram`.
- Agents/Tools: rename the bash tool to exec (config alias maintained). (#748) — thanks @myfunc. - Apps: refresh iOS/Android/macOS app icons for Clawdbot branding. (#521) — thanks @fishfisher
- Agents: add pre-compaction memory flush config (`agents.defaults.compaction.*`) with a soft threshold + system prompt. - Docs: expand parameter descriptions for agent/wake hooks. (#532) — thanks @mcinteerj
- Config: add `$include` directive for modular config files. (#731) — thanks @pasogott. - Docs: add community showcase entries from Discord. (#476) — thanks @gupsammy
- Build: set pnpm minimum release age to 2880 minutes (2 days). (#718) — thanks @dan-dr. - TUI: refresh status bar after think/verbose/reasoning changes. (#519) — thanks @jdrhyne
- macOS: prompt to install the global `clawdbot` CLI when missing in local mode; install via `clawd.bot/install-cli.sh` (no onboarding) and use external launchd/CLI instead of the embedded gateway runtime. - TUI: stop overriding agent timeout so config defaults apply; warn on invalid `--timeout-ms`. (#549)
- Docs: add gog calendar event color IDs from `gog calendar colors`. (#715) — thanks @mjrussell. - Status: show Verbose/Elevated only when enabled.
- Cron/CLI: add `--model` flag to cron add/edit commands. (#711) — thanks @mjrussell. - Status: filter usage summary to the active model provider.
- Cron/CLI: trim model overrides on cron edits and document main-session guidance. (#711) — thanks @mjrussell. - Status: map model providers to usage sources so unrelated usage doesn’t appear.
- Skills: bundle `skill-creator` to guide creating and packaging skills. - Status: fix Claude usage snapshots when `anthropic:default` is a setup-token lacking `user:profile` by preferring `anthropic:claude-cli`; optional claude.ai fallback via `CLAUDE_AI_SESSION_KEY` / `CLAUDE_WEB_COOKIE`.
- Providers: add per-DM history limit overrides (`dmHistoryLimit`) with provider-level config. (#728) — thanks @pkrmf. - Commands: allow /elevated off in groups without a mention; keep /elevated on mention-gated.
- Discord: expose channel/category management actions in the message tool. (#730) — thanks @NicholasSpisak. - Commands: keep multi-directive messages from clearing directive handling.
- Docs: rename README “macOS app” section to “Apps”. (#733) — thanks @AbhisekBasu1. - Commands: warn when /elevated runs in direct (unsandboxed) runtime.
- Gateway: require `client.id` in WebSocket connect params; use `client.instanceId` for presence de-dupe; update docs/tests. - Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond.
- macOS: remove the attach-only gateway setting; local mode now always manages launchd while still attaching to an existing gateway if present. - Commands: return /status in directive-only multi-line messages.
- Models: fall back to configured models when the provider catalog is unavailable.
### Installer - Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) — thanks @neist
- Postinstall: replace `git apply` with builtin JS patcher (works npm/pnpm/bun; no git dependency) plus regression tests. - Agent: bypass Anthropic OAuth tool-name blocks by capitalizing built-ins and keeping pruning tool matching case-insensitive. (#553) — thanks @andrewting19
- Postinstall: skip pnpm patch fallback when the new patcher is active. - Commands/Tools: disable /restart and gateway restart tool by default (enable with commands.restart=true).
- Installer tests: add root+non-root docker smokes, CI workflow to fetch clawd.bot scripts and run install sh/cli with onboarding skipped. - Gateway/CLI: add `clawdbot gateway discover` (Bonjour scan on `local.` + `clawdbot.internal.`) with `--timeout` and `--json`. — thanks @steipete
- Installer UX: support `CLAWDBOT_NO_ONBOARD=1` for non-interactive installs; fix npm prefix on Linux and auto-install git. - Gateway/CLI: make `clawdbot gateway status` human-readable by default, add `--json`, and probe localhost + configured remote (warn on multiple gateways). — thanks @steipete
- Installer UX: add `install.sh --help` with flags/env and git install hint. - Gateway/CLI: support remote loopback gateways via SSH tunnel in `clawdbot gateway status` (`--ssh` / `--ssh-auto`). — thanks @steipete
- Installer UX: add `--install-method git|npm` and auto-detect source checkouts (prompt to update git checkout vs migrate to npm). - Gateway/Discovery: include `gatewayPort`/`sshPort`/`cliPath` in wide-area Bonjour records, and add a tailnet DNS fallback for `gateway discover` when split DNS isn’t configured. — thanks @steipete
- CLI: add global `--no-color` (and respect `NO_COLOR=1`) to disable ANSI output. — thanks @steipete
### Fixes - CLI: centralize lobster palette + apply it to onboarding/config prompts. — thanks @steipete
- Models/Onboarding: configure MiniMax (minimax.io) via Anthropic-compatible `/anthropic` endpoint by default (keep `minimax-api` as a legacy alias). - Gateway/CLI: add `clawdbot gateway --dev/--reset` to auto-create a dev config/workspace with a robot identity (no BOOTSTRAP.md), and reset wipes config/creds/sessions/workspace (subcommand --dev no longer collides with global --dev profile). — thanks @steipete
- Models: normalize Gemini 3 Pro/Flash IDs to preview names for live model lookups. (#769) — thanks @steipete. - Configure: stop prompting to open the Control UI (still shown in onboarding). — thanks @steipete
- CLI: fix guardCancel typing for configure prompts. (#769) — thanks @steipete. - Configure: add wizard mode to remove a provider config block. — thanks @steipete
- Gateway/WebChat: include handshake validation details in the WebSocket close reason for easier debugging; preserve close codes. - Onboarding/TUI: prompt to start TUI (best option) when BOOTSTRAP.md exists and add `tui --message` to auto-send the first prompt. — thanks @steipete
- Gateway/Auth: send invalid connect responses before closing the handshake; stabilize invalid-connect auth test. - Telegram: suppress grammY getUpdates stack traces; log concise retry message instead. — thanks @steipete
- Gateway: tighten gateway listener detection. - Gateway/CLI: allow dev profile (`clawdbot --dev`) to auto-create the dev config + workspace. — thanks @steipete
- Control UI: hide onboarding chat when configured and guard the mobile chat sidebar overlay. - Dev templates: ship C-3PO dev workspace defaults as docs templates and use them for dev bootstrap. — thanks @steipete
- Auth: read Codex keychain credentials and make the lookup platform-aware. - Config: fix Minimax hosted onboarding to write `agents.defaults` and allow `msteams` as a heartbeat target. — thanks @steipete
- macOS/Release: avoid bundling dist artifacts in relay builds and generate appcasts from zip-only sources. - Discord: add channel/category management actions (create/edit/move/delete + category removal). (#487) - thanks @NicholasSpisak
- Doctor: surface plugin diagnostics in the report. - Docs: split CLI install commands into separate code blocks. (#601) — thanks @martinpucik
- Plugins: treat `plugins.load.paths` directory entries as package roots when they contain `package.json` + `clawdbot.extensions`; load plugin packages from config dirs; extract archives without system tar. - WhatsApp: record outbound provider activity using the active account id. (#537) — thanks @Nachx639
- Config: expand `~` in `CLAWDBOT_CONFIG_PATH` and common path-like config fields (including `plugins.load.paths`); guard invalid `$include` paths. (#731) — thanks @pasogott. - Discord: add gateway HELLO timeout to detect zombie connections. (#608) — thanks @NicholasSpisak
- Agents: stop pre-creating session transcripts so first user messages persist in JSONL history. - Docker: cache dependency layer for faster rebuilds. (#605) — thanks @zknicker
- Agents: skip pre-compaction memory flush when the session workspace is read-only.
- Auto-reply: ignore inline `/status` directives unless the message is directive-only.
- Auto-reply: align `/think` default display with model reasoning defaults. (#751) — thanks @gabriel-trigo.
- Auto-reply: flush block reply buffers on tool boundaries. (#750) — thanks @sebslight.
- Auto-reply: allow sender fallback for command authorization when `SenderId` is empty (WhatsApp self-chat). (#755) — thanks @juanpablodlc.
- Auto-reply: treat whitespace-only sender ids as missing for command authorization (WhatsApp self-chat). (#766) — thanks @steipete.
- Heartbeat: refresh prompt text for updated defaults.
- Agents/Tools: use PowerShell on Windows to capture system utility output. (#748) — thanks @myfunc.
- Docker: tolerate unset optional env vars in docker-setup.sh under strict mode. (#725) — thanks @petradonka.
- CLI/Update: preserve base environment when passing overrides to update subprocesses. (#713) — thanks @danielz1z.
- Agents: treat message tool errors as failures so fallback replies still send; require `to` + `message` for `action=send`. (#717) — thanks @theglove44.
- Agents: preserve reasoning items on tool-only turns.
- Agents/Subagents: wait for completion before announcing, align wait timeout with run timeout, and make announce prompts more emphatic.
- Agents: route subagent transcripts to the target agent sessions directory and add regression coverage. (#708) — thanks @xMikeMickelson.
- Agents/Tools: preserve action enums when flattening tool schemas. (#708) — thanks @xMikeMickelson.
- Gateway/Agents: canonicalize main session aliases for store writes and add regression coverage. (#709) — thanks @xMikeMickelson.
- Agents: reset sessions and retry when auto-compaction overflows instead of crashing the gateway.
- Providers/Telegram: normalize command mentions for consistent parsing. (#729) — thanks @obviyus.
- Providers: skip DM history limit handling for non-DM sessions. (#728) — thanks @pkrmf.
- Sandbox: fix non-main mode incorrectly sandboxing the main DM session and align `/status` runtime reporting with effective sandbox state.
- Sandbox/Gateway: treat `agent:<id>:main` as a main-session alias when `session.mainKey` is customized (backwards compatible).
- Auto-reply: fast-path allowlisted slash commands (inline `/help`/`/commands`/`/status`/`/whoami` stripped before model).
## 2026.1.10
### 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).
### 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.
- CLI: add `clawdbot reset` and `clawdbot uninstall` flows (interactive + non-interactive) plus docker cleanup smoke test.
- Providers: move provider wiring to a plugin architecture. (#661).
- 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).
- 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
- Auto-reply: suppress draft/typing streaming for `NO_REPLY` (silent system ops) so it doesn’t leak partial output.
- 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).
- Heartbeat: strip markup-wrapped `HEARTBEAT_OK` so acks don’t leak to external providers (e.g., Telegram).
- 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.
- Agents/Pi: inject config `temperature`/`maxTokens` into streaming without replacing the session streamFn; cover with live maxTokens probe. (#732) — thanks @peschee.
- 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.
- Auth: read Codex CLI keychain tokens on macOS before falling back to `~/.codex/auth.json`, preventing stale refresh tokens from breaking gateway live tests.
- 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 Code 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).
## 2026.1.8 ## 2026.1.8
@@ -371,7 +196,7 @@
- Previously, if you didn’t configure an allowlist, your bot could be **open to anyone** (especially discoverable Telegram bots). - 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"`). - 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`). - 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>`. - Approve requests via `clawdbot pairing list --provider <provider>` + `clawdbot pairing approve --provider <provider> <code>`.
- Sandbox: default `agent.sandbox.scope` to `"agent"` (one container/workspace per agent). Use `"session"` for per-session isolation; `"shared"` disables cross-session isolation. - Sandbox: default `agent.sandbox.scope` to `"agent"` (one container/workspace per agent). Use `"session"` for per-session isolation; `"shared"` disables cross-session isolation.
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only). - Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only).
- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup. - Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup.
+1 -1
View File
@@ -13,7 +13,7 @@ Welcome to the lobster tank! 🦞
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete) - GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
- **Shadow** - Discord + Slack subsystem - **Shadow** - Discord + Slack subsystem
- GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed) - GitHub: [@4shadowed](https://github.com/4shadowed) · X: [@4shad0wed](https://x.com/4shad0wed)
- **Jos** - Telegram, API, Nix mode - **Jos** - Telegram, API, Nix mode
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes) - GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
-8
View File
@@ -8,14 +8,6 @@ RUN corepack enable
WORKDIR /app 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 package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY ui/package.json ./ui/package.json COPY ui/package.json ./ui/package.json
COPY patches ./patches COPY patches ./patches
-1
View File
@@ -14,7 +14,6 @@ RUN apt-get update \
jq \ jq \
novnc \ novnc \
python3 \ python3 \
socat \
websockify \ websockify \
x11vnc \ x11vnc \
xvfb \ xvfb \
+143 -162
View File
@@ -1,7 +1,7 @@
# 🦞 Clawdbot — Personal AI Assistant # 🦞 CLAWDBOT — Personal AI Assistant
<p align="center"> <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>
<p align="center"> <p align="center">
@@ -16,26 +16,26 @@
</p> </p>
**Clawdbot** is a *personal AI assistant* you run on your own devices. **Clawdbot** is a *personal AI assistant* you run on your own devices.
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant. It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it. If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
[Website](https://clawdbot.com) · [Docs](https://docs.clawd.bot) · [Getting Started](https://docs.clawd.bot/start/getting-started) · [Updating](https://docs.clawd.bot/install/updating) · [Showcase](https://docs.clawd.bot/start/showcase) · [FAQ](https://docs.clawd.bot/start/faq) · [Wizard](https://docs.clawd.bot/start/wizard) · [Nix](https://github.com/clawdbot/nix-clawdbot) · [Docker](https://docs.clawd.bot/install/docker) · [Discord](https://discord.gg/clawd) [Website](https://clawdbot.com) · [Docs](https://docs.clawd.bot) · [Getting Started](https://docs.clawd.bot/getting-started) · [Updating](https://docs.clawd.bot/updating) · [Showcase](https://docs.clawd.bot/showcase) · [FAQ](https://docs.clawd.bot/faq) · [Wizard](https://docs.clawd.bot/wizard) · [Nix](https://github.com/clawdbot/nix-clawdbot) · [Docker](https://docs.clawd.bot/docker) · [Discord](https://discord.gg/clawd)
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**. Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
Works with npm, pnpm, or bun. Works with npm, pnpm, or bun.
New install? Start here: [Getting started](https://docs.clawd.bot/start/getting-started) New install? Start here: [Getting started](https://docs.clawd.bot/getting-started)
**Subscriptions (OAuth):** **Subscriptions (OAuth):**
- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max) - **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max)
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex) - **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.clawd.bot/start/onboarding). Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.clawd.bot/onboarding).
## Models (selection + auth) ## Models (selection + auth)
- Models config + CLI: [Models](https://docs.clawd.bot/concepts/models) - Models config + CLI: [Models](https://docs.clawd.bot/models)
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.clawd.bot/concepts/model-failover) - Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.clawd.bot/model-failover)
## Install (recommended) ## Install (recommended)
@@ -54,7 +54,7 @@ The wizard installs the Gateway daemon (launchd/systemd user service) so it stay
Runtime: **Node ≥22**. Runtime: **Node ≥22**.
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.clawd.bot/start/getting-started) Full beginner guide (auth, pairing, providers): [Getting started](https://docs.clawd.bot/getting-started)
```bash ```bash
clawdbot onboard --install-daemon clawdbot onboard --install-daemon
@@ -64,11 +64,11 @@ clawdbot gateway --port 18789 --verbose
# Send a message # Send a message
clawdbot message send --to +1234567890 --message "Hello from Clawdbot" clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord/Microsoft Teams) # Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
clawdbot agent --message "Ship checklist" --thinking high clawdbot agent --message "Ship checklist" --thinking high
``` ```
Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`). Upgrading? [Updating guide](https://docs.clawd.bot/updating) (and run `clawdbot doctor`).
## From source (development) ## From source (development)
@@ -94,94 +94,89 @@ Note: `pnpm clawdbot ...` runs TypeScript directly (via `tsx`). `pnpm build` pro
Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted input**. Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted input**.
Full security guide: [Security](https://docs.clawd.bot/gateway/security) Full security guide: [Security](https://docs.clawd.bot/security)
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Slack: Default behavior on Telegram/WhatsApp/Signal/iMessage/Discord/Slack:
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message. - **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 <channel> <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 channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`). - 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. Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## Highlights ## Highlights
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events. - **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, providers, tools, and events.
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat, macOS, iOS/Android. - **[Multi-provider inbox](https://docs.clawd.bot/surface)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions). - **[Multi-agent routing](https://docs.clawd.bot/configuration)** — route inbound providers/accounts/peers to isolated agents (workspaces + per-agent sessions).
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs. - **[Voice Wake](https://docs.clawd.bot/voicewake) + [Talk Mode](https://docs.clawd.bot/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui). - **[Live Canvas](https://docs.clawd.bot/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui).
- **[First-class tools](https://docs.clawd.bot/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions. - **[First-class tools](https://docs.clawd.bot/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
- **[Companion apps](https://docs.clawd.bot/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes). - **[Companion apps](https://docs.clawd.bot/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes).
- **[Onboarding](https://docs.clawd.bot/start/wizard) + [skills](https://docs.clawd.bot/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills. - **[Onboarding](https://docs.clawd.bot/wizard) + [skills](https://docs.clawd.bot/skills)** — wizard-driven setup with bundled/managed/workspace skills.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=clawdbot/clawdbot&type=date&legend=top-left)](https://www.star-history.com/#clawdbot/clawdbot&type=date&legend=top-left)
## Everything we built so far ## Everything we built so far
### Core platform ### Core platform
- [Gateway WS control plane](https://docs.clawd.bot/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawd.bot/web), and [Canvas host](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui). - [Gateway WS control plane](https://docs.clawd.bot/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawd.bot/web), and [Canvas host](https://docs.clawd.bot/mac/canvas#canvas-a2ui).
- [CLI surface](https://docs.clawd.bot/tools/agent-send): gateway, agent, send, [wizard](https://docs.clawd.bot/start/wizard), and [doctor](https://docs.clawd.bot/gateway/doctor). - [CLI surface](https://docs.clawd.bot/agent-send): gateway, agent, send, [wizard](https://docs.clawd.bot/wizard), and [doctor](https://docs.clawd.bot/doctor).
- [Pi agent runtime](https://docs.clawd.bot/concepts/agent) in RPC mode with tool streaming and block streaming. - [Pi agent runtime](https://docs.clawd.bot/agent) in RPC mode with tool streaming and block streaming.
- [Session model](https://docs.clawd.bot/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawd.bot/concepts/groups). - [Session model](https://docs.clawd.bot/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawd.bot/groups).
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio). - [Media pipeline](https://docs.clawd.bot/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/audio).
### Channels ### Providers
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/webchat). - [Providers](https://docs.clawd.bot/surface): [WhatsApp](https://docs.clawd.bot/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/telegram) (grammY), [Slack](https://docs.clawd.bot/slack) (Bolt), [Discord](https://docs.clawd.bot/discord) (discord.js), [Signal](https://docs.clawd.bot/signal) (signal-cli), [iMessage](https://docs.clawd.bot/imessage) (imsg), [WebChat](https://docs.clawd.bot/webchat).
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels). - [Group routing](https://docs.clawd.bot/group-messages): mention gating, reply tags, per-provider chunking and routing. Provider rules: [Providers](https://docs.clawd.bot/surface).
### Apps + nodes ### Apps + nodes
- [macOS app](https://docs.clawd.bot/platforms/macos): menu bar control plane, [Voice Wake](https://docs.clawd.bot/nodes/voicewake)/PTT, [Talk Mode](https://docs.clawd.bot/nodes/talk) overlay, [WebChat](https://docs.clawd.bot/web/webchat), debug tools, [remote gateway](https://docs.clawd.bot/gateway/remote) control. - [macOS app](https://docs.clawd.bot/macos): menu bar control plane, [Voice Wake](https://docs.clawd.bot/voicewake)/PTT, [Talk Mode](https://docs.clawd.bot/talk) overlay, [WebChat](https://docs.clawd.bot/webchat), debug tools, [remote gateway](https://docs.clawd.bot/remote) control.
- [iOS node](https://docs.clawd.bot/platforms/ios): [Canvas](https://docs.clawd.bot/platforms/mac/canvas), [Voice Wake](https://docs.clawd.bot/nodes/voicewake), [Talk Mode](https://docs.clawd.bot/nodes/talk), camera, screen recording, Bonjour pairing. - [iOS node](https://docs.clawd.bot/ios): [Canvas](https://docs.clawd.bot/mac/canvas), [Voice Wake](https://docs.clawd.bot/voicewake), [Talk Mode](https://docs.clawd.bot/talk), camera, screen recording, Bonjour pairing.
- [Android node](https://docs.clawd.bot/platforms/android): [Canvas](https://docs.clawd.bot/platforms/mac/canvas), [Talk Mode](https://docs.clawd.bot/nodes/talk), camera, screen recording, optional SMS. - [Android node](https://docs.clawd.bot/android): [Canvas](https://docs.clawd.bot/mac/canvas), [Talk Mode](https://docs.clawd.bot/talk), camera, screen recording, optional SMS.
- [macOS node mode](https://docs.clawd.bot/nodes): system.run/notify + canvas/camera exposure. - [macOS node mode](https://docs.clawd.bot/nodes): system.run/notify + canvas/camera exposure.
### Tools + automation ### Tools + automation
- [Browser control](https://docs.clawd.bot/tools/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles. - [Browser control](https://docs.clawd.bot/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
- [Canvas](https://docs.clawd.bot/platforms/mac/canvas): [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui) push/reset, eval, snapshot. - [Canvas](https://docs.clawd.bot/mac/canvas): [A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
- [Nodes](https://docs.clawd.bot/nodes): camera snap/clip, screen record, [location.get](https://docs.clawd.bot/nodes/location-command), notifications. - [Nodes](https://docs.clawd.bot/nodes): camera snap/clip, screen record, [location.get](https://docs.clawd.bot/location-command), notifications.
- [Cron + wakeups](https://docs.clawd.bot/automation/cron-jobs); [webhooks](https://docs.clawd.bot/automation/webhook); [Gmail Pub/Sub](https://docs.clawd.bot/automation/gmail-pubsub). - [Cron + wakeups](https://docs.clawd.bot/cron); [webhooks](https://docs.clawd.bot/webhook); [Gmail Pub/Sub](https://docs.clawd.bot/gmail-pubsub).
- [Skills platform](https://docs.clawd.bot/tools/skills): bundled, managed, and workspace skills with install gating + UI. - [Skills platform](https://docs.clawd.bot/skills): bundled, managed, and workspace skills with install gating + UI.
### Runtime + safety ### Runtime + safety
- [Channel routing](https://docs.clawd.bot/concepts/channel-routing), [retry policy](https://docs.clawd.bot/concepts/retry), and [streaming/chunking](https://docs.clawd.bot/concepts/streaming). - [Provider routing](https://docs.clawd.bot/provider-routing), [retry policy](https://docs.clawd.bot/retry), and [streaming/chunking](https://docs.clawd.bot/streaming).
- [Presence](https://docs.clawd.bot/concepts/presence), [typing indicators](https://docs.clawd.bot/concepts/typing-indicators), and [usage tracking](https://docs.clawd.bot/concepts/usage-tracking). - [Presence](https://docs.clawd.bot/presence), [typing indicators](https://docs.clawd.bot/typing-indicators), and [usage tracking](https://docs.clawd.bot/usage-tracking).
- [Models](https://docs.clawd.bot/concepts/models), [model failover](https://docs.clawd.bot/concepts/model-failover), and [session pruning](https://docs.clawd.bot/concepts/session-pruning). - [Models](https://docs.clawd.bot/models), [model failover](https://docs.clawd.bot/model-failover), and [session pruning](https://docs.clawd.bot/session-pruning).
- [Security](https://docs.clawd.bot/gateway/security) and [troubleshooting](https://docs.clawd.bot/channels/troubleshooting). - [Security](https://docs.clawd.bot/security) and [troubleshooting](https://docs.clawd.bot/troubleshooting).
### Ops + packaging ### Ops + packaging
- [Control UI](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/web/webchat) served directly from the Gateway. - [Control UI](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/webchat) served directly from the Gateway.
- [Tailscale Serve/Funnel](https://docs.clawd.bot/gateway/tailscale) or [SSH tunnels](https://docs.clawd.bot/gateway/remote) with token/password auth. - [Tailscale Serve/Funnel](https://docs.clawd.bot/tailscale) or [SSH tunnels](https://docs.clawd.bot/remote) with token/password auth.
- [Nix mode](https://docs.clawd.bot/install/nix) for declarative config; [Docker](https://docs.clawd.bot/install/docker)-based installs. - [Nix mode](https://docs.clawd.bot/nix) for declarative config; [Docker](https://docs.clawd.bot/docker)-based installs.
- [Doctor](https://docs.clawd.bot/gateway/doctor) migrations, [logging](https://docs.clawd.bot/logging). - [Doctor](https://docs.clawd.bot/doctor) migrations, [logging](https://docs.clawd.bot/logging).
## How it works (short) ## How it works (short)
``` ```
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Microsoft Teams / WebChat WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
│ │
▼ ▼
┌───────────────────────────────┐ ┌───────────────────────────────┐
│ Gateway │ │ Gateway │ ws://127.0.0.1:18789
│ (control plane) │ │ (control plane) │ bridge: tcp://0.0.0.0:18790
│ ws://127.0.0.1:18789 │
└──────────────┬────────────────┘ └──────────────┬────────────────┘
│ │
├─ Pi agent (RPC) ├─ Pi agent (RPC)
├─ CLI (clawdbot …) ├─ CLI (clawdbot …)
├─ WebChat UI ├─ WebChat UI
├─ macOS app ├─ macOS app
└─ iOS / Android nodes └─ iOS/Android nodes
``` ```
## Key subsystems ## Key subsystems
- **[Gateway WebSocket network](https://docs.clawd.bot/concepts/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawd.bot/gateway)). - **[Gateway WebSocket network](https://docs.clawd.bot/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawd.bot/gateway)).
- **[Tailscale exposure](https://docs.clawd.bot/gateway/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawd.bot/gateway/remote)). - **[Tailscale exposure](https://docs.clawd.bot/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawd.bot/remote)).
- **[Browser control](https://docs.clawd.bot/tools/browser)** — clawd‑managed Chrome/Chromium with CDP control. - **[Browser control](https://docs.clawd.bot/browser)** — clawd‑managed Chrome/Chromium with CDP control.
- **[Canvas + A2UI](https://docs.clawd.bot/platforms/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui)). - **[Canvas + A2UI](https://docs.clawd.bot/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui)).
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always‑on speech and continuous conversation. - **[Voice Wake](https://docs.clawd.bot/voicewake) + [Talk Mode](https://docs.clawd.bot/talk)** — always‑on speech and continuous conversation.
- **[Nodes](https://docs.clawd.bot/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`. - **[Nodes](https://docs.clawd.bot/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
## Tailscale access (Gateway dashboard) ## Tailscale access (Gateway dashboard)
@@ -198,17 +193,17 @@ Notes:
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set. - Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown. - Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
Details: [Tailscale guide](https://docs.clawd.bot/gateway/tailscale) · [Web surfaces](https://docs.clawd.bot/web) Details: [Tailscale guide](https://docs.clawd.bot/tailscale) · [Web surfaces](https://docs.clawd.bot/web)
## Remote Gateway (Linux is great) ## Remote Gateway (Linux is great)
It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed. It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed.
- **Gateway host** runs the exec tool and channel connections by default. - **Gateway host** runs the bash tool and provider connections by default.
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`. - **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
In short: exec runs where the Gateway lives; device actions run where the device lives. In short: bash runs where the Gateway lives; device actions run where the device lives.
Details: [Remote access](https://docs.clawd.bot/gateway/remote) · [Nodes](https://docs.clawd.bot/nodes) · [Security](https://docs.clawd.bot/gateway/security) Details: [Remote access](https://docs.clawd.bot/remote) · [Nodes](https://docs.clawd.bot/nodes) · [Security](https://docs.clawd.bot/security)
## macOS permissions via the Gateway protocol ## macOS permissions via the Gateway protocol
@@ -223,7 +218,7 @@ Elevated bash (host permissions) is separate from macOS TCC:
- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted. - Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted.
- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`. - Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd.bot/platforms/macos) · [Gateway protocol](https://docs.clawd.bot/concepts/architecture) Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd.bot/macos) · [Gateway protocol](https://docs.clawd.bot/architecture)
## Agent to Agent (sessions_* tools) ## Agent to Agent (sessions_* tools)
@@ -232,7 +227,7 @@ Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd
- `sessions_history` — fetch transcript logs for a session. - `sessions_history` — fetch transcript logs for a session.
- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`). - `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
Details: [Session tools](https://docs.clawd.bot/concepts/session-tool) Details: [Session tools](https://docs.clawd.bot/session-tool)
## Skills registry (ClawdHub) ## Skills registry (ClawdHub)
@@ -242,18 +237,18 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc
## Chat commands ## Chat commands
Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands are owner-only): Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):
- `/status` — compact session status (model + tokens, cost when available) - `/status` — compact session status (model + tokens, cost when available)
- `/new` or `/reset` — reset the session - `/new` or `/reset` — reset the session
- `/compact` — compact session context (summary) - `/compact` — compact session context (summary)
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only) - `/think <level>` — off|minimal|low|medium|high
- `/verbose on|off` - `/verbose on|off`
- `/cost on|off` — append per-response token/cost usage lines - `/cost on|off` — append per-response token/cost usage lines
- `/restart` — restart the gateway (owner-only in groups) - `/restart` — restart the gateway (owner-only in groups)
- `/activation mention|always` — group activation toggle (groups only) - `/activation mention|always` — group activation toggle (groups only)
## Apps (optional) ## macOS app (optional)
The Gateway alone delivers a great experience. All apps are optional and add extra features. The Gateway alone delivers a great experience. All apps are optional and add extra features.
@@ -279,13 +274,13 @@ Note: signed builds required for macOS permissions to stick across rebuilds (see
- Voice trigger forwarding + Canvas surface. - Voice trigger forwarding + Canvas surface.
- Controlled via `clawdbot nodes …`. - Controlled via `clawdbot nodes …`.
Runbook: [iOS connect](https://docs.clawd.bot/platforms/ios). Runbook: [iOS connect](https://docs.clawd.bot/ios).
### Android node (optional) ### Android node (optional)
- Pairs via the same Bridge + pairing flow as iOS. - Pairs via the same Bridge + pairing flow as iOS.
- Exposes Canvas, Camera, and Screen capture commands. - Exposes Canvas, Camera, and Screen capture commands.
- Runbook: [Android connect](https://docs.clawd.bot/platforms/android). - Runbook: [Android connect](https://docs.clawd.bot/android).
## Agent workspace + skills ## Agent workspace + skills
@@ -305,7 +300,7 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
} }
``` ```
[Full configuration reference (all keys + examples).](https://docs.clawd.bot/gateway/configuration) [Full configuration reference (all keys + examples).](https://docs.clawd.bot/configuration)
## Security model (important) ## Security model (important)
@@ -313,63 +308,54 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
- **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions. - **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`. - **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
Details: [Security guide](https://docs.clawd.bot/gateway/security) · [Docker + sandboxing](https://docs.clawd.bot/install/docker) · [Sandbox config](https://docs.clawd.bot/gateway/configuration) Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxing](https://docs.clawd.bot/docker) · [Sandbox config](https://docs.clawd.bot/configuration)
### [WhatsApp](https://docs.clawd.bot/channels/whatsapp) ### [WhatsApp](https://docs.clawd.bot/whatsapp)
- Link the device: `pnpm clawdbot channels 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 `channels.whatsapp.allowFrom`. - Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
- If `channels.whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all. - If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
### [Telegram](https://docs.clawd.bot/channels/telegram) ### [Telegram](https://docs.clawd.bot/telegram)
- Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` (env wins). - Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
- Optional: set `channels.telegram.groups` (with `channels.telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `channels.telegram.allowFrom` or `channels.telegram.webhookUrl` as needed. - Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `telegram.allowFrom` or `telegram.webhookUrl` as needed.
```json5 ```json5
{ {
channels: { telegram: {
telegram: { botToken: "123456:ABCDEF"
botToken: "123456:ABCDEF"
}
} }
} }
``` ```
### [Slack](https://docs.clawd.bot/channels/slack) ### [Slack](https://docs.clawd.bot/slack)
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `channels.slack.botToken` + `channels.slack.appToken`). - Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `slack.botToken` + `slack.appToken`).
### [Discord](https://docs.clawd.bot/channels/discord) ### [Discord](https://docs.clawd.bot/discord)
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins). - Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.dm.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed. - Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
```json5 ```json5
{ {
channels: { discord: {
discord: { token: "1234abcd"
token: "1234abcd"
}
} }
} }
``` ```
### [Signal](https://docs.clawd.bot/channels/signal) ### [Signal](https://docs.clawd.bot/signal)
- Requires `signal-cli` and a `channels.signal` config section. - Requires `signal-cli` and a `signal` config section.
### [iMessage](https://docs.clawd.bot/channels/imessage) ### [iMessage](https://docs.clawd.bot/imessage)
- macOS only; Messages must be signed in. - macOS only; Messages must be signed in.
- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all. - If `imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
### [Microsoft Teams](https://docs.clawd.bot/channels/msteams) ### [WebChat](https://docs.clawd.bot/webchat)
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
### [WebChat](https://docs.clawd.bot/web/webchat)
- Uses the Gateway WebSocket; no separate WebChat port/config. - Uses the Gateway WebSocket; no separate WebChat port/config.
@@ -389,68 +375,68 @@ Browser control (optional):
Use these when you’re past the onboarding flow and want the deeper reference. Use these when you’re past the onboarding flow and want the deeper reference.
- [Start with the docs index for navigation and “what’s where.”](https://docs.clawd.bot) - [Start with the docs index for navigation and “what’s where.”](https://docs.clawd.bot)
- [Read the architecture overview for the gateway + protocol model.](https://docs.clawd.bot/concepts/architecture) - [Read the architecture overview for the gateway + protocol model.](https://docs.clawd.bot/architecture)
- [Use the full configuration reference when you need every key and example.](https://docs.clawd.bot/gateway/configuration) - [Use the full configuration reference when you need every key and example.](https://docs.clawd.bot/configuration)
- [Run the Gateway by the book with the operational runbook.](https://docs.clawd.bot/gateway) - [Run the Gateway by the book with the operational runbook.](https://docs.clawd.bot/gateway)
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawd.bot/web) - [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawd.bot/web)
- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawd.bot/gateway/remote) - [Understand remote access over SSH tunnels or tailnets.](https://docs.clawd.bot/remote)
- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawd.bot/start/wizard) - [Follow the onboarding wizard flow for a guided setup.](https://docs.clawd.bot/wizard)
- [Wire external triggers via the webhook surface.](https://docs.clawd.bot/automation/webhook) - [Wire external triggers via the webhook surface.](https://docs.clawd.bot/webhook)
- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/automation/gmail-pubsub) - [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/gmail-pubsub)
- [Learn the macOS menu bar companion details.](https://docs.clawd.bot/platforms/mac/menu-bar) - [Learn the macOS menu bar companion details.](https://docs.clawd.bot/mac/menu-bar)
- [Platform guides: Windows (WSL2)](https://docs.clawd.bot/platforms/windows), [Linux](https://docs.clawd.bot/platforms/linux), [macOS](https://docs.clawd.bot/platforms/macos), [iOS](https://docs.clawd.bot/platforms/ios), [Android](https://docs.clawd.bot/platforms/android) - [Platform guides: Windows (WSL2)](https://docs.clawd.bot/windows), [Linux](https://docs.clawd.bot/linux), [macOS](https://docs.clawd.bot/macos), [iOS](https://docs.clawd.bot/ios), [Android](https://docs.clawd.bot/android)
- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/channels/troubleshooting) - [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/troubleshooting)
- [Review security guidance before exposing anything.](https://docs.clawd.bot/gateway/security) - [Review security guidance before exposing anything.](https://docs.clawd.bot/security)
## Advanced docs (discovery + control) ## Advanced docs (discovery + control)
- [Discovery + transports](https://docs.clawd.bot/gateway/discovery) - [Discovery + transports](https://docs.clawd.bot/discovery)
- [Bonjour/mDNS](https://docs.clawd.bot/gateway/bonjour) - [Bonjour/mDNS](https://docs.clawd.bot/bonjour)
- [Gateway pairing](https://docs.clawd.bot/gateway/pairing) - [Gateway pairing](https://docs.clawd.bot/gateway/pairing)
- [Remote gateway README](https://docs.clawd.bot/gateway/remote-gateway-readme) - [Remote gateway README](https://docs.clawd.bot/remote-gateway-readme)
- [Control UI](https://docs.clawd.bot/web/control-ui) - [Control UI](https://docs.clawd.bot/control-ui)
- [Dashboard](https://docs.clawd.bot/web/dashboard) - [Dashboard](https://docs.clawd.bot/dashboard)
## Operations & troubleshooting ## Operations & troubleshooting
- [Health checks](https://docs.clawd.bot/gateway/health) - [Health checks](https://docs.clawd.bot/health)
- [Gateway lock](https://docs.clawd.bot/gateway/gateway-lock) - [Gateway lock](https://docs.clawd.bot/gateway-lock)
- [Background process](https://docs.clawd.bot/gateway/background-process) - [Background process](https://docs.clawd.bot/background-process)
- [Browser troubleshooting (Linux)](https://docs.clawd.bot/tools/browser-linux-troubleshooting) - [Browser troubleshooting (Linux)](https://docs.clawd.bot/browser-linux-troubleshooting)
- [Logging](https://docs.clawd.bot/logging) - [Logging](https://docs.clawd.bot/logging)
## Deep dives ## Deep dives
- [Agent loop](https://docs.clawd.bot/concepts/agent-loop) - [Agent loop](https://docs.clawd.bot/agent-loop)
- [Presence](https://docs.clawd.bot/concepts/presence) - [Presence](https://docs.clawd.bot/presence)
- [TypeBox schemas](https://docs.clawd.bot/concepts/typebox) - [TypeBox schemas](https://docs.clawd.bot/typebox)
- [RPC adapters](https://docs.clawd.bot/reference/rpc) - [RPC adapters](https://docs.clawd.bot/rpc)
- [Queue](https://docs.clawd.bot/concepts/queue) - [Queue](https://docs.clawd.bot/queue)
## Workspace & skills ## Workspace & skills
- [Skills config](https://docs.clawd.bot/tools/skills-config) - [Skills config](https://docs.clawd.bot/skills-config)
- [Default AGENTS](https://docs.clawd.bot/reference/AGENTS.default) - [Default AGENTS](https://docs.clawd.bot/AGENTS.default)
- [Templates: AGENTS](https://docs.clawd.bot/reference/templates/AGENTS) - [Templates: AGENTS](https://docs.clawd.bot/templates/AGENTS)
- [Templates: BOOTSTRAP](https://docs.clawd.bot/reference/templates/BOOTSTRAP) - [Templates: BOOTSTRAP](https://docs.clawd.bot/templates/BOOTSTRAP)
- [Templates: IDENTITY](https://docs.clawd.bot/reference/templates/IDENTITY) - [Templates: IDENTITY](https://docs.clawd.bot/templates/IDENTITY)
- [Templates: SOUL](https://docs.clawd.bot/reference/templates/SOUL) - [Templates: SOUL](https://docs.clawd.bot/templates/SOUL)
- [Templates: TOOLS](https://docs.clawd.bot/reference/templates/TOOLS) - [Templates: TOOLS](https://docs.clawd.bot/templates/TOOLS)
- [Templates: USER](https://docs.clawd.bot/reference/templates/USER) - [Templates: USER](https://docs.clawd.bot/templates/USER)
## Platform internals ## Platform internals
- [macOS dev setup](https://docs.clawd.bot/platforms/mac/dev-setup) - [macOS dev setup](https://docs.clawd.bot/mac/dev-setup)
- [macOS menu bar](https://docs.clawd.bot/platforms/mac/menu-bar) - [macOS menu bar](https://docs.clawd.bot/mac/menu-bar)
- [macOS voice wake](https://docs.clawd.bot/platforms/mac/voicewake) - [macOS voice wake](https://docs.clawd.bot/mac/voicewake)
- [iOS node](https://docs.clawd.bot/platforms/ios) - [iOS node](https://docs.clawd.bot/ios)
- [Android node](https://docs.clawd.bot/platforms/android) - [Android node](https://docs.clawd.bot/android)
- [Windows (WSL2)](https://docs.clawd.bot/platforms/windows) - [Windows (WSL2)](https://docs.clawd.bot/windows)
- [Linux app](https://docs.clawd.bot/platforms/linux) - [Linux app](https://docs.clawd.bot/linux)
## Email hooks (Gmail) ## Email hooks (Gmail)
- [docs.clawd.bot/gmail-pubsub](https://docs.clawd.bot/automation/gmail-pubsub) - [docs.clawd.bot/gmail-pubsub](https://docs.clawd.bot/gmail-pubsub)
## Clawd ## Clawd
@@ -472,21 +458,16 @@ Thanks to all clawtributors:
<p align="left"> <p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/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/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/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/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/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/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/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/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></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/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/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/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></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/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/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/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></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/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/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></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/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/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/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/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></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/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></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/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/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/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/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/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/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="Sebastian Barrios" title="Sebastian Barrios"/></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/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/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/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/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/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/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/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/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></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/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/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/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></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/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></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/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/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></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/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/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></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/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></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/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/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/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></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/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/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/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/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/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/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/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/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/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></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/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/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></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/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/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></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/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></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/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/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></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/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/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/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></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/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/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/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/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/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/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></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/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/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/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=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></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/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></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/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/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></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=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></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/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></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/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></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/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></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/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/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=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></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>
</p> </p>
+23 -24
View File
@@ -1,56 +1,55 @@
<?xml version="1.0" standalone="yes"?> <?xml version="1.0" standalone="yes"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0"> <rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel> <channel>
<title>Clawdbot</title> <title>Clawdis</title>
<item> <item>
<title>2026.1.12-2</title> <title>2026.1.5-3</title>
<pubDate>Tue, 13 Jan 2026 10:05:25 +0000</pubDate> <pubDate>Mon, 05 Jan 2026 04:30:46 +0100</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link> <link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5534</sparkle:version> <sparkle:version>3095</sparkle:version>
<sparkle:shortVersionString>2026.1.12-2</sparkle:shortVersionString> <sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion> <sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.12-2</h2> <description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
<h3>Fixes</h3> <h3>Fixes</h3>
<ul> <ul>
<li>Packaging: include <code>dist/memory/**</code> in the npm tarball (fixes <code>ERR_MODULE_NOT_FOUND</code> for <code>dist/memory/index.js</code>).</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> </ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p> <p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description> ]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/> <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>
<item> <item>
<title>2026.1.11-3</title> <title>2026.1.5-3</title>
<pubDate>Mon, 12 Jan 2026 10:40:23 +0000</pubDate> <pubDate>Mon, 05 Jan 2026 03:57:59 +0100</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link> <link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5212</sparkle:version> <sparkle:version>3091</sparkle:version>
<sparkle:shortVersionString>2026.1.11-3</sparkle:shortVersionString> <sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion> <sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.11-3</h2> <description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
<h3>Fixes</h3> <h3>Fixes</h3>
<ul> <ul>
<li>CLI: avoid top-level await warnings in the entrypoint on fresh installs.</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>
<li>CLI: show a commit hash in the banner for npm installs (package.json gitHead fallback).</li>
</ul> </ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p> <p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description> ]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/> <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>
<item> <item>
<title>2026.1.11-2</title> <title>2026.1.5-2</title>
<pubDate>Mon, 12 Jan 2026 10:25:53 +0000</pubDate> <pubDate>Mon, 05 Jan 2026 03:51:30 +0100</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link> <link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5210</sparkle:version> <sparkle:version>3089</sparkle:version>
<sparkle:shortVersionString>2026.1.11-2</sparkle:shortVersionString> <sparkle:shortVersionString>2026.1.5-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion> <sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.11-2</h2> <description><![CDATA[<h2>Clawdbot 2026.1.5-2</h2>
<h3>Fixes</h3> <h3>Fixes</h3>
<ul> <ul>
<li>Installer: ensure the CLI entrypoint is executable after npm installs.</li> <li>NPM package: include <code>dist/sessions</code> so <code>clawdbot agent</code> resolves session helpers in npx installs.</li>
<li>Packaging: include <code>dist/plugins/</code> in the npm package to avoid missing module errors.</li> <li>Node 25: avoid unsupported directory import by targeting <code>qrcode-terminal/vendor/QRCode/*.js</code> modules.</li>
</ul> </ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p> <p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description> ]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-2/Clawdbot-2026.1.11-2.zip" length="19860732" type="application/octet-stream" sparkle:edSignature="0UG+d9v3Qf5F9vs/KozUB404WpHjFBQRVoRuhwtzF8kpU7jJmmGlQzh1c61E+LMN4fHcljpxIwHHrvvIfRyrCw=="/> <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> </item>
</channel> </channel>
</rss> </rss>
+2 -5
View File
@@ -21,8 +21,8 @@ android {
applicationId = "com.clawdbot.android" applicationId = "com.clawdbot.android"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 202601114 versionCode = 20260109
versionName = "2026.1.11-4" versionName = "2026.1.9"
} }
buildTypes { buildTypes {
@@ -49,7 +49,6 @@ android {
lint { lint {
disable += setOf("IconLauncherShape") disable += setOf("IconLauncherShape")
warningsAsErrors = true
} }
testOptions { testOptions {
@@ -73,7 +72,6 @@ androidComponents {
kotlin { kotlin {
compilerOptions { compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) 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("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
implementation("androidx.security:security-crypto:1.1.0") implementation("androidx.security:security-crypto:1.1.0")
implementation("androidx.exifinterface:exifinterface:1.4.2")
// CameraX (for node.invoke camera.* parity) // CameraX (for node.invoke camera.* parity)
implementation("androidx.camera:camera-core:1.5.2") implementation("androidx.camera:camera-core:1.5.2")
@@ -1,26 +1,8 @@
package com.clawdbot.android package com.clawdbot.android
import android.app.Application import android.app.Application
import android.os.StrictMode
class NodeApp : Application() { class NodeApp : Application() {
val runtime: NodeRuntime by lazy { NodeRuntime(this) } 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(),
)
}
}
} }
@@ -8,7 +8,7 @@ import android.graphics.BitmapFactory
import android.graphics.Matrix import android.graphics.Matrix
import android.util.Base64 import android.util.Base64
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.exifinterface.media.ExifInterface import android.media.ExifInterface
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCapture
@@ -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() { private suspend fun ensureMicPermission() {
val granted = val granted =
+12 -4
View File
@@ -161,10 +161,18 @@ actor BridgeClient {
purpose: String, purpose: String,
_ op: @escaping @Sendable () async throws -> T) async throws -> T _ op: @escaping @Sendable () async throws -> T) async throws -> T
{ {
try await AsyncTimeout.withTimeout( try await withThrowingTaskGroup(of: T.self) { group in
seconds: Double(seconds), group.addTask {
onTimeout: { TimeoutError(purpose: purpose, seconds: seconds) }, try await op()
operation: 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 { private func startAndWaitForReady(_ connection: NWConnection, queue: DispatchQueue) async throws {
@@ -115,11 +115,7 @@ final class BridgeConnectionController {
self.didAutoConnect = true self.didAutoConnect = true
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port) let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
self.startAutoConnect( self.startAutoConnect(endpoint: endpoint, token: token, instanceId: instanceId)
endpoint: endpoint,
bridgeStableID: BridgeEndpointID.stableID(endpoint),
token: token,
instanceId: instanceId)
return return
} }
@@ -136,11 +132,7 @@ final class BridgeConnectionController {
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return } guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
self.didAutoConnect = true self.didAutoConnect = true
self.startAutoConnect( self.startAutoConnect(endpoint: target.endpoint, token: token, instanceId: instanceId)
endpoint: target.endpoint,
bridgeStableID: target.stableID,
token: token,
instanceId: instanceId)
} }
private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) { private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
@@ -179,12 +171,7 @@ final class BridgeConnectionController {
"bridge-token.\(instanceId)" "bridge-token.\(instanceId)"
} }
private func startAutoConnect( private func startAutoConnect(endpoint: NWEndpoint, token: String, instanceId: String) {
endpoint: NWEndpoint,
bridgeStableID: String,
token: String,
instanceId: String)
{
guard let appModel else { return } guard let appModel else { return }
Task { [weak self] in Task { [weak self] in
guard let self else { return } guard let self else { return }
@@ -205,10 +192,7 @@ final class BridgeConnectionController {
service: "com.clawdbot.bridge", service: "com.clawdbot.bridge",
account: self.keychainAccount(instanceId: instanceId)) account: self.keychainAccount(instanceId: instanceId))
} }
appModel.connectToBridge( appModel.connectToBridge(endpoint: endpoint, hello: self.makeHello(token: resolvedToken))
endpoint: endpoint,
bridgeStableID: bridgeStableID,
hello: self.makeHello(token: resolvedToken))
} catch { } catch {
await MainActor.run { await MainActor.run {
appModel.bridgeStatusText = "Bridge error: \(error.localizedDescription)" appModel.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
+14 -4
View File
@@ -321,10 +321,20 @@ actor BridgeSession {
seconds: Double, seconds: Double,
operation: @escaping @Sendable () async throws -> T) async throws -> T operation: @escaping @Sendable () async throws -> T) async throws -> T
{ {
try await AsyncTimeout.withTimeout( try await withThrowingTaskGroup(of: T.self) { group in
seconds: seconds, group.addTask { try await operation() }
onTimeout: { TimeoutError(message: "UNAVAILABLE: connection timeout") }, group.addTask {
operation: operation) 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> { private static func makeStateStream(for connection: NWConnection) -> AsyncStream<NWConnection.State> {
+4 -4
View File
@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.11-4</string> <string>2026.1.9</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>202601113</string> <string>20260109</string>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoadsInWebContent</key> <key>NSAllowsArbitraryLoadsInWebContent</key>
@@ -35,10 +35,10 @@
<string>Clawdbot can capture photos or short video clips when requested via the bridge.</string> <string>Clawdbot can capture photos or short video clips when requested via the bridge.</string>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>Clawdbot discovers and connects to your Clawdbot bridge on the local network.</string> <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> <key>NSLocationWhenInUseUsageDescription</key>
<string>Clawdbot uses your location when you allow location sharing.</string> <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> <key>NSMicrophoneUsageDescription</key>
<string>Clawdbot needs microphone access for voice wake.</string> <string>Clawdbot needs microphone access for voice wake.</string>
<key>NSSpeechRecognitionUsageDescription</key> <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, 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 { private static func accuracyValue(_ accuracy: ClawdbotLocationAccuracy) -> CLLocationAccuracy {
@@ -104,35 +117,26 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
} }
} }
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus if let cont = self.authContinuation {
Task { @MainActor in self.authContinuation = nil
if let cont = self.authContinuation { cont.resume(returning: manager.authorizationStatus)
self.authContinuation = nil
cont.resume(returning: status)
}
} }
} }
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
let locs = locations guard let cont = self.locationContinuation else { return }
Task { @MainActor in self.locationContinuation = nil
guard let cont = self.locationContinuation else { return } if let latest = locations.last {
self.locationContinuation = nil cont.resume(returning: latest)
if let latest = locs.last { } else {
cont.resume(returning: latest) cont.resume(throwing: Error.unavailable)
} else {
cont.resume(throwing: Error.unavailable)
}
} }
} }
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) { func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
let err = error guard let cont = self.locationContinuation else { return }
Task { @MainActor in self.locationContinuation = nil
guard let cont = self.locationContinuation else { return } cont.resume(throwing: error)
self.locationContinuation = nil
cont.resume(throwing: err)
}
} }
} }
+1 -3
View File
@@ -204,14 +204,12 @@ final class NodeAppModel {
func connectToBridge( func connectToBridge(
endpoint: NWEndpoint, endpoint: NWEndpoint,
bridgeStableID: String,
hello: BridgeHello) hello: BridgeHello)
{ {
self.bridgeTask?.cancel() self.bridgeTask?.cancel()
self.bridgeServerName = nil self.bridgeServerName = nil
self.bridgeRemoteAddress = nil self.bridgeRemoteAddress = nil
let id = bridgeStableID.trimmingCharacters(in: .whitespacesAndNewlines) self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
self.connectedBridgeID = id.isEmpty ? BridgeEndpointID.stableID(endpoint) : id
self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil self.voiceWakeSyncTask = nil
@@ -425,7 +425,6 @@ struct SettingsTab: View {
self.appModel.connectToBridge( self.appModel.connectToBridge(
endpoint: bridge.endpoint, endpoint: bridge.endpoint,
bridgeStableID: bridge.stableID,
hello: BridgeHello( hello: BridgeHello(
nodeId: self.instanceId, nodeId: self.instanceId,
displayName: self.displayName, displayName: self.displayName,
@@ -500,7 +499,6 @@ struct SettingsTab: View {
self.appModel.connectToBridge( self.appModel.connectToBridge(
endpoint: endpoint, endpoint: endpoint,
bridgeStableID: BridgeEndpointID.stableID(endpoint),
hello: BridgeHello( hello: BridgeHello(
nodeId: self.instanceId, nodeId: self.instanceId,
displayName: self.displayName, displayName: self.displayName,
+6 -4
View File
@@ -288,8 +288,9 @@ final class TalkModeManager: NSObject {
self.chatSubscribedSessionKeys.insert(key) self.chatSubscribedSessionKeys.insert(key)
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)") self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
} catch { } catch {
let err = error.localizedDescription self.logger.warning(
self.logger.warning("chat.subscribe failed key=\(key, privacy: .public) err=\(err, privacy: .public)") "chat.subscribe failed sessionKey=\(key, privacy: .public) " +
"err=\(error.localizedDescription, privacy: .public)")
} }
} }
@@ -527,8 +528,9 @@ final class TalkModeManager: NSObject {
self.lastPlaybackWasPCM = false self.lastPlaybackWasPCM = false
result = await self.mp3Player.play(stream: stream) result = await self.mp3Player.play(stream: stream)
} }
let duration = Date().timeIntervalSince(started) self.logger.info(
self.logger.info("elevenlabs stream finished=\(result.finished, privacy: .public) dur=\(duration, privacy: .public)s") "elevenlabs stream finished=\(result.finished, privacy: .public) " +
"dur=\(Date().timeIntervalSince(started), privacy: .public)s")
if !result.finished, let interruptedAt = result.interruptedAt { if !result.finished, let interruptedAt = result.interruptedAt {
self.lastInterruptedAtSeconds = interruptedAt self.lastInterruptedAtSeconds = interruptedAt
} }
@@ -175,6 +175,7 @@ private func withKeychainValues<T>(
} }
@Test @MainActor func makeHelloBuildsCapsAndCommands() { @Test @MainActor func makeHelloBuildsCapsAndCommands() {
let defaults = UserDefaults.standard
let voiceWakeKey = VoiceWakePreferences.enabledKey let voiceWakeKey = VoiceWakePreferences.enabledKey
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) { withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
+2 -2
View File
@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>BNDL</string> <string>BNDL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.11-4</string> <string>2026.1.9</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>202601113</string> <string>20260109</string>
</dict> </dict>
</plist> </plist>
-24
View File
@@ -5,10 +5,6 @@ options:
iOS: "17.0" iOS: "17.0"
xcodeVersion: "16.0" xcodeVersion: "16.0"
settings:
base:
SWIFT_VERSION: "6.0"
packages: packages:
ClawdbotKit: ClawdbotKit:
path: ../shared/ClawdbotKit path: ../shared/ClawdbotKit
@@ -72,15 +68,11 @@ targets:
PRODUCT_BUNDLE_IDENTIFIER: com.clawdbot.ios PRODUCT_BUNDLE_IDENTIFIER: com.clawdbot.ios
PROVISIONING_PROFILE_SPECIFIER: "com.clawdbot.ios Development" PROVISIONING_PROFILE_SPECIFIER: "com.clawdbot.ios Development"
SWIFT_VERSION: "6.0" SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
ENABLE_APPINTENTS_METADATA: NO
info: info:
path: Sources/Info.plist path: Sources/Info.plist
properties: properties:
CFBundleDisplayName: Clawdbot CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.1.9"
CFBundleVersion: "20260109"
UILaunchScreen: {} UILaunchScreen: {}
UIApplicationSceneManifest: UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false UIApplicationSupportsMultipleScenes: false
@@ -92,20 +84,8 @@ targets:
NSBonjourServices: NSBonjourServices:
- _clawdbot-bridge._tcp - _clawdbot-bridge._tcp
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the bridge. 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. NSMicrophoneUsageDescription: Clawdbot needs microphone access for voice wake.
NSSpeechRecognitionUsageDescription: Clawdbot uses on-device speech recognition 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: ClawdbotTests:
type: bundle.unit-test type: bundle.unit-test
@@ -116,17 +96,13 @@ targets:
- target: Clawdbot - target: Clawdbot
- package: Swabble - package: Swabble
product: SwabbleKit product: SwabbleKit
- sdk: AppIntents.framework
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.clawdbot.ios.tests PRODUCT_BUNDLE_IDENTIFIER: com.clawdbot.ios.tests
SWIFT_VERSION: "6.0" SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Clawdbot.app/Clawdbot" TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Clawdbot.app/Clawdbot"
BUNDLE_LOADER: "$(TEST_HOST)" BUNDLE_LOADER: "$(TEST_HOST)"
info: info:
path: Tests/Info.plist path: Tests/Info.plist
properties: properties:
CFBundleDisplayName: ClawdbotTests CFBundleDisplayName: ClawdbotTests
CFBundleShortVersionString: "2026.1.9"
CFBundleVersion: "20260109"
-32
View File
@@ -10,10 +10,7 @@ let package = Package(
], ],
products: [ products: [
.library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]), .library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]),
.library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]),
.executable(name: "Clawdbot", targets: ["Clawdbot"]), .executable(name: "Clawdbot", targets: ["Clawdbot"]),
.executable(name: "clawdbot-mac-discovery", targets: ["ClawdbotDiscoveryCLI"]),
.executable(name: "clawdbot-mac-wizard", targets: ["ClawdbotWizardCLI"]),
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"), .package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
@@ -39,20 +36,10 @@ let package = Package(
swiftSettings: [ swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"), .enableUpcomingFeature("StrictConcurrency"),
]), ]),
.target(
name: "ClawdbotDiscovery",
dependencies: [
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
],
path: "Sources/ClawdbotDiscovery",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget( .executableTarget(
name: "Clawdbot", name: "Clawdbot",
dependencies: [ dependencies: [
"ClawdbotIPC", "ClawdbotIPC",
"ClawdbotDiscovery",
"ClawdbotProtocol", "ClawdbotProtocol",
.product(name: "ClawdbotKit", package: "ClawdbotKit"), .product(name: "ClawdbotKit", package: "ClawdbotKit"),
.product(name: "ClawdbotChatUI", package: "ClawdbotKit"), .product(name: "ClawdbotChatUI", package: "ClawdbotKit"),
@@ -74,30 +61,11 @@ let package = Package(
swiftSettings: [ swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"), .enableUpcomingFeature("StrictConcurrency"),
]), ]),
.executableTarget(
name: "ClawdbotDiscoveryCLI",
dependencies: [
"ClawdbotDiscovery",
],
path: "Sources/ClawdbotDiscoveryCLI",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "ClawdbotWizardCLI",
dependencies: [
"ClawdbotProtocol",
],
path: "Sources/ClawdbotWizardCLI",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.testTarget( .testTarget(
name: "ClawdbotIPCTests", name: "ClawdbotIPCTests",
dependencies: [ dependencies: [
"ClawdbotIPC", "ClawdbotIPC",
"Clawdbot", "Clawdbot",
"ClawdbotDiscovery",
"ClawdbotProtocol", "ClawdbotProtocol",
.product(name: "SwabbleKit", package: "swabble"), .product(name: "SwabbleKit", package: "swabble"),
], ],
+16 -1
View File
@@ -182,6 +182,14 @@ final class AppState {
} }
} }
var attachExistingGatewayOnly: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.attachExistingGatewayOnly, forKey: attachExistingGatewayOnlyKey)
}
}
}
var remoteTarget: String { var remoteTarget: String {
didSet { didSet {
self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) }
@@ -204,7 +212,7 @@ final class AppState {
private var earBoostTask: Task<Void, Never>? private var earBoostTask: Task<Void, Never>?
init(preview: Bool = false) { init(preview: Bool = false) {
self.isPreview = preview || ProcessInfo.processInfo.isRunningTests self.isPreview = preview
let onboardingSeen = UserDefaults.standard.bool(forKey: "clawdbot.onboardingSeen") let onboardingSeen = UserDefaults.standard.bool(forKey: "clawdbot.onboardingSeen")
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey) self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
self.launchAtLogin = false self.launchAtLogin = false
@@ -294,6 +302,8 @@ final class AppState {
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
self.peekabooBridgeEnabled = UserDefaults.standard self.peekabooBridgeEnabled = UserDefaults.standard
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true .object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
self.attachExistingGatewayOnly = UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
if !self.isPreview { if !self.isPreview {
Task.detached(priority: .utility) { [weak self] in Task.detached(priority: .utility) { [weak self] in
let current = await LaunchAgentManager.status() let current = await LaunchAgentManager.status()
@@ -594,6 +604,7 @@ extension AppState {
state.remoteIdentity = "~/.ssh/id_ed25519" state.remoteIdentity = "~/.ssh/id_ed25519"
state.remoteProjectRoot = "~/Projects/clawdbot" state.remoteProjectRoot = "~/Projects/clawdbot"
state.remoteCliPath = "" state.remoteCliPath = ""
state.attachExistingGatewayOnly = false
return state return state
} }
} }
@@ -612,6 +623,10 @@ enum AppStateStore {
static var canvasEnabled: Bool { static var canvasEnabled: Bool {
UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
} }
static var attachExistingGatewayOnly: Bool {
UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
}
} }
@MainActor @MainActor
@@ -1,16 +1,12 @@
import Foundation import Foundation
public enum AsyncTimeout { enum AsyncTimeout {
public static func withTimeout<T: Sendable>( static func withTimeout<T: Sendable>(
seconds: Double, seconds: Double,
onTimeout: @escaping @Sendable () -> Error, onTimeout: @escaping @Sendable () -> Error,
operation: @escaping @Sendable () async throws -> T) async throws -> T operation: @escaping @Sendable () async throws -> T) async throws -> T
{ {
let clamped = max(0, seconds) let clamped = max(0, seconds)
if clamped == 0 {
return try await operation()
}
return try await withThrowingTaskGroup(of: T.self) { group in return try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() } group.addTask { try await operation() }
group.addTask { group.addTask {
@@ -23,14 +19,4 @@ public enum AsyncTimeout {
throw onTimeout() 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)
}
} }
@@ -187,7 +187,7 @@ actor BridgeServer {
thinking: "low", thinking: "low",
deliver: false, deliver: false,
to: nil, to: nil,
channel: .last)) provider: .last))
case "agent.request": case "agent.request":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
@@ -205,7 +205,7 @@ actor BridgeServer {
?? "node-\(nodeId)" ?? "node-\(nodeId)"
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let channel = GatewayAgentChannel(raw: link.channel) let provider = GatewayAgentProvider(raw: link.channel)
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( _ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: message, message: message,
@@ -213,7 +213,7 @@ actor BridgeServer {
thinking: thinking, thinking: thinking,
deliver: link.deliver, deliver: link.deliver,
to: to, to: to,
channel: channel)) provider: provider))
default: default:
break break
@@ -2,8 +2,8 @@ import ClawdbotKit
import Foundation import Foundation
import Network import Network
public enum BridgeEndpointID { enum BridgeEndpointID {
public static func stableID(_ endpoint: NWEndpoint) -> String { static func stableID(_ endpoint: NWEndpoint) -> String {
switch endpoint { switch endpoint {
case let .service(name, type, domain, _): case let .service(name, type, domain, _):
// Keep stable across encoded/decoded differences (e.g. \032 for spaces). // 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)) BonjourEscapes.decode(String(describing: endpoint))
} }
@@ -1,84 +0,0 @@
import AppKit
import Foundation
import OSLog
@MainActor
final class CLIInstallPrompter {
static let shared = CLIInstallPrompter()
private let logger = Logger(subsystem: "com.clawdbot", category: "cli.prompt")
private var isPrompting = false
func checkAndPromptIfNeeded(reason: String) {
guard self.shouldPrompt() else { return }
guard let version = Self.appVersion() else { return }
self.isPrompting = true
UserDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey)
let alert = NSAlert()
alert.messageText = "Install Clawdbot CLI?"
alert.informativeText = "Local mode needs the CLI so launchd can run the gateway."
alert.addButton(withTitle: "Install CLI")
alert.addButton(withTitle: "Not now")
alert.addButton(withTitle: "Open Settings")
let response = alert.runModal()
switch response {
case .alertFirstButtonReturn:
Task { await self.installCLI() }
case .alertThirdButtonReturn:
self.openSettings(tab: .general)
default:
break
}
self.logger.debug("cli install prompt handled reason=\(reason, privacy: .public)")
self.isPrompting = false
}
private func shouldPrompt() -> Bool {
guard !self.isPrompting else { return false }
guard AppStateStore.shared.onboardingSeen else { return false }
guard AppStateStore.shared.connectionMode == .local else { return false }
guard CLIInstaller.installedLocation() == nil else { return false }
guard let version = Self.appVersion() else { return false }
let lastPrompt = UserDefaults.standard.string(forKey: cliInstallPromptedVersionKey)
return lastPrompt != version
}
private func installCLI() async {
let status = StatusBox()
await CLIInstaller.install { message in
await status.set(message)
}
if let message = await status.get() {
let alert = NSAlert()
alert.messageText = "CLI install finished"
alert.informativeText = message
alert.runModal()
}
}
private func openSettings(tab: SettingsTab) {
SettingsTabRouter.request(tab)
SettingsWindowOpener.shared.open()
DispatchQueue.main.async {
NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab)
}
}
private static func appVersion() -> String? {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
}
}
private actor StatusBox {
private var value: String?
func set(_ value: String) {
self.value = value
}
func get() -> String? {
self.value
}
}
+53 -54
View File
@@ -2,16 +2,24 @@ import Foundation
@MainActor @MainActor
enum CLIInstaller { enum CLIInstaller {
private static func embeddedHelperURL() -> URL {
Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdbot")
}
static func installedLocation() -> String? { static func installedLocation() -> String? {
self.installedLocation( self.installedLocation(
searchPaths: CommandResolver.preferredPaths(), searchPaths: cliHelperSearchPaths,
embeddedHelper: self.embeddedHelperURL(),
fileManager: .default) fileManager: .default)
} }
static func installedLocation( static func installedLocation(
searchPaths: [String], searchPaths: [String],
embeddedHelper: URL,
fileManager: FileManager) -> String? fileManager: FileManager) -> String?
{ {
let embedded = embeddedHelper.resolvingSymlinksInPath()
for basePath in searchPaths { for basePath in searchPaths {
let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdbot").path let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdbot").path
var isDirectory: ObjCBool = false var isDirectory: ObjCBool = false
@@ -24,7 +32,10 @@ enum CLIInstaller {
guard fileManager.isExecutableFile(atPath: candidate) else { continue } guard fileManager.isExecutableFile(atPath: candidate) else { continue }
return candidate let resolved = URL(fileURLWithPath: candidate).resolvingSymlinksInPath()
if resolved == embedded {
return candidate
}
} }
return nil return nil
@@ -34,70 +45,58 @@ enum CLIInstaller {
self.installedLocation() != nil self.installedLocation() != nil
} }
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async { static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
let expected = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest" let helper = self.embeddedHelperURL()
let prefix = Self.installPrefix() guard FileManager.default.isExecutableFile(atPath: helper.path) else {
await statusHandler("Installing clawdbot CLI…") await statusHandler(
let cmd = self.installScriptCommand(version: expected, prefix: prefix) "Embedded CLI missing in bundle; repackage via scripts/package-mac-app.sh " +
let response = await ShellExecutor.runDetailed(command: cmd, cwd: nil, env: nil, timeout: 900) "(or restart-mac.sh without SKIP_GATEWAY_PACKAGE=1).")
if response.success {
let parsed = self.parseInstallEvents(response.stdout)
let installedVersion = parsed.last { $0.event == "done" }?.version
let summary = installedVersion.map { "Installed clawdbot \($0)." } ?? "Installed clawdbot."
await statusHandler(summary)
return return
} }
let parsed = self.parseInstallEvents(response.stdout) let targets = cliHelperSearchPaths.map { "\($0)/clawdbot" }
if let error = parsed.last(where: { $0.event == "error" })?.message { let result = await self.privilegedSymlink(source: helper.path, targets: targets)
await statusHandler("Install failed: \(error)") await statusHandler(result)
return
}
let detail = response.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
let fallback = response.errorMessage ?? "install failed"
await statusHandler("Install failed: \(detail.isEmpty ? fallback : detail)")
} }
private static func installPrefix() -> String { private static func privilegedSymlink(source: String, targets: [String]) async -> String {
FileManager.default.homeDirectoryForCurrentUser let escapedSource = self.shellEscape(source)
.appendingPathComponent(".clawdbot") let targetList = targets.map(self.shellEscape).joined(separator: " ")
.path let cmds = [
} "mkdir -p /usr/local/bin /opt/homebrew/bin",
targets.map { "ln -sf \(escapedSource) \($0)" }.joined(separator: "; "),
].joined(separator: "; ")
private static func installScriptCommand(version: String, prefix: String) -> [String] {
let escapedVersion = self.shellEscape(version)
let escapedPrefix = self.shellEscape(prefix)
let script = """ let script = """
curl -fsSL https://clawd.bot/install-cli.sh | \ do shell script "\(cmds)" with administrator privileges
bash -s -- --json --no-onboard --prefix \(escapedPrefix) --version \(escapedVersion)
""" """
return ["/bin/bash", "-lc", script]
}
private static func parseInstallEvents(_ output: String) -> [InstallEvent] { let proc = Process()
let decoder = JSONDecoder() proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
let lines = output proc.arguments = ["-e", script]
.split(whereSeparator: \.isNewline)
.map { String($0) } let pipe = Pipe()
var events: [InstallEvent] = [] proc.standardOutput = pipe
for line in lines { proc.standardError = pipe
guard let data = line.data(using: .utf8) else { continue }
if let event = try? decoder.decode(InstallEvent.self, from: data) { do {
events.append(event) try proc.run()
proc.waitUntilExit()
let data = pipe.fileHandleForReading.readToEndSafely()
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if proc.terminationStatus == 0 {
return output.isEmpty ? "CLI helper linked into \(targetList)" : output
} }
if output.lowercased().contains("user canceled") {
return "Install canceled"
}
return "Failed to install CLI helper: \(output)"
} catch {
return "Failed to run installer: \(error.localizedDescription)"
} }
return events
} }
private static func shellEscape(_ raw: String) -> String { private static func shellEscape(_ path: String) -> String {
"'" + raw.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" "'" + path.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
} }
} }
private struct InstallEvent: Decodable {
let event: String
let version: String?
let message: String?
}
@@ -86,7 +86,7 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
thinking: "low", thinking: "low",
deliver: false, deliver: false,
to: nil, to: nil,
channel: .last, provider: .last,
idempotencyKey: actionId)) idempotencyKey: actionId))
await MainActor.run { await MainActor.run {
@@ -84,30 +84,12 @@ enum CommandResolver {
"/bin", "/bin",
] ]
extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0) extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0)
let clawdbotPaths = self.clawdbotManagedPaths(home: home) extras.insert(contentsOf: self.nodeManagerBinPaths(home: home), at: 1)
if !clawdbotPaths.isEmpty {
extras.insert(contentsOf: clawdbotPaths, at: 1)
}
extras.insert(contentsOf: self.nodeManagerBinPaths(home: home), at: 1 + clawdbotPaths.count)
var seen = Set<String>() var seen = Set<String>()
// Preserve order while stripping duplicates so PATH lookups remain deterministic. // Preserve order while stripping duplicates so PATH lookups remain deterministic.
return (extras + current).filter { seen.insert($0).inserted } return (extras + current).filter { seen.insert($0).inserted }
} }
private static func clawdbotManagedPaths(home: URL) -> [String] {
let base = home.appendingPathComponent(".clawdbot")
let bin = base.appendingPathComponent("bin")
let nodeBin = base.appendingPathComponent("tools/node/bin")
var paths: [String] = []
if FileManager.default.fileExists(atPath: bin.path) {
paths.append(bin.path)
}
if FileManager.default.fileExists(atPath: nodeBin.path) {
paths.append(nodeBin.path)
}
return paths
}
private static func nodeManagerBinPaths(home: URL) -> [String] { private static func nodeManagerBinPaths(home: URL) -> [String] {
var bins: [String] = [] var bins: [String] = []
@@ -405,6 +387,10 @@ enum CommandResolver {
cliPath: cliPath) cliPath: cliPath)
} }
static var attachExistingGatewayOnly: Bool {
UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
}
static func connectionModeIsRemote(defaults: UserDefaults = .standard) -> Bool { static func connectionModeIsRemote(defaults: UserDefaults = .standard) -> Bool {
self.connectionSettings(defaults: defaults).mode == .remote self.connectionSettings(defaults: defaults).mode == .remote
} }
+45 -230
View File
@@ -12,12 +12,11 @@ struct ConfigSettings: View {
"Clawd uses a separate Chrome profile and ports (default 18791/18792) " "Clawd uses a separate Chrome profile and ports (default 18791/18792) "
+ "so it won’t interfere with your daily browser." + "so it won’t interfere with your daily browser."
@State private var configModel: String = "" @State private var configModel: String = ""
@State private var customModel: String = ""
@State private var configSaving = false @State private var configSaving = false
@State private var hasLoaded = false @State private var hasLoaded = false
@State private var models: [ModelChoice] = [] @State private var models: [ModelChoice] = []
@State private var modelsLoading = false @State private var modelsLoading = false
@State private var modelSearchQuery: String = ""
@State private var isModelPickerOpen = false
@State private var modelError: String? @State private var modelError: String?
@State private var modelsSourceLabel: String? @State private var modelsSourceLabel: String?
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath @AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
@@ -37,10 +36,10 @@ struct ConfigSettings: View {
@State private var talkInterruptOnSpeech: Bool = true @State private var talkInterruptOnSpeech: Bool = true
@State private var talkApiKey: String = "" @State private var talkApiKey: String = ""
@State private var gatewayApiKeyFound = false @State private var gatewayApiKeyFound = false
@FocusState private var modelSearchFocused: Bool
private struct ConfigDraft { private struct ConfigDraft {
let configModel: String let configModel: String
let customModel: String
let heartbeatMinutes: Int? let heartbeatMinutes: Int?
let heartbeatBody: String let heartbeatBody: String
let browserEnabled: Bool let browserEnabled: Bool
@@ -70,9 +69,7 @@ struct ConfigSettings: View {
self.allowAutosave = true self.allowAutosave = true
} }
} }
}
extension ConfigSettings {
private var content: some View { private var content: some View {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
self.header self.header
@@ -109,7 +106,8 @@ extension ConfigSettings {
GridRow { GridRow {
self.gridLabel("Model") self.gridLabel("Model")
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
self.modelPickerField self.modelPicker
self.customModelField
self.modelMetaLabels self.modelMetaLabels
} }
} }
@@ -118,114 +116,37 @@ extension ConfigSettings {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
private var modelPickerField: some View { private var modelPicker: some View {
Button { Picker("Model", selection: self.$configModel) {
guard !self.modelsLoading else { return } ForEach(self.models) { choice in
self.isModelPickerOpen = true Text("\(choice.name) — \(choice.provider.uppercased())")
} label: { .tag(choice.id)
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)
} }
.padding(.vertical, 6) Text("Manual entry…").tag("__custom__")
.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
} }
.labelsHidden()
.frame(maxWidth: .infinity)
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty)) .disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
.onChange(of: self.isModelPickerOpen) { _, isOpen in .onChange(of: self.configModel) { _, _ in
if isOpen { self.autosaveConfig()
self.modelSearchQuery = ""
self.modelSearchFocused = true
}
} }
} }
private var modelPickerPopover: some View { @ViewBuilder
VStack(alignment: .leading, spacing: 10) { private var customModelField: some View {
TextField("Search models", text: self.$modelSearchQuery) if self.configModel == "__custom__" {
TextField("Enter model ID", text: self.$customModel)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.focused(self.$modelSearchFocused) .frame(maxWidth: .infinity)
.controlSize(.small) .onChange(of: self.customModel) { _, newValue in
.onSubmit { self.configModel = newValue
if let exact = self.exactMatchForQuery() { self.autosaveConfig()
self.selectModel(exact)
return
}
if let manual = self.manualEntryCandidate {
self.selectManualModel(manual)
return
}
if self.modelSearchMatches.count == 1 {
self.selectModel(self.modelSearchMatches[0])
}
} }
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 @ViewBuilder
private var modelMetaLabels: some View { 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 { if let contextLabel = self.selectedContextLabel {
Text(contextLabel) Text(contextLabel)
.font(.footnote) .font(.footnote)
@@ -463,9 +384,7 @@ extension ConfigSettings {
} }
.padding(.top, 2) .padding(.top, 2)
} }
}
extension ConfigSettings {
private func loadConfig() async { private func loadConfig() async {
let parsed = await ConfigStore.load() let parsed = await ConfigStore.load()
let agents = parsed["agents"] as? [String: Any] let agents = parsed["agents"] as? [String: Any]
@@ -484,8 +403,10 @@ extension ConfigSettings {
}() }()
if !loadedModel.isEmpty { if !loadedModel.isEmpty {
self.configModel = loadedModel self.configModel = loadedModel
self.customModel = loadedModel
} else { } else {
self.configModel = SessionLoader.fallbackModel self.configModel = SessionLoader.fallbackModel
self.customModel = SessionLoader.fallbackModel
} }
if let heartbeatEvery { if let heartbeatEvery {
@@ -538,6 +459,7 @@ extension ConfigSettings {
defer { self.configSaving = false } defer { self.configSaving = false }
let configModel = self.configModel let configModel = self.configModel
let customModel = self.customModel
let heartbeatMinutes = self.heartbeatMinutes let heartbeatMinutes = self.heartbeatMinutes
let heartbeatBody = self.heartbeatBody let heartbeatBody = self.heartbeatBody
let browserEnabled = self.browserEnabled let browserEnabled = self.browserEnabled
@@ -550,6 +472,7 @@ extension ConfigSettings {
let draft = ConfigDraft( let draft = ConfigDraft(
configModel: configModel, configModel: configModel,
customModel: customModel,
heartbeatMinutes: heartbeatMinutes, heartbeatMinutes: heartbeatMinutes,
heartbeatBody: heartbeatBody, heartbeatBody: heartbeatBody,
browserEnabled: browserEnabled, browserEnabled: browserEnabled,
@@ -575,7 +498,8 @@ extension ConfigSettings {
var browser = root["browser"] as? [String: Any] ?? [:] var browser = root["browser"] as? [String: Any] ?? [:]
var talk = root["talk"] as? [String: Any] ?? [:] var talk = root["talk"] as? [String: Any] ?? [:]
let chosenModel = draft.configModel.trimmingCharacters(in: .whitespacesAndNewlines) let chosenModel = (draft.configModel == "__custom__" ? draft.customModel : draft.configModel)
.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedModel = chosenModel let trimmedModel = chosenModel
if !trimmedModel.isEmpty { if !trimmedModel.isEmpty {
var model = defaults["model"] as? [String: Any] ?? [:] var model = defaults["model"] as? [String: Any] ?? [:]
@@ -643,9 +567,7 @@ extension ConfigSettings {
return error.localizedDescription return error.localizedDescription
} }
} }
}
extension ConfigSettings {
private var browserColor: Color { private var browserColor: Color {
let raw = self.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines) let raw = self.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
let hex = raw.hasPrefix("#") ? String(raw.dropFirst()) : raw let hex = raw.hasPrefix("#") ? String(raw.dropFirst()) : raw
@@ -742,9 +664,7 @@ extension ConfigSettings {
if host == "::1" { return true } if host == "::1" { return true }
return false return false
} }
}
extension ConfigSettings {
private func loadModels() async { private func loadModels() async {
guard !self.modelsLoading else { return } guard !self.modelsLoading else { return }
self.modelsLoading = true self.modelsLoading = true
@@ -758,11 +678,23 @@ extension ConfigSettings {
timeoutMs: 15000) timeoutMs: 15000)
self.models = res.models self.models = res.models
self.modelsSourceLabel = "gateway" self.modelsSourceLabel = "gateway"
if !self.configModel.isEmpty,
!res.models.contains(where: { $0.id == self.configModel })
{
self.customModel = self.configModel
self.configModel = "__custom__"
}
} catch { } catch {
do { do {
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath) let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
self.models = loaded self.models = loaded
self.modelsSourceLabel = "local fallback" self.modelsSourceLabel = "local fallback"
if !self.configModel.isEmpty,
!loaded.contains(where: { $0.id == self.configModel })
{
self.customModel = self.configModel
self.configModel = "__custom__"
}
} catch { } catch {
self.modelError = error.localizedDescription self.modelError = error.localizedDescription
self.models = [] self.models = []
@@ -775,129 +707,11 @@ extension ConfigSettings {
let models: [ModelChoice] 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? { private var selectedContextLabel: String? {
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
guard guard
let choice = self.selectedModelChoice, !chosenId.isEmpty,
let choice = self.models.first(where: { $0.id == chosenId }),
let context = choice.contextWindow let context = choice.contextWindow
else { else {
return nil return nil
@@ -908,7 +722,8 @@ extension ConfigSettings {
} }
private var selectedAnthropicAuthMode: AnthropicAuthMode? { 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 } guard choice.provider.lowercased() == "anthropic" else { return nil }
return AnthropicAuthResolver.resolve() return AnthropicAuthResolver.resolve()
} }
@@ -27,7 +27,8 @@ final class ConnectionModeCoordinator {
GatewayProcessManager.shared.setActive(true) GatewayProcessManager.shared.setActive(true)
if GatewayAutostartPolicy.shouldEnsureLaunchAgent( if GatewayAutostartPolicy.shouldEnsureLaunchAgent(
mode: .local, mode: .local,
paused: paused) paused: paused,
attachExistingOnly: AppStateStore.attachExistingGatewayOnly)
{ {
Task { await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded() } Task { await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded() }
} }
@@ -11,9 +11,9 @@ extension ConnectionsSettings {
} }
@ViewBuilder @ViewBuilder
func channelHeaderActions(_ channel: ConnectionChannel) -> some View { func providerHeaderActions(_ provider: ConnectionProvider) -> some View {
HStack(spacing: 8) { HStack(spacing: 8) {
if channel == .whatsapp { if provider == .whatsapp {
Button("Logout") { Button("Logout") {
Task { await self.store.logoutWhatsApp() } Task { await self.store.logoutWhatsApp() }
} }
@@ -21,7 +21,7 @@ extension ConnectionsSettings {
.disabled(self.store.whatsappBusy) .disabled(self.store.whatsappBusy)
} }
if channel == .telegram { if provider == .telegram {
Button("Logout") { Button("Logout") {
Task { await self.store.logoutTelegram() } Task { await self.store.logoutTelegram() }
} }
@@ -1,16 +1,8 @@
import SwiftUI import SwiftUI
extension ConnectionsSettings { extension ConnectionsSettings {
private func channelStatus<T: Decodable>(
_ id: String,
as type: T.Type) -> T?
{
self.store.snapshot?.decodeChannel(id, as: type)
}
var whatsAppTint: Color { var whatsAppTint: Color {
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) guard let status = self.store.snapshot?.whatsapp else { return .secondary }
else { return .secondary }
if !status.configured { return .secondary } if !status.configured { return .secondary }
if !status.linked { return .red } if !status.linked { return .red }
if status.lastError != nil { return .orange } if status.lastError != nil { return .orange }
@@ -20,8 +12,7 @@ extension ConnectionsSettings {
} }
var telegramTint: Color { var telegramTint: Color {
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self) guard let status = self.store.snapshot?.telegram else { return .secondary }
else { return .secondary }
if !status.configured { return .secondary } if !status.configured { return .secondary }
if status.lastError != nil { return .orange } if status.lastError != nil { return .orange }
if status.probe?.ok == false { return .orange } if status.probe?.ok == false { return .orange }
@@ -30,8 +21,7 @@ extension ConnectionsSettings {
} }
var discordTint: Color { var discordTint: Color {
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self) guard let status = self.store.snapshot?.discord else { return .secondary }
else { return .secondary }
if !status.configured { return .secondary } if !status.configured { return .secondary }
if status.lastError != nil { return .orange } if status.lastError != nil { return .orange }
if status.probe?.ok == false { return .orange } if status.probe?.ok == false { return .orange }
@@ -40,8 +30,7 @@ extension ConnectionsSettings {
} }
var signalTint: Color { var signalTint: Color {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) guard let status = self.store.snapshot?.signal else { return .secondary }
else { return .secondary }
if !status.configured { return .secondary } if !status.configured { return .secondary }
if status.lastError != nil { return .orange } if status.lastError != nil { return .orange }
if status.probe?.ok == false { return .orange } if status.probe?.ok == false { return .orange }
@@ -50,8 +39,7 @@ extension ConnectionsSettings {
} }
var imessageTint: Color { var imessageTint: Color {
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self) guard let status = self.store.snapshot?.imessage else { return .secondary }
else { return .secondary }
if !status.configured { return .secondary } if !status.configured { return .secondary }
if status.lastError != nil { return .orange } if status.lastError != nil { return .orange }
if status.probe?.ok == false { return .orange } if status.probe?.ok == false { return .orange }
@@ -60,8 +48,7 @@ extension ConnectionsSettings {
} }
var whatsAppSummary: String { var whatsAppSummary: String {
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) guard let status = self.store.snapshot?.whatsapp else { return "Checking…" }
else { return "Checking…" }
if !status.linked { return "Not linked" } if !status.linked { return "Not linked" }
if status.connected { return "Connected" } if status.connected { return "Connected" }
if status.running { return "Running" } if status.running { return "Running" }
@@ -69,40 +56,35 @@ extension ConnectionsSettings {
} }
var telegramSummary: String { var telegramSummary: String {
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self) guard let status = self.store.snapshot?.telegram else { return "Checking…" }
else { return "Checking…" }
if !status.configured { return "Not configured" } if !status.configured { return "Not configured" }
if status.running { return "Running" } if status.running { return "Running" }
return "Configured" return "Configured"
} }
var discordSummary: String { var discordSummary: String {
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self) guard let status = self.store.snapshot?.discord else { return "Checking…" }
else { return "Checking…" }
if !status.configured { return "Not configured" } if !status.configured { return "Not configured" }
if status.running { return "Running" } if status.running { return "Running" }
return "Configured" return "Configured"
} }
var signalSummary: String { var signalSummary: String {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) guard let status = self.store.snapshot?.signal else { return "Checking…" }
else { return "Checking…" }
if !status.configured { return "Not configured" } if !status.configured { return "Not configured" }
if status.running { return "Running" } if status.running { return "Running" }
return "Configured" return "Configured"
} }
var imessageSummary: String { var imessageSummary: String {
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self) guard let status = self.store.snapshot?.imessage else { return "Checking…" }
else { return "Checking…" }
if !status.configured { return "Not configured" } if !status.configured { return "Not configured" }
if status.running { return "Running" } if status.running { return "Running" }
return "Configured" return "Configured"
} }
var whatsAppDetails: String? { var whatsAppDetails: String? {
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) guard let status = self.store.snapshot?.whatsapp else { return nil }
else { return nil }
var lines: [String] = [] var lines: [String] = []
if let e164 = status.`self`?.e164 ?? status.`self`?.jid { if let e164 = status.`self`?.e164 ?? status.`self`?.jid {
lines.append("Linked as \(e164)") lines.append("Linked as \(e164)")
@@ -132,8 +114,7 @@ extension ConnectionsSettings {
} }
var telegramDetails: String? { var telegramDetails: String? {
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self) guard let status = self.store.snapshot?.telegram else { return nil }
else { return nil }
var lines: [String] = [] var lines: [String] = []
if let source = status.tokenSource { if let source = status.tokenSource {
lines.append("Token source: \(source)") lines.append("Token source: \(source)")
@@ -164,8 +145,7 @@ extension ConnectionsSettings {
} }
var discordDetails: String? { var discordDetails: String? {
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self) guard let status = self.store.snapshot?.discord else { return nil }
else { return nil }
var lines: [String] = [] var lines: [String] = []
if let source = status.tokenSource { if let source = status.tokenSource {
lines.append("Token source: \(source)") lines.append("Token source: \(source)")
@@ -193,8 +173,7 @@ extension ConnectionsSettings {
} }
var signalDetails: String? { var signalDetails: String? {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) guard let status = self.store.snapshot?.signal else { return nil }
else { return nil }
var lines: [String] = [] var lines: [String] = []
lines.append("Base URL: \(status.baseUrl)") lines.append("Base URL: \(status.baseUrl)")
if let probe = status.probe { if let probe = status.probe {
@@ -220,8 +199,7 @@ extension ConnectionsSettings {
} }
var imessageDetails: String? { var imessageDetails: String? {
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self) guard let status = self.store.snapshot?.imessage else { return nil }
else { return nil }
var lines: [String] = [] var lines: [String] = []
if let cliPath = status.cliPath, !cliPath.isEmpty { if let cliPath = status.cliPath, !cliPath.isEmpty {
lines.append("CLI: \(cliPath)") lines.append("CLI: \(cliPath)")
@@ -243,68 +221,63 @@ extension ConnectionsSettings {
} }
var isTelegramTokenLocked: Bool { var isTelegramTokenLocked: Bool {
self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?.tokenSource == "env" self.store.snapshot?.telegram.tokenSource == "env"
} }
var isDiscordTokenLocked: Bool { var isDiscordTokenLocked: Bool {
self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?.tokenSource == "env" self.store.snapshot?.discord?.tokenSource == "env"
} }
var orderedChannels: [ConnectionChannel] { var orderedProviders: [ConnectionProvider] {
ConnectionChannel.allCases.sorted { lhs, rhs in ConnectionProvider.allCases.sorted { lhs, rhs in
let lhsEnabled = self.channelEnabled(lhs) let lhsEnabled = self.providerEnabled(lhs)
let rhsEnabled = self.channelEnabled(rhs) let rhsEnabled = self.providerEnabled(rhs)
if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled } if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled }
return lhs.sortOrder < rhs.sortOrder return lhs.sortOrder < rhs.sortOrder
} }
} }
var enabledChannels: [ConnectionChannel] { var enabledProviders: [ConnectionProvider] {
self.orderedChannels.filter { self.channelEnabled($0) } self.orderedProviders.filter { self.providerEnabled($0) }
} }
var availableChannels: [ConnectionChannel] { var availableProviders: [ConnectionProvider] {
self.orderedChannels.filter { !self.channelEnabled($0) } self.orderedProviders.filter { !self.providerEnabled($0) }
} }
func ensureSelection() { func ensureSelection() {
guard let selected = self.selectedChannel else { guard let selected = self.selectedProvider else {
self.selectedChannel = self.orderedChannels.first self.selectedProvider = self.orderedProviders.first
return return
} }
if !self.orderedChannels.contains(selected) { if !self.orderedProviders.contains(selected) {
self.selectedChannel = self.orderedChannels.first self.selectedProvider = self.orderedProviders.first
} }
} }
func channelEnabled(_ channel: ConnectionChannel) -> Bool { func providerEnabled(_ provider: ConnectionProvider) -> Bool {
switch channel { switch provider {
case .whatsapp: case .whatsapp:
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) guard let status = self.store.snapshot?.whatsapp else { return false }
else { return false }
return status.configured || status.linked || status.running return status.configured || status.linked || status.running
case .telegram: case .telegram:
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self) guard let status = self.store.snapshot?.telegram else { return false }
else { return false }
return status.configured || status.running return status.configured || status.running
case .discord: case .discord:
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self) guard let status = self.store.snapshot?.discord else { return false }
else { return false }
return status.configured || status.running return status.configured || status.running
case .signal: case .signal:
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) guard let status = self.store.snapshot?.signal else { return false }
else { return false }
return status.configured || status.running return status.configured || status.running
case .imessage: case .imessage:
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self) guard let status = self.store.snapshot?.imessage else { return false }
else { return false }
return status.configured || status.running return status.configured || status.running
} }
} }
@ViewBuilder @ViewBuilder
func channelSection(_ channel: ConnectionChannel) -> some View { func providerSection(_ provider: ConnectionProvider) -> some View {
switch channel { switch provider {
case .whatsapp: case .whatsapp:
self.whatsAppSection self.whatsAppSection
case .telegram: case .telegram:
@@ -318,8 +291,8 @@ extension ConnectionsSettings {
} }
} }
func channelTint(_ channel: ConnectionChannel) -> Color { func providerTint(_ provider: ConnectionProvider) -> Color {
switch channel { switch provider {
case .whatsapp: case .whatsapp:
self.whatsAppTint self.whatsAppTint
case .telegram: case .telegram:
@@ -333,8 +306,8 @@ extension ConnectionsSettings {
} }
} }
func channelSummary(_ channel: ConnectionChannel) -> String { func providerSummary(_ provider: ConnectionProvider) -> String {
switch channel { switch provider {
case .whatsapp: case .whatsapp:
self.whatsAppSummary self.whatsAppSummary
case .telegram: case .telegram:
@@ -348,8 +321,8 @@ extension ConnectionsSettings {
} }
} }
func channelDetails(_ channel: ConnectionChannel) -> String? { func providerDetails(_ provider: ConnectionProvider) -> String? {
switch channel { switch provider {
case .whatsapp: case .whatsapp:
self.whatsAppDetails self.whatsAppDetails
case .telegram: case .telegram:
@@ -363,56 +336,43 @@ extension ConnectionsSettings {
} }
} }
func channelLastCheckText(_ channel: ConnectionChannel) -> String { func providerLastCheckText(_ provider: ConnectionProvider) -> String {
guard let date = self.channelLastCheck(channel) else { return "never" } guard let date = self.providerLastCheck(provider) else { return "never" }
return relativeAge(from: date) return relativeAge(from: date)
} }
func channelLastCheck(_ channel: ConnectionChannel) -> Date? { func providerLastCheck(_ provider: ConnectionProvider) -> Date? {
switch channel { switch provider {
case .whatsapp: case .whatsapp:
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) guard let status = self.store.snapshot?.whatsapp else { return nil }
else { return nil }
return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt) return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt)
case .telegram: case .telegram:
return self return self.date(fromMs: self.store.snapshot?.telegram.lastProbeAt)
.date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
.lastProbeAt)
case .discord: case .discord:
return self return self.date(fromMs: self.store.snapshot?.discord?.lastProbeAt)
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.lastProbeAt)
case .signal: case .signal:
return self return self.date(fromMs: self.store.snapshot?.signal?.lastProbeAt)
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
case .imessage: case .imessage:
return self return self.date(fromMs: self.store.snapshot?.imessage?.lastProbeAt)
.date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)?
.lastProbeAt)
} }
} }
func channelHasError(_ channel: ConnectionChannel) -> Bool { func providerHasError(_ provider: ConnectionProvider) -> Bool {
switch channel { switch provider {
case .whatsapp: case .whatsapp:
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) guard let status = self.store.snapshot?.whatsapp else { return false }
else { return false }
return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true
case .telegram: case .telegram:
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self) guard let status = self.store.snapshot?.telegram else { return false }
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false return status.lastError?.isEmpty == false || status.probe?.ok == false
case .discord: case .discord:
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self) guard let status = self.store.snapshot?.discord else { return false }
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false return status.lastError?.isEmpty == false || status.probe?.ok == false
case .signal: case .signal:
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) guard let status = self.store.snapshot?.signal else { return false }
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false return status.lastError?.isEmpty == false || status.probe?.ok == false
case .imessage: case .imessage:
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self) guard let status = self.store.snapshot?.imessage else { return false }
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false return status.lastError?.isEmpty == false || status.probe?.ok == false
} }
} }
@@ -11,7 +11,7 @@ extension ConnectionsSettings {
self.store.start() self.store.start()
self.ensureSelection() self.ensureSelection()
} }
.onChange(of: self.orderedChannels) { _, _ in .onChange(of: self.orderedProviders) { _, _ in
self.ensureSelection() self.ensureSelection()
} }
.onDisappear { self.store.stop() } .onDisappear { self.store.stop() }
@@ -20,17 +20,17 @@ extension ConnectionsSettings {
private var sidebar: some View { private var sidebar: some View {
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 8) { LazyVStack(alignment: .leading, spacing: 8) {
if !self.enabledChannels.isEmpty { if !self.enabledProviders.isEmpty {
self.sidebarSectionHeader("Configured") self.sidebarSectionHeader("Configured")
ForEach(self.enabledChannels) { channel in ForEach(self.enabledProviders) { provider in
self.sidebarRow(channel) self.sidebarRow(provider)
} }
} }
if !self.availableChannels.isEmpty { if !self.availableProviders.isEmpty {
self.sidebarSectionHeader("Available") self.sidebarSectionHeader("Available")
ForEach(self.availableChannels) { channel in ForEach(self.availableProviders) { provider in
self.sidebarRow(channel) self.sidebarRow(provider)
} }
} }
} }
@@ -46,8 +46,8 @@ extension ConnectionsSettings {
private var detail: some View { private var detail: some View {
Group { Group {
if let channel = self.selectedChannel { if let provider = self.selectedProvider {
self.channelDetail(channel) self.providerDetail(provider)
} else { } else {
self.emptyDetail self.emptyDetail
} }
@@ -59,7 +59,7 @@ extension ConnectionsSettings {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Connections") Text("Connections")
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
Text("Select a channel to view status and settings.") Text("Select a provider to view status and settings.")
.font(.callout) .font(.callout)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -67,12 +67,12 @@ extension ConnectionsSettings {
.padding(.vertical, 18) .padding(.vertical, 18)
} }
private func channelDetail(_ channel: ConnectionChannel) -> some View { private func providerDetail(_ provider: ConnectionProvider) -> some View {
ScrollView(.vertical) { ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
self.detailHeader(for: channel) self.detailHeader(for: provider)
Divider() Divider()
self.channelSection(channel) self.providerSection(provider)
Spacer(minLength: 0) Spacer(minLength: 0)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -81,18 +81,18 @@ extension ConnectionsSettings {
} }
} }
private func sidebarRow(_ channel: ConnectionChannel) -> some View { private func sidebarRow(_ provider: ConnectionProvider) -> some View {
let isSelected = self.selectedChannel == channel let isSelected = self.selectedProvider == provider
return Button { return Button {
self.selectedChannel = channel self.selectedProvider = provider
} label: { } label: {
HStack(spacing: 8) { HStack(spacing: 8) {
Circle() Circle()
.fill(self.channelTint(channel)) .fill(self.providerTint(provider))
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(channel.title) Text(provider.title)
Text(self.channelSummary(channel)) Text(self.providerSummary(provider))
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -119,23 +119,23 @@ extension ConnectionsSettings {
.padding(.top, 2) .padding(.top, 2)
} }
private func detailHeader(for channel: ConnectionChannel) -> some View { private func detailHeader(for provider: ConnectionProvider) -> some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 10) { HStack(alignment: .firstTextBaseline, spacing: 10) {
Label(channel.detailTitle, systemImage: channel.systemImage) Label(provider.detailTitle, systemImage: provider.systemImage)
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
self.statusBadge( self.statusBadge(
self.channelSummary(channel), self.providerSummary(provider),
color: self.channelTint(channel)) color: self.providerTint(provider))
Spacer() Spacer()
self.channelHeaderActions(channel) self.providerHeaderActions(provider)
} }
HStack(spacing: 10) { HStack(spacing: 10) {
Text("Last check \(self.channelLastCheckText(channel))") Text("Last check \(self.providerLastCheckText(provider))")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if self.channelHasError(channel) { if self.providerHasError(provider) {
Text("Error") Text("Error")
.font(.caption2.weight(.semibold)) .font(.caption2.weight(.semibold))
.padding(.horizontal, 6) .padding(.horizontal, 6)
@@ -146,7 +146,7 @@ extension ConnectionsSettings {
} }
} }
if let details = self.channelDetails(channel) { if let details = self.providerDetails(provider) {
Text(details) Text(details)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -2,7 +2,7 @@ import AppKit
import SwiftUI import SwiftUI
struct ConnectionsSettings: View { struct ConnectionsSettings: View {
enum ConnectionChannel: String, CaseIterable, Identifiable, Hashable { enum ConnectionProvider: String, CaseIterable, Identifiable, Hashable {
case whatsapp case whatsapp
case telegram case telegram
case discord case discord
@@ -53,7 +53,7 @@ struct ConnectionsSettings: View {
} }
@Bindable var store: ConnectionsStore @Bindable var store: ConnectionsStore
@State var selectedChannel: ConnectionChannel? @State var selectedProvider: ConnectionProvider?
@State var showTelegramToken = false @State var showTelegramToken = false
@State var showDiscordToken = false @State var showDiscordToken = false
@@ -31,8 +31,8 @@ extension ConnectionsStore {
"probe": AnyCodable(probe), "probe": AnyCodable(probe),
"timeoutMs": AnyCodable(8000), "timeoutMs": AnyCodable(8000),
] ]
let snap: ChannelsStatusSnapshot = try await GatewayConnection.shared.requestDecoded( let snap: ProvidersStatusSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .channelsStatus, method: .providersStatus,
params: params, params: params,
timeoutMs: 12000) timeoutMs: 12000)
self.snapshot = snap self.snapshot = snap
@@ -100,12 +100,9 @@ extension ConnectionsStore {
self.whatsappBusy = true self.whatsappBusy = true
defer { self.whatsappBusy = false } defer { self.whatsappBusy = false }
do { do {
let params: [String: AnyCodable] = [ let result: WhatsAppLogoutResult = try await GatewayConnection.shared.requestDecoded(
"channel": AnyCodable("whatsapp"), method: .webLogout,
] params: nil,
let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded(
method: .channelsLogout,
params: params,
timeoutMs: 15000) timeoutMs: 15000)
self.whatsappLoginMessage = result.cleared self.whatsappLoginMessage = result.cleared
? "Logged out and cleared credentials." ? "Logged out and cleared credentials."
@@ -122,12 +119,9 @@ extension ConnectionsStore {
self.telegramBusy = true self.telegramBusy = true
defer { self.telegramBusy = false } defer { self.telegramBusy = false }
do { do {
let params: [String: AnyCodable] = [ let result: TelegramLogoutResult = try await GatewayConnection.shared.requestDecoded(
"channel": AnyCodable("telegram"), method: .telegramLogout,
] params: nil,
let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded(
method: .channelsLogout,
params: params,
timeoutMs: 15000) timeoutMs: 15000)
if result.envToken == true { if result.envToken == true {
self.configStatus = "Telegram token still set via env; config cleared." self.configStatus = "Telegram token still set via env; config cleared."
@@ -154,9 +148,11 @@ private struct WhatsAppLoginWaitResult: Codable {
let message: String let message: String
} }
private struct ChannelLogoutResult: Codable { private struct WhatsAppLogoutResult: Codable {
let channel: String? let cleared: Bool
let accountId: String? }
private struct TelegramLogoutResult: Codable {
let cleared: Bool let cleared: Bool
let envToken: Bool? let envToken: Bool?
} }
@@ -2,7 +2,7 @@ import ClawdbotProtocol
import Foundation import Foundation
import Observation import Observation
struct ChannelsStatusSnapshot: Codable { struct ProvidersStatusSnapshot: Codable {
struct WhatsAppSelf: Codable { struct WhatsAppSelf: Codable {
let e164: String? let e164: String?
let jid: String? let jid: String?
@@ -121,54 +121,12 @@ struct ChannelsStatusSnapshot: Codable {
let lastProbeAt: Double? let lastProbeAt: Double?
} }
struct ChannelAccountSnapshot: Codable {
let accountId: String
let name: String?
let enabled: Bool?
let configured: Bool?
let linked: Bool?
let running: Bool?
let connected: Bool?
let reconnectAttempts: Int?
let lastConnectedAt: Double?
let lastError: String?
let lastStartAt: Double?
let lastStopAt: Double?
let lastInboundAt: Double?
let lastOutboundAt: Double?
let lastProbeAt: Double?
let mode: String?
let dmPolicy: String?
let allowFrom: [String]?
let tokenSource: String?
let botTokenSource: String?
let appTokenSource: String?
let baseUrl: String?
let allowUnmentionedGroups: Bool?
let cliPath: String?
let dbPath: String?
let port: Int?
let probe: AnyCodable?
let audit: AnyCodable?
let application: AnyCodable?
}
let ts: Double let ts: Double
let channelOrder: [String] let whatsapp: WhatsAppStatus
let channelLabels: [String: String] let telegram: TelegramStatus
let channels: [String: AnyCodable] let discord: DiscordStatus?
let channelAccounts: [String: [ChannelAccountSnapshot]] let signal: SignalStatus?
let channelDefaultAccountId: [String: String] let imessage: IMessageStatus?
func decodeChannel<T: Decodable>(_ id: String, as type: T.Type) -> T? {
guard let value = self.channels[id] else { return nil }
do {
let data = try JSONEncoder().encode(value)
return try JSONDecoder().decode(type, from: data)
} catch {
return nil
}
}
} }
struct ConfigSnapshot: Codable { struct ConfigSnapshot: Codable {
@@ -230,7 +188,7 @@ struct DiscordGuildForm: Identifiable {
final class ConnectionsStore { final class ConnectionsStore {
static let shared = ConnectionsStore() static let shared = ConnectionsStore()
var snapshot: ChannelsStatusSnapshot? var snapshot: ProvidersStatusSnapshot?
var lastError: String? var lastError: String?
var lastSuccess: Date? var lastSuccess: Date?
var isRefreshing = false var isRefreshing = false
+2 -1
View File
@@ -32,8 +32,9 @@ let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled"
let deepLinkKeyKey = "clawdbot.deepLinkKey" let deepLinkKeyKey = "clawdbot.deepLinkKey"
let modelCatalogPathKey = "clawdbot.modelCatalogPath" let modelCatalogPathKey = "clawdbot.modelCatalogPath"
let modelCatalogReloadKey = "clawdbot.modelCatalogReload" let modelCatalogReloadKey = "clawdbot.modelCatalogReload"
let cliInstallPromptedVersionKey = "clawdbot.cliInstallPromptedVersion" let attachExistingGatewayOnlyKey = "clawdbot.gateway.attachExistingOnly"
let heartbeatsEnabledKey = "clawdbot.heartbeatsEnabled" let heartbeatsEnabledKey = "clawdbot.heartbeatsEnabled"
let debugFileLogEnabledKey = "clawdbot.debug.fileLogEnabled" let debugFileLogEnabledKey = "clawdbot.debug.fileLogEnabled"
let appLogLevelKey = "clawdbot.debug.appLogLevel" let appLogLevelKey = "clawdbot.debug.appLogLevel"
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
let cliHelperSearchPaths = ["/usr/local/bin", "/opt/homebrew/bin"]
@@ -108,7 +108,6 @@ final class ControlChannel {
self.logger.info( self.logger.info(
"control channel configure mode=remote " + "control channel configure mode=remote " +
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)") "target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
self.state = .connecting
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
await self.configure() await self.configure()
} catch { } catch {
@@ -183,7 +182,7 @@ final class ControlChannel {
{ {
let reason = urlErr.failureURLString ?? urlErr.localizedDescription let reason = urlErr.failureURLString ?? urlErr.localizedDescription
return 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. " + "or clear it on the gateway. " +
"Reason: \(reason)" "Reason: \(reason)"
} }
@@ -212,6 +211,12 @@ final class ControlChannel {
return "Gateway connection was closed; start the gateway (localhost:\(port)) and retry." return "Gateway connection was closed; start the gateway (localhost:\(port)) and retry."
case .cannotFindHost, .cannotConnectToHost: case .cannotFindHost, .cannotConnectToHost:
let isRemote = CommandResolver.connectionModeIsRemote() let isRemote = CommandResolver.connectionModeIsRemote()
if AppStateStore.attachExistingGatewayOnly, !isRemote {
return """
Cannot reach gateway at localhost:\(port) and “Attach existing gateway only” is enabled.
Disable it in Debug Settings or start a gateway on that port.
"""
}
if isRemote { if isRemote {
return """ return """
Cannot reach gateway at localhost:\(port). Cannot reach gateway at localhost:\(port).
@@ -13,9 +13,7 @@ extension CronJobEditor {
guard let job else { return } guard let job else { return }
self.name = job.name self.name = job.name
self.description = job.description ?? "" self.description = job.description ?? ""
self.agentId = job.agentId ?? ""
self.enabled = job.enabled self.enabled = job.enabled
self.deleteAfterRun = job.deleteAfterRun ?? false
self.sessionTarget = job.sessionTarget self.sessionTarget = job.sessionTarget
self.wakeMode = job.wakeMode self.wakeMode = job.wakeMode
@@ -36,13 +34,13 @@ extension CronJobEditor {
case let .systemEvent(text): case let .systemEvent(text):
self.payloadKind = .systemEvent self.payloadKind = .systemEvent
self.systemEventText = text self.systemEventText = text
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver): case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver):
self.payloadKind = .agentTurn self.payloadKind = .agentTurn
self.agentMessage = message self.agentMessage = message
self.thinking = thinking ?? "" self.thinking = thinking ?? ""
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? "" self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
self.deliver = deliver ?? false self.deliver = deliver ?? false
self.channel = GatewayAgentChannel(raw: channel) self.provider = GatewayAgentProvider(raw: provider)
self.to = to ?? "" self.to = to ?? ""
self.bestEffortDeliver = bestEffortDeliver ?? false self.bestEffortDeliver = bestEffortDeliver ?? false
} }
@@ -61,60 +59,18 @@ extension CronJobEditor {
} }
func buildPayload() throws -> [String: AnyCodable] { func buildPayload() throws -> [String: AnyCodable] {
let name = try self.requireName() let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
let description = self.trimmed(self.description)
let agentId = self.trimmed(self.agentId)
let schedule = try self.buildSchedule()
let payload = try self.buildSelectedPayload()
try self.validateSessionTarget(payload)
try self.validatePayloadRequiredFields(payload)
var root: [String: Any] = [
"name": name,
"enabled": self.enabled,
"schedule": schedule,
"sessionTarget": self.sessionTarget.rawValue,
"wakeMode": self.wakeMode.rawValue,
"payload": payload,
]
self.applyDeleteAfterRun(to: &root)
if !description.isEmpty { root["description"] = description }
if !agentId.isEmpty {
root["agentId"] = agentId
} else if self.job?.agentId != nil {
root["agentId"] = NSNull()
}
if self.sessionTarget == .isolated {
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
root["isolation"] = [
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
]
}
return root.mapValues { AnyCodable($0) }
}
func trimmed(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines)
}
func requireName() throws -> String {
let name = self.trimmed(self.name)
if name.isEmpty { if name.isEmpty {
throw NSError( throw NSError(
domain: "Cron", domain: "Cron",
code: 0, code: 0,
userInfo: [NSLocalizedDescriptionKey: "Name is required."]) userInfo: [NSLocalizedDescriptionKey: "Name is required."])
} }
return name let description = self.description.trimmingCharacters(in: .whitespacesAndNewlines)
} let schedule: [String: Any]
func buildSchedule() throws -> [String: Any] {
switch self.scheduleKind { switch self.scheduleKind {
case .at: case .at:
return ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)] schedule = ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
case .every: case .every:
guard let ms = Self.parseDurationMs(self.everyText) else { guard let ms = Self.parseDurationMs(self.everyText) else {
throw NSError( throw NSError(
@@ -122,35 +78,34 @@ extension CronJobEditor {
code: 0, code: 0,
userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."]) userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."])
} }
return ["kind": "every", "everyMs": ms] schedule = ["kind": "every", "everyMs": ms]
case .cron: case .cron:
let expr = self.trimmed(self.cronExpr) let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines)
if expr.isEmpty { if expr.isEmpty {
throw NSError( throw NSError(
domain: "Cron", domain: "Cron",
code: 0, code: 0,
userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."]) userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
} }
let tz = self.trimmed(self.cronTz) let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines)
if tz.isEmpty { if tz.isEmpty {
return ["kind": "cron", "expr": expr] schedule = ["kind": "cron", "expr": expr]
} else {
schedule = ["kind": "cron", "expr": expr, "tz": tz]
} }
return ["kind": "cron", "expr": expr, "tz": tz]
} }
}
func buildSelectedPayload() throws -> [String: Any] { let payload: [String: Any] = {
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() } if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
switch self.payloadKind { switch self.payloadKind {
case .systemEvent: case .systemEvent:
let text = self.trimmed(self.systemEventText) let text = self.systemEventText.trimmingCharacters(in: .whitespacesAndNewlines)
return ["kind": "systemEvent", "text": text] return ["kind": "systemEvent", "text": text]
case .agentTurn: case .agentTurn:
return self.buildAgentTurnPayload() return self.buildAgentTurnPayload()
} }
} }()
func validateSessionTarget(_ payload: [String: Any]) throws {
if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" { if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" {
throw NSError( throw NSError(
domain: "Cron", domain: "Cron",
@@ -167,9 +122,7 @@ extension CronJobEditor {
code: 0, code: 0,
userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."]) userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."])
} }
}
func validatePayloadRequiredFields(_ payload: [String: Any]) throws {
if payload["kind"] as? String == "systemEvent" { if payload["kind"] as? String == "systemEvent" {
if (payload["text"] as? String ?? "").isEmpty { if (payload["text"] as? String ?? "").isEmpty {
throw NSError( throw NSError(
@@ -177,8 +130,7 @@ extension CronJobEditor {
code: 0, code: 0,
userInfo: [NSLocalizedDescriptionKey: "System event text is required."]) userInfo: [NSLocalizedDescriptionKey: "System event text is required."])
} }
} } else if payload["kind"] as? String == "agentTurn" {
if payload["kind"] as? String == "agentTurn" {
if (payload["message"] as? String ?? "").isEmpty { if (payload["message"] as? String ?? "").isEmpty {
throw NSError( throw NSError(
domain: "Cron", domain: "Cron",
@@ -186,14 +138,25 @@ extension CronJobEditor {
userInfo: [NSLocalizedDescriptionKey: "Agent message is required."]) userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
} }
} }
}
func applyDeleteAfterRun(to root: inout [String: Any]) { var root: [String: Any] = [
if self.scheduleKind == .at { "name": name,
root["deleteAfterRun"] = self.deleteAfterRun "enabled": self.enabled,
} else if self.job?.deleteAfterRun != nil { "schedule": schedule,
root["deleteAfterRun"] = false "sessionTarget": self.sessionTarget.rawValue,
"wakeMode": self.wakeMode.rawValue,
"payload": payload,
]
if !description.isEmpty { root["description"] = description }
if self.sessionTarget == .isolated {
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
root["isolation"] = [
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
]
} }
return root.mapValues { AnyCodable($0) }
} }
func buildAgentTurnPayload() -> [String: Any] { func buildAgentTurnPayload() -> [String: Any] {
@@ -204,7 +167,7 @@ extension CronJobEditor {
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n } if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
payload["deliver"] = self.deliver payload["deliver"] = self.deliver
if self.deliver { if self.deliver {
payload["channel"] = self.channel.rawValue payload["provider"] = self.provider.rawValue
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines) let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { payload["to"] = to } if !to.isEmpty { payload["to"] = to }
payload["bestEffortDeliver"] = self.bestEffortDeliver payload["bestEffortDeliver"] = self.bestEffortDeliver
@@ -3,7 +3,6 @@ extension CronJobEditor {
mutating func exerciseForTesting() { mutating func exerciseForTesting() {
self.name = "Test job" self.name = "Test job"
self.description = "Test description" self.description = "Test description"
self.agentId = "ops"
self.enabled = true self.enabled = true
self.sessionTarget = .isolated self.sessionTarget = .isolated
self.wakeMode = .now self.wakeMode = .now
@@ -14,7 +13,7 @@ extension CronJobEditor {
self.payloadKind = .agentTurn self.payloadKind = .agentTurn
self.agentMessage = "Run diagnostic" self.agentMessage = "Run diagnostic"
self.deliver = true self.deliver = true
self.channel = .last self.provider = .last
self.to = "+15551230000" self.to = "+15551230000"
self.thinking = "low" self.thinking = "low"
self.timeoutSeconds = "90" self.timeoutSeconds = "90"
+12 -25
View File
@@ -18,7 +18,7 @@ struct CronJobEditor: View {
static let scheduleKindNote = static let scheduleKindNote =
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression." "“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
static let isolatedPayloadNote = static let isolatedPayloadNote =
"Isolated jobs always run an agent turn. The result can be delivered to a channel, " "Isolated jobs always run an agent turn. The result can be delivered to a provider, "
+ "and a short summary is posted back to your main chat." + "and a short summary is posted back to your main chat."
static let mainPayloadNote = static let mainPayloadNote =
"System events are injected into the current main session. Agent turns require an isolated session target." "System events are injected into the current main session. Agent turns require an isolated session target."
@@ -27,11 +27,9 @@ struct CronJobEditor: View {
@State var name: String = "" @State var name: String = ""
@State var description: String = "" @State var description: String = ""
@State var agentId: String = ""
@State var enabled: Bool = true @State var enabled: Bool = true
@State var sessionTarget: CronSessionTarget = .main @State var sessionTarget: CronSessionTarget = .main
@State var wakeMode: CronWakeMode = .nextHeartbeat @State var wakeMode: CronWakeMode = .nextHeartbeat
@State var deleteAfterRun: Bool = false
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } } enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
@State var scheduleKind: ScheduleKind = .every @State var scheduleKind: ScheduleKind = .every
@@ -45,7 +43,7 @@ struct CronJobEditor: View {
@State var systemEventText: String = "" @State var systemEventText: String = ""
@State var agentMessage: String = "" @State var agentMessage: String = ""
@State var deliver: Bool = false @State var deliver: Bool = false
@State var channel: GatewayAgentChannel = .last @State var provider: GatewayAgentProvider = .last
@State var to: String = "" @State var to: String = ""
@State var thinking: String = "" @State var thinking: String = ""
@State var timeoutSeconds: String = "" @State var timeoutSeconds: String = ""
@@ -79,12 +77,6 @@ struct CronJobEditor: View {
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
GridRow {
self.gridLabel("Agent ID")
TextField("Optional (default agent)", text: self.$agentId)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow { GridRow {
self.gridLabel("Enabled") self.gridLabel("Enabled")
Toggle("", isOn: self.$enabled) Toggle("", isOn: self.$enabled)
@@ -157,11 +149,6 @@ struct CronJobEditor: View {
.labelsHidden() .labelsHidden()
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
GridRow {
self.gridLabel("Auto-delete")
Toggle("Delete after successful run", isOn: self.$deleteAfterRun)
.toggleStyle(.switch)
}
case .every: case .every:
GridRow { GridRow {
self.gridLabel("Every") self.gridLabel("Every")
@@ -323,7 +310,7 @@ struct CronJobEditor: View {
} }
GridRow { GridRow {
self.gridLabel("Deliver") self.gridLabel("Deliver")
Toggle("Deliver result to a channel", isOn: self.$deliver) Toggle("Deliver result to a provider", isOn: self.$deliver)
.toggleStyle(.switch) .toggleStyle(.switch)
} }
} }
@@ -331,15 +318,15 @@ struct CronJobEditor: View {
if self.deliver { if self.deliver {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow { GridRow {
self.gridLabel("Channel") self.gridLabel("Provider")
Picker("", selection: self.$channel) { Picker("", selection: self.$provider) {
Text("last").tag(GatewayAgentChannel.last) Text("last").tag(GatewayAgentProvider.last)
Text("whatsapp").tag(GatewayAgentChannel.whatsapp) Text("whatsapp").tag(GatewayAgentProvider.whatsapp)
Text("telegram").tag(GatewayAgentChannel.telegram) Text("telegram").tag(GatewayAgentProvider.telegram)
Text("discord").tag(GatewayAgentChannel.discord) Text("discord").tag(GatewayAgentProvider.discord)
Text("slack").tag(GatewayAgentChannel.slack) Text("slack").tag(GatewayAgentProvider.slack)
Text("signal").tag(GatewayAgentChannel.signal) Text("signal").tag(GatewayAgentProvider.signal)
Text("imessage").tag(GatewayAgentChannel.imessage) Text("imessage").tag(GatewayAgentProvider.imessage)
} }
.labelsHidden() .labelsHidden()
.pickerStyle(.segmented) .pickerStyle(.segmented)
+5 -8
View File
@@ -74,12 +74,12 @@ enum CronPayload: Codable, Equatable {
thinking: String?, thinking: String?,
timeoutSeconds: Int?, timeoutSeconds: Int?,
deliver: Bool?, deliver: Bool?,
channel: String?, provider: String?,
to: String?, to: String?,
bestEffortDeliver: Bool?) bestEffortDeliver: Bool?)
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver case kind, text, message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver
} }
var kind: String { var kind: String {
@@ -101,8 +101,7 @@ enum CronPayload: Codable, Equatable {
thinking: container.decodeIfPresent(String.self, forKey: .thinking), thinking: container.decodeIfPresent(String.self, forKey: .thinking),
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds), timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver), deliver: container.decodeIfPresent(Bool.self, forKey: .deliver),
channel: container.decodeIfPresent(String.self, forKey: .channel) provider: container.decodeIfPresent(String.self, forKey: .provider),
?? container.decodeIfPresent(String.self, forKey: .provider),
to: container.decodeIfPresent(String.self, forKey: .to), to: container.decodeIfPresent(String.self, forKey: .to),
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver)) bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
default: default:
@@ -119,12 +118,12 @@ enum CronPayload: Codable, Equatable {
switch self { switch self {
case let .systemEvent(text): case let .systemEvent(text):
try container.encode(text, forKey: .text) try container.encode(text, forKey: .text)
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver): case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver):
try container.encode(message, forKey: .message) try container.encode(message, forKey: .message)
try container.encodeIfPresent(thinking, forKey: .thinking) try container.encodeIfPresent(thinking, forKey: .thinking)
try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds) try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
try container.encodeIfPresent(deliver, forKey: .deliver) try container.encodeIfPresent(deliver, forKey: .deliver)
try container.encodeIfPresent(channel, forKey: .channel) try container.encodeIfPresent(provider, forKey: .provider)
try container.encodeIfPresent(to, forKey: .to) try container.encodeIfPresent(to, forKey: .to)
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver) try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
} }
@@ -146,11 +145,9 @@ struct CronJobState: Codable, Equatable {
struct CronJob: Identifiable, Codable, Equatable { struct CronJob: Identifiable, Codable, Equatable {
let id: String let id: String
let agentId: String?
var name: String var name: String
var description: String? var description: String?
var enabled: Bool var enabled: Bool
var deleteAfterRun: Bool?
let createdAtMs: Int let createdAtMs: Int
let updatedAtMs: Int let updatedAtMs: Int
let schedule: CronSchedule let schedule: CronSchedule
@@ -20,9 +20,6 @@ extension CronSettings {
HStack(spacing: 6) { HStack(spacing: 6) {
StatusPill(text: job.sessionTarget.rawValue, tint: .secondary) StatusPill(text: job.sessionTarget.rawValue, tint: .secondary)
StatusPill(text: job.wakeMode.rawValue, tint: .secondary) StatusPill(text: job.wakeMode.rawValue, tint: .secondary)
if let agentId = job.agentId, !agentId.isEmpty {
StatusPill(text: "agent \(agentId)", tint: .secondary)
}
if let status = job.state.lastStatus { if let status = job.state.lastStatus {
StatusPill(text: status, tint: status == "ok" ? .green : .orange) StatusPill(text: status, tint: status == "ok" ? .green : .orange)
} }
@@ -94,15 +91,9 @@ extension CronSettings {
func detailCard(_ job: CronJob) -> some View { func detailCard(_ job: CronJob) -> some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) } LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) }
if case .at = job.schedule, job.deleteAfterRun == true {
LabeledContent("Auto-delete") { Text("after success") }
}
if let desc = job.description, !desc.isEmpty { if let desc = job.description, !desc.isEmpty {
LabeledContent("Description") { Text(desc).font(.callout) } LabeledContent("Description") { Text(desc).font(.callout) }
} }
if let agentId = job.agentId, !agentId.isEmpty {
LabeledContent("Agent") { Text(agentId) }
}
LabeledContent("Session") { Text(job.sessionTarget.rawValue) } LabeledContent("Session") { Text(job.sessionTarget.rawValue) }
LabeledContent("Wake") { Text(job.wakeMode.rawValue) } LabeledContent("Wake") { Text(job.wakeMode.rawValue) }
LabeledContent("Next run") { LabeledContent("Next run") {
@@ -7,11 +7,9 @@ struct CronSettings_Previews: PreviewProvider {
store.jobs = [ store.jobs = [
CronJob( CronJob(
id: "job-1", id: "job-1",
agentId: "ops",
name: "Daily summary", name: "Daily summary",
description: nil, description: nil,
enabled: true, enabled: true,
deleteAfterRun: nil,
createdAtMs: 0, createdAtMs: 0,
updatedAtMs: 0, updatedAtMs: 0,
schedule: .every(everyMs: 86_400_000, anchorMs: nil), schedule: .every(everyMs: 86_400_000, anchorMs: nil),
@@ -22,7 +20,7 @@ struct CronSettings_Previews: PreviewProvider {
thinking: "low", thinking: "low",
timeoutSeconds: 600, timeoutSeconds: 600,
deliver: true, deliver: true,
channel: "last", provider: "last",
to: nil, to: nil,
bestEffortDeliver: true), bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"), isolation: CronIsolation(postToMainPrefix: "Cron"),
@@ -61,11 +59,9 @@ extension CronSettings {
let job = CronJob( let job = CronJob(
id: "job-1", id: "job-1",
agentId: "ops",
name: "Daily summary", name: "Daily summary",
description: "Summary job", description: "Summary job",
enabled: true, enabled: true,
deleteAfterRun: nil,
createdAtMs: 1_700_000_000_000, createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_100_000, updatedAtMs: 1_700_000_100_000,
schedule: .cron(expr: "0 8 * * *", tz: "UTC"), schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
@@ -76,7 +72,7 @@ extension CronSettings {
thinking: "low", thinking: "low",
timeoutSeconds: 120, timeoutSeconds: 120,
deliver: true, deliver: true,
channel: "whatsapp", provider: "whatsapp",
to: "+15551234567", to: "+15551234567",
bestEffortDeliver: true), bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "), isolation: CronIsolation(postToMainPrefix: "[cron] "),
@@ -5,7 +5,6 @@ import SwiftUI
enum DebugActions { enum DebugActions {
private static let verboseDefaultsKey = "clawdbot.debug.verboseMain" private static let verboseDefaultsKey = "clawdbot.debug.verboseMain"
private static let sessionMenuLimit = 12 private static let sessionMenuLimit = 12
private static let onboardingSeenKey = "clawdbot.onboardingSeen"
@MainActor @MainActor
static func openAgentEventsWindow() { static func openAgentEventsWindow() {
@@ -184,14 +183,6 @@ enum DebugActions {
NSApp.terminate(nil) 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 @MainActor
private static func resolveSessionStorePath() -> String { private static func resolveSessionStorePath() -> String {
let defaultPath = SessionLoader.defaultStorePath let defaultPath = SessionLoader.defaultStorePath
@@ -28,6 +28,7 @@ struct DebugSettings: View {
@State private var tunnelResetInFlight = false @State private var tunnelResetInFlight = false
@State private var tunnelResetStatus: String? @State private var tunnelResetStatus: String?
@State private var pendingKill: DebugActions.PortListener? @State private var pendingKill: DebugActions.PortListener?
@AppStorage(attachExistingGatewayOnlyKey) private var attachExistingGatewayOnly: Bool = false
@AppStorage(debugFileLogEnabledKey) private var diagnosticsFileLogEnabled: Bool = false @AppStorage(debugFileLogEnabledKey) private var diagnosticsFileLogEnabled: Bool = false
@AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue @AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue
@@ -107,7 +108,7 @@ struct DebugSettings: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
GridRow { GridRow {
self.gridLabel("CLI") self.gridLabel("CLI helper")
let loc = CLIInstaller.installedLocation() let loc = CLIInstaller.installedLocation()
Text(loc ?? "missing") Text(loc ?? "missing")
.font(.caption.monospaced()) .font(.caption.monospaced())
@@ -144,6 +145,16 @@ struct DebugSettings: View {
} }
.frame(maxWidth: .infinity, alignment: .leading) .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() let key = DeepLinkHandler.currentKey()
@@ -486,7 +497,6 @@ struct DebugSettings: View {
HStack(spacing: 8) { HStack(spacing: 8) {
Button("Restart app") { DebugActions.restartApp() } Button("Restart app") { DebugActions.restartApp() }
Button("Restart onboarding") { DebugActions.restartOnboarding() }
Button("Reveal app in Finder") { self.revealApp() } Button("Reveal app in Finder") { self.revealApp() }
Spacer(minLength: 0) Spacer(minLength: 0)
} }
@@ -772,7 +782,7 @@ struct DebugSettings: View {
} }
private var canRestartGateway: Bool { private var canRestartGateway: Bool {
self.state.connectionMode == .local self.state.connectionMode == .local && !self.attachExistingGatewayOnly
} }
private func configURL() -> URL { private func configURL() -> URL {
+3 -3
View File
@@ -59,7 +59,7 @@ final class DeepLinkHandler {
} }
do { do {
let channel = GatewayAgentChannel(raw: link.channel) let provider = GatewayAgentProvider(raw: link.channel)
let explicitSessionKey = link.sessionKey? let explicitSessionKey = link.sessionKey?
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty .nonEmpty
@@ -72,9 +72,9 @@ final class DeepLinkHandler {
message: messagePreview, message: messagePreview,
sessionKey: resolvedSessionKey, sessionKey: resolvedSessionKey,
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
deliver: channel.shouldDeliver(link.deliver), deliver: provider.shouldDeliver(link.deliver),
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
channel: channel, provider: provider,
timeoutSeconds: link.timeoutSeconds, timeoutSeconds: link.timeoutSeconds,
idempotencyKey: UUID().uuidString) idempotencyKey: UUID().uuidString)
@@ -0,0 +1,26 @@
import Foundation
enum GatewayAgentChannel: String, CaseIterable, Sendable {
case last
case webchat
case whatsapp
case telegram
init(raw: String?) {
let trimmed = raw?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased() ?? ""
self = GatewayAgentChannel(rawValue: trimmed) ?? .last
}
func shouldDeliver(_ isLast: Bool) -> Bool {
switch self {
case .webchat:
false
case .last:
isLast
case .whatsapp, .telegram:
true
}
}
}
@@ -7,8 +7,9 @@ enum GatewayAutostartPolicy {
static func shouldEnsureLaunchAgent( static func shouldEnsureLaunchAgent(
mode: AppState.ConnectionMode, mode: AppState.ConnectionMode,
paused: Bool) -> Bool paused: Bool,
attachExistingOnly: Bool) -> Bool
{ {
self.shouldStartGateway(mode: mode, paused: paused) self.shouldStartGateway(mode: mode, paused: paused) && !attachExistingOnly
} }
} }
@@ -1,4 +1,3 @@
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
import OSLog import OSLog
@@ -76,7 +75,6 @@ actor GatewayChannelActor {
private var tickIntervalMs: Double = 30000 private var tickIntervalMs: Double = 30000
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private let connectTimeoutSeconds: Double = 6
private var watchdogTask: Task<Void, Never>? private var watchdogTask: Task<Void, Never>?
private var tickTask: Task<Void, Never>? private var tickTask: Task<Void, Never>?
private let defaultRequestTimeoutMs: Double = 15000 private let defaultRequestTimeoutMs: Double = 15000
@@ -165,15 +163,7 @@ actor GatewayChannelActor {
self.task = self.session.makeWebSocketTask(url: self.url) self.task = self.session.makeWebSocketTask(url: self.url)
self.task?.resume() self.task?.resume()
do { do {
try await AsyncTimeout.withTimeout( try await self.sendConnect()
seconds: self.connectTimeoutSeconds,
onTimeout: {
NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect timed out"])
},
operation: { try await self.sendConnect() })
} catch { } catch {
let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)") let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
self.connected = false self.connected = false
@@ -202,17 +192,15 @@ actor GatewayChannelActor {
let osVersion = ProcessInfo.processInfo.operatingSystemVersion let osVersion = ProcessInfo.processInfo.operatingSystemVersion
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
let clientDisplayName = InstanceIdentity.displayName let clientName = InstanceIdentity.displayName
let clientId = "clawdbot-macos"
let reqId = UUID().uuidString let reqId = UUID().uuidString
var client: [String: ProtoAnyCodable] = [ var client: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(clientId), "name": ProtoAnyCodable(clientName),
"displayName": ProtoAnyCodable(clientDisplayName),
"version": ProtoAnyCodable( "version": ProtoAnyCodable(
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"), Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
"platform": ProtoAnyCodable(platform), "platform": ProtoAnyCodable(platform),
"mode": ProtoAnyCodable("ui"), "mode": ProtoAnyCodable("app"),
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId), "instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
] ]
client["deviceFamily"] = ProtoAnyCodable("Mac") client["deviceFamily"] = ProtoAnyCodable("Mac")
@@ -391,11 +379,7 @@ actor GatewayChannelActor {
} }
} }
func request( func request(method: String, params: [String: AnyCodable]?, timeoutMs: Double? = nil) async throws -> Data {
method: String,
params: [String: ClawdbotProtocol.AnyCodable]?,
timeoutMs: Double? = nil) async throws -> Data
{
do { do {
try await self.connect() try await self.connect()
} catch { } catch {
@@ -446,8 +430,8 @@ actor GatewayChannelActor {
if res.ok == false { if res.ok == false {
let code = res.error?["code"]?.value as? String let code = res.error?["code"]?.value as? String
let msg = res.error?["message"]?.value as? String let msg = res.error?["message"]?.value as? String
let details: [String: ClawdbotProtocol.AnyCodable] = (res.error ?? [:]).reduce(into: [:]) { acc, pair in let details: [String: AnyCodable] = (res.error ?? [:]).reduce(into: [:]) { acc, pair in
acc[pair.key] = ClawdbotProtocol.AnyCodable(pair.value.value) acc[pair.key] = AnyCodable(pair.value.value)
} }
throw GatewayResponseError(method: method, code: code, message: msg, details: details) throw GatewayResponseError(method: method, code: code, message: msg, details: details)
} }
@@ -5,7 +5,7 @@ import OSLog
private let gatewayConnectionLogger = Logger(subsystem: "com.clawdbot", category: "gateway.connection") private let gatewayConnectionLogger = Logger(subsystem: "com.clawdbot", category: "gateway.connection")
enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable { enum GatewayAgentProvider: String, Codable, CaseIterable, Sendable {
case last case last
case whatsapp case whatsapp
case telegram case telegram
@@ -13,12 +13,11 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
case slack case slack
case signal case signal
case imessage case imessage
case msteams
case webchat case webchat
init(raw: String?) { init(raw: String?) {
let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
self = GatewayAgentChannel(rawValue: normalized) ?? .last self = GatewayAgentProvider(rawValue: normalized) ?? .last
} }
var isDeliverable: Bool { self != .webchat } var isDeliverable: Bool { self != .webchat }
@@ -32,7 +31,7 @@ struct GatewayAgentInvocation: Sendable {
var thinking: String? var thinking: String?
var deliver: Bool = false var deliver: Bool = false
var to: String? var to: String?
var channel: GatewayAgentChannel = .last var provider: GatewayAgentProvider = .last
var timeoutSeconds: Int? var timeoutSeconds: Int?
var idempotencyKey: String = UUID().uuidString var idempotencyKey: String = UUID().uuidString
} }
@@ -52,7 +51,7 @@ actor GatewayConnection {
case setHeartbeats = "set-heartbeats" case setHeartbeats = "set-heartbeats"
case systemEvent = "system-event" case systemEvent = "system-event"
case health case health
case channelsStatus = "channels.status" case providersStatus = "providers.status"
case configGet = "config.get" case configGet = "config.get"
case configSet = "config.set" case configSet = "config.set"
case wizardStart = "wizard.start" case wizardStart = "wizard.start"
@@ -62,7 +61,8 @@ actor GatewayConnection {
case talkMode = "talk.mode" case talkMode = "talk.mode"
case webLoginStart = "web.login.start" case webLoginStart = "web.login.start"
case webLoginWait = "web.login.wait" case webLoginWait = "web.login.wait"
case channelsLogout = "channels.logout" case webLogout = "web.logout"
case telegramLogout = "telegram.logout"
case modelsList = "models.list" case modelsList = "models.list"
case chatHistory = "chat.history" case chatHistory = "chat.history"
case chatSend = "chat.send" case chatSend = "chat.send"
@@ -368,7 +368,7 @@ extension GatewayConnection {
"thinking": AnyCodable(invocation.thinking ?? "default"), "thinking": AnyCodable(invocation.thinking ?? "default"),
"deliver": AnyCodable(invocation.deliver), "deliver": AnyCodable(invocation.deliver),
"to": AnyCodable(invocation.to ?? ""), "to": AnyCodable(invocation.to ?? ""),
"channel": AnyCodable(invocation.channel.rawValue), "provider": AnyCodable(invocation.provider.rawValue),
"idempotencyKey": AnyCodable(invocation.idempotencyKey), "idempotencyKey": AnyCodable(invocation.idempotencyKey),
] ]
if let timeout = invocation.timeoutSeconds { if let timeout = invocation.timeoutSeconds {
@@ -389,7 +389,7 @@ extension GatewayConnection {
sessionKey: String, sessionKey: String,
deliver: Bool, deliver: Bool,
to: String?, to: String?,
channel: GatewayAgentChannel = .last, provider: GatewayAgentProvider = .last,
timeoutSeconds: Int? = nil, timeoutSeconds: Int? = nil,
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?) idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
{ {
@@ -399,7 +399,7 @@ extension GatewayConnection {
thinking: thinking, thinking: thinking,
deliver: deliver, deliver: deliver,
to: to, to: to,
channel: channel, provider: provider,
timeoutSeconds: timeoutSeconds, timeoutSeconds: timeoutSeconds,
idempotencyKey: idempotencyKey)) idempotencyKey: idempotencyKey))
} }
@@ -1,4 +1,3 @@
import ClawdbotDiscovery
import SwiftUI import SwiftUI
struct GatewayDiscoveryInlineList: View { struct GatewayDiscoveryInlineList: View {
@@ -6,79 +6,43 @@ import OSLog
@MainActor @MainActor
@Observable @Observable
public final class GatewayDiscoveryModel { final class GatewayDiscoveryModel {
public struct LocalIdentity: Equatable, Sendable { struct LocalIdentity: Equatable {
public var hostTokens: Set<String> var hostTokens: Set<String>
public var displayTokens: Set<String> var displayTokens: Set<String>
public init(hostTokens: Set<String>, displayTokens: Set<String>) {
self.hostTokens = hostTokens
self.displayTokens = displayTokens
}
} }
public struct DiscoveredGateway: Identifiable, Equatable, Sendable { struct DiscoveredGateway: Identifiable, Equatable {
public var id: String { self.stableID } var id: String { self.stableID }
public var displayName: String var displayName: String
public var lanHost: String? var lanHost: String?
public var tailnetDns: String? var tailnetDns: String?
public var sshPort: Int var sshPort: Int
public var gatewayPort: Int? var gatewayPort: Int?
public var cliPath: String? var cliPath: String?
public var stableID: String var stableID: String
public var debugID: String var debugID: String
public var isLocal: Bool 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
}
} }
public var gateways: [DiscoveredGateway] = [] var gateways: [DiscoveredGateway] = []
public var statusText: String = "Idle" var statusText: String = "Idle"
private var browsers: [String: NWBrowser] = [:] private var browsers: [String: NWBrowser] = [:]
private var resultsByDomain: [String: Set<NWBrowser.Result>] = [:] private var resultsByDomain: [String: Set<NWBrowser.Result>] = [:]
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:] private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
private var statesByDomain: [String: NWBrowser.State] = [:] private var statesByDomain: [String: NWBrowser.State] = [:]
private var localIdentity: LocalIdentity private var localIdentity: LocalIdentity
private let localDisplayName: String?
private let filterLocalGateways: Bool
private var resolvedTXTByID: [String: [String: String]] = [:] private var resolvedTXTByID: [String: [String: String]] = [:]
private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:] 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") private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-discovery")
public init( init() {
localDisplayName: String? = nil, self.localIdentity = Self.buildLocalIdentityFast()
filterLocalGateways: Bool = true)
{
self.localDisplayName = localDisplayName
self.filterLocalGateways = filterLocalGateways
self.localIdentity = Self.buildLocalIdentityFast(displayName: localDisplayName)
self.refreshLocalIdentity() self.refreshLocalIdentity()
} }
public func start() { func start() {
if !self.browsers.isEmpty { return } if !self.browsers.isEmpty { return }
for domain in ClawdbotBonjour.bridgeServiceDomains { for domain in ClawdbotBonjour.bridgeServiceDomains {
@@ -108,24 +72,9 @@ public final class GatewayDiscoveryModel {
self.browsers[domain] = browser self.browsers[domain] = browser
browser.start(queue: DispatchQueue(label: "com.clawdbot.macos.gateway-discovery.\(domain)")) browser.start(queue: DispatchQueue(label: "com.clawdbot.macos.gateway-discovery.\(domain)"))
} }
self.scheduleWideAreaFallback()
} }
public func refreshWideAreaFallbackNow(timeoutSeconds: TimeInterval = 5.0) { func stop() {
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() {
for browser in self.browsers.values { for browser in self.browsers.values {
browser.cancel() browser.cancel()
} }
@@ -136,52 +85,15 @@ public final class GatewayDiscoveryModel {
self.resolvedTXTByID = [:] self.resolvedTXTByID = [:]
self.pendingTXTResolvers.values.forEach { $0.cancel() } self.pendingTXTResolvers.values.forEach { $0.cancel() }
self.pendingTXTResolvers = [:] self.pendingTXTResolvers = [:]
self.wideAreaFallbackTask?.cancel()
self.wideAreaFallbackTask = nil
self.wideAreaFallbackGateways = []
self.gateways = [] self.gateways = []
self.statusText = "Stopped" 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() { private func recomputeGateways() {
let primary = self.sortedDeduped(gateways: self.gatewaysByDomain.values.flatMap(\.self)) self.gateways = self.gatewaysByDomain.values
let primaryFiltered = self.filterLocalGateways ? primary.filter { !$0.isLocal } : primary .flatMap(\.self)
if !primaryFiltered.isEmpty { .filter { !$0.isLocal }
self.gateways = primaryFiltered .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
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
} }
private func updateGateways(for domain: String) { private func updateGateways(for domain: String) {
@@ -234,74 +146,6 @@ public final class GatewayDiscoveryModel {
isLocal: isLocal) isLocal: isLocal)
} }
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } .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() { private func updateGatewaysForAllDomains() {
@@ -364,15 +208,15 @@ public final class GatewayDiscoveryModel {
return merged return merged
} }
public struct GatewayTXT: Equatable { struct GatewayTXT: Equatable {
public var lanHost: String? var lanHost: String?
public var tailnetDns: String? var tailnetDns: String?
public var sshPort: Int var sshPort: Int
public var gatewayPort: Int? var gatewayPort: Int?
public var cliPath: String? var cliPath: String?
} }
public static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT { static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT {
var lanHost: String? var lanHost: String?
var tailnetDns: String? var tailnetDns: String?
var sshPort = 22 var sshPort = 22
@@ -412,7 +256,7 @@ public final class GatewayDiscoveryModel {
cliPath: cliPath) 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)" var target = "\(user)@\(host)"
if port != 22 { if port != 22 {
target += ":\(port)" target += ":\(port)"
@@ -480,7 +324,7 @@ public final class GatewayDiscoveryModel {
return titled.isEmpty ? normalized : titled return titled.isEmpty ? normalized : titled
} }
public nonisolated static func isLocalGateway( nonisolated static func isLocalGateway(
lanHost: String?, lanHost: String?,
tailnetDns: String?, tailnetDns: String?,
displayName: String?, displayName: String?,
@@ -502,19 +346,18 @@ public final class GatewayDiscoveryModel {
{ {
return true return true
} }
if let serviceHost = normalizeServiceHostToken(serviceName), if let service = normalizeServiceToken(serviceName) {
local.hostTokens.contains(serviceHost) for token in local.hostTokens where service.contains(token) {
{ return true
return true }
} }
return false return false
} }
private func refreshLocalIdentity() { private func refreshLocalIdentity() {
let fastIdentity = self.localIdentity let fastIdentity = self.localIdentity
let displayName = self.localDisplayName
Task.detached(priority: .utility) { Task.detached(priority: .utility) {
let slowIdentity = Self.buildLocalIdentitySlow(displayName: displayName) let slowIdentity = Self.buildLocalIdentitySlow()
let merged = Self.mergeLocalIdentity(fast: fastIdentity, slow: slowIdentity) let merged = Self.mergeLocalIdentity(fast: fastIdentity, slow: slowIdentity)
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self else { return } guard let self else { return }
@@ -534,7 +377,7 @@ public final class GatewayDiscoveryModel {
displayTokens: fast.displayTokens.union(slow.displayTokens)) 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 hostTokens: Set<String> = []
var displayTokens: Set<String> = [] var displayTokens: Set<String> = []
@@ -543,14 +386,14 @@ public final class GatewayDiscoveryModel {
hostTokens.insert(token) hostTokens.insert(token)
} }
if let token = normalizeDisplayToken(displayName) { if let token = normalizeDisplayToken(InstanceIdentity.displayName) {
displayTokens.insert(token) displayTokens.insert(token)
} }
return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens) 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 hostTokens: Set<String> = []
var displayTokens: Set<String> = [] var displayTokens: Set<String> = []
@@ -560,10 +403,6 @@ public final class GatewayDiscoveryModel {
hostTokens.insert(token) hostTokens.insert(token)
} }
if let token = normalizeDisplayToken(displayName) {
displayTokens.insert(token)
}
if let token = normalizeDisplayToken(Host.current().localizedName) { if let token = normalizeDisplayToken(Host.current().localizedName) {
displayTokens.insert(token) displayTokens.insert(token)
} }
@@ -595,14 +434,11 @@ public final class GatewayDiscoveryModel {
return trimmed.lowercased() 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 } guard let raw else { return nil }
let prettified = Self.prettifyInstanceName(raw) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
let strippedBridge = prettified.replacingOccurrences( if trimmed.isEmpty { return nil }
of: #"\s*-?\s*bridge$"#, return trimmed.lowercased()
with: "",
options: .regularExpression)
return self.normalizeHostToken(strippedBridge)
} }
} }
@@ -3,7 +3,6 @@ import OSLog
enum GatewayEndpointState: Sendable, Equatable { enum GatewayEndpointState: Sendable, Equatable {
case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?) 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) case unavailable(mode: AppState.ConnectionMode, reason: String)
} }
@@ -15,7 +14,6 @@ enum GatewayEndpointState: Sendable, Equatable {
actor GatewayEndpointStore { actor GatewayEndpointStore {
static let shared = GatewayEndpointStore() static let shared = GatewayEndpointStore()
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"] private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
private static let remoteConnectingDetail = "Connecting to remote gateway…"
struct Deps: Sendable { struct Deps: Sendable {
let mode: @Sendable () async -> AppState.ConnectionMode let mode: @Sendable () async -> AppState.ConnectionMode
@@ -28,13 +26,7 @@ actor GatewayEndpointStore {
static let live = Deps( static let live = Deps(
mode: { await MainActor.run { AppStateStore.shared.connectionMode } }, mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
token: { token: { ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] },
let root = ClawdbotConfigFile.loadDict()
return GatewayEndpointStore.resolveGatewayToken(
isRemote: CommandResolver.connectionModeIsRemote(),
root: root,
env: ProcessInfo.processInfo.environment)
},
password: { password: {
let root = ClawdbotConfigFile.loadDict() let root = ClawdbotConfigFile.loadDict()
return GatewayEndpointStore.resolveGatewayPassword( return GatewayEndpointStore.resolveGatewayPassword(
@@ -91,46 +83,11 @@ actor GatewayEndpointStore {
return nil 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 deps: Deps
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint") private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
private var state: GatewayEndpointState private var state: GatewayEndpointState
private var subscribers: [UUID: AsyncStream<GatewayEndpointState>.Continuation] = [:] private var subscribers: [UUID: AsyncStream<GatewayEndpointState>.Continuation] = [:]
private var remoteEnsure: (token: UUID, task: Task<UInt16, Error>)?
init(deps: Deps = .live) { init(deps: Deps = .live) {
self.deps = deps self.deps = deps
@@ -158,8 +115,7 @@ actor GatewayEndpointStore {
token: token, token: token,
password: password) password: password)
case .remote: case .remote:
self.state = .connecting(mode: .remote, detail: Self.remoteConnectingDetail) self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
Task { await self.setMode(.remote) }
case .unconfigured: case .unconfigured:
self.state = .unavailable(mode: .unconfigured, reason: "Gateway not configured") self.state = .unavailable(mode: .unconfigured, reason: "Gateway not configured")
} }
@@ -188,7 +144,6 @@ actor GatewayEndpointStore {
let password = self.deps.password() let password = self.deps.password()
switch mode { switch mode {
case .local: case .local:
self.cancelRemoteEnsure()
let port = self.deps.localPort() let port = self.deps.localPort()
let host = await self.deps.localHost() let host = await self.deps.localHost()
self.setState(.ready( self.setState(.ready(
@@ -199,18 +154,15 @@ actor GatewayEndpointStore {
case .remote: case .remote:
let port = await self.deps.remotePortIfRunning() let port = await self.deps.remotePortIfRunning()
guard let port else { guard let port else {
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail)) self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
self.kickRemoteEnsureIfNeeded(detail: Self.remoteConnectingDetail)
return return
} }
self.cancelRemoteEnsure()
self.setState(.ready( self.setState(.ready(
mode: .remote, mode: .remote,
url: URL(string: "ws://127.0.0.1:\(Int(port))")!, url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
token: token, token: token,
password: password)) password: password))
case .unconfigured: case .unconfigured:
self.cancelRemoteEnsure()
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured")) self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
} }
} }
@@ -224,13 +176,8 @@ actor GatewayEndpointStore {
code: 1, code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
} }
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail) let port = try await self.deps.ensureRemoteTunnel()
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else { await self.setMode(.remote)
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Missing tunnel port"])
}
return port return port
} }
@@ -239,11 +186,6 @@ actor GatewayEndpointStore {
switch self.state { switch self.state {
case let .ready(_, url, token, password): case let .ready(_, url, token, password):
return (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): case let .unavailable(mode, reason):
guard mode == .remote else { guard mode == .remote else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason]) throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason])
@@ -251,76 +193,21 @@ actor GatewayEndpointStore {
// Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet), // 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. // recreate it on demand so callers can recover without a manual reconnect.
self.logger.info( do {
"endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)") self.logger.info(
return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail) "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()
private func cancelRemoteEnsure() { let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
self.remoteEnsure?.task.cancel() self.setState(.ready(mode: .remote, url: url, token: token, password: password))
self.remoteEnsure = nil return (url, token, password)
} } catch {
let msg = "\(reason) (\(error.localizedDescription))"
private func kickRemoteEnsureIfNeeded(detail: String) { self.setState(.unavailable(mode: .remote, reason: msg))
if self.remoteEnsure != nil { self.logger.error("remote control tunnel ensure failed \(msg, privacy: .public)")
self.setState(.connecting(mode: .remote, detail: detail)) throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg])
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"])
} }
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])
} }
} }
@@ -341,11 +228,6 @@ actor GatewayEndpointStore {
self.logger self.logger
.debug( .debug(
"resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)") "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): case let .unavailable(mode, reason):
let modeDesc = String(describing: mode) let modeDesc = String(describing: mode)
self.logger self.logger
@@ -388,43 +270,6 @@ actor GatewayEndpointStore {
} }
} }
extension GatewayEndpointStore {
static func dashboardURL(for config: GatewayConnection.Config) throws -> URL {
guard var components = URLComponents(url: config.url, resolvingAgainstBaseURL: false) else {
throw NSError(domain: "Dashboard", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Invalid gateway URL",
])
}
switch components.scheme?.lowercased() {
case "ws":
components.scheme = "http"
case "wss":
components.scheme = "https"
default:
components.scheme = "http"
}
components.path = "/"
var queryItems: [URLQueryItem] = []
if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
queryItems.append(URLQueryItem(name: "token", value: token))
}
if let password = config.password?.trimmingCharacters(in: .whitespacesAndNewlines),
!password.isEmpty
{
queryItems.append(URLQueryItem(name: "password", value: password))
}
components.queryItems = queryItems.isEmpty ? nil : queryItems
guard let url = components.url else {
throw NSError(domain: "Dashboard", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Failed to build dashboard URL",
])
}
return url
}
}
#if DEBUG #if DEBUG
extension GatewayEndpointStore { extension GatewayEndpointStore {
static func _testResolveGatewayPassword( static func _testResolveGatewayPassword(
@@ -65,6 +65,12 @@ enum GatewayEnvironment {
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.env") private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.env")
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"] private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
static func bundledGatewayExecutable() -> String? {
guard let res = Bundle.main.resourceURL else { return nil }
let path = res.appendingPathComponent("Relay/clawdbot").path
return FileManager.default.isExecutableFile(atPath: path) ? path : nil
}
static func gatewayPort() -> Int { static func gatewayPort() -> Int {
if let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PORT"] { if let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PORT"] {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -99,6 +105,28 @@ enum GatewayEnvironment {
} }
let expected = self.expectedGatewayVersion() let expected = self.expectedGatewayVersion()
if let bundled = self.bundledGatewayExecutable() {
let installed = self.readGatewayVersion(binary: bundled)
if let expected, let installed, !installed.compatible(with: 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: nil,
gatewayVersion: installed.description,
requiredGateway: expected.description,
message: message)
}
let gatewayVersionText = installed?.description ?? "unknown"
return GatewayEnvironmentStatus(
kind: .ok,
nodeVersion: nil,
gatewayVersion: gatewayVersionText,
requiredGateway: expected?.description,
message: "Bundled gateway \(gatewayVersionText) (bun)")
}
let projectRoot = CommandResolver.projectRoot() let projectRoot = CommandResolver.projectRoot()
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot) let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
@@ -119,7 +147,7 @@ enum GatewayEnvironment {
nodeVersion: runtime.version.description, nodeVersion: runtime.version.description,
gatewayVersion: nil, gatewayVersion: nil,
requiredGateway: expected?.description, requiredGateway: expected?.description,
message: "clawdbot CLI not found in PATH; install the CLI.") message: "clawdbot CLI not found in PATH; install the global package.")
} }
let installed = gatewayBin.flatMap { self.readGatewayVersion(binary: $0) } let installed = gatewayBin.flatMap { self.readGatewayVersion(binary: $0) }
@@ -169,6 +197,7 @@ enum GatewayEnvironment {
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot) let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
let status = self.check() let status = self.check()
let gatewayBin = CommandResolver.clawdbotExecutable() let gatewayBin = CommandResolver.clawdbotExecutable()
let bundled = self.bundledGatewayExecutable()
let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths()) let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths())
guard case .ok = status.kind else { guard case .ok = status.kind else {
@@ -176,17 +205,20 @@ enum GatewayEnvironment {
} }
let port = self.gatewayPort() let port = self.gatewayPort()
if let gatewayBin { if let bundled {
let bind = self.preferredGatewayBind() ?? "loopback" let bind = self.preferredGatewayBind() ?? "loopback"
let cmd = [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind] let cmd = [bundled, "gateway-daemon", "--port", "\(port)", "--bind", bind]
return GatewayCommandResolution(status: status, command: cmd)
}
if let gatewayBin {
let cmd = [gatewayBin, "gateway", "--port", "\(port)"]
return GatewayCommandResolution(status: status, command: cmd) return GatewayCommandResolution(status: status, command: cmd)
} }
if let entry = projectEntrypoint, if let entry = projectEntrypoint,
case let .success(resolvedRuntime) = runtime case let .success(resolvedRuntime) = runtime
{ {
let bind = self.preferredGatewayBind() ?? "loopback" let cmd = [resolvedRuntime.path, entry, "gateway", "--port", "\(port)"]
let cmd = [resolvedRuntime.path, entry, "gateway-daemon", "--port", "\(port)", "--bind", bind]
return GatewayCommandResolution(status: status, command: cmd) return GatewayCommandResolution(status: status, command: cmd)
} }
@@ -4,18 +4,6 @@ enum GatewayLaunchAgentManager {
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd") private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"] private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway" private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway"
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
private enum GatewayProgramArgumentsError: LocalizedError {
case message(String)
var errorDescription: String? {
switch self {
case let .message(message):
message
}
}
}
private static var plistURL: URL { private static var plistURL: URL {
FileManager.default.homeDirectoryForCurrentUser FileManager.default.homeDirectoryForCurrentUser
@@ -27,46 +15,32 @@ enum GatewayLaunchAgentManager {
.appendingPathComponent("Library/LaunchAgents/\(legacyGatewayLaunchdLabel).plist") .appendingPathComponent("Library/LaunchAgents/\(legacyGatewayLaunchdLabel).plist")
} }
private static func gatewayProgramArguments( private static func gatewayExecutablePath(bundlePath: String) -> String {
port: Int, "\(bundlePath)/Contents/Resources/Relay/clawdbot"
bind: String) -> Result<[String], GatewayProgramArgumentsError> }
{
let projectRoot = CommandResolver.projectRoot()
#if DEBUG
if let localBin = CommandResolver.projectClawdbotExecutable(projectRoot: projectRoot) {
return .success([localBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
}
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot) {
switch CommandResolver.runtimeResolution() {
case let .success(runtime):
let cmd = CommandResolver.makeRuntimeCommand(
runtime: runtime,
entrypoint: entry,
subcommand: "gateway-daemon",
extraArgs: ["--port", "\(port)", "--bind", bind])
return .success(cmd)
case .failure:
break
}
}
#endif
let searchPaths = CommandResolver.preferredPaths()
if let gatewayBin = CommandResolver.clawdbotExecutable(searchPaths: searchPaths) {
return .success([gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
}
private static func relayDir(bundlePath: String) -> String {
"\(bundlePath)/Contents/Resources/Relay"
}
private static func gatewayProgramArguments(bundlePath: String, port: Int, bind: String) -> [String] {
#if DEBUG
let projectRoot = CommandResolver.projectRoot()
if let localBin = CommandResolver.projectClawdbotExecutable(projectRoot: projectRoot) {
return [localBin, "gateway", "--port", "\(port)", "--bind", bind]
}
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot), if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot),
case let .success(runtime) = CommandResolver.runtimeResolution(searchPaths: searchPaths) case let .success(runtime) = CommandResolver.runtimeResolution()
{ {
let cmd = CommandResolver.makeRuntimeCommand( return CommandResolver.makeRuntimeCommand(
runtime: runtime, runtime: runtime,
entrypoint: entry, entrypoint: entry,
subcommand: "gateway-daemon", subcommand: "gateway",
extraArgs: ["--port", "\(port)", "--bind", bind]) extraArgs: ["--port", "\(port)", "--bind", bind])
return .success(cmd)
} }
#endif
return .failure(.message("clawdbot CLI not found in PATH; install the CLI.")) let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
} }
static func isLoaded() async -> Bool { static func isLoaded() async -> Bool {
@@ -76,14 +50,14 @@ enum GatewayLaunchAgentManager {
} }
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? { static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
_ = bundlePath
if enabled, self.isLaunchAgentWriteDisabled() {
self.logger.info("launchd enable skipped (disable marker set)")
return nil
}
if enabled { if enabled {
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"]) _ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.legacyPlistURL) try? FileManager.default.removeItem(at: self.legacyPlistURL)
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
self.logger.error("launchd enable failed: gateway missing at \(gatewayBin)")
return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh"
}
let desiredBind = self.preferredGatewayBind() ?? "loopback" let desiredBind = self.preferredGatewayBind() ?? "loopback"
let desiredToken = self.preferredGatewayToken() let desiredToken = self.preferredGatewayToken()
@@ -93,30 +67,22 @@ enum GatewayLaunchAgentManager {
bind: desiredBind, bind: desiredBind,
token: desiredToken, token: desiredToken,
password: desiredPassword) password: desiredPassword)
let programArgumentsResult = self.gatewayProgramArguments(port: port, bind: desiredBind)
guard case let .success(programArguments) = programArgumentsResult else {
if case let .failure(error) = programArgumentsResult {
let message = error.localizedDescription
self.logger.error("launchd enable failed: \(message)")
return message
}
return "Failed to resolve gateway command."
}
// If launchd already loaded the job (common on login), avoid `bootout` unless we must // If launchd already loaded the job (common on login), avoid `bootout` unless we must
// change the config. `bootout` can kill a just-started gateway and cause attach loops. // change the config. `bootout` can kill a just-started gateway and cause attach loops.
let loaded = await self.isLoaded() let loaded = await self.isLoaded()
if loaded { if loaded,
if let existing = self.readPlistConfig(), existing.matches(desiredConfig) { let existing = self.readPlistConfig(),
self.logger.info("launchd job already loaded with desired config; skipping bootout") existing.matches(desiredConfig)
await self.ensureEnabled() {
_ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) self.logger.info("launchd job already loaded with desired config; skipping bootout")
return nil await self.ensureEnabled()
} _ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
return nil
} }
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)") self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
self.writePlist(programArguments: programArguments) self.writePlist(bundlePath: bundlePath, port: port)
await self.ensureEnabled() await self.ensureEnabled()
if loaded { if loaded {
@@ -145,13 +111,19 @@ enum GatewayLaunchAgentManager {
_ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) _ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
} }
private static func writePlist(programArguments: [String]) { private static func writePlist(bundlePath: String, port: Int) {
let preferredPath = CommandResolver.preferredPaths().joined(separator: ":") let relayDir = self.relayDir(bundlePath: bundlePath)
let preferredPath = ([relayDir] + CommandResolver.preferredPaths())
.joined(separator: ":")
let bind = self.preferredGatewayBind() ?? "loopback"
let programArguments = self.gatewayProgramArguments(bundlePath: bundlePath, port: port, bind: bind)
let token = self.preferredGatewayToken() let token = self.preferredGatewayToken()
let password = self.preferredGatewayPassword() let password = self.preferredGatewayPassword()
var envEntries = """ var envEntries = """
<key>PATH</key> <key>PATH</key>
<string>\(preferredPath)</string> <string>\(preferredPath)</string>
<key>CLAWDBOT_IMAGE_BACKEND</key>
<string>sips</string>
""" """
if let token { if let token {
let escapedToken = self.escapePlistValue(token) let escapedToken = self.escapePlistValue(token)
@@ -232,20 +204,7 @@ enum GatewayLaunchAgentManager {
private static func preferredGatewayToken() -> String? { private static func preferredGatewayToken() -> String? {
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] ?? "" let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed.isEmpty ? nil : trimmed
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
} }
private static func preferredGatewayPassword() -> String? { private static func preferredGatewayPassword() -> String? {
@@ -329,16 +288,16 @@ enum GatewayLaunchAgentManager {
} }
} }
extension GatewayLaunchAgentManager {
private static func isLaunchAgentWriteDisabled() -> Bool {
let marker = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(self.disableLaunchAgentMarker)
return FileManager.default.fileExists(atPath: marker.path)
}
}
#if DEBUG #if DEBUG
extension GatewayLaunchAgentManager { extension GatewayLaunchAgentManager {
static func _testGatewayExecutablePath(bundlePath: String) -> String {
self.gatewayExecutablePath(bundlePath: bundlePath)
}
static func _testRelayDir(bundlePath: String) -> String {
self.relayDir(bundlePath: bundlePath)
}
static func _testPreferredGatewayBind() -> String? { static func _testPreferredGatewayBind() -> String? {
self.preferredGatewayBind() self.preferredGatewayBind()
} }
@@ -69,6 +69,7 @@ final class GatewayProcessManager {
func ensureLaunchAgentEnabledIfNeeded() async { func ensureLaunchAgentEnabledIfNeeded() async {
guard !CommandResolver.connectionModeIsRemote() else { return } guard !CommandResolver.connectionModeIsRemote() else { return }
guard !AppStateStore.attachExistingGatewayOnly else { return }
let enabled = await GatewayLaunchAgentManager.isLoaded() let enabled = await GatewayLaunchAgentManager.isLoaded()
guard !enabled else { return } guard !enabled else { return }
let bundlePath = Bundle.main.bundleURL.path let bundlePath = Bundle.main.bundleURL.path
@@ -96,6 +97,15 @@ final class GatewayProcessManager {
if await self.attachExistingGatewayIfAvailable() { if await self.attachExistingGatewayIfAvailable() {
return return
} }
// Respect debug toggle: only attach, never spawn, when enabled.
if AppStateStore.attachExistingGatewayOnly {
await MainActor.run {
self.status = .failed("Attach-only enabled; no gateway to attach")
self.appendLog("[gateway] attach-only enabled; not spawning local gateway\n")
self.logger.warning("gateway attach-only enabled; not spawning")
}
return
}
await self.enableLaunchdGateway() await self.enableLaunchdGateway()
} }
} }
@@ -211,21 +221,9 @@ final class GatewayProcessManager {
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String { private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
let instanceText = instance ?? "pid unknown" let instanceText = instance ?? "pid unknown"
if let snap { if let snap {
let linkId = snap.channelOrder?.first(where: { let linked = snap.web.linked ? "linked" : "not linked"
if let summary = snap.channels[$0] { return summary.linked != nil } let authAge = snap.web.authAgeMs.flatMap(msToAge) ?? "unknown age"
return false return "port \(port), \(linked), auth \(authAge), \(instanceText)"
}) ?? snap.channels.keys.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
return false
})
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
let label =
linkId.flatMap { snap.channelLabels?[$0] } ??
linkId?.capitalized ??
"channel"
let linkText = linked ? "linked" : "not linked"
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
} }
return "port \(port), health probe succeeded, \(instanceText)" return "port \(port), health probe succeeded, \(instanceText)"
} }
@@ -241,7 +239,7 @@ final class GatewayProcessManager {
let lower = message.lowercased() let lower = message.lowercased()
if self.isGatewayAuthFailure(error) { if self.isGatewayAuthFailure(error) {
return """ 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. to match the running gateway (or clear it on the gateway) and retry.
""" """
} }
@@ -1,5 +1,4 @@
import AppKit import AppKit
import ClawdbotDiscovery
import ClawdbotIPC import ClawdbotIPC
import ClawdbotKit import ClawdbotKit
import CoreLocation import CoreLocation
@@ -13,8 +12,7 @@ struct GeneralSettings: View {
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true @AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
private let healthStore = HealthStore.shared private let healthStore = HealthStore.shared
private let gatewayManager = GatewayProcessManager.shared private let gatewayManager = GatewayProcessManager.shared
@State private var gatewayDiscovery = GatewayDiscoveryModel( @State private var gatewayDiscovery = GatewayDiscoveryModel()
localDisplayName: InstanceIdentity.displayName)
@State private var isInstallingCLI = false @State private var isInstallingCLI = false
@State private var cliStatus: String? @State private var cliStatus: String?
@State private var cliInstalled = false @State private var cliInstalled = false
@@ -30,22 +28,10 @@ struct GeneralSettings: View {
ScrollView(.vertical) { ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
if !self.state.onboardingSeen { if !self.state.onboardingSeen {
Button { Text("Complete onboarding to finish setup")
DebugActions.restartOnboarding() .font(.callout.weight(.semibold))
} label: { .foregroundColor(.accentColor)
HStack(spacing: 8) { .padding(.bottom, 2)
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)
} }
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@@ -164,18 +150,13 @@ struct GeneralSettings: View {
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool { private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
guard mode != .off else { return true } guard mode != .off else { return true }
guard CLLocationManager.locationServicesEnabled() else {
await MainActor.run { LocationPermissionHelper.openSettings() }
return false
}
let status = CLLocationManager().authorizationStatus let status = CLLocationManager().authorizationStatus
let requireAlways = mode == .always // Note: macOS only supports authorizedAlways, not authorizedWhenInUse (iOS only)
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) { if status == .authorizedAlways {
return true return true
} }
let updated = await LocationPermissionRequester.shared.request(always: requireAlways) let updated = await LocationPermissionRequester.shared.request(always: mode == .always)
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways) return updated == .authorizedAlways
} }
private var connectionSection: some View { private var connectionSection: some View {
@@ -349,7 +330,7 @@ struct GeneralSettings: View {
Button { Button {
Task { await self.installCLI() } Task { await self.installCLI() }
} label: { } label: {
let title = self.cliInstalled ? "Reinstall CLI" : "Install CLI" let title = self.cliInstalled ? "Reinstall CLI helper" : "Install CLI helper"
ZStack { ZStack {
Text(title) Text(title)
.opacity(self.isInstallingCLI ? 0 : 1) .opacity(self.isInstallingCLI ? 0 : 1)
@@ -388,7 +369,7 @@ struct GeneralSettings: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(2) .lineLimit(2)
} else { } else {
Text("Installs a user-space Node 22+ runtime and the CLI (no Homebrew).") Text("Symlink \"clawdbot\" into /usr/local/bin and /opt/homebrew/bin for scripts.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(2) .lineLimit(2)
@@ -456,8 +437,10 @@ struct GeneralSettings: View {
self.isInstallingCLI = true self.isInstallingCLI = true
defer { isInstallingCLI = false } defer { isInstallingCLI = false }
await CLIInstaller.install { status in await CLIInstaller.install { status in
self.cliStatus = status await MainActor.run {
self.refreshCLIStatus() self.cliStatus = status
self.refreshCLIStatus()
}
} }
} }
@@ -496,19 +479,7 @@ struct GeneralSettings: View {
} }
if let snap = snapshot { if let snap = snapshot {
let linkId = snap.channelOrder?.first(where: { Text("Linked auth age: \(healthAgeString(snap.web.authAgeMs))")
if let summary = snap.channels[$0] { return summary.linked != nil }
return false
}) ?? snap.channels.keys.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
return false
})
let linkLabel =
linkId.flatMap { snap.channelLabels?[$0] } ??
linkId?.capitalized ??
"Link channel"
let linkAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }
Text("\(linkLabel) auth age: \(healthAgeString(linkAge))")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text("Session store: \(snap.sessions.path) (\(snap.sessions.count) entries)") Text("Session store: \(snap.sessions.path) (\(snap.sessions.count) entries)")
+46 -86
View File
@@ -4,29 +4,35 @@ import Observation
import SwiftUI import SwiftUI
struct HealthSnapshot: Codable, Sendable { struct HealthSnapshot: Codable, Sendable {
struct ChannelSummary: Codable, Sendable { struct Telegram: Codable, Sendable {
struct Probe: Codable, Sendable { struct Probe: Codable, Sendable {
struct Bot: Codable, Sendable { struct Bot: Codable, Sendable {
let id: Int?
let username: String? let username: String?
} }
struct Webhook: Codable, Sendable { let ok: Bool
let url: String?
}
let ok: Bool?
let status: Int? let status: Int?
let error: String? let error: String?
let elapsedMs: Double? let elapsedMs: Double?
let bot: Bot? let bot: Bot?
let webhook: Webhook?
} }
let configured: Bool? let configured: Bool
let linked: Bool?
let authAgeMs: Double?
let probe: Probe? let probe: Probe?
let lastProbeAt: Double? }
struct Web: Codable, Sendable {
struct Connect: Codable, Sendable {
let ok: Bool
let status: Int?
let error: String?
let elapsedMs: Double?
}
let linked: Bool
let authAgeMs: Double?
let connect: Connect?
} }
struct SessionInfo: Codable, Sendable { struct SessionInfo: Codable, Sendable {
@@ -44,9 +50,8 @@ struct HealthSnapshot: Codable, Sendable {
let ok: Bool? let ok: Bool?
let ts: Double let ts: Double
let durationMs: Double let durationMs: Double
let channels: [String: ChannelSummary] let web: Web
let channelOrder: [String]? let telegram: Telegram?
let channelLabels: [String: String]?
let heartbeatSeconds: Int? let heartbeatSeconds: Int?
let sessions: Sessions let sessions: Sessions
} }
@@ -89,13 +94,6 @@ final class HealthStore {
} }
} }
// Test-only escape hatch: the HealthStore is a process-wide singleton but
// state derivation is pure from `snapshot` + `lastError`.
func __setSnapshotForTest(_ snapshot: HealthSnapshot?, lastError: String? = nil) {
self.snapshot = snapshot
self.lastError = lastError
}
func start() { func start() {
guard self.loopTask == nil else { return } guard self.loopTask == nil else { return }
self.loopTask = Task { [weak self] in self.loopTask = Task { [weak self] in
@@ -144,49 +142,10 @@ final class HealthStore {
} }
} }
private static func isChannelHealthy(_ summary: HealthSnapshot.ChannelSummary) -> Bool { private static func isTelegramHealthy(_ snap: HealthSnapshot) -> Bool {
guard summary.configured == true else { return false } guard let tg = snap.telegram, tg.configured else { return false }
// If probe is missing, treat it as "configured but unknown health" (not a hard fail). // If probe is missing, treat it as "configured but unknown health" (not a hard fail).
return summary.probe?.ok ?? true return tg.probe?.ok ?? true
}
private static func describeProbeFailure(_ probe: HealthSnapshot.ChannelSummary.Probe) -> String {
let elapsed = probe.elapsedMs.map { "\(Int($0))ms" }
if let error = probe.error, error.lowercased().contains("timeout") || probe.status == nil {
if let elapsed { return "Health check timed out (\(elapsed))" }
return "Health check timed out"
}
let code = probe.status.map { "status \($0)" } ?? "status unknown"
let reason = probe.error?.isEmpty == false ? probe.error! : "health probe failed"
if let elapsed { return "\(reason) (\(code), \(elapsed))" }
return "\(reason) (\(code))"
}
private func resolveLinkChannel(
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
{
let order = snap.channelOrder ?? Array(snap.channels.keys)
for id in order {
if let summary = snap.channels[id], summary.linked != nil {
return (id: id, summary: summary)
}
}
return nil
}
private func resolveFallbackChannel(
_ snap: HealthSnapshot,
excluding id: String?) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
{
let order = snap.channelOrder ?? Array(snap.channels.keys)
for channelId in order {
if channelId == id { continue }
guard let summary = snap.channels[channelId] else { continue }
if Self.isChannelHealthy(summary) {
return (id: channelId, summary: summary)
}
}
return nil
} }
var state: HealthState { var state: HealthState {
@@ -194,15 +153,13 @@ final class HealthStore {
return .degraded(error) return .degraded(error)
} }
guard let snap = self.snapshot else { return .unknown } guard let snap = self.snapshot else { return .unknown }
guard let link = self.resolveLinkChannel(snap) else { return .unknown } if !snap.web.linked {
if link.summary.linked != true { // WhatsApp Web linking is optional if Telegram is healthy; don't paint the whole app red.
// Linking is optional if any other channel is healthy; don't paint the whole app red. return Self.isTelegramHealthy(snap) ? .degraded("Not linked") : .linkingNeeded
let fallback = self.resolveFallbackChannel(snap, excluding: link.id)
return fallback != nil ? .degraded("Not linked") : .linkingNeeded
} }
// A channel can be "linked" but still unhealthy (failed probe / cannot connect). if let connect = snap.web.connect, !connect.ok {
if let probe = link.summary.probe, probe.ok == false { let reason = connect.error ?? "connect failed"
return .degraded(Self.describeProbeFailure(probe)) return .degraded(reason)
} }
return .ok return .ok
} }
@@ -211,22 +168,19 @@ final class HealthStore {
if self.isRefreshing { return "Health check running…" } if self.isRefreshing { return "Health check running…" }
if let error = self.lastError { return "Health check failed: \(error)" } if let error = self.lastError { return "Health check failed: \(error)" }
guard let snap = self.snapshot else { return "Health check pending" } guard let snap = self.snapshot else { return "Health check pending" }
guard let link = self.resolveLinkChannel(snap) else { return "Health check pending" } if !snap.web.linked {
if link.summary.linked != true { if let tg = snap.telegram, tg.configured {
if let fallback = self.resolveFallbackChannel(snap, excluding: link.id) { let tgLabel = (tg.probe?.ok ?? true) ? "Telegram ok" : "Telegram degraded"
let fallbackLabel = snap.channelLabels?[fallback.id] ?? fallback.id.capitalized return "\(tgLabel) · Not linked — run clawdbot login"
let fallbackState = (fallback.summary.probe?.ok ?? true) ? "ok" : "degraded"
return "\(fallbackLabel) \(fallbackState) · Not linked — run clawdbot login"
} }
return "Not linked — run clawdbot login" return "Not linked — run clawdbot login"
} }
let auth = link.summary.authAgeMs.map { msToAge($0) } ?? "unknown" let auth = snap.web.authAgeMs.map { msToAge($0) } ?? "unknown"
if let probe = link.summary.probe, probe.ok == false { if let connect = snap.web.connect, !connect.ok {
let status = probe.status.map(String.init) ?? "?" let code = connect.status.map(String.init) ?? "?"
let suffix = probe.status == nil ? "probe degraded" : "probe degraded · status \(status)" return "Link stale? status \(code)"
return "linked · auth \(auth) · \(suffix)"
} }
return "linked · auth \(auth)" return "linked · auth \(auth) · socket ok"
} }
/// Short, human-friendly detail for the last failure, used in the UI. /// Short, human-friendly detail for the last failure, used in the UI.
@@ -247,11 +201,17 @@ final class HealthStore {
} }
func describeFailure(from snap: HealthSnapshot, fallback: String?) -> String { func describeFailure(from snap: HealthSnapshot, fallback: String?) -> String {
if let link = self.resolveLinkChannel(snap), link.summary.linked != true { if !snap.web.linked {
return "Not linked — run clawdbot login" return "Not linked — run clawdbot login"
} }
if let link = self.resolveLinkChannel(snap), let probe = link.summary.probe, probe.ok == false { if let connect = snap.web.connect, !connect.ok {
return Self.describeProbeFailure(probe) let elapsed = connect.elapsedMs.map { "\(Int($0))ms" } ?? "unknown duration"
if let err = connect.error, err.lowercased().contains("timeout") || connect.status == nil {
return "Health check timed out (\(elapsed))"
}
let code = connect.status.map { "status \($0)" } ?? "status unknown"
let reason = connect.error ?? "connect failed"
return "\(reason) (\(code), \(elapsed))"
} }
if let fallback, !fallback.isEmpty { if let fallback, !fallback.isEmpty {
return fallback return fallback
@@ -242,18 +242,6 @@ final class InstancesStore {
do { do {
let data = try await ControlChannel.shared.health(timeout: 8) let data = try await ControlChannel.shared.health(timeout: 8)
guard let snap = decodeHealthSnapshot(from: data) else { return } guard let snap = decodeHealthSnapshot(from: data) else { return }
let linkId = snap.channelOrder?.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
return false
}) ?? snap.channels.keys.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
return false
})
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
let linkLabel =
linkId.flatMap { snap.channelLabels?[$0] } ??
linkId?.capitalized ??
"channel"
let entry = InstanceInfo( let entry = InstanceInfo(
id: "health-\(snap.ts)", id: "health-\(snap.ts)",
host: "gateway (health)", host: "gateway (health)",
@@ -265,7 +253,7 @@ final class InstancesStore {
lastInputSeconds: nil, lastInputSeconds: nil,
mode: "health", mode: "health",
reason: "health probe", reason: "health probe",
text: "Health ok · \(linkLabel) linked=\(linked)", text: "Health ok · linked=\(snap.web.linked)",
ts: snap.ts) ts: snap.ts)
if !self.instances.contains(where: { $0.id == entry.id }) { if !self.instances.contains(where: { $0.id == entry.id }) {
self.instances.insert(entry, at: 0) self.instances.insert(entry, at: 0)
+1 -1
View File
@@ -29,7 +29,7 @@ enum LogLocator {
stdoutLog.path stdoutLog.path
} }
/// Path to use for the Gateway launchd job stdout/err. /// Path to use for the embedded Gateway launchd job stdout/err.
static var launchdGatewayLogPath: String { static var launchdGatewayLogPath: String {
gatewayLog.path gatewayLog.path
} }
@@ -70,7 +70,6 @@ struct ClawdbotApp: App {
} }
.onChange(of: self.state.connectionMode) { _, mode in .onChange(of: self.state.connectionMode) { _, mode in
Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) } Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) }
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "connection-mode")
} }
Settings { Settings {
@@ -263,9 +262,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) } Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(AppStateStore.shared.peekabooBridgeEnabled) } Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(AppStateStore.shared.peekabooBridgeEnabled) }
self.scheduleFirstRunOnboardingIfNeeded() self.scheduleFirstRunOnboardingIfNeeded()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "launch")
}
// Developer/testing helper: auto-open chat when launched with --chat (or legacy --webchat). // Developer/testing helper: auto-open chat when launched with --chat (or legacy --webchat).
if CommandLine.arguments.contains("--chat") || CommandLine.arguments.contains("--webchat") { if CommandLine.arguments.contains("--chat") || CommandLine.arguments.contains("--webchat") {
@@ -153,9 +153,6 @@ struct MenuContent: View {
self.micRefreshTask = nil self.micRefreshTask = nil
self.micObserver.stop() self.micObserver.stop()
} }
.task { @MainActor in
SettingsWindowOpener.shared.register(openSettings: self.openSettings)
}
} }
private var connectionLabel: String { private var connectionLabel: String {
@@ -279,18 +276,13 @@ struct MenuContent: View {
Label("Send Test Notification", systemImage: "bell") Label("Send Test Notification", systemImage: "bell")
} }
Divider() Divider()
if self.state.connectionMode == .local { if self.state.connectionMode == .local, !AppStateStore.attachExistingGatewayOnly {
Button { Button {
DebugActions.restartGateway() DebugActions.restartGateway()
} label: { } label: {
Label("Restart Gateway", systemImage: "arrow.clockwise") Label("Restart Gateway", systemImage: "arrow.clockwise")
} }
} }
Button {
DebugActions.restartOnboarding()
} label: {
Label("Restart Onboarding", systemImage: "arrow.counterclockwise")
}
Button { Button {
DebugActions.restartApp() DebugActions.restartApp()
} label: { } label: {
@@ -304,16 +296,34 @@ struct MenuContent: View {
SettingsTabRouter.request(tab) SettingsTabRouter.request(tab)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
self.openSettings() self.openSettings()
DispatchQueue.main.async { NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab)
NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab)
}
} }
@MainActor @MainActor
private func openDashboard() async { private func openDashboard() async {
do { do {
let config = try await GatewayEndpointStore.shared.requireConfig() let config = try await GatewayEndpointStore.shared.requireConfig()
let url = try GatewayEndpointStore.dashboardURL(for: config) let wsURL = config.url
guard var components = URLComponents(url: wsURL, resolvingAgainstBaseURL: false) else {
throw NSError(domain: "Dashboard", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Invalid gateway URL",
])
}
switch components.scheme?.lowercased() {
case "ws":
components.scheme = "http"
case "wss":
components.scheme = "https"
default:
components.scheme = "http"
}
components.path = "/"
components.query = nil
guard let url = components.url else {
throw NSError(domain: "Dashboard", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Failed to build dashboard URL",
])
}
NSWorkspace.shared.open(url) NSWorkspace.shared.open(url)
} catch { } catch {
let alert = NSAlert() let alert = NSAlert()
@@ -99,9 +99,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
} }
return NSRect.zero return NSRect.zero
} }
}
extension MenuSessionsInjector {
// MARK: - Injection // MARK: - Injection
private func inject(into menu: NSMenu) { private func inject(into menu: NSMenu) {
@@ -113,7 +111,6 @@ extension MenuSessionsInjector {
guard let insertIndex = self.findInsertIndex(in: menu) else { return } guard let insertIndex = self.findInsertIndex(in: menu) else { return }
let width = self.initialWidth(for: menu) let width = self.initialWidth(for: menu)
let isConnected = self.isControlChannelConnected let isConnected = self.isControlChannelConnected
let channelState = ControlChannel.shared.state
var cursor = insertIndex var cursor = insertIndex
var headerView: NSView? var headerView: NSView?
@@ -136,7 +133,7 @@ extension MenuSessionsInjector {
let hosted = self.makeHostedView( let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView( rootView: AnyView(MenuSessionsHeaderView(
count: rows.count, count: rows.count,
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))), statusText: isConnected ? nil : "Gateway disconnected")),
width: width, width: width,
highlighted: false) highlighted: false)
headerItem.view = hosted headerItem.view = hosted
@@ -169,7 +166,7 @@ extension MenuSessionsInjector {
headerItem.isEnabled = false headerItem.isEnabled = false
let statusText = isConnected let statusText = isConnected
? (self.cachedErrorText ?? "Loading sessions…") ? (self.cachedErrorText ?? "Loading sessions…")
: self.controlChannelStatusText(for: channelState) : "Gateway disconnected"
let hosted = self.makeHostedView( let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView( rootView: AnyView(MenuSessionsHeaderView(
count: 0, count: 0,
@@ -221,14 +218,6 @@ extension MenuSessionsInjector {
cursor += 1 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 } guard self.isControlChannelConnected else { return }
if let error = self.nodesStore.lastError?.nonEmpty { if let error = self.nodesStore.lastError?.nonEmpty {
@@ -283,36 +272,18 @@ extension MenuSessionsInjector {
} }
var cursor = cursor 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() let headerItem = NSMenuItem()
headerItem.tag = self.tag headerItem.tag = self.tag
headerItem.isEnabled = false headerItem.isEnabled = false
headerItem.view = self.makeHostedView( headerItem.view = self.makeHostedView(
rootView: AnyView(MenuUsageHeaderView( rootView: AnyView(MenuUsageHeaderView(
count: rows.count)), count: rows.count,
statusText: errorText)),
width: width, width: width,
highlighted: false) highlighted: false)
menu.insertItem(headerItem, at: cursor) menu.insertItem(headerItem, at: cursor)
cursor += 1 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 { if rows.isEmpty {
menu.insertItem( menu.insertItem(
self.makeMessageItem(text: errorText ?? "No usage available", symbolName: "minus", width: width), self.makeMessageItem(text: errorText ?? "No usage available", symbolName: "minus", width: width),
@@ -321,28 +292,6 @@ extension MenuSessionsInjector {
return cursor 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 { for row in rows {
let item = NSMenuItem() let item = NSMenuItem()
item.tag = self.tag item.tag = self.tag
@@ -358,34 +307,11 @@ extension MenuSessionsInjector {
return cursor 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] { private var usageRows: [UsageRow] {
guard let summary = self.cachedUsageSummary else { return [] } guard let summary = self.cachedUsageSummary else { return [] }
return summary.primaryRows() 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 { private var isControlChannelConnected: Bool {
#if DEBUG #if DEBUG
if let override = self.testControlChannelConnected { return override } if let override = self.testControlChannelConnected { return override }
@@ -394,19 +320,6 @@ extension MenuSessionsInjector {
return false return false
} }
private func controlChannelStatusText(for state: ControlChannel.ConnectionState) -> String {
switch state {
case .connected:
"Loading sessions…"
case .connecting:
"Connecting…"
case let .degraded(message):
message.nonEmpty ?? "Gateway disconnected"
case .disconnected:
"Gateway disconnected"
}
}
private func gatewayEntry() -> NodeInfo? { private func gatewayEntry() -> NodeInfo? {
let mode = AppStateStore.shared.connectionMode let mode = AppStateStore.shared.connectionMode
let isConnected = self.isControlChannelConnected let isConnected = self.isControlChannelConnected
@@ -478,31 +391,18 @@ extension MenuSessionsInjector {
return item 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( let view = AnyView(
HStack(alignment: .top, spacing: 8) { Label(text, systemImage: symbolName)
Image(systemName: symbolName) .font(.caption)
.font(.caption) .foregroundStyle(.secondary)
.foregroundStyle(.secondary) .multilineTextAlignment(.leading)
.frame(width: 14, alignment: .leading) .lineLimit(nil)
.padding(.top, 1) .fixedSize(horizontal: false, vertical: true)
.padding(.leading, 18)
Text(text) .padding(.trailing, 12)
.font(.caption) .padding(.vertical, 6)
.foregroundStyle(.secondary) .frame(minWidth: 300, alignment: .leading))
.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))
let item = NSMenuItem() let item = NSMenuItem()
item.tag = self.tag item.tag = self.tag
@@ -510,9 +410,7 @@ extension MenuSessionsInjector {
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false) item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
return item return item
} }
}
extension MenuSessionsInjector {
// MARK: - Cache // MARK: - Cache
private func refreshCache(force: Bool) async { private func refreshCache(force: Bool) async {
@@ -583,9 +481,7 @@ extension MenuSessionsInjector {
} }
return "Sessions unavailable" return "Sessions unavailable"
} }
}
extension MenuSessionsInjector {
// MARK: - Submenus // MARK: - Submenus
private func buildSubmenu(for row: SessionRow, storePath: String) -> NSMenu { private func buildSubmenu(for row: SessionRow, storePath: String) -> NSMenu {
@@ -917,9 +813,7 @@ extension MenuSessionsInjector {
NSPasteboard.general.clearContents() NSPasteboard.general.clearContents()
NSPasteboard.general.setString(value, forType: .string) NSPasteboard.general.setString(value, forType: .string)
} }
}
extension MenuSessionsInjector {
// MARK: - Width + placement // MARK: - Width + placement
private func findInsertIndex(in menu: NSMenu) -> Int? { private func findInsertIndex(in menu: NSMenu) -> Int? {
@@ -995,9 +889,7 @@ extension MenuSessionsInjector {
return lhsName < rhsName return lhsName < rhsName
} }
} }
}
extension MenuSessionsInjector {
// MARK: - Views // MARK: - Views
private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView { private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView {
@@ -1046,12 +938,6 @@ extension MenuSessionsInjector {
self.cacheUpdatedAt = Date() self.cacheUpdatedAt = Date()
} }
func setTestingUsageSummary(_ summary: GatewayUsageSummary?, errorText: String? = nil) {
self.cachedUsageSummary = summary
self.cachedUsageErrorText = errorText
self.usageCacheUpdatedAt = Date()
}
func injectForTesting(into menu: NSMenu) { func injectForTesting(into menu: NSMenu) {
self.inject(into: menu) self.inject(into: menu)
} }
@@ -2,6 +2,7 @@ import SwiftUI
struct MenuUsageHeaderView: View { struct MenuUsageHeaderView: View {
let count: Int let count: Int
let statusText: String?
private let paddingTop: CGFloat = 8 private let paddingTop: CGFloat = 8
private let paddingBottom: CGFloat = 6 private let paddingBottom: CGFloat = 6
@@ -19,6 +20,14 @@ struct MenuUsageHeaderView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if let statusText, !statusText.isEmpty {
Text(statusText)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
} }
.padding(.top, self.paddingTop) .padding(.top, self.paddingTop)
.padding(.bottom, self.paddingBottom) .padding(.bottom, self.paddingBottom)
@@ -20,14 +20,12 @@ actor MacNodeBridgeSession {
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private let clock = ContinuousClock() private let clock = ContinuousClock()
private var disconnectHandler: (@Sendable (String) async -> Void)?
private var connection: NWConnection? private var connection: NWConnection?
private var queue: DispatchQueue? private var queue: DispatchQueue?
private var buffer = Data() private var buffer = Data()
private var pendingRPC: [String: CheckedContinuation<BridgeRPCResponse, Error>] = [:] private var pendingRPC: [String: CheckedContinuation<BridgeRPCResponse, Error>] = [:]
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:] private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:]
private var invokeTasks: [UUID: Task<Void, Never>] = [:]
private var pingTask: Task<Void, Never>? private var pingTask: Task<Void, Never>?
private var lastPongAt: ContinuousClock.Instant? private var lastPongAt: ContinuousClock.Instant?
@@ -37,12 +35,10 @@ actor MacNodeBridgeSession {
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello, hello: BridgeHello,
onConnected: (@Sendable (String) async -> Void)? = nil, onConnected: (@Sendable (String) async -> Void)? = nil,
onDisconnected: (@Sendable (String) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
async throws async throws
{ {
await self.disconnect() await self.disconnect()
self.disconnectHandler = onDisconnected
self.state = .connecting self.state = .connecting
let params = NWParameters.tcp let params = NWParameters.tcp
@@ -87,7 +83,6 @@ actor MacNodeBridgeSession {
let data = line.data(using: .utf8), let data = line.data(using: .utf8),
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data) let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
else { else {
self.logger.error("node bridge hello failed (unexpected response)")
await self.disconnect() await self.disconnect()
throw NSError(domain: "Bridge", code: 1, userInfo: [ throw NSError(domain: "Bridge", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response", NSLocalizedDescriptionKey: "Unexpected bridge response",
@@ -102,67 +97,53 @@ actor MacNodeBridgeSession {
} else if base.type == "error" { } else if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data) let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
self.state = .failed(message: "\(err.code): \(err.message)") self.state = .failed(message: "\(err.code): \(err.message)")
self.logger.error("node bridge hello error: \(err.code, privacy: .public)")
await self.disconnect() await self.disconnect()
throw NSError(domain: "Bridge", code: 2, userInfo: [ throw NSError(domain: "Bridge", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(err.code): \(err.message)", NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
]) ])
} else { } else {
self.state = .failed(message: "Unexpected bridge response") self.state = .failed(message: "Unexpected bridge response")
self.logger.error("node bridge hello failed (unexpected frame)")
await self.disconnect() await self.disconnect()
throw NSError(domain: "Bridge", code: 3, userInfo: [ throw NSError(domain: "Bridge", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response", NSLocalizedDescriptionKey: "Unexpected bridge response",
]) ])
} }
do { while true {
while true { guard let next = try await self.receiveLine() else { break }
guard let next = try await self.receiveLine() else { break } guard let nextData = next.data(using: .utf8) else { continue }
guard let nextData = next.data(using: .utf8) else { continue } guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue }
guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue }
switch nextBase.type { switch nextBase.type {
case "res": case "res":
let res = try self.decoder.decode(BridgeRPCResponse.self, from: nextData) let res = try self.decoder.decode(BridgeRPCResponse.self, from: nextData)
if let cont = self.pendingRPC.removeValue(forKey: res.id) { if let cont = self.pendingRPC.removeValue(forKey: res.id) {
cont.resume(returning: res) 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
} }
}
await self.handleDisconnect(reason: "connection closed") case "event":
} catch { let evt = try self.decoder.decode(BridgeEventFrame.self, from: nextData)
self.logger.error( self.broadcastServerEvent(evt)
"node bridge receive failed: \(error.localizedDescription, privacy: .public)")
await self.handleDisconnect(reason: "receive failed") case "ping":
throw error let ping = try self.decoder.decode(BridgePing.self, from: nextData)
try await self.send(BridgePong(type: "pong", id: ping.id))
case "pong":
let pong = try self.decoder.decode(BridgePong.self, from: nextData)
self.notePong(pong)
case "invoke":
let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData)
let res = await onInvoke(req)
try await self.send(res)
default:
continue
}
} }
await self.disconnect()
} }
func sendEvent(event: String, payloadJSON: String?) async throws { func sendEvent(event: String, payloadJSON: String?) async throws {
@@ -224,8 +205,6 @@ actor MacNodeBridgeSession {
self.pingTask?.cancel() self.pingTask?.cancel()
self.pingTask = nil self.pingTask = nil
self.lastPongAt = nil self.lastPongAt = nil
self.disconnectHandler = nil
self.cancelInvokeTasks()
self.connection?.cancel() self.connection?.cancel()
self.connection = nil self.connection = nil
@@ -333,7 +312,6 @@ actor MacNodeBridgeSession {
private func startPingLoop() { private func startPingLoop() {
self.pingTask?.cancel() self.pingTask?.cancel()
self.lastPongAt = self.clock.now self.lastPongAt = self.clock.now
self.logger.debug("node bridge ping loop started")
self.pingTask = Task { [weak self] in self.pingTask = Task { [weak self] in
guard let self else { return } guard let self else { return }
await self.runPingLoop() await self.runPingLoop()
@@ -358,7 +336,7 @@ actor MacNodeBridgeSession {
"Node bridge heartbeat timed out; disconnecting " + "Node bridge heartbeat timed out; disconnecting " +
"(age: \(ageDescription, privacy: .public))." "(age: \(ageDescription, privacy: .public))."
self.logger.warning(message) self.logger.warning(message)
await self.handleDisconnect(reason: "ping timeout") await self.disconnect()
return return
} }
} }
@@ -372,7 +350,7 @@ actor MacNodeBridgeSession {
"Node bridge ping send failed; disconnecting " + "Node bridge ping send failed; disconnecting " +
"(error: \(errorDescription, privacy: .public))." "(error: \(errorDescription, privacy: .public))."
self.logger.warning(message) self.logger.warning(message)
await self.handleDisconnect(reason: "ping send failed") await self.disconnect()
return return
} }
} }
@@ -391,45 +369,15 @@ actor MacNodeBridgeSession {
"Node bridge connection failed; disconnecting " + "Node bridge connection failed; disconnecting " +
"(error: \(errorDescription, privacy: .public))." "(error: \(errorDescription, privacy: .public))."
self.logger.warning(message) self.logger.warning(message)
await self.handleDisconnect(reason: "connection failed") await self.disconnect()
case .cancelled: case .cancelled:
self.logger.warning("Node bridge connection cancelled; disconnecting.") self.logger.warning("Node bridge connection cancelled; disconnecting.")
await self.handleDisconnect(reason: "connection cancelled") await self.disconnect()
default: default:
break 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( private static func makeStateStream(
for connection: NWConnection) -> AsyncStream<NWConnection.State> for connection: NWConnection) -> AsyncStream<NWConnection.State>
{ {
@@ -1,4 +1,3 @@
import ClawdbotDiscovery
import ClawdbotKit import ClawdbotKit
import Foundation import Foundation
import Network import Network
@@ -62,17 +61,12 @@ final class MacNodeModeCoordinator {
retryDelay = 1_000_000_000 retryDelay = 1_000_000_000
do { do {
let hello = await self.makeHello() let hello = await self.makeHello()
self.logger.info(
"mac node bridge connecting endpoint=\(endpoint, privacy: .public)")
try await self.session.connect( try await self.session.connect(
endpoint: endpoint, endpoint: endpoint,
hello: hello, hello: hello,
onConnected: { [weak self] serverName in onConnected: { [weak self] serverName in
self?.logger.info("mac node connected to \(serverName, privacy: .public)") self?.logger.info("mac node connected to \(serverName, privacy: .public)")
}, },
onDisconnected: { reason in
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
},
onInvoke: { [weak self] req in onInvoke: { [weak self] req in
guard let self else { guard let self else {
return BridgeInvokeResponse( return BridgeInvokeResponse(
@@ -86,8 +80,7 @@ final class MacNodeModeCoordinator {
if await self.tryPair(endpoint: endpoint, error: error) { if await self.tryPair(endpoint: endpoint, error: error) {
continue continue
} }
self.logger.error( self.logger.error("mac node bridge connect failed: \(error.localizedDescription, privacy: .public)")
"mac node bridge connect failed: \(error.localizedDescription, privacy: .public)")
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000)) try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
retryDelay = min(retryDelay * 2, 10_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 }) let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode })
if mode == .remote { if mode == .remote {
do { do {
if let tunnel = self.tunnel, if self.tunnel == nil || self.tunnel?.process.isRunning == false {
tunnel.process.isRunning, let remotePort = Self.remoteBridgePort()
let localPort = tunnel.localPort self.tunnel = try await RemotePortTunnel.create(
{ remotePort: remotePort,
let healthy = await self.bridgeTunnelHealthy(localPort: localPort, timeoutSeconds: 1.0) allowRemoteUrlOverride: false)
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
} }
let remotePort = Self.remoteBridgePort()
self.tunnel = try await RemotePortTunnel.create(
remotePort: remotePort,
allowRemoteUrlOverride: false)
if let localPort = self.tunnel?.localPort, if let localPort = self.tunnel?.localPort,
let port = NWEndpoint.Port(rawValue: localPort) let port = NWEndpoint.Port(rawValue: localPort)
{ {
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) return .hostPort(host: "127.0.0.1", port: port)
} }
} catch { } catch {
@@ -336,21 +311,6 @@ final class MacNodeModeCoordinator {
return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds) 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? { private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
final class DiscoveryState: @unchecked Sendable { final class DiscoveryState: @unchecked Sendable {
let lock = NSLock() let lock = NSLock()
@@ -222,15 +222,7 @@ actor MacNodeRuntime {
(Self.locationPreciseEnabled() ? .precise : .balanced) (Self.locationPreciseEnabled() ? .precise : .balanced)
let services = await self.mainActorServices() let services = await self.mainActorServices()
let status = await services.locationAuthorizationStatus() let status = await services.locationAuthorizationStatus()
let hasPermission = switch mode { if status != .authorizedAlways {
case .always:
status == .authorizedAlways
case .whileUsing:
status == .authorizedAlways
case .off:
false
}
if !hasPermission {
return BridgeInvokeResponse( return BridgeInvokeResponse(
id: req.id, id: req.id,
ok: false, ok: false,
@@ -1,5 +1,4 @@
import AppKit import AppKit
import ClawdbotDiscovery
import ClawdbotIPC import ClawdbotIPC
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
@@ -534,7 +533,7 @@ final class NodePairingApprovalPrompter {
return SSHTarget(host: host, port: port) return SSHTarget(host: host, port: port)
} }
let model = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) let model = GatewayDiscoveryModel()
model.start() model.start()
defer { model.stop() } defer { model.stop() }
+10 -25
View File
@@ -1,6 +1,5 @@
import AppKit import AppKit
import ClawdbotChatUI import ClawdbotChatUI
import ClawdbotDiscovery
import ClawdbotIPC import ClawdbotIPC
import Combine import Combine
import Observation import Observation
@@ -32,7 +31,7 @@ final class OnboardingController {
let hosting = NSHostingController(rootView: OnboardingView()) let hosting = NSHostingController(rootView: OnboardingView())
let window = NSWindow(contentViewController: hosting) let window = NSWindow(contentViewController: hosting)
window.title = UIStrings.welcomeTitle 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.styleMask = [.titled, .closable, .fullSizeContentView]
window.titlebarAppearsTransparent = true window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden window.titleVisibility = .hidden
@@ -48,11 +47,6 @@ final class OnboardingController {
self.window?.close() self.window?.close()
self.window = nil self.window = nil
} }
func restart() {
self.close()
self.show()
}
} }
struct OnboardingView: View { struct OnboardingView: View {
@@ -98,10 +92,7 @@ struct OnboardingView: View {
@Bindable var state: AppState @Bindable var state: AppState
var permissionMonitor: PermissionMonitor var permissionMonitor: PermissionMonitor
static let windowWidth: CGFloat = 630 let pageWidth: CGFloat = 630
static let windowHeight: CGFloat = 752 // ~+10% to fit full onboarding content
let pageWidth: CGFloat = Self.windowWidth
let contentHeight: CGFloat = 460 let contentHeight: CGFloat = 460
let connectionPageIndex = 1 let connectionPageIndex = 1
let anthropicAuthPageIndex = 2 let anthropicAuthPageIndex = 2
@@ -120,26 +111,22 @@ struct OnboardingView: View {
let permissionsPageIndex = 5 let permissionsPageIndex = 5
static func pageOrder( static func pageOrder(
for mode: AppState.ConnectionMode, for mode: AppState.ConnectionMode,
showOnboardingChat: Bool) -> [Int] needsBootstrap: Bool) -> [Int]
{ {
switch mode { switch mode {
case .remote: case .remote:
// Remote setup doesn't need local gateway/CLI/workspace setup pages, // Remote setup doesn't need local gateway/CLI/workspace setup pages,
// and WhatsApp/Telegram setup is optional. // and WhatsApp/Telegram setup is optional.
showOnboardingChat ? [0, 1, 5, 8, 9] : [0, 1, 5, 9] needsBootstrap ? [0, 1, 5, 8, 9] : [0, 1, 5, 9]
case .unconfigured: case .unconfigured:
showOnboardingChat ? [0, 1, 8, 9] : [0, 1, 9] needsBootstrap ? [0, 1, 8, 9] : [0, 1, 9]
case .local: case .local:
showOnboardingChat ? [0, 1, 3, 5, 8, 9] : [0, 1, 3, 5, 9] needsBootstrap ? [0, 1, 3, 5, 8, 9] : [0, 1, 3, 5, 9]
} }
} }
var showOnboardingChat: Bool {
self.state.connectionMode == .local && self.needsBootstrap
}
var pageOrder: [Int] { var pageOrder: [Int] {
Self.pageOrder(for: self.state.connectionMode, showOnboardingChat: self.showOnboardingChat) Self.pageOrder(for: self.state.connectionMode, needsBootstrap: self.needsBootstrap)
} }
var pageCount: Int { self.pageOrder.count } var pageCount: Int { self.pageOrder.count }
@@ -155,8 +142,8 @@ struct OnboardingView: View {
var canAdvance: Bool { !self.isWizardBlocking } var canAdvance: Bool { !self.isWizardBlocking }
var devLinkCommand: String { var devLinkCommand: String {
let version = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest" let bundlePath = Bundle.main.bundlePath
return "npm install -g clawdbot@\(version)" return "ln -sf '\(bundlePath)/Contents/Resources/Relay/clawdbot' /usr/local/bin/clawdbot"
} }
struct LocalGatewayProbe: Equatable { struct LocalGatewayProbe: Equatable {
@@ -169,9 +156,7 @@ struct OnboardingView: View {
init( init(
state: AppState = AppStateStore.shared, state: AppState = AppStateStore.shared,
permissionMonitor: PermissionMonitor = .shared, permissionMonitor: PermissionMonitor = .shared,
discoveryModel: GatewayDiscoveryModel = GatewayDiscoveryModel( discoveryModel: GatewayDiscoveryModel = GatewayDiscoveryModel())
localDisplayName: InstanceIdentity.displayName,
filterLocalGateways: false))
{ {
self.state = state self.state = state
self.permissionMonitor = permissionMonitor self.permissionMonitor = permissionMonitor
@@ -1,7 +1,5 @@
import AppKit import AppKit
import ClawdbotDiscovery
import ClawdbotIPC import ClawdbotIPC
import Foundation
import SwiftUI import SwiftUI
extension OnboardingView { extension OnboardingView {
@@ -42,9 +40,7 @@ extension OnboardingView {
func openSettings(tab: SettingsTab) { func openSettings(tab: SettingsTab) {
SettingsTabRouter.request(tab) SettingsTabRouter.request(tab)
self.openSettings() self.openSettings()
DispatchQueue.main.async { NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab)
NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab)
}
} }
func handleBack() { func handleBack() {
@@ -3,7 +3,7 @@ import Foundation
extension OnboardingView { extension OnboardingView {
func maybeKickoffOnboardingChat(for pageIndex: Int) { func maybeKickoffOnboardingChat(for pageIndex: Int) {
guard pageIndex == self.onboardingChatPageIndex else { return } guard pageIndex == self.onboardingChatPageIndex else { return }
guard self.showOnboardingChat else { return } guard self.needsBootstrap else { return }
guard !self.didAutoKickoff else { return } guard !self.didAutoKickoff else { return }
self.didAutoKickoff = true self.didAutoKickoff = true
@@ -27,7 +27,7 @@ extension OnboardingView {
Spacer(minLength: 0) Spacer(minLength: 0)
self.navigationBar self.navigationBar
} }
.frame(width: self.pageWidth, height: Self.windowHeight) .frame(width: self.pageWidth, height: 684)
.background(Color(NSColor.windowBackgroundColor)) .background(Color(NSColor.windowBackgroundColor))
.onAppear { .onAppear {
self.currentPage = 0 self.currentPage = 0
@@ -54,7 +54,6 @@ extension OnboardingView {
self.stopPermissionMonitoring() self.stopPermissionMonitoring()
self.stopDiscovery() self.stopDiscovery()
self.stopAuthMonitoring() self.stopAuthMonitoring()
Task { await self.onboardingWizard.cancelIfRunning() }
} }
.task { .task {
await self.refreshPerms() await self.refreshPerms()
@@ -174,22 +173,6 @@ extension OnboardingView {
.shadow(color: .black.opacity(0.06), radius: 8, y: 3)) .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 { func featureRow(title: String, subtitle: String, systemImage: String) -> some View {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
Image(systemName: systemImage) Image(systemName: systemImage)
@@ -33,7 +33,7 @@ extension OnboardingView {
if shouldMonitor, !self.monitoringDiscovery { if shouldMonitor, !self.monitoringDiscovery {
self.monitoringDiscovery = true self.monitoringDiscovery = true
Task { @MainActor in Task { @MainActor in
try? await Task.sleep(nanoseconds: 150_000_000) try? await Task.sleep(nanoseconds: 550_000_000)
guard self.monitoringDiscovery else { return } guard self.monitoringDiscovery else { return }
self.gatewayDiscovery.start() self.gatewayDiscovery.start()
await self.refreshLocalGatewayProbe() await self.refreshLocalGatewayProbe()
@@ -95,7 +95,7 @@ extension OnboardingView {
self.installingCLI = true self.installingCLI = true
defer { installingCLI = false } defer { installingCLI = false }
await CLIInstaller.install { message in await CLIInstaller.install { message in
self.cliStatus = message await MainActor.run { self.cliStatus = message }
} }
self.refreshCLIStatus() self.refreshCLIStatus()
} }
@@ -1,6 +1,5 @@
import AppKit import AppKit
import ClawdbotChatUI import ClawdbotChatUI
import ClawdbotDiscovery
import ClawdbotIPC import ClawdbotIPC
import SwiftUI import SwiftUI
@@ -116,11 +115,6 @@ extension OnboardingView {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if self.gatewayDiscovery.gateways.isEmpty { if self.gatewayDiscovery.gateways.isEmpty {
ProgressView().controlSize(.small) ProgressView().controlSize(.small)
Button("Refresh") {
self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0)
}
.buttonStyle(.link)
.help("Retry Tailscale discovery (DNS-SD).")
} }
Spacer(minLength: 0) Spacer(minLength: 0)
} }
@@ -494,9 +488,9 @@ extension OnboardingView {
func cliPage() -> some View { func cliPage() -> some View {
self.onboardingPage { self.onboardingPage {
Text("Install the CLI") Text("Install the helper CLI")
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
Text("Required for local mode: installs `clawdbot` so launchd can run the gateway.") Text("Optional, but recommended: link `clawdbot` so scripts can reach the local gateway.")
.font(.body) .font(.body)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@@ -522,7 +516,7 @@ extension OnboardingView {
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(self.installingCLI) .disabled(self.installingCLI)
Button(self.copied ? "Copied" : "Copy install command") { Button(self.copied ? "Copied" : "Copy dev link") {
self.copyToPasteboard(self.devLinkCommand) self.copyToPasteboard(self.devLinkCommand)
} }
.disabled(self.installingCLI) .disabled(self.installingCLI)
@@ -541,8 +535,8 @@ extension OnboardingView {
} else if !self.cliInstalled, self.cliInstallLocation == nil { } else if !self.cliInstalled, self.cliInstallLocation == nil {
Text( Text(
""" """
Installs a user-space Node 22+ runtime and the CLI (no Homebrew). We install into /usr/local/bin and /opt/homebrew/bin.
Rerun anytime to reinstall or update. Rerun anytime if you move the build output.
""") """)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -654,7 +648,7 @@ extension OnboardingView {
.frame(maxWidth: 520) .frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
self.onboardingGlassCard(padding: 8) { self.onboardingCard(padding: 8) {
ClawdbotChatView(viewModel: self.onboardingChatModel, style: .onboarding) ClawdbotChatView(viewModel: self.onboardingChatModel, style: .onboarding)
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
} }
@@ -694,7 +688,7 @@ extension OnboardingView {
systemImage: "bubble.left.and.bubble.right") systemImage: "bubble.left.and.bubble.right")
self.featureActionRow( self.featureActionRow(
title: "Connect WhatsApp or Telegram", title: "Connect WhatsApp or Telegram",
subtitle: "Open Settings → Connections to link channels and monitor status.", subtitle: "Open Settings → Connections to link providers and monitor status.",
systemImage: "link") systemImage: "link")
{ {
self.openSettings(tab: .connections) self.openSettings(tab: .connections)
@@ -1,4 +1,3 @@
import ClawdbotDiscovery
import SwiftUI import SwiftUI
#if DEBUG #if DEBUG
@@ -6,7 +5,7 @@ import SwiftUI
extension OnboardingView { extension OnboardingView {
static func exerciseForTesting() { static func exerciseForTesting() {
let state = AppState(preview: true) let state = AppState(preview: true)
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) let discovery = GatewayDiscoveryModel()
discovery.statusText = "Searching..." discovery.statusText = "Searching..."
let gateway = GatewayDiscoveryModel.DiscoveredGateway( let gateway = GatewayDiscoveryModel.DiscoveredGateway(
displayName: "Test Bridge", displayName: "Test Bridge",
@@ -35,10 +35,6 @@ final class OnboardingWizardModel {
private(set) var errorMessage: String? private(set) var errorMessage: String?
var isStarting = false var isStarting = false
var isSubmitting = false var isSubmitting = false
private var lastStartMode: AppState.ConnectionMode?
private var lastStartWorkspace: String?
private var restartAttempts = 0
private let maxRestartAttempts = 1
var isComplete: Bool { self.status == "done" } var isComplete: Bool { self.status == "done" }
var isRunning: Bool { self.status == "running" } var isRunning: Bool { self.status == "running" }
@@ -50,9 +46,6 @@ final class OnboardingWizardModel {
self.errorMessage = nil self.errorMessage = nil
self.isStarting = false self.isStarting = false
self.isSubmitting = false self.isSubmitting = false
self.restartAttempts = 0
self.lastStartMode = nil
self.lastStartWorkspace = nil
} }
func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async { func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async {
@@ -60,18 +53,9 @@ final class OnboardingWizardModel {
guard mode == .local else { return } guard mode == .local else { return }
self.isStarting = true self.isStarting = true
self.errorMessage = nil self.errorMessage = nil
self.lastStartMode = mode
self.lastStartWorkspace = workspace
defer { self.isStarting = false } defer { self.isStarting = false }
do { do {
GatewayProcessManager.shared.setActive(true)
if await GatewayProcessManager.shared.waitForGatewayReady(timeout: 12) == false {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Gateway did not become ready. Check that it is running."])
}
var params: [String: AnyCodable] = ["mode": AnyCodable("local")] var params: [String: AnyCodable] = ["mode": AnyCodable("local")]
if let workspace, !workspace.isEmpty { if let workspace, !workspace.isEmpty {
params["workspace"] = AnyCodable(workspace) params["workspace"] = AnyCodable(workspace)
@@ -105,9 +89,6 @@ final class OnboardingWizardModel {
params: params) params: params)
self.applyNextResult(res) self.applyNextResult(res)
} catch { } catch {
if self.restartIfSessionLost(error: error) {
return
}
self.status = "error" self.status = "error"
self.errorMessage = error.localizedDescription self.errorMessage = error.localizedDescription
onboardingWizardLogger.error("submit failed: \(error.localizedDescription, privacy: .public)") onboardingWizardLogger.error("submit failed: \(error.localizedDescription, privacy: .public)")
@@ -130,53 +111,30 @@ final class OnboardingWizardModel {
private func applyStartResult(_ res: WizardStartResult) { private func applyStartResult(_ res: WizardStartResult) {
self.sessionId = res.sessionid self.sessionId = res.sessionid
self.status = wizardStatusString(res.status) ?? (res.done ? "done" : "running") self.status = anyCodableStringValue(res.status) ?? (res.done ? "done" : "running")
self.errorMessage = res.error self.errorMessage = res.error
self.currentStep = decodeWizardStep(res.step) self.currentStep = decodeWizardStep(res.step)
if self.currentStep == nil, res.step != nil {
onboardingWizardLogger.error("wizard step decode failed")
}
if res.done { self.currentStep = nil } if res.done { self.currentStep = nil }
self.restartAttempts = 0
} }
private func applyNextResult(_ res: WizardNextResult) { private func applyNextResult(_ res: WizardNextResult) {
let status = wizardStatusString(res.status) self.status = anyCodableStringValue(res.status) ?? self.status
self.status = status ?? self.status
self.errorMessage = res.error self.errorMessage = res.error
self.currentStep = decodeWizardStep(res.step) self.currentStep = decodeWizardStep(res.step)
if self.currentStep == nil, res.step != nil {
onboardingWizardLogger.error("wizard step decode failed")
}
if res.done { self.currentStep = nil } if res.done { self.currentStep = nil }
if res.done || status == "done" || status == "cancelled" || status == "error" { if res.done || anyCodableStringValue(res.status) == "done" || anyCodableStringValue(res.status) == "cancelled"
|| anyCodableStringValue(res.status) == "error"
{
self.sessionId = nil self.sessionId = nil
} }
} }
private func applyStatusResult(_ res: WizardStatusResult) { private func applyStatusResult(_ res: WizardStatusResult) {
self.status = wizardStatusString(res.status) ?? "unknown" self.status = anyCodableStringValue(res.status) ?? "unknown"
self.errorMessage = res.error self.errorMessage = res.error
self.currentStep = nil self.currentStep = nil
self.sessionId = nil self.sessionId = nil
} }
private func restartIfSessionLost(error: Error) -> Bool {
guard let gatewayError = error as? GatewayResponseError else { return false }
guard gatewayError.code == ErrorCode.invalidRequest.rawValue else { return false }
let message = gatewayError.message.lowercased()
guard message.contains("wizard not found") || message.contains("wizard not running") else { return false }
guard let mode = self.lastStartMode, self.restartAttempts < self.maxRestartAttempts else {
return false
}
self.restartAttempts += 1
self.sessionId = nil
self.currentStep = nil
self.status = nil
self.errorMessage = "Wizard session lost. Restarting…"
Task { await self.startIfNeeded(mode: mode, workspace: self.lastStartWorkspace) }
return true
}
} }
struct OnboardingWizardStepView: View { struct OnboardingWizardStepView: View {
@@ -375,3 +333,98 @@ private struct WizardOptionItem: Identifiable {
var id: Int { self.index } var id: Int { self.index }
} }
private struct WizardOption {
let value: ProtocolAnyCodable?
let label: String
let hint: String?
}
private func decodeWizardStep(_ raw: [String: ProtocolAnyCodable]?) -> WizardStep? {
guard let raw else { return nil }
do {
let data = try JSONEncoder().encode(raw)
return try JSONDecoder().decode(WizardStep.self, from: data)
} catch {
onboardingWizardLogger.error("wizard step decode failed: \(error.localizedDescription, privacy: .public)")
return nil
}
}
private func parseWizardOptions(_ raw: [[String: ProtocolAnyCodable]]?) -> [WizardOption] {
guard let raw else { return [] }
return raw.map { entry in
let value = entry["value"]
let label = (entry["label"]?.value as? String) ?? ""
let hint = entry["hint"]?.value as? String
return WizardOption(value: value, label: label, hint: hint)
}
}
private func wizardStepType(_ step: WizardStep) -> String {
(step.type.value as? String) ?? ""
}
private func anyCodableString(_ value: ProtocolAnyCodable?) -> String {
switch value?.value {
case let string as String:
string
case let int as Int:
String(int)
case let double as Double:
String(double)
case let bool as Bool:
bool ? "true" : "false"
default:
""
}
}
private func anyCodableStringValue(_ value: ProtocolAnyCodable?) -> String? {
value?.value as? String
}
private func anyCodableBool(_ value: ProtocolAnyCodable?) -> Bool {
switch value?.value {
case let bool as Bool:
bool
case let string as String:
string.lowercased() == "true"
default:
false
}
}
private func anyCodableArray(_ value: ProtocolAnyCodable?) -> [ProtocolAnyCodable] {
switch value?.value {
case let arr as [ProtocolAnyCodable]:
arr
case let arr as [Any]:
arr.map { ProtocolAnyCodable($0) }
default:
[]
}
}
private func anyCodableEqual(_ lhs: ProtocolAnyCodable?, _ rhs: ProtocolAnyCodable?) -> Bool {
switch (lhs?.value, rhs?.value) {
case let (l as String, r as String):
l == r
case let (l as Int, r as Int):
l == r
case let (l as Double, r as Double):
l == r
case let (l as Bool, r as Bool):
l == r
case let (l as String, r as Int):
l == String(r)
case let (l as Int, r as String):
String(l) == r
case let (l as String, r as Double):
l == String(r)
case let (l as Double, r as String):
String(l) == r
default:
false
}
}
@@ -10,18 +10,6 @@ import Speech
import UserNotifications import UserNotifications
enum PermissionManager { 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] { static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] {
var results: [Capability: Bool] = [:] var results: [Capability: Bool] = [:]
for cap in caps { for cap in caps {
@@ -150,23 +138,18 @@ enum PermissionManager {
} }
private static func ensureLocation(interactive: Bool) async -> Bool { 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 let status = CLLocationManager().authorizationStatus
switch status { switch status {
case .authorizedAlways, .authorizedWhenInUse, .authorized: // Note: macOS only supports authorizedAlways, not authorizedWhenInUse (iOS only)
case .authorizedAlways:
return true return true
case .notDetermined: case .notDetermined:
guard interactive else { return false } guard interactive else { return false }
let updated = await LocationPermissionRequester.shared.request(always: false) let updated = await LocationPermissionRequester.shared.request(always: false)
return self.isLocationAuthorized(status: updated, requireAlways: false) return updated == .authorizedAlways
case .denied, .restricted: case .denied, .restricted:
if interactive { if interactive {
await MainActor.run { LocationPermissionHelper.openSettings() } LocationPermissionHelper.openSettings()
} }
return false return false
@unknown default: @unknown default:
@@ -219,8 +202,8 @@ enum PermissionManager {
case .location: case .location:
let status = CLLocationManager().authorizationStatus let status = CLLocationManager().authorizationStatus
results[cap] = CLLocationManager.locationServicesEnabled() // Note: macOS only supports authorizedAlways
&& self.isLocationAuthorized(status: status, requireAlways: false) results[cap] = status == .authorizedAlways
} }
} }
return results return results
@@ -292,7 +275,6 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
static let shared = LocationPermissionRequester() static let shared = LocationPermissionRequester()
private let manager = CLLocationManager() private let manager = CLLocationManager()
private var continuation: CheckedContinuation<CLAuthorizationStatus, Never>? private var continuation: CheckedContinuation<CLAuthorizationStatus, Never>?
private var timeoutTask: Task<Void, Never>?
override init() { override init() {
super.init() super.init()
@@ -300,74 +282,23 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
} }
func request(always: Bool) async -> CLAuthorizationStatus { func request(always: Bool) async -> CLAuthorizationStatus {
let current = self.manager.authorizationStatus if always {
if PermissionManager.isLocationAuthorized(status: current, requireAlways: always) { self.manager.requestAlwaysAuthorization()
return current } else {
self.manager.requestWhenInUseAuthorization()
} }
return await withCheckedContinuation { cont in return await withCheckedContinuation { cont in
self.continuation = cont 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 for Swift 6 strict concurrency compatibility
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus let status = manager.authorizationStatus
Task { @MainActor in Task { @MainActor in
self.finish(status: status) guard let cont = self.continuation else { return }
} self.continuation = nil
} cont.resume(returning: 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)
} }
} }
} }
@@ -15,7 +15,7 @@ struct PermissionsSettings: View {
.padding(.horizontal, 2) .padding(.horizontal, 2)
.padding(.vertical, 6) .padding(.vertical, 6)
Button("Restart onboarding") { self.showOnboarding() } Button("Show onboarding") { self.showOnboarding() }
.buttonStyle(.bordered) .buttonStyle(.bordered)
Spacer() Spacer()
} }
@@ -344,17 +344,14 @@ actor PortGuardian {
private func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool { private func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool {
let cmd = listener.command.lowercased() let cmd = listener.command.lowercased()
let full = listener.fullCommand.lowercased() let expectedCommands = ["node", "clawdbot", "tsx", "pnpm", "bun"]
switch mode { switch mode {
case .remote: case .remote:
// Remote mode expects an SSH tunnel for the gateway WebSocket port. // Remote mode expects an SSH tunnel for the gateway WebSocket port.
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") } if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
return false return false
case .local: case .local:
if !cmd.contains("clawdbot") { return false } return expectedCommands.contains { cmd.contains($0) }
if full.contains("gateway-daemon") { return true }
// If args are unavailable, treat a clawdbot listener as expected.
return full == cmd
case .unconfigured: case .unconfigured:
return false return false
} }
@@ -41,8 +41,7 @@ final class RemotePortTunnel {
static func create( static func create(
remotePort: Int, remotePort: Int,
preferredLocalPort: UInt16? = nil, preferredLocalPort: UInt16? = nil,
allowRemoteUrlOverride: Bool = true, allowRemoteUrlOverride: Bool = true) async throws -> RemotePortTunnel
allowRandomLocalPort: Bool = true) async throws -> RemotePortTunnel
{ {
let settings = CommandResolver.connectionSettings() let settings = CommandResolver.connectionSettings()
guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else { guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else {
@@ -52,9 +51,7 @@ final class RemotePortTunnel {
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not configured"]) userInfo: [NSLocalizedDescriptionKey: "Remote mode is not configured"])
} }
let localPort = try await Self.findPort( let localPort = try await Self.findPort(preferred: preferredLocalPort)
preferred: preferredLocalPort,
allowRandom: allowRandomLocalPort)
let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines) let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines)
let remotePortOverride = let remotePortOverride =
allowRemoteUrlOverride && remotePort == GatewayEnvironment.gatewayPort() allowRemoteUrlOverride && remotePort == GatewayEnvironment.gatewayPort()
@@ -175,16 +172,8 @@ final class RemotePortTunnel {
return trimmed.split(separator: ".").first.map(String.init) ?? trimmed 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, 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 return try await withCheckedThrowingContinuation { cont in
let queue = DispatchQueue(label: "com.clawdbot.remote.tunnel.port", qos: .utility) 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 let logger = Logger(subsystem: "com.clawdbot", category: "remote-tunnel")
private var controlTunnel: RemotePortTunnel? private var controlTunnel: RemotePortTunnel?
private var restartInFlight = false
private var lastRestartAt: Date?
private let restartBackoffSeconds: TimeInterval = 2.0
func controlTunnelPortIfRunning() async -> UInt16? { 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, if let tunnel = self.controlTunnel,
tunnel.process.isRunning, tunnel.process.isRunning,
let local = tunnel.localPort let local = tunnel.localPort
@@ -25,7 +18,6 @@ actor RemoteTunnelManager {
return local return local
} }
self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting") self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting")
await self.beginRestart()
tunnel.terminate() tunnel.terminate()
self.controlTunnel = nil self.controlTunnel = nil
} }
@@ -42,11 +34,6 @@ actor RemoteTunnelManager {
"pid=\(desc.pid, privacy: .public)") "pid=\(desc.pid, privacy: .public)")
return desiredPort 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) await self.cleanupStaleTunnel(desc: desc, port: desiredPort)
} }
return nil return nil
@@ -69,15 +56,12 @@ actor RemoteTunnelManager {
"identitySet=\(identitySet, privacy: .public)") "identitySet=\(identitySet, privacy: .public)")
if let local = await self.controlTunnelPortIfRunning() { return local } if let local = await self.controlTunnelPortIfRunning() { return local }
await self.waitForRestartBackoffIfNeeded()
let desiredPort = UInt16(GatewayEnvironment.gatewayPort()) let desiredPort = UInt16(GatewayEnvironment.gatewayPort())
let tunnel = try await RemotePortTunnel.create( let tunnel = try await RemotePortTunnel.create(
remotePort: GatewayEnvironment.gatewayPort(), remotePort: GatewayEnvironment.gatewayPort(),
preferredLocalPort: desiredPort, preferredLocalPort: desiredPort)
allowRandomLocalPort: false)
self.controlTunnel = tunnel self.controlTunnel = tunnel
self.endRestart()
let resolvedPort = tunnel.localPort ?? desiredPort let resolvedPort = tunnel.localPort ?? desiredPort
self.logger.info("ssh tunnel ready localPort=\(resolvedPort, privacy: .public)") self.logger.info("ssh tunnel ready localPort=\(resolvedPort, privacy: .public)")
return tunnel.localPort ?? desiredPort return tunnel.localPort ?? desiredPort
@@ -99,35 +83,6 @@ actor RemoteTunnelManager {
return false 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 { private func cleanupStaleTunnel(desc: PortGuardian.Descriptor, port: UInt16) async {
let pid = desc.pid let pid = desc.pid
self.logger.error( self.logger.error(
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.11-4</string> <string>2026.1.9</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>202601113</string> <string>20260109</string>
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>
<string>Clawdbot</string> <string>Clawdbot</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -47,14 +47,10 @@
<string>Clawdbot captures the screen when the agent needs screenshots for context.</string> <string>Clawdbot captures the screen when the agent needs screenshots for context.</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>Clawdbot can capture photos or short video clips when requested by the agent.</string> <string>Clawdbot can capture photos or short video clips when requested by the agent.</string>
<key>NSLocationUsageDescription</key> <key>NSLocationUsageDescription</key>
<string>Clawdbot can share your location when requested by the agent.</string> <string>Clawdbot can share your location when requested by the agent.</string>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>Clawdbot can share your location when requested by the agent.</string> <string>Clawdbot needs the mic for Voice Wake tests and agent audio capture.</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>NSSpeechRecognitionUsageDescription</key> <key>NSSpeechRecognitionUsageDescription</key>
<string>Clawdbot uses speech recognition to detect your Voice Wake trigger phrase.</string> <string>Clawdbot uses speech recognition to detect your Voice Wake trigger phrase.</string>
<key>NSAppleEventsUsageDescription</key> <key>NSAppleEventsUsageDescription</key>
@@ -58,7 +58,7 @@ struct SettingsRootView: View {
PermissionsSettings( PermissionsSettings(
status: self.permissionMonitor.status, status: self.permissionMonitor.status,
refresh: self.refreshPerms, refresh: self.refreshPerms,
showOnboarding: { DebugActions.restartOnboarding() }) showOnboarding: { OnboardingController.shared.show() })
.tabItem { Label("Permissions", systemImage: "lock.shield") } .tabItem { Label("Permissions", systemImage: "lock.shield") }
.tag(SettingsTab.permissions) .tag(SettingsTab.permissions)
@@ -1,36 +0,0 @@
import AppKit
import SwiftUI
@objc
private protocol SettingsWindowMenuActions {
@objc(showSettingsWindow:)
optional func showSettingsWindow(_ sender: Any?)
@objc(showPreferencesWindow:)
optional func showPreferencesWindow(_ sender: Any?)
}
@MainActor
final class SettingsWindowOpener {
static let shared = SettingsWindowOpener()
private var openSettingsAction: OpenSettingsAction?
func register(openSettings: OpenSettingsAction) {
self.openSettingsAction = openSettings
}
func open() {
NSApp.activate(ignoringOtherApps: true)
if let openSettingsAction {
openSettingsAction()
return
}
// Fallback path: mimic the built-in Settings menu item action.
let didOpen = NSApp.sendAction(#selector(SettingsWindowMenuActions.showSettingsWindow(_:)), to: nil, from: nil)
if !didOpen {
_ = NSApp.sendAction(#selector(SettingsWindowMenuActions.showPreferencesWindow(_:)), to: nil, from: nil)
}
}
}
@@ -50,13 +50,10 @@ enum ShellExecutor {
errorMessage: "failed to start: \(error.localizedDescription)") errorMessage: "failed to start: \(error.localizedDescription)")
} }
let outTask = Task { stdoutPipe.fileHandleForReading.readToEndSafely() }
let errTask = Task { stderrPipe.fileHandleForReading.readToEndSafely() }
let waitTask = Task { () -> ShellResult in let waitTask = Task { () -> ShellResult in
process.waitUntilExit() process.waitUntilExit()
let out = await outTask.value let out = stdoutPipe.fileHandleForReading.readToEndSafely()
let err = await errTask.value let err = stderrPipe.fileHandleForReading.readToEndSafely()
let status = Int(process.terminationStatus) let status = Int(process.terminationStatus)
return ShellResult( return ShellResult(
stdout: String(bytes: out, encoding: .utf8) ?? "", stdout: String(bytes: out, encoding: .utf8) ?? "",
+1 -8
View File
@@ -21,7 +21,6 @@ struct GatewayUsageSummary: Codable {
struct UsageRow: Identifiable { struct UsageRow: Identifiable {
let id: String let id: String
let providerId: String
let displayName: String let displayName: String
let plan: String? let plan: String?
let windowLabel: String? let windowLabel: String?
@@ -29,11 +28,6 @@ struct UsageRow: Identifiable {
let resetAt: Date? let resetAt: Date?
let error: String? let error: String?
var hasError: Bool {
if let error, !error.isEmpty { return true }
return false
}
var titleText: String { var titleText: String {
if let plan, !plan.isEmpty { return "\(self.displayName) (\(plan))" } if let plan, !plan.isEmpty { return "\(self.displayName) (\(plan))" }
return self.displayName return self.displayName
@@ -46,6 +40,7 @@ struct UsageRow: Identifiable {
} }
func detailText(now: Date = .init()) -> String { func detailText(now: Date = .init()) -> String {
if let error, !error.isEmpty { return error }
guard let remaining = self.remainingPercent else { return "No data" } guard let remaining = self.remainingPercent else { return "No data" }
var parts = ["\(remaining)% left"] var parts = ["\(remaining)% left"]
if let windowLabel, !windowLabel.isEmpty { parts.append(windowLabel) } if let windowLabel, !windowLabel.isEmpty { parts.append(windowLabel) }
@@ -78,7 +73,6 @@ extension GatewayUsageSummary {
if let error = provider.error, provider.windows.isEmpty { if let error = provider.error, provider.windows.isEmpty {
return UsageRow( return UsageRow(
id: provider.provider, id: provider.provider,
providerId: provider.provider,
displayName: provider.displayName, displayName: provider.displayName,
plan: provider.plan, plan: provider.plan,
windowLabel: nil, windowLabel: nil,
@@ -93,7 +87,6 @@ extension GatewayUsageSummary {
return UsageRow( return UsageRow(
id: "\(provider.provider)-\(window.label)", id: "\(provider.provider)-\(window.label)",
providerId: provider.provider,
displayName: provider.displayName, displayName: provider.displayName,
plan: provider.plan, plan: provider.plan,
windowLabel: window.label, windowLabel: window.label,
@@ -3,19 +3,12 @@ import SwiftUI
struct UsageMenuLabelView: View { struct UsageMenuLabelView: View {
let row: UsageRow let row: UsageRow
let width: CGFloat let width: CGFloat
var showsChevron: Bool = false
@Environment(\.menuItemHighlighted) private var isHighlighted
private let paddingLeading: CGFloat = 22 private let paddingLeading: CGFloat = 22
private let paddingTrailing: CGFloat = 14 private let paddingTrailing: CGFloat = 14
private let barHeight: CGFloat = 6 private let barHeight: CGFloat = 6
private var primaryTextColor: Color { private var primaryTextColor: Color { .primary }
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary private var secondaryTextColor: Color { .secondary }
}
private var secondaryTextColor: Color {
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@@ -37,31 +30,12 @@ struct UsageMenuLabelView: View {
Spacer(minLength: 4) Spacer(minLength: 4)
if !self.row.hasError { Text(self.row.detailText())
Text(self.row.detailText()) .font(.caption.monospacedDigit())
.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)
.foregroundStyle(self.secondaryTextColor) .foregroundStyle(self.secondaryTextColor)
.multilineTextAlignment(.leading) .lineLimit(1)
.lineLimit(2) .fixedSize(horizontal: true, vertical: false)
.truncationMode(.tail) .layoutPriority(2)
.fixedSize(horizontal: false, vertical: true)
} }
} }
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -37,7 +37,7 @@ enum VoiceWakeForwarder {
var thinking: String = "low" var thinking: String = "low"
var deliver: Bool = true var deliver: Bool = true
var to: String? var to: String?
var channel: GatewayAgentChannel = .last var provider: GatewayAgentProvider = .last
} }
@discardableResult @discardableResult
@@ -46,14 +46,14 @@ enum VoiceWakeForwarder {
options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError> options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError>
{ {
let payload = Self.prefixedTranscript(transcript) let payload = Self.prefixedTranscript(transcript)
let deliver = options.channel.shouldDeliver(options.deliver) let deliver = options.provider.shouldDeliver(options.deliver)
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: payload, message: payload,
sessionKey: options.sessionKey, sessionKey: options.sessionKey,
thinking: options.thinking, thinking: options.thinking,
deliver: deliver, deliver: deliver,
to: options.to, to: options.to,
channel: options.channel)) provider: options.provider))
if result.ok { if result.ok {
self.logger.info("voice wake forward ok") self.logger.info("voice wake forward ok")
@@ -219,7 +219,6 @@ final class WorkActivityStore {
if let detail = display.detailLine, !detail.isEmpty { if let detail = display.detailLine, !detail.isEmpty {
return "\(display.label): \(detail)" return "\(display.label): \(detail)"
} }
return display.label return display.label
} }
Loaded 100 of 2279 files, more files were not shown because too many files have changed in this diff. Show more