Compare commits

..
Author SHA1 Message Date
Peter Steinberger 07cc816969 fix: explain /bash enablement when disabled (#722) (thanks @vrknetha) 2026-01-13 07:18:15 +00:00
Peter Steinberger b0d0914ca0 docs: group FAQ sections 2026-01-13 07:17:28 +00:00
Peter Steinberger b33334620a Tests: run e2e gateway with node 2026-01-13 07:17:28 +00:00
Peter Steinberger ebc4fe57d7 Docs: reorder 2025.1.12 changelog 2026-01-13 07:17:28 +00:00
Peter Steinberger 5909cf36b9 Deps: update Pi + Vitest and add Bedrock docs 2026-01-13 07:17:28 +00:00
Peter Steinberger e3a97b96d9 docs: add FAQ table of contents 2026-01-13 07:17:28 +00:00
Peter Steinberger 4942f3af0f fix: per-agent model fallbacks (#583) (thanks @mitschabaude-bot) 2026-01-13 07:17:28 +00:00
Gregor's Bot ad44e32910 Docs: document agents.list model fallbacks 2026-01-13 07:17:28 +00:00
Gregor's Bot b0b77aae88 Agents: test per-agent model fallbacks override 2026-01-13 07:17:28 +00:00
Gregor's Bot 007f8c9222 Config: support per-agent model fallbacks 2026-01-13 07:17:28 +00:00
Peter Steinberger dbd5a76cb0 fix: gate xhigh by model (#444) (thanks @grp06) 2026-01-13 07:17:28 +00:00
George Pickett 630b256aee Thinking: gate xhigh by model 2026-01-13 07:17:28 +00:00
Peter Steinberger e170bc397d test(tools): cover tool policy helpers 2026-01-13 07:17:28 +00:00
Peter Steinberger d2f033aee5 docs: update changelog for tool schema and profiles 2026-01-13 07:17:28 +00:00
Peter Steinberger 8f12b47b6d feat(tools): add tool profiles and group shorthands 2026-01-13 07:17:28 +00:00
Peter Steinberger e8b382e8ed fix(tools): harden tool schemas for strict providers 2026-01-13 07:17:28 +00:00
2271 changed files with 142493 additions and 188300 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"
+3 -28
View File
@@ -74,9 +74,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
@@ -141,31 +141,6 @@ jobs:
- name: Run ${{ matrix.task }} (${{ matrix.runtime }}) - name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }} run: ${{ matrix.command }}
secrets:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install detect-secrets
run: |
python -m pip install --upgrade pip
python -m pip install detect-secrets==1.5.0
- name: Detect secrets
run: |
if ! detect-secrets scan --baseline .secrets.baseline; then
echo "::error::Secret scanning failed. See docs/gateway/security.md#secret-scanning-detect-secrets"
exit 1
fi
checks-windows: checks-windows:
runs-on: blacksmith-4vcpu-windows-2025 runs-on: blacksmith-4vcpu-windows-2025
defaults: defaults:
-1
View File
@@ -29,5 +29,4 @@ jobs:
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
CLAWDBOT_NO_ONBOARD: "1" CLAWDBOT_NO_ONBOARD: "1"
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1" CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
run: pnpm test:install:smoke run: pnpm test:install:smoke
-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"
}
+3 -9
View File
@@ -6,7 +6,6 @@
- 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`). - 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)
@@ -26,12 +25,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.
@@ -60,9 +59,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!
@@ -74,14 +70,12 @@
- 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. - 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. - 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. - 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.**
+26 -141
View File
@@ -1,158 +1,43 @@
# Changelog # Changelog
## 2026.1.15 (unreleased) ## 2025.1.12 (Unreleased)
- Slack: add optional user token routing for read access with safe write fallback. (#981) — thanks @jalehman.
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
## 2026.1.14-1
### Highlights ### Highlights
- Web search: `web_search`/`web_fetch` tools (Brave API) + first-time setup in onboarding/configure.
- Browser control: Chrome extension relay takeover mode + remote browser control via `clawdbot browser serve`.
- Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy.
### Changes
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor`.
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor` (includes browser control exposure checks).
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
- Docs: expand gateway security hardening guidance and incident response checklist.
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill.
- CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips.
- 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`.
### Fixes
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
- 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: 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.
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
- 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.
## 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. - 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). - 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. - Models: add Synthetic provider plus Moonshot Kimi K2 0905 + turbo/thinking variants (with docs).
- Cron: one-shot schedules accept ISO timestamps (UTC) with optional delete-after-run; cron jobs can target a specific agent (CLI + macOS/Control UI). - Cron: 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. - Agents: add compaction mode config with optional safeguard summarization and per-agent model fallbacks.
### New & Improved ### 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: add custom OpenAI-compatible embedding endpoints; support OpenAI/local `node-llama-cpp` embeddings with per-agent overrides and provider metadata in tools/CLI.
- 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. - 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. - 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/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`.
- 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.
- 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.
- 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`.
- 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).
- 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.
- 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. - 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 ### Fixes
- Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds. - 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.
- Tools: apply global tool allow/deny even when agent-specific tool policy is set. - 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.
- 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. - MiniMax: strip malformed tool invocation XML; include `MiniMax-VL-01` in implicit provider for image pairing.
- Auth: drop invalid auth profiles from ordering so environment keys can still be used for providers like MiniMax. - Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid incorrect role errors.
- 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. - Messaging: enforce context isolation for message tool sends; keep typing indicators alive during tool execution.
- Agents: auto-recover from compaction context overflow by resetting the session and retrying; propagate overflow details from embedded runs so callers can recover. - Auto-reply: `/status` allowlist behavior, reasoning-tag enforcement on fallback, and system-event enqueueing for elevated/reasoning toggles.
- MiniMax: strip malformed tool invocation XML; include `MiniMax-VL-01` in implicit provider for image pairing. (#809) — thanks @latitudeki5223. - Auto-reply: explain how to enable `/bash` when it’s disabled; add security notes + FAQ. (#722) — thanks @vrknetha.
- 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. - 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. - 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.
- 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. - Telegram: preserve forum topic thread ids, persist polling offsets, respect account bindings in webhook mode, and show typing indicator in General topics.
- Slack: accept slash commands with or without leading `/` for custom command configs. (#798) — thanks @thewilloftheshadow. - Slack: accept slash commands with or without leading `/` for custom command configs.
- Cron: persist disabled jobs correctly; accept `jobId` aliases for update/run/remove params. (#205, #252) — thanks @thewilloftheshadow. - Cron: persist disabled jobs correctly; accept `jobId` aliases for update/run/remove params.
- 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. - 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.
- 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. - 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.
- 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. - 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)".
- Connections UI: polish multi-account account cards. (#816) — thanks @steipete. - Connections UI: polish multi-account account cards.
### Maintenance ### Maintenance
- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai. - Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai.
@@ -398,7 +283,7 @@
- Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj. - Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj.
- Build: Docker build cache layer (#605) — thanks @zknicker. - 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). - Auth: enable OAuth token refresh for Claude CLI credentials (`anthropic:claude-cli`) with bidirectional sync back to Claude Code storage (file on Linux/Windows, Keychain on macOS). This allows long-running agents to operate autonomously without manual re-authentication (#654 — thanks @radek-paclt).
## 2026.1.8 ## 2026.1.8
+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)
Submodule Peekaboo updated: 95ad7532c1...c1243a7978.
+54 -63
View File
@@ -16,13 +16,13 @@
</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, 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.
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/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)
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/start/getting-started)
@@ -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/start/getting-started)
```bash ```bash
clawdbot onboard --install-daemon clawdbot onboard --install-daemon
@@ -97,17 +97,17 @@ Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted i
Full security guide: [Security](https://docs.clawd.bot/gateway/security) Full security guide: [Security](https://docs.clawd.bot/gateway/security)
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Slack: Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/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> <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/providers)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, 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/gateway/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/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/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/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/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.
@@ -127,9 +127,9 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
- [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/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawd.bot/concepts/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/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/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/providers): [WhatsApp](https://docs.clawd.bot/providers/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/providers/telegram) (grammY), [Slack](https://docs.clawd.bot/providers/slack) (Bolt), [Discord](https://docs.clawd.bot/providers/discord) (discord.js), [Signal](https://docs.clawd.bot/providers/signal) (signal-cli), [iMessage](https://docs.clawd.bot/providers/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/providers/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/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/concepts/group-messages): mention gating, reply tags, per-provider chunking and routing. Provider rules: [Providers](https://docs.clawd.bot/providers).
### 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/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.
@@ -145,10 +145,10 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
- [Skills platform](https://docs.clawd.bot/tools/skills): bundled, managed, and workspace skills with install gating + UI. - [Skills platform](https://docs.clawd.bot/tools/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/concepts/provider-routing), [retry policy](https://docs.clawd.bot/concepts/retry), and [streaming/chunking](https://docs.clawd.bot/concepts/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/concepts/presence), [typing indicators](https://docs.clawd.bot/concepts/typing-indicators), and [usage tracking](https://docs.clawd.bot/concepts/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/concepts/models), [model failover](https://docs.clawd.bot/concepts/model-failover), and [session pruning](https://docs.clawd.bot/concepts/session-pruning).
- [Security](https://docs.clawd.bot/gateway/security) and [troubleshooting](https://docs.clawd.bot/channels/troubleshooting). - [Security](https://docs.clawd.bot/gateway/security) and [troubleshooting](https://docs.clawd.bot/providers/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/web/webchat) served directly from the Gateway.
@@ -204,7 +204,7 @@ Details: [Tailscale guide](https://docs.clawd.bot/gateway/tailscale) · [Web sur
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 exec 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: exec runs where the Gateway lives; device actions run where the device lives.
@@ -315,56 +315,52 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
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/gateway/security) · [Docker + sandboxing](https://docs.clawd.bot/install/docker) · [Sandbox config](https://docs.clawd.bot/gateway/configuration)
### [WhatsApp](https://docs.clawd.bot/channels/whatsapp) ### [WhatsApp](https://docs.clawd.bot/providers/whatsapp)
- Link the device: `pnpm clawdbot channels login` (stores creds in `~/.clawdbot/credentials`). - Link the device: `pnpm clawdbot providers 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/providers/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/providers/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/providers/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/providers/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/providers/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) ### [Microsoft Teams](https://docs.clawd.bot/providers/msteams)
- Configure a Teams app + Bot Framework, then add a `msteams` config section. - 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"`. - Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
@@ -399,7 +395,7 @@ Use these when you’re past the onboarding flow and want the deeper reference.
- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/automation/gmail-pubsub) - [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/automation/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/platforms/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/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)
- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/channels/troubleshooting) - [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/providers/troubleshooting)
- [Review security guidance before exposing anything.](https://docs.clawd.bot/gateway/security) - [Review security guidance before exposing anything.](https://docs.clawd.bot/gateway/security)
## Advanced docs (discovery + control) ## Advanced docs (discovery + control)
@@ -468,29 +464,24 @@ 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/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a>
<a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/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/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/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/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/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/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/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/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/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/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/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/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/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/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/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/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/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/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/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/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/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/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="Sebastian Barrios" title="Sebastian Barrios"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
<a href="https://github.com/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/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/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/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/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/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/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/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/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/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/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/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/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/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/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/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/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/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/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/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/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/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/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/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/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/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/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/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/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/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/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/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/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/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/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/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/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/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/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/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/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
<a href="https://github.com/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/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a>
<a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/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/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/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/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a>
<a href="https://github.com/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/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/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=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/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>
<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> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></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)
+33 -188
View File
@@ -2,194 +2,6 @@
<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>Clawdbot</title>
<item>
<title>2026.1.14-1</title>
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5825</sparkle:version>
<sparkle:shortVersionString>2026.1.14-1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.14-1</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>
<h4>Gateway / Daemon / Sessions</h4>
<ul>
<li>Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.</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>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
</item>
<item>
<title>2026.1.12-2</title>
<pubDate>Tue, 13 Jan 2026 10:05:25 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5534</sparkle:version>
<sparkle:shortVersionString>2026.1.12-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.12-2</h2>
<h3>Fixes</h3>
<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>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/>
</item>
<item> <item>
<title>2026.1.11-3</title> <title>2026.1.11-3</title>
<pubDate>Mon, 12 Jan 2026 10:40:23 +0000</pubDate> <pubDate>Mon, 12 Jan 2026 10:40:23 +0000</pubDate>
@@ -207,5 +19,38 @@
]]></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.11-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/>
</item> </item>
<item>
<title>2026.1.11-2</title>
<pubDate>Mon, 12 Jan 2026 10:25:53 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5210</sparkle:version>
<sparkle:shortVersionString>2026.1.11-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.11-2</h2>
<h3>Fixes</h3>
<ul>
<li>Installer: ensure the CLI entrypoint is executable after npm installs.</li>
<li>Packaging: include <code>dist/plugins/</code> in the npm package to avoid missing module errors.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-2/Clawdbot-2026.1.11-2.zip" length="19860732" type="application/octet-stream" sparkle:edSignature="0UG+d9v3Qf5F9vs/KozUB404WpHjFBQRVoRuhwtzF8kpU7jJmmGlQzh1c61E+LMN4fHcljpxIwHHrvvIfRyrCw=="/>
</item>
<item>
<title>2026.1.11-1</title>
<pubDate>Mon, 12 Jan 2026 09:53:46 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5207</sparkle:version>
<sparkle:shortVersionString>2026.1.11-1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.11-1</h2>
<h3>Fixes</h3>
<ul>
<li>Installer: include <code>patches/</code> in the npm package so postinstall patching works for npm/bun installs.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-1/Clawdbot-2026.1.11-1.zip" length="19860761" type="application/octet-stream" sparkle:edSignature="CXKzzha/s6cGBeF0TMz+cV8/pfqoAL9ZyNVacYRLnnHEwA1cMbOWRftpGRhYe4HknVQYYBgNQqZK2lBxpOZgBg=="/>
</item>
</channel> </channel>
</rss> </rss>
@@ -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)
} }
@@ -78,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(),
@@ -142,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()
@@ -173,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()
@@ -580,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,
@@ -628,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() {
@@ -723,7 +701,7 @@ class NodeRuntime(context: Context) {
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 mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
applyMainSessionKey(mainKey) _mainSessionKey.value = mainKey
val parsed = parseHexColorArgb(raw) val parsed = parseHexColorArgb(raw)
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB _seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
@@ -4,10 +4,3 @@ internal fun normalizeMainKey(raw: String?): String {
val trimmed = raw?.trim() val trimmed = raw?.trim()
return if (!trimmed.isNullOrEmpty()) trimmed else "main" 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:")
}
@@ -31,7 +31,7 @@ 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,
@@ -64,7 +64,6 @@ 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 var desired: Pair<BridgeEndpoint, Hello>? = null private var desired: Pair<BridgeEndpoint, Hello>? = null
private var job: Job? = null private var job: Job? = null
@@ -91,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
@@ -215,9 +212,7 @@ class BridgeSession(
"hello-ok" -> { "hello-ok" -> {
val name = first["serverName"].asStringOrNull() ?: "Bridge" val name = first["serverName"].asStringOrNull() ?: "Bridge"
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null } val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null }
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint) canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
mainSessionKey = rawMainSessionKey
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked". // Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
runCatching { runCatching {
@@ -227,7 +222,7 @@ class BridgeSession(
) )
} }
} }
onConnected(name, conn.remoteAddress, rawMainSessionKey) onConnected(name, conn.remoteAddress)
} }
"error" -> { "error" -> {
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE" val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
@@ -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) }
} }
@@ -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,7 +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 com.clawdbot.android.normalizeMainKey
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
@@ -117,13 +116,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
@@ -835,9 +827,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)
} }
} }
+2 -10
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,7 +68,7 @@ actor BridgeSession {
func connect( func connect(
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello, hello: BridgeHello,
onConnected: (@Sendable (String, String?) async -> Void)? = nil, onConnected: (@Sendable (String) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
async throws async throws
{ {
@@ -108,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)")
@@ -220,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()
@@ -238,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,
+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(
+5 -27
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,
@@ -232,15 +232,12 @@ final class NodeAppModel {
try await self.bridge.connect( try await self.bridge.connect(
endpoint: endpoint, endpoint: endpoint,
hello: hello, hello: hello,
onConnected: { [weak self] serverName, mainSessionKey in onConnected: { [weak self] serverName 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
@@ -289,10 +286,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()
} }
} }
@@ -309,23 +303,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
} }
@@ -354,10 +335,7 @@ final class NodeAppModel {
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String) let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
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
-7
View File
@@ -5,11 +5,4 @@ enum SessionKey {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "main" : trimmed 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:")
}
} }
+1 -11
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 {
@@ -656,10 +649,7 @@ 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) self.mainSessionKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
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] = [:]
@@ -307,8 +307,8 @@ enum AgentWorkspace {
} }
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
urls.append(cwd.appendingPathComponent("docs") urls.append(cwd.appendingPathComponent("docs")
.appendingPathComponent(self.templateDirname) .appendingPathComponent(self.templateDirname)
.appendingPathComponent(named)) .appendingPathComponent(named))
return urls return urls
} }
@@ -109,8 +109,8 @@ struct AnthropicAuthControls: View {
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.disabled(self.busy || self.connectionMode != .local || self.code .disabled(self.busy || self.connectionMode != .local || self.code
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty) .isEmpty)
} }
} }
@@ -228,7 +228,7 @@ enum ClawdbotOAuthStore {
static func oauthDir() -> URL { static func oauthDir() -> URL {
if let override = ProcessInfo.processInfo.environment[self.clawdbotOAuthDirEnv]? if let override = ProcessInfo.processInfo.environment[self.clawdbotOAuthDirEnv]?
.trimmingCharacters(in: .whitespacesAndNewlines), .trimmingCharacters(in: .whitespacesAndNewlines),
!override.isEmpty !override.isEmpty
{ {
let expanded = NSString(string: override).expandingTildeInPath let expanded = NSString(string: override).expandingTildeInPath
return URL(fileURLWithPath: expanded, isDirectory: true) return URL(fileURLWithPath: expanded, isDirectory: true)
+4 -4
View File
@@ -264,8 +264,8 @@ final class AppState {
} }
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
let configHasRemoteUrl = !(configRemoteUrl? let configHasRemoteUrl = !(configRemoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true) .isEmpty ?? true)
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey) let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
let resolvedConnectionMode: ConnectionMode = if let configMode { let resolvedConnectionMode: ConnectionMode = if let configMode {
@@ -356,8 +356,8 @@ final class AppState {
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String
let hasRemoteUrl = !(remoteUrl? let hasRemoteUrl = !(remoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true) .isEmpty ?? true)
let desiredMode: ConnectionMode? = switch modeRaw { let desiredMode: ConnectionMode? = switch modeRaw {
case "local": case "local":
@@ -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)")
} }
@@ -182,12 +182,12 @@ actor BridgeServer {
?? "main" ?? "main"
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( _ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: text, message: text,
sessionKey: sessionKey, sessionKey: sessionKey,
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,15 +205,15 @@ 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,
sessionKey: sessionKey, sessionKey: sessionKey,
thinking: thinking, thinking: thinking,
deliver: link.deliver, deliver: link.deliver,
to: to, to: to,
channel: channel)) provider: provider))
default: default:
break break
@@ -55,7 +55,7 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
guard let name = ClawdbotCanvasA2UIAction.extractActionName(userAction) else { return } guard let name = ClawdbotCanvasA2UIAction.extractActionName(userAction) else { return }
let actionId = let actionId =
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? UUID().uuidString ?? UUID().uuidString
canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)") canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)")
@@ -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 {
@@ -39,13 +39,13 @@ final class CanvasFileWatcher: @unchecked Sendable {
kFSEventStreamCreateFlagNoDefer) kFSEventStreamCreateFlagNoDefer)
guard let stream = FSEventStreamCreate( guard let stream = FSEventStreamCreate(
kCFAllocatorDefault, kCFAllocatorDefault,
Self.callback, Self.callback,
&context, &context,
paths, paths,
FSEventStreamEventId(kFSEventStreamEventIdSinceNow), FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
0.05, 0.05,
flags) flags)
else { else {
retainedSelf.release() retainedSelf.release()
return return
@@ -242,8 +242,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
} }
guard let url = CanvasScheme.makeURL( guard let url = CanvasScheme.makeURL(
session: CanvasWindowController.sanitizeSessionKey(self.sessionKey), session: CanvasWindowController.sanitizeSessionKey(self.sessionKey),
path: trimmed) path: trimmed)
else { else {
canvasWindowLogger canvasWindowLogger
.error( .error(
@@ -125,13 +125,13 @@ enum CommandResolver {
// fnm // fnm
bins.append(contentsOf: self.versionedNodeBinPaths( bins.append(contentsOf: self.versionedNodeBinPaths(
base: home.appendingPathComponent(".local/share/fnm/node-versions"), base: home.appendingPathComponent(".local/share/fnm/node-versions"),
suffix: "installation/bin")) suffix: "installation/bin"))
// nvm // nvm
bins.append(contentsOf: self.versionedNodeBinPaths( bins.append(contentsOf: self.versionedNodeBinPaths(
base: home.appendingPathComponent(".nvm/versions/node"), base: home.appendingPathComponent(".nvm/versions/node"),
suffix: "bin")) suffix: "bin"))
return bins return bins
} }
@@ -45,13 +45,13 @@ final class ConfigFileWatcher: @unchecked Sendable {
kFSEventStreamCreateFlagNoDefer) kFSEventStreamCreateFlagNoDefer)
guard let stream = FSEventStreamCreate( guard let stream = FSEventStreamCreate(
kCFAllocatorDefault, kCFAllocatorDefault,
Self.callback, Self.callback,
&context, &context,
paths, paths,
FSEventStreamEventId(kFSEventStreamEventIdSinceNow), FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
0.05, 0.05,
flags) flags)
else { else {
retainedSelf.release() retainedSelf.release()
return return
@@ -10,7 +10,7 @@ struct ConfigSettings: View {
"When enabled, the browser server will only connect if the clawd browser is already running." "When enabled, the browser server will only connect if the clawd browser is already running."
private static let browserProfileNote = private static let browserProfileNote =
"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 configSaving = false @State private var configSaving = false
@State private var hasLoaded = false @State private var hasLoaded = false
@@ -97,8 +97,8 @@ extension ConfigSettings {
Text("Clawdbot CLI config") Text("Clawdbot CLI config")
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
Text(self.isNixMode Text(self.isNixMode
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild." ? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
: "Edit ~/.clawdbot/clawdbot.json (agent / session / routing / messages).") : "Edit ~/.clawdbot/clawdbot.json (agent / session / routing / messages).")
.font(.callout) .font(.callout)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -753,9 +753,9 @@ extension ConfigSettings {
do { do {
let res: ModelsListResult = let res: ModelsListResult =
try await GatewayConnection.shared try await GatewayConnection.shared
.requestDecoded( .requestDecoded(
method: .modelsList, method: .modelsList,
timeoutMs: 15000) timeoutMs: 15000)
self.models = res.models self.models = res.models
self.modelsSourceLabel = "gateway" self.modelsSourceLabel = "gateway"
} catch { } catch {
@@ -792,8 +792,8 @@ extension ConfigSettings {
choice.provider, choice.provider,
self.modelRef(for: choice), self.modelRef(for: choice),
] ]
.joined(separator: " ") .joined(separator: " ")
.lowercased() .lowercased()
return tokens.allSatisfy { haystack.contains($0) } return tokens.allSatisfy { haystack.contains($0) }
} }
} }
+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
@@ -53,8 +53,8 @@ final class ConnectionModeCoordinator {
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
let settings = CommandResolver.connectionSettings() let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote( try await ControlChannel.shared.configure(mode: .remote(
target: settings.target, target: settings.target,
identity: settings.identity)) identity: settings.identity))
} catch { } catch {
self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)") self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)")
} }
@@ -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,15 +1,15 @@
import SwiftUI import SwiftUI
extension ConnectionsSettings { extension ConnectionsSettings {
private func channelStatus<T: Decodable>( private func providerStatus<T: Decodable>(
_ id: String, _ id: String,
as type: T.Type) -> T? as type: T.Type) -> T?
{ {
self.store.snapshot?.decodeChannel(id, as: type) self.store.snapshot?.decodeProvider(id, as: type)
} }
var whatsAppTint: Color { var whatsAppTint: Color {
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
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 }
@@ -20,7 +20,7 @@ extension ConnectionsSettings {
} }
var telegramTint: Color { var telegramTint: Color {
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self) guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
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 }
@@ -30,7 +30,7 @@ extension ConnectionsSettings {
} }
var discordTint: Color { var discordTint: Color {
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self) guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
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 }
@@ -40,7 +40,7 @@ extension ConnectionsSettings {
} }
var signalTint: Color { var signalTint: Color {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
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 }
@@ -50,7 +50,7 @@ extension ConnectionsSettings {
} }
var imessageTint: Color { var imessageTint: Color {
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self) guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
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 }
@@ -60,7 +60,7 @@ extension ConnectionsSettings {
} }
var whatsAppSummary: String { var whatsAppSummary: String {
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
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" }
@@ -69,7 +69,7 @@ extension ConnectionsSettings {
} }
var telegramSummary: String { var telegramSummary: String {
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self) guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
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" }
@@ -77,7 +77,7 @@ extension ConnectionsSettings {
} }
var discordSummary: String { var discordSummary: String {
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self) guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
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" }
@@ -85,7 +85,7 @@ extension ConnectionsSettings {
} }
var signalSummary: String { var signalSummary: String {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
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" }
@@ -93,7 +93,7 @@ extension ConnectionsSettings {
} }
var imessageSummary: String { var imessageSummary: String {
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self) guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
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" }
@@ -101,7 +101,7 @@ extension ConnectionsSettings {
} }
var whatsAppDetails: String? { var whatsAppDetails: String? {
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
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 {
@@ -132,7 +132,7 @@ extension ConnectionsSettings {
} }
var telegramDetails: String? { var telegramDetails: String? {
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self) guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
else { return nil } else { return nil }
var lines: [String] = [] var lines: [String] = []
if let source = status.tokenSource { if let source = status.tokenSource {
@@ -164,7 +164,7 @@ extension ConnectionsSettings {
} }
var discordDetails: String? { var discordDetails: String? {
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self) guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
else { return nil } else { return nil }
var lines: [String] = [] var lines: [String] = []
if let source = status.tokenSource { if let source = status.tokenSource {
@@ -193,7 +193,7 @@ extension ConnectionsSettings {
} }
var signalDetails: String? { var signalDetails: String? {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
else { return nil } else { return nil }
var lines: [String] = [] var lines: [String] = []
lines.append("Base URL: \(status.baseUrl)") lines.append("Base URL: \(status.baseUrl)")
@@ -220,7 +220,7 @@ extension ConnectionsSettings {
} }
var imessageDetails: String? { var imessageDetails: String? {
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self) guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
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 {
@@ -243,68 +243,68 @@ extension ConnectionsSettings {
} }
var isTelegramTokenLocked: Bool { var isTelegramTokenLocked: Bool {
self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?.tokenSource == "env" self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)?.tokenSource == "env"
} }
var isDiscordTokenLocked: Bool { var isDiscordTokenLocked: Bool {
self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?.tokenSource == "env" self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)?.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.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
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.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
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.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
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.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
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.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
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 +318,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 +333,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 +348,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,55 +363,55 @@ 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.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
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.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)? .date(fromMs: self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)?
.lastProbeAt) .lastProbeAt)
case .discord: case .discord:
return self return self
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)? .date(fromMs: self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)?
.lastProbeAt) .lastProbeAt)
case .signal: case .signal:
return self return self
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt) .date(fromMs: self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)?.lastProbeAt)
case .imessage: case .imessage:
return self return self
.date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)? .date(fromMs: self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)?
.lastProbeAt) .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.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
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.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
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.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
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.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
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.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
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)
@@ -38,30 +27,17 @@ extension ConnectionsStore {
private func applyUIConfig(_ snap: ConfigSnapshot) { private func applyUIConfig(_ snap: ConfigSnapshot) {
let ui = snap.config?[ let ui = snap.config?[
"ui", "ui",
]?.dictionaryValue ]?.dictionaryValue
let rawSeam = ui?[ let rawSeam = ui?[
"seamColor", "seamColor",
]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" ]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
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
@@ -101,10 +101,10 @@ extension ConnectionsStore {
defer { self.whatsappBusy = false } defer { self.whatsappBusy = false }
do { do {
let params: [String: AnyCodable] = [ let params: [String: AnyCodable] = [
"channel": AnyCodable("whatsapp"), "provider": AnyCodable("whatsapp"),
] ]
let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded( let result: ProviderLogoutResult = try await GatewayConnection.shared.requestDecoded(
method: .channelsLogout, method: .providersLogout,
params: params, params: params,
timeoutMs: 15000) timeoutMs: 15000)
self.whatsappLoginMessage = result.cleared self.whatsappLoginMessage = result.cleared
@@ -123,10 +123,10 @@ extension ConnectionsStore {
defer { self.telegramBusy = false } defer { self.telegramBusy = false }
do { do {
let params: [String: AnyCodable] = [ let params: [String: AnyCodable] = [
"channel": AnyCodable("telegram"), "provider": AnyCodable("telegram"),
] ]
let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded( let result: ProviderLogoutResult = try await GatewayConnection.shared.requestDecoded(
method: .channelsLogout, method: .providersLogout,
params: params, params: params,
timeoutMs: 15000) timeoutMs: 15000)
if result.envToken == true { if result.envToken == true {
@@ -154,8 +154,8 @@ private struct WhatsAppLoginWaitResult: Codable {
let message: String let message: String
} }
private struct ChannelLogoutResult: Codable { private struct ProviderLogoutResult: Codable {
let channel: String? let provider: String?
let accountId: String? let accountId: String?
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,7 +121,7 @@ struct ChannelsStatusSnapshot: Codable {
let lastProbeAt: Double? let lastProbeAt: Double?
} }
struct ChannelAccountSnapshot: Codable { struct ProviderAccountSnapshot: Codable {
let accountId: String let accountId: String
let name: String? let name: String?
let enabled: Bool? let enabled: Bool?
@@ -154,14 +154,14 @@ struct ChannelsStatusSnapshot: Codable {
} }
let ts: Double let ts: Double
let channelOrder: [String] let providerOrder: [String]
let channelLabels: [String: String] let providerLabels: [String: String]
let channels: [String: AnyCodable] let providers: [String: AnyCodable]
let channelAccounts: [String: [ChannelAccountSnapshot]] let providerAccounts: [String: [ProviderAccountSnapshot]]
let channelDefaultAccountId: [String: String] let providerDefaultAccountId: [String: String]
func decodeChannel<T: Decodable>(_ id: String, as type: T.Type) -> T? { func decodeProvider<T: Decodable>(_ id: String, as type: T.Type) -> T? {
guard let value = self.channels[id] else { return nil } guard let value = self.providers[id] else { return nil }
do { do {
let data = try JSONEncoder().encode(value) let data = try JSONEncoder().encode(value)
return try JSONDecoder().decode(type, from: data) return try JSONDecoder().decode(type, from: data)
@@ -180,7 +180,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 +230,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 +307,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
@@ -189,20 +189,20 @@ enum CritterIconRenderer {
canvas.context.setFillColor(NSColor.labelColor.cgColor) canvas.context.setFillColor(NSColor.labelColor.cgColor)
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: geometry.bodyRect, roundedRect: geometry.bodyRect,
cornerWidth: geometry.bodyCorner, cornerWidth: geometry.bodyCorner,
cornerHeight: geometry.bodyCorner, cornerHeight: geometry.bodyCorner,
transform: nil)) transform: nil))
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: geometry.leftEarRect, roundedRect: geometry.leftEarRect,
cornerWidth: geometry.earCorner, cornerWidth: geometry.earCorner,
cornerHeight: geometry.earCorner, cornerHeight: geometry.earCorner,
transform: nil)) transform: nil))
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: geometry.rightEarRect, roundedRect: geometry.rightEarRect,
cornerWidth: geometry.earCorner, cornerWidth: geometry.earCorner,
cornerHeight: geometry.earCorner, cornerHeight: geometry.earCorner,
transform: nil)) transform: nil))
for i in 0..<4 { for i in 0..<4 {
let x = geometry.legStartX + CGFloat(i) * (geometry.legW + geometry.legSpacing) let x = geometry.legStartX + CGFloat(i) * (geometry.legW + geometry.legSpacing)
@@ -213,10 +213,10 @@ enum CritterIconRenderer {
width: geometry.legW, width: geometry.legW,
height: geometry.legH * geometry.legHeightScale) height: geometry.legH * geometry.legHeightScale)
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: rect, roundedRect: rect,
cornerWidth: geometry.legW * 0.34, cornerWidth: geometry.legW * 0.34,
cornerHeight: geometry.legW * 0.34, cornerHeight: geometry.legW * 0.34,
transform: nil)) transform: nil))
} }
canvas.context.fillPath() canvas.context.fillPath()
} }
@@ -252,15 +252,15 @@ enum CritterIconRenderer {
height: holeH) height: holeH)
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: leftHoleRect, roundedRect: leftHoleRect,
cornerWidth: holeCorner, cornerWidth: holeCorner,
cornerHeight: holeCorner, cornerHeight: holeCorner,
transform: nil)) transform: nil))
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: rightHoleRect, roundedRect: rightHoleRect,
cornerWidth: holeCorner, cornerWidth: holeCorner,
cornerHeight: holeCorner, cornerHeight: holeCorner,
transform: nil)) transform: nil))
} }
if options.eyesClosedLines { if options.eyesClosedLines {
@@ -278,41 +278,41 @@ enum CritterIconRenderer {
width: lineW, width: lineW,
height: lineH) height: lineH)
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: leftRect, roundedRect: leftRect,
cornerWidth: corner, cornerWidth: corner,
cornerHeight: corner, cornerHeight: corner,
transform: nil)) transform: nil))
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: rightRect, roundedRect: rightRect,
cornerWidth: corner, cornerWidth: corner,
cornerHeight: corner, cornerHeight: corner,
transform: nil)) transform: nil))
} else { } else {
let eyeOpen = max(0.05, 1 - options.blink) let eyeOpen = max(0.05, 1 - options.blink)
let eyeH = canvas.snapY(geometry.bodyRect.height * 0.26 * eyeOpen) let eyeH = canvas.snapY(geometry.bodyRect.height * 0.26 * eyeOpen)
let left = CGMutablePath() let left = CGMutablePath()
left.move(to: CGPoint( left.move(to: CGPoint(
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2), x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
y: canvas.snapY(leftCenter.y - eyeH))) y: canvas.snapY(leftCenter.y - eyeH)))
left.addLine(to: CGPoint( left.addLine(to: CGPoint(
x: canvas.snapX(leftCenter.x + geometry.eyeW / 2), x: canvas.snapX(leftCenter.x + geometry.eyeW / 2),
y: canvas.snapY(leftCenter.y))) y: canvas.snapY(leftCenter.y)))
left.addLine(to: CGPoint( left.addLine(to: CGPoint(
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2), x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
y: canvas.snapY(leftCenter.y + eyeH))) y: canvas.snapY(leftCenter.y + eyeH)))
left.closeSubpath() left.closeSubpath()
let right = CGMutablePath() let right = CGMutablePath()
right.move(to: CGPoint( right.move(to: CGPoint(
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2), x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
y: canvas.snapY(rightCenter.y - eyeH))) y: canvas.snapY(rightCenter.y - eyeH)))
right.addLine(to: CGPoint( right.addLine(to: CGPoint(
x: canvas.snapX(rightCenter.x - geometry.eyeW / 2), x: canvas.snapX(rightCenter.x - geometry.eyeW / 2),
y: canvas.snapY(rightCenter.y))) y: canvas.snapY(rightCenter.y)))
right.addLine(to: CGPoint( right.addLine(to: CGPoint(
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2), x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
y: canvas.snapY(rightCenter.y + eyeH))) y: canvas.snapY(rightCenter.y + eyeH)))
right.closeSubpath() right.closeSubpath()
canvas.context.addPath(left) canvas.context.addPath(left)
@@ -121,12 +121,12 @@ extension CritterStatusLabel {
} }
return Image(nsImage: CritterIconRenderer.makeIcon( return Image(nsImage: CritterIconRenderer.makeIcon(
blink: self.blinkAmount, blink: self.blinkAmount,
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0), legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
earWiggle: self.earWiggle, earWiggle: self.earWiggle,
earScale: self.earBoostActive ? 1.9 : 1.0, earScale: self.earBoostActive ? 1.9 : 1.0,
earHoles: self.earBoostActive, earHoles: self.earBoostActive,
badge: badge)) badge: badge))
} }
private func resetMotion() { private func resetMotion() {
@@ -36,13 +36,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
} }
@@ -204,7 +204,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
@@ -14,7 +14,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"
+15 -15
View File
@@ -11,15 +11,15 @@ struct CronJobEditor: View {
let labelColumnWidth: CGFloat = 160 let labelColumnWidth: CGFloat = 160
static let introText = static let introText =
"Create a schedule that wakes clawd via the Gateway. " "Create a schedule that wakes clawd via the Gateway. "
+ "Use an isolated session for agent turns so your main chat stays clean." + "Use an isolated session for agent turns so your main chat stays clean."
static let sessionTargetNote = static let sessionTargetNote =
"Main jobs post a system event into the current main session. " "Main jobs post a system event into the current main session. "
+ "Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/Discord/etc)." + "Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/Discord/etc)."
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."
static let mainSummaryNote = static let mainSummaryNote =
@@ -45,7 +45,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 = ""
@@ -323,7 +323,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 +331,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)
+11 -12
View File
@@ -70,16 +70,16 @@ enum CronSchedule: Codable, Equatable {
enum CronPayload: Codable, Equatable { enum CronPayload: Codable, Equatable {
case systemEvent(text: String) case systemEvent(text: String)
case agentTurn( case agentTurn(
message: String, message: String,
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)
} }
@@ -26,8 +26,8 @@ extension CronSettings {
}) })
} }
.alert("Delete cron job?", isPresented: Binding( .alert("Delete cron job?", isPresented: Binding(
get: { self.confirmDelete != nil }, get: { self.confirmDelete != nil },
set: { if !$0 { self.confirmDelete = nil } })) set: { if !$0 { self.confirmDelete = nil } }))
{ {
Button("Cancel", role: .cancel) { self.confirmDelete = nil } Button("Cancel", role: .cancel) { self.confirmDelete = nil }
Button("Delete", role: .destructive) { Button("Delete", role: .destructive) {
@@ -42,9 +42,9 @@ extension CronSettings {
} }
} }
.onChange(of: self.store.selectedJobId) { _, newValue in .onChange(of: self.store.selectedJobId) { _, newValue in
guard let newValue else { return } guard let newValue else { return }
Task { await self.store.refreshRuns(jobId: newValue) } Task { await self.store.refreshRuns(jobId: newValue) }
} }
} }
var schedulerBanner: some View { var schedulerBanner: some View {
@@ -69,8 +69,8 @@ extension CronSettings {
Spacer() Spacer()
HStack(spacing: 8) { HStack(spacing: 8) {
Toggle("Enabled", isOn: Binding( Toggle("Enabled", isOn: Binding(
get: { job.enabled }, get: { job.enabled },
set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } })) set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } }))
.toggleStyle(.switch) .toggleStyle(.switch)
.labelsHidden() .labelsHidden()
Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } } Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } }
@@ -22,7 +22,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"),
@@ -76,7 +76,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] "),
@@ -102,8 +102,8 @@ enum DebugActions {
_ = try await RemoteTunnelManager.shared.ensureControlTunnel() _ = try await RemoteTunnelManager.shared.ensureControlTunnel()
let settings = CommandResolver.connectionSettings() let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote( try await ControlChannel.shared.configure(mode: .remote(
target: settings.target, target: settings.target,
identity: settings.identity)) identity: settings.identity))
} catch { } catch {
// ControlChannel will surface a degraded state; also refresh health to update the menu text. // ControlChannel will surface a degraded state; also refresh health to update the menu text.
Task { await HealthStore.shared.refresh(onDemand: true) } Task { await HealthStore.shared.refresh(onDemand: true) }
@@ -127,8 +127,8 @@ enum DebugActions {
_ = try await RemoteTunnelManager.shared.ensureControlTunnel() _ = try await RemoteTunnelManager.shared.ensureControlTunnel()
let settings = CommandResolver.connectionSettings() let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote( try await ControlChannel.shared.configure(mode: .remote(
target: settings.target, target: settings.target,
identity: settings.identity)) identity: settings.identity))
await HealthStore.shared.refresh(onDemand: true) await HealthStore.shared.refresh(onDemand: true)
return .success("SSH tunnel reset.") return .success("SSH tunnel reset.")
} catch { } catch {
+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)
@@ -107,9 +107,9 @@ enum DeviceModelCatalog {
private static func loadMapping(resourceName: String) -> [String: String] { private static func loadMapping(resourceName: String) -> [String: String] {
guard let url = self.resourceBundle?.url( guard let url = self.resourceBundle?.url(
forResource: resourceName, forResource: resourceName,
withExtension: "json", withExtension: "json",
subdirectory: self.resourceSubdirectory) subdirectory: self.resourceSubdirectory)
else { return [:] } else { return [:] }
do { do {
@@ -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
}
}
}
@@ -113,17 +113,17 @@ actor GatewayChannelActor {
self.task = nil self.task = nil
await self.failPending(NSError( await self.failPending(NSError(
domain: "Gateway", domain: "Gateway",
code: 0, code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"])) userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
let waiters = self.connectWaiters let waiters = self.connectWaiters
self.connectWaiters.removeAll() self.connectWaiters.removeAll()
for waiter in waiters { for waiter in waiters {
waiter.resume(throwing: NSError( waiter.resume(throwing: NSError(
domain: "Gateway", domain: "Gateway",
code: 0, code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"])) userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
} }
} }
@@ -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
@@ -18,7 +18,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
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 +32,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 +52,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 +62,7 @@ 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 providersLogout = "providers.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,19 +389,19 @@ 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?)
{ {
await self.sendAgent(GatewayAgentInvocation( await self.sendAgent(GatewayAgentInvocation(
message: message, message: message,
sessionKey: sessionKey, sessionKey: sessionKey,
thinking: thinking, thinking: thinking,
deliver: deliver, deliver: deliver,
to: to, to: to,
channel: channel, provider: provider,
timeoutSeconds: timeoutSeconds, timeoutSeconds: timeoutSeconds,
idempotencyKey: idempotencyKey)) idempotencyKey: idempotencyKey))
} }
func sendSystemEvent(_ params: [String: AnyCodable]) async { func sendSystemEvent(_ params: [String: AnyCodable]) async {
@@ -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
} }
@@ -27,7 +27,7 @@ struct GatewayDiscoveryInlineList: View {
ForEach(self.discovery.gateways.prefix(6)) { gateway in ForEach(self.discovery.gateways.prefix(6)) { gateway in
let target = self.suggestedSSHTarget(gateway) let target = self.suggestedSSHTarget(gateway)
let selected = (target != nil && self.currentTarget? let selected = (target != nil && self.currentTarget?
.trimmingCharacters(in: .whitespacesAndNewlines) == target) .trimmingCharacters(in: .whitespacesAndNewlines) == target)
Button { Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
@@ -61,8 +61,8 @@ struct GatewayDiscoveryInlineList: View {
.background( .background(
RoundedRectangle(cornerRadius: 10, style: .continuous) RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(self.rowBackground( .fill(self.rowBackground(
selected: selected, selected: selected,
hovered: self.hoveredGatewayID == gateway.id))) hovered: self.hoveredGatewayID == gateway.id)))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous) RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder( .strokeBorder(
@@ -33,16 +33,14 @@ actor GatewayEndpointStore {
return GatewayEndpointStore.resolveGatewayToken( return GatewayEndpointStore.resolveGatewayToken(
isRemote: CommandResolver.connectionModeIsRemote(), isRemote: CommandResolver.connectionModeIsRemote(),
root: root, root: root,
env: ProcessInfo.processInfo.environment, env: ProcessInfo.processInfo.environment)
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
}, },
password: { password: {
let root = ClawdbotConfigFile.loadDict() let root = ClawdbotConfigFile.loadDict()
return GatewayEndpointStore.resolveGatewayPassword( return GatewayEndpointStore.resolveGatewayPassword(
isRemote: CommandResolver.connectionModeIsRemote(), isRemote: CommandResolver.connectionModeIsRemote(),
root: root, root: root,
env: ProcessInfo.processInfo.environment, env: ProcessInfo.processInfo.environment)
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
}, },
localPort: { GatewayEnvironment.gatewayPort() }, localPort: { GatewayEnvironment.gatewayPort() },
localHost: { localHost: {
@@ -62,8 +60,7 @@ actor GatewayEndpointStore {
private static func resolveGatewayPassword( private static func resolveGatewayPassword(
isRemote: Bool, isRemote: Bool,
root: [String: Any], root: [String: Any],
env: [String: String], env: [String: String]) -> String?
launchdSnapshot: LaunchAgentPlistSnapshot?) -> String?
{ {
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? "" let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -91,19 +88,13 @@ actor GatewayEndpointStore {
return pw return pw
} }
} }
if let password = launchdSnapshot?.password?.trimmingCharacters(in: .whitespacesAndNewlines),
!password.isEmpty
{
return password
}
return nil return nil
} }
private static func resolveGatewayToken( private static func resolveGatewayToken(
isRemote: Bool, isRemote: Bool,
root: [String: Any], root: [String: Any],
env: [String: String], env: [String: String]) -> String?
launchdSnapshot: LaunchAgentPlistSnapshot?) -> String?
{ {
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? "" let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -131,11 +122,6 @@ actor GatewayEndpointStore {
return value return value
} }
} }
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
return token
}
return nil return nil
} }
@@ -206,10 +192,10 @@ actor GatewayEndpointStore {
let port = self.deps.localPort() let port = self.deps.localPort()
let host = await self.deps.localHost() let host = await self.deps.localHost()
self.setState(.ready( self.setState(.ready(
mode: .local, mode: .local,
url: URL(string: "ws://\(host):\(port)")!, url: URL(string: "ws://\(host):\(port)")!,
token: token, token: token,
password: password)) password: password))
case .remote: case .remote:
let port = await self.deps.remotePortIfRunning() let port = await self.deps.remotePortIfRunning()
guard let port else { guard let port else {
@@ -219,10 +205,10 @@ actor GatewayEndpointStore {
} }
self.cancelRemoteEnsure() self.cancelRemoteEnsure()
self.setState(.ready( self.setState(.ready(
mode: .remote, mode: .remote,
url: URL(string: "ws://127.0.0.1:\(Int(port))")!, url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
token: token, token: token,
password: password)) password: password))
case .unconfigured: case .unconfigured:
self.cancelRemoteEnsure() self.cancelRemoteEnsure()
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured")) self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
@@ -402,61 +388,14 @@ actor GatewayEndpointStore {
} }
} }
extension GatewayEndpointStore {
static func dashboardURL(for config: GatewayConnection.Config) throws -> URL {
guard var components = URLComponents(url: config.url, resolvingAgainstBaseURL: false) else {
throw NSError(domain: "Dashboard", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Invalid gateway URL",
])
}
switch components.scheme?.lowercased() {
case "ws":
components.scheme = "http"
case "wss":
components.scheme = "https"
default:
components.scheme = "http"
}
components.path = "/"
var queryItems: [URLQueryItem] = []
if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
queryItems.append(URLQueryItem(name: "token", value: token))
}
if let password = config.password?.trimmingCharacters(in: .whitespacesAndNewlines),
!password.isEmpty
{
queryItems.append(URLQueryItem(name: "password", value: password))
}
components.queryItems = queryItems.isEmpty ? nil : queryItems
guard let url = components.url else {
throw NSError(domain: "Dashboard", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Failed to build dashboard URL",
])
}
return url
}
}
#if DEBUG #if DEBUG
extension GatewayEndpointStore { extension GatewayEndpointStore {
static func _testResolveGatewayPassword( static func _testResolveGatewayPassword(
isRemote: Bool, isRemote: Bool,
root: [String: Any], root: [String: Any],
env: [String: String], env: [String: String]) -> String?
launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String?
{ {
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot) self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
}
static func _testResolveGatewayToken(
isRemote: Bool,
root: [String: Any],
env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String?
{
self.resolveGatewayToken(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot)
} }
static func _testResolveGatewayBindMode( static func _testResolveGatewayBindMode(
@@ -306,10 +306,6 @@ enum GatewayLaunchAgentManager {
password: snapshot.password) password: snapshot.password)
} }
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
LaunchAgentPlist.snapshot(url: self.plistURL)
}
private static func ensureEnabled() async { private static func ensureEnabled() async {
let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
guard result.status != 0 else { return } guard result.status != 0 else { return }
@@ -8,7 +8,7 @@ enum GatewayPayloadDecoding {
} }
static func decodeIfPresent<T: Decodable>(_ payload: ClawdbotProtocol.AnyCodable?, as _: T.Type = T.self) throws static func decodeIfPresent<T: Decodable>(_ payload: ClawdbotProtocol.AnyCodable?, as _: T.Type = T.self) throws
-> T? -> T?
{ {
guard let payload else { return nil } guard let payload else { return nil }
return try self.decode(payload, as: T.self) return try self.decode(payload, as: T.self)
@@ -211,19 +211,19 @@ final class GatewayProcessManager {
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String { private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
let instanceText = instance ?? "pid unknown" let instanceText = instance ?? "pid unknown"
if let snap { if let snap {
let linkId = snap.channelOrder?.first(where: { let linkId = snap.providerOrder?.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil } if let summary = snap.providers[$0] { return summary.linked != nil }
return false return false
}) ?? snap.channels.keys.first(where: { }) ?? snap.providers.keys.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil } if let summary = snap.providers[$0] { return summary.linked != nil }
return false return false
}) })
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false let linked = linkId.flatMap { snap.providers[$0]?.linked } ?? false
let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age" let authAge = linkId.flatMap { snap.providers[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
let label = let label =
linkId.flatMap { snap.channelLabels?[$0] } ?? linkId.flatMap { snap.providerLabels?[$0] } ??
linkId?.capitalized ?? linkId?.capitalized ??
"channel" "provider"
let linkText = linked ? "linked" : "not linked" let linkText = linked ? "linked" : "not linked"
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)" return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
} }
@@ -238,7 +238,7 @@ struct GeneralSettings: View {
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || self.state.remoteTarget .disabled(self.remoteStatus == .checking || self.state.remoteTarget
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
} }
GatewayDiscoveryInlineList( GatewayDiscoveryInlineList(
@@ -496,18 +496,18 @@ struct GeneralSettings: View {
} }
if let snap = snapshot { if let snap = snapshot {
let linkId = snap.channelOrder?.first(where: { let linkId = snap.providerOrder?.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil } if let summary = snap.providers[$0] { return summary.linked != nil }
return false return false
}) ?? snap.channels.keys.first(where: { }) ?? snap.providers.keys.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil } if let summary = snap.providers[$0] { return summary.linked != nil }
return false return false
}) })
let linkLabel = let linkLabel =
linkId.flatMap { snap.channelLabels?[$0] } ?? linkId.flatMap { snap.providerLabels?[$0] } ??
linkId?.capitalized ?? linkId?.capitalized ??
"Link channel" "Link provider"
let linkAge = linkId.flatMap { snap.channels[$0]?.authAgeMs } let linkAge = linkId.flatMap { snap.providers[$0]?.authAgeMs }
Text("\(linkLabel) auth age: \(healthAgeString(linkAge))") Text("\(linkLabel) auth age: \(healthAgeString(linkAge))")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -627,8 +627,8 @@ extension GeneralSettings {
let originalMode = AppStateStore.shared.connectionMode let originalMode = AppStateStore.shared.connectionMode
do { do {
try await ControlChannel.shared.configure(mode: .remote( try await ControlChannel.shared.configure(mode: .remote(
target: settings.target, target: settings.target,
identity: settings.identity)) identity: settings.identity))
let data = try await ControlChannel.shared.health(timeout: 10) let data = try await ControlChannel.shared.health(timeout: 10)
if decodeHealthSnapshot(from: data) != nil { if decodeHealthSnapshot(from: data) != nil {
self.remoteStatus = .ok self.remoteStatus = .ok
+27 -27
View File
@@ -4,7 +4,7 @@ import Observation
import SwiftUI import SwiftUI
struct HealthSnapshot: Codable, Sendable { struct HealthSnapshot: Codable, Sendable {
struct ChannelSummary: Codable, Sendable { struct ProviderSummary: Codable, Sendable {
struct Probe: Codable, Sendable { struct Probe: Codable, Sendable {
struct Bot: Codable, Sendable { struct Bot: Codable, Sendable {
let username: String? let username: String?
@@ -44,9 +44,9 @@ struct HealthSnapshot: Codable, Sendable {
let ok: Bool? let ok: Bool?
let ts: Double let ts: Double
let durationMs: Double let durationMs: Double
let channels: [String: ChannelSummary] let providers: [String: ProviderSummary]
let channelOrder: [String]? let providerOrder: [String]?
let channelLabels: [String: String]? let providerLabels: [String: String]?
let heartbeatSeconds: Int? let heartbeatSeconds: Int?
let sessions: Sessions let sessions: Sessions
} }
@@ -144,13 +144,13 @@ final class HealthStore {
} }
} }
private static func isChannelHealthy(_ summary: HealthSnapshot.ChannelSummary) -> Bool { private static func isProviderHealthy(_ summary: HealthSnapshot.ProviderSummary) -> Bool {
guard summary.configured == true else { return false } guard summary.configured == true else { return false }
// If probe is missing, treat it as "configured but unknown health" (not a hard fail). // If probe is missing, treat it as "configured but unknown health" (not a hard fail).
return summary.probe?.ok ?? true return summary.probe?.ok ?? true
} }
private static func describeProbeFailure(_ probe: HealthSnapshot.ChannelSummary.Probe) -> String { private static func describeProbeFailure(_ probe: HealthSnapshot.ProviderSummary.Probe) -> String {
let elapsed = probe.elapsedMs.map { "\(Int($0))ms" } let elapsed = probe.elapsedMs.map { "\(Int($0))ms" }
if let error = probe.error, error.lowercased().contains("timeout") || probe.status == nil { if let error = probe.error, error.lowercased().contains("timeout") || probe.status == nil {
if let elapsed { return "Health check timed out (\(elapsed))" } if let elapsed { return "Health check timed out (\(elapsed))" }
@@ -162,28 +162,28 @@ final class HealthStore {
return "\(reason) (\(code))" return "\(reason) (\(code))"
} }
private func resolveLinkChannel( private func resolveLinkProvider(
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)? _ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ProviderSummary)?
{ {
let order = snap.channelOrder ?? Array(snap.channels.keys) let order = snap.providerOrder ?? Array(snap.providers.keys)
for id in order { for id in order {
if let summary = snap.channels[id], summary.linked != nil { if let summary = snap.providers[id], summary.linked != nil {
return (id: id, summary: summary) return (id: id, summary: summary)
} }
} }
return nil return nil
} }
private func resolveFallbackChannel( private func resolveFallbackProvider(
_ snap: HealthSnapshot, _ snap: HealthSnapshot,
excluding id: String?) -> (id: String, summary: HealthSnapshot.ChannelSummary)? excluding id: String?) -> (id: String, summary: HealthSnapshot.ProviderSummary)?
{ {
let order = snap.channelOrder ?? Array(snap.channels.keys) let order = snap.providerOrder ?? Array(snap.providers.keys)
for channelId in order { for providerId in order {
if channelId == id { continue } if providerId == id { continue }
guard let summary = snap.channels[channelId] else { continue } guard let summary = snap.providers[providerId] else { continue }
if Self.isChannelHealthy(summary) { if Self.isProviderHealthy(summary) {
return (id: channelId, summary: summary) return (id: providerId, summary: summary)
} }
} }
return nil return nil
@@ -194,13 +194,13 @@ final class HealthStore {
return .degraded(error) return .degraded(error)
} }
guard let snap = self.snapshot else { return .unknown } guard let snap = self.snapshot else { return .unknown }
guard let link = self.resolveLinkChannel(snap) else { return .unknown } guard let link = self.resolveLinkProvider(snap) else { return .unknown }
if link.summary.linked != true { if link.summary.linked != true {
// Linking is optional if any other channel is healthy; don't paint the whole app red. // Linking is optional if any other provider is healthy; don't paint the whole app red.
let fallback = self.resolveFallbackChannel(snap, excluding: link.id) let fallback = self.resolveFallbackProvider(snap, excluding: link.id)
return fallback != nil ? .degraded("Not linked") : .linkingNeeded return fallback != nil ? .degraded("Not linked") : .linkingNeeded
} }
// A channel can be "linked" but still unhealthy (failed probe / cannot connect). // A provider can be "linked" but still unhealthy (failed probe / cannot connect).
if let probe = link.summary.probe, probe.ok == false { if let probe = link.summary.probe, probe.ok == false {
return .degraded(Self.describeProbeFailure(probe)) return .degraded(Self.describeProbeFailure(probe))
} }
@@ -211,10 +211,10 @@ final class HealthStore {
if self.isRefreshing { return "Health check running…" } if self.isRefreshing { return "Health check running…" }
if let error = self.lastError { return "Health check failed: \(error)" } if let error = self.lastError { return "Health check failed: \(error)" }
guard let snap = self.snapshot else { return "Health check pending" } guard let snap = self.snapshot else { return "Health check pending" }
guard let link = self.resolveLinkChannel(snap) else { return "Health check pending" } guard let link = self.resolveLinkProvider(snap) else { return "Health check pending" }
if link.summary.linked != true { if link.summary.linked != true {
if let fallback = self.resolveFallbackChannel(snap, excluding: link.id) { if let fallback = self.resolveFallbackProvider(snap, excluding: link.id) {
let fallbackLabel = snap.channelLabels?[fallback.id] ?? fallback.id.capitalized let fallbackLabel = snap.providerLabels?[fallback.id] ?? fallback.id.capitalized
let fallbackState = (fallback.summary.probe?.ok ?? true) ? "ok" : "degraded" let fallbackState = (fallback.summary.probe?.ok ?? true) ? "ok" : "degraded"
return "\(fallbackLabel) \(fallbackState) · Not linked — run clawdbot login" return "\(fallbackLabel) \(fallbackState) · Not linked — run clawdbot login"
} }
@@ -247,10 +247,10 @@ final class HealthStore {
} }
func describeFailure(from snap: HealthSnapshot, fallback: String?) -> String { func describeFailure(from snap: HealthSnapshot, fallback: String?) -> String {
if let link = self.resolveLinkChannel(snap), link.summary.linked != true { if let link = self.resolveLinkProvider(snap), link.summary.linked != true {
return "Not linked — run clawdbot login" return "Not linked — run clawdbot login"
} }
if let link = self.resolveLinkChannel(snap), let probe = link.summary.probe, probe.ok == false { if let link = self.resolveLinkProvider(snap), let probe = link.summary.probe, probe.ok == false {
return Self.describeProbeFailure(probe) return Self.describeProbeFailure(probe)
} }
if let fallback, !fallback.isEmpty { if let fallback, !fallback.isEmpty {
@@ -13,7 +13,7 @@ enum InstanceIdentity {
let defaults = Self.defaults let defaults = Self.defaults
if let existing = defaults.string(forKey: instanceIdKey)? if let existing = defaults.string(forKey: instanceIdKey)?
.trimmingCharacters(in: .whitespacesAndNewlines), .trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty !existing.isEmpty
{ {
return existing return existing
} }
@@ -242,18 +242,18 @@ final class InstancesStore {
do { do {
let data = try await ControlChannel.shared.health(timeout: 8) let data = try await ControlChannel.shared.health(timeout: 8)
guard let snap = decodeHealthSnapshot(from: data) else { return } guard let snap = decodeHealthSnapshot(from: data) else { return }
let linkId = snap.channelOrder?.first(where: { let linkId = snap.providerOrder?.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil } if let summary = snap.providers[$0] { return summary.linked != nil }
return false return false
}) ?? snap.channels.keys.first(where: { }) ?? snap.providers.keys.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil } if let summary = snap.providers[$0] { return summary.linked != nil }
return false return false
}) })
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false let linked = linkId.flatMap { snap.providers[$0]?.linked } ?? false
let linkLabel = let linkLabel =
linkId.flatMap { snap.channelLabels?[$0] } ?? linkId.flatMap { snap.providerLabels?[$0] } ??
linkId?.capitalized ?? linkId?.capitalized ??
"channel" "provider"
let entry = InstanceInfo( let entry = InstanceInfo(
id: "health-\(snap.ts)", id: "health-\(snap.ts)",
host: "gateway (health)", host: "gateway (health)",
+8 -27
View File
@@ -1,25 +1,9 @@
import Foundation import Foundation
enum LogLocator { enum LogLocator {
private static var logDir: URL { private static let logDir = URL(fileURLWithPath: "/tmp/clawdbot")
if let override = ProcessInfo.processInfo.environment["CLAWDBOT_LOG_DIR"], !override.isEmpty { private static let stdoutLog = logDir.appendingPathComponent("clawdbot-stdout.log")
return URL(fileURLWithPath: override) private static let gatewayLog = logDir.appendingPathComponent("clawdbot-gateway.log")
}
return URL(fileURLWithPath: "/tmp/clawdbot")
}
private static var stdoutLog: URL {
logDir.appendingPathComponent("clawdbot-stdout.log")
}
private static var gatewayLog: URL {
logDir.appendingPathComponent("clawdbot-gateway.log")
}
private static func ensureLogDirExists() {
try? FileManager.default.createDirectory(at: self.logDir, withIntermediateDirectories: true)
}
private static func modificationDate(for url: URL) -> Date { private static func modificationDate(for url: URL) -> Date {
(try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
@@ -27,12 +11,11 @@ enum LogLocator {
/// Returns the newest log file under /tmp/clawdbot/ (rolling or stdout), or nil if none exist. /// Returns the newest log file under /tmp/clawdbot/ (rolling or stdout), or nil if none exist.
static func bestLogFile() -> URL? { static func bestLogFile() -> URL? {
self.ensureLogDirExists()
let fm = FileManager.default let fm = FileManager.default
let files = (try? fm.contentsOfDirectory( let files = (try? fm.contentsOfDirectory(
at: self.logDir, at: self.logDir,
includingPropertiesForKeys: [.contentModificationDateKey], includingPropertiesForKeys: [.contentModificationDateKey],
options: [.skipsHiddenFiles])) ?? [] options: [.skipsHiddenFiles])) ?? []
return files return files
.filter { $0.lastPathComponent.hasPrefix("clawdbot") && $0.pathExtension == "log" } .filter { $0.lastPathComponent.hasPrefix("clawdbot") && $0.pathExtension == "log" }
@@ -43,13 +26,11 @@ enum LogLocator {
/// Path to use for launchd stdout/err. /// Path to use for launchd stdout/err.
static var launchdLogPath: String { static var launchdLogPath: String {
self.ensureLogDirExists() stdoutLog.path
return stdoutLog.path
} }
/// Path to use for the Gateway launchd job stdout/err. /// Path to use for the Gateway launchd job stdout/err.
static var launchdGatewayLogPath: String { static var launchdGatewayLogPath: String {
self.ensureLogDirExists() gatewayLog.path
return gatewayLog.path
} }
} }
@@ -102,14 +102,11 @@ struct MenuContent: View {
} }
if self.state.canvasEnabled { if self.state.canvasEnabled {
Button { Button {
Task { @MainActor in if self.state.canvasPanelVisible {
if self.state.canvasPanelVisible { CanvasManager.shared.hideAll()
CanvasManager.shared.hideAll() } else {
} else { // Don't force a navigation on re-open: preserve the current web view state.
let sessionKey = await GatewayConnection.shared.mainSessionKey() _ = try? CanvasManager.shared.show(sessionKey: "main", path: nil)
// Don't force a navigation on re-open: preserve the current web view state.
_ = try? CanvasManager.shared.show(sessionKey: sessionKey, path: nil)
}
} }
} label: { } label: {
Label( Label(
@@ -316,7 +313,27 @@ struct MenuContent: View {
private func openDashboard() async { private func openDashboard() async {
do { do {
let config = try await GatewayEndpointStore.shared.requireConfig() let config = try await GatewayEndpointStore.shared.requireConfig()
let url = try GatewayEndpointStore.dashboardURL(for: config) let wsURL = config.url
guard var components = URLComponents(url: wsURL, resolvingAgainstBaseURL: false) else {
throw NSError(domain: "Dashboard", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Invalid gateway URL",
])
}
switch components.scheme?.lowercased() {
case "ws":
components.scheme = "http"
case "wss":
components.scheme = "https"
default:
components.scheme = "http"
}
components.path = "/"
components.query = nil
guard let url = components.url else {
throw NSError(domain: "Dashboard", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Failed to build dashboard URL",
])
}
NSWorkspace.shared.open(url) NSWorkspace.shared.open(url)
} catch { } catch {
let alert = NSAlert() let alert = NSAlert()
@@ -51,9 +51,9 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
let initialWidth = self.initialCardWidth(for: menu) let initialWidth = self.initialCardWidth(for: menu)
let initial = AnyView(ContextMenuCardView( let initial = AnyView(ContextMenuCardView(
rows: initialRows, rows: initialRows,
statusText: initialStatusText, statusText: initialStatusText,
isLoading: initialIsLoading)) isLoading: initialIsLoading))
let hosting = NSHostingView(rootView: initial) let hosting = NSHostingView(rootView: initial)
hosting.frame.size.width = max(1, initialWidth) hosting.frame.size.width = max(1, initialWidth)
@@ -103,7 +103,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
extension MenuSessionsInjector { extension MenuSessionsInjector {
// MARK: - Injection // MARK: - Injection
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
private func inject(into menu: NSMenu) { private func inject(into menu: NSMenu) {
// Remove any previous injected items. // Remove any previous injected items.
@@ -121,15 +120,13 @@ extension MenuSessionsInjector {
if let snapshot = self.cachedSnapshot { if let snapshot = self.cachedSnapshot {
let now = Date() let now = Date()
let mainKey = self.mainSessionKey
let rows = snapshot.rows.filter { row in let rows = snapshot.rows.filter { row in
if row.key == "main", mainKey != "main" { return false } if row.key == "main" { return true }
if row.key == mainKey { return true }
guard let updatedAt = row.updatedAt else { return false } guard let updatedAt = row.updatedAt else { return false }
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
}.sorted { lhs, rhs in }.sorted { lhs, rhs in
if lhs.key == mainKey { return true } if lhs.key == "main" { return true }
if rhs.key == mainKey { return false } if rhs.key == "main" { return false }
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
} }
@@ -138,8 +135,8 @@ extension MenuSessionsInjector {
headerItem.isEnabled = false headerItem.isEnabled = false
let hosted = self.makeHostedView( let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView( rootView: AnyView(MenuSessionsHeaderView(
count: rows.count, count: rows.count,
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))), statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
width: width, width: width,
highlighted: false) highlighted: false)
headerItem.view = hosted headerItem.view = hosted
@@ -175,8 +172,8 @@ extension MenuSessionsInjector {
: self.controlChannelStatusText(for: channelState) : self.controlChannelStatusText(for: channelState)
let hosted = self.makeHostedView( let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView( rootView: AnyView(MenuSessionsHeaderView(
count: 0, count: 0,
statusText: statusText)), statusText: statusText)),
width: width, width: width,
highlighted: false) highlighted: false)
headerItem.view = hosted headerItem.view = hosted
@@ -299,7 +296,7 @@ extension MenuSessionsInjector {
headerItem.isEnabled = false headerItem.isEnabled = false
headerItem.view = self.makeHostedView( headerItem.view = self.makeHostedView(
rootView: AnyView(MenuUsageHeaderView( rootView: AnyView(MenuUsageHeaderView(
count: rows.count)), count: rows.count)),
width: width, width: width,
highlighted: false) highlighted: false)
menu.insertItem(headerItem, at: cursor) menu.insertItem(headerItem, at: cursor)
@@ -472,11 +469,11 @@ extension MenuSessionsInjector {
item.tag = self.tag item.tag = self.tag
item.isEnabled = false item.isEnabled = false
let view = AnyView(SessionMenuPreviewView( let view = AnyView(SessionMenuPreviewView(
sessionKey: sessionKey, sessionKey: sessionKey,
width: width, width: width,
maxItems: 10, maxItems: 10,
maxLines: maxLines, maxLines: maxLines,
title: title)) title: title))
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false) item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
return item return item
} }
@@ -596,10 +593,10 @@ extension MenuSessionsInjector {
let width = self.submenuWidth() let width = self.submenuWidth()
menu.addItem(self.makeSessionPreviewItem( menu.addItem(self.makeSessionPreviewItem(
sessionKey: row.key, sessionKey: row.key,
title: "Recent messages (last 10)", title: "Recent messages (last 10)",
width: width, width: width,
maxLines: 3)) maxLines: 3))
let morePreview = NSMenuItem(title: "More preview…", action: nil, keyEquivalent: "") let morePreview = NSMenuItem(title: "More preview…", action: nil, keyEquivalent: "")
morePreview.submenu = self.buildPreviewSubmenu(sessionKey: row.key, width: width) morePreview.submenu = self.buildPreviewSubmenu(sessionKey: row.key, width: width)
@@ -648,7 +645,7 @@ extension MenuSessionsInjector {
compact.representedObject = row.key compact.representedObject = row.key
menu.addItem(compact) menu.addItem(compact)
if row.key != self.mainSessionKey, row.key != "global" { if row.key != "main", row.key != "global" {
let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "") let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "")
del.target = self del.target = self
del.representedObject = row.key del.representedObject = row.key
@@ -703,10 +700,10 @@ extension MenuSessionsInjector {
private func buildPreviewSubmenu(sessionKey: String, width: CGFloat) -> NSMenu { private func buildPreviewSubmenu(sessionKey: String, width: CGFloat) -> NSMenu {
let menu = NSMenu() let menu = NSMenu()
menu.addItem(self.makeSessionPreviewItem( menu.addItem(self.makeSessionPreviewItem(
sessionKey: sessionKey, sessionKey: sessionKey,
title: "Recent messages (expanded)", title: "Recent messages (expanded)",
width: width, width: width,
maxLines: 8)) maxLines: 8))
return menu return menu
} }
@@ -763,9 +760,9 @@ extension MenuSessionsInjector {
!commands.isEmpty !commands.isEmpty
{ {
menu.addItem(self.makeNodeMultilineItem( menu.addItem(self.makeNodeMultilineItem(
label: "Commands", label: "Commands",
value: commands.joined(separator: ", "), value: commands.joined(separator: ", "),
width: width)) width: width))
} }
return menu return menu
@@ -855,9 +852,9 @@ extension MenuSessionsInjector {
guard let key = sender.representedObject as? String else { return } guard let key = sender.representedObject as? String else { return }
Task { @MainActor in Task { @MainActor in
guard SessionActions.confirmDestructiveAction( guard SessionActions.confirmDestructiveAction(
title: "Reset session?", title: "Reset session?",
message: "Starts a new session id for “\(key)”.", message: "Starts a new session id for “\(key)”.",
action: "Reset") action: "Reset")
else { return } else { return }
do { do {
@@ -874,9 +871,9 @@ extension MenuSessionsInjector {
guard let key = sender.representedObject as? String else { return } guard let key = sender.representedObject as? String else { return }
Task { @MainActor in Task { @MainActor in
guard SessionActions.confirmDestructiveAction( guard SessionActions.confirmDestructiveAction(
title: "Compact session log?", title: "Compact session log?",
message: "Keeps the last 400 lines; archives the old file.", message: "Keeps the last 400 lines; archives the old file.",
action: "Compact") action: "Compact")
else { return } else { return }
do { do {
@@ -893,9 +890,9 @@ extension MenuSessionsInjector {
guard let key = sender.representedObject as? String else { return } guard let key = sender.representedObject as? String else { return }
Task { @MainActor in Task { @MainActor in
guard SessionActions.confirmDestructiveAction( guard SessionActions.confirmDestructiveAction(
title: "Delete session?", title: "Delete session?",
message: "Deletes the “\(key)” entry and archives its transcript.", message: "Deletes the “\(key)” entry and archives its transcript.",
action: "Delete") action: "Delete")
else { return } else { return }
do { do {
@@ -36,10 +36,10 @@ actor MacNodeBridgeSession {
func connect( func connect(
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello, hello: BridgeHello,
onConnected: (@Sendable (String, String?) async -> Void)? = nil, onConnected: (@Sendable (String) async -> Void)? = nil,
onDisconnected: (@Sendable (String) async -> Void)? = nil, onDisconnected: (@Sendable (String) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
async throws async throws
{ {
await self.disconnect() await self.disconnect()
self.disconnectHandler = onDisconnected self.disconnectHandler = onDisconnected
@@ -77,15 +77,15 @@ actor MacNodeBridgeSession {
}) })
guard let line = try await AsyncTimeout.withTimeout( guard let line = try await AsyncTimeout.withTimeout(
seconds: 6, seconds: 6,
onTimeout: { onTimeout: {
TimeoutError(message: "operation timed out") TimeoutError(message: "operation timed out")
}, },
operation: { operation: {
try await self.receiveLine() try await self.receiveLine()
}), }),
let data = line.data(using: .utf8), let data = line.data(using: .utf8),
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data) let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
else { else {
self.logger.error("node bridge hello failed (unexpected response)") self.logger.error("node bridge hello failed (unexpected response)")
await self.disconnect() await self.disconnect()
@@ -98,8 +98,7 @@ actor MacNodeBridgeSession {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data) let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName) self.state = .connected(serverName: ok.serverName)
self.startPingLoop() self.startPingLoop()
let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) await onConnected?(ok.serverName)
await onConnected?(ok.serverName, mainKey?.isEmpty == false ? mainKey : nil)
} 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)")
@@ -67,11 +67,8 @@ final class MacNodeModeCoordinator {
try await self.session.connect( try await self.session.connect(
endpoint: endpoint, endpoint: endpoint,
hello: hello, hello: hello,
onConnected: { [weak self] serverName, mainSessionKey in onConnected: { [weak self] serverName in
self?.logger.info("mac node connected to \(serverName, privacy: .public)") self?.logger.info("mac node connected to \(serverName, privacy: .public)")
if let mainSessionKey {
await self?.runtime.updateMainSessionKey(mainSessionKey)
}
}, },
onDisconnected: { reason in onDisconnected: { reason in
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason) await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
@@ -389,10 +386,10 @@ final class MacNodeModeCoordinator {
let preferred = BridgeDiscoveryPreferences.preferredStableID() let preferred = BridgeDiscoveryPreferences.preferredStableID()
if let preferred, if let preferred,
let match = results.first(where: { let match = results.first(where: {
if case .service = $0.endpoint { if case .service = $0.endpoint {
return BridgeEndpointID.stableID($0.endpoint) == preferred return BridgeEndpointID.stableID($0.endpoint) == preferred
} }
return false return false
}) })
{ {
state.finish(match.endpoint) state.finish(match.endpoint)
@@ -7,7 +7,6 @@ actor MacNodeRuntime {
private let cameraCapture = CameraCaptureService() private let cameraCapture = CameraCaptureService()
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)? private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
private var mainSessionKey: String = "main"
init( init(
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = { makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
@@ -17,12 +16,6 @@ actor MacNodeRuntime {
self.makeMainActorServices = makeMainActorServices self.makeMainActorServices = makeMainActorServices
} }
func updateMainSessionKey(_ sessionKey: String) {
let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.mainSessionKey = trimmed
}
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
let command = req.command let command = req.command
if self.isCanvasCommand(command), !Self.canvasEnabled() { if self.isCanvasCommand(command), !Self.canvasEnabled() {
@@ -79,32 +72,28 @@ actor MacNodeRuntime {
let placement = params.placement.map { let placement = params.placement.map {
CanvasPlacement(x: $0.x, y: $0.y, width: $0.width, height: $0.height) CanvasPlacement(x: $0.x, y: $0.y, width: $0.width, height: $0.height)
} }
let sessionKey = self.mainSessionKey
try await MainActor.run { try await MainActor.run {
_ = try CanvasManager.shared.showDetailed( _ = try CanvasManager.shared.showDetailed(
sessionKey: sessionKey, sessionKey: "main",
target: url, target: url,
placement: placement) placement: placement)
} }
return BridgeInvokeResponse(id: req.id, ok: true) return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdbotCanvasCommand.hide.rawValue: case ClawdbotCanvasCommand.hide.rawValue:
let sessionKey = self.mainSessionKey
await MainActor.run { await MainActor.run {
CanvasManager.shared.hide(sessionKey: sessionKey) CanvasManager.shared.hide(sessionKey: "main")
} }
return BridgeInvokeResponse(id: req.id, ok: true) return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdbotCanvasCommand.navigate.rawValue: case ClawdbotCanvasCommand.navigate.rawValue:
let params = try Self.decodeParams(ClawdbotCanvasNavigateParams.self, from: req.paramsJSON) let params = try Self.decodeParams(ClawdbotCanvasNavigateParams.self, from: req.paramsJSON)
let sessionKey = self.mainSessionKey
try await MainActor.run { try await MainActor.run {
_ = try CanvasManager.shared.show(sessionKey: sessionKey, path: params.url) _ = try CanvasManager.shared.show(sessionKey: "main", path: params.url)
} }
return BridgeInvokeResponse(id: req.id, ok: true) return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdbotCanvasCommand.evalJS.rawValue: case ClawdbotCanvasCommand.evalJS.rawValue:
let params = try Self.decodeParams(ClawdbotCanvasEvalParams.self, from: req.paramsJSON) let params = try Self.decodeParams(ClawdbotCanvasEvalParams.self, from: req.paramsJSON)
let sessionKey = self.mainSessionKey
let result = try await CanvasManager.shared.eval( let result = try await CanvasManager.shared.eval(
sessionKey: sessionKey, sessionKey: "main",
javaScript: params.javaScript) javaScript: params.javaScript)
let payload = try Self.encodePayload(["result": result] as [String: String]) let payload = try Self.encodePayload(["result": result] as [String: String])
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
@@ -120,8 +109,7 @@ actor MacNodeRuntime {
}() }()
let quality = params?.quality ?? 0.9 let quality = params?.quality ?? 0.9
let sessionKey = self.mainSessionKey let path = try await CanvasManager.shared.snapshot(sessionKey: "main", outPath: nil)
let path = try await CanvasManager.shared.snapshot(sessionKey: sessionKey, outPath: nil)
defer { try? FileManager.default.removeItem(atPath: path) } defer { try? FileManager.default.removeItem(atPath: path) }
let data = try Data(contentsOf: URL(fileURLWithPath: path)) let data = try Data(contentsOf: URL(fileURLWithPath: path))
guard let image = NSImage(data: data) else { guard let image = NSImage(data: data) else {
@@ -181,10 +169,10 @@ actor MacNodeRuntime {
var height: Int var height: Int
} }
let payload = try Self.encodePayload(SnapPayload( let payload = try Self.encodePayload(SnapPayload(
format: (params.format ?? .jpg).rawValue, format: (params.format ?? .jpg).rawValue,
base64: res.data.base64EncodedString(), base64: res.data.base64EncodedString(),
width: Int(res.size.width), width: Int(res.size.width),
height: Int(res.size.height))) height: Int(res.size.height)))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdbotCameraCommand.clip.rawValue: case ClawdbotCameraCommand.clip.rawValue:
let params = (try? Self.decodeParams(ClawdbotCameraClipParams.self, from: req.paramsJSON)) ?? let params = (try? Self.decodeParams(ClawdbotCameraClipParams.self, from: req.paramsJSON)) ??
@@ -204,10 +192,10 @@ actor MacNodeRuntime {
var hasAudio: Bool var hasAudio: Bool
} }
let payload = try Self.encodePayload(ClipPayload( let payload = try Self.encodePayload(ClipPayload(
format: (params.format ?? .mp4).rawValue, format: (params.format ?? .mp4).rawValue,
base64: data.base64EncodedString(), base64: data.base64EncodedString(),
durationMs: res.durationMs, durationMs: res.durationMs,
hasAudio: res.hasAudio)) hasAudio: res.hasAudio))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdbotCameraCommand.list.rawValue: case ClawdbotCameraCommand.list.rawValue:
let devices = await self.cameraCapture.listDevices() let devices = await self.cameraCapture.listDevices()
@@ -312,12 +300,12 @@ actor MacNodeRuntime {
var hasAudio: Bool var hasAudio: Bool
} }
let payload = try Self.encodePayload(ScreenPayload( let payload = try Self.encodePayload(ScreenPayload(
format: "mp4", format: "mp4",
base64: data.base64EncodedString(), base64: data.base64EncodedString(),
durationMs: params.durationMs, durationMs: params.durationMs,
fps: params.fps, fps: params.fps,
screenIndex: params.screenIndex, screenIndex: params.screenIndex,
hasAudio: res.hasAudio)) hasAudio: res.hasAudio))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
} }
@@ -331,8 +319,7 @@ actor MacNodeRuntime {
private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
try await self.ensureA2UIHost() try await self.ensureA2UIHost()
let sessionKey = self.mainSessionKey let json = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: """
let json = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: """
(() => { (() => {
if (!globalThis.clawdbotA2UI) return JSON.stringify({ ok: false, error: "missing clawdbotA2UI" }); if (!globalThis.clawdbotA2UI) return JSON.stringify({ ok: false, error: "missing clawdbotA2UI" });
return JSON.stringify(globalThis.clawdbotA2UI.reset()); return JSON.stringify(globalThis.clawdbotA2UI.reset());
@@ -371,8 +358,7 @@ actor MacNodeRuntime {
} }
})() })()
""" """
let sessionKey = self.mainSessionKey let resultJSON = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: js)
let resultJSON = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: js)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
} }
@@ -383,9 +369,8 @@ actor MacNodeRuntime {
NSLocalizedDescriptionKey: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", NSLocalizedDescriptionKey: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
]) ])
} }
let sessionKey = self.mainSessionKey
_ = try await MainActor.run { _ = try await MainActor.run {
try CanvasManager.shared.show(sessionKey: sessionKey, path: a2uiUrl) try CanvasManager.shared.show(sessionKey: "main", path: a2uiUrl)
} }
if await self.isA2UIReady(poll: true) { return } if await self.isA2UIReady(poll: true) { return }
throw NSError(domain: "Canvas", code: 31, userInfo: [ throw NSError(domain: "Canvas", code: 31, userInfo: [
@@ -404,8 +389,7 @@ actor MacNodeRuntime {
let deadline = poll ? Date().addingTimeInterval(6.0) : Date() let deadline = poll ? Date().addingTimeInterval(6.0) : Date()
while true { while true {
do { do {
let sessionKey = self.mainSessionKey let ready = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: """
let ready = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: """
(() => String(Boolean(globalThis.clawdbotA2UI)))() (() => String(Boolean(globalThis.clawdbotA2UI)))()
""") """)
let trimmed = ready.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = ready.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -454,12 +438,12 @@ actor MacNodeRuntime {
} }
let payload = try Self.encodePayload(RunPayload( let payload = try Self.encodePayload(RunPayload(
exitCode: result.exitCode, exitCode: result.exitCode,
timedOut: result.timedOut, timedOut: result.timedOut,
success: result.success, success: result.success,
stdout: result.stdout, stdout: result.stdout,
stderr: result.stderr, stderr: result.stderr,
error: result.errorMessage)) error: result.errorMessage))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
} }
@@ -576,8 +560,8 @@ actor MacNodeRuntime {
case .jpeg: case .jpeg:
let clamped = min(1.0, max(0.05, quality)) let clamped = min(1.0, max(0.05, quality))
guard let data = rep.representation( guard let data = rep.representation(
using: .jpeg, using: .jpeg,
properties: [.compressionFactor: clamped]) properties: [.compressionFactor: clamped])
else { else {
throw NSError(domain: "Canvas", code: 24, userInfo: [ throw NSError(domain: "Canvas", code: 24, userInfo: [
NSLocalizedDescriptionKey: "jpeg encode failed", NSLocalizedDescriptionKey: "jpeg encode failed",
@@ -454,7 +454,7 @@ final class NodePairingApprovalPrompter {
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings() let settings = await center.notificationSettings()
guard settings.authorizationStatus == .authorized || guard settings.authorizationStatus == .authorized ||
settings.authorizationStatus == .provisional settings.authorizationStatus == .provisional
else { else {
return return
} }
@@ -547,7 +547,7 @@ final class NodePairingApprovalPrompter {
let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first
guard let gateway else { return nil } guard let gateway else { return nil }
let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ??
gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty) gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
guard let host, !host.isEmpty else { return nil } guard let host, !host.isEmpty else { return nil }
let port = gateway.sshPort > 0 ? gateway.sshPort : 22 let port = gateway.sshPort > 0 ? gateway.sshPort : 22
return SSHTarget(host: host, port: port) return SSHTarget(host: host, port: port)
@@ -694,7 +694,7 @@ extension OnboardingView {
systemImage: "bubble.left.and.bubble.right") systemImage: "bubble.left.and.bubble.right")
self.featureActionRow( self.featureActionRow(
title: "Connect WhatsApp or Telegram", title: "Connect WhatsApp or Telegram",
subtitle: "Open Settings → Connections to link channels and monitor status.", subtitle: "Open Settings → Connections to link providers and monitor status.",
systemImage: "link") systemImage: "link")
{ {
self.openSettings(tab: .connections) self.openSettings(tab: .connections)
@@ -58,13 +58,6 @@ final class OnboardingWizardModel {
func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async { func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async {
guard self.sessionId == nil, !self.isStarting else { return } guard self.sessionId == nil, !self.isStarting else { return }
guard mode == .local else { return } guard mode == .local else { return }
if self.shouldSkipWizard() {
self.sessionId = nil
self.currentStep = nil
self.status = "done"
self.errorMessage = nil
return
}
self.isStarting = true self.isStarting = true
self.errorMessage = nil self.errorMessage = nil
self.lastStartMode = mode self.lastStartMode = mode
@@ -184,33 +177,6 @@ final class OnboardingWizardModel {
Task { await self.startIfNeeded(mode: mode, workspace: self.lastStartWorkspace) } Task { await self.startIfNeeded(mode: mode, workspace: self.lastStartWorkspace) }
return true return true
} }
private func shouldSkipWizard() -> Bool {
let root = ClawdbotConfigFile.loadDict()
if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty {
return true
}
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any]
{
if let mode = auth["mode"] as? String,
!mode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
if let token = auth["token"] as? String,
!token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
if let password = auth["password"] as? String,
!password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
}
return false
}
} }
struct OnboardingWizardStepView: View { struct OnboardingWizardStepView: View {
@@ -77,10 +77,10 @@ final class PeekabooBridgeHostCoordinator {
var infoCF: CFDictionary? var infoCF: CFDictionary?
guard SecCodeCopySigningInformation( guard SecCodeCopySigningInformation(
staticCode, staticCode,
SecCSFlags(rawValue: kSecCSSigningInformation), SecCSFlags(rawValue: kSecCSSigningInformation),
&infoCF) == errSecSuccess, &infoCF) == errSecSuccess,
let info = infoCF as? [String: Any] let info = infoCF as? [String: Any]
else { else {
return nil return nil
} }
@@ -106,9 +106,9 @@ private final class ClawdbotPeekabooBridgeServices: PeekabooBridgeServiceProvidi
let feedbackClient: any AutomationFeedbackClient = NoopAutomationFeedbackClient() let feedbackClient: any AutomationFeedbackClient = NoopAutomationFeedbackClient()
let snapshots = InMemorySnapshotManager(options: .init( let snapshots = InMemorySnapshotManager(options: .init(
snapshotValidityWindow: 600, snapshotValidityWindow: 600,
maxSnapshots: 50, maxSnapshots: 50,
deleteArtifactsOnCleanup: false)) deleteArtifactsOnCleanup: false))
let applications = ApplicationService(feedbackClient: feedbackClient) let applications = ApplicationService(feedbackClient: feedbackClient)
let screenCapture = ScreenCaptureService(loggingService: logging) let screenCapture = ScreenCaptureService(loggingService: logging)
@@ -158,10 +158,10 @@ actor PortGuardian {
mode: mode, mode: mode,
listeners: listeners) listeners: listeners)
reports.append(Self.buildReport( reports.append(Self.buildReport(
port: port, port: port,
listeners: listeners, listeners: listeners,
mode: mode, mode: mode,
tunnelHealthy: tunnelHealthy)) tunnelHealthy: tunnelHealthy))
} }
return reports return reports
@@ -105,8 +105,8 @@ final class RemotePortTunnel {
return return
} }
guard let line = String(data: data, encoding: .utf8)? guard let line = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines), .trimmingCharacters(in: .whitespacesAndNewlines),
!line.isEmpty !line.isEmpty
else { return } else { return }
Self.logger.error("ssh tunnel stderr: \(line, privacy: .public)") Self.logger.error("ssh tunnel stderr: \(line, privacy: .public)")
} }
@@ -42,11 +42,11 @@ struct RuntimeResolution {
enum RuntimeResolutionError: Error { enum RuntimeResolutionError: Error {
case notFound(searchPaths: [String]) case notFound(searchPaths: [String])
case unsupported( case unsupported(
kind: RuntimeKind, kind: RuntimeKind,
found: RuntimeVersion, found: RuntimeVersion,
required: RuntimeVersion, required: RuntimeVersion,
path: String, path: String,
searchPaths: [String]) searchPaths: [String])
case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String]) case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String])
} }
@@ -65,21 +65,21 @@ enum RuntimeLocator {
} }
guard let rawVersion = readVersion(of: binary, pathEnv: pathEnv) else { guard let rawVersion = readVersion(of: binary, pathEnv: pathEnv) else {
return .failure(.versionParse( return .failure(.versionParse(
kind: runtime, kind: runtime,
raw: "(unreadable)", raw: "(unreadable)",
path: binary, path: binary,
searchPaths: searchPaths)) searchPaths: searchPaths))
} }
guard let parsed = RuntimeVersion.from(string: rawVersion) else { guard let parsed = RuntimeVersion.from(string: rawVersion) else {
return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths)) return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths))
} }
guard parsed >= self.minNode else { guard parsed >= self.minNode else {
return .failure(.unsupported( return .failure(.unsupported(
kind: runtime, kind: runtime,
found: parsed, found: parsed,
required: self.minNode, required: self.minNode,
path: binary, path: binary,
searchPaths: searchPaths)) searchPaths: searchPaths))
} }
return .success(RuntimeResolution(kind: runtime, path: binary, version: parsed)) return .success(RuntimeResolution(kind: runtime, path: binary, version: parsed))
@@ -251,11 +251,11 @@ private final class StreamRecorder: NSObject, SCStreamOutput, SCStreamDelegate,
if let err = self.writer.error { if let err = self.writer.error {
cont cont
.resume(throwing: ScreenRecordService.ScreenRecordError .resume(throwing: ScreenRecordService.ScreenRecordError
.writeFailed(err.localizedDescription)) .writeFailed(err.localizedDescription))
} else if self.writer.status != .completed { } else if self.writer.status != .completed {
cont cont
.resume(throwing: ScreenRecordService.ScreenRecordError .resume(throwing: ScreenRecordService.ScreenRecordError
.writeFailed("Failed to finalize video")) .writeFailed("Failed to finalize video"))
} else { } else {
cont.resume() cont.resume()
} }
@@ -54,9 +54,9 @@ enum SoundEffectCatalog {
var map: [String: URL] = [:] var map: [String: URL] = [:]
for root in Self.searchRoots { for root in Self.searchRoots {
guard let contents = try? FileManager.default.contentsOfDirectory( guard let contents = try? FileManager.default.contentsOfDirectory(
at: root, at: root,
includingPropertiesForKeys: nil, includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]) options: [.skipsHiddenFiles])
else { continue } else { continue }
for url in contents where Self.allowedExtensions.contains(url.pathExtension.lowercased()) { for url in contents where Self.allowedExtensions.contains(url.pathExtension.lowercased()) {
@@ -88,9 +88,9 @@ enum SoundEffectPlayer {
static func sound(from bookmark: Data) -> NSSound? { static func sound(from bookmark: Data) -> NSSound? {
var stale = false var stale = false
guard let url = try? URL( guard let url = try? URL(
resolvingBookmarkData: bookmark, resolvingBookmarkData: bookmark,
options: [.withoutUI, .withSecurityScope], options: [.withoutUI, .withSecurityScope],
bookmarkDataIsStale: &stale) bookmarkDataIsStale: &stale)
else { return nil } else { return nil }
let scoped = url.startAccessingSecurityScopedResource() let scoped = url.startAccessingSecurityScopedResource()
@@ -354,9 +354,9 @@ actor TalkModeRuntime {
"session=\(sessionKey, privacy: .public)") "session=\(sessionKey, privacy: .public)")
guard let assistantText = await self.waitForAssistantText( guard let assistantText = await self.waitForAssistantText(
sessionKey: sessionKey, sessionKey: sessionKey,
since: startedAt, since: startedAt,
timeoutSeconds: 45) timeoutSeconds: 45)
else { else {
self.logger.warning("talk assistant text missing after timeout") self.logger.warning("talk assistant text missing after timeout")
await self.startListening() await self.startListening()
@@ -37,7 +37,7 @@ enum VoiceWakeForwarder {
var thinking: String = "low" var thinking: String = "low"
var deliver: Bool = true var deliver: Bool = true
var to: String? var to: String?
var channel: GatewayAgentChannel = .last var provider: GatewayAgentProvider = .last
} }
@discardableResult @discardableResult
@@ -46,14 +46,14 @@ enum VoiceWakeForwarder {
options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError> options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError>
{ {
let payload = Self.prefixedTranscript(transcript) let payload = Self.prefixedTranscript(transcript)
let deliver = options.channel.shouldDeliver(options.deliver) let deliver = options.provider.shouldDeliver(options.deliver)
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: payload, message: payload,
sessionKey: options.sessionKey, sessionKey: options.sessionKey,
thinking: options.thinking, thinking: options.thinking,
deliver: deliver, deliver: deliver,
to: options.to, to: options.to,
channel: options.channel)) provider: options.provider))
if result.ok { if result.ok {
self.logger.info("voice wake forward ok") self.logger.info("voice wake forward ok")
@@ -493,10 +493,10 @@ actor VoiceWakeRuntime {
config: WakeWordGateConfig) -> WakeWordGateMatch? config: WakeWordGateConfig) -> WakeWordGateMatch?
{ {
guard let command = VoiceWakeTextUtils.textOnlyCommand( guard let command = VoiceWakeTextUtils.textOnlyCommand(
transcript: transcript, transcript: transcript,
triggers: triggers, triggers: triggers,
minCommandLength: config.minCommandLength, minCommandLength: config.minCommandLength,
trimWake: Self.trimmedAfterTrigger) trimWake: Self.trimmedAfterTrigger)
else { return nil } else { return nil }
return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command) return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
} }
@@ -519,9 +519,9 @@ actor VoiceWakeRuntime {
guard let lastSeenAt, let lastText else { return } guard let lastSeenAt, let lastText else { return }
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return } guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
guard let match = self.textOnlyFallbackMatch( guard let match = self.textOnlyFallbackMatch(
transcript: lastText, transcript: lastText,
triggers: triggers, triggers: triggers,
config: gateConfig) config: gateConfig)
else { return } else { return }
if let cooldown = self.cooldownUntil, Date() < cooldown { if let cooldown = self.cooldownUntil, Date() < cooldown {
return return
@@ -155,7 +155,7 @@ struct VoiceWakeSettings: View {
Label("Add word", systemImage: "plus") Label("Add word", systemImage: "plus")
} }
.disabled(self.state.swabbleTriggerWords .disabled(self.state.swabbleTriggerWords
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) .contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers } Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers }
} }
@@ -440,21 +440,21 @@ struct VoiceWakeSettings: View {
{ idx, localeID in { idx, localeID in
HStack(spacing: 8) { HStack(spacing: 8) {
Picker("Extra \(idx + 1)", selection: Binding( Picker("Extra \(idx + 1)", selection: Binding(
get: { localeID }, get: { localeID },
set: { newValue in set: { newValue in
guard self.state guard self.state
.voiceWakeAdditionalLocaleIDs.indices .voiceWakeAdditionalLocaleIDs.indices
.contains(idx) else { return } .contains(idx) else { return }
self.state self.state
.voiceWakeAdditionalLocaleIDs[idx] = .voiceWakeAdditionalLocaleIDs[idx] =
newValue newValue
})) { })) {
ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in
Text(self.friendlyName(for: Locale(identifier: id))).tag(id) Text(self.friendlyName(for: Locale(identifier: id))).tag(id)
}
} }
} .labelsHidden()
.labelsHidden() .frame(width: 220)
.frame(width: 220)
Button { Button {
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return } guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
Loaded 100 of 2271 files, more files were not shown because too many files have changed in this diff. Show more