Compare commits

..
Author SHA1 Message Date
Peter Steinberger 6f3474ae51 fix: scrub tool schemas for Cloud Code Assist (#567) (thanks @erikpr1994) 2026-01-09 14:07:11 +01:00
Erik cb4d0ad5c1 fix(agents): remove unsupported JSON Schema keywords for Cloud Code Assist API
Cloud Code Assist API requires strict JSON Schema draft 2020-12 compliance
and rejects keywords like patternProperties, additionalProperties, $schema,
$id, $ref, $defs, and definitions.

This extends cleanSchemaForGemini to:
- Remove all unsupported keywords from tool schemas
- Add oneOf literal flattening (matching existing anyOf behavior)
- Add test to verify no unsupported keywords remain in tool schemas
2026-01-09 13:53:47 +01:00
2593 changed files with 124039 additions and 277188 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"
+10 -147
View File
@@ -5,59 +5,8 @@ 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: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -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,38 +85,13 @@ 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: windows-latest
defaults: defaults:
run: run:
shell: bash shell: bash
@@ -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,72 +164,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 }}
checks-macos:
if: github.event_name == 'pull_request'
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
include:
- task: test
command: pnpm test
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Checkout submodules (retry)
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
check-latest: true
- name: Runtime versions
run: |
node -v
npm -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
- name: Enable corepack and pin pnpm
run: |
corepack enable
corepack prepare pnpm@10.23.0 --activate
pnpm -v
- name: Install dependencies
env:
CI: true
run: |
export PATH="$NODE_BIN:$PATH"
which node
node -v
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Run ${{ matrix.task }}
run: ${{ matrix.command }}
macos-app: macos-app:
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
runs-on: macos-latest runs-on: macos-latest
@@ -549,7 +412,7 @@ jobs:
PY PY
android: android:
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
-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
-37
View File
@@ -1,37 +0,0 @@
name: Workflow Sanity
on:
pull_request:
push:
jobs:
no-tabs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fail on tabs in workflow files
run: |
python - <<'PY'
from __future__ import annotations
import pathlib
import sys
root = pathlib.Path(".github/workflows")
bad: list[str] = []
for path in sorted(root.rglob("*.yml")):
if b"\t" in path.read_bytes():
bad.append(str(path))
for path in sorted(root.rglob("*.yaml")):
if b"\t" in path.read_bytes():
bad.append(str(path))
if bad:
print("Tabs found in workflow file(s):")
for path in bad:
print(f"- {path}")
sys.exit(1)
PY
-4
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
@@ -54,6 +53,3 @@ apps/ios/*.mobileprovision
# Local untracked files # Local untracked files
.local/ .local/
.vscode/
IDENTITY.md
USER.md
-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"
}
+1 -1
View File
@@ -48,4 +48,4 @@
--allman false --allman false
# Exclusions # Exclusions
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/ClawdisProtocol,apps/macos/Sources/ClawdbotProtocol --exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol,apps/macos/Sources/ClawdbotProtocol
-2
View File
@@ -17,8 +17,6 @@ excluded:
- dist - dist
- coverage - coverage
- "*.playground" - "*.playground"
# Generated (protocol-gen-swift.ts)
- apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift
analyzer_rules: analyzer_rules:
- unused_declaration - unused_declaration
+4 -32
View File
@@ -1,22 +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/`.
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
- 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).
@@ -26,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.
@@ -41,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.
@@ -50,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`.
@@ -60,9 +50,6 @@
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list. - When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README. - After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
## Shorthand Commands
- `sync up`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
### PR Workflow (Review vs Land) ### PR Workflow (Review vs Land)
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code. - **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this! - **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
@@ -70,20 +57,14 @@
## 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
- Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`). - Rebrand/migration issues (Clawdis → Clawdbot) or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
## Agent-Specific Notes ## Agent-Specific Notes
- Vocabulary: "makeup" = "mac app". - Vocabulary: "makeup" = "mac app".
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
- 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.
- 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.
@@ -106,8 +87,7 @@
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant. - **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/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.
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel. - Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
@@ -116,14 +96,6 @@
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`. - launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping. - For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
## NPM + 1Password (publish/verify)
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on).
- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`.
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
- Kill the tmux session after publish.
## Exclamation Mark Escaping Workaround ## Exclamation Mark Escaping Workaround
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax: The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
+98 -474
View File
@@ -1,475 +1,99 @@
# Changelog # Changelog
## 2026.1.15 (unreleased) ## Unreleased
### Highlights - CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows. - Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc
- Browser: improve remote CDP/Browserless support (auth passthrough, `wss` upgrade, timeouts, clearer errors). - Commands: accept /models as an alias for /model.
- Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf. - Debugging: add raw model stream logging flags and document gateway watch mode.
- Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs). - Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled).
- Auth: respect cooldown tracking even with explicit `auth.order` (avoid repeatedly trying known-bad keys). — thanks @steipete
### Breaking - 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.
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702) - CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging.
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`. - WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj
- Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223
### Changes - Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj
- CLI: set process titles to `clawdbot-<command>` for clearer process listings. - Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles.
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware). - Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence.
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf. - Config: migrate routing/agent config into agents.list/agents.defaults and messages/tools/audio with default agent selection and per-agent identity config.
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007. - Agent: enable adaptive context pruning by default for tool-result trimming.
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee. - Doctor: check config/state permissions and offer to tighten them. — thanks @steipete
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows. - Doctor/Daemon: audit supervisor configs, add --repair/--force flows, surface service config audits in daemon status, and document user vs system services. — thanks @steipete
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker. - Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c
- TUI: show provider/model labels for the active session and default model. - Daemon: add KillMode=process to systemd units to avoid podman restart hangs. (#541) — thanks @ogulcancelik
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example. - Doctor: run legacy state migrations in non-interactive mode without prompts.
- UI: show gateway auth guidance + doc link on unauthorized Control UI connections. - Cron: parse Telegram topic targets for isolated delivery. (#478) — thanks @nachoiacovino
- Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `clawdbot security audit`. - Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos
- Apps: store node auth tokens encrypted (Keychain/SecurePrefs). - Cron: allow Telegram delivery targets with topic/thread IDs (e.g. `-100…:topic:123`). (#474) — thanks @mitschabaude-bot
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts. - Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter. - Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24). - Agents: strip empty assistant text blocks from session history to avoid Claude API 400s. (#210)
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields. - Agents: scrub unsupported JSON Schema keywords from tool schemas for Cloud Code Assist API compatibility. (#567) — thanks @erikpr1994
- macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`). - Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123
- Docs: add Date & Time guide and update prompt/timezone configuration docs. - Auto-reply: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc. - Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev. - Auto-reply: preserve spacing when stripping inline directives. (#539) — thanks @joshp123
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs. - Auto-reply: fix /status usage summary filtering for the active provider.
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands. - Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors. - Status: compact /status with session token usage + estimated cost, add `/cost` per-response usage lines (tokens-only for OAuth).
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`. - Status: show active auth profile and key snippet in /status.
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors. - Agent: promote `<think>`/`<thinking>` tag reasoning into structured thinking blocks so `/reasoning` works consistently for OpenAI-compat providers.
- Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer. - macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy
- Browser: increase remote CDP reachability timeouts + add `remoteCdpTimeoutMs`/`remoteCdpHandshakeTimeoutMs`. - WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj
- Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm. - Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini).
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi. - Control UI: logs tab opens at the newest entries (bottom).
- Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino. - Control UI: add Docs link, remove chat composer divider, and add New session button.
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow. - Control UI: link sessions list to chat view. (#471) — thanks @HazAT
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE. - Control UI: show/patch per-session reasoning level and render extracted reasoning in chat.
- Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos
### Fixes - Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow).
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj. - Telegram: retry long-polling conflicts with backoff to avoid fatal exits.
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr. - Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen. - WhatsApp: resolve @lid JIDs via Baileys mapping to unblock inbound messages. (#415)
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter. - Pairing: replies now include sender ids for Discord/Slack/Signal/iMessage/WhatsApp; pairing list labels them explicitly.
- Agents: avoid false positives when logging unsupported Google tool schema keywords. - Signal: accept UUID-only senders for pairing/allowlists/routing when sourceNumber is missing. (#523) — thanks @neist
- Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm. - Agent system prompt: avoid automatic self-updates unless explicitly requested.
- Status: restore usage summary line for current provider when no OAuth profiles exist. - Onboarding: tighten QuickStart hint copy for configuring later.
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4. - Onboarding: set Gemini 3 Pro as the default model for Gemini API key auth. (#489) — thanks @jonasjancarik
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204. - Onboarding: avoid “token expired” for Codex CLI when expiry is heuristic.
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639. - Onboarding: QuickStart jumps straight into provider selection with Telegram preselected when unset.
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads. - Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker).
- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904) - Onboarding: clarify WhatsApp owner number prompt and label pairing phone number.
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback). - Onboarding: add hosted MiniMax M2.1 API key flow + config. (#495) — thanks @tobiasbischoff
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page). - Daemon runtime: remove Bun from selection options.
- Browser: upgrade `ws` → `wss` when remote CDP uses `https` (fixes Browserless handshake). - CLI: restore hidden `gateway-daemon` alias for legacy launchd configs.
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c. - Onboarding/Configure: add OpenAI API key flow that stores in shared `~/.clawdbot/.env` for launchd; simplify Anthropic token prompt order.
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake. - 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.
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba. - Control UI: show skill install progress + per-skill results, hide install once binaries present. (#445) — thanks @pkrmf
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash. - Providers/Doctor: surface Discord privileged intent (Message Content) misconfiguration with actionable warnings.
- Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998) - Providers/Doctor: warn when Telegram config expects unmentioned group messages but Bot API privacy mode is likely enabled; surface WhatsApp login/disconnect hints.
- Providers/Doctor: add last inbound/outbound activity timestamps in `providers status` and extend `--probe` with Discord channel permission + Telegram group membership audits.
## 2026.1.14-1 - Docs: add provider troubleshooting index (`/providers/troubleshooting`) and link it from the main troubleshooting guide.
- Docs: clarify model allowlist errors and add safety notes for verbose/reasoning in groups.
### Highlights - Docs: add Ansible installation guide. (#545) — thanks @pasogott
- Web search: `web_search`/`web_fetch` tools (Brave API) + first-time setup in onboarding/configure. - Telegram: include the user id in DM pairing messages and label it clearly in `clawdbot pairing list --provider telegram`.
- Browser control: Chrome extension relay takeover mode + remote browser control via `clawdbot browser serve`. - Apps: refresh iOS/Android/macOS app icons for Clawdbot branding. (#521) — thanks @fishfisher
- Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba. - Docs: expand parameter descriptions for agent/wake hooks. (#532) — thanks @mcinteerj
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy. - Docs: add community showcase entries from Discord. (#476) — thanks @gupsammy
- TUI: refresh status bar after think/verbose/reasoning changes. (#519) — thanks @jdrhyne
### Changes - Status: show Verbose/Elevated only when enabled.
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics. - Status: filter usage summary to the active model provider.
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors. - Status: map model providers to usage sources so unrelated usage doesn’t appear.
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging. - Commands: allow /elevated off in groups without a mention; keep /elevated on mention-gated.
- Security: expand `clawdbot security audit` checks (model hygiene, config includes, plugin allowlists, exposure matrix) and extend `--fix` to tighten more sensitive state paths. - Commands: keep multi-directive messages from clearing directive handling.
- Security: add `SECURITY.md` reporting policy. - Commands: warn when /elevated runs in direct (unsandboxed) runtime.
- Channels: add Matrix plugin (external) with docs + onboarding hooks. - Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond.
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba. - Commands: return /status in directive-only multi-line messages.
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`. - Models: fall back to configured models when the provider catalog is unavailable.
- Docs: expand gateway security hardening guidance and incident response checklist. - Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) — thanks @neist
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf. - Agent: bypass Anthropic OAuth tool-name blocks by capitalizing built-ins and keeping pruning tool matching case-insensitive. (#553) — thanks @andrewting19
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia. - Commands/Tools: disable /restart and gateway restart tool by default (enable with commands.restart=true).
- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill. - Gateway/CLI: add `clawdbot gateway discover` (Bonjour scan on `local.` + `clawdbot.internal.`) with `--timeout` and `--json`. — thanks @steipete
- CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips. - Gateway/CLI: make `clawdbot gateway status` human-readable by default, add `--json`, and probe localhost + configured remote (warn on multiple gateways). — thanks @steipete
- Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control via `clawdbot browser serve` + `browser.controlToken`. - Gateway/CLI: support remote loopback gateways via SSH tunnel in `clawdbot gateway status` (`--ssh` / `--ssh-auto`). — thanks @steipete
- CLI: add global `--no-color` (and respect `NO_COLOR=1`) to disable ANSI output. — thanks @steipete
### Fixes - CLI: centralize lobster palette + apply it to onboarding/config prompts. — thanks @steipete
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204. - Gateway/CLI: add `clawdbot gateway --dev/--reset` to auto-create a dev config/workspace with a robot identity (no BOOTSTRAP.md). — thanks @steipete
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
- Agents: harden Antigravity Claude history/tool-call sanitization. (#968) — thanks @rdev.
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman.
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
- Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer.
- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer.
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
## 2026.1.14
### Changes
- Usage: add MiniMax coding plan usage tracking.
- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.
- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)
- Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.
- Config: add `channels.<provider>.configWrites` gating for channel-initiated config writes; migrate Slack channel IDs.
### Fixes
- Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor.
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.
- TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`.
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
#### Agents / Auth / Tools / Sandbox
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer.
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
#### macOS / Apps
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
- macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)
- macOS: prefer the default bridge tunnel port in remote mode for node bridge connectivity; document macOS remote control + bridge tunnels. (#960, fixes #865) — thanks @kkarimi.
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
- Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.
- Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.
- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4.
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.
- WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.
- WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.
## 2026.1.13
### Fixes
- Postinstall: treat already-applied pnpm patches as no-ops to avoid npm/bun install failures.
- Packaging: pin `@mariozechner/pi-ai` to 0.45.7 and refresh patched dependency to match npm resolution.
## 2026.1.12-2
### Fixes
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`).
- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4.
- Agents: strip invalid Gemini thought signatures from OpenRouter history to avoid 400s. (#841, #845) — thanks @MatthieuBizien.
## 2026.1.12-1
### Fixes
- Packaging: include `dist/channels/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/channels/registry.js`).
## 2026.1.12
### Highlights
- **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.*`).
- Memory: add vector search for agent memories (Markdown-only) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback.
- Plugins: restore full voice-call plugin parity (Telnyx/Twilio, streaming, inbound policies, tools/CLI).
- Models: add Synthetic provider plus Moonshot Kimi K2 0905 + turbo/thinking variants (with docs). (#811) — thanks @siraht; (#818) — thanks @mickahouan.
- Cron: one-shot schedules accept ISO timestamps (UTC) with optional delete-after-run; cron jobs can target a specific agent (CLI + macOS/Control UI).
- Agents: add compaction mode config with optional safeguard summarization and per-agent model fallbacks. (#700) — thanks @thewilloftheshadow; (#583) — thanks @mitschabaude-bot.
### New & Improved
- 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.
- 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.
- Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config.
- 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.
- Tools: add provider/model-specific tool policy overrides (`tools.byProvider`) to trim tool exposure per provider.
- 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.
- 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.
- 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.
- 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.
- 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.
- Heartbeat: default `ackMaxChars` to 300 so short `HEARTBEAT_OK` replies stay internal.
### Installer
- Install: run `clawdbot doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected.
### Fixes
- Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds.
- Tools: apply global tool allow/deny even when agent-specific tool policy is set.
- 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.
- Auth: drop invalid auth profiles from ordering so environment keys can still be used for providers like MiniMax.
- 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.
- Agents: auto-recover from compaction context overflow by resetting the session and retrying; propagate overflow details from embedded runs so callers can recover.
- MiniMax: strip malformed tool invocation XML; include `MiniMax-VL-01` in implicit provider for image pairing. (#809) — thanks @latitudeki5223.
- Onboarding/Auth: honor `CLAWDBOT_AGENT_DIR` / `PI_CODING_AGENT_DIR` when writing auth profiles (MiniMax). (#829) — thanks @roshanasingh4.
- Anthropic: handle `overloaded_error` with a friendly message and failover classification. (#832) — thanks @danielz1z.
- Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid incorrect role errors. (#804) — thanks @ThomsenDrake.
- Messaging: enforce context isolation for message tool sends; keep typing indicators alive during tool execution. (#793) — thanks @hsrvc; (#450, #447) — thanks @thewilloftheshadow.
- Auto-reply: `/status` allowlist behavior, reasoning-tag enforcement on fallback, and system-event enqueueing for elevated/reasoning toggles. (#810) — thanks @mcinteerj.
- System events: include local timestamps when events are injected into prompts. (#245) — thanks @thewilloftheshadow.
- Auto-reply: resolve ambiguous `/model` matches; fix streaming block reply media handling; keep >300 char heartbeat replies instead of dropping.
- 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.
- 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.
- Slack: accept slash commands with or without leading `/` for custom command configs. (#798) — thanks @thewilloftheshadow.
- Cron: persist disabled jobs correctly; accept `jobId` aliases for update/run/remove params. (#205, #252) — thanks @thewilloftheshadow.
- 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.
- 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.
- 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.
- Connections UI: polish multi-account account cards. (#816) — thanks @steipete.
### Maintenance
- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai.
- Testing: update Vitest + browser-playwright to 4.0.17.
- Docs: add Amazon Bedrock provider notes and link from models/FAQ.
## 2026.1.11
### Highlights
- Plugins are now first-class: loader + CLI management, plus the new Voice Call plugin.
- Config: modular `$include` support for split config files. (#731) — thanks @pasogott.
- Agents/Pi: reserve compaction headroom so pre-compaction memory writes can run before auto-compaction.
- Agents: automatic pre-compaction memory flush turn to store durable memories before compaction.
### Changes
- CLI/Onboarding: simplify MiniMax auth choice to a single M2.1 option.
- CLI: configure section selection now loops until Continue.
- Docs: explain MiniMax vs MiniMax Lightning (speed vs cost) and restore LM Studio example.
- Docs: add Cerebras GLM 4.6/4.7 config example (OpenAI-compatible endpoint).
- Onboarding/CLI: group model/auth choice by provider and label Z.AI as GLM 4.7.
- Onboarding/Docs: add Moonshot AI (Kimi K2) auth choice + config example.
- CLI/Onboarding: prompt to reuse detected API keys for Moonshot/MiniMax/Z.AI/Gemini/Anthropic/OpenCode.
- Auto-reply: add compact `/model` picker (models + available providers) and show provider endpoints in `/model status`.
- Control UI: add Config tab model presets (MiniMax M2.1, GLM 4.7, Kimi) for one-click setup.
- Plugins: add extension loader (tools/RPC/CLI/services), discovery paths, and config schema + Control UI labels (uiHints).
- Plugins: add `clawdbot plugins install` (path/tgz/npm), plus `list|info|enable|disable|doctor` UX.
- Plugins: voice-call plugin now real (Twilio/log), adds start/status RPC/CLI/tool + tests.
- Docs: add plugins doc + cross-links from tools/skills/gateway config.
- Docs: add beginner-friendly plugin quick start + expand Voice Call plugin docs.
- Tests: add Docker plugin loader + tgz-install smoke test.
- Tests: extend Docker plugin E2E to cover installing from local folders (`plugins.load.paths`) and `file:` npm specs.
- Tests: add coverage for pre-compaction memory flush settings.
- Tests: modernize live model smoke selection for current releases and enforce tools/images/thinking-high coverage. (#769) — thanks @steipete.
- Agents/Tools: add `apply_patch` tool for multi-file edits (experimental; gated by tools.exec.applyPatch; OpenAI-only).
- Agents/Tools: rename the bash tool to exec (config alias maintained). (#748) — thanks @myfunc.
- Agents: add pre-compaction memory flush config (`agents.defaults.compaction.*`) with a soft threshold + system prompt.
- Config: add `$include` directive for modular config files. (#731) — thanks @pasogott.
- Build: set pnpm minimum release age to 2880 minutes (2 days). (#718) — thanks @dan-dr.
- 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.
- Docs: add gog calendar event color IDs from `gog calendar colors`. (#715) — thanks @mjrussell.
- Cron/CLI: add `--model` flag to cron add/edit commands. (#711) — thanks @mjrussell.
- Cron/CLI: trim model overrides on cron edits and document main-session guidance. (#711) — thanks @mjrussell.
- Skills: bundle `skill-creator` to guide creating and packaging skills.
- Providers: add per-DM history limit overrides (`dmHistoryLimit`) with provider-level config. (#728) — thanks @pkrmf.
- Discord: expose channel/category management actions in the message tool. (#730) — thanks @NicholasSpisak.
- Docs: rename README “macOS app” section to “Apps”. (#733) — thanks @AbhisekBasu1.
- Gateway: require `client.id` in WebSocket connect params; use `client.instanceId` for presence de-dupe; update docs/tests.
- macOS: remove the attach-only gateway setting; local mode now always manages launchd while still attaching to an existing gateway if present.
### Installer
- Postinstall: replace `git apply` with builtin JS patcher (works npm/pnpm/bun; no git dependency) plus regression tests.
- Postinstall: skip pnpm patch fallback when the new patcher is active.
- Installer tests: add root+non-root docker smokes, CI workflow to fetch clawd.bot scripts and run install sh/cli with onboarding skipped.
- Installer UX: support `CLAWDBOT_NO_ONBOARD=1` for non-interactive installs; fix npm prefix on Linux and auto-install git.
- Installer UX: add `install.sh --help` with flags/env and git install hint.
- Installer UX: add `--install-method git|npm` and auto-detect source checkouts (prompt to update git checkout vs migrate to npm).
### Fixes
- Models/Onboarding: configure MiniMax (minimax.io) via Anthropic-compatible `/anthropic` endpoint by default (keep `minimax-api` as a legacy alias).
- Models: normalize Gemini 3 Pro/Flash IDs to preview names for live model lookups. (#769) — thanks @steipete.
- CLI: fix guardCancel typing for configure prompts. (#769) — thanks @steipete.
- Gateway/WebChat: include handshake validation details in the WebSocket close reason for easier debugging; preserve close codes.
- Gateway/Auth: send invalid connect responses before closing the handshake; stabilize invalid-connect auth test.
- Gateway: tighten gateway listener detection.
- Control UI: hide onboarding chat when configured and guard the mobile chat sidebar overlay.
- Auth: read Codex keychain credentials and make the lookup platform-aware.
- macOS/Release: avoid bundling dist artifacts in relay builds and generate appcasts from zip-only sources.
- Doctor: surface plugin diagnostics in the report.
- 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.
- Config: expand `~` in `CLAWDBOT_CONFIG_PATH` and common path-like config fields (including `plugins.load.paths`); guard invalid `$include` paths. (#731) — thanks @pasogott.
- Agents: stop pre-creating session transcripts so first user messages persist in JSONL history.
- 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
@@ -486,9 +110,9 @@
- 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 `agents.defaults.sandbox.scope` to `"agent"` (one container/workspace per agent). Use `"session"` for per-session isolation; `"shared"` disables cross-session isolation.
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `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 `agents.defaults.userTimezone` to tell the model the user’s local time (system prompt only).
- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup. - Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup.
- Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context. - Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context.
- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior. - Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior.
@@ -514,7 +138,7 @@
## 2026.1.5 ## 2026.1.5
### Highlights ### Highlights
- Models: add image-specific model config (`agent.imageModel` + fallbacks) and scan support. - Models: add image-specific model config (`agents.defaults.imageModel` + fallbacks) and scan support.
- Agent tools: new `image` tool routed to the image model (when configured). - Agent tools: new `image` tool routed to the image model (when configured).
- Config: default model shorthands (`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`). - Config: default model shorthands (`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`).
- Docs: document built-in model shorthands + precedence (user config wins). - Docs: document built-in model shorthands + precedence (user config wins).
@@ -539,7 +163,7 @@
- Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas). - Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas).
- Onboarding: when running from source, auto-build missing Control UI assets (`bun run ui:build`). - Onboarding: when running from source, auto-build missing Control UI assets (`bun run ui:build`).
- Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed). - Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed).
- Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off. - Agent tools: honor `tools.allow` / `tools.deny` policy even when sandbox is off.
- Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events. - Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events.
- Commands: unify /status (inline) and command auth across providers; group bypass for authorized control commands; remove Discord /clawd slash handler. - Commands: unify /status (inline) and command auth across providers; group bypass for authorized control commands; remove Discord /clawd slash handler.
- CLI: run `clawdbot agent` via the Gateway by default; use `--local` to force embedded mode. - CLI: run `clawdbot agent` via the Gateway by default; use `--local` to force embedded mode.
+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)
+1 -14
View File
@@ -8,22 +8,9 @@ RUN corepack enable
WORKDIR /app WORKDIR /app
ARG CLAWDBOT_DOCKER_APT_PACKAGES="" COPY . .
RUN if [ -n "$CLAWDBOT_DOCKER_APT_PACKAGES" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $CLAWDBOT_DOCKER_APT_PACKAGES && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
fi
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY ui/package.json ./ui/package.json
COPY patches ./patches
COPY scripts ./scripts
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build RUN pnpm build
RUN pnpm ui:install RUN pnpm ui:install
RUN pnpm ui:build RUN pnpm ui:build
-1
View File
@@ -14,7 +14,6 @@ RUN apt-get update \
jq \ jq \
novnc \ novnc \
python3 \ python3 \
socat \
websockify \ websockify \
x11vnc \ x11vnc \
xvfb \ xvfb \
Submodule Peekaboo updated: 5c195f5e46...c1243a7978.
+143 -167
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
@@ -468,29 +454,19 @@ AI/vibe-coded PRs welcome! 🤖
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix. Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
Core contributors:
- @cpojer — Telegram onboarding UX + docs
Thanks to all clawtributors: Thanks to all clawtributors:
<p align="left"> <p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></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/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/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/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a>
<a href="https://github.com/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/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/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/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
<a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/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/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a>
<a href="https://github.com/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/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/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/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/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a>
<a href="https://github.com/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/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/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/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/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/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/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/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
<a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/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/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/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/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a>
<a href="https://github.com/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/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/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/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></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/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/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/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a>
<a href="https://github.com/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/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/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/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/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/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
<a href="https://github.com/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/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/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/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/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/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/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a>
<a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></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/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/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a>
<a href="https://github.com/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/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/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=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/search?q=Tobias%20Bischoff"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a>
<a href="https://github.com/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/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/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/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/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/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a>
<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/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/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a>
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/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/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a>
<a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=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/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/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></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/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/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/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></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/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></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/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></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/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></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>
-15
View File
@@ -1,15 +0,0 @@
# Security Policy
If you believe you’ve found a security issue in Clawdbot, please report it privately.
## Reporting
- Email: `steipete@gmail.com`
- What to include: reproduction steps, impact assessment, and (if possible) a minimal PoC.
## Operational Guidance
For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see:
- `https://docs.clawd.bot/gateway/security`
@@ -34,7 +34,8 @@ extension AttributedString {
var ranges: [Range<AttributedString.Index>] = [] var ranges: [Range<AttributedString.Index>] = []
for wordRange in wordRanges { for wordRange in wordRanges {
if let lastRange = ranges.last, if let lastRange = ranges.last,
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength { self[lastRange].characters.count + self[wordRange].characters.count <= maxLength
{
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
} else { } else {
ranges.append(wordRange) ranges.append(wordRange)
@@ -13,7 +13,8 @@ public actor TranscriptsStore {
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
fileURL = dir.appendingPathComponent("transcripts.log") fileURL = dir.appendingPathComponent("transcripts.log")
if let data = try? Data(contentsOf: fileURL), if let data = try? Data(contentsOf: fileURL),
let text = String(data: data, encoding: .utf8) { let text = String(data: data, encoding: .utf8)
{
entries = text.split(separator: "\n").map(String.init).suffix(limit) entries = text.split(separator: "\n").map(String.init).suffix(limit)
} }
} }
@@ -24,7 +24,8 @@ public struct WakeWordGateConfig: Sendable, Equatable {
public init( public init(
triggers: [String], triggers: [String],
minPostTriggerGap: TimeInterval = 0.45, minPostTriggerGap: TimeInterval = 0.45,
minCommandLength: Int = 1) { minCommandLength: Int = 1)
{
self.triggers = triggers self.triggers = triggers
self.minPostTriggerGap = minPostTriggerGap self.minPostTriggerGap = minPostTriggerGap
self.minCommandLength = minCommandLength self.minCommandLength = minCommandLength
@@ -56,12 +57,6 @@ public enum WakeWordGate {
let tokens: [String] let tokens: [String]
} }
private struct MatchCandidate {
let index: Int
let triggerEnd: TimeInterval
let gap: TimeInterval
}
public static func match( public static func match(
transcript: String, transcript: String,
segments: [WakeWordSegment], segments: [WakeWordSegment],
@@ -73,7 +68,7 @@ public enum WakeWordGate {
let tokens = normalizeSegments(segments) let tokens = normalizeSegments(segments)
guard !tokens.isEmpty else { return nil } guard !tokens.isEmpty else { return nil }
var best: MatchCandidate? var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
for trigger in triggerTokens { for trigger in triggerTokens {
let count = trigger.tokens.count let count = trigger.tokens.count
@@ -89,7 +84,7 @@ public enum WakeWordGate {
if let best, i <= best.index { continue } if let best, i <= best.index { continue }
best = MatchCandidate(index: i, triggerEnd: triggerEnd, gap: gap) best = (i, triggerEnd, gap)
} }
} }
@@ -24,7 +24,7 @@ enum CLIRegistry {
subcommands: [ subcommands: [
descriptor(for: ServiceInstall.self), descriptor(for: ServiceInstall.self),
descriptor(for: ServiceUninstall.self), descriptor(for: ServiceUninstall.self),
descriptor(for: ServiceStatus.self) descriptor(for: ServiceStatus.self),
]) ])
let doctorDesc = descriptor(for: DoctorCommand.self) let doctorDesc = descriptor(for: DoctorCommand.self)
let setupDesc = descriptor(for: SetupCommand.self) let setupDesc = descriptor(for: SetupCommand.self)
@@ -54,7 +54,7 @@ enum CLIRegistry {
startDesc, startDesc,
stopDesc, stopDesc,
restartDesc, restartDesc,
statusDesc statusDesc,
]) ])
return [root] return [root]
} }
@@ -25,7 +25,7 @@ private enum LaunchdHelper {
"Label": label, "Label": label,
"ProgramArguments": [executable, "serve"], "ProgramArguments": [executable, "serve"],
"RunAtLoad": true, "RunAtLoad": true,
"KeepAlive": true "KeepAlive": true,
] ]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: plistURL) try data.write(to: plistURL)
+67 -112
View File
@@ -25,123 +25,78 @@ private func dispatch(invocation: CommandInvocation) async throws {
switch first { switch first {
case "swabble": case "swabble":
try await dispatchSwabble(parsed: parsed, path: path) guard path.count >= 2 else { throw CommanderProgramError.missingSubcommand(command: "swabble") }
let sub = path[1]
switch sub {
case "serve":
var cmd = ServeCommand(parsed: parsed)
try await cmd.run()
case "transcribe":
var cmd = TranscribeCommand(parsed: parsed)
try await cmd.run()
case "test-hook":
var cmd = TestHookCommand(parsed: parsed)
try await cmd.run()
case "mic":
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "mic") }
let micSub = path[2]
if micSub == "list" {
var cmd = MicList(parsed: parsed)
try await cmd.run()
} else if micSub == "set" {
var cmd = MicSet(parsed: parsed)
try await cmd.run()
} else {
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
}
case "service":
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "service") }
let svcSub = path[2]
switch svcSub {
case "install":
var cmd = ServiceInstall()
try await cmd.run()
case "uninstall":
var cmd = ServiceUninstall()
try await cmd.run()
case "status":
var cmd = ServiceStatus()
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
}
case "doctor":
var cmd = DoctorCommand(parsed: parsed)
try await cmd.run()
case "setup":
var cmd = SetupCommand(parsed: parsed)
try await cmd.run()
case "health":
var cmd = HealthCommand(parsed: parsed)
try await cmd.run()
case "tail-log":
var cmd = TailLogCommand(parsed: parsed)
try await cmd.run()
case "start":
var cmd = StartCommand()
try await cmd.run()
case "stop":
var cmd = StopCommand()
try await cmd.run()
case "restart":
var cmd = RestartCommand()
try await cmd.run()
case "status":
var cmd = StatusCommand()
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
}
default: default:
throw CommanderProgramError.unknownCommand(first) throw CommanderProgramError.unknownCommand(first)
} }
} }
@available(macOS 26.0, *)
@MainActor
private func dispatchSwabble(parsed: ParsedValues, path: [String]) async throws {
let sub = try subcommand(path, index: 1, command: "swabble")
switch sub {
case "mic":
try await dispatchMic(parsed: parsed, path: path)
case "service":
try await dispatchService(path: path)
default:
let handlers = swabbleHandlers(parsed: parsed)
guard let handler = handlers[sub] else {
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
}
try await handler()
}
}
@available(macOS 26.0, *)
@MainActor
private func swabbleHandlers(parsed: ParsedValues) -> [String: () async throws -> Void] {
[
"serve": {
var cmd = ServeCommand(parsed: parsed)
try await cmd.run()
},
"transcribe": {
var cmd = TranscribeCommand(parsed: parsed)
try await cmd.run()
},
"test-hook": {
var cmd = TestHookCommand(parsed: parsed)
try await cmd.run()
},
"doctor": {
var cmd = DoctorCommand(parsed: parsed)
try await cmd.run()
},
"setup": {
var cmd = SetupCommand(parsed: parsed)
try await cmd.run()
},
"health": {
var cmd = HealthCommand(parsed: parsed)
try await cmd.run()
},
"tail-log": {
var cmd = TailLogCommand(parsed: parsed)
try await cmd.run()
},
"start": {
var cmd = StartCommand()
try await cmd.run()
},
"stop": {
var cmd = StopCommand()
try await cmd.run()
},
"restart": {
var cmd = RestartCommand()
try await cmd.run()
},
"status": {
var cmd = StatusCommand()
try await cmd.run()
}
]
}
@available(macOS 26.0, *)
@MainActor
private func dispatchMic(parsed: ParsedValues, path: [String]) async throws {
let micSub = try subcommand(path, index: 2, command: "mic")
switch micSub {
case "list":
var cmd = MicList(parsed: parsed)
try await cmd.run()
case "set":
var cmd = MicSet(parsed: parsed)
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
}
}
@available(macOS 26.0, *)
@MainActor
private func dispatchService(path: [String]) async throws {
let svcSub = try subcommand(path, index: 2, command: "service")
switch svcSub {
case "install":
var cmd = ServiceInstall()
try await cmd.run()
case "uninstall":
var cmd = ServiceUninstall()
try await cmd.run()
case "status":
var cmd = ServiceStatus()
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
}
}
private func subcommand(_ path: [String], index: Int, command: String) throws -> String {
guard path.count > index else {
throw CommanderProgramError.missingSubcommand(command: command)
}
return path[index]
}
if #available(macOS 26.0, *) { if #available(macOS 26.0, *) {
let exitCode = await runCLI() let exitCode = await runCLI()
exit(exitCode) exit(exitCode)
+23 -179
View File
@@ -1,211 +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.14-1</title> <title>2026.1.5-3</title>
<pubDate>Thu, 15 Jan 2026 11:14:40 +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>5825</sparkle:version> <sparkle:version>3095</sparkle:version>
<sparkle:shortVersionString>2026.1.14-1</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.14-1</h2> <description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
<h3>Highlights</h3>
<ul>
<li>Web search: <code>web_search</code>/<code>web_fetch</code> tools (Brave API) + first-time setup in onboarding/configure.</li>
<li>Browser control: Chrome extension relay takeover mode + remote browser control via <code>clawdbot browser serve</code>.</li>
<li>Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.</li>
<li>Security: expanded <code>clawdbot security audit</code> (+ <code>--fix</code>), detect-secrets CI scan, and a <code>SECURITY.md</code> reporting policy.</li>
</ul>
<h3>Changes</h3>
<h4>Web Tools</h4>
<ul>
<li>Tools: add <code>web_search</code>/<code>web_fetch</code> (Brave API), including helpful setup hints when the key is missing.</li>
<li>Tools: enable <code>web_fetch</code> by default (unless explicitly disabled in config).</li>
<li>CLI/Docs: add <code>clawdbot configure --section web</code> for storing Brave API keys and update onboarding tips.</li>
</ul>
<h4>Browser / Control UI</h4>
<ul>
<li>Browser: add Chrome extension relay takeover mode (toolbar button) + <code>clawdbot browser serve</code> remote control + <code>browser.controlToken</code>.</li>
<li>Browser: ship a built-in <code>chrome</code> profile for extension relay and start the relay automatically when running locally.</li>
<li>Browser: default <code>browser.defaultProfile</code> to <code>chrome</code> (existing Chrome takeover mode).</li>
<li>Browser: add <code>clawdbot browser extension install/path</code> and copy extension path to clipboard.</li>
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
<li>Control UI: show raw any-map entries in config views; move Docs link into the left nav.</li>
</ul>
<h4>Plugins</h4>
<ul>
<li>Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.</li>
<li>Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.</li>
<li>Channels: add Matrix plugin (external) with docs + onboarding hooks.</li>
<li>Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.</li>
</ul>
<h4>Security</h4>
<ul>
<li>Security: expand <code>clawdbot security audit</code> checks and publish a <code>SECURITY.md</code> reporting policy.</li>
<li>Security: extend <code>clawdbot security audit --fix</code> to tighten more sensitive state paths.</li>
<li>Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.</li>
</ul>
<h4>Onboarding / Daemon</h4>
<ul>
<li>Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require <code>--accept-risk</code> for <code>--non-interactive</code>.</li>
<li>Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.</li>
</ul>
<h4>Auth / Usage / Config</h4>
<ul>
<li>Usage: add MiniMax coding plan usage tracking.</li>
<li>Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.</li>
<li>Agents: add optional auth-profile copy prompt on <code>agents add</code> and improve auth error messaging.</li>
<li>Auth: add dynamic template variables to <code>messages.responsePrefix</code>. (#928) — thanks @sebslight.</li>
<li>Config: add <code>channels.<provider>.configWrites</code> gating for channel-initiated config writes; migrate Slack channel IDs.</li>
</ul>
<h4>Channels</h4>
<ul>
<li>Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.</li>
<li>WhatsApp: add <code>channels.whatsapp.sendReadReceipts</code> to disable auto read receipts. (#882) — thanks @chrisrodz.</li>
</ul>
<h4>Docs</h4>
<ul>
<li>Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.</li>
<li>Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.</li>
<li>Docs: expand gateway security hardening guidance and incident response checklist.</li>
<li>Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.</li>
<li>Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)</li>
<li>Docs: add per-command CLI doc pages and link them from <code>clawdbot <command> --help</code>.</li>
<li>Docs: add multi-gateway guide (sidebar + nav).</li>
</ul>
<h3>Fixes</h3> <h3>Fixes</h3>
<h4>Gateway / Daemon / Sessions</h4>
<ul> <ul>
<li>Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.</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>Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare <code>main</code> alias).</li>
<li>Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.</li>
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
<li>Browser: persist role snapshot refs per CDP target so <code>snapshot</code> → <code>act</code> clicks work even if Playwright returns a different Page instance.</li>
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
<li>Daemon: clear persisted launchd disabled state before bootstrap (fixes <code>daemon install</code> after uninstall). (#849) — thanks @ndraiman.</li>
<li>Sessions: return deep clones (<code>structuredClone</code>) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.</li>
<li>Heartbeat: keep <code>updatedAt</code> monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.</li>
<li>Agent: clear run context after CLI runs (<code>clearAgentRunContext</code>) to avoid runaway contexts. (#934) — thanks @ronak-guliani.</li>
<li>Gateway/Dev: ensure <code>pnpm gateway:dev</code> always uses the dev profile config + state (<code>~/.clawdbot-dev</code>).</li>
</ul>
<h4>CLI / Onboarding</h4>
<ul>
<li>Onboarding: show web search setup at the end (not the beginning).</li>
<li>Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.</li>
<li>Health: colorize “not configured” provider lines for easier scanning.</li>
</ul>
<h4>Control UI / TUI</h4>
<ul>
<li>Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)</li>
<li>UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.</li>
<li>UI: always apply <code>?token=</code> from URL (fixes unauthorized after re-onboard).</li>
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
<li>TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.</li>
<li>TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.</li>
<li>TUI: show LLM error messages (rate limits, auth, etc.) instead of <code>(no output)</code>.</li>
</ul>
<h4>Agents / Auth / Tools / Sandbox</h4>
<ul>
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
<li>Logging: tolerate <code>EIO</code> from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.</li>
<li>Sandbox: restore <code>docker.binds</code> config validation and preserve configured PATH for <code>docker exec</code>. (#873) — thanks @akonyer.</li>
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
</ul>
<h4>macOS / Apps</h4>
<ul>
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
<li>macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.</li>
<li>macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)</li>
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
<li>macOS: fix cron preview/testing payload to use <code>channel</code> key. (#867) — thanks @wes-davis.</li>
<li>macOS: update cron testing channel arg. (#896) — thanks @ngutman.</li>
</ul>
<h4>Channels / Messaging</h4>
<ul>
<li>Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)</li>
<li>Slack: respect <code>channels.slack.requireMention</code> default when resolving channel mention gating. (#850) — thanks @evalexpr.</li>
<li>Slack: drop Socket Mode events with mismatched <code>api_app_id</code>/<code>team_id</code>. (#889) — thanks @roshanasingh4.</li>
<li>Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.</li>
<li>Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.</li>
<li>Telegram: honor <code>channels.telegram.timeoutSeconds</code> for grammY API requests. (#863) — thanks @Snaver.</li>
<li>Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).</li>
<li>Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.</li>
<li>Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.</li>
<li>Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.</li>
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
<li>Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.</li>
<li>iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)</li>
<li>WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.</li>
<li>WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.</li>
<li>WhatsApp: harden owner command auth.</li>
<li>Auto-reply: treat trailing <code>NO_REPLY</code> tokens as silent replies.</li>
</ul>
<h4>Config / Doctor / Packaging</h4>
<ul>
<li>Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).</li>
<li>Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).</li>
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</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.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/> <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.12-2</title> <title>2026.1.5-3</title>
<pubDate>Tue, 13 Jan 2026 10:05:25 +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>5534</sparkle:version> <sparkle:version>3091</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="160797048" type="application/octet-stream" sparkle:edSignature="5KYFg0SW7liwLxLJbfzd2KsAxbX06gMH0rH/W3a4V0p4N48hjz4AsSrfFLdGZSnW+6XaJjC3MN6Ynh+l7kffDQ=="/>
</item> </item>
<item> <item>
<title>2026.1.11-3</title> <title>2026.1.5-2</title>
<pubDate>Mon, 12 Jan 2026 10:40:23 +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>5212</sparkle:version> <sparkle:version>3089</sparkle:version>
<sparkle:shortVersionString>2026.1.11-3</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-3</h2> <description><![CDATA[<h2>Clawdbot 2026.1.5-2</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 <code>dist/sessions</code> so <code>clawdbot agent</code> resolves session helpers in npx installs.</li>
<li>CLI: show a commit hash in the banner for npm installs (package.json gitHead fallback).</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-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-2/Clawdbot-2026.1.5-2.zip" length="150250417" type="application/octet-stream" sparkle:edSignature="ntHNmwyHrv6cPk6NAKOT3AUkwdt5ZadrGU6mJK4GmVxi44uIMT3ZXluvnqK9SxXQwA0H0dXjiGMS/cg8NbgqDA=="/>
</item> </item>
</channel> </channel>
</rss> </rss>
+3 -6
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")
@@ -119,7 +116,7 @@ dependencies {
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7") testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7") testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
testImplementation("org.robolectric:robolectric:4.16") testImplementation("org.robolectric:robolectric:4.16")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2") testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.1")
} }
tasks.withType<Test>().configureEach { tasks.withType<Test>().configureEach {
@@ -27,7 +27,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val remoteAddress: StateFlow<String?> = runtime.remoteAddress val remoteAddress: StateFlow<String?> = runtime.remoteAddress
val isForeground: StateFlow<Boolean> = runtime.isForeground val isForeground: StateFlow<Boolean> = runtime.isForeground
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
val mainSessionKey: StateFlow<String> = runtime.mainSessionKey
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
@@ -139,7 +138,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.handleCanvasA2UIActionFromWebView(payloadJson) runtime.handleCanvasA2UIActionFromWebView(payloadJson)
} }
fun loadChat(sessionKey: String) { fun loadChat(sessionKey: String = "main") {
runtime.loadChat(sessionKey) runtime.loadChat(sessionKey)
} }
@@ -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(),
)
}
}
} }
@@ -16,7 +16,6 @@ import com.clawdbot.android.bridge.BridgeDiscovery
import com.clawdbot.android.bridge.BridgeEndpoint import com.clawdbot.android.bridge.BridgeEndpoint
import com.clawdbot.android.bridge.BridgePairingClient import com.clawdbot.android.bridge.BridgePairingClient
import com.clawdbot.android.bridge.BridgeSession import com.clawdbot.android.bridge.BridgeSession
import com.clawdbot.android.bridge.BridgeTlsParams
import com.clawdbot.android.node.CameraCaptureManager import com.clawdbot.android.node.CameraCaptureManager
import com.clawdbot.android.node.LocationCaptureManager import com.clawdbot.android.node.LocationCaptureManager
import com.clawdbot.android.BuildConfig import com.clawdbot.android.BuildConfig
@@ -79,7 +78,7 @@ class NodeRuntime(context: Context) {
payloadJson = payloadJson =
buildJsonObject { buildJsonObject {
put("message", JsonPrimitive(command)) put("message", JsonPrimitive(command))
put("sessionKey", JsonPrimitive(resolveMainSessionKey())) put("sessionKey", JsonPrimitive(mainSessionKey.value))
put("thinking", JsonPrimitive(chatThinkingLevel.value)) put("thinking", JsonPrimitive(chatThinkingLevel.value))
put("deliver", JsonPrimitive(false)) put("deliver", JsonPrimitive(false))
}.toString(), }.toString(),
@@ -143,13 +142,12 @@ class NodeRuntime(context: Context) {
private val session = private val session =
BridgeSession( BridgeSession(
scope = scope, scope = scope,
onConnected = { name, remote, mainSessionKey -> onConnected = { name, remote ->
_statusText.value = "Connected" _statusText.value = "Connected"
_serverName.value = name _serverName.value = name
_remoteAddress.value = remote _remoteAddress.value = remote
_isConnected.value = true _isConnected.value = true
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
applyMainSessionKey(mainSessionKey)
scope.launch { refreshBrandingFromGateway() } scope.launch { refreshBrandingFromGateway() }
scope.launch { refreshWakeWordsFromGateway() } scope.launch { refreshWakeWordsFromGateway() }
maybeNavigateToA2uiOnConnect() maybeNavigateToA2uiOnConnect()
@@ -161,9 +159,6 @@ class NodeRuntime(context: Context) {
onInvoke = { req -> onInvoke = { req ->
handleInvoke(req.command, req.paramsJson) handleInvoke(req.command, req.paramsJson)
}, },
onTlsFingerprint = { stableId, fingerprint ->
prefs.saveBridgeTlsFingerprint(stableId, fingerprint)
},
) )
private val chat = ChatController(scope = scope, session = session, json = json) private val chat = ChatController(scope = scope, session = session, json = json)
@@ -177,31 +172,11 @@ class NodeRuntime(context: Context) {
_remoteAddress.value = null _remoteAddress.value = null
_isConnected.value = false _isConnected.value = false
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) { _mainSessionKey.value = "main"
_mainSessionKey.value = "main"
}
val mainKey = resolveMainSessionKey()
talkMode.setMainSessionKey(mainKey)
chat.applyMainSessionKey(mainKey)
chat.onDisconnected(message) chat.onDisconnected(message)
showLocalCanvasOnDisconnect() showLocalCanvasOnDisconnect()
} }
private fun applyMainSessionKey(candidate: String?) {
val trimmed = candidate?.trim().orEmpty()
if (trimmed.isEmpty()) return
if (isCanonicalMainSessionKey(_mainSessionKey.value)) return
if (_mainSessionKey.value == trimmed) return
_mainSessionKey.value = trimmed
talkMode.setMainSessionKey(trimmed)
chat.applyMainSessionKey(trimmed)
}
private fun resolveMainSessionKey(): String {
val trimmed = _mainSessionKey.value.trim()
return if (trimmed.isEmpty()) "main" else trimmed
}
private fun maybeNavigateToA2uiOnConnect() { private fun maybeNavigateToA2uiOnConnect() {
val a2uiUrl = resolveA2uiHostUrl() ?: return val a2uiUrl = resolveA2uiHostUrl() ?: return
val current = canvas.currentUrl()?.trim().orEmpty() val current = canvas.currentUrl()?.trim().orEmpty()
@@ -492,17 +467,12 @@ class NodeRuntime(context: Context) {
scope.launch { scope.launch {
_statusText.value = "Connecting…" _statusText.value = "Connecting…"
val storedToken = prefs.loadBridgeToken() val storedToken = prefs.loadBridgeToken()
val tls = resolveTlsParams(endpoint)
val resolved = val resolved =
if (storedToken.isNullOrBlank()) { if (storedToken.isNullOrBlank()) {
_statusText.value = "Pairing…" _statusText.value = "Pairing…"
BridgePairingClient().pairAndHello( BridgePairingClient().pairAndHello(
endpoint = endpoint, endpoint = endpoint,
hello = buildPairingHello(token = null), hello = buildPairingHello(token = null),
tls = tls,
onTlsFingerprint = { fingerprint ->
prefs.saveBridgeTlsFingerprint(endpoint.stableId, fingerprint)
},
) )
} else { } else {
BridgePairingClient.PairResult(ok = true, token = storedToken.trim()) BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
@@ -519,7 +489,6 @@ class NodeRuntime(context: Context) {
session.connect( session.connect(
endpoint = endpoint, endpoint = endpoint,
hello = buildSessionHello(token = authToken), hello = buildSessionHello(token = authToken),
tls = tls,
) )
} }
} }
@@ -566,41 +535,6 @@ class NodeRuntime(context: Context) {
session.disconnect() session.disconnect()
} }
private fun resolveTlsParams(endpoint: BridgeEndpoint): BridgeTlsParams? {
val stored = prefs.loadBridgeTlsFingerprint(endpoint.stableId)
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
val manual = endpoint.stableId.startsWith("manual|")
if (hinted) {
return BridgeTlsParams(
required = true,
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
allowTOFU = stored == null,
stableId = endpoint.stableId,
)
}
if (!stored.isNullOrBlank()) {
return BridgeTlsParams(
required = true,
expectedFingerprint = stored,
allowTOFU = false,
stableId = endpoint.stableId,
)
}
if (manual) {
return BridgeTlsParams(
required = false,
expectedFingerprint = null,
allowTOFU = true,
stableId = endpoint.stableId,
)
}
return null
}
fun handleCanvasA2UIActionFromWebView(payloadJson: String) { fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
scope.launch { scope.launch {
val trimmed = payloadJson.trim() val trimmed = payloadJson.trim()
@@ -625,7 +559,7 @@ class NodeRuntime(context: Context) {
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" } (userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
val contextJson = (userActionObj["context"] as? JsonObject)?.toString() val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
val sessionKey = resolveMainSessionKey() val sessionKey = "main"
val message = val message =
ClawdbotCanvasA2UIAction.formatAgentMessage( ClawdbotCanvasA2UIAction.formatAgentMessage(
actionName = name, actionName = name,
@@ -673,9 +607,8 @@ class NodeRuntime(context: Context) {
} }
} }
fun loadChat(sessionKey: String) { fun loadChat(sessionKey: String = "main") {
val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() } chat.load(sessionKey)
chat.load(key)
} }
fun refreshChat() { fun refreshChat() {
@@ -767,8 +700,8 @@ class NodeRuntime(context: Context) {
val ui = config?.get("ui").asObjectOrNull() val ui = config?.get("ui").asObjectOrNull()
val raw = ui?.get("seamColor").asStringOrNull()?.trim() val raw = ui?.get("seamColor").asStringOrNull()?.trim()
val sessionCfg = config?.get("session").asObjectOrNull() val sessionCfg = config?.get("session").asObjectOrNull()
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()) val rawMainKey = sessionCfg?.get("mainKey").asStringOrNull()?.trim()
applyMainSessionKey(mainKey) _mainSessionKey.value = rawMainKey?.takeIf { it.isNotEmpty() } ?: "main"
val parsed = parseHexColorArgb(raw) val parsed = parseHexColorArgb(raw)
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB _seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
@@ -147,16 +147,6 @@ class SecurePrefs(context: Context) {
prefs.edit { putString(key, token.trim()) } prefs.edit { putString(key, token.trim()) }
} }
fun loadBridgeTlsFingerprint(stableId: String): String? {
val key = "bridge.tls.$stableId"
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
}
fun saveBridgeTlsFingerprint(stableId: String, fingerprint: String) {
val key = "bridge.tls.$stableId"
prefs.edit { putString(key, fingerprint.trim()) }
}
private fun loadOrCreateInstanceId(): String { private fun loadOrCreateInstanceId(): String {
val existing = prefs.getString("node.instanceId", null)?.trim() val existing = prefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing if (!existing.isNullOrBlank()) return existing
@@ -1,13 +0,0 @@
package com.clawdbot.android
internal fun normalizeMainKey(raw: String?): String {
val trimmed = raw?.trim()
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
}
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
val trimmed = raw?.trim().orEmpty()
if (trimmed.isEmpty()) return false
if (trimmed == "global") return true
return trimmed.startsWith("agent:")
}
@@ -143,8 +143,6 @@ class BridgeDiscovery(
val gatewayPort = txtInt(resolved, "gatewayPort") val gatewayPort = txtInt(resolved, "gatewayPort")
val bridgePort = txtInt(resolved, "bridgePort") val bridgePort = txtInt(resolved, "bridgePort")
val canvasPort = txtInt(resolved, "canvasPort") val canvasPort = txtInt(resolved, "canvasPort")
val tlsEnabled = txtBool(resolved, "bridgeTls")
val tlsFingerprint = txt(resolved, "bridgeTlsSha256")
val id = stableId(serviceName, "local.") val id = stableId(serviceName, "local.")
localById[id] = localById[id] =
BridgeEndpoint( BridgeEndpoint(
@@ -157,8 +155,6 @@ class BridgeDiscovery(
gatewayPort = gatewayPort, gatewayPort = gatewayPort,
bridgePort = bridgePort, bridgePort = bridgePort,
canvasPort = canvasPort, canvasPort = canvasPort,
tlsEnabled = tlsEnabled,
tlsFingerprintSha256 = tlsFingerprint,
) )
publish() publish()
} }
@@ -213,11 +209,6 @@ class BridgeDiscovery(
return txt(info, key)?.toIntOrNull() return txt(info, key)?.toIntOrNull()
} }
private fun txtBool(info: NsdServiceInfo, key: String): Boolean {
val raw = txt(info, key)?.trim()?.lowercase() ?: return false
return raw == "1" || raw == "true" || raw == "yes"
}
private suspend fun refreshUnicast(domain: String) { private suspend fun refreshUnicast(domain: String) {
val ptrName = "${serviceType}${domain}" val ptrName = "${serviceType}${domain}"
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
@@ -261,8 +252,6 @@ class BridgeDiscovery(
val gatewayPort = txtIntValue(txt, "gatewayPort") val gatewayPort = txtIntValue(txt, "gatewayPort")
val bridgePort = txtIntValue(txt, "bridgePort") val bridgePort = txtIntValue(txt, "bridgePort")
val canvasPort = txtIntValue(txt, "canvasPort") val canvasPort = txtIntValue(txt, "canvasPort")
val tlsEnabled = txtBoolValue(txt, "bridgeTls")
val tlsFingerprint = txtValue(txt, "bridgeTlsSha256")
val id = stableId(instanceName, domain) val id = stableId(instanceName, domain)
next[id] = next[id] =
BridgeEndpoint( BridgeEndpoint(
@@ -275,8 +264,6 @@ class BridgeDiscovery(
gatewayPort = gatewayPort, gatewayPort = gatewayPort,
bridgePort = bridgePort, bridgePort = bridgePort,
canvasPort = canvasPort, canvasPort = canvasPort,
tlsEnabled = tlsEnabled,
tlsFingerprintSha256 = tlsFingerprint,
) )
} }
@@ -487,11 +474,6 @@ class BridgeDiscovery(
return txtValue(records, key)?.toIntOrNull() return txtValue(records, key)?.toIntOrNull()
} }
private fun txtBoolValue(records: List<TXTRecord>, key: String): Boolean {
val raw = txtValue(records, key)?.trim()?.lowercase() ?: return false
return raw == "1" || raw == "true" || raw == "yes"
}
private fun decodeDnsTxtString(raw: String): String { private fun decodeDnsTxtString(raw: String): String {
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes. // dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible. // Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
@@ -10,8 +10,6 @@ data class BridgeEndpoint(
val gatewayPort: Int? = null, val gatewayPort: Int? = null,
val bridgePort: Int? = null, val bridgePort: Int? = null,
val canvasPort: Int? = null, val canvasPort: Int? = null,
val tlsEnabled: Boolean = false,
val tlsFingerprintSha256: String? = null,
) { ) {
companion object { companion object {
fun manual(host: String, port: Int): BridgeEndpoint = fun manual(host: String, port: Int): BridgeEndpoint =
@@ -20,8 +18,6 @@ data class BridgeEndpoint(
name = "$host:$port", name = "$host:$port",
host = host, host = host,
port = port, port = port,
tlsEnabled = false,
tlsFingerprintSha256 = null,
) )
} }
} }
@@ -14,6 +14,7 @@ import java.io.BufferedWriter
import java.io.InputStreamReader import java.io.InputStreamReader
import java.io.OutputStreamWriter import java.io.OutputStreamWriter
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Socket
class BridgePairingClient { class BridgePairingClient {
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@@ -32,120 +33,95 @@ class BridgePairingClient {
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null) data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
suspend fun pairAndHello( suspend fun pairAndHello(endpoint: BridgeEndpoint, hello: Hello): PairResult =
endpoint: BridgeEndpoint,
hello: Hello,
tls: BridgeTlsParams? = null,
onTlsFingerprint: ((String) -> Unit)? = null,
): PairResult =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (tls != null) { val socket = Socket()
try { socket.tcpNoDelay = true
return@withContext pairAndHelloWithTls(endpoint, hello, tls, onTlsFingerprint) try {
} catch (e: Exception) { socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
if (tls.required) throw e socket.soTimeout = 60_000
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
fun send(line: String) {
writer.write(line)
writer.write("\n")
writer.flush()
} }
}
pairAndHelloWithTls(endpoint, hello, null, null)
}
private fun pairAndHelloWithTls( fun sendJson(obj: JsonObject) = send(obj.toString())
endpoint: BridgeEndpoint,
hello: Hello,
tls: BridgeTlsParams?,
onTlsFingerprint: ((String) -> Unit)?,
): PairResult {
val socket =
createBridgeSocket(tls) { fingerprint ->
onTlsFingerprint?.invoke(fingerprint)
}
socket.tcpNoDelay = true
try {
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 60_000
startTlsHandshakeIfNeeded(socket)
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8)) sendJson(
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8)) buildJsonObject {
put("type", JsonPrimitive("hello"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.token?.let { put("token", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
},
)
fun send(line: String) { val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
writer.write(line) ?: return@withContext PairResult(ok = false, token = null, error = "unexpected bridge response")
writer.write("\n") when (firstObj["type"].asStringOrNull()) {
writer.flush() "hello-ok" -> PairResult(ok = true, token = hello.token)
} "error" -> {
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
return@withContext PairResult(ok = false, token = null, error = "$code: $message")
}
fun sendJson(obj: JsonObject) = send(obj.toString()) sendJson(
buildJsonObject {
put("type", JsonPrimitive("pair-request"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
},
)
sendJson( while (true) {
buildJsonObject { val nextLine = reader.readLine() ?: break
put("type", JsonPrimitive("hello")) val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
put("nodeId", JsonPrimitive(hello.nodeId)) when (next["type"].asStringOrNull()) {
hello.displayName?.let { put("displayName", JsonPrimitive(it)) } "pair-ok" -> {
hello.token?.let { put("token", JsonPrimitive(it)) } val token = next["token"].asStringOrNull()
hello.platform?.let { put("platform", JsonPrimitive(it)) } return@withContext PairResult(ok = !token.isNullOrBlank(), token = token)
hello.version?.let { put("version", JsonPrimitive(it)) } }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) } "error" -> {
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) } val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) } val m = next["message"].asStringOrNull() ?: "pairing failed"
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) } return@withContext PairResult(ok = false, token = null, error = "$c: $m")
}, }
)
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
?: return PairResult(ok = false, token = null, error = "unexpected bridge response")
return when (firstObj["type"].asStringOrNull()) {
"hello-ok" -> PairResult(ok = true, token = hello.token)
"error" -> {
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
return PairResult(ok = false, token = null, error = "$code: $message")
}
sendJson(
buildJsonObject {
put("type", JsonPrimitive("pair-request"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
},
)
while (true) {
val nextLine = reader.readLine() ?: break
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
when (next["type"].asStringOrNull()) {
"pair-ok" -> {
val token = next["token"].asStringOrNull()
return PairResult(ok = !token.isNullOrBlank(), token = token)
}
"error" -> {
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
val m = next["message"].asStringOrNull() ?: "pairing failed"
return PairResult(ok = false, token = null, error = "$c: $m")
} }
} }
PairResult(ok = false, token = null, error = "pairing failed")
} }
PairResult(ok = false, token = null, error = "pairing failed") else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
}
} catch (e: Exception) {
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
PairResult(ok = false, token = null, error = message)
} finally {
try {
socket.close()
} catch (_: Throwable) {
// ignore
} }
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
}
} catch (e: Exception) {
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
return PairResult(ok = false, token = null, error = message)
} finally {
try {
socket.close()
} catch (_: Throwable) {
// ignore
} }
} }
}
} }
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
@@ -31,11 +31,10 @@ import java.util.concurrent.ConcurrentHashMap
class BridgeSession( class BridgeSession(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val onConnected: (serverName: String, remoteAddress: String?, mainSessionKey: String?) -> Unit, private val onConnected: (serverName: String, remoteAddress: String?) -> Unit,
private val onDisconnected: (message: String) -> Unit, private val onDisconnected: (message: String) -> Unit,
private val onEvent: (event: String, payloadJson: String?) -> Unit, private val onEvent: (event: String, payloadJson: String?) -> Unit,
private val onInvoke: suspend (InvokeRequest) -> InvokeResult, private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
) { ) {
data class Hello( data class Hello(
val nodeId: String, val nodeId: String,
@@ -65,19 +64,12 @@ class BridgeSession(
private val writeLock = Mutex() private val writeLock = Mutex()
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>() private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
@Volatile private var canvasHostUrl: String? = null @Volatile private var canvasHostUrl: String? = null
@Volatile private var mainSessionKey: String? = null
private data class DesiredConnection( private var desired: Pair<BridgeEndpoint, Hello>? = null
val endpoint: BridgeEndpoint,
val hello: Hello,
val tls: BridgeTlsParams?,
)
private var desired: DesiredConnection? = null
private var job: Job? = null private var job: Job? = null
fun connect(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams? = null) { fun connect(endpoint: BridgeEndpoint, hello: Hello) {
desired = DesiredConnection(endpoint, hello, tls) desired = endpoint to hello
if (job == null) { if (job == null) {
job = scope.launch(Dispatchers.IO) { runLoop() } job = scope.launch(Dispatchers.IO) { runLoop() }
} }
@@ -85,7 +77,7 @@ class BridgeSession(
suspend fun updateHello(hello: Hello) { suspend fun updateHello(hello: Hello) {
val target = desired ?: return val target = desired ?: return
desired = target.copy(hello = hello) desired = target.first to hello
val conn = currentConnection ?: return val conn = currentConnection ?: return
conn.sendJson(buildHelloJson(hello)) conn.sendJson(buildHelloJson(hello))
} }
@@ -98,13 +90,11 @@ class BridgeSession(
job?.cancelAndJoin() job?.cancelAndJoin()
job = null job = null
canvasHostUrl = null canvasHostUrl = null
mainSessionKey = null
onDisconnected("Offline") onDisconnected("Offline")
} }
} }
fun currentCanvasHostUrl(): String? = canvasHostUrl fun currentCanvasHostUrl(): String? = canvasHostUrl
fun currentMainSessionKey(): String? = mainSessionKey
suspend fun sendEvent(event: String, payloadJson: String?) { suspend fun sendEvent(event: String, payloadJson: String?) {
val conn = currentConnection ?: return val conn = currentConnection ?: return
@@ -172,10 +162,10 @@ class BridgeSession(
continue continue
} }
val (endpoint, hello, tls) = target val (endpoint, hello) = target
try { try {
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…") onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
connectOnce(endpoint, hello, tls) connectOnce(endpoint, hello)
attempt = 0 attempt = 0
} catch (err: Throwable) { } catch (err: Throwable) {
attempt += 1 attempt += 1
@@ -199,76 +189,58 @@ class BridgeSession(
return InvokeResult.error(code = "UNAVAILABLE", message = msg) return InvokeResult.error(code = "UNAVAILABLE", message = msg)
} }
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) = private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello) =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (tls != null) { val socket = Socket()
try { socket.tcpNoDelay = true
connectWithSocket(endpoint, hello, tls) socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
return@withContext socket.soTimeout = 0
} catch (err: Throwable) {
if (tls.required) throw err
}
}
connectWithSocket(endpoint, hello, null)
}
private suspend fun connectWithSocket(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) { val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val socket = val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
createBridgeSocket(tls) { fingerprint ->
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
}
socket.tcpNoDelay = true
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 0
startTlsHandshakeIfNeeded(socket)
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8)) val conn = Connection(socket, reader, writer, writeLock)
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8)) currentConnection = conn
val conn = Connection(socket, reader, writer, writeLock) try {
currentConnection = conn conn.sendJson(buildHelloJson(hello))
try { val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
conn.sendJson(buildHelloJson(hello)) val first = json.parseToJsonElement(firstLine).asObjectOrNull()
?: throw IllegalStateException("unexpected bridge response")
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection") when (first["type"].asStringOrNull()) {
val first = json.parseToJsonElement(firstLine).asObjectOrNull() "hello-ok" -> {
?: throw IllegalStateException("unexpected bridge response") val name = first["serverName"].asStringOrNull() ?: "Bridge"
when (first["type"].asStringOrNull()) { val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
"hello-ok" -> { canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
val name = first["serverName"].asStringOrNull() ?: "Bridge" if (BuildConfig.DEBUG) {
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null } // Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null } runCatching {
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint) android.util.Log.d(
mainSessionKey = rawMainSessionKey "ClawdbotBridge",
if (BuildConfig.DEBUG) { "canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked". )
runCatching { }
android.util.Log.d(
"ClawdbotBridge",
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
)
} }
onConnected(name, conn.remoteAddress)
} }
onConnected(name, conn.remoteAddress, rawMainSessionKey) "error" -> {
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
val msg = first["message"].asStringOrNull() ?: "connect failed"
throw IllegalStateException("$code: $msg")
}
else -> throw IllegalStateException("unexpected bridge response")
} }
"error" -> {
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
val msg = first["message"].asStringOrNull() ?: "connect failed"
throw IllegalStateException("$code: $msg")
}
else -> throw IllegalStateException("unexpected bridge response")
}
while (scope.isActive) { while (scope.isActive) {
val line = reader.readLine() ?: break val line = reader.readLine() ?: break
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
when (frame["type"].asStringOrNull()) { when (frame["type"].asStringOrNull()) {
"event" -> { "event" -> {
val event = frame["event"].asStringOrNull() ?: continue val event = frame["event"].asStringOrNull() ?: return@withContext
val payload = frame["payloadJSON"].asStringOrNull() val payload = frame["payloadJSON"].asStringOrNull()
onEvent(event, payload) onEvent(event, payload)
} }
"ping" -> { "ping" -> {
val id = frame["id"].asStringOrNull() ?: "" val id = frame["id"].asStringOrNull() ?: ""
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) }) conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
@@ -314,20 +286,20 @@ class BridgeSession(
}, },
) )
} }
"invoke-res" -> { "invoke-res" -> {
// gateway->node only (ignore) // gateway->node only (ignore)
}
} }
} }
} finally {
currentConnection = null
for ((_, waiter) in pending) {
waiter.cancel()
}
pending.clear()
conn.closeQuietly()
} }
} finally {
currentConnection = null
for ((_, waiter) in pending) {
waiter.cancel()
}
pending.clear()
conn.closeQuietly()
} }
}
private fun buildHelloJson(hello: Hello): JsonObject = private fun buildHelloJson(hello: Hello): JsonObject =
buildJsonObject { buildJsonObject {
@@ -1,81 +0,0 @@
package com.clawdbot.android.bridge
import android.annotation.SuppressLint
import java.net.Socket
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocket
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
data class BridgeTlsParams(
val required: Boolean,
val expectedFingerprint: String?,
val allowTOFU: Boolean,
val stableId: String,
)
fun createBridgeSocket(params: BridgeTlsParams?, onStore: ((String) -> Unit)? = null): Socket {
if (params == null) return Socket()
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
val defaultTrust = defaultTrustManager()
@SuppressLint("CustomX509TrustManager")
val trustManager =
object : X509TrustManager {
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
defaultTrust.checkClientTrusted(chain, authType)
}
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
val fingerprint = sha256Hex(chain[0].encoded)
if (expected != null) {
if (fingerprint != expected) {
throw CertificateException("bridge TLS fingerprint mismatch")
}
return
}
if (params.allowTOFU) {
onStore?.invoke(fingerprint)
return
}
defaultTrust.checkServerTrusted(chain, authType)
}
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrust.acceptedIssuers
}
val context = SSLContext.getInstance("TLS")
context.init(null, arrayOf(trustManager), SecureRandom())
return context.socketFactory.createSocket()
}
fun startTlsHandshakeIfNeeded(socket: Socket) {
if (socket is SSLSocket) {
socket.startHandshake()
}
}
private fun defaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as java.security.KeyStore?)
val trust =
factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager
return trust ?: throw IllegalStateException("No default X509TrustManager found")
}
private fun sha256Hex(data: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256").digest(data)
val out = StringBuilder(digest.size * 2)
for (byte in digest) {
out.append(String.format("%02x", byte))
}
return out.toString()
}
private fun normalizeFingerprint(raw: String): String {
return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
}
@@ -71,21 +71,12 @@ class ChatController(
_sessionId.value = null _sessionId.value = null
} }
fun load(sessionKey: String) { fun load(sessionKey: String = "main") {
val key = sessionKey.trim().ifEmpty { "main" } val key = sessionKey.trim().ifEmpty { "main" }
_sessionKey.value = key _sessionKey.value = key
scope.launch { bootstrap(forceHealth = true) } scope.launch { bootstrap(forceHealth = true) }
} }
fun applyMainSessionKey(mainSessionKey: String) {
val trimmed = mainSessionKey.trim()
if (trimmed.isEmpty()) return
if (_sessionKey.value == trimmed) return
if (_sessionKey.value != "main") return
_sessionKey.value = trimmed
scope.launch { bootstrap(forceHealth = true) }
}
fun refresh() { fun refresh() {
scope.launch { bootstrap(forceHealth = true) } scope.launch { bootstrap(forceHealth = true) }
} }
@@ -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 =
@@ -44,7 +44,6 @@ import com.clawdbot.android.chat.ChatSessionEntry
fun ChatComposer( fun ChatComposer(
sessionKey: String, sessionKey: String,
sessions: List<ChatSessionEntry>, sessions: List<ChatSessionEntry>,
mainSessionKey: String,
healthOk: Boolean, healthOk: Boolean,
thinkingLevel: String, thinkingLevel: String,
pendingRunCount: Int, pendingRunCount: Int,
@@ -62,7 +61,7 @@ fun ChatComposer(
var showThinkingMenu by remember { mutableStateOf(false) } var showThinkingMenu by remember { mutableStateOf(false) }
var showSessionMenu by remember { mutableStateOf(false) } var showSessionMenu by remember { mutableStateOf(false) }
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey) val sessionOptions = resolveSessionChoices(sessionKey, sessions)
val currentSessionLabel = val currentSessionLabel =
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
@@ -33,14 +33,13 @@ fun ChatSheetContent(viewModel: MainViewModel) {
val pendingRunCount by viewModel.pendingRunCount.collectAsState() val pendingRunCount by viewModel.pendingRunCount.collectAsState()
val healthOk by viewModel.chatHealthOk.collectAsState() val healthOk by viewModel.chatHealthOk.collectAsState()
val sessionKey by viewModel.chatSessionKey.collectAsState() val sessionKey by viewModel.chatSessionKey.collectAsState()
val mainSessionKey by viewModel.mainSessionKey.collectAsState()
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState() val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState() val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState() val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val sessions by viewModel.chatSessions.collectAsState() val sessions by viewModel.chatSessions.collectAsState()
LaunchedEffect(mainSessionKey) { LaunchedEffect(Unit) {
viewModel.loadChat(mainSessionKey) viewModel.loadChat("main")
viewModel.refreshChatSessions(limit = 200) viewModel.refreshChatSessions(limit = 200)
} }
@@ -86,7 +85,6 @@ fun ChatSheetContent(viewModel: MainViewModel) {
ChatComposer( ChatComposer(
sessionKey = sessionKey, sessionKey = sessionKey,
sessions = sessions, sessions = sessions,
mainSessionKey = mainSessionKey,
healthOk = healthOk, healthOk = healthOk,
thinkingLevel = thinkingLevel, thinkingLevel = thinkingLevel,
pendingRunCount = pendingRunCount, pendingRunCount = pendingRunCount,
@@ -2,23 +2,20 @@ package com.clawdbot.android.ui.chat
import com.clawdbot.android.chat.ChatSessionEntry import com.clawdbot.android.chat.ChatSessionEntry
private const val MAIN_SESSION_KEY = "main"
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
fun resolveSessionChoices( fun resolveSessionChoices(
currentSessionKey: String, currentSessionKey: String,
sessions: List<ChatSessionEntry>, sessions: List<ChatSessionEntry>,
mainSessionKey: String,
nowMs: Long = System.currentTimeMillis(), nowMs: Long = System.currentTimeMillis(),
): List<ChatSessionEntry> { ): List<ChatSessionEntry> {
val mainKey = mainSessionKey.trim().ifEmpty { "main" } val current = currentSessionKey.trim()
val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it }
val aliasKey = if (mainKey == "main") null else "main"
val cutoff = nowMs - RECENT_WINDOW_MS val cutoff = nowMs - RECENT_WINDOW_MS
val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L } val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L }
val recent = mutableListOf<ChatSessionEntry>() val recent = mutableListOf<ChatSessionEntry>()
val seen = mutableSetOf<String>() val seen = mutableSetOf<String>()
for (entry in sorted) { for (entry in sorted) {
if (aliasKey != null && entry.key == aliasKey) continue
if (!seen.add(entry.key)) continue if (!seen.add(entry.key)) continue
if ((entry.updatedAtMs ?: 0L) < cutoff) continue if ((entry.updatedAtMs ?: 0L) < cutoff) continue
recent.add(entry) recent.add(entry)
@@ -26,13 +23,13 @@ fun resolveSessionChoices(
val result = mutableListOf<ChatSessionEntry>() val result = mutableListOf<ChatSessionEntry>()
val included = mutableSetOf<String>() val included = mutableSetOf<String>()
val mainEntry = sorted.firstOrNull { it.key == mainKey } val mainEntry = sorted.firstOrNull { it.key == MAIN_SESSION_KEY }
if (mainEntry != null) { if (mainEntry != null) {
result.add(mainEntry) result.add(mainEntry)
included.add(mainKey) included.add(MAIN_SESSION_KEY)
} else if (current == mainKey) { } else if (current == MAIN_SESSION_KEY) {
result.add(ChatSessionEntry(key = mainKey, updatedAtMs = null)) result.add(ChatSessionEntry(key = MAIN_SESSION_KEY, updatedAtMs = null))
included.add(mainKey) included.add(MAIN_SESSION_KEY)
} }
for (entry in recent) { for (entry in recent) {
@@ -21,8 +21,6 @@ import android.speech.tts.UtteranceProgressListener
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.clawdbot.android.bridge.BridgeSession import com.clawdbot.android.bridge.BridgeSession
import com.clawdbot.android.isCanonicalMainSessionKey
import com.clawdbot.android.normalizeMainKey
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.util.UUID import java.util.UUID
@@ -117,13 +115,6 @@ class TalkModeManager(
chatSubscribedSessionKey = null chatSubscribedSessionKey = null
} }
fun setMainSessionKey(sessionKey: String?) {
val trimmed = sessionKey?.trim().orEmpty()
if (trimmed.isEmpty()) return
if (isCanonicalMainSessionKey(mainSessionKey)) return
mainSessionKey = trimmed
}
fun setEnabled(enabled: Boolean) { fun setEnabled(enabled: Boolean) {
if (_isEnabled.value == enabled) return if (_isEnabled.value == enabled) return
_isEnabled.value = enabled _isEnabled.value = enabled
@@ -823,7 +814,7 @@ class TalkModeManager(
val config = root?.get("config").asObjectOrNull() val config = root?.get("config").asObjectOrNull()
val talk = config?.get("talk").asObjectOrNull() val talk = config?.get("talk").asObjectOrNull()
val sessionCfg = config?.get("session").asObjectOrNull() val sessionCfg = config?.get("session").asObjectOrNull()
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()) val mainKey = sessionCfg?.get("mainKey").asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: "main"
val voice = talk?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } val voice = talk?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val aliases = val aliases =
talk?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) -> talk?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) ->
@@ -835,9 +826,7 @@ class TalkModeManager(
val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull() val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
if (!isCanonicalMainSessionKey(mainSessionKey)) { mainSessionKey = mainKey
mainSessionKey = mainKey
}
defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
voiceAliases = aliases voiceAliases = aliases
if (!voiceOverrideActive) currentVoiceId = defaultVoiceId if (!voiceOverrideActive) currentVoiceId = defaultVoiceId
@@ -30,7 +30,7 @@ class BridgeSessionTest {
val session = val session =
BridgeSession( BridgeSession(
scope = scope, scope = scope,
onConnected = { _, _, _ -> connected.complete(Unit) }, onConnected = { _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ }, onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ }, onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) }, onInvoke = { BridgeSession.InvokeResult.ok(null) },
@@ -97,7 +97,7 @@ class BridgeSessionTest {
val session = val session =
BridgeSession( BridgeSession(
scope = scope, scope = scope,
onConnected = { _, _, _ -> connected.complete(Unit) }, onConnected = { _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ }, onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ }, onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) }, onInvoke = { BridgeSession.InvokeResult.ok(null) },
@@ -167,7 +167,7 @@ class BridgeSessionTest {
val session = val session =
BridgeSession( BridgeSession(
scope = scope, scope = scope,
onConnected = { _, _, _ -> connected.complete(Unit) }, onConnected = { _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ }, onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ }, onEvent = { _, _ -> /* ignore */ },
onInvoke = { throw IllegalStateException("FOO_BAR: boom") }, onInvoke = { throw IllegalStateException("FOO_BAR: boom") },
@@ -239,7 +239,7 @@ class BridgeSessionTest {
val session = val session =
BridgeSession( BridgeSession(
scope = scope, scope = scope,
onConnected = { _, _, _ -> connected.countDown() }, onConnected = { _, _ -> connected.countDown() },
onDisconnected = { /* ignore */ }, onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ }, onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) }, onInvoke = { BridgeSession.InvokeResult.ok(null) },
@@ -19,7 +19,7 @@ class SessionFiltersTest {
ChatSessionEntry(key = "recent-2", updatedAtMs = recent2), ChatSessionEntry(key = "recent-2", updatedAtMs = recent2),
) )
val result = resolveSessionChoices("main", sessions, mainSessionKey = "main", nowMs = now).map { it.key } val result = resolveSessionChoices("main", sessions, nowMs = now).map { it.key }
assertEquals(listOf("main", "recent-1", "recent-2"), result) assertEquals(listOf("main", "recent-1", "recent-2"), result)
} }
@@ -29,7 +29,7 @@ class SessionFiltersTest {
val recent = now - 10 * 60 * 1000L val recent = now - 10 * 60 * 1000L
val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent)) val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent))
val result = resolveSessionChoices("custom", sessions, mainSessionKey = "main", nowMs = now).map { it.key } val result = resolveSessionChoices("custom", sessions, nowMs = now).map { it.key }
assertEquals(listOf("main", "custom"), result) assertEquals(listOf("main", "custom"), result)
} }
} }
+13 -43
View File
@@ -10,36 +10,10 @@ actor BridgeClient {
func pairAndHello( func pairAndHello(
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello, hello: BridgeHello,
tls: BridgeTLSParams? = nil,
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{
do {
return try await self.pairAndHelloOnce(
endpoint: endpoint,
hello: hello,
tls: tls,
onStatus: onStatus)
} catch {
if let tls, !tls.required {
return try await self.pairAndHelloOnce(
endpoint: endpoint,
hello: hello,
tls: nil,
onStatus: onStatus)
}
throw error
}
}
private func pairAndHelloOnce(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: BridgeTLSParams?,
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{ {
self.lineBuffer = Data() self.lineBuffer = Data()
let params = self.makeParameters(tls: tls) let connection = NWConnection(to: endpoint, using: .tcp)
let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client") let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client")
defer { connection.cancel() } defer { connection.cancel() }
try await self.withTimeout(seconds: 8, purpose: "connect") { try await self.withTimeout(seconds: 8, purpose: "connect") {
@@ -168,18 +142,6 @@ actor BridgeClient {
} }
} }
private func makeParameters(tls: BridgeTLSParams?) -> NWParameters {
if let tlsOptions = makeBridgeTLSOptions(tls) {
let tcpOptions = NWProtocolTCP.Options()
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
params.includePeerToPeer = true
return params
}
let params = NWParameters.tcp
params.includePeerToPeer = true
return params
}
private struct TimeoutError: LocalizedError, Sendable { private struct TimeoutError: LocalizedError, Sendable {
var purpose: String var purpose: String
var seconds: Int var seconds: Int
@@ -199,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 {
@@ -10,7 +10,6 @@ protocol BridgePairingClient: Sendable {
func pairAndHello( func pairAndHello(
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello, hello: BridgeHello,
tls: BridgeTLSParams?,
onStatus: (@Sendable (String) -> Void)?) async throws -> String onStatus: (@Sendable (String) -> Void)?) async throws -> String
} }
@@ -116,14 +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)
let stableID = BridgeEndpointID.stableID(endpoint) self.startAutoConnect(endpoint: endpoint, token: token, instanceId: instanceId)
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
self.startAutoConnect(
endpoint: endpoint,
bridgeStableID: stableID,
tls: tlsParams,
token: token,
instanceId: instanceId)
return return
} }
@@ -139,14 +131,8 @@ 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 }
let tlsParams = self.resolveDiscoveredTLSParams(bridge: target)
self.didAutoConnect = true self.didAutoConnect = true
self.startAutoConnect( self.startAutoConnect(endpoint: target.endpoint, token: token, instanceId: instanceId)
endpoint: target.endpoint,
bridgeStableID: target.stableID,
tls: tlsParams,
token: token,
instanceId: instanceId)
} }
private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) { private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
@@ -185,13 +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,
tls: BridgeTLSParams?,
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 }
@@ -200,7 +180,6 @@ final class BridgeConnectionController {
let refreshed = try await self.bridgeClientFactory().pairAndHello( let refreshed = try await self.bridgeClientFactory().pairAndHello(
endpoint: endpoint, endpoint: endpoint,
hello: hello, hello: hello,
tls: tls,
onStatus: { status in onStatus: { status in
Task { @MainActor in Task { @MainActor in
appModel.bridgeStatusText = status appModel.bridgeStatusText = status
@@ -213,11 +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,
tls: tls,
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)"
@@ -226,47 +201,6 @@ final class BridgeConnectionController {
} }
} }
private func resolveDiscoveredTLSParams(
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
{
let stableID = bridge.stableID
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
return BridgeTLSParams(
required: true,
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
allowTOFU: stored == nil,
storeKey: stableID)
}
if let stored {
return BridgeTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
}
return nil
}
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
return BridgeTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
}
return BridgeTLSParams(
required: false,
expectedFingerprint: nil,
allowTOFU: true,
storeKey: stableID)
}
private func resolvedDisplayName(defaults: UserDefaults) -> String { private func resolvedDisplayName(defaults: UserDefaults) -> String {
let key = "node.displayName" let key = "node.displayName"
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -23,8 +23,6 @@ final class BridgeDiscoveryModel {
var gatewayPort: Int? var gatewayPort: Int?
var bridgePort: Int? var bridgePort: Int?
var canvasPort: Int? var canvasPort: Int?
var tlsEnabled: Bool
var tlsFingerprintSha256: String?
var cliPath: String? var cliPath: String?
} }
@@ -92,8 +90,6 @@ final class BridgeDiscoveryModel {
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"), gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
bridgePort: Self.txtIntValue(txt, key: "bridgePort"), bridgePort: Self.txtIntValue(txt, key: "bridgePort"),
canvasPort: Self.txtIntValue(txt, key: "canvasPort"), canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
tlsEnabled: Self.txtBoolValue(txt, key: "bridgeTls"),
tlsFingerprintSha256: Self.txtValue(txt, key: "bridgeTlsSha256"),
cliPath: Self.txtValue(txt, key: "cliPath")) cliPath: Self.txtValue(txt, key: "cliPath"))
default: default:
return nil return nil
@@ -218,9 +214,4 @@ final class BridgeDiscoveryModel {
guard let raw = self.txtValue(dict, key: key) else { return nil } guard let raw = self.txtValue(dict, key: key) else { return nil }
return Int(raw) return Int(raw)
} }
private static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
return raw == "1" || raw == "true" || raw == "yes"
}
} }
+18 -55
View File
@@ -26,7 +26,6 @@ actor BridgeSession {
private(set) var state: State = .idle private(set) var state: State = .idle
private var canvasHostUrl: String? private var canvasHostUrl: String?
private var mainSessionKey: String?
func currentCanvasHostUrl() -> String? { func currentCanvasHostUrl() -> String? {
self.canvasHostUrl self.canvasHostUrl
@@ -69,42 +68,15 @@ actor BridgeSession {
func connect( func connect(
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello, hello: BridgeHello,
tls: BridgeTLSParams? = nil, onConnected: (@Sendable (String) async -> Void)? = nil,
onConnected: (@Sendable (String, 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.state = .connecting self.state = .connecting
do {
try await self.connectOnce(
endpoint: endpoint,
hello: hello,
tls: tls,
onConnected: onConnected,
onInvoke: onInvoke)
} catch {
if let tls, !tls.required {
try await self.connectOnce(
endpoint: endpoint,
hello: hello,
tls: nil,
onConnected: onConnected,
onInvoke: onInvoke)
return
}
throw error
}
}
private func connectOnce( let params = NWParameters.tcp
endpoint: NWEndpoint, params.includePeerToPeer = true
hello: BridgeHello,
tls: BridgeTLSParams?,
onConnected: (@Sendable (String, String?) async -> Void)?,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
{
let params = self.makeParameters(tls: tls)
let connection = NWConnection(to: endpoint, using: params) let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session") let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session")
self.connection = connection self.connection = connection
@@ -135,9 +107,7 @@ actor BridgeSession {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data) let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName) self.state = .connected(serverName: ok.serverName)
self.canvasHostUrl = ok.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines) self.canvasHostUrl = ok.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines)
let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) await onConnected?(ok.serverName)
self.mainSessionKey = (mainKey?.isEmpty == false) ? mainKey : nil
await onConnected?(ok.serverName, self.mainSessionKey)
} 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)")
@@ -247,7 +217,6 @@ actor BridgeSession {
self.queue = nil self.queue = nil
self.buffer = Data() self.buffer = Data()
self.canvasHostUrl = nil self.canvasHostUrl = nil
self.mainSessionKey = nil
let pending = self.pendingRPC.values let pending = self.pendingRPC.values
self.pendingRPC.removeAll() self.pendingRPC.removeAll()
@@ -265,10 +234,6 @@ actor BridgeSession {
self.state = .idle self.state = .idle
} }
func currentMainSessionKey() -> String? {
self.mainSessionKey
}
private func beginRPC( private func beginRPC(
id: String, id: String,
request: BridgeRPCRequest, request: BridgeRPCRequest,
@@ -282,18 +247,6 @@ actor BridgeSession {
} }
} }
private func makeParameters(tls: BridgeTLSParams?) -> NWParameters {
if let tlsOptions = makeBridgeTLSOptions(tls) {
let tcpOptions = NWProtocolTCP.Options()
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
params.includePeerToPeer = true
return params
}
let params = NWParameters.tcp
params.includePeerToPeer = true
return params
}
private func timeoutRPC(id: String) async { private func timeoutRPC(id: String) async {
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return } guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [ cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [
@@ -368,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> {
-66
View File
@@ -1,66 +0,0 @@
import CryptoKit
import Foundation
import Network
import Security
struct BridgeTLSParams: Sendable {
let required: Bool
let expectedFingerprint: String?
let allowTOFU: Bool
let storeKey: String?
}
enum BridgeTLSStore {
private static let service = "com.clawdbot.bridge.tls"
static func loadFingerprint(stableID: String) -> String? {
KeychainStore.loadString(service: service, account: stableID)?.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func saveFingerprint(_ value: String, stableID: String) {
_ = KeychainStore.saveString(value, service: service, account: stableID)
}
}
func makeBridgeTLSOptions(_ params: BridgeTLSParams?) -> NWProtocolTLS.Options? {
guard let params else { return nil }
let options = NWProtocolTLS.Options()
let expected = params.expectedFingerprint.map(normalizeBridgeFingerprint)
let allowTOFU = params.allowTOFU
let storeKey = params.storeKey
sec_protocol_options_set_verify_block(
options.securityProtocolOptions,
{ _, trust, complete in
let trustRef = sec_trust_copy_ref(trust).takeRetainedValue()
if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate],
let cert = chain.first
{
let data = SecCertificateCopyData(cert) as Data
let fingerprint = sha256Hex(data)
if let expected {
complete(fingerprint == expected)
return
}
if allowTOFU {
if let storeKey { BridgeTLSStore.saveFingerprint(fingerprint, stableID: storeKey) }
complete(true)
return
}
}
let ok = SecTrustEvaluateWithError(trustRef, nil)
complete(ok)
},
DispatchQueue(label: "com.clawdbot.bridge.tls.verify"))
return options
}
private func sha256Hex(_ data: Data) -> String {
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
private func normalizeBridgeFingerprint(_ raw: String) -> String {
raw.lowercased().filter { $0.isHexDigit }
}
+9 -20
View File
@@ -190,7 +190,14 @@ actor CameraController {
} }
func listDevices() -> [CameraDeviceInfo] { func listDevices() -> [CameraDeviceInfo] {
return Self.discoverVideoDevices().map { device in let types: [AVCaptureDevice.DeviceType] = [
.builtInWideAngleCamera,
]
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: types,
mediaType: .video,
position: .unspecified)
return session.devices.map { device in
CameraDeviceInfo( CameraDeviceInfo(
id: device.uniqueID, id: device.uniqueID,
name: device.localizedName, name: device.localizedName,
@@ -225,7 +232,7 @@ actor CameraController {
deviceId: String?) -> AVCaptureDevice? deviceId: String?) -> AVCaptureDevice?
{ {
if let deviceId, !deviceId.isEmpty { if let deviceId, !deviceId.isEmpty {
if let match = Self.discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) { if let match = AVCaptureDevice.devices(for: .video).first(where: { $0.uniqueID == deviceId }) {
return match return match
} }
} }
@@ -245,24 +252,6 @@ actor CameraController {
} }
} }
private nonisolated static func discoverVideoDevices() -> [AVCaptureDevice] {
let types: [AVCaptureDevice.DeviceType] = [
.builtInWideAngleCamera,
.builtInUltraWideCamera,
.builtInTelephotoCamera,
.builtInDualCamera,
.builtInDualWideCamera,
.builtInTripleCamera,
.builtInTrueDepthCamera,
.builtInLiDARDepthCamera,
]
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: types,
mediaType: .video,
position: .unspecified)
return session.devices
}
nonisolated static func clampQuality(_ quality: Double?) -> Double { nonisolated static func clampQuality(_ quality: Double?) -> Double {
let q = quality ?? 0.9 let q = quality ?? 0.9
return min(1.0, max(0.05, q)) return min(1.0, max(0.05, q))
+1 -1
View File
@@ -6,7 +6,7 @@ struct ChatSheet: View {
@State private var viewModel: ClawdbotChatViewModel @State private var viewModel: ClawdbotChatViewModel
private let userAccent: Color? private let userAccent: Color?
init(bridge: BridgeSession, sessionKey: String, userAccent: Color? = nil) { init(bridge: BridgeSession, sessionKey: String = "main", userAccent: Color? = nil) {
let transport = IOSBridgeChatTransport(bridge: bridge) let transport = IOSBridgeChatTransport(bridge: bridge)
self._viewModel = State( self._viewModel = State(
initialValue: ClawdbotChatViewModel( initialValue: ClawdbotChatViewModel(
+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)
}
} }
} }
+8 -33
View File
@@ -109,7 +109,7 @@ final class NodeAppModel {
let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name
let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased() let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
let contextJSON = ClawdbotCanvasA2UIAction.compactJSON(userAction["context"]) let contextJSON = ClawdbotCanvasA2UIAction.compactJSON(userAction["context"])
let sessionKey = self.mainSessionKey let sessionKey = "main"
let messageContext = ClawdbotCanvasA2UIAction.AgentMessageContext( let messageContext = ClawdbotCanvasA2UIAction.AgentMessageContext(
actionName: name, actionName: name,
@@ -204,15 +204,12 @@ final class NodeAppModel {
func connectToBridge( func connectToBridge(
endpoint: NWEndpoint, endpoint: NWEndpoint,
bridgeStableID: String,
tls: BridgeTLSParams?,
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
@@ -233,16 +230,12 @@ final class NodeAppModel {
try await self.bridge.connect( try await self.bridge.connect(
endpoint: endpoint, endpoint: endpoint,
hello: hello, hello: hello,
tls: tls, onConnected: { [weak self] serverName in
onConnected: { [weak self] serverName, mainSessionKey in
guard let self else { return } guard let self else { return }
await MainActor.run { await MainActor.run {
self.bridgeStatusText = "Connected" self.bridgeStatusText = "Connected"
self.bridgeServerName = serverName self.bridgeServerName = serverName
} }
await MainActor.run {
self.applyMainSessionKey(mainSessionKey)
}
if let addr = await self.bridge.currentRemoteAddress() { if let addr = await self.bridge.currentRemoteAddress() {
await MainActor.run { await MainActor.run {
self.bridgeRemoteAddress = addr self.bridgeRemoteAddress = addr
@@ -291,10 +284,7 @@ final class NodeAppModel {
self.bridgeRemoteAddress = nil self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil self.connectedBridgeID = nil
self.seamColorHex = nil self.seamColorHex = nil
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { self.mainSessionKey = "main"
self.mainSessionKey = "main"
self.talkMode.updateMainSessionKey(self.mainSessionKey)
}
self.showLocalCanvasOnDisconnect() self.showLocalCanvasOnDisconnect()
} }
} }
@@ -311,23 +301,10 @@ final class NodeAppModel {
self.bridgeRemoteAddress = nil self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil self.connectedBridgeID = nil
self.seamColorHex = nil self.seamColorHex = nil
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { self.mainSessionKey = "main"
self.mainSessionKey = "main"
self.talkMode.updateMainSessionKey(self.mainSessionKey)
}
self.showLocalCanvasOnDisconnect() self.showLocalCanvasOnDisconnect()
} }
private func applyMainSessionKey(_ key: String?) {
let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let current = self.mainSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
if SessionKey.isCanonicalMainSessionKey(current) { return }
if trimmed == current { return }
self.mainSessionKey = trimmed
self.talkMode.updateMainSessionKey(trimmed)
}
var seamColor: Color { var seamColor: Color {
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
} }
@@ -353,13 +330,11 @@ final class NodeAppModel {
let ui = config["ui"] as? [String: Any] let ui = config["ui"] as? [String: Any]
let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let session = config["session"] as? [String: Any] let session = config["session"] as? [String: Any]
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String) let rawMainKey = (session?["mainKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let mainKey = rawMainKey.isEmpty ? "main" : rawMainKey
await MainActor.run { await MainActor.run {
self.seamColorHex = raw.isEmpty ? nil : raw self.seamColorHex = raw.isEmpty ? nil : raw
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { self.mainSessionKey = mainKey
self.mainSessionKey = mainKey
self.talkMode.updateMainSessionKey(mainKey)
}
} }
} catch { } catch {
// ignore // ignore
@@ -137,11 +137,9 @@ final class ScreenRecordService: @unchecked Sendable {
recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void
{ {
{ sample, type, error in { sample, type, error in
let sampleBox = UncheckedSendableBox(value: sample)
// ReplayKit can call the capture handler on a background queue. // ReplayKit can call the capture handler on a background queue.
// Serialize writes to avoid queue asserts. // Serialize writes to avoid queue asserts.
recordQueue.async { recordQueue.async {
let sample = sampleBox.value
if let error { if let error {
state.withLock { state in state.withLock { state in
if state.handlerError == nil { state.handlerError = error } if state.handlerError == nil { state.handlerError = error }
-15
View File
@@ -1,15 +0,0 @@
import Foundation
enum SessionKey {
static func normalizeMainKey(_ raw: String?) -> String {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "main" : trimmed
}
static func isCanonicalMainSessionKey(_ value: String?) -> Bool {
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return false }
if trimmed == "global" { return true }
return trimmed.hasPrefix("agent:")
}
}
@@ -407,11 +407,9 @@ struct SettingsTab: View {
modelIdentifier: self.modelIdentifier(), modelIdentifier: self.modelIdentifier(),
caps: self.currentCaps(), caps: self.currentCaps(),
commands: self.currentCommands()) commands: self.currentCommands())
let tlsParams = self.resolveDiscoveredTLSParams(bridge: bridge)
let token = try await BridgeClient().pairAndHello( let token = try await BridgeClient().pairAndHello(
endpoint: bridge.endpoint, endpoint: bridge.endpoint,
hello: hello, hello: hello,
tls: tlsParams,
onStatus: { status in onStatus: { status in
Task { @MainActor in Task { @MainActor in
statusStore.text = status statusStore.text = status
@@ -427,8 +425,6 @@ struct SettingsTab: View {
self.appModel.connectToBridge( self.appModel.connectToBridge(
endpoint: bridge.endpoint, endpoint: bridge.endpoint,
bridgeStableID: bridge.stableID,
tls: tlsParams,
hello: BridgeHello( hello: BridgeHello(
nodeId: self.instanceId, nodeId: self.instanceId,
displayName: self.displayName, displayName: self.displayName,
@@ -465,8 +461,6 @@ struct SettingsTab: View {
defer { self.connectingBridgeID = nil } defer { self.connectingBridgeID = nil }
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port) let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
let stableID = BridgeEndpointID.stableID(endpoint)
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
do { do {
let statusStore = self.connectStatus let statusStore = self.connectStatus
@@ -490,7 +484,6 @@ struct SettingsTab: View {
let token = try await BridgeClient().pairAndHello( let token = try await BridgeClient().pairAndHello(
endpoint: endpoint, endpoint: endpoint,
hello: hello, hello: hello,
tls: tlsParams,
onStatus: { status in onStatus: { status in
Task { @MainActor in Task { @MainActor in
statusStore.text = status statusStore.text = status
@@ -506,8 +499,6 @@ struct SettingsTab: View {
self.appModel.connectToBridge( self.appModel.connectToBridge(
endpoint: endpoint, endpoint: endpoint,
bridgeStableID: stableID,
tls: tlsParams,
hello: BridgeHello( hello: BridgeHello(
nodeId: self.instanceId, nodeId: self.instanceId,
displayName: self.displayName, displayName: self.displayName,
@@ -524,47 +515,6 @@ struct SettingsTab: View {
} }
} }
private func resolveDiscoveredTLSParams(
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
{
let stableID = bridge.stableID
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
return BridgeTLSParams(
required: true,
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
allowTOFU: stored == nil,
storeKey: stableID)
}
if let stored {
return BridgeTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
}
return nil
}
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
return BridgeTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
}
return BridgeTLSParams(
required: false,
expectedFingerprint: nil,
allowTOFU: true,
storeKey: stableID)
}
private static func primaryIPv4Address() -> String? { private static func primaryIPv4Address() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>? var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
+8 -15
View File
@@ -53,13 +53,6 @@ final class TalkModeManager: NSObject {
self.bridge = bridge self.bridge = bridge
} }
func updateMainSessionKey(_ sessionKey: String?) {
let trimmed = (sessionKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
if SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { return }
self.mainSessionKey = trimmed
}
func setEnabled(_ enabled: Bool) { func setEnabled(_ enabled: Bool) {
self.isEnabled = enabled self.isEnabled = enabled
if enabled { if enabled {
@@ -295,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)")
} }
} }
@@ -534,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
} }
@@ -656,10 +651,8 @@ final class TalkModeManager: NSObject {
guard let config = json["config"] as? [String: Any] else { return } guard let config = json["config"] as? [String: Any] else { return }
let talk = config["talk"] as? [String: Any] let talk = config["talk"] as? [String: Any]
let session = config["session"] as? [String: Any] let session = config["session"] as? [String: Any]
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String) let rawMainKey = (session?["mainKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { self.mainSessionKey = rawMainKey.isEmpty ? "main" : rawMainKey
self.mainSessionKey = mainKey
}
self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let aliases = talk?["voiceAliases"] as? [String: Any] { if let aliases = talk?["voiceAliases"] as? [String: Any] {
var resolved: [String: String] = [:] var resolved: [String: String] = [:]
+1 -2
View File
@@ -26,8 +26,7 @@ Sources/Voice/VoiceTab.swift
Sources/Voice/VoiceWakeManager.swift Sources/Voice/VoiceWakeManager.swift
Sources/Voice/VoiceWakePreferences.swift Sources/Voice/VoiceWakePreferences.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownSplitter.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift
@@ -27,7 +27,6 @@ private actor MockBridgePairingClient: BridgePairingClient {
func pairAndHello( func pairAndHello(
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello, hello: BridgeHello,
tls: BridgeTLSParams?,
onStatus: (@Sendable (String) -> Void)?) async throws -> String onStatus: (@Sendable (String) -> Void)?) async throws -> String
{ {
self.lastToken = hello.token self.lastToken = hello.token
@@ -176,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]) {
@@ -245,8 +245,6 @@ private func withKeychainValues<T>(
gatewayPort: 18789, gatewayPort: 18789,
bridgePort: 18790, bridgePort: 18790,
canvasPort: 18793, canvasPort: 18793,
tlsEnabled: false,
tlsFingerprintSha256: nil,
cliPath: nil) cliPath: nil)
let mock = MockBridgePairingClient(resultToken: "new-token") let mock = MockBridgePairingClient(resultToken: "new-token")
let account = "bridge-token.ios-test" let account = "bridge-token.ios-test"
@@ -295,8 +293,6 @@ private func withKeychainValues<T>(
gatewayPort: 18789, gatewayPort: 18789,
bridgePort: 18790, bridgePort: 18790,
canvasPort: 18793, canvasPort: 18793,
tlsEnabled: false,
tlsFingerprintSha256: nil,
cliPath: nil) cliPath: nil)
let bridgeB = BridgeDiscoveryModel.DiscoveredBridge( let bridgeB = BridgeDiscoveryModel.DiscoveredBridge(
name: "Gateway B", name: "Gateway B",
@@ -308,8 +304,6 @@ private func withKeychainValues<T>(
gatewayPort: 28789, gatewayPort: 28789,
bridgePort: 28790, bridgePort: 28790,
canvasPort: 28793, canvasPort: 28793,
tlsEnabled: false,
tlsFingerprintSha256: nil,
cliPath: nil) cliPath: nil)
let mock = MockBridgePairingClient(resultToken: "token-ok") let mock = MockBridgePairingClient(resultToken: "token-ok")
+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>
+1 -25
View File
@@ -2,13 +2,9 @@ name: Clawdbot
options: options:
bundleIdPrefix: com.clawdbot bundleIdPrefix: com.clawdbot
deploymentTarget: deploymentTarget:
iOS: "18.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"
+1 -28
View File
@@ -1,5 +1,5 @@
{ {
"originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b", "originHash" : "9de32b5fc115432dadd84c3ab4d67d2fed22ffaf5675a77033d69ea194ac3862",
"pins" : [ "pins" : [
{ {
"identity" : "elevenlabskit", "identity" : "elevenlabskit",
@@ -73,15 +73,6 @@
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a" "revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
} }
}, },
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
"version" : "1.3.2"
}
},
{ {
"identity" : "swift-configuration", "identity" : "swift-configuration",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -153,24 +144,6 @@
"revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
"version" : "1.6.3" "version" : "1.6.3"
} }
},
{
"identity" : "swiftui-math",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swiftui-math",
"state" : {
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
"version" : "0.1.0"
}
},
{
"identity" : "textual",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/textual",
"state" : {
"revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3",
"version" : "0.2.0"
}
} }
], ],
"version" : 3 "version" : 3
-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"),
], ],
+25 -59
View File
@@ -29,10 +29,7 @@ final class AppState {
} }
var launchAtLogin: Bool { var launchAtLogin: Bool {
didSet { didSet { self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } } }
guard !self.isInitializing else { return }
self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } }
}
} }
var onboardingSeen: Bool { var onboardingSeen: Bool {
@@ -170,10 +167,6 @@ final class AppState {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } } didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
} }
var systemRunPolicy: SystemRunPolicy {
didSet { self.ifNotPreview { MacNodeConfigFile.setSystemRunPolicy(self.systemRunPolicy) } }
}
/// Tracks whether the Canvas panel is currently visible (not persisted). /// Tracks whether the Canvas panel is currently visible (not persisted).
var canvasPanelVisible: Bool = false var canvasPanelVisible: Bool = false
@@ -186,6 +179,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) }
@@ -208,7 +209,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
@@ -296,9 +297,10 @@ final class AppState {
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
self.systemRunPolicy = SystemRunPolicy.load()
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()
@@ -341,15 +343,6 @@ final class AppState {
return host return host
} }
private static func sanitizeSSHTarget(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("ssh ") {
return trimmed.replacingOccurrences(of: "ssh ", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
return trimmed
}
private func startConfigWatcher() { private func startConfigWatcher() {
let configUrl = ClawdbotConfigFile.url() let configUrl = ClawdbotConfigFile.url()
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
@@ -415,7 +408,6 @@ final class AppState {
let connectionMode = self.connectionMode let connectionMode = self.connectionMode
let remoteTarget = self.remoteTarget let remoteTarget = self.remoteTarget
let remoteIdentity = self.remoteIdentity
let desiredMode: String? = switch connectionMode { let desiredMode: String? = switch connectionMode {
case .local: case .local:
"local" "local"
@@ -445,46 +437,15 @@ final class AppState {
changed = true changed = true
} }
if connectionMode == .remote { if connectionMode == .remote, let host = remoteHost {
var remote = gateway["remote"] as? [String: Any] ?? [:] var remote = gateway["remote"] as? [String: Any] ?? [:]
var remoteChanged = false let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
if let host = remoteHost { let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let existingUrl = (remote["url"] as? String)? let port = parsedExisting?.port ?? 18789
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl) if existingUrl != desiredUrl {
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws" remote["url"] = desiredUrl
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
remoteChanged = true
}
}
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
if !sanitizedTarget.isEmpty {
if (remote["sshTarget"] as? String) != sanitizedTarget {
remote["sshTarget"] = sanitizedTarget
remoteChanged = true
}
} else if remote["sshTarget"] != nil {
remote.removeValue(forKey: "sshTarget")
remoteChanged = true
}
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedIdentity.isEmpty {
if (remote["sshIdentity"] as? String) != trimmedIdentity {
remote["sshIdentity"] = trimmedIdentity
remoteChanged = true
}
} else if remote["sshIdentity"] != nil {
remote.removeValue(forKey: "sshIdentity")
remoteChanged = true
}
if remoteChanged {
gateway["remote"] = remote gateway["remote"] = remote
changed = true changed = true
} }
@@ -640,6 +601,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
} }
} }
@@ -658,6 +620,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)
}
} }
@@ -282,12 +282,7 @@ actor BridgeConnectionHandler {
do { do {
try await self.send(BridgePairOk(type: "pair-ok", token: token)) try await self.send(BridgePairOk(type: "pair-ok", token: token))
self.isAuthenticated = true self.isAuthenticated = true
let mainSessionKey = await GatewayConnection.shared.mainSessionKey() try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
try await self.send(
BridgeHelloOk(
type: "hello-ok",
serverName: serverName,
mainSessionKey: mainSessionKey))
} catch { } catch {
self.logger.error("bridge send pair-ok failed: \(error.localizedDescription, privacy: .public)") self.logger.error("bridge send pair-ok failed: \(error.localizedDescription, privacy: .public)")
} }
@@ -303,12 +298,7 @@ actor BridgeConnectionHandler {
case .ok: case .ok:
self.isAuthenticated = true self.isAuthenticated = true
do { do {
let mainSessionKey = await GatewayConnection.shared.mainSessionKey() try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
try await self.send(
BridgeHelloOk(
type: "hello-ok",
serverName: serverName,
mainSessionKey: mainSessionKey))
} catch { } catch {
self.logger.error("bridge send hello-ok failed: \(error.localizedDescription, privacy: .public)") self.logger.error("bridge send hello-ok failed: \(error.localizedDescription, privacy: .public)")
} }
@@ -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()
} }
+1 -10
View File
@@ -19,7 +19,6 @@ enum ConfigStore {
} }
private static let overrideStore = OverrideStore() private static let overrideStore = OverrideStore()
@MainActor private static var lastHash: String?
private static func isRemoteMode() async -> Bool { private static func isRemoteMode() async -> Bool {
let overrides = await self.overrideStore.overrides let overrides = await self.overrideStore.overrides
@@ -76,7 +75,6 @@ enum ConfigStore {
method: .configGet, method: .configGet,
params: nil, params: nil,
timeoutMs: 8000) timeoutMs: 8000)
self.lastHash = snap.hash
return snap.config?.mapValues { $0.foundationValue } ?? [:] return snap.config?.mapValues { $0.foundationValue } ?? [:]
} catch { } catch {
return nil return nil
@@ -85,24 +83,17 @@ enum ConfigStore {
@MainActor @MainActor
private static func saveToGateway(_ root: [String: Any]) async throws { private static func saveToGateway(_ root: [String: Any]) async throws {
if self.lastHash == nil {
_ = await self.loadFromGateway()
}
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else { guard let raw = String(data: data, encoding: .utf8) else {
throw NSError(domain: "ConfigStore", code: 1, userInfo: [ throw NSError(domain: "ConfigStore", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode config.", NSLocalizedDescriptionKey: "Failed to encode config.",
]) ])
} }
var params: [String: AnyCodable] = ["raw": AnyCodable(raw)] let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
if let baseHash = self.lastHash {
params["baseHash"] = AnyCodable(baseHash)
}
_ = try await GatewayConnection.shared.requestRaw( _ = try await GatewayConnection.shared.requestRaw(
method: .configSet, method: .configSet,
params: params, params: params,
timeoutMs: 10000) timeoutMs: 10000)
_ = await self.loadFromGateway()
} }
#if DEBUG #if DEBUG
@@ -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
@@ -2,16 +2,6 @@ import ClawdbotProtocol
import Foundation import Foundation
extension ConnectionsStore { extension ConnectionsStore {
var isTelegramTokenLocked: Bool {
self.snapshot?.decodeChannel("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
.tokenSource == "env"
}
var isDiscordTokenLocked: Bool {
self.snapshot?.decodeChannel("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.tokenSource == "env"
}
func loadConfig() async { func loadConfig() async {
do { do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded( let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
@@ -22,7 +12,6 @@ extension ConnectionsStore {
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json." ? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
: nil : nil
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:] self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
self.configHash = snap.hash
self.configLoaded = true self.configLoaded = true
self.applyUIConfig(snap) self.applyUIConfig(snap)
@@ -45,23 +34,10 @@ extension ConnectionsStore {
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
} }
private func resolveChannelConfig(_ snap: ConfigSnapshot, key: String) -> [String: AnyCodable]? {
if let channels = snap.config?["channels"]?.dictionaryValue,
let entry = channels[key]?.dictionaryValue
{
return entry
}
return snap.config?[key]?.dictionaryValue
}
private func applyTelegramConfig(_ snap: ConfigSnapshot) { private func applyTelegramConfig(_ snap: ConfigSnapshot) {
let telegram = self.resolveChannelConfig(snap, key: "telegram") let telegram = snap.config?["telegram"]?.dictionaryValue
self.telegramToken = telegram?["botToken"]?.stringValue ?? "" self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
let groups = telegram?["groups"]?.dictionaryValue self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true
let defaultGroup = groups?["*"]?.dictionaryValue
self.telegramRequireMention = defaultGroup?["requireMention"]?.boolValue
?? telegram?["requireMention"]?.boolValue
?? true
self.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue) self.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue)
self.telegramProxy = telegram?["proxy"]?.stringValue ?? "" self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? "" self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
@@ -70,7 +46,7 @@ extension ConnectionsStore {
} }
private func applyDiscordConfig(_ snap: ConfigSnapshot) { private func applyDiscordConfig(_ snap: ConfigSnapshot) {
let discord = self.resolveChannelConfig(snap, key: "discord") let discord = snap.config?["discord"]?.dictionaryValue
self.discordEnabled = discord?["enabled"]?.boolValue ?? true self.discordEnabled = discord?["enabled"]?.boolValue ?? true
self.discordToken = discord?["token"]?.stringValue ?? "" self.discordToken = discord?["token"]?.stringValue ?? ""
@@ -146,7 +122,7 @@ extension ConnectionsStore {
} }
private func applySignalConfig(_ snap: ConfigSnapshot) { private func applySignalConfig(_ snap: ConfigSnapshot) {
let signal = self.resolveChannelConfig(snap, key: "signal") let signal = snap.config?["signal"]?.dictionaryValue
self.signalEnabled = signal?["enabled"]?.boolValue ?? true self.signalEnabled = signal?["enabled"]?.boolValue ?? true
self.signalAccount = signal?["account"]?.stringValue ?? "" self.signalAccount = signal?["account"]?.stringValue ?? ""
self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? "" self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? ""
@@ -163,7 +139,7 @@ extension ConnectionsStore {
} }
private func applyIMessageConfig(_ snap: ConfigSnapshot) { private func applyIMessageConfig(_ snap: ConfigSnapshot) {
let imessage = self.resolveChannelConfig(snap, key: "imessage") let imessage = snap.config?["imessage"]?.dictionaryValue
self.imessageEnabled = imessage?["enabled"]?.boolValue ?? true self.imessageEnabled = imessage?["enabled"]?.boolValue ?? true
self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? "" self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? ""
self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? "" self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? ""
@@ -174,15 +150,6 @@ extension ConnectionsStore {
self.imessageMediaMaxMb = self.numberString(from: imessage?["mediaMaxMb"]) self.imessageMediaMaxMb = self.numberString(from: imessage?["mediaMaxMb"])
} }
private func channelConfigRoot(for key: String) -> [String: Any] {
if let channels = self.configRoot["channels"] as? [String: Any],
let entry = channels[key] as? [String: Any]
{
return entry
}
return self.configRoot[key] as? [String: Any] ?? [:]
}
func saveTelegramConfig() async { func saveTelegramConfig() async {
guard !self.isSavingConfig else { return } guard !self.isSavingConfig else { return }
self.isSavingConfig = true self.isSavingConfig = true
@@ -191,24 +158,30 @@ extension ConnectionsStore {
await self.loadConfig() await self.loadConfig()
} }
var telegram: [String: Any] = [:] var telegram: [String: Any] = (self.configRoot["telegram"] as? [String: Any]) ?? [:]
if !self.isTelegramTokenLocked { let token = self.trimmed(self.telegramToken)
self.setPatchString(&telegram, key: "botToken", value: self.telegramToken) if token.isEmpty {
telegram.removeValue(forKey: "botToken")
} else {
telegram["botToken"] = token
} }
telegram["requireMention"] = NSNull()
telegram["groups"] = [
"*": [
"requireMention": self.telegramRequireMention,
],
]
let allow = self.splitCsv(self.telegramAllowFrom)
self.setPatchList(&telegram, key: "allowFrom", values: allow)
self.setPatchString(&telegram, key: "proxy", value: self.telegramProxy)
self.setPatchString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
self.setPatchString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
self.setPatchString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
await self.persistChannelPatch("telegram", payload: telegram) telegram["requireMention"] = self.telegramRequireMention
let allow = self.splitCsv(self.telegramAllowFrom)
if allow.isEmpty {
telegram.removeValue(forKey: "allowFrom")
} else {
telegram["allowFrom"] = allow
}
self.setOptionalString(&telegram, key: "proxy", value: self.telegramProxy)
self.setOptionalString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
self.setOptionalString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
self.setOptionalString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
self.setSection("telegram", payload: telegram)
await self.persistConfig()
} }
func saveDiscordConfig() async { func saveDiscordConfig() async {
@@ -219,9 +192,9 @@ extension ConnectionsStore {
await self.loadConfig() await self.loadConfig()
} }
let base = self.channelConfigRoot(for: "discord") let discord = self.buildDiscordConfig()
let discord = self.buildDiscordPatch(base: base) self.setSection("discord", payload: discord)
await self.persistChannelPatch("discord", payload: discord) await self.persistConfig()
} }
func saveSignalConfig() async { func saveSignalConfig() async {
@@ -232,23 +205,42 @@ extension ConnectionsStore {
await self.loadConfig() await self.loadConfig()
} }
var signal: [String: Any] = [:] var signal: [String: Any] = (self.configRoot["signal"] as? [String: Any]) ?? [:]
self.setPatchBool(&signal, key: "enabled", value: self.signalEnabled, defaultValue: true) if self.signalEnabled {
self.setPatchString(&signal, key: "account", value: self.signalAccount) signal.removeValue(forKey: "enabled")
self.setPatchString(&signal, key: "httpUrl", value: self.signalHttpUrl) } else {
self.setPatchString(&signal, key: "httpHost", value: self.signalHttpHost) signal["enabled"] = false
self.setPatchNumber(&signal, key: "httpPort", value: self.signalHttpPort) }
self.setPatchString(&signal, key: "cliPath", value: self.signalCliPath)
self.setPatchBool(&signal, key: "autoStart", value: self.signalAutoStart, defaultValue: true)
self.setPatchString(&signal, key: "receiveMode", value: self.signalReceiveMode)
self.setPatchBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments, defaultValue: false)
self.setPatchBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories, defaultValue: false)
self.setPatchBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts, defaultValue: false)
let allow = self.splitCsv(self.signalAllowFrom)
self.setPatchList(&signal, key: "allowFrom", values: allow)
self.setPatchNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
await self.persistChannelPatch("signal", payload: signal) self.setOptionalString(&signal, key: "account", value: self.signalAccount)
self.setOptionalString(&signal, key: "httpUrl", value: self.signalHttpUrl)
self.setOptionalString(&signal, key: "httpHost", value: self.signalHttpHost)
self.setOptionalNumber(&signal, key: "httpPort", value: self.signalHttpPort)
self.setOptionalString(&signal, key: "cliPath", value: self.signalCliPath)
if self.signalAutoStart {
signal.removeValue(forKey: "autoStart")
} else {
signal["autoStart"] = false
}
self.setOptionalString(&signal, key: "receiveMode", value: self.signalReceiveMode)
self.setOptionalBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments)
self.setOptionalBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories)
self.setOptionalBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts)
let allow = self.splitCsv(self.signalAllowFrom)
if allow.isEmpty {
signal.removeValue(forKey: "allowFrom")
} else {
signal["allowFrom"] = allow
}
self.setOptionalNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
self.setSection("signal", payload: signal)
await self.persistConfig()
} }
func saveIMessageConfig() async { func saveIMessageConfig() async {
@@ -259,168 +251,147 @@ extension ConnectionsStore {
await self.loadConfig() await self.loadConfig()
} }
var imessage: [String: Any] = [:] var imessage: [String: Any] = (self.configRoot["imessage"] as? [String: Any]) ?? [:]
self.setPatchBool(&imessage, key: "enabled", value: self.imessageEnabled, defaultValue: true) if self.imessageEnabled {
self.setPatchString(&imessage, key: "cliPath", value: self.imessageCliPath) imessage.removeValue(forKey: "enabled")
self.setPatchString(&imessage, key: "dbPath", value: self.imessageDbPath) } else {
imessage["enabled"] = false
}
self.setOptionalString(&imessage, key: "cliPath", value: self.imessageCliPath)
self.setOptionalString(&imessage, key: "dbPath", value: self.imessageDbPath)
let service = self.trimmed(self.imessageService) let service = self.trimmed(self.imessageService)
if service.isEmpty || service == "auto" { if service.isEmpty || service == "auto" {
imessage["service"] = NSNull() imessage.removeValue(forKey: "service")
} else { } else {
imessage["service"] = service imessage["service"] = service
} }
self.setPatchString(&imessage, key: "region", value: self.imessageRegion) self.setOptionalString(&imessage, key: "region", value: self.imessageRegion)
let allow = self.splitCsv(self.imessageAllowFrom) let allow = self.splitCsv(self.imessageAllowFrom)
self.setPatchList(&imessage, key: "allowFrom", values: allow) if allow.isEmpty {
imessage.removeValue(forKey: "allowFrom")
} else {
imessage["allowFrom"] = allow
}
self.setPatchBool( self.setOptionalBool(&imessage, key: "includeAttachments", value: self.imessageIncludeAttachments)
&imessage, self.setOptionalNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
key: "includeAttachments",
value: self.imessageIncludeAttachments,
defaultValue: false)
self.setPatchNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
await self.persistChannelPatch("imessage", payload: imessage) self.setSection("imessage", payload: imessage)
await self.persistConfig()
} }
private func buildDiscordPatch(base: [String: Any]) -> [String: Any] { private func buildDiscordConfig() -> [String: Any] {
var discord: [String: Any] = [:] var discord: [String: Any] = (self.configRoot["discord"] as? [String: Any]) ?? [:]
self.setPatchBool(&discord, key: "enabled", value: self.discordEnabled, defaultValue: true) if self.discordEnabled {
if !self.isDiscordTokenLocked { discord.removeValue(forKey: "enabled")
self.setPatchString(&discord, key: "token", value: self.discordToken) } else {
discord["enabled"] = false
} }
self.setOptionalString(&discord, key: "token", value: self.discordToken)
if let dm = self.buildDiscordDmPatch() { if let dm = self.buildDiscordDmConfig(base: discord["dm"] as? [String: Any] ?? [:]) {
discord["dm"] = dm discord["dm"] = dm
} else { } else {
discord["dm"] = NSNull() discord.removeValue(forKey: "dm")
} }
self.setPatchNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb) self.setOptionalNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
self.setPatchInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true) self.setOptionalInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
self.setPatchInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false) self.setOptionalInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
let replyToMode = self.trimmed(self.discordReplyToMode) let replyToMode = self.trimmed(self.discordReplyToMode)
if replyToMode.isEmpty || replyToMode == "off" || !["first", "all"].contains(replyToMode) { if replyToMode.isEmpty || replyToMode == "off" {
discord["replyToMode"] = NSNull() discord.removeValue(forKey: "replyToMode")
} else { } else if ["first", "all"].contains(replyToMode) {
discord["replyToMode"] = replyToMode discord["replyToMode"] = replyToMode
} else {
discord.removeValue(forKey: "replyToMode")
} }
let baseGuilds = base["guilds"] as? [String: Any] ?? [:] if let guilds = self.buildDiscordGuildsConfig() {
if let guilds = self.buildDiscordGuildsPatch(base: baseGuilds) {
discord["guilds"] = guilds discord["guilds"] = guilds
} else { } else {
discord["guilds"] = NSNull() discord.removeValue(forKey: "guilds")
} }
if let actions = self.buildDiscordActionsPatch() { if let actions = self.buildDiscordActionsConfig(base: discord["actions"] as? [String: Any] ?? [:]) {
discord["actions"] = actions discord["actions"] = actions
} else { } else {
discord["actions"] = NSNull() discord.removeValue(forKey: "actions")
} }
if let slash = self.buildDiscordSlashPatch() { if let slash = self.buildDiscordSlashConfig(base: discord["slashCommand"] as? [String: Any] ?? [:]) {
discord["slashCommand"] = slash discord["slashCommand"] = slash
} else { } else {
discord["slashCommand"] = NSNull() discord.removeValue(forKey: "slashCommand")
} }
return discord return discord
} }
private func buildDiscordDmPatch() -> [String: Any]? { private func buildDiscordDmConfig(base: [String: Any]) -> [String: Any]? {
var dm: [String: Any] = [:] var dm = base
self.setPatchBool(&dm, key: "enabled", value: self.discordDmEnabled, defaultValue: true) if self.discordDmEnabled {
dm.removeValue(forKey: "enabled")
} else {
dm["enabled"] = false
}
let allow = self.splitCsv(self.discordAllowFrom) let allow = self.splitCsv(self.discordAllowFrom)
self.setPatchList(&dm, key: "allowFrom", values: allow) if allow.isEmpty {
self.setPatchBool(&dm, key: "groupEnabled", value: self.discordGroupEnabled, defaultValue: false) dm.removeValue(forKey: "allowFrom")
} else {
dm["allowFrom"] = allow
}
if self.discordGroupEnabled {
dm["groupEnabled"] = true
} else {
dm.removeValue(forKey: "groupEnabled")
}
let groupChannels = self.splitCsv(self.discordGroupChannels) let groupChannels = self.splitCsv(self.discordGroupChannels)
self.setPatchList(&dm, key: "groupChannels", values: groupChannels) if groupChannels.isEmpty {
dm.removeValue(forKey: "groupChannels")
} else {
dm["groupChannels"] = groupChannels
}
return dm.isEmpty ? nil : dm return dm.isEmpty ? nil : dm
} }
private func buildDiscordGuildsPatch(base: [String: Any]) -> Any? { private func buildDiscordGuildsConfig() -> [String: Any]? {
if self.discordGuilds.isEmpty { let guilds: [String: Any] = self.discordGuilds.reduce(into: [:]) { result, entry in
return NSNull()
}
var patch: [String: Any] = [:]
let baseKeys = Set(base.keys)
var formKeys = Set<String>()
for entry in self.discordGuilds {
let key = self.trimmed(entry.key) let key = self.trimmed(entry.key)
guard !key.isEmpty else { continue } guard !key.isEmpty else { return }
formKeys.insert(key) var payload: [String: Any] = [:]
let baseGuild = base[key] as? [String: Any] ?? [:] let slug = self.trimmed(entry.slug)
patch[key] = self.buildDiscordGuildPatch(entry, base: baseGuild) if !slug.isEmpty { payload["slug"] = slug }
if entry.requireMention { payload["requireMention"] = true }
if ["off", "own", "all", "allowlist"].contains(entry.reactionNotifications) {
payload["reactionNotifications"] = entry.reactionNotifications
}
let users = self.splitCsv(entry.users)
if !users.isEmpty { payload["users"] = users }
let channels: [String: Any] = entry.channels.reduce(into: [:]) { channelsResult, channel in
let channelKey = self.trimmed(channel.key)
guard !channelKey.isEmpty else { return }
var channelPayload: [String: Any] = [:]
if !channel.allow { channelPayload["allow"] = false }
if channel.requireMention { channelPayload["requireMention"] = true }
channelsResult[channelKey] = channelPayload
}
if !channels.isEmpty { payload["channels"] = channels }
result[key] = payload
} }
for key in baseKeys.subtracting(formKeys) { return guilds.isEmpty ? nil : guilds
patch[key] = NSNull()
}
return patch.isEmpty ? NSNull() : patch
} }
private func buildDiscordGuildPatch(_ entry: DiscordGuildForm, base: [String: Any]) -> [String: Any] { private func buildDiscordActionsConfig(base: [String: Any]) -> [String: Any]? {
var payload: [String: Any] = [:] var actions = base
let slug = self.trimmed(entry.slug)
if slug.isEmpty {
payload["slug"] = NSNull()
} else {
payload["slug"] = slug
}
if entry.requireMention {
payload["requireMention"] = true
} else {
payload["requireMention"] = NSNull()
}
if ["off", "all", "allowlist"].contains(entry.reactionNotifications) {
payload["reactionNotifications"] = entry.reactionNotifications
} else {
payload["reactionNotifications"] = NSNull()
}
let users = self.splitCsv(entry.users)
self.setPatchList(&payload, key: "users", values: users)
let baseChannels = base["channels"] as? [String: Any] ?? [:]
if let channels = self.buildDiscordChannelsPatch(base: baseChannels, forms: entry.channels) {
payload["channels"] = channels
} else {
payload["channels"] = NSNull()
}
return payload
}
private func buildDiscordChannelsPatch(base: [String: Any], forms: [DiscordGuildChannelForm]) -> Any? {
if forms.isEmpty {
return NSNull()
}
var patch: [String: Any] = [:]
let baseKeys = Set(base.keys)
var formKeys = Set<String>()
for channel in forms {
let channelKey = self.trimmed(channel.key)
guard !channelKey.isEmpty else { continue }
formKeys.insert(channelKey)
var channelPayload: [String: Any] = [:]
self.setPatchBool(&channelPayload, key: "allow", value: channel.allow, defaultValue: true)
self.setPatchBool(
&channelPayload,
key: "requireMention",
value: channel.requireMention,
defaultValue: false)
patch[channelKey] = channelPayload
}
for key in baseKeys.subtracting(formKeys) {
patch[key] = NSNull()
}
return patch.isEmpty ? NSNull() : patch
}
private func buildDiscordActionsPatch() -> [String: Any]? {
var actions: [String: Any] = [:]
self.setAction(&actions, key: "reactions", value: self.discordActionReactions, defaultValue: true) self.setAction(&actions, key: "reactions", value: self.discordActionReactions, defaultValue: true)
self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true) self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true)
self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true) self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true)
@@ -439,44 +410,52 @@ extension ConnectionsStore {
return actions.isEmpty ? nil : actions return actions.isEmpty ? nil : actions
} }
private func buildDiscordSlashPatch() -> [String: Any]? { private func buildDiscordSlashConfig(base: [String: Any]) -> [String: Any]? {
var slash: [String: Any] = [:] var slash = base
self.setPatchBool(&slash, key: "enabled", value: self.discordSlashEnabled, defaultValue: false) if self.discordSlashEnabled {
self.setPatchString(&slash, key: "name", value: self.discordSlashName) slash["enabled"] = true
self.setPatchString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix) } else {
self.setPatchBool(&slash, key: "ephemeral", value: self.discordSlashEphemeral, defaultValue: true) slash.removeValue(forKey: "enabled")
}
self.setOptionalString(&slash, key: "name", value: self.discordSlashName)
self.setOptionalString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
if self.discordSlashEphemeral {
slash.removeValue(forKey: "ephemeral")
} else {
slash["ephemeral"] = false
}
return slash.isEmpty ? nil : slash return slash.isEmpty ? nil : slash
} }
private func persistChannelPatch(_ channelId: String, payload: [String: Any]) async { private func persistConfig() async {
do { do {
guard let baseHash = self.configHash else {
self.configStatus = "Config hash missing; reload and retry."
return
}
let data = try JSONSerialization.data( let data = try JSONSerialization.data(
withJSONObject: ["channels": [channelId: payload]], withJSONObject: self.configRoot,
options: [.prettyPrinted, .sortedKeys]) options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else { guard let raw = String(data: data, encoding: .utf8) else {
self.configStatus = "Failed to encode config." self.configStatus = "Failed to encode config."
return return
} }
let params: [String: AnyCodable] = [ let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
"raw": AnyCodable(raw),
"baseHash": AnyCodable(baseHash),
]
_ = try await GatewayConnection.shared.requestRaw( _ = try await GatewayConnection.shared.requestRaw(
method: .configPatch, method: .configSet,
params: params, params: params,
timeoutMs: 10000) timeoutMs: 10000)
self.configStatus = "Saved to ~/.clawdbot/clawdbot.json." self.configStatus = "Saved to ~/.clawdbot/clawdbot.json."
await self.loadConfig()
await self.refresh(probe: true) await self.refresh(probe: true)
} catch { } catch {
self.configStatus = error.localizedDescription self.configStatus = error.localizedDescription
} }
} }
private func setSection(_ key: String, payload: [String: Any]) {
if payload.isEmpty {
self.configRoot.removeValue(forKey: key)
} else {
self.configRoot[key] = payload
}
}
private func stringList(from values: [AnyCodable]?) -> String { private func stringList(from values: [AnyCodable]?) -> String {
guard let values else { return "" } guard let values else { return "" }
let strings = values.compactMap { entry -> String? in let strings = values.compactMap { entry -> String? in
@@ -513,29 +492,25 @@ extension ConnectionsStore {
value.trimmingCharacters(in: .whitespacesAndNewlines) value.trimmingCharacters(in: .whitespacesAndNewlines)
} }
private func setPatchString(_ target: inout [String: Any], key: String, value: String) { private func setOptionalString(_ target: inout [String: Any], key: String, value: String) {
let trimmed = self.trimmed(value) let trimmed = self.trimmed(value)
if trimmed.isEmpty { if trimmed.isEmpty {
target[key] = NSNull() target.removeValue(forKey: key)
} else { } else {
target[key] = trimmed target[key] = trimmed
} }
} }
private func setPatchNumber(_ target: inout [String: Any], key: String, value: String) { private func setOptionalNumber(_ target: inout [String: Any], key: String, value: String) {
let trimmed = self.trimmed(value) let trimmed = self.trimmed(value)
if trimmed.isEmpty { if trimmed.isEmpty {
target[key] = NSNull() target.removeValue(forKey: key)
return } else if let number = Double(trimmed) {
}
if let number = Double(trimmed) {
target[key] = number target[key] = number
} else {
target[key] = NSNull()
} }
} }
private func setPatchInt( private func setOptionalInt(
_ target: inout [String: Any], _ target: inout [String: Any],
key: String, key: String,
value: String, value: String,
@@ -543,39 +518,26 @@ extension ConnectionsStore {
{ {
let trimmed = self.trimmed(value) let trimmed = self.trimmed(value)
if trimmed.isEmpty { if trimmed.isEmpty {
target[key] = NSNull() target.removeValue(forKey: key)
return return
} }
guard let number = Int(trimmed) else { guard let number = Int(trimmed) else {
target[key] = NSNull() target.removeValue(forKey: key)
return return
} }
let isValid = allowZero ? number >= 0 : number > 0 let isValid = allowZero ? number >= 0 : number > 0
guard isValid else { guard isValid else {
target[key] = NSNull() target.removeValue(forKey: key)
return return
} }
target[key] = number target[key] = number
} }
private func setPatchBool( private func setOptionalBool(_ target: inout [String: Any], key: String, value: Bool) {
_ target: inout [String: Any], if value {
key: String, target[key] = true
value: Bool,
defaultValue: Bool)
{
if value == defaultValue {
target[key] = NSNull()
} else { } else {
target[key] = value target.removeValue(forKey: key)
}
}
private func setPatchList(_ target: inout [String: Any], key: String, values: [String]) {
if values.isEmpty {
target[key] = NSNull()
} else {
target[key] = values
} }
} }
@@ -586,7 +548,7 @@ extension ConnectionsStore {
defaultValue: Bool) defaultValue: Bool)
{ {
if value == defaultValue { if value == defaultValue {
actions[key] = NSNull() actions.removeValue(forKey: key)
} else { } else {
actions[key] = value actions[key] = value
} }
@@ -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 {
@@ -180,7 +138,6 @@ struct ConfigSnapshot: Codable {
let path: String? let path: String?
let exists: Bool? let exists: Bool?
let raw: String? let raw: String?
let hash: String?
let parsed: AnyCodable? let parsed: AnyCodable?
let valid: Bool? let valid: Bool?
let config: [String: AnyCodable]? let config: [String: AnyCodable]?
@@ -231,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
@@ -308,7 +265,6 @@ final class ConnectionsStore {
var pollTask: Task<Void, Never>? var pollTask: Task<Void, Never>?
var configRoot: [String: Any] = [:] var configRoot: [String: Any] = [:]
var configLoaded = false var configLoaded = false
var configHash: String?
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) { init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview self.isPreview = isPreview
+2 -4
View File
@@ -26,17 +26,15 @@ let remoteProjectRootKey = "clawdbot.remoteProjectRoot"
let remoteCliPathKey = "clawdbot.remoteCliPath" let remoteCliPathKey = "clawdbot.remoteCliPath"
let canvasEnabledKey = "clawdbot.canvasEnabled" let canvasEnabledKey = "clawdbot.canvasEnabled"
let cameraEnabledKey = "clawdbot.cameraEnabled" let cameraEnabledKey = "clawdbot.cameraEnabled"
let systemRunPolicyKey = "clawdbot.systemRunPolicy"
let systemRunAllowlistKey = "clawdbot.systemRunAllowlist"
let systemRunEnabledKey = "clawdbot.systemRunEnabled"
let locationModeKey = "clawdbot.locationMode" let locationModeKey = "clawdbot.locationMode"
let locationPreciseKey = "clawdbot.locationPreciseEnabled" let locationPreciseKey = "clawdbot.locationPreciseEnabled"
let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled" 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,20 +138,25 @@ extension CronJobEditor {
userInfo: [NSLocalizedDescriptionKey: "Agent message is required."]) userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
} }
} }
}
func applyDeleteAfterRun( var root: [String: Any] = [
to root: inout [String: Any], "name": name,
scheduleKind: ScheduleKind? = nil, "enabled": self.enabled,
deleteAfterRun: Bool? = nil) "schedule": schedule,
{ "sessionTarget": self.sessionTarget.rawValue,
let resolvedSchedule = scheduleKind ?? self.scheduleKind "wakeMode": self.wakeMode.rawValue,
let resolvedDelete = deleteAfterRun ?? self.deleteAfterRun "payload": payload,
if resolvedSchedule == .at { ]
root["deleteAfterRun"] = resolvedDelete if !description.isEmpty { root["description"] = description }
} else if self.job?.deleteAfterRun != nil {
root["deleteAfterRun"] = false 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] {
@@ -210,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,10 +51,9 @@ 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 configPatch = "config.patch"
case wizardStart = "wizard.start" case wizardStart = "wizard.start"
case wizardNext = "wizard.next" case wizardNext = "wizard.next"
case wizardCancel = "wizard.cancel" case wizardCancel = "wizard.cancel"
@@ -63,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"
@@ -233,18 +232,7 @@ actor GatewayConnection {
func canvasHostUrl() async -> String? { func canvasHostUrl() async -> String? {
guard let snapshot = self.lastSnapshot else { return nil } guard let snapshot = self.lastSnapshot else { return nil }
let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? "" let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private func sessionDefaultString(_ defaults: [String: AnyCodable]?, key: String) -> String {
(defaults?[key]?.stringValue ?? "")
.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
}
func cachedMainSessionKey() -> String? {
guard let snapshot = self.lastSnapshot else { return nil }
let trimmed = self.sessionDefaultString(snapshot.snapshot.sessiondefaults, key: "mainSessionKey")
return trimmed.isEmpty ? nil : trimmed return trimmed.isEmpty ? nil : trimmed
} }
@@ -279,35 +267,12 @@ actor GatewayConnection {
private func broadcast(_ push: GatewayPush) { private func broadcast(_ push: GatewayPush) {
if case let .snapshot(snapshot) = push { if case let .snapshot(snapshot) = push {
self.lastSnapshot = snapshot self.lastSnapshot = snapshot
if let mainSessionKey = self.cachedMainSessionKey() {
Task { @MainActor in
WorkActivityStore.shared.setMainSessionKey(mainSessionKey)
}
}
} }
for (_, continuation) in self.subscribers { for (_, continuation) in self.subscribers {
continuation.yield(push) continuation.yield(push)
} }
} }
private func canonicalizeSessionKey(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
guard !trimmed.isEmpty else { return trimmed }
guard let defaults = self.lastSnapshot?.snapshot.sessiondefaults else { return trimmed }
let mainSessionKey = self.sessionDefaultString(defaults, key: "mainSessionKey")
guard !mainSessionKey.isEmpty else { return trimmed }
let mainKey = self.sessionDefaultString(defaults, key: "mainKey")
let defaultAgentId = self.sessionDefaultString(defaults, key: "defaultAgentId")
let isMainAlias =
trimmed == "main" ||
(!mainKey.isEmpty && trimmed == mainKey) ||
trimmed == mainSessionKey ||
(!defaultAgentId.isEmpty &&
(trimmed == "agent:\(defaultAgentId):main" ||
(mainKey.isEmpty == false && trimmed == "agent:\(defaultAgentId):\(mainKey)")))
return isMainAlias ? mainSessionKey : trimmed
}
private func configure(url: URL, token: String?, password: String?) async { private func configure(url: URL, token: String?, password: String?) async {
if self.client != nil, self.configuredURL == url, self.configuredToken == token, if self.client != nil, self.configuredURL == url, self.configuredToken == token,
self.configuredPassword == password self.configuredPassword == password
@@ -366,9 +331,6 @@ extension GatewayConnection {
} }
func mainSessionKey(timeoutMs: Double = 15000) async -> String { func mainSessionKey(timeoutMs: Double = 15000) async -> String {
if let cached = self.cachedMainSessionKey() {
return cached
}
do { do {
let data = try await self.requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs) let data = try await self.requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs)
return try Self.mainSessionKey(fromConfigGetData: data) return try Self.mainSessionKey(fromConfigGetData: data)
@@ -399,15 +361,14 @@ extension GatewayConnection {
func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) { func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) {
let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return (false, "message empty") } guard !trimmed.isEmpty else { return (false, "message empty") }
let sessionKey = self.canonicalizeSessionKey(invocation.sessionKey)
var params: [String: AnyCodable] = [ var params: [String: AnyCodable] = [
"message": AnyCodable(trimmed), "message": AnyCodable(trimmed),
"sessionKey": AnyCodable(sessionKey), "sessionKey": AnyCodable(invocation.sessionKey),
"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 {
@@ -428,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?)
{ {
@@ -438,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))
} }
@@ -507,8 +468,7 @@ extension GatewayConnection {
limit: Int? = nil, limit: Int? = nil,
timeoutMs: Int? = nil) async throws -> ClawdbotChatHistoryPayload timeoutMs: Int? = nil) async throws -> ClawdbotChatHistoryPayload
{ {
let resolvedKey = self.canonicalizeSessionKey(sessionKey) var params: [String: AnyCodable] = ["sessionKey": AnyCodable(sessionKey)]
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)]
if let limit { params["limit"] = AnyCodable(limit) } if let limit { params["limit"] = AnyCodable(limit) }
let timeout = timeoutMs.map { Double($0) } let timeout = timeoutMs.map { Double($0) }
return try await self.requestDecoded( return try await self.requestDecoded(
@@ -525,9 +485,8 @@ extension GatewayConnection {
attachments: [ClawdbotChatAttachmentPayload], attachments: [ClawdbotChatAttachmentPayload],
timeoutMs: Int = 30000) async throws -> ClawdbotChatSendResponse timeoutMs: Int = 30000) async throws -> ClawdbotChatSendResponse
{ {
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
var params: [String: AnyCodable] = [ var params: [String: AnyCodable] = [
"sessionKey": AnyCodable(resolvedKey), "sessionKey": AnyCodable(sessionKey),
"message": AnyCodable(message), "message": AnyCodable(message),
"thinking": AnyCodable(thinking), "thinking": AnyCodable(thinking),
"idempotencyKey": AnyCodable(idempotencyKey), "idempotencyKey": AnyCodable(idempotencyKey),
@@ -553,11 +512,10 @@ extension GatewayConnection {
} }
func chatAbort(sessionKey: String, runId: String) async throws -> Bool { func chatAbort(sessionKey: String, runId: String) async throws -> Bool {
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? } struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? }
let res: AbortResponse = try await self.requestDecoded( let res: AbortResponse = try await self.requestDecoded(
method: .chatAbort, method: .chatAbort,
params: ["sessionKey": AnyCodable(resolvedKey), "runId": AnyCodable(runId)]) params: ["sessionKey": AnyCodable(sessionKey), "runId": AnyCodable(runId)])
return res.aborted ?? false return res.aborted ?? false
} }
@@ -1,4 +1,3 @@
import ClawdbotDiscovery
import SwiftUI import SwiftUI
struct GatewayDiscoveryInlineList: View { struct GatewayDiscoveryInlineList: View {
Loaded 100 of 2593 files, more files were not shown because too many files have changed in this diff. Show more