Compare commits

..
Author SHA1 Message Date
Peter Steinberger 4c151760df fix: show account counts in connections UI (#813) (thanks @dbhurley) 2026-01-13 01:52:36 +00:00
DB Hurley 32c93aefbe feat(ui): display per-account status for multi-account Telegram
When multiple Telegram accounts are configured, the Connections UI now
displays individual status cards for each account showing:
- Bot username and account ID
- Running/configured status
- Last inbound message time
- Per-account errors

Falls back to the existing summary view for single-account configs.
No changes needed to types (already added upstream).
2026-01-13 01:52:36 +00:00
2273 changed files with 140026 additions and 196617 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
task: protocol
command: pnpm protocol:check
- runtime: node
task: format
command: pnpm format
- runtime: bun
task: lint
command: bunx biome check src
- runtime: bun
task: test
command: bunx vitest run
@@ -141,31 +141,6 @@ jobs:
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
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:
runs-on: blacksmith-4vcpu-windows-2025
defaults:
-1
View File
@@ -29,5 +29,4 @@ jobs:
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
CLAWDBOT_NO_ONBOARD: "1"
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
run: pnpm test:install:smoke
-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 -12
View File
@@ -1,12 +1,9 @@
# Repository Guidelines
- Repo: https://github.com/clawdbot/clawdbot
- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
## Project Structure & Module Organization
- 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`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
## Docs Linking (Mintlify)
@@ -26,12 +23,12 @@
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
- Node remains supported for running built output (`dist/*`) and production installs.
- 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`
## Coding Style & Naming Conventions
- 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.
- 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.
@@ -60,9 +57,6 @@
- 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.
## 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)
- **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!
@@ -74,14 +68,12 @@
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
## Troubleshooting
- Rebrand/migration issues 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
- 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.
- Never update the Carbon dependency.
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
@@ -106,7 +98,6 @@
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
+55 -172
View File
@@ -1,194 +1,78 @@
# Changelog
## 2026.1.15 (unreleased)
## 2026.1.12-4
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
- TUI: show provider/model labels for the active session and default model.
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
- 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.
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
- 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.
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
- 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.
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
### Fixes
- Onboarding/Configure: refuse to proceed with invalid configs; run `clawdbot doctor` first to avoid wiping custom fields. (#764 — thanks @mukhtharcm)
- Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid “Incorrect role information” errors. (#804 — thanks @ThomsenDrake)
- Connections UI: show account counts for multi-account providers and keep Telegram per-account cards. (#813 — thanks @dbhurley)
- Discord/Slack: centralize reply-thread planning so auto-thread replies stay in the created thread without parent reply refs.
- Update: run `clawdbot doctor --non-interactive` during updates to avoid TTY hangs. (#781 — thanks @ronyrus)
- Tools: allow Claude/Gemini tool param aliases (`file_path`, `old_string`, `new_string`) while enforcing required params at runtime. (#793 — thanks @hsrvc)
- Gemini: downgrade tool-call history missing `thought_signature` to avoid INVALID_ARGUMENT errors. (#793 — thanks @hsrvc)
- Messaging: enforce context isolation for message tool sends across providers (normalized targets + tests). (#793 — thanks @hsrvc)
- Auto-reply: re-evaluate reasoning tag enforcement on fallback providers to prevent leaked reasoning. (#810 — thanks @mcinteerj)
## 2026.1.14-1
### 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.
## 2026.1.12-3
### 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: expand `clawdbot security audit` checks (model hygiene, config includes, plugin allowlists, exposure matrix) and extend `--fix` to tighten more sensitive state paths.
- Security: add `SECURITY.md` reporting policy.
- Channels: add Matrix plugin (external) with docs + onboarding hooks.
- 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`.
- Sandbox: drop legacy `memory` tool-policy shorthand; require explicit `group:memory`.
### 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.
- TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`.
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
#### Agents / Auth / Tools / Sandbox
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer.
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
#### macOS / Apps
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
- macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)
- macOS: prefer the default bridge tunnel port in remote mode for node bridge connectivity; document macOS remote control + bridge tunnels. (#960, fixes #865) — thanks @kkarimi.
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
- Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.
- Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.
- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4.
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.
- WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.
- WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.
## 2026.1.13
### Fixes
- Postinstall: treat already-applied pnpm patches as no-ops to avoid npm/bun install failures.
- Packaging: pin `@mariozechner/pi-ai` to 0.45.7 and refresh patched dependency to match npm resolution.
- Telegram: tolerate mocked bots missing native-command APIs (`setMyCommands`, `command`) during tests.
- Auto-reply: fix streaming block reply media handling (no redeclared/use-before-declare vars).
## 2026.1.12-2
### Changes
- Subagents: add config to set default sub-agent model (`agents.defaults.subagents.model` + per-agent override); still overridden by `sessions_spawn.model`.
- Plugins: restore full voice-call plugin parity (Telnyx/Twilio, streaming, inbound policies, tools/CLI).
- Sandbox: support tool-policy groups in `tools.sandbox.tools` (e.g. `group:memory`, `group:fs`) to reduce config churn.
### 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.
- Models/MiniMax: strip malformed tool invocation XML (`<invoke>...</invoke>` and `</minimax:tool_call>`) from assistant text to prevent tool call leaks into user messages. (#809 — thanks @latitudeki5223)
- Tools/Models: MiniMax vision now uses the Coding Plan VLM endpoint (`/v1/coding_plan/vlm`) so the `image` tool works with MiniMax keys (also accepts `@/path/to/file.png`-style inputs).
- Gateway/macOS: reduce noisy loopback WS "closed before connect" logs during tests.
- Auto-reply: resolve ambiguous `/model` fuzzy matches by picking the best candidate instead of erroring.
## 2026.1.12-1
### Fixes
- Packaging: include `dist/channels/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/channels/registry.js`).
### Changes
- Heartbeat: raise default `ackMaxChars` to 300 so any `HEARTBEAT_OK` replies with short padding stay internal (fewer noisy heartbeat posts on providers).
- Onboarding: normalize API key inputs (strip `export KEY=...` wrappers) so shell-style entries paste cleanly.
## 2026.1.12
## 2026.1.12 (Unreleased)
### Highlights
- **BREAKING:** rename chat “providers” (Slack/Telegram/WhatsApp/…) to **channels** across CLI/RPC/config; legacy config keys auto-migrate on load (and are written back as `channels.*`).
- Memory: add vector search for agent memories (Markdown-only) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback.
- Plugins: restore full voice-call plugin parity (Telnyx/Twilio, streaming, inbound policies, tools/CLI).
- Models: add Synthetic provider plus Moonshot Kimi K2 0905 + turbo/thinking variants (with docs). (#811) — thanks @siraht; (#818) — thanks @mickahouan.
- Cron: one-shot schedules accept ISO timestamps (UTC) with optional delete-after-run; cron jobs can target a specific agent (CLI + macOS/Control UI).
- Agents: add compaction mode config with optional safeguard summarization and per-agent model fallbacks. (#700) — thanks @thewilloftheshadow; (#583) — thanks @mitschabaude-bot.
- Memory: add vector search for agent memories (Markdown-only scope) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback.
### New & Improved
- Memory: add custom OpenAI-compatible embedding endpoints; support OpenAI/local `node-llama-cpp` embeddings with per-agent overrides and provider metadata in tools/CLI. (#819) — thanks @mukhtharcm.
- Memory: new `clawdbot memory` CLI plus `memory_search`/`memory_get` tools with snippets + line ranges; index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default.
- Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config.
- Tools/Sandbox: add tool profiles + group shorthands; support tool-policy groups in `tools.sandbox.tools`; drop legacy `memory` shorthand; allow Docker bind mounts via `docker.binds`. (#790) — thanks @akonyer.
- Tools: add provider/model-specific tool policy overrides (`tools.byProvider`) to trim tool exposure per provider.
- Tools: add browser `scrollintoview` action; allow Claude/Gemini tool param aliases; allow thinking `xhigh` for GPT-5.2/Codex with safe downgrades. (#793) — thanks @hsrvc; (#444) — thanks @grp06.
- Gateway/CLI: add Tailscale binary discovery, custom bind mode, and probe auth retry; add `clawdbot dashboard` auto-open flow; default native slash commands to `"auto"` with per-provider overrides. (#740) — thanks @jeffersonwarrior.
- Auth/Onboarding: add Chutes OAuth (PKCE + refresh + onboarding choice); normalize API key inputs; default TUI onboarding to `deliver: false`. (#726) — thanks @FrieSei; (#791) — thanks @roshanasingh4.
- Providers: add `discord.allowBots`; trim legacy MiniMax M2 from default catalogs; route MiniMax vision to the Coding Plan VLM endpoint (also accepts `@/path/to/file.png` inputs). (#802) — thanks @zknicker.
- Gateway: allow Tailscale Serve identity headers to satisfy token auth; rebuild Control UI assets when protocol schema is newer. (#823) — thanks @roshanasingh4; (#786) — thanks @meaningfool.
- Heartbeat: default `ackMaxChars` to 300 so short `HEARTBEAT_OK` replies stay internal.
### Installer
- Install: run `clawdbot doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected.
### Changes
- Agents: strengthen memory recall guidance (memory_search mandatory for past work/preferences; system prompt injects conditional recall section; memory_get now described as safe snippet fetch).
- Browser: add `scrollintoview` action to scroll refs into view before click/type.
- Memory: embedding providers support OpenAI or local `node-llama-cpp`; config adds defaults + per-agent overrides, provider/fallback metadata surfaced in tools/CLI.
- CLI/Tools: new `clawdbot memory` commands plus `memory_search`/`memory_get` tools returning snippets + line ranges and provider info.
- Runtime: memory index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default; inline status replies now stay auth-gated while inline prompts continue to the agent.
- Discord: add `discord.allowBots` to permit bot-authored messages (still ignores its own messages) with docs warning about bot loops. (#802) — thanks @zknicker.
- CLI/Onboarding: `clawdbot dashboard` prints/copies the tokenized Control UI link and opens it; onboarding now auto-opens the dashboard with your token and keeps the link in the summary.
- Commands: native slash commands now default to `"auto"` (on for Discord/Telegram, off for Slack) with per-provider overrides (`discord/telegram/slack.commands.native`) and docs updated.
- Sandbox: allow Docker bind mounts via `docker.binds`; merges global + per-agent binds (per-agent ignored under shared scope) for custom host paths. (#790 — thanks @akonyer)
### Fixes
- Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds.
- Tools: apply global tool allow/deny even when agent-specific tool policy is set.
- Models/Providers: treat credential validation failures as auth errors to trigger fallback; normalize `${ENV_VAR}` apiKey values and auto-fill missing provider keys; preserve explicit GitHub Copilot provider config + agent-dir auth profiles. (#822) — thanks @sebslight; (#705) — thanks @TAGOOZ.
- Auth: drop invalid auth profiles from ordering so environment keys can still be used for providers like MiniMax.
- Gemini: normalize Gemini 3 ids to preview variants; strip Gemini CLI tool call/response ids; downgrade missing `thought_signature`; strip Claude `msg_*` thought_signature fields to avoid base64 decode errors. (#795) — thanks @thewilloftheshadow; (#783) — thanks @ananth-vardhan-cn; (#793) — thanks @hsrvc; (#805) — thanks @marcmarg.
- Agents: auto-recover from compaction context overflow by resetting the session and retrying; propagate overflow details from embedded runs so callers can recover.
- MiniMax: strip malformed tool invocation XML; include `MiniMax-VL-01` in implicit provider for image pairing. (#809) — thanks @latitudeki5223.
- Onboarding/Auth: honor `CLAWDBOT_AGENT_DIR` / `PI_CODING_AGENT_DIR` when writing auth profiles (MiniMax). (#829) — thanks @roshanasingh4.
- Anthropic: handle `overloaded_error` with a friendly message and failover classification. (#832) — thanks @danielz1z.
- Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid incorrect role errors. (#804) — thanks @ThomsenDrake.
- Messaging: enforce context isolation for message tool sends; keep typing indicators alive during tool execution. (#793) — thanks @hsrvc; (#450, #447) — thanks @thewilloftheshadow.
- Auto-reply: `/status` allowlist behavior, reasoning-tag enforcement on fallback, and system-event enqueueing for elevated/reasoning toggles. (#810) — thanks @mcinteerj.
- System events: include local timestamps when events are injected into prompts. (#245) — thanks @thewilloftheshadow.
- Auto-reply: resolve ambiguous `/model` matches; fix streaming block reply media handling; keep >300 char heartbeat replies instead of dropping.
- Discord/Slack: centralize reply-thread planning; fix autoThread routing + add per-channel autoThread; avoid duplicate listeners; keep reasoning italics intact; allow clearing channel parents via message tool. (#800, #807) — thanks @davidguttman; (#744) — thanks @thewilloftheshadow.
- Telegram: preserve forum topic thread ids, persist polling offsets, respect account bindings in webhook mode, and show typing indicator in General topics. (#727, #739) — thanks @thewilloftheshadow; (#821) — thanks @gumadeiras; (#779) — thanks @azade-c.
- Slack: accept slash commands with or without leading `/` for custom command configs. (#798) — thanks @thewilloftheshadow.
- Cron: persist disabled jobs correctly; accept `jobId` aliases for update/run/remove params. (#205, #252) — thanks @thewilloftheshadow.
- Gateway/CLI: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides; `agents.list` respects explicit config; reduce noisy loopback WS logs during tests; run `clawdbot doctor --non-interactive` during updates. (#781) — thanks @ronyrus.
- Onboarding/Control UI: refuse invalid configs (run doctor first); quote Windows browser URLs for OAuth; keep chat scroll position unless the user is near the bottom. (#764) — thanks @mukhtharcm; (#794) — thanks @roshanasingh4; (#217) — thanks @thewilloftheshadow.
- Tools/UI: harden tool input schemas for strict providers; drop null-only union variants for Gemini schema cleanup; treat `maxChars: 0` as unlimited; keep TUI last streamed response instead of "(no output)". (#782) — thanks @AbhisekBasu1; (#796) — thanks @gabriel-trigo; (#747) — thanks @thewilloftheshadow.
- Connections UI: polish multi-account account cards. (#816) — thanks @steipete.
### Maintenance
- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai.
- Testing: update Vitest + browser-playwright to 4.0.17.
- Docs: add Amazon Bedrock provider notes and link from models/FAQ.
- Auto-reply: inline `/status` now honors allowlists (authorized stripped + replied inline; unauthorized leaves text for the agent) to match command gating tests.
- Models: normalize `${ENV_VAR}` apiKey config values and auto-fill missing provider `apiKey` from env/auth when custom provider models are configured (fixes MiniMax “Unknown model” on fresh installs).
- Models/Tools: include `MiniMax-VL-01` in implicit MiniMax provider so image pairing uses a real vision model.
- Telegram: show typing indicator in General forum topics. (#779) — thanks @azade-c.
- Discord: keep reasoning italics intact when messages are chunked, so reasoning stays italic across multi-part sends.
- Models: keep explicit GitHub Copilot provider config and honor agent-dir auth profiles for auto-injection. (#705) — thanks @TAGOOZ.
- Auto-reply: restore 300-char heartbeat ack limit and keep >300 char replies instead of dropping them; adjust long heartbeat test content accordingly.
- Gateway: `agents.list` now honors explicit `agents.list` config without pulling stray agents from disk; GitHub Copilot CLI auth path uses the updated provider build.
- Google: apply patched pi-ai `google-gemini-cli` function call handling (strips ids) after upgrading to pi-ai 0.43.0.
- Tools: allow Gemini/Claude-style aliases (`file_path`, `old_string`, `new_string`) in read/write/edit schemas while still enforcing required params at runtime to avoid validation loops.
- Auto-reply: elevated/reasoning toggles now enqueue system events so the model sees the mode change immediately.
- Tools: keep `image` available in sandbox and fail over when image models return empty output (fixes “(no text returned)”).
- Discord: add per-channel `autoThread` to auto-create threads for top-level messages. (#800) — thanks @davidguttman.
- Discord: fix autoThread routing so replies stay in the created thread and avoid reply references. (#807) — thanks @davidguttman.
- Onboarding: TUI defaults to `deliver: false` to avoid cross-provider auto-delivery leaks; onboarding spawns the TUI with explicit `deliver: false`. (#791 — thanks @roshanasingh4)
## 2026.1.11
@@ -260,7 +144,6 @@
- Auto-reply: align `/think` default display with model reasoning defaults. (#751) — thanks @gabriel-trigo.
- Auto-reply: flush block reply buffers on tool boundaries. (#750) — thanks @sebslight.
- Auto-reply: allow sender fallback for command authorization when `SenderId` is empty (WhatsApp self-chat). (#755) — thanks @juanpablodlc.
- Auto-reply: treat whitespace-only sender ids as missing for command authorization (WhatsApp self-chat). (#766) — thanks @steipete.
- Heartbeat: refresh prompt text for updated defaults.
- Agents/Tools: use PowerShell on Windows to capture system utility output. (#748) — thanks @myfunc.
- Docker: tolerate unset optional env vars in docker-setup.sh under strict mode. (#725) — thanks @petradonka.
@@ -429,7 +312,7 @@
- Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj.
- Build: Docker build cache layer (#605) — thanks @zknicker.
- Auth: enable OAuth token refresh for Claude Code CLI credentials (`anthropic:claude-cli`) with bidirectional sync back to Claude Code storage (file on Linux/Windows, Keychain on macOS). This allows long-running agents to operate autonomously without manual re-authentication (#654 — thanks @radek-paclt).
- 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
+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)
- **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
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
Submodule Peekaboo updated: 5c195f5e46...c1243a7978.
+58 -76
View File
@@ -16,13 +16,13 @@
</p>
**Clawdbot** is a *personal AI assistant* you run on your own devices.
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
[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.
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**.
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
clawdbot onboard --install-daemon
@@ -64,7 +64,7 @@ clawdbot gateway --port 18789 --verbose
# Send a message
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord/Microsoft Teams)
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
clawdbot agent --message "Ship checklist" --thinking high
```
@@ -96,28 +96,24 @@ Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted i
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
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.
- Approve with: `clawdbot pairing approve <channel> <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`).
Default behavior on Telegram/WhatsApp/Signal/iMessage/Discord/Slack:
- **DM pairing** (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
- Approve with: `clawdbot pairing approve <provider> <code>` (then the sender is added to a local allowlist store).
- 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.
## Highlights
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — 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).
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, providers, tools, and events.
- **[Multi-provider inbox](https://docs.clawd.bot/providers)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
- **[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.
- **[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.
- **[Companion apps](https://docs.clawd.bot/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes).
- **[Onboarding](https://docs.clawd.bot/start/wizard) + [skills](https://docs.clawd.bot/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=clawdbot/clawdbot&type=date&legend=top-left)](https://www.star-history.com/#clawdbot/clawdbot&type=date&legend=top-left)
## Everything we built so far
### Core platform
@@ -127,9 +123,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).
- [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
- [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).
- [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).
### Providers
- [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), [WebChat](https://docs.clawd.bot/web/webchat).
- [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
- [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 +141,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.
### 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).
- [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
- [Control UI](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/web/webchat) served directly from the Gateway.
@@ -159,7 +155,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## How it works (short)
```
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Microsoft Teams / WebChat
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
│
▼
┌───────────────────────────────┐
@@ -204,7 +200,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.
- **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`.
In short: exec runs where the Gateway lives; device actions run where the device lives.
@@ -242,12 +238,12 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc
## Chat commands
Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands are owner-only):
Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):
- `/status` — compact session status (model + tokens, cost when available)
- `/new` or `/reset` — reset the session
- `/compact` — compact session context (summary)
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
- `/think <level>` — off|minimal|low|medium|high
- `/verbose on|off`
- `/cost on|off` — append per-response token/cost usage lines
- `/restart` — restart the gateway (owner-only in groups)
@@ -315,59 +311,50 @@ 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)
### [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`).
- Allowlist who can talk to the assistant via `channels.whatsapp.allowFrom`.
- If `channels.whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
- Link the device: `pnpm clawdbot providers login` (stores creds in `~/.clawdbot/credentials`).
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
- If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
### [Telegram](https://docs.clawd.bot/channels/telegram)
### [Telegram](https://docs.clawd.bot/providers/telegram)
- Set `TELEGRAM_BOT_TOKEN` or `channels.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.
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `telegram.allowFrom` or `telegram.webhookUrl` as needed.
```json5
{
channels: {
telegram: {
botToken: "123456:ABCDEF"
}
telegram: {
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).
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.dm.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
```json5
{
channels: {
discord: {
token: "1234abcd"
}
discord: {
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.
- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
### [Microsoft Teams](https://docs.clawd.bot/channels/msteams)
- 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"`.
- If `imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
### [WebChat](https://docs.clawd.bot/web/webchat)
@@ -399,7 +386,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)
- [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)
- [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)
## Advanced docs (discovery + control)
@@ -468,29 +455,24 @@ AI/vibe-coded PRs welcome! 🤖
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
Core contributors:
- @cpojer — Telegram onboarding UX + docs
Thanks to all clawtributors:
<p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a>
<a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a>
<a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a>
<a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/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=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>
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a>
<a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/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/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/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a>
<a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/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/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/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/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/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a>
<a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="Sebastian Barrios" title="Sebastian Barrios"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
<a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/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/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/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=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/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=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a>
<a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a>
<a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=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/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a>
<a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
<a href="https://github.com/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>
</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>] = []
for wordRange in wordRanges {
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
} else {
ranges.append(wordRange)
@@ -13,7 +13,8 @@ public actor TranscriptsStore {
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
fileURL = dir.appendingPathComponent("transcripts.log")
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)
}
}
@@ -24,7 +24,8 @@ public struct WakeWordGateConfig: Sendable, Equatable {
public init(
triggers: [String],
minPostTriggerGap: TimeInterval = 0.45,
minCommandLength: Int = 1) {
minCommandLength: Int = 1)
{
self.triggers = triggers
self.minPostTriggerGap = minPostTriggerGap
self.minCommandLength = minCommandLength
@@ -56,12 +57,6 @@ public enum WakeWordGate {
let tokens: [String]
}
private struct MatchCandidate {
let index: Int
let triggerEnd: TimeInterval
let gap: TimeInterval
}
public static func match(
transcript: String,
segments: [WakeWordSegment],
@@ -73,7 +68,7 @@ public enum WakeWordGate {
let tokens = normalizeSegments(segments)
guard !tokens.isEmpty else { return nil }
var best: MatchCandidate?
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
for trigger in triggerTokens {
let count = trigger.tokens.count
@@ -89,7 +84,7 @@ public enum WakeWordGate {
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: [
descriptor(for: ServiceInstall.self),
descriptor(for: ServiceUninstall.self),
descriptor(for: ServiceStatus.self)
descriptor(for: ServiceStatus.self),
])
let doctorDesc = descriptor(for: DoctorCommand.self)
let setupDesc = descriptor(for: SetupCommand.self)
@@ -54,7 +54,7 @@ enum CLIRegistry {
startDesc,
stopDesc,
restartDesc,
statusDesc
statusDesc,
])
return [root]
}
@@ -25,7 +25,7 @@ private enum LaunchdHelper {
"Label": label,
"ProgramArguments": [executable, "serve"],
"RunAtLoad": true,
"KeepAlive": true
"KeepAlive": true,
]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: plistURL)
+67 -112
View File
@@ -25,123 +25,78 @@ private func dispatch(invocation: CommandInvocation) async throws {
switch first {
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:
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, *) {
let exitCode = await runCLI()
exit(exitCode)
+33 -188
View File
@@ -2,194 +2,6 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<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>
<title>2026.1.11-3</title>
<pubDate>Mon, 12 Jan 2026 10:40:23 +0000</pubDate>
@@ -207,5 +19,38 @@
]]></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=="/>
</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>
</rss>
@@ -27,7 +27,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
val isForeground: StateFlow<Boolean> = runtime.isForeground
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
val mainSessionKey: StateFlow<String> = runtime.mainSessionKey
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
@@ -139,7 +138,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
}
fun loadChat(sessionKey: String) {
fun loadChat(sessionKey: String = "main") {
runtime.loadChat(sessionKey)
}
@@ -78,7 +78,7 @@ class NodeRuntime(context: Context) {
payloadJson =
buildJsonObject {
put("message", JsonPrimitive(command))
put("sessionKey", JsonPrimitive(resolveMainSessionKey()))
put("sessionKey", JsonPrimitive(mainSessionKey.value))
put("thinking", JsonPrimitive(chatThinkingLevel.value))
put("deliver", JsonPrimitive(false))
}.toString(),
@@ -142,13 +142,12 @@ class NodeRuntime(context: Context) {
private val session =
BridgeSession(
scope = scope,
onConnected = { name, remote, mainSessionKey ->
onConnected = { name, remote ->
_statusText.value = "Connected"
_serverName.value = name
_remoteAddress.value = remote
_isConnected.value = true
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
applyMainSessionKey(mainSessionKey)
scope.launch { refreshBrandingFromGateway() }
scope.launch { refreshWakeWordsFromGateway() }
maybeNavigateToA2uiOnConnect()
@@ -173,31 +172,11 @@ class NodeRuntime(context: Context) {
_remoteAddress.value = null
_isConnected.value = false
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
_mainSessionKey.value = "main"
}
val mainKey = resolveMainSessionKey()
talkMode.setMainSessionKey(mainKey)
chat.applyMainSessionKey(mainKey)
_mainSessionKey.value = "main"
chat.onDisconnected(message)
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() {
val a2uiUrl = resolveA2uiHostUrl() ?: return
val current = canvas.currentUrl()?.trim().orEmpty()
@@ -580,7 +559,7 @@ class NodeRuntime(context: Context) {
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
val sessionKey = resolveMainSessionKey()
val sessionKey = "main"
val message =
ClawdbotCanvasA2UIAction.formatAgentMessage(
actionName = name,
@@ -628,9 +607,8 @@ class NodeRuntime(context: Context) {
}
}
fun loadChat(sessionKey: String) {
val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() }
chat.load(key)
fun loadChat(sessionKey: String = "main") {
chat.load(sessionKey)
}
fun refreshChat() {
@@ -723,7 +701,7 @@ class NodeRuntime(context: Context) {
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
val sessionCfg = config?.get("session").asObjectOrNull()
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
applyMainSessionKey(mainKey)
_mainSessionKey.value = mainKey
val parsed = parseHexColorArgb(raw)
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
@@ -4,10 +4,3 @@ internal fun normalizeMainKey(raw: String?): String {
val trimmed = raw?.trim()
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
}
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
val trimmed = raw?.trim().orEmpty()
if (trimmed.isEmpty()) return false
if (trimmed == "global") return true
return trimmed.startsWith("agent:")
}
@@ -31,7 +31,7 @@ import java.util.concurrent.ConcurrentHashMap
class BridgeSession(
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 onEvent: (event: String, payloadJson: String?) -> Unit,
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
@@ -64,7 +64,6 @@ class BridgeSession(
private val writeLock = Mutex()
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
@Volatile private var canvasHostUrl: String? = null
@Volatile private var mainSessionKey: String? = null
private var desired: Pair<BridgeEndpoint, Hello>? = null
private var job: Job? = null
@@ -91,13 +90,11 @@ class BridgeSession(
job?.cancelAndJoin()
job = null
canvasHostUrl = null
mainSessionKey = null
onDisconnected("Offline")
}
}
fun currentCanvasHostUrl(): String? = canvasHostUrl
fun currentMainSessionKey(): String? = mainSessionKey
suspend fun sendEvent(event: String, payloadJson: String?) {
val conn = currentConnection ?: return
@@ -215,9 +212,7 @@ class BridgeSession(
"hello-ok" -> {
val name = first["serverName"].asStringOrNull() ?: "Bridge"
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null }
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
mainSessionKey = rawMainSessionKey
if (BuildConfig.DEBUG) {
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
runCatching {
@@ -227,7 +222,7 @@ class BridgeSession(
)
}
}
onConnected(name, conn.remoteAddress, rawMainSessionKey)
onConnected(name, conn.remoteAddress)
}
"error" -> {
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
@@ -71,21 +71,12 @@ class ChatController(
_sessionId.value = null
}
fun load(sessionKey: String) {
fun load(sessionKey: String = "main") {
val key = sessionKey.trim().ifEmpty { "main" }
_sessionKey.value = key
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() {
scope.launch { bootstrap(forceHealth = true) }
}
@@ -44,7 +44,6 @@ import com.clawdbot.android.chat.ChatSessionEntry
fun ChatComposer(
sessionKey: String,
sessions: List<ChatSessionEntry>,
mainSessionKey: String,
healthOk: Boolean,
thinkingLevel: String,
pendingRunCount: Int,
@@ -62,7 +61,7 @@ fun ChatComposer(
var showThinkingMenu by remember { mutableStateOf(false) }
var showSessionMenu by remember { mutableStateOf(false) }
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
val sessionOptions = resolveSessionChoices(sessionKey, sessions)
val currentSessionLabel =
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
@@ -33,14 +33,13 @@ fun ChatSheetContent(viewModel: MainViewModel) {
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
val healthOk by viewModel.chatHealthOk.collectAsState()
val sessionKey by viewModel.chatSessionKey.collectAsState()
val mainSessionKey by viewModel.mainSessionKey.collectAsState()
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val sessions by viewModel.chatSessions.collectAsState()
LaunchedEffect(mainSessionKey) {
viewModel.loadChat(mainSessionKey)
LaunchedEffect(Unit) {
viewModel.loadChat("main")
viewModel.refreshChatSessions(limit = 200)
}
@@ -86,7 +85,6 @@ fun ChatSheetContent(viewModel: MainViewModel) {
ChatComposer(
sessionKey = sessionKey,
sessions = sessions,
mainSessionKey = mainSessionKey,
healthOk = healthOk,
thinkingLevel = thinkingLevel,
pendingRunCount = pendingRunCount,
@@ -2,23 +2,20 @@ package com.clawdbot.android.ui.chat
import com.clawdbot.android.chat.ChatSessionEntry
private const val MAIN_SESSION_KEY = "main"
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
fun resolveSessionChoices(
currentSessionKey: String,
sessions: List<ChatSessionEntry>,
mainSessionKey: String,
nowMs: Long = System.currentTimeMillis(),
): List<ChatSessionEntry> {
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it }
val aliasKey = if (mainKey == "main") null else "main"
val current = currentSessionKey.trim()
val cutoff = nowMs - RECENT_WINDOW_MS
val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L }
val recent = mutableListOf<ChatSessionEntry>()
val seen = mutableSetOf<String>()
for (entry in sorted) {
if (aliasKey != null && entry.key == aliasKey) continue
if (!seen.add(entry.key)) continue
if ((entry.updatedAtMs ?: 0L) < cutoff) continue
recent.add(entry)
@@ -26,13 +23,13 @@ fun resolveSessionChoices(
val result = mutableListOf<ChatSessionEntry>()
val included = mutableSetOf<String>()
val mainEntry = sorted.firstOrNull { it.key == mainKey }
val mainEntry = sorted.firstOrNull { it.key == MAIN_SESSION_KEY }
if (mainEntry != null) {
result.add(mainEntry)
included.add(mainKey)
} else if (current == mainKey) {
result.add(ChatSessionEntry(key = mainKey, updatedAtMs = null))
included.add(mainKey)
included.add(MAIN_SESSION_KEY)
} else if (current == MAIN_SESSION_KEY) {
result.add(ChatSessionEntry(key = MAIN_SESSION_KEY, updatedAtMs = null))
included.add(MAIN_SESSION_KEY)
}
for (entry in recent) {
@@ -21,7 +21,6 @@ import android.speech.tts.UtteranceProgressListener
import android.util.Log
import androidx.core.content.ContextCompat
import com.clawdbot.android.bridge.BridgeSession
import com.clawdbot.android.isCanonicalMainSessionKey
import com.clawdbot.android.normalizeMainKey
import java.net.HttpURLConnection
import java.net.URL
@@ -117,13 +116,6 @@ class TalkModeManager(
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) {
if (_isEnabled.value == enabled) return
_isEnabled.value = enabled
@@ -835,9 +827,7 @@ class TalkModeManager(
val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
if (!isCanonicalMainSessionKey(mainSessionKey)) {
mainSessionKey = mainKey
}
mainSessionKey = mainKey
defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
voiceAliases = aliases
if (!voiceOverrideActive) currentVoiceId = defaultVoiceId
@@ -30,7 +30,7 @@ class BridgeSessionTest {
val session =
BridgeSession(
scope = scope,
onConnected = { _, _, _ -> connected.complete(Unit) },
onConnected = { _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) },
@@ -97,7 +97,7 @@ class BridgeSessionTest {
val session =
BridgeSession(
scope = scope,
onConnected = { _, _, _ -> connected.complete(Unit) },
onConnected = { _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) },
@@ -167,7 +167,7 @@ class BridgeSessionTest {
val session =
BridgeSession(
scope = scope,
onConnected = { _, _, _ -> connected.complete(Unit) },
onConnected = { _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { throw IllegalStateException("FOO_BAR: boom") },
@@ -239,7 +239,7 @@ class BridgeSessionTest {
val session =
BridgeSession(
scope = scope,
onConnected = { _, _, _ -> connected.countDown() },
onConnected = { _, _ -> connected.countDown() },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) },
@@ -19,7 +19,7 @@ class SessionFiltersTest {
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)
}
@@ -29,7 +29,7 @@ class SessionFiltersTest {
val recent = now - 10 * 60 * 1000L
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)
}
}
+2 -10
View File
@@ -26,7 +26,6 @@ actor BridgeSession {
private(set) var state: State = .idle
private var canvasHostUrl: String?
private var mainSessionKey: String?
func currentCanvasHostUrl() -> String? {
self.canvasHostUrl
@@ -69,7 +68,7 @@ actor BridgeSession {
func connect(
endpoint: NWEndpoint,
hello: BridgeHello,
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
onConnected: (@Sendable (String) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
async throws
{
@@ -108,9 +107,7 @@ actor BridgeSession {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName)
self.canvasHostUrl = ok.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines)
let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
self.mainSessionKey = (mainKey?.isEmpty == false) ? mainKey : nil
await onConnected?(ok.serverName, self.mainSessionKey)
await onConnected?(ok.serverName)
} else if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
self.state = .failed(message: "\(err.code): \(err.message)")
@@ -220,7 +217,6 @@ actor BridgeSession {
self.queue = nil
self.buffer = Data()
self.canvasHostUrl = nil
self.mainSessionKey = nil
let pending = self.pendingRPC.values
self.pendingRPC.removeAll()
@@ -238,10 +234,6 @@ actor BridgeSession {
self.state = .idle
}
func currentMainSessionKey() -> String? {
self.mainSessionKey
}
private func beginRPC(
id: String,
request: BridgeRPCRequest,
+1 -1
View File
@@ -6,7 +6,7 @@ struct ChatSheet: View {
@State private var viewModel: ClawdbotChatViewModel
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)
self._viewModel = State(
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 instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
let contextJSON = ClawdbotCanvasA2UIAction.compactJSON(userAction["context"])
let sessionKey = self.mainSessionKey
let sessionKey = "main"
let messageContext = ClawdbotCanvasA2UIAction.AgentMessageContext(
actionName: name,
@@ -232,15 +232,12 @@ final class NodeAppModel {
try await self.bridge.connect(
endpoint: endpoint,
hello: hello,
onConnected: { [weak self] serverName, mainSessionKey in
onConnected: { [weak self] serverName in
guard let self else { return }
await MainActor.run {
self.bridgeStatusText = "Connected"
self.bridgeServerName = serverName
}
await MainActor.run {
self.applyMainSessionKey(mainSessionKey)
}
if let addr = await self.bridge.currentRemoteAddress() {
await MainActor.run {
self.bridgeRemoteAddress = addr
@@ -289,10 +286,7 @@ final class NodeAppModel {
self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil
self.seamColorHex = nil
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = "main"
self.talkMode.updateMainSessionKey(self.mainSessionKey)
}
self.mainSessionKey = "main"
self.showLocalCanvasOnDisconnect()
}
}
@@ -309,23 +303,10 @@ final class NodeAppModel {
self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil
self.seamColorHex = nil
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = "main"
self.talkMode.updateMainSessionKey(self.mainSessionKey)
}
self.mainSessionKey = "main"
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 {
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
}
@@ -354,10 +335,7 @@ final class NodeAppModel {
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
await MainActor.run {
self.seamColorHex = raw.isEmpty ? nil : raw
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = mainKey
self.talkMode.updateMainSessionKey(mainKey)
}
self.mainSessionKey = mainKey
}
} catch {
// ignore
-7
View File
@@ -5,11 +5,4 @@ enum SessionKey {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "main" : trimmed
}
static func isCanonicalMainSessionKey(_ value: String?) -> Bool {
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return false }
if trimmed == "global" { return true }
return trimmed.hasPrefix("agent:")
}
}
+1 -11
View File
@@ -53,13 +53,6 @@ final class TalkModeManager: NSObject {
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) {
self.isEnabled = enabled
if enabled {
@@ -656,10 +649,7 @@ final class TalkModeManager: NSObject {
guard let config = json["config"] as? [String: Any] else { return }
let talk = config["talk"] as? [String: Any]
let session = config["session"] as? [String: Any]
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = mainKey
}
self.mainSessionKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let aliases = talk?["voiceAliases"] as? [String: Any] {
var resolved: [String: String] = [:]
@@ -282,12 +282,7 @@ actor BridgeConnectionHandler {
do {
try await self.send(BridgePairOk(type: "pair-ok", token: token))
self.isAuthenticated = true
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
try await self.send(
BridgeHelloOk(
type: "hello-ok",
serverName: serverName,
mainSessionKey: mainSessionKey))
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
} catch {
self.logger.error("bridge send pair-ok failed: \(error.localizedDescription, privacy: .public)")
}
@@ -303,12 +298,7 @@ actor BridgeConnectionHandler {
case .ok:
self.isAuthenticated = true
do {
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
try await self.send(
BridgeHelloOk(
type: "hello-ok",
serverName: serverName,
mainSessionKey: mainSessionKey))
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
} catch {
self.logger.error("bridge send hello-ok failed: \(error.localizedDescription, privacy: .public)")
}
@@ -187,7 +187,7 @@ actor BridgeServer {
thinking: "low",
deliver: false,
to: nil,
channel: .last))
provider: .last))
case "agent.request":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
@@ -205,7 +205,7 @@ actor BridgeServer {
?? "node-\(nodeId)"
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let channel = GatewayAgentChannel(raw: link.channel)
let provider = GatewayAgentProvider(raw: link.channel)
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: message,
@@ -213,7 +213,7 @@ actor BridgeServer {
thinking: thinking,
deliver: link.deliver,
to: to,
channel: channel))
provider: provider))
default:
break
@@ -86,7 +86,7 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
thinking: "low",
deliver: false,
to: nil,
channel: .last,
provider: .last,
idempotencyKey: actionId))
await MainActor.run {
+1 -10
View File
@@ -19,7 +19,6 @@ enum ConfigStore {
}
private static let overrideStore = OverrideStore()
@MainActor private static var lastHash: String?
private static func isRemoteMode() async -> Bool {
let overrides = await self.overrideStore.overrides
@@ -76,7 +75,6 @@ enum ConfigStore {
method: .configGet,
params: nil,
timeoutMs: 8000)
self.lastHash = snap.hash
return snap.config?.mapValues { $0.foundationValue } ?? [:]
} catch {
return nil
@@ -85,24 +83,17 @@ enum ConfigStore {
@MainActor
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])
guard let raw = String(data: data, encoding: .utf8) else {
throw NSError(domain: "ConfigStore", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode config.",
])
}
var params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
if let baseHash = self.lastHash {
params["baseHash"] = AnyCodable(baseHash)
}
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
_ = try await GatewayConnection.shared.requestRaw(
method: .configSet,
params: params,
timeoutMs: 10000)
_ = await self.loadFromGateway()
}
#if DEBUG
@@ -11,9 +11,9 @@ extension ConnectionsSettings {
}
@ViewBuilder
func channelHeaderActions(_ channel: ConnectionChannel) -> some View {
func providerHeaderActions(_ provider: ConnectionProvider) -> some View {
HStack(spacing: 8) {
if channel == .whatsapp {
if provider == .whatsapp {
Button("Logout") {
Task { await self.store.logoutWhatsApp() }
}
@@ -21,7 +21,7 @@ extension ConnectionsSettings {
.disabled(self.store.whatsappBusy)
}
if channel == .telegram {
if provider == .telegram {
Button("Logout") {
Task { await self.store.logoutTelegram() }
}
@@ -1,15 +1,15 @@
import SwiftUI
extension ConnectionsSettings {
private func channelStatus<T: Decodable>(
private func providerStatus<T: Decodable>(
_ id: String,
as type: T.Type) -> T?
{
self.store.snapshot?.decodeChannel(id, as: type)
self.store.snapshot?.decodeProvider(id, as: type)
}
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 }
if !status.configured { return .secondary }
if !status.linked { return .red }
@@ -20,7 +20,7 @@ extension ConnectionsSettings {
}
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 }
if !status.configured { return .secondary }
if status.lastError != nil { return .orange }
@@ -30,7 +30,7 @@ extension ConnectionsSettings {
}
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 }
if !status.configured { return .secondary }
if status.lastError != nil { return .orange }
@@ -40,7 +40,7 @@ extension ConnectionsSettings {
}
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 }
if !status.configured { return .secondary }
if status.lastError != nil { return .orange }
@@ -50,7 +50,7 @@ extension ConnectionsSettings {
}
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 }
if !status.configured { return .secondary }
if status.lastError != nil { return .orange }
@@ -60,7 +60,7 @@ extension ConnectionsSettings {
}
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…" }
if !status.linked { return "Not linked" }
if status.connected { return "Connected" }
@@ -69,7 +69,7 @@ extension ConnectionsSettings {
}
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…" }
if !status.configured { return "Not configured" }
if status.running { return "Running" }
@@ -77,7 +77,7 @@ extension ConnectionsSettings {
}
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…" }
if !status.configured { return "Not configured" }
if status.running { return "Running" }
@@ -85,7 +85,7 @@ extension ConnectionsSettings {
}
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…" }
if !status.configured { return "Not configured" }
if status.running { return "Running" }
@@ -93,7 +93,7 @@ extension ConnectionsSettings {
}
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…" }
if !status.configured { return "Not configured" }
if status.running { return "Running" }
@@ -101,7 +101,7 @@ extension ConnectionsSettings {
}
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 }
var lines: [String] = []
if let e164 = status.`self`?.e164 ?? status.`self`?.jid {
@@ -132,7 +132,7 @@ extension ConnectionsSettings {
}
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 }
var lines: [String] = []
if let source = status.tokenSource {
@@ -164,7 +164,7 @@ extension ConnectionsSettings {
}
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 }
var lines: [String] = []
if let source = status.tokenSource {
@@ -193,7 +193,7 @@ extension ConnectionsSettings {
}
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 }
var lines: [String] = []
lines.append("Base URL: \(status.baseUrl)")
@@ -220,7 +220,7 @@ extension ConnectionsSettings {
}
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 }
var lines: [String] = []
if let cliPath = status.cliPath, !cliPath.isEmpty {
@@ -243,68 +243,68 @@ extension ConnectionsSettings {
}
var isTelegramTokenLocked: Bool {
self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?.tokenSource == "env"
self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)?.tokenSource == "env"
}
var isDiscordTokenLocked: Bool {
self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?.tokenSource == "env"
self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)?.tokenSource == "env"
}
var orderedChannels: [ConnectionChannel] {
ConnectionChannel.allCases.sorted { lhs, rhs in
let lhsEnabled = self.channelEnabled(lhs)
let rhsEnabled = self.channelEnabled(rhs)
var orderedProviders: [ConnectionProvider] {
ConnectionProvider.allCases.sorted { lhs, rhs in
let lhsEnabled = self.providerEnabled(lhs)
let rhsEnabled = self.providerEnabled(rhs)
if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled }
return lhs.sortOrder < rhs.sortOrder
}
}
var enabledChannels: [ConnectionChannel] {
self.orderedChannels.filter { self.channelEnabled($0) }
var enabledProviders: [ConnectionProvider] {
self.orderedProviders.filter { self.providerEnabled($0) }
}
var availableChannels: [ConnectionChannel] {
self.orderedChannels.filter { !self.channelEnabled($0) }
var availableProviders: [ConnectionProvider] {
self.orderedProviders.filter { !self.providerEnabled($0) }
}
func ensureSelection() {
guard let selected = self.selectedChannel else {
self.selectedChannel = self.orderedChannels.first
guard let selected = self.selectedProvider else {
self.selectedProvider = self.orderedProviders.first
return
}
if !self.orderedChannels.contains(selected) {
self.selectedChannel = self.orderedChannels.first
if !self.orderedProviders.contains(selected) {
self.selectedProvider = self.orderedProviders.first
}
}
func channelEnabled(_ channel: ConnectionChannel) -> Bool {
switch channel {
func providerEnabled(_ provider: ConnectionProvider) -> Bool {
switch provider {
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 }
return status.configured || status.linked || status.running
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 }
return status.configured || status.running
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 }
return status.configured || status.running
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 }
return status.configured || status.running
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 }
return status.configured || status.running
}
}
@ViewBuilder
func channelSection(_ channel: ConnectionChannel) -> some View {
switch channel {
func providerSection(_ provider: ConnectionProvider) -> some View {
switch provider {
case .whatsapp:
self.whatsAppSection
case .telegram:
@@ -318,8 +318,8 @@ extension ConnectionsSettings {
}
}
func channelTint(_ channel: ConnectionChannel) -> Color {
switch channel {
func providerTint(_ provider: ConnectionProvider) -> Color {
switch provider {
case .whatsapp:
self.whatsAppTint
case .telegram:
@@ -333,8 +333,8 @@ extension ConnectionsSettings {
}
}
func channelSummary(_ channel: ConnectionChannel) -> String {
switch channel {
func providerSummary(_ provider: ConnectionProvider) -> String {
switch provider {
case .whatsapp:
self.whatsAppSummary
case .telegram:
@@ -348,8 +348,8 @@ extension ConnectionsSettings {
}
}
func channelDetails(_ channel: ConnectionChannel) -> String? {
switch channel {
func providerDetails(_ provider: ConnectionProvider) -> String? {
switch provider {
case .whatsapp:
self.whatsAppDetails
case .telegram:
@@ -363,55 +363,55 @@ extension ConnectionsSettings {
}
}
func channelLastCheckText(_ channel: ConnectionChannel) -> String {
guard let date = self.channelLastCheck(channel) else { return "never" }
func providerLastCheckText(_ provider: ConnectionProvider) -> String {
guard let date = self.providerLastCheck(provider) else { return "never" }
return relativeAge(from: date)
}
func channelLastCheck(_ channel: ConnectionChannel) -> Date? {
switch channel {
func providerLastCheck(_ provider: ConnectionProvider) -> Date? {
switch provider {
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 }
return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt)
case .telegram:
return self
.date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
.date(fromMs: self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)?
.lastProbeAt)
case .discord:
return self
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.date(fromMs: self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)?
.lastProbeAt)
case .signal:
return self
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
.date(fromMs: self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)?.lastProbeAt)
case .imessage:
return self
.date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)?
.date(fromMs: self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)?
.lastProbeAt)
}
}
func channelHasError(_ channel: ConnectionChannel) -> Bool {
switch channel {
func providerHasError(_ provider: ConnectionProvider) -> Bool {
switch provider {
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 }
return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true
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 }
return status.lastError?.isEmpty == false || status.probe?.ok == false
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 }
return status.lastError?.isEmpty == false || status.probe?.ok == false
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 }
return status.lastError?.isEmpty == false || status.probe?.ok == false
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 }
return status.lastError?.isEmpty == false || status.probe?.ok == false
}
@@ -11,7 +11,7 @@ extension ConnectionsSettings {
self.store.start()
self.ensureSelection()
}
.onChange(of: self.orderedChannels) { _, _ in
.onChange(of: self.orderedProviders) { _, _ in
self.ensureSelection()
}
.onDisappear { self.store.stop() }
@@ -20,17 +20,17 @@ extension ConnectionsSettings {
private var sidebar: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
if !self.enabledChannels.isEmpty {
if !self.enabledProviders.isEmpty {
self.sidebarSectionHeader("Configured")
ForEach(self.enabledChannels) { channel in
self.sidebarRow(channel)
ForEach(self.enabledProviders) { provider in
self.sidebarRow(provider)
}
}
if !self.availableChannels.isEmpty {
if !self.availableProviders.isEmpty {
self.sidebarSectionHeader("Available")
ForEach(self.availableChannels) { channel in
self.sidebarRow(channel)
ForEach(self.availableProviders) { provider in
self.sidebarRow(provider)
}
}
}
@@ -46,8 +46,8 @@ extension ConnectionsSettings {
private var detail: some View {
Group {
if let channel = self.selectedChannel {
self.channelDetail(channel)
if let provider = self.selectedProvider {
self.providerDetail(provider)
} else {
self.emptyDetail
}
@@ -59,7 +59,7 @@ extension ConnectionsSettings {
VStack(alignment: .leading, spacing: 8) {
Text("Connections")
.font(.title3.weight(.semibold))
Text("Select a channel to view status and settings.")
Text("Select a provider to view status and settings.")
.font(.callout)
.foregroundStyle(.secondary)
}
@@ -67,12 +67,12 @@ extension ConnectionsSettings {
.padding(.vertical, 18)
}
private func channelDetail(_ channel: ConnectionChannel) -> some View {
private func providerDetail(_ provider: ConnectionProvider) -> some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 16) {
self.detailHeader(for: channel)
self.detailHeader(for: provider)
Divider()
self.channelSection(channel)
self.providerSection(provider)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -81,18 +81,18 @@ extension ConnectionsSettings {
}
}
private func sidebarRow(_ channel: ConnectionChannel) -> some View {
let isSelected = self.selectedChannel == channel
private func sidebarRow(_ provider: ConnectionProvider) -> some View {
let isSelected = self.selectedProvider == provider
return Button {
self.selectedChannel = channel
self.selectedProvider = provider
} label: {
HStack(spacing: 8) {
Circle()
.fill(self.channelTint(channel))
.fill(self.providerTint(provider))
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
Text(channel.title)
Text(self.channelSummary(channel))
Text(provider.title)
Text(self.providerSummary(provider))
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -119,23 +119,23 @@ extension ConnectionsSettings {
.padding(.top, 2)
}
private func detailHeader(for channel: ConnectionChannel) -> some View {
private func detailHeader(for provider: ConnectionProvider) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Label(channel.detailTitle, systemImage: channel.systemImage)
Label(provider.detailTitle, systemImage: provider.systemImage)
.font(.title3.weight(.semibold))
self.statusBadge(
self.channelSummary(channel),
color: self.channelTint(channel))
self.providerSummary(provider),
color: self.providerTint(provider))
Spacer()
self.channelHeaderActions(channel)
self.providerHeaderActions(provider)
}
HStack(spacing: 10) {
Text("Last check \(self.channelLastCheckText(channel))")
Text("Last check \(self.providerLastCheckText(provider))")
.font(.caption)
.foregroundStyle(.secondary)
if self.channelHasError(channel) {
if self.providerHasError(provider) {
Text("Error")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
@@ -146,7 +146,7 @@ extension ConnectionsSettings {
}
}
if let details = self.channelDetails(channel) {
if let details = self.providerDetails(provider) {
Text(details)
.font(.caption)
.foregroundStyle(.secondary)
@@ -2,7 +2,7 @@ import AppKit
import SwiftUI
struct ConnectionsSettings: View {
enum ConnectionChannel: String, CaseIterable, Identifiable, Hashable {
enum ConnectionProvider: String, CaseIterable, Identifiable, Hashable {
case whatsapp
case telegram
case discord
@@ -53,7 +53,7 @@ struct ConnectionsSettings: View {
}
@Bindable var store: ConnectionsStore
@State var selectedChannel: ConnectionChannel?
@State var selectedProvider: ConnectionProvider?
@State var showTelegramToken = false
@State var showDiscordToken = false
@@ -2,16 +2,6 @@ import ClawdbotProtocol
import Foundation
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 {
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
@@ -22,7 +12,6 @@ extension ConnectionsStore {
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
: nil
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
self.configHash = snap.hash
self.configLoaded = true
self.applyUIConfig(snap)
@@ -45,23 +34,10 @@ extension ConnectionsStore {
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) {
let telegram = self.resolveChannelConfig(snap, key: "telegram")
let telegram = snap.config?["telegram"]?.dictionaryValue
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
let groups = telegram?["groups"]?.dictionaryValue
let defaultGroup = groups?["*"]?.dictionaryValue
self.telegramRequireMention = defaultGroup?["requireMention"]?.boolValue
?? telegram?["requireMention"]?.boolValue
?? true
self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true
self.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue)
self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
@@ -70,7 +46,7 @@ extension ConnectionsStore {
}
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.discordToken = discord?["token"]?.stringValue ?? ""
@@ -146,7 +122,7 @@ extension ConnectionsStore {
}
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.signalAccount = signal?["account"]?.stringValue ?? ""
self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? ""
@@ -163,7 +139,7 @@ extension ConnectionsStore {
}
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.imessageCliPath = imessage?["cliPath"]?.stringValue ?? ""
self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? ""
@@ -174,15 +150,6 @@ extension ConnectionsStore {
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 {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
@@ -191,24 +158,30 @@ extension ConnectionsStore {
await self.loadConfig()
}
var telegram: [String: Any] = [:]
if !self.isTelegramTokenLocked {
self.setPatchString(&telegram, key: "botToken", value: self.telegramToken)
var telegram: [String: Any] = (self.configRoot["telegram"] as? [String: Any]) ?? [:]
let token = self.trimmed(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 {
@@ -219,9 +192,9 @@ extension ConnectionsStore {
await self.loadConfig()
}
let base = self.channelConfigRoot(for: "discord")
let discord = self.buildDiscordPatch(base: base)
await self.persistChannelPatch("discord", payload: discord)
let discord = self.buildDiscordConfig()
self.setSection("discord", payload: discord)
await self.persistConfig()
}
func saveSignalConfig() async {
@@ -232,23 +205,42 @@ extension ConnectionsStore {
await self.loadConfig()
}
var signal: [String: Any] = [:]
self.setPatchBool(&signal, key: "enabled", value: self.signalEnabled, defaultValue: true)
self.setPatchString(&signal, key: "account", value: self.signalAccount)
self.setPatchString(&signal, key: "httpUrl", value: self.signalHttpUrl)
self.setPatchString(&signal, key: "httpHost", value: self.signalHttpHost)
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)
var signal: [String: Any] = (self.configRoot["signal"] as? [String: Any]) ?? [:]
if self.signalEnabled {
signal.removeValue(forKey: "enabled")
} else {
signal["enabled"] = false
}
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 {
@@ -259,168 +251,147 @@ extension ConnectionsStore {
await self.loadConfig()
}
var imessage: [String: Any] = [:]
self.setPatchBool(&imessage, key: "enabled", value: self.imessageEnabled, defaultValue: true)
self.setPatchString(&imessage, key: "cliPath", value: self.imessageCliPath)
self.setPatchString(&imessage, key: "dbPath", value: self.imessageDbPath)
var imessage: [String: Any] = (self.configRoot["imessage"] as? [String: Any]) ?? [:]
if self.imessageEnabled {
imessage.removeValue(forKey: "enabled")
} 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)
if service.isEmpty || service == "auto" {
imessage["service"] = NSNull()
imessage.removeValue(forKey: "service")
} else {
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)
self.setPatchList(&imessage, key: "allowFrom", values: allow)
if allow.isEmpty {
imessage.removeValue(forKey: "allowFrom")
} else {
imessage["allowFrom"] = allow
}
self.setPatchBool(
&imessage,
key: "includeAttachments",
value: self.imessageIncludeAttachments,
defaultValue: false)
self.setPatchNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
self.setOptionalBool(&imessage, key: "includeAttachments", value: self.imessageIncludeAttachments)
self.setOptionalNumber(&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] {
var discord: [String: Any] = [:]
self.setPatchBool(&discord, key: "enabled", value: self.discordEnabled, defaultValue: true)
if !self.isDiscordTokenLocked {
self.setPatchString(&discord, key: "token", value: self.discordToken)
private func buildDiscordConfig() -> [String: Any] {
var discord: [String: Any] = (self.configRoot["discord"] as? [String: Any]) ?? [:]
if self.discordEnabled {
discord.removeValue(forKey: "enabled")
} 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
} else {
discord["dm"] = NSNull()
discord.removeValue(forKey: "dm")
}
self.setPatchNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
self.setPatchInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
self.setPatchInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
self.setOptionalNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
self.setOptionalInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
self.setOptionalInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
let replyToMode = self.trimmed(self.discordReplyToMode)
if replyToMode.isEmpty || replyToMode == "off" || !["first", "all"].contains(replyToMode) {
discord["replyToMode"] = NSNull()
} else {
if replyToMode.isEmpty || replyToMode == "off" {
discord.removeValue(forKey: "replyToMode")
} else if ["first", "all"].contains(replyToMode) {
discord["replyToMode"] = replyToMode
} else {
discord.removeValue(forKey: "replyToMode")
}
let baseGuilds = base["guilds"] as? [String: Any] ?? [:]
if let guilds = self.buildDiscordGuildsPatch(base: baseGuilds) {
if let guilds = self.buildDiscordGuildsConfig() {
discord["guilds"] = guilds
} 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
} 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
} else {
discord["slashCommand"] = NSNull()
discord.removeValue(forKey: "slashCommand")
}
return discord
}
private func buildDiscordDmPatch() -> [String: Any]? {
var dm: [String: Any] = [:]
self.setPatchBool(&dm, key: "enabled", value: self.discordDmEnabled, defaultValue: true)
private func buildDiscordDmConfig(base: [String: Any]) -> [String: Any]? {
var dm = base
if self.discordDmEnabled {
dm.removeValue(forKey: "enabled")
} else {
dm["enabled"] = false
}
let allow = self.splitCsv(self.discordAllowFrom)
self.setPatchList(&dm, key: "allowFrom", values: allow)
self.setPatchBool(&dm, key: "groupEnabled", value: self.discordGroupEnabled, defaultValue: false)
if allow.isEmpty {
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)
self.setPatchList(&dm, key: "groupChannels", values: groupChannels)
if groupChannels.isEmpty {
dm.removeValue(forKey: "groupChannels")
} else {
dm["groupChannels"] = groupChannels
}
return dm.isEmpty ? nil : dm
}
private func buildDiscordGuildsPatch(base: [String: Any]) -> Any? {
if self.discordGuilds.isEmpty {
return NSNull()
}
var patch: [String: Any] = [:]
let baseKeys = Set(base.keys)
var formKeys = Set<String>()
for entry in self.discordGuilds {
private func buildDiscordGuildsConfig() -> [String: Any]? {
let guilds: [String: Any] = self.discordGuilds.reduce(into: [:]) { result, entry in
let key = self.trimmed(entry.key)
guard !key.isEmpty else { continue }
formKeys.insert(key)
let baseGuild = base[key] as? [String: Any] ?? [:]
patch[key] = self.buildDiscordGuildPatch(entry, base: baseGuild)
guard !key.isEmpty else { return }
var payload: [String: Any] = [:]
let slug = self.trimmed(entry.slug)
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) {
patch[key] = NSNull()
}
return patch.isEmpty ? NSNull() : patch
return guilds.isEmpty ? nil : guilds
}
private func buildDiscordGuildPatch(_ entry: DiscordGuildForm, base: [String: Any]) -> [String: Any] {
var payload: [String: Any] = [:]
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] = [:]
private func buildDiscordActionsConfig(base: [String: Any]) -> [String: Any]? {
var actions = base
self.setAction(&actions, key: "reactions", value: self.discordActionReactions, defaultValue: true)
self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true)
self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true)
@@ -439,44 +410,52 @@ extension ConnectionsStore {
return actions.isEmpty ? nil : actions
}
private func buildDiscordSlashPatch() -> [String: Any]? {
var slash: [String: Any] = [:]
self.setPatchBool(&slash, key: "enabled", value: self.discordSlashEnabled, defaultValue: false)
self.setPatchString(&slash, key: "name", value: self.discordSlashName)
self.setPatchString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
self.setPatchBool(&slash, key: "ephemeral", value: self.discordSlashEphemeral, defaultValue: true)
private func buildDiscordSlashConfig(base: [String: Any]) -> [String: Any]? {
var slash = base
if self.discordSlashEnabled {
slash["enabled"] = true
} else {
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
}
private func persistChannelPatch(_ channelId: String, payload: [String: Any]) async {
private func persistConfig() async {
do {
guard let baseHash = self.configHash else {
self.configStatus = "Config hash missing; reload and retry."
return
}
let data = try JSONSerialization.data(
withJSONObject: ["channels": [channelId: payload]],
withJSONObject: self.configRoot,
options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else {
self.configStatus = "Failed to encode config."
return
}
let params: [String: AnyCodable] = [
"raw": AnyCodable(raw),
"baseHash": AnyCodable(baseHash),
]
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
_ = try await GatewayConnection.shared.requestRaw(
method: .configPatch,
method: .configSet,
params: params,
timeoutMs: 10000)
self.configStatus = "Saved to ~/.clawdbot/clawdbot.json."
await self.loadConfig()
await self.refresh(probe: true)
} catch {
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 {
guard let values else { return "" }
let strings = values.compactMap { entry -> String? in
@@ -513,29 +492,25 @@ extension ConnectionsStore {
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)
if trimmed.isEmpty {
target[key] = NSNull()
target.removeValue(forKey: key)
} else {
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)
if trimmed.isEmpty {
target[key] = NSNull()
return
}
if let number = Double(trimmed) {
target.removeValue(forKey: key)
} else if let number = Double(trimmed) {
target[key] = number
} else {
target[key] = NSNull()
}
}
private func setPatchInt(
private func setOptionalInt(
_ target: inout [String: Any],
key: String,
value: String,
@@ -543,39 +518,26 @@ extension ConnectionsStore {
{
let trimmed = self.trimmed(value)
if trimmed.isEmpty {
target[key] = NSNull()
target.removeValue(forKey: key)
return
}
guard let number = Int(trimmed) else {
target[key] = NSNull()
target.removeValue(forKey: key)
return
}
let isValid = allowZero ? number >= 0 : number > 0
guard isValid else {
target[key] = NSNull()
target.removeValue(forKey: key)
return
}
target[key] = number
}
private func setPatchBool(
_ target: inout [String: Any],
key: String,
value: Bool,
defaultValue: Bool)
{
if value == defaultValue {
target[key] = NSNull()
private func setOptionalBool(_ target: inout [String: Any], key: String, value: Bool) {
if value {
target[key] = true
} else {
target[key] = value
}
}
private func setPatchList(_ target: inout [String: Any], key: String, values: [String]) {
if values.isEmpty {
target[key] = NSNull()
} else {
target[key] = values
target.removeValue(forKey: key)
}
}
@@ -586,7 +548,7 @@ extension ConnectionsStore {
defaultValue: Bool)
{
if value == defaultValue {
actions[key] = NSNull()
actions.removeValue(forKey: key)
} else {
actions[key] = value
}
@@ -31,8 +31,8 @@ extension ConnectionsStore {
"probe": AnyCodable(probe),
"timeoutMs": AnyCodable(8000),
]
let snap: ChannelsStatusSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .channelsStatus,
let snap: ProvidersStatusSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .providersStatus,
params: params,
timeoutMs: 12000)
self.snapshot = snap
@@ -101,10 +101,10 @@ extension ConnectionsStore {
defer { self.whatsappBusy = false }
do {
let params: [String: AnyCodable] = [
"channel": AnyCodable("whatsapp"),
"provider": AnyCodable("whatsapp"),
]
let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded(
method: .channelsLogout,
let result: ProviderLogoutResult = try await GatewayConnection.shared.requestDecoded(
method: .providersLogout,
params: params,
timeoutMs: 15000)
self.whatsappLoginMessage = result.cleared
@@ -123,10 +123,10 @@ extension ConnectionsStore {
defer { self.telegramBusy = false }
do {
let params: [String: AnyCodable] = [
"channel": AnyCodable("telegram"),
"provider": AnyCodable("telegram"),
]
let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded(
method: .channelsLogout,
let result: ProviderLogoutResult = try await GatewayConnection.shared.requestDecoded(
method: .providersLogout,
params: params,
timeoutMs: 15000)
if result.envToken == true {
@@ -154,8 +154,8 @@ private struct WhatsAppLoginWaitResult: Codable {
let message: String
}
private struct ChannelLogoutResult: Codable {
let channel: String?
private struct ProviderLogoutResult: Codable {
let provider: String?
let accountId: String?
let cleared: Bool
let envToken: Bool?
@@ -2,7 +2,7 @@ import ClawdbotProtocol
import Foundation
import Observation
struct ChannelsStatusSnapshot: Codable {
struct ProvidersStatusSnapshot: Codable {
struct WhatsAppSelf: Codable {
let e164: String?
let jid: String?
@@ -121,7 +121,7 @@ struct ChannelsStatusSnapshot: Codable {
let lastProbeAt: Double?
}
struct ChannelAccountSnapshot: Codable {
struct ProviderAccountSnapshot: Codable {
let accountId: String
let name: String?
let enabled: Bool?
@@ -154,14 +154,14 @@ struct ChannelsStatusSnapshot: Codable {
}
let ts: Double
let channelOrder: [String]
let channelLabels: [String: String]
let channels: [String: AnyCodable]
let channelAccounts: [String: [ChannelAccountSnapshot]]
let channelDefaultAccountId: [String: String]
let providerOrder: [String]
let providerLabels: [String: String]
let providers: [String: AnyCodable]
let providerAccounts: [String: [ProviderAccountSnapshot]]
let providerDefaultAccountId: [String: String]
func decodeChannel<T: Decodable>(_ id: String, as type: T.Type) -> T? {
guard let value = self.channels[id] else { return nil }
func decodeProvider<T: Decodable>(_ id: String, as type: T.Type) -> T? {
guard let value = self.providers[id] else { return nil }
do {
let data = try JSONEncoder().encode(value)
return try JSONDecoder().decode(type, from: data)
@@ -180,7 +180,6 @@ struct ConfigSnapshot: Codable {
let path: String?
let exists: Bool?
let raw: String?
let hash: String?
let parsed: AnyCodable?
let valid: Bool?
let config: [String: AnyCodable]?
@@ -231,7 +230,7 @@ struct DiscordGuildForm: Identifiable {
final class ConnectionsStore {
static let shared = ConnectionsStore()
var snapshot: ChannelsStatusSnapshot?
var snapshot: ProvidersStatusSnapshot?
var lastError: String?
var lastSuccess: Date?
var isRefreshing = false
@@ -308,7 +307,6 @@ final class ConnectionsStore {
var pollTask: Task<Void, Never>?
var configRoot: [String: Any] = [:]
var configLoaded = false
var configHash: String?
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview
@@ -13,9 +13,7 @@ extension CronJobEditor {
guard let job else { return }
self.name = job.name
self.description = job.description ?? ""
self.agentId = job.agentId ?? ""
self.enabled = job.enabled
self.deleteAfterRun = job.deleteAfterRun ?? false
self.sessionTarget = job.sessionTarget
self.wakeMode = job.wakeMode
@@ -36,13 +34,13 @@ extension CronJobEditor {
case let .systemEvent(text):
self.payloadKind = .systemEvent
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.agentMessage = message
self.thinking = thinking ?? ""
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
self.deliver = deliver ?? false
self.channel = GatewayAgentChannel(raw: channel)
self.provider = GatewayAgentProvider(raw: provider)
self.to = to ?? ""
self.bestEffortDeliver = bestEffortDeliver ?? false
}
@@ -61,60 +59,18 @@ extension CronJobEditor {
}
func buildPayload() throws -> [String: AnyCodable] {
let name = try self.requireName()
let description = self.trimmed(self.description)
let agentId = self.trimmed(self.agentId)
let schedule = try self.buildSchedule()
let payload = try self.buildSelectedPayload()
try self.validateSessionTarget(payload)
try self.validatePayloadRequiredFields(payload)
var root: [String: Any] = [
"name": name,
"enabled": self.enabled,
"schedule": schedule,
"sessionTarget": self.sessionTarget.rawValue,
"wakeMode": self.wakeMode.rawValue,
"payload": payload,
]
self.applyDeleteAfterRun(to: &root)
if !description.isEmpty { root["description"] = description }
if !agentId.isEmpty {
root["agentId"] = agentId
} else if self.job?.agentId != nil {
root["agentId"] = NSNull()
}
if self.sessionTarget == .isolated {
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
root["isolation"] = [
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
]
}
return root.mapValues { AnyCodable($0) }
}
func trimmed(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines)
}
func requireName() throws -> String {
let name = self.trimmed(self.name)
let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
if name.isEmpty {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Name is required."])
}
return name
}
func buildSchedule() throws -> [String: Any] {
let description = self.description.trimmingCharacters(in: .whitespacesAndNewlines)
let schedule: [String: Any]
switch self.scheduleKind {
case .at:
return ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
schedule = ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
case .every:
guard let ms = Self.parseDurationMs(self.everyText) else {
throw NSError(
@@ -122,35 +78,34 @@ extension CronJobEditor {
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."])
}
return ["kind": "every", "everyMs": ms]
schedule = ["kind": "every", "everyMs": ms]
case .cron:
let expr = self.trimmed(self.cronExpr)
let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines)
if expr.isEmpty {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
}
let tz = self.trimmed(self.cronTz)
let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines)
if tz.isEmpty {
return ["kind": "cron", "expr": expr]
schedule = ["kind": "cron", "expr": expr]
} else {
schedule = ["kind": "cron", "expr": expr, "tz": tz]
}
return ["kind": "cron", "expr": expr, "tz": tz]
}
}
func buildSelectedPayload() throws -> [String: Any] {
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
switch self.payloadKind {
case .systemEvent:
let text = self.trimmed(self.systemEventText)
return ["kind": "systemEvent", "text": text]
case .agentTurn:
return self.buildAgentTurnPayload()
}
}
let payload: [String: Any] = {
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
switch self.payloadKind {
case .systemEvent:
let text = self.systemEventText.trimmingCharacters(in: .whitespacesAndNewlines)
return ["kind": "systemEvent", "text": text]
case .agentTurn:
return self.buildAgentTurnPayload()
}
}()
func validateSessionTarget(_ payload: [String: Any]) throws {
if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" {
throw NSError(
domain: "Cron",
@@ -167,9 +122,7 @@ extension CronJobEditor {
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."])
}
}
func validatePayloadRequiredFields(_ payload: [String: Any]) throws {
if payload["kind"] as? String == "systemEvent" {
if (payload["text"] as? String ?? "").isEmpty {
throw NSError(
@@ -177,8 +130,7 @@ extension CronJobEditor {
code: 0,
userInfo: [NSLocalizedDescriptionKey: "System event text is required."])
}
}
if payload["kind"] as? String == "agentTurn" {
} else if payload["kind"] as? String == "agentTurn" {
if (payload["message"] as? String ?? "").isEmpty {
throw NSError(
domain: "Cron",
@@ -186,14 +138,25 @@ extension CronJobEditor {
userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
}
}
}
func applyDeleteAfterRun(to root: inout [String: Any]) {
if self.scheduleKind == .at {
root["deleteAfterRun"] = self.deleteAfterRun
} else if self.job?.deleteAfterRun != nil {
root["deleteAfterRun"] = false
var root: [String: Any] = [
"name": name,
"enabled": self.enabled,
"schedule": schedule,
"sessionTarget": self.sessionTarget.rawValue,
"wakeMode": self.wakeMode.rawValue,
"payload": payload,
]
if !description.isEmpty { root["description"] = description }
if self.sessionTarget == .isolated {
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
root["isolation"] = [
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
]
}
return root.mapValues { AnyCodable($0) }
}
func buildAgentTurnPayload() -> [String: Any] {
@@ -204,7 +167,7 @@ extension CronJobEditor {
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
payload["deliver"] = self.deliver
if self.deliver {
payload["channel"] = self.channel.rawValue
payload["provider"] = self.provider.rawValue
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { payload["to"] = to }
payload["bestEffortDeliver"] = self.bestEffortDeliver
@@ -3,7 +3,6 @@ extension CronJobEditor {
mutating func exerciseForTesting() {
self.name = "Test job"
self.description = "Test description"
self.agentId = "ops"
self.enabled = true
self.sessionTarget = .isolated
self.wakeMode = .now
@@ -14,7 +13,7 @@ extension CronJobEditor {
self.payloadKind = .agentTurn
self.agentMessage = "Run diagnostic"
self.deliver = true
self.channel = .last
self.provider = .last
self.to = "+15551230000"
self.thinking = "low"
self.timeoutSeconds = "90"
+12 -25
View File
@@ -18,7 +18,7 @@ struct CronJobEditor: View {
static let scheduleKindNote =
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
static let isolatedPayloadNote =
"Isolated jobs always run an agent turn. The result can be delivered to a 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."
static let mainPayloadNote =
"System events are injected into the current main session. Agent turns require an isolated session target."
@@ -27,11 +27,9 @@ struct CronJobEditor: View {
@State var name: String = ""
@State var description: String = ""
@State var agentId: String = ""
@State var enabled: Bool = true
@State var sessionTarget: CronSessionTarget = .main
@State var wakeMode: CronWakeMode = .nextHeartbeat
@State var deleteAfterRun: Bool = false
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
@State var scheduleKind: ScheduleKind = .every
@@ -45,7 +43,7 @@ struct CronJobEditor: View {
@State var systemEventText: String = ""
@State var agentMessage: String = ""
@State var deliver: Bool = false
@State var channel: GatewayAgentChannel = .last
@State var provider: GatewayAgentProvider = .last
@State var to: String = ""
@State var thinking: String = ""
@State var timeoutSeconds: String = ""
@@ -79,12 +77,6 @@ struct CronJobEditor: View {
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Agent ID")
TextField("Optional (default agent)", text: self.$agentId)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$enabled)
@@ -157,11 +149,6 @@ struct CronJobEditor: View {
.labelsHidden()
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Auto-delete")
Toggle("Delete after successful run", isOn: self.$deleteAfterRun)
.toggleStyle(.switch)
}
case .every:
GridRow {
self.gridLabel("Every")
@@ -323,7 +310,7 @@ struct CronJobEditor: View {
}
GridRow {
self.gridLabel("Deliver")
Toggle("Deliver result to a channel", isOn: self.$deliver)
Toggle("Deliver result to a provider", isOn: self.$deliver)
.toggleStyle(.switch)
}
}
@@ -331,15 +318,15 @@ struct CronJobEditor: View {
if self.deliver {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Channel")
Picker("", selection: self.$channel) {
Text("last").tag(GatewayAgentChannel.last)
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
Text("telegram").tag(GatewayAgentChannel.telegram)
Text("discord").tag(GatewayAgentChannel.discord)
Text("slack").tag(GatewayAgentChannel.slack)
Text("signal").tag(GatewayAgentChannel.signal)
Text("imessage").tag(GatewayAgentChannel.imessage)
self.gridLabel("Provider")
Picker("", selection: self.$provider) {
Text("last").tag(GatewayAgentProvider.last)
Text("whatsapp").tag(GatewayAgentProvider.whatsapp)
Text("telegram").tag(GatewayAgentProvider.telegram)
Text("discord").tag(GatewayAgentProvider.discord)
Text("slack").tag(GatewayAgentProvider.slack)
Text("signal").tag(GatewayAgentProvider.signal)
Text("imessage").tag(GatewayAgentProvider.imessage)
}
.labelsHidden()
.pickerStyle(.segmented)
+5 -8
View File
@@ -74,12 +74,12 @@ enum CronPayload: Codable, Equatable {
thinking: String?,
timeoutSeconds: Int?,
deliver: Bool?,
channel: String?,
provider: String?,
to: String?,
bestEffortDeliver: Bool?)
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 {
@@ -101,8 +101,7 @@ enum CronPayload: Codable, Equatable {
thinking: container.decodeIfPresent(String.self, forKey: .thinking),
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver),
channel: container.decodeIfPresent(String.self, forKey: .channel)
?? container.decodeIfPresent(String.self, forKey: .provider),
provider: container.decodeIfPresent(String.self, forKey: .provider),
to: container.decodeIfPresent(String.self, forKey: .to),
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
default:
@@ -119,12 +118,12 @@ enum CronPayload: Codable, Equatable {
switch self {
case let .systemEvent(text):
try container.encode(text, forKey: .text)
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver):
try container.encode(message, forKey: .message)
try container.encodeIfPresent(thinking, forKey: .thinking)
try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
try container.encodeIfPresent(deliver, forKey: .deliver)
try container.encodeIfPresent(channel, forKey: .channel)
try container.encodeIfPresent(provider, forKey: .provider)
try container.encodeIfPresent(to, forKey: .to)
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
}
@@ -146,11 +145,9 @@ struct CronJobState: Codable, Equatable {
struct CronJob: Identifiable, Codable, Equatable {
let id: String
let agentId: String?
var name: String
var description: String?
var enabled: Bool
var deleteAfterRun: Bool?
let createdAtMs: Int
let updatedAtMs: Int
let schedule: CronSchedule
@@ -20,9 +20,6 @@ extension CronSettings {
HStack(spacing: 6) {
StatusPill(text: job.sessionTarget.rawValue, tint: .secondary)
StatusPill(text: job.wakeMode.rawValue, tint: .secondary)
if let agentId = job.agentId, !agentId.isEmpty {
StatusPill(text: "agent \(agentId)", tint: .secondary)
}
if let status = job.state.lastStatus {
StatusPill(text: status, tint: status == "ok" ? .green : .orange)
}
@@ -94,15 +91,9 @@ extension CronSettings {
func detailCard(_ job: CronJob) -> some View {
VStack(alignment: .leading, spacing: 10) {
LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) }
if case .at = job.schedule, job.deleteAfterRun == true {
LabeledContent("Auto-delete") { Text("after success") }
}
if let desc = job.description, !desc.isEmpty {
LabeledContent("Description") { Text(desc).font(.callout) }
}
if let agentId = job.agentId, !agentId.isEmpty {
LabeledContent("Agent") { Text(agentId) }
}
LabeledContent("Session") { Text(job.sessionTarget.rawValue) }
LabeledContent("Wake") { Text(job.wakeMode.rawValue) }
LabeledContent("Next run") {
@@ -7,11 +7,9 @@ struct CronSettings_Previews: PreviewProvider {
store.jobs = [
CronJob(
id: "job-1",
agentId: "ops",
name: "Daily summary",
description: nil,
enabled: true,
deleteAfterRun: nil,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .every(everyMs: 86_400_000, anchorMs: nil),
@@ -22,7 +20,7 @@ struct CronSettings_Previews: PreviewProvider {
thinking: "low",
timeoutSeconds: 600,
deliver: true,
channel: "last",
provider: "last",
to: nil,
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"),
@@ -61,11 +59,9 @@ extension CronSettings {
let job = CronJob(
id: "job-1",
agentId: "ops",
name: "Daily summary",
description: "Summary job",
enabled: true,
deleteAfterRun: nil,
createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_100_000,
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
@@ -76,7 +72,7 @@ extension CronSettings {
thinking: "low",
timeoutSeconds: 120,
deliver: true,
channel: "whatsapp",
provider: "whatsapp",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "),
+3 -3
View File
@@ -59,7 +59,7 @@ final class DeepLinkHandler {
}
do {
let channel = GatewayAgentChannel(raw: link.channel)
let provider = GatewayAgentProvider(raw: link.channel)
let explicitSessionKey = link.sessionKey?
.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty
@@ -72,9 +72,9 @@ final class DeepLinkHandler {
message: messagePreview,
sessionKey: resolvedSessionKey,
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
deliver: channel.shouldDeliver(link.deliver),
deliver: provider.shouldDeliver(link.deliver),
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
channel: channel,
provider: provider,
timeoutSeconds: link.timeoutSeconds,
idempotencyKey: UUID().uuidString)
@@ -0,0 +1,26 @@
import Foundation
enum GatewayAgentChannel: String, CaseIterable, Sendable {
case last
case webchat
case whatsapp
case telegram
init(raw: String?) {
let trimmed = raw?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased() ?? ""
self = GatewayAgentChannel(rawValue: trimmed) ?? .last
}
func shouldDeliver(_ isLast: Bool) -> Bool {
switch self {
case .webchat:
false
case .last:
isLast
case .whatsapp, .telegram:
true
}
}
}
@@ -5,7 +5,7 @@ import OSLog
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 whatsapp
case telegram
@@ -18,7 +18,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
init(raw: String?) {
let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
self = GatewayAgentChannel(rawValue: normalized) ?? .last
self = GatewayAgentProvider(rawValue: normalized) ?? .last
}
var isDeliverable: Bool { self != .webchat }
@@ -32,7 +32,7 @@ struct GatewayAgentInvocation: Sendable {
var thinking: String?
var deliver: Bool = false
var to: String?
var channel: GatewayAgentChannel = .last
var provider: GatewayAgentProvider = .last
var timeoutSeconds: Int?
var idempotencyKey: String = UUID().uuidString
}
@@ -52,10 +52,9 @@ actor GatewayConnection {
case setHeartbeats = "set-heartbeats"
case systemEvent = "system-event"
case health
case channelsStatus = "channels.status"
case providersStatus = "providers.status"
case configGet = "config.get"
case configSet = "config.set"
case configPatch = "config.patch"
case wizardStart = "wizard.start"
case wizardNext = "wizard.next"
case wizardCancel = "wizard.cancel"
@@ -63,7 +62,7 @@ actor GatewayConnection {
case talkMode = "talk.mode"
case webLoginStart = "web.login.start"
case webLoginWait = "web.login.wait"
case channelsLogout = "channels.logout"
case providersLogout = "providers.logout"
case modelsList = "models.list"
case chatHistory = "chat.history"
case chatSend = "chat.send"
@@ -233,18 +232,7 @@ actor GatewayConnection {
func canvasHostUrl() async -> String? {
guard let snapshot = self.lastSnapshot else { return nil }
let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: CharacterSet.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")
let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
@@ -279,35 +267,12 @@ actor GatewayConnection {
private func broadcast(_ push: GatewayPush) {
if case let .snapshot(snapshot) = push {
self.lastSnapshot = snapshot
if let mainSessionKey = self.cachedMainSessionKey() {
Task { @MainActor in
WorkActivityStore.shared.setMainSessionKey(mainSessionKey)
}
}
}
for (_, continuation) in self.subscribers {
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 {
if self.client != nil, self.configuredURL == url, self.configuredToken == token,
self.configuredPassword == password
@@ -366,9 +331,6 @@ extension GatewayConnection {
}
func mainSessionKey(timeoutMs: Double = 15000) async -> String {
if let cached = self.cachedMainSessionKey() {
return cached
}
do {
let data = try await self.requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs)
return try Self.mainSessionKey(fromConfigGetData: data)
@@ -399,15 +361,14 @@ extension GatewayConnection {
func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) {
let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return (false, "message empty") }
let sessionKey = self.canonicalizeSessionKey(invocation.sessionKey)
var params: [String: AnyCodable] = [
"message": AnyCodable(trimmed),
"sessionKey": AnyCodable(sessionKey),
"sessionKey": AnyCodable(invocation.sessionKey),
"thinking": AnyCodable(invocation.thinking ?? "default"),
"deliver": AnyCodable(invocation.deliver),
"to": AnyCodable(invocation.to ?? ""),
"channel": AnyCodable(invocation.channel.rawValue),
"provider": AnyCodable(invocation.provider.rawValue),
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
]
if let timeout = invocation.timeoutSeconds {
@@ -428,7 +389,7 @@ extension GatewayConnection {
sessionKey: String,
deliver: Bool,
to: String?,
channel: GatewayAgentChannel = .last,
provider: GatewayAgentProvider = .last,
timeoutSeconds: Int? = nil,
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
{
@@ -438,7 +399,7 @@ extension GatewayConnection {
thinking: thinking,
deliver: deliver,
to: to,
channel: channel,
provider: provider,
timeoutSeconds: timeoutSeconds,
idempotencyKey: idempotencyKey))
}
@@ -507,8 +468,7 @@ extension GatewayConnection {
limit: Int? = nil,
timeoutMs: Int? = nil) async throws -> ClawdbotChatHistoryPayload
{
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)]
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(sessionKey)]
if let limit { params["limit"] = AnyCodable(limit) }
let timeout = timeoutMs.map { Double($0) }
return try await self.requestDecoded(
@@ -525,9 +485,8 @@ extension GatewayConnection {
attachments: [ClawdbotChatAttachmentPayload],
timeoutMs: Int = 30000) async throws -> ClawdbotChatSendResponse
{
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
var params: [String: AnyCodable] = [
"sessionKey": AnyCodable(resolvedKey),
"sessionKey": AnyCodable(sessionKey),
"message": AnyCodable(message),
"thinking": AnyCodable(thinking),
"idempotencyKey": AnyCodable(idempotencyKey),
@@ -553,11 +512,10 @@ extension GatewayConnection {
}
func chatAbort(sessionKey: String, runId: String) async throws -> Bool {
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? }
let res: AbortResponse = try await self.requestDecoded(
method: .chatAbort,
params: ["sessionKey": AnyCodable(resolvedKey), "runId": AnyCodable(runId)])
params: ["sessionKey": AnyCodable(sessionKey), "runId": AnyCodable(runId)])
return res.aborted ?? false
}
@@ -33,16 +33,14 @@ actor GatewayEndpointStore {
return GatewayEndpointStore.resolveGatewayToken(
isRemote: CommandResolver.connectionModeIsRemote(),
root: root,
env: ProcessInfo.processInfo.environment,
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
env: ProcessInfo.processInfo.environment)
},
password: {
let root = ClawdbotConfigFile.loadDict()
return GatewayEndpointStore.resolveGatewayPassword(
isRemote: CommandResolver.connectionModeIsRemote(),
root: root,
env: ProcessInfo.processInfo.environment,
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
env: ProcessInfo.processInfo.environment)
},
localPort: { GatewayEnvironment.gatewayPort() },
localHost: {
@@ -62,8 +60,7 @@ actor GatewayEndpointStore {
private static func resolveGatewayPassword(
isRemote: Bool,
root: [String: Any],
env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot?) -> String?
env: [String: String]) -> String?
{
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -91,19 +88,13 @@ actor GatewayEndpointStore {
return pw
}
}
if let password = launchdSnapshot?.password?.trimmingCharacters(in: .whitespacesAndNewlines),
!password.isEmpty
{
return password
}
return nil
}
private static func resolveGatewayToken(
isRemote: Bool,
root: [String: Any],
env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot?) -> String?
env: [String: String]) -> String?
{
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -131,11 +122,6 @@ actor GatewayEndpointStore {
return value
}
}
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
return token
}
return nil
}
@@ -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
extension GatewayEndpointStore {
static func _testResolveGatewayPassword(
isRemote: Bool,
root: [String: Any],
env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String?
env: [String: String]) -> String?
{
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot)
}
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)
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
}
static func _testResolveGatewayBindMode(
@@ -306,10 +306,6 @@ enum GatewayLaunchAgentManager {
password: snapshot.password)
}
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
LaunchAgentPlist.snapshot(url: self.plistURL)
}
private static func ensureEnabled() async {
let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
guard result.status != 0 else { return }
@@ -211,19 +211,19 @@ final class GatewayProcessManager {
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
let instanceText = instance ?? "pid unknown"
if let snap {
let linkId = snap.channelOrder?.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
let linkId = snap.providerOrder?.first(where: {
if let summary = snap.providers[$0] { return summary.linked != nil }
return false
}) ?? snap.channels.keys.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
}) ?? snap.providers.keys.first(where: {
if let summary = snap.providers[$0] { return summary.linked != nil }
return false
})
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
let linked = linkId.flatMap { snap.providers[$0]?.linked } ?? false
let authAge = linkId.flatMap { snap.providers[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
let label =
linkId.flatMap { snap.channelLabels?[$0] } ??
linkId.flatMap { snap.providerLabels?[$0] } ??
linkId?.capitalized ??
"channel"
"provider"
let linkText = linked ? "linked" : "not linked"
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
}
@@ -496,18 +496,18 @@ struct GeneralSettings: View {
}
if let snap = snapshot {
let linkId = snap.channelOrder?.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
let linkId = snap.providerOrder?.first(where: {
if let summary = snap.providers[$0] { return summary.linked != nil }
return false
}) ?? snap.channels.keys.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
}) ?? snap.providers.keys.first(where: {
if let summary = snap.providers[$0] { return summary.linked != nil }
return false
})
let linkLabel =
linkId.flatMap { snap.channelLabels?[$0] } ??
linkId.flatMap { snap.providerLabels?[$0] } ??
linkId?.capitalized ??
"Link channel"
let linkAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }
"Link provider"
let linkAge = linkId.flatMap { snap.providers[$0]?.authAgeMs }
Text("\(linkLabel) auth age: \(healthAgeString(linkAge))")
.font(.caption)
.foregroundStyle(.secondary)
+27 -27
View File
@@ -4,7 +4,7 @@ import Observation
import SwiftUI
struct HealthSnapshot: Codable, Sendable {
struct ChannelSummary: Codable, Sendable {
struct ProviderSummary: Codable, Sendable {
struct Probe: Codable, Sendable {
struct Bot: Codable, Sendable {
let username: String?
@@ -44,9 +44,9 @@ struct HealthSnapshot: Codable, Sendable {
let ok: Bool?
let ts: Double
let durationMs: Double
let channels: [String: ChannelSummary]
let channelOrder: [String]?
let channelLabels: [String: String]?
let providers: [String: ProviderSummary]
let providerOrder: [String]?
let providerLabels: [String: String]?
let heartbeatSeconds: Int?
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 }
// If probe is missing, treat it as "configured but unknown health" (not a hard fail).
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" }
if let error = probe.error, error.lowercased().contains("timeout") || probe.status == nil {
if let elapsed { return "Health check timed out (\(elapsed))" }
@@ -162,28 +162,28 @@ final class HealthStore {
return "\(reason) (\(code))"
}
private func resolveLinkChannel(
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
private func resolveLinkProvider(
_ 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 {
if let summary = snap.channels[id], summary.linked != nil {
if let summary = snap.providers[id], summary.linked != nil {
return (id: id, summary: summary)
}
}
return nil
}
private func resolveFallbackChannel(
private func resolveFallbackProvider(
_ 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)
for channelId in order {
if channelId == id { continue }
guard let summary = snap.channels[channelId] else { continue }
if Self.isChannelHealthy(summary) {
return (id: channelId, summary: summary)
let order = snap.providerOrder ?? Array(snap.providers.keys)
for providerId in order {
if providerId == id { continue }
guard let summary = snap.providers[providerId] else { continue }
if Self.isProviderHealthy(summary) {
return (id: providerId, summary: summary)
}
}
return nil
@@ -194,13 +194,13 @@ final class HealthStore {
return .degraded(error)
}
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 {
// Linking is optional if any other channel is healthy; don't paint the whole app red.
let fallback = self.resolveFallbackChannel(snap, excluding: link.id)
// Linking is optional if any other provider is healthy; don't paint the whole app red.
let fallback = self.resolveFallbackProvider(snap, excluding: link.id)
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 {
return .degraded(Self.describeProbeFailure(probe))
}
@@ -211,10 +211,10 @@ final class HealthStore {
if self.isRefreshing { return "Health check running…" }
if let error = self.lastError { return "Health check failed: \(error)" }
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 let fallback = self.resolveFallbackChannel(snap, excluding: link.id) {
let fallbackLabel = snap.channelLabels?[fallback.id] ?? fallback.id.capitalized
if let fallback = self.resolveFallbackProvider(snap, excluding: link.id) {
let fallbackLabel = snap.providerLabels?[fallback.id] ?? fallback.id.capitalized
let fallbackState = (fallback.summary.probe?.ok ?? true) ? "ok" : "degraded"
return "\(fallbackLabel) \(fallbackState) · Not linked — run clawdbot login"
}
@@ -247,10 +247,10 @@ final class HealthStore {
}
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"
}
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)
}
if let fallback, !fallback.isEmpty {
@@ -242,18 +242,18 @@ final class InstancesStore {
do {
let data = try await ControlChannel.shared.health(timeout: 8)
guard let snap = decodeHealthSnapshot(from: data) else { return }
let linkId = snap.channelOrder?.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
let linkId = snap.providerOrder?.first(where: {
if let summary = snap.providers[$0] { return summary.linked != nil }
return false
}) ?? snap.channels.keys.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
}) ?? snap.providers.keys.first(where: {
if let summary = snap.providers[$0] { return summary.linked != nil }
return false
})
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
let linked = linkId.flatMap { snap.providers[$0]?.linked } ?? false
let linkLabel =
linkId.flatMap { snap.channelLabels?[$0] } ??
linkId.flatMap { snap.providerLabels?[$0] } ??
linkId?.capitalized ??
"channel"
"provider"
let entry = InstanceInfo(
id: "health-\(snap.ts)",
host: "gateway (health)",
+5 -24
View File
@@ -1,25 +1,9 @@
import Foundation
enum LogLocator {
private static var logDir: URL {
if let override = ProcessInfo.processInfo.environment["CLAWDBOT_LOG_DIR"], !override.isEmpty {
return URL(fileURLWithPath: override)
}
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 let logDir = URL(fileURLWithPath: "/tmp/clawdbot")
private static let stdoutLog = logDir.appendingPathComponent("clawdbot-stdout.log")
private static let gatewayLog = logDir.appendingPathComponent("clawdbot-gateway.log")
private static func modificationDate(for url: URL) -> Date {
(try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
@@ -27,7 +11,6 @@ enum LogLocator {
/// Returns the newest log file under /tmp/clawdbot/ (rolling or stdout), or nil if none exist.
static func bestLogFile() -> URL? {
self.ensureLogDirExists()
let fm = FileManager.default
let files = (try? fm.contentsOfDirectory(
at: self.logDir,
@@ -43,13 +26,11 @@ enum LogLocator {
/// Path to use for launchd stdout/err.
static var launchdLogPath: String {
self.ensureLogDirExists()
return stdoutLog.path
stdoutLog.path
}
/// Path to use for the Gateway launchd job stdout/err.
static var launchdGatewayLogPath: String {
self.ensureLogDirExists()
return gatewayLog.path
gatewayLog.path
}
}
@@ -102,14 +102,11 @@ struct MenuContent: View {
}
if self.state.canvasEnabled {
Button {
Task { @MainActor in
if self.state.canvasPanelVisible {
CanvasManager.shared.hideAll()
} else {
let sessionKey = await GatewayConnection.shared.mainSessionKey()
// Don't force a navigation on re-open: preserve the current web view state.
_ = try? CanvasManager.shared.show(sessionKey: sessionKey, path: nil)
}
if self.state.canvasPanelVisible {
CanvasManager.shared.hideAll()
} else {
// Don't force a navigation on re-open: preserve the current web view state.
_ = try? CanvasManager.shared.show(sessionKey: "main", path: nil)
}
} label: {
Label(
@@ -316,7 +313,27 @@ struct MenuContent: View {
private func openDashboard() async {
do {
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)
} catch {
let alert = NSAlert()
@@ -104,8 +104,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
extension MenuSessionsInjector {
// MARK: - Injection
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
private func inject(into menu: NSMenu) {
// Remove any previous injected items.
for item in menu.items where item.tag == self.tag {
@@ -122,15 +120,13 @@ extension MenuSessionsInjector {
if let snapshot = self.cachedSnapshot {
let now = Date()
let mainKey = self.mainSessionKey
let rows = snapshot.rows.filter { row in
if row.key == "main", mainKey != "main" { return false }
if row.key == mainKey { return true }
if row.key == "main" { return true }
guard let updatedAt = row.updatedAt else { return false }
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
}.sorted { lhs, rhs in
if lhs.key == mainKey { return true }
if rhs.key == mainKey { return false }
if lhs.key == "main" { return true }
if rhs.key == "main" { return false }
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
}
@@ -649,7 +645,7 @@ extension MenuSessionsInjector {
compact.representedObject = row.key
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: "")
del.target = self
del.representedObject = row.key
@@ -36,7 +36,7 @@ actor MacNodeBridgeSession {
func connect(
endpoint: NWEndpoint,
hello: BridgeHello,
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
onConnected: (@Sendable (String) async -> Void)? = nil,
onDisconnected: (@Sendable (String) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
async throws
@@ -98,8 +98,7 @@ actor MacNodeBridgeSession {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName)
self.startPingLoop()
let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
await onConnected?(ok.serverName, mainKey?.isEmpty == false ? mainKey : nil)
await onConnected?(ok.serverName)
} else if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
self.state = .failed(message: "\(err.code): \(err.message)")
@@ -67,11 +67,8 @@ final class MacNodeModeCoordinator {
try await self.session.connect(
endpoint: endpoint,
hello: hello,
onConnected: { [weak self] serverName, mainSessionKey in
onConnected: { [weak self] serverName in
self?.logger.info("mac node connected to \(serverName, privacy: .public)")
if let mainSessionKey {
await self?.runtime.updateMainSessionKey(mainSessionKey)
}
},
onDisconnected: { reason in
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
@@ -312,23 +309,9 @@ final class MacNodeModeCoordinator {
}
let remotePort = Self.remoteBridgePort()
let preferredLocalPort = Self.loopbackBridgePort()
if let preferredLocalPort {
self.logger.info(
"mac node bridge tunnel starting " +
"preferredLocalPort=\(preferredLocalPort, privacy: .public) " +
"remotePort=\(remotePort, privacy: .public)")
} else {
self.logger.info(
"mac node bridge tunnel starting " +
"preferredLocalPort=none " +
"remotePort=\(remotePort, privacy: .public)")
}
self.tunnel = try await RemotePortTunnel.create(
remotePort: remotePort,
preferredLocalPort: preferredLocalPort,
allowRemoteUrlOverride: false,
allowRandomLocalPort: true)
allowRemoteUrlOverride: false)
if let localPort = self.tunnel?.localPort,
let port = NWEndpoint.Port(rawValue: localPort)
{
@@ -7,7 +7,6 @@ actor MacNodeRuntime {
private let cameraCapture = CameraCaptureService()
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
private var mainSessionKey: String = "main"
init(
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
@@ -17,12 +16,6 @@ actor MacNodeRuntime {
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 {
let command = req.command
if self.isCanvasCommand(command), !Self.canvasEnabled() {
@@ -79,32 +72,28 @@ actor MacNodeRuntime {
let placement = params.placement.map {
CanvasPlacement(x: $0.x, y: $0.y, width: $0.width, height: $0.height)
}
let sessionKey = self.mainSessionKey
try await MainActor.run {
_ = try CanvasManager.shared.showDetailed(
sessionKey: sessionKey,
sessionKey: "main",
target: url,
placement: placement)
}
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdbotCanvasCommand.hide.rawValue:
let sessionKey = self.mainSessionKey
await MainActor.run {
CanvasManager.shared.hide(sessionKey: sessionKey)
CanvasManager.shared.hide(sessionKey: "main")
}
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdbotCanvasCommand.navigate.rawValue:
let params = try Self.decodeParams(ClawdbotCanvasNavigateParams.self, from: req.paramsJSON)
let sessionKey = self.mainSessionKey
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)
case ClawdbotCanvasCommand.evalJS.rawValue:
let params = try Self.decodeParams(ClawdbotCanvasEvalParams.self, from: req.paramsJSON)
let sessionKey = self.mainSessionKey
let result = try await CanvasManager.shared.eval(
sessionKey: sessionKey,
sessionKey: "main",
javaScript: params.javaScript)
let payload = try Self.encodePayload(["result": result] as [String: String])
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
@@ -120,8 +109,7 @@ actor MacNodeRuntime {
}()
let quality = params?.quality ?? 0.9
let sessionKey = self.mainSessionKey
let path = try await CanvasManager.shared.snapshot(sessionKey: sessionKey, outPath: nil)
let path = try await CanvasManager.shared.snapshot(sessionKey: "main", outPath: nil)
defer { try? FileManager.default.removeItem(atPath: path) }
let data = try Data(contentsOf: URL(fileURLWithPath: path))
guard let image = NSImage(data: data) else {
@@ -331,8 +319,7 @@ actor MacNodeRuntime {
private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
try await self.ensureA2UIHost()
let sessionKey = self.mainSessionKey
let json = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: """
let json = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: """
(() => {
if (!globalThis.clawdbotA2UI) return JSON.stringify({ ok: false, error: "missing clawdbotA2UI" });
return JSON.stringify(globalThis.clawdbotA2UI.reset());
@@ -371,8 +358,7 @@ actor MacNodeRuntime {
}
})()
"""
let sessionKey = self.mainSessionKey
let resultJSON = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: js)
let resultJSON = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: js)
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",
])
}
let sessionKey = self.mainSessionKey
_ = 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 }
throw NSError(domain: "Canvas", code: 31, userInfo: [
@@ -404,8 +389,7 @@ actor MacNodeRuntime {
let deadline = poll ? Date().addingTimeInterval(6.0) : Date()
while true {
do {
let sessionKey = self.mainSessionKey
let ready = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: """
let ready = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: """
(() => String(Boolean(globalThis.clawdbotA2UI)))()
""")
let trimmed = ready.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -694,7 +694,7 @@ extension OnboardingView {
systemImage: "bubble.left.and.bubble.right")
self.featureActionRow(
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")
{
self.openSettings(tab: .connections)
@@ -58,13 +58,6 @@ final class OnboardingWizardModel {
func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async {
guard self.sessionId == nil, !self.isStarting 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.errorMessage = nil
self.lastStartMode = mode
@@ -156,7 +149,8 @@ final class OnboardingWizardModel {
onboardingWizardLogger.error("wizard step decode failed")
}
if res.done { self.currentStep = nil }
if res.done || status == "done" || status == "cancelled" || status == "error" {
if res.done || status == "done" || status == "cancelled" || status == "error"
{
self.sessionId = nil
}
}
@@ -184,33 +178,6 @@ final class OnboardingWizardModel {
Task { await self.startIfNeeded(mode: mode, workspace: self.lastStartWorkspace) }
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 {
@@ -37,7 +37,7 @@ enum VoiceWakeForwarder {
var thinking: String = "low"
var deliver: Bool = true
var to: String?
var channel: GatewayAgentChannel = .last
var provider: GatewayAgentProvider = .last
}
@discardableResult
@@ -46,14 +46,14 @@ enum VoiceWakeForwarder {
options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError>
{
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(
message: payload,
sessionKey: options.sessionKey,
thinking: options.thinking,
deliver: deliver,
to: options.to,
channel: options.channel))
provider: options.provider))
if result.ok {
self.logger.info("voice wake forward ok")
@@ -28,11 +28,9 @@ final class WorkActivityStore {
private var currentSessionKey: String?
private var toolSeqBySession: [String: Int] = [:]
private var mainSessionKeyStorage = "main"
private let mainSessionKey = "main"
private let toolResultGrace: TimeInterval = 2.0
var mainSessionKey: String { self.mainSessionKeyStorage }
func handleJob(sessionKey: String, state: String) {
let isStart = state.lowercased() == "started" || state.lowercased() == "streaming"
if isStart {
@@ -131,17 +129,6 @@ final class WorkActivityStore {
self.refreshDerivedState()
}
func setMainSessionKey(_ sessionKey: String) {
let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard trimmed != self.mainSessionKeyStorage else { return }
self.mainSessionKeyStorage = trimmed
if let current = self.currentSessionKey, !self.isActive(sessionKey: current) {
self.pickNextSession()
}
self.refreshDerivedState()
}
private func clearJob(sessionKey: String) {
guard self.jobs[sessionKey] != nil else { return }
self.jobs.removeValue(forKey: sessionKey)
@@ -164,8 +151,8 @@ final class WorkActivityStore {
private func pickNextSession() {
// Prefer main if present.
if self.isActive(sessionKey: self.mainSessionKeyStorage) {
self.currentSessionKey = self.mainSessionKeyStorage
if self.isActive(sessionKey: self.mainSessionKey) {
self.currentSessionKey = self.mainSessionKey
return
}
@@ -176,7 +163,7 @@ final class WorkActivityStore {
}
private func role(for sessionKey: String) -> SessionRole {
sessionKey == self.mainSessionKeyStorage ? .main : .other
sessionKey == self.mainSessionKey ? .main : .other
}
private func isActive(sessionKey: String) -> Bool {
@@ -252,7 +252,6 @@ public struct Snapshot: Codable, Sendable {
public let uptimems: Int
public let configpath: String?
public let statedir: String?
public let sessiondefaults: [String: AnyCodable]?
public init(
presence: [PresenceEntry],
@@ -260,8 +259,7 @@ public struct Snapshot: Codable, Sendable {
stateversion: StateVersion,
uptimems: Int,
configpath: String?,
statedir: String?,
sessiondefaults: [String: AnyCodable]?
statedir: String?
) {
self.presence = presence
self.health = health
@@ -269,7 +267,6 @@ public struct Snapshot: Codable, Sendable {
self.uptimems = uptimems
self.configpath = configpath
self.statedir = statedir
self.sessiondefaults = sessiondefaults
}
private enum CodingKeys: String, CodingKey {
case presence
@@ -278,7 +275,6 @@ public struct Snapshot: Codable, Sendable {
case uptimems = "uptimeMs"
case configpath = "configPath"
case statedir = "stateDir"
case sessiondefaults = "sessionDefaults"
}
}
@@ -345,7 +341,7 @@ public struct SendParams: Codable, Sendable {
public let message: String
public let mediaurl: String?
public let gifplayback: Bool?
public let channel: String?
public let provider: String?
public let accountid: String?
public let idempotencykey: String
@@ -354,7 +350,7 @@ public struct SendParams: Codable, Sendable {
message: String,
mediaurl: String?,
gifplayback: Bool?,
channel: String?,
provider: String?,
accountid: String?,
idempotencykey: String
) {
@@ -362,7 +358,7 @@ public struct SendParams: Codable, Sendable {
self.message = message
self.mediaurl = mediaurl
self.gifplayback = gifplayback
self.channel = channel
self.provider = provider
self.accountid = accountid
self.idempotencykey = idempotencykey
}
@@ -371,7 +367,7 @@ public struct SendParams: Codable, Sendable {
case message
case mediaurl = "mediaUrl"
case gifplayback = "gifPlayback"
case channel
case provider
case accountid = "accountId"
case idempotencykey = "idempotencyKey"
}
@@ -383,7 +379,7 @@ public struct PollParams: Codable, Sendable {
public let options: [String]
public let maxselections: Int?
public let durationhours: Int?
public let channel: String?
public let provider: String?
public let accountid: String?
public let idempotencykey: String
@@ -393,7 +389,7 @@ public struct PollParams: Codable, Sendable {
options: [String],
maxselections: Int?,
durationhours: Int?,
channel: String?,
provider: String?,
accountid: String?,
idempotencykey: String
) {
@@ -402,7 +398,7 @@ public struct PollParams: Codable, Sendable {
self.options = options
self.maxselections = maxselections
self.durationhours = durationhours
self.channel = channel
self.provider = provider
self.accountid = accountid
self.idempotencykey = idempotencykey
}
@@ -412,7 +408,7 @@ public struct PollParams: Codable, Sendable {
case options
case maxselections = "maxSelections"
case durationhours = "durationHours"
case channel
case provider
case accountid = "accountId"
case idempotencykey = "idempotencyKey"
}
@@ -426,7 +422,7 @@ public struct AgentParams: Codable, Sendable {
public let thinking: String?
public let deliver: Bool?
public let attachments: [AnyCodable]?
public let channel: String?
public let provider: String?
public let timeout: Int?
public let lane: String?
public let extrasystemprompt: String?
@@ -442,7 +438,7 @@ public struct AgentParams: Codable, Sendable {
thinking: String?,
deliver: Bool?,
attachments: [AnyCodable]?,
channel: String?,
provider: String?,
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
@@ -457,7 +453,7 @@ public struct AgentParams: Codable, Sendable {
self.thinking = thinking
self.deliver = deliver
self.attachments = attachments
self.channel = channel
self.provider = provider
self.timeout = timeout
self.lane = lane
self.extrasystemprompt = extrasystemprompt
@@ -473,7 +469,7 @@ public struct AgentParams: Codable, Sendable {
case thinking
case deliver
case attachments
case channel
case provider
case timeout
case lane
case extrasystemprompt = "extraSystemPrompt"
@@ -849,67 +845,42 @@ public struct ConfigGetParams: Codable, Sendable {
public struct ConfigSetParams: Codable, Sendable {
public let raw: String
public let basehash: String?
public init(
raw: String,
basehash: String?
raw: String
) {
self.raw = raw
self.basehash = basehash
}
private enum CodingKeys: String, CodingKey {
case raw
case basehash = "baseHash"
}
}
public struct ConfigApplyParams: Codable, Sendable {
public let raw: String
public let basehash: String?
public let sessionkey: String?
public let note: String?
public let restartdelayms: Int?
public init(
raw: String,
basehash: String?,
sessionkey: String?,
note: String?,
restartdelayms: Int?
) {
self.raw = raw
self.basehash = basehash
self.sessionkey = sessionkey
self.note = note
self.restartdelayms = restartdelayms
}
private enum CodingKeys: String, CodingKey {
case raw
case basehash = "baseHash"
case sessionkey = "sessionKey"
case note
case restartdelayms = "restartDelayMs"
}
}
public struct ConfigPatchParams: Codable, Sendable {
public let raw: String
public let basehash: String?
public init(
raw: String,
basehash: String?
) {
self.raw = raw
self.basehash = basehash
}
private enum CodingKeys: String, CodingKey {
case raw
case basehash = "baseHash"
}
}
public struct ConfigSchemaParams: Codable, Sendable {
}
@@ -1131,7 +1102,7 @@ public struct TalkModeParams: Codable, Sendable {
}
}
public struct ChannelsStatusParams: Codable, Sendable {
public struct ProvidersStatusParams: Codable, Sendable {
public let probe: Bool?
public let timeoutms: Int?
@@ -1148,52 +1119,52 @@ public struct ChannelsStatusParams: Codable, Sendable {
}
}
public struct ChannelsStatusResult: Codable, Sendable {
public struct ProvidersStatusResult: Codable, Sendable {
public let ts: Int
public let channelorder: [String]
public let channellabels: [String: AnyCodable]
public let channels: [String: AnyCodable]
public let channelaccounts: [String: AnyCodable]
public let channeldefaultaccountid: [String: AnyCodable]
public let providerorder: [String]
public let providerlabels: [String: AnyCodable]
public let providers: [String: AnyCodable]
public let provideraccounts: [String: AnyCodable]
public let providerdefaultaccountid: [String: AnyCodable]
public init(
ts: Int,
channelorder: [String],
channellabels: [String: AnyCodable],
channels: [String: AnyCodable],
channelaccounts: [String: AnyCodable],
channeldefaultaccountid: [String: AnyCodable]
providerorder: [String],
providerlabels: [String: AnyCodable],
providers: [String: AnyCodable],
provideraccounts: [String: AnyCodable],
providerdefaultaccountid: [String: AnyCodable]
) {
self.ts = ts
self.channelorder = channelorder
self.channellabels = channellabels
self.channels = channels
self.channelaccounts = channelaccounts
self.channeldefaultaccountid = channeldefaultaccountid
self.providerorder = providerorder
self.providerlabels = providerlabels
self.providers = providers
self.provideraccounts = provideraccounts
self.providerdefaultaccountid = providerdefaultaccountid
}
private enum CodingKeys: String, CodingKey {
case ts
case channelorder = "channelOrder"
case channellabels = "channelLabels"
case channels
case channelaccounts = "channelAccounts"
case channeldefaultaccountid = "channelDefaultAccountId"
case providerorder = "providerOrder"
case providerlabels = "providerLabels"
case providers
case provideraccounts = "providerAccounts"
case providerdefaultaccountid = "providerDefaultAccountId"
}
}
public struct ChannelsLogoutParams: Codable, Sendable {
public let channel: String
public struct ProvidersLogoutParams: Codable, Sendable {
public let provider: String
public let accountid: String?
public init(
channel: String,
provider: String,
accountid: String?
) {
self.channel = channel
self.provider = provider
self.accountid = accountid
}
private enum CodingKeys: String, CodingKey {
case channel
case provider
case accountid = "accountId"
}
}
@@ -1385,7 +1356,6 @@ public struct CronJob: Codable, Sendable {
public let name: String
public let description: String?
public let enabled: Bool
public let deleteafterrun: Bool?
public let createdatms: Int
public let updatedatms: Int
public let schedule: AnyCodable
@@ -1401,7 +1371,6 @@ public struct CronJob: Codable, Sendable {
name: String,
description: String?,
enabled: Bool,
deleteafterrun: Bool?,
createdatms: Int,
updatedatms: Int,
schedule: AnyCodable,
@@ -1416,7 +1385,6 @@ public struct CronJob: Codable, Sendable {
self.name = name
self.description = description
self.enabled = enabled
self.deleteafterrun = deleteafterrun
self.createdatms = createdatms
self.updatedatms = updatedatms
self.schedule = schedule
@@ -1432,7 +1400,6 @@ public struct CronJob: Codable, Sendable {
case name
case description
case enabled
case deleteafterrun = "deleteAfterRun"
case createdatms = "createdAtMs"
case updatedatms = "updatedAtMs"
case schedule
@@ -1465,7 +1432,6 @@ public struct CronAddParams: Codable, Sendable {
public let agentid: AnyCodable?
public let description: String?
public let enabled: Bool?
public let deleteafterrun: Bool?
public let schedule: AnyCodable
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
@@ -1477,7 +1443,6 @@ public struct CronAddParams: Codable, Sendable {
agentid: AnyCodable?,
description: String?,
enabled: Bool?,
deleteafterrun: Bool?,
schedule: AnyCodable,
sessiontarget: AnyCodable,
wakemode: AnyCodable,
@@ -1488,7 +1453,6 @@ public struct CronAddParams: Codable, Sendable {
self.agentid = agentid
self.description = description
self.enabled = enabled
self.deleteafterrun = deleteafterrun
self.schedule = schedule
self.sessiontarget = sessiontarget
self.wakemode = wakemode
@@ -1500,7 +1464,6 @@ public struct CronAddParams: Codable, Sendable {
case agentid = "agentId"
case description
case enabled
case deleteafterrun = "deleteAfterRun"
case schedule
case sessiontarget = "sessionTarget"
case wakemode = "wakeMode"
@@ -1509,6 +1472,70 @@ public struct CronAddParams: Codable, Sendable {
}
}
public struct CronUpdateParams: Codable, Sendable {
public let id: String
public let patch: [String: AnyCodable]
public init(
id: String,
patch: [String: AnyCodable]
) {
self.id = id
self.patch = patch
}
private enum CodingKeys: String, CodingKey {
case id
case patch
}
}
public struct CronRemoveParams: Codable, Sendable {
public let id: String
public init(
id: String
) {
self.id = id
}
private enum CodingKeys: String, CodingKey {
case id
}
}
public struct CronRunParams: Codable, Sendable {
public let id: String
public let mode: AnyCodable?
public init(
id: String,
mode: AnyCodable?
) {
self.id = id
self.mode = mode
}
private enum CodingKeys: String, CodingKey {
case id
case mode
}
}
public struct CronRunsParams: Codable, Sendable {
public let id: String
public let limit: Int?
public init(
id: String,
limit: Int?
) {
self.id = id
self.limit = limit
}
private enum CodingKeys: String, CodingKey {
case id
case limit
}
}
public struct CronRunLogEntry: Codable, Sendable {
public let ts: Int
public let jobid: String
@@ -22,17 +22,17 @@ struct WizardCliOptions {
case "--json":
opts.json = true
case "--url":
opts.url = self.nextValue(args, index: &i)
opts.url = nextValue(args, index: &i)
case "--token":
opts.token = self.nextValue(args, index: &i)
opts.token = nextValue(args, index: &i)
case "--password":
opts.password = self.nextValue(args, index: &i)
opts.password = nextValue(args, index: &i)
case "--mode":
if let value = nextValue(args, index: &i) {
opts.mode = value
}
case "--workspace":
opts.workspace = self.nextValue(args, index: &i)
opts.workspace = nextValue(args, index: &i)
default:
break
}
@@ -118,8 +118,7 @@ private func printUsage() {
clawdbot-mac-wizard
Usage:
clawdbot-mac-wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--workspace <path>] [--json]
clawdbot-mac-wizard [--url <ws://host:port>] [--token <token>] [--password <password>] [--mode <local|remote>] [--workspace <path>] [--json]
Options:
--url <url> Gateway WebSocket URL (overrides config)
@@ -7,16 +7,16 @@ import Testing
struct ConnectionsSettingsSmokeTests {
@Test func connectionsSettingsBuildsBodyWithSnapshot() {
let store = ConnectionsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot(
store.snapshot = ProvidersStatusSnapshot(
ts: 1_700_000_000_000,
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
channelLabels: [
providerOrder: ["whatsapp", "telegram", "signal", "imessage"],
providerLabels: [
"whatsapp": "WhatsApp",
"telegram": "Telegram",
"signal": "Signal",
"imessage": "iMessage",
],
channels: [
providers: [
"whatsapp": AnyCodable([
"configured": true,
"linked": true,
@@ -72,8 +72,8 @@ struct ConnectionsSettingsSmokeTests {
"lastProbeAt": 1_700_000_050_000,
]),
],
channelAccounts: [:],
channelDefaultAccountId: [
providerAccounts: [:],
providerDefaultAccountId: [
"whatsapp": "default",
"telegram": "default",
"signal": "default",
@@ -97,16 +97,16 @@ struct ConnectionsSettingsSmokeTests {
@Test func connectionsSettingsBuildsBodyWithoutSnapshot() {
let store = ConnectionsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot(
store.snapshot = ProvidersStatusSnapshot(
ts: 1_700_000_000_000,
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
channelLabels: [
providerOrder: ["whatsapp", "telegram", "signal", "imessage"],
providerLabels: [
"whatsapp": "WhatsApp",
"telegram": "Telegram",
"signal": "Signal",
"imessage": "iMessage",
],
channels: [
providers: [
"whatsapp": AnyCodable([
"configured": false,
"linked": false,
@@ -148,8 +148,8 @@ struct ConnectionsSettingsSmokeTests {
"lastProbeAt": 1_700_000_200_000,
]),
],
channelAccounts: [:],
channelDefaultAccountId: [
providerAccounts: [:],
providerDefaultAccountId: [
"whatsapp": "default",
"telegram": "default",
"signal": "default",
@@ -23,11 +23,8 @@ struct CronJobEditorSmokeTests {
@Test func cronJobEditorBuildsBodyForExistingJob() {
let job = CronJob(
id: "job-1",
agentId: "ops",
name: "Daily summary",
description: nil,
enabled: true,
deleteAfterRun: nil,
createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_000_000,
schedule: .every(everyMs: 3_600_000, anchorMs: 1_700_000_000_000),
@@ -68,24 +65,4 @@ struct CronJobEditorSmokeTests {
onSave: { _ in })
view.exerciseForTesting()
}
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
var view = CronJobEditor(
job: nil,
isSaving: .constant(false),
error: .constant(nil),
onCancel: {},
onSave: { _ in })
view.name = "One-shot"
view.sessionTarget = .main
view.payloadKind = .systemEvent
view.systemEventText = "hello"
view.scheduleKind = .at
view.atDate = Date(timeIntervalSince1970: 1_700_000_000)
view.deleteAfterRun = true
let payload = try view.buildPayload()
let raw = payload["deleteAfterRun"]?.value as? Bool
#expect(raw == true)
}
}
@@ -39,27 +39,6 @@ struct CronModelsTests {
#expect(decoded == payload)
}
@Test func jobEncodesAndDecodesDeleteAfterRun() throws {
let job = CronJob(
id: "job-1",
agentId: nil,
name: "One-shot",
description: nil,
enabled: true,
deleteAfterRun: true,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .at(atMs: 1_700_000_000_000),
sessionTarget: .main,
wakeMode: .now,
payload: .systemEvent(text: "ping"),
isolation: nil,
state: CronJobState())
let data = try JSONEncoder().encode(job)
let decoded = try JSONDecoder().decode(CronJob.self, from: data)
#expect(decoded.deleteAfterRun == true)
}
@Test func scheduleDecodeRejectsUnknownKind() {
let json = """
{"kind":"wat","atMs":1}
@@ -81,11 +60,9 @@ struct CronModelsTests {
@Test func displayNameTrimsWhitespaceAndFallsBack() {
let base = CronJob(
id: "x",
agentId: nil,
name: " hello ",
description: nil,
enabled: true,
deleteAfterRun: nil,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .at(atMs: 0),
@@ -104,11 +81,9 @@ struct CronModelsTests {
@Test func nextRunDateAndLastRunDateDeriveFromState() {
let job = CronJob(
id: "x",
agentId: nil,
name: "t",
description: nil,
enabled: true,
deleteAfterRun: nil,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .at(atMs: 0),
@@ -1,63 +1,36 @@
import Foundation
import Testing
@testable import Clawdbot
@Suite struct GatewayEndpointStoreTests {
@Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() {
let snapshot = LaunchAgentPlistSnapshot(
programArguments: [],
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
port: nil,
bind: nil,
token: "launchd-token",
password: nil)
let envToken = GatewayEndpointStore._testResolveGatewayToken(
isRemote: false,
root: [:],
env: ["CLAWDBOT_GATEWAY_TOKEN": "env-token"],
launchdSnapshot: snapshot)
#expect(envToken == "env-token")
let fallbackToken = GatewayEndpointStore._testResolveGatewayToken(
isRemote: false,
root: [:],
env: [:],
launchdSnapshot: snapshot)
#expect(fallbackToken == "launchd-token")
@Suite(.serialized)
struct GatewayEndpointStoreTests {
@Test func resolvesLocalHostFromBindModes() {
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "loopback",
tailscaleIP: "100.64.0.10") == "127.0.0.1")
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "lan",
tailscaleIP: "100.64.0.10") == "127.0.0.1")
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: "100.64.0.10") == "100.64.0.10")
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: nil) == "127.0.0.1")
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: "100.64.0.10") == "100.64.0.10")
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: nil) == "127.0.0.1")
}
@Test func resolveGatewayTokenIgnoresLaunchdInRemoteMode() {
let snapshot = LaunchAgentPlistSnapshot(
programArguments: [],
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
port: nil,
bind: nil,
token: "launchd-token",
password: nil)
let token = GatewayEndpointStore._testResolveGatewayToken(
isRemote: true,
root: [:],
env: [:],
launchdSnapshot: snapshot)
#expect(token == nil)
}
@Test func resolveGatewayPasswordFallsBackToLaunchd() {
let snapshot = LaunchAgentPlistSnapshot(
programArguments: [],
environment: ["CLAWDBOT_GATEWAY_PASSWORD": "launchd-pass"],
port: nil,
bind: nil,
token: nil,
password: "launchd-pass")
let password = GatewayEndpointStore._testResolveGatewayPassword(
isRemote: false,
root: [:],
env: [:],
launchdSnapshot: snapshot)
#expect(password == "launchd-pass")
@Test func resolvesBindModeFromEnvOrConfig() {
let root: [String: Any] = ["gateway": ["bind": "tailnet"]]
#expect(GatewayEndpointStore._testResolveGatewayBindMode(
root: root,
env: [:]) == "tailnet")
#expect(GatewayEndpointStore._testResolveGatewayBindMode(
root: root,
env: ["CLAWDBOT_GATEWAY_BIND": "lan"]) == "lan")
}
}
@@ -5,14 +5,14 @@ import Testing
@Suite struct HealthDecodeTests {
private let sampleJSON: String = // minimal but complete payload
"""
{"ts":1733622000,"durationMs":420,"channels":{"whatsapp":{"linked":true,"authAgeMs":120000},"telegram":{"configured":true,"probe":{"ok":true,"elapsedMs":800}}},"channelOrder":["whatsapp","telegram"],"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}}
{"ts":1733622000,"durationMs":420,"providers":{"whatsapp":{"linked":true,"authAgeMs":120000},"telegram":{"configured":true,"probe":{"ok":true,"elapsedMs":800}}},"providerOrder":["whatsapp","telegram"],"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}}
"""
@Test func decodesCleanJSON() async throws {
let data = Data(sampleJSON.utf8)
let snap = decodeHealthSnapshot(from: data)
#expect(snap?.channels["whatsapp"]?.linked == true)
#expect(snap?.providers["whatsapp"]?.linked == true)
#expect(snap?.sessions.count == 1)
}
@@ -20,7 +20,7 @@ import Testing
let noisy = "debug: something logged\n" + self.sampleJSON + "\ntrailer"
let snap = decodeHealthSnapshot(from: Data(noisy.utf8))
#expect(snap?.channels["telegram"]?.probe?.elapsedMs == 800)
#expect(snap?.providers["telegram"]?.probe?.elapsedMs == 800)
}
@Test func failsWithoutBraces() async throws {
@@ -3,12 +3,12 @@ import Testing
@testable import Clawdbot
@Suite struct HealthStoreStateTests {
@Test @MainActor func linkedChannelProbeFailureDegradesState() async throws {
@Test @MainActor func linkedProviderProbeFailureDegradesState() async throws {
let snap = HealthSnapshot(
ok: true,
ts: 0,
durationMs: 1,
channels: [
providers: [
"whatsapp": .init(
configured: true,
linked: true,
@@ -19,24 +19,28 @@ import Testing
error: "gateway connect failed",
elapsedMs: 12,
bot: nil,
webhook: nil),
lastProbeAt: 0),
webhook: nil
),
lastProbeAt: 0
),
],
channelOrder: ["whatsapp"],
channelLabels: ["whatsapp": "WhatsApp"],
providerOrder: ["whatsapp"],
providerLabels: ["whatsapp": "WhatsApp"],
heartbeatSeconds: 60,
sessions: .init(path: "/tmp/sessions.json", count: 0, recent: []))
sessions: .init(path: "/tmp/sessions.json", count: 0, recent: [])
)
let store = HealthStore.shared
store.__setSnapshotForTest(snap, lastError: nil)
switch store.state {
case let .degraded(message):
case .degraded(let message):
#expect(!message.isEmpty)
default:
Issue.record("Expected degraded state when probe fails for linked channel")
Issue.record("Expected degraded state when probe fails for linked provider")
}
#expect(store.summaryLine.contains("probe degraded"))
}
}
@@ -1,24 +0,0 @@
import Darwin
import Foundation
import Testing
@testable import Clawdbot
@Suite struct LogLocatorTests {
@Test func launchdGatewayLogPathEnsuresTmpDirExists() throws {
let fm = FileManager.default
let baseDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let logDir = baseDir.appendingPathComponent("clawdbot-tests-\(UUID().uuidString)")
setenv("CLAWDBOT_LOG_DIR", logDir.path, 1)
defer {
unsetenv("CLAWDBOT_LOG_DIR")
try? fm.removeItem(at: logDir)
}
_ = LogLocator.launchdGatewayLogPath
var isDir: ObjCBool = false
#expect(fm.fileExists(atPath: logDir.path, isDirectory: &isDir))
#expect(isDir.boolValue == true)
}
}
@@ -12,11 +12,9 @@ struct SettingsViewSmokeTests {
let job1 = CronJob(
id: "job-1",
agentId: "ops",
name: " Morning Check-in ",
description: nil,
enabled: true,
deleteAfterRun: nil,
createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_100_000,
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
@@ -34,11 +32,9 @@ struct SettingsViewSmokeTests {
let job2 = CronJob(
id: "job-2",
agentId: nil,
name: "",
description: nil,
enabled: false,
deleteAfterRun: nil,
createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_100_000,
schedule: .every(everyMs: 30000, anchorMs: nil),
@@ -100,18 +100,11 @@ public struct BridgeHelloOk: Codable, Sendable {
public let type: String
public let serverName: String
public let canvasHostUrl: String?
public let mainSessionKey: String?
public init(
type: String = "hello-ok",
serverName: String,
canvasHostUrl: String? = nil,
mainSessionKey: String? = nil)
{
public init(type: String = "hello-ok", serverName: String, canvasHostUrl: String? = nil) {
self.type = type
self.serverName = serverName
self.canvasHostUrl = canvasHostUrl
self.mainSessionKey = mainSessionKey
}
}
-22
View File
@@ -1,22 +0,0 @@
# Clawdbot Chrome Extension (Browser Relay)
Purpose: attach Clawdbot to an existing Chrome tab so the Gateway can automate it (via the local CDP relay server).
## Dev / load unpacked
1. Build/run Clawdbot Gateway with browser control enabled.
2. Ensure the relay server is reachable at `http://127.0.0.1:18792/` (default).
3. Install the extension to a stable path:
```bash
clawdbot browser extension install
clawdbot browser extension path
```
4. Chrome → `chrome://extensions` → enable “Developer mode”.
5. “Load unpacked” → select the path printed above.
6. Pin the extension. Click the icon on a tab to attach/detach.
## Options
- `Relay port`: defaults to `18792`.
-438
View File
@@ -1,438 +0,0 @@
const DEFAULT_PORT = 18792
const BADGE = {
on: { text: 'ON', color: '#FF5A36' },
off: { text: '', color: '#000000' },
connecting: { text: '…', color: '#F59E0B' },
error: { text: '!', color: '#B91C1C' },
}
/** @type {WebSocket|null} */
let relayWs = null
/** @type {Promise<void>|null} */
let relayConnectPromise = null
let debuggerListenersInstalled = false
let nextSession = 1
/** @type {Map<number, {state:'connecting'|'connected', sessionId?:string, targetId?:string, attachOrder?:number}>} */
const tabs = new Map()
/** @type {Map<string, number>} */
const tabBySession = new Map()
/** @type {Map<string, number>} */
const childSessionToTab = new Map()
/** @type {Map<number, {resolve:(v:any)=>void, reject:(e:Error)=>void}>} */
const pending = new Map()
function nowStack() {
try {
return new Error().stack || ''
} catch {
return ''
}
}
async function getRelayPort() {
const stored = await chrome.storage.local.get(['relayPort'])
const raw = stored.relayPort
const n = Number.parseInt(String(raw || ''), 10)
if (!Number.isFinite(n) || n <= 0 || n > 65535) return DEFAULT_PORT
return n
}
function setBadge(tabId, kind) {
const cfg = BADGE[kind]
void chrome.action.setBadgeText({ tabId, text: cfg.text })
void chrome.action.setBadgeBackgroundColor({ tabId, color: cfg.color })
void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {})
}
async function ensureRelayConnection() {
if (relayWs && relayWs.readyState === WebSocket.OPEN) return
if (relayConnectPromise) return await relayConnectPromise
relayConnectPromise = (async () => {
const port = await getRelayPort()
const httpBase = `http://127.0.0.1:${port}`
const wsUrl = `ws://127.0.0.1:${port}/extension`
// Fast preflight: is the relay server up?
try {
await fetch(`${httpBase}/`, { method: 'HEAD', signal: AbortSignal.timeout(2000) })
} catch (err) {
throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`)
}
const ws = new WebSocket(wsUrl)
relayWs = ws
await new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000)
ws.onopen = () => {
clearTimeout(t)
resolve()
}
ws.onerror = () => {
clearTimeout(t)
reject(new Error('WebSocket connect failed'))
}
ws.onclose = (ev) => {
clearTimeout(t)
reject(new Error(`WebSocket closed (${ev.code} ${ev.reason || 'no reason'})`))
}
})
ws.onmessage = (event) => void onRelayMessage(String(event.data || ''))
ws.onclose = () => onRelayClosed('closed')
ws.onerror = () => onRelayClosed('error')
if (!debuggerListenersInstalled) {
debuggerListenersInstalled = true
chrome.debugger.onEvent.addListener(onDebuggerEvent)
chrome.debugger.onDetach.addListener(onDebuggerDetach)
}
})()
try {
await relayConnectPromise
} finally {
relayConnectPromise = null
}
}
function onRelayClosed(reason) {
relayWs = null
for (const [id, p] of pending.entries()) {
pending.delete(id)
p.reject(new Error(`Relay disconnected (${reason})`))
}
for (const tabId of tabs.keys()) {
void chrome.debugger.detach({ tabId }).catch(() => {})
setBadge(tabId, 'connecting')
void chrome.action.setTitle({
tabId,
title: 'Clawdbot Browser Relay: disconnected (click to re-attach)',
})
}
tabs.clear()
tabBySession.clear()
childSessionToTab.clear()
}
function sendToRelay(payload) {
const ws = relayWs
if (!ws || ws.readyState !== WebSocket.OPEN) {
throw new Error('Relay not connected')
}
ws.send(JSON.stringify(payload))
}
async function maybeOpenHelpOnce() {
try {
const stored = await chrome.storage.local.get(['helpOnErrorShown'])
if (stored.helpOnErrorShown === true) return
await chrome.storage.local.set({ helpOnErrorShown: true })
await chrome.runtime.openOptionsPage()
} catch {
// ignore
}
}
function requestFromRelay(command) {
const id = command.id
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject })
try {
sendToRelay(command)
} catch (err) {
pending.delete(id)
reject(err instanceof Error ? err : new Error(String(err)))
}
})
}
async function onRelayMessage(text) {
/** @type {any} */
let msg
try {
msg = JSON.parse(text)
} catch {
return
}
if (msg && msg.method === 'ping') {
try {
sendToRelay({ method: 'pong' })
} catch {
// ignore
}
return
}
if (msg && typeof msg.id === 'number' && (msg.result !== undefined || msg.error !== undefined)) {
const p = pending.get(msg.id)
if (!p) return
pending.delete(msg.id)
if (msg.error) p.reject(new Error(String(msg.error)))
else p.resolve(msg.result)
return
}
if (msg && typeof msg.id === 'number' && msg.method === 'forwardCDPCommand') {
try {
const result = await handleForwardCdpCommand(msg)
sendToRelay({ id: msg.id, result })
} catch (err) {
sendToRelay({ id: msg.id, error: err instanceof Error ? err.message : String(err) })
}
}
}
function getTabBySessionId(sessionId) {
const direct = tabBySession.get(sessionId)
if (direct) return { tabId: direct, kind: 'main' }
const child = childSessionToTab.get(sessionId)
if (child) return { tabId: child, kind: 'child' }
return null
}
function getTabByTargetId(targetId) {
for (const [tabId, tab] of tabs.entries()) {
if (tab.targetId === targetId) return tabId
}
return null
}
async function attachTab(tabId, opts = {}) {
const debuggee = { tabId }
await chrome.debugger.attach(debuggee, '1.3')
await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch(() => {})
const info = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo'))
const targetInfo = info?.targetInfo
const targetId = String(targetInfo?.targetId || '').trim()
if (!targetId) {
throw new Error('Target.getTargetInfo returned no targetId')
}
const sessionId = `cb-tab-${nextSession++}`
const attachOrder = nextSession
tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder })
tabBySession.set(sessionId, tabId)
void chrome.action.setTitle({
tabId,
title: 'Clawdbot Browser Relay: attached (click to detach)',
})
if (!opts.skipAttachedEvent) {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.attachedToTarget',
params: {
sessionId,
targetInfo: { ...targetInfo, attached: true },
waitingForDebugger: false,
},
},
})
}
setBadge(tabId, 'on')
return { sessionId, targetId }
}
async function detachTab(tabId, reason) {
const tab = tabs.get(tabId)
if (tab?.sessionId && tab?.targetId) {
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.detachedFromTarget',
params: { sessionId: tab.sessionId, targetId: tab.targetId, reason },
},
})
} catch {
// ignore
}
}
if (tab?.sessionId) tabBySession.delete(tab.sessionId)
tabs.delete(tabId)
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
if (parentTabId === tabId) childSessionToTab.delete(childSessionId)
}
try {
await chrome.debugger.detach({ tabId })
} catch {
// ignore
}
setBadge(tabId, 'off')
void chrome.action.setTitle({
tabId,
title: 'Clawdbot Browser Relay (click to attach/detach)',
})
}
async function connectOrToggleForActiveTab() {
const [active] = await chrome.tabs.query({ active: true, currentWindow: true })
const tabId = active?.id
if (!tabId) return
const existing = tabs.get(tabId)
if (existing?.state === 'connected') {
await detachTab(tabId, 'toggle')
return
}
tabs.set(tabId, { state: 'connecting' })
setBadge(tabId, 'connecting')
void chrome.action.setTitle({
tabId,
title: 'Clawdbot Browser Relay: connecting to local relay…',
})
try {
await ensureRelayConnection()
await attachTab(tabId)
} catch (err) {
tabs.delete(tabId)
setBadge(tabId, 'error')
void chrome.action.setTitle({
tabId,
title: 'Clawdbot Browser Relay: relay not running (open options for setup)',
})
void maybeOpenHelpOnce()
// Extra breadcrumbs in chrome://extensions service worker logs.
const message = err instanceof Error ? err.message : String(err)
console.warn('attach failed', message, nowStack())
}
}
async function handleForwardCdpCommand(msg) {
const method = String(msg?.params?.method || '').trim()
const params = msg?.params?.params || undefined
const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined
// Map command to tab
const bySession = sessionId ? getTabBySessionId(sessionId) : null
const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined
const tabId =
bySession?.tabId ||
(targetId ? getTabByTargetId(targetId) : null) ||
(() => {
// No sessionId: pick the first connected tab (stable-ish).
for (const [id, tab] of tabs.entries()) {
if (tab.state === 'connected') return id
}
return null
})()
if (!tabId) throw new Error(`No attached tab for method ${method}`)
/** @type {chrome.debugger.DebuggerSession} */
const debuggee = { tabId }
if (method === 'Runtime.enable') {
try {
await chrome.debugger.sendCommand(debuggee, 'Runtime.disable')
await new Promise((r) => setTimeout(r, 50))
} catch {
// ignore
}
return await chrome.debugger.sendCommand(debuggee, 'Runtime.enable', params)
}
if (method === 'Target.createTarget') {
const url = typeof params?.url === 'string' ? params.url : 'about:blank'
const tab = await chrome.tabs.create({ url, active: false })
if (!tab.id) throw new Error('Failed to create tab')
await new Promise((r) => setTimeout(r, 100))
const attached = await attachTab(tab.id)
return { targetId: attached.targetId }
}
if (method === 'Target.closeTarget') {
const target = typeof params?.targetId === 'string' ? params.targetId : ''
const toClose = target ? getTabByTargetId(target) : tabId
if (!toClose) return { success: false }
try {
await chrome.tabs.remove(toClose)
} catch {
return { success: false }
}
return { success: true }
}
if (method === 'Target.activateTarget') {
const target = typeof params?.targetId === 'string' ? params.targetId : ''
const toActivate = target ? getTabByTargetId(target) : tabId
if (!toActivate) return {}
const tab = await chrome.tabs.get(toActivate).catch(() => null)
if (!tab) return {}
if (tab.windowId) {
await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {})
}
await chrome.tabs.update(toActivate, { active: true }).catch(() => {})
return {}
}
const tabState = tabs.get(tabId)
const mainSessionId = tabState?.sessionId
const debuggerSession =
sessionId && mainSessionId && sessionId !== mainSessionId
? { ...debuggee, sessionId }
: debuggee
return await chrome.debugger.sendCommand(debuggerSession, method, params)
}
function onDebuggerEvent(source, method, params) {
const tabId = source.tabId
if (!tabId) return
const tab = tabs.get(tabId)
if (!tab?.sessionId) return
if (method === 'Target.attachedToTarget' && params?.sessionId) {
childSessionToTab.set(String(params.sessionId), tabId)
}
if (method === 'Target.detachedFromTarget' && params?.sessionId) {
childSessionToTab.delete(String(params.sessionId))
}
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
sessionId: source.sessionId || tab.sessionId,
method,
params,
},
})
} catch {
// ignore
}
}
function onDebuggerDetach(source, reason) {
const tabId = source.tabId
if (!tabId) return
if (!tabs.has(tabId)) return
void detachTab(tabId, reason)
}
chrome.action.onClicked.addListener(() => void connectOrToggleForActiveTab())
chrome.runtime.onInstalled.addListener(() => {
// Useful: first-time instructions.
void chrome.runtime.openOptionsPage()
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 614 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 B

-25
View File
@@ -1,25 +0,0 @@
{
"manifest_version": 3,
"name": "Clawdbot Browser Relay",
"version": "0.1.0",
"description": "Attach Clawdbot to your existing Chrome tab via a local CDP relay server.",
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"permissions": ["debugger", "tabs", "activeTab", "storage"],
"host_permissions": ["http://127.0.0.1/*", "http://localhost/*"],
"background": { "service_worker": "background.js", "type": "module" },
"action": {
"default_title": "Clawdbot Browser Relay (click to attach/detach)",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"options_ui": { "page": "options.html", "open_in_tab": true }
}
-197
View File
@@ -1,197 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Clawdbot Browser Relay</title>
<style>
:root {
color-scheme: light dark;
--accent: #ff5a36;
--panel: color-mix(in oklab, canvas 92%, canvasText 8%);
--border: color-mix(in oklab, canvasText 18%, transparent);
--muted: color-mix(in oklab, canvasText 70%, transparent);
--shadow: 0 10px 30px color-mix(in oklab, canvasText 18%, transparent);
font-family: ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Rounded",
"SF Pro Display", "Segoe UI", sans-serif;
line-height: 1.4;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(1000px 500px at 10% 0%, color-mix(in oklab, var(--accent) 30%, transparent), transparent 70%),
radial-gradient(900px 450px at 90% 0%, color-mix(in oklab, var(--accent) 18%, transparent), transparent 75%),
canvas;
color: canvasText;
}
.wrap {
max-width: 820px;
margin: 36px auto;
padding: 0 24px 48px 24px;
}
header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.logo {
width: 44px;
height: 44px;
border-radius: 14px;
background: color-mix(in oklab, var(--accent) 18%, transparent);
border: 1px solid color-mix(in oklab, var(--accent) 35%, transparent);
box-shadow: var(--shadow);
display: grid;
place-items: center;
}
.logo img {
width: 28px;
height: 28px;
image-rendering: pixelated;
}
h1 {
font-size: 20px;
margin: 0;
letter-spacing: -0.01em;
}
.subtitle {
margin: 2px 0 0 0;
color: var(--muted);
font-size: 13px;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px;
box-shadow: var(--shadow);
}
.card h2 {
margin: 0 0 10px 0;
font-size: 14px;
letter-spacing: 0.01em;
}
.card p {
margin: 8px 0 0 0;
color: var(--muted);
font-size: 13px;
}
.row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
label {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
input {
width: 160px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: color-mix(in oklab, canvas 92%, canvasText 8%);
color: canvasText;
outline: none;
}
input:focus {
border-color: color-mix(in oklab, var(--accent) 70%, transparent);
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 20%, transparent);
}
button {
padding: 10px 14px;
border-radius: 12px;
border: 1px solid color-mix(in oklab, var(--accent) 55%, transparent);
background: linear-gradient(
180deg,
color-mix(in oklab, var(--accent) 80%, white 20%),
var(--accent)
);
color: white;
font-weight: 650;
letter-spacing: 0.01em;
cursor: pointer;
}
button:active {
transform: translateY(1px);
}
.hint {
margin-top: 10px;
font-size: 12px;
color: var(--muted);
}
code {
font-family: ui-monospace, Menlo, Monaco, Consolas, "SF Mono", monospace;
font-size: 12px;
}
a {
color: color-mix(in oklab, var(--accent) 85%, canvasText 15%);
}
.status {
margin-top: 10px;
font-size: 12px;
color: color-mix(in oklab, var(--accent) 70%, canvasText 30%);
min-height: 16px;
}
.status[data-kind='ok'] {
color: color-mix(in oklab, #16a34a 75%, canvasText 25%);
}
.status[data-kind='error'] {
color: color-mix(in oklab, #ef4444 75%, canvasText 25%);
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo" aria-hidden="true">
<img src="icons/icon128.png" alt="" />
</div>
<div>
<h1>Clawdbot Browser Relay</h1>
<p class="subtitle">Click the toolbar button on a tab to attach / detach.</p>
</div>
</header>
<div class="grid">
<div class="card">
<h2>Getting started</h2>
<p>
If you see a red <code>!</code> badge on the extension icon, the relay server is not reachable.
Start Clawdbot’s browser relay on this machine (Gateway or <code>clawdbot browser serve</code>),
then click the toolbar button again.
</p>
<p>
Full guide (install, remote Gateway, security): <a href="https://docs.clawd.bot/tools/chrome-extension" target="_blank" rel="noreferrer">docs.clawd.bot/tools/chrome-extension</a>
</p>
</div>
<div class="card">
<h2>Relay port</h2>
<label for="port">Port</label>
<div class="row">
<input id="port" inputmode="numeric" pattern="[0-9]*" />
<button id="save" type="button">Save</button>
</div>
<div class="hint">
Default: <code>18792</code>. Extension connects to: <code id="relay-url">http://127.0.0.1:&lt;port&gt;/</code>.
Only change this if your Clawdbot profile uses a different <code>cdpUrl</code> port.
</div>
<div class="status" id="status"></div>
</div>
</div>
<script type="module" src="options.js"></script>
</div>
</body>
</html>
-59
View File
@@ -1,59 +0,0 @@
const DEFAULT_PORT = 18792
function clampPort(value) {
const n = Number.parseInt(String(value || ''), 10)
if (!Number.isFinite(n)) return DEFAULT_PORT
if (n <= 0 || n > 65535) return DEFAULT_PORT
return n
}
function updateRelayUrl(port) {
const el = document.getElementById('relay-url')
if (!el) return
el.textContent = `http://127.0.0.1:${port}/`
}
function setStatus(kind, message) {
const status = document.getElementById('status')
if (!status) return
status.dataset.kind = kind || ''
status.textContent = message || ''
}
async function checkRelayReachable(port) {
const url = `http://127.0.0.1:${port}/`
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), 900)
try {
const res = await fetch(url, { method: 'HEAD', signal: ctrl.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setStatus('ok', `Relay reachable at ${url}`)
} catch {
setStatus(
'error',
`Relay not reachable at ${url}. Start Clawdbot’s browser relay on this machine, then click the toolbar button again.`,
)
} finally {
clearTimeout(t)
}
}
async function load() {
const stored = await chrome.storage.local.get(['relayPort'])
const port = clampPort(stored.relayPort)
document.getElementById('port').value = String(port)
updateRelayUrl(port)
await checkRelayReachable(port)
}
async function save() {
const input = document.getElementById('port')
const port = clampPort(input.value)
await chrome.storage.local.set({ relayPort: port })
input.value = String(port)
updateRelayUrl(port)
await checkRelayReachable(port)
}
document.getElementById('save').addEventListener('click', () => void save())
void load()
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://biomejs.dev/schemas/biome.json",
"formatter": {
"enabled": true,
"indentWidth": 2,
"indentStyle": "space"
},
"files": {
"includes": ["src/**/*.ts", "test/**/*.ts"]
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ nav:
- title: "Android App"
url: "/platforms/android/"
- title: "Telegram"
url: "/channels/telegram/"
url: "/providers/telegram/"
- title: "Security"
url: "/gateway/security/"
- title: "Troubleshooting"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

+14 -44
View File
@@ -20,24 +20,6 @@ cron is the mechanism.
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, optionally deliver output.
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
## Beginner-friendly overview
Think of a cron job as: **when** to run + **what** to do.
1) **Choose a schedule**
- One-shot reminder → `schedule.kind = "at"` (CLI: `--at`)
- Repeating job → `schedule.kind = "every"` or `schedule.kind = "cron"`
- If your ISO timestamp omits a timezone, it is treated as **UTC**.
2) **Choose where it runs**
- `sessionTarget: "main"` → run during the next heartbeat with main context.
- `sessionTarget: "isolated"` → run a dedicated agent turn in `cron:<jobId>`.
3) **Choose the payload**
- Main session → `payload.kind = "systemEvent"`
- Isolated session → `payload.kind = "agentTurn"`
Optional: `deleteAfterRun: true` removes successful one-shot jobs from the store.
## Concepts
### Jobs
@@ -50,11 +32,10 @@ A cron job is a stored record with:
Jobs are identified by a stable `jobId` (used by CLI/Gateway APIs).
In agent tool calls, `jobId` is canonical; legacy `id` is accepted for compatibility.
Jobs can optionally auto-delete after a successful one-shot run via `deleteAfterRun: true`.
### Schedules
Cron supports three schedule kinds:
- `at`: one-shot timestamp (ms since epoch). Gateway accepts ISO 8601 and coerces to UTC.
- `at`: one-shot timestamp (ms since epoch).
- `every`: fixed interval (ms).
- `cron`: 5-field cron expression with optional IANA timezone.
@@ -80,7 +61,7 @@ Key behaviors:
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
- A summary is posted to the main session (prefix `Cron`, configurable).
- `wakeMode: "now"` triggers an immediate heartbeat after posting the summary.
- If `payload.deliver: true`, output is delivered to a channel; otherwise it stays internal.
- If `payload.deliver: true`, output is delivered to a provider; otherwise it stays internal.
Use isolated jobs for noisy, frequent, or "background chores" that shouldn't spam
your main chat history.
@@ -94,9 +75,9 @@ Common `agentTurn` fields:
- `message`: required text prompt.
- `model` / `thinking`: optional overrides (see below).
- `timeoutSeconds`: optional timeout override.
- `deliver`: `true` to send output to a channel target.
- `channel`: `last` or a specific channel.
- `to`: channel-specific target (phone/chat/channel id).
- `deliver`: `true` to send output to a provider target.
- `provider`: `last` or a specific provider.
- `to`: provider-specific target (phone/chat/channel id).
- `bestEffortDeliver`: avoid failing the job if delivery fails.
Isolation options (only for `session=isolated`):
@@ -105,7 +86,7 @@ Isolation options (only for `session=isolated`):
### Model and thinking overrides
Isolated jobs (`agentTurn`) can override the model and thinking level:
- `model`: Provider/model string (e.g., `anthropic/claude-sonnet-4-20250514`) or alias (e.g., `opus`)
- `thinking`: Thinking level (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`; GPT-5.2 + Codex models only)
- `thinking`: Thinking level (`off`, `minimal`, `low`, `medium`, `high`)
Note: You can set `model` on main-session jobs too, but it changes the shared main
session model. We recommend model overrides only for isolated jobs to avoid
@@ -116,12 +97,12 @@ Resolution priority:
2. Hook-specific defaults (e.g., `hooks.gmail.model`)
3. Agent config default
### Delivery (channel + target)
Isolated jobs can deliver output to a channel. The job payload can specify:
- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `signal` / `imessage` / `last`
- `to`: channel-specific recipient target
### Delivery (provider + target)
Isolated jobs can deliver output to a provider. The job payload can specify:
- `provider`: `whatsapp` / `telegram` / `discord` / `slack` / `signal` / `imessage` / `last`
- `to`: provider-specific recipient target
If `channel` or `to` is omitted, cron can fall back to the main session’s “last route”
If `provider` or `to` is omitted, cron can fall back to the main session’s “last route”
(the last place the agent replied).
Target format reminders:
@@ -162,17 +143,6 @@ Disable cron entirely:
## CLI quickstart
One-shot reminder (UTC ISO, auto-delete after success):
```bash
clawdbot cron add \
--name "Send reminder" \
--at "2026-01-12T18:00:00Z" \
--session main \
--system-event "Reminder: submit expense report." \
--wake now \
--delete-after-run
```
One-shot reminder (main session, wake immediately):
```bash
clawdbot cron add \
@@ -192,7 +162,7 @@ clawdbot cron add \
--session isolated \
--message "Summarize inbox + calendar for today." \
--deliver \
--channel whatsapp \
--provider whatsapp \
--to "+15551234567"
```
@@ -205,7 +175,7 @@ clawdbot cron add \
--session isolated \
--message "Summarize today; send to the nightly topic." \
--deliver \
--channel telegram \
--provider telegram \
--to "-1001234567890:topic:123"
```
@@ -220,7 +190,7 @@ clawdbot cron add \
--model "opus" \
--thinking high \
--deliver \
--channel whatsapp \
--provider whatsapp \
--to "+15551234567"
Agent selection (multi-agent setups):
+3 -3
View File
@@ -32,7 +32,7 @@ Example hook config (enable Gmail preset mapping):
```
To deliver the Gmail summary to a chat surface, override the preset with a mapping
that sets `deliver` + optional `channel`/`to`:
that sets `deliver` + optional `provider`/`to`:
```json5
{
@@ -51,7 +51,7 @@ that sets `deliver` + optional `channel`/`to`:
"New email from {{messages[0].from}}\nSubject: {{messages[0].subject}}\n{{messages[0].snippet}}\n{{messages[0].body}}",
model: "openai/gpt-5.2-mini",
deliver: true,
channel: "last"
provider: "last"
// to: "+15551234567"
}
]
@@ -59,7 +59,7 @@ that sets `deliver` + optional `channel`/`to`:
}
```
If you want a fixed channel, set `channel` + `to`. Otherwise `channel: "last"`
If you want a fixed channel, set `provider` + `to`. Otherwise `provider: "last"`
uses the last delivery route (falls back to WhatsApp).
To force a cheaper model for Gmail runs, set `model` in the mapping
+9 -9
View File
@@ -7,8 +7,8 @@ read_when:
# Polls
## Supported channels
- WhatsApp (web channel)
## Supported providers
- WhatsApp (web provider)
- Discord
- MS Teams (Adaptive Cards)
@@ -22,18 +22,18 @@ clawdbot message poll --to 123456789@g.us \
--poll-question "Meeting time?" --poll-option "10am" --poll-option "2pm" --poll-option "4pm" --poll-multi
# Discord
clawdbot message poll --channel discord --to channel:123456789 \
clawdbot message poll --provider discord --to channel:123456789 \
--poll-question "Snack?" --poll-option "Pizza" --poll-option "Sushi"
clawdbot message poll --channel discord --to channel:123456789 \
clawdbot message poll --provider discord --to channel:123456789 \
--poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48
# MS Teams
clawdbot message poll --channel msteams --to conversation:19:abc@thread.tacv2 \
clawdbot message poll --provider msteams --to conversation:19:abc@thread.tacv2 \
--poll-question "Lunch?" --poll-option "Pizza" --poll-option "Sushi"
```
Options:
- `--channel`: `whatsapp` (default), `discord`, or `msteams`
- `--provider`: `whatsapp` (default), `discord`, or `msteams`
- `--poll-multi`: allow selecting multiple options
- `--poll-duration-hours`: Discord-only (defaults to 24 when omitted)
@@ -47,16 +47,16 @@ Params:
- `options` (string[], required)
- `maxSelections` (number, optional)
- `durationHours` (number, optional)
- `channel` (string, optional, default: `whatsapp`)
- `provider` (string, optional, default: `whatsapp`)
- `idempotencyKey` (string, required)
## Channel differences
## Provider differences
- WhatsApp: 2-12 options, `maxSelections` must be within option count, ignores `durationHours`.
- Discord: 2-10 options, `durationHours` clamped to 1-768 hours (default 24). `maxSelections > 1` enables multi-select; Discord does not support a strict selection count.
- MS Teams: Adaptive Card polls (Clawdbot-managed). No native poll API; `durationHours` is ignored.
## Agent tool (Message)
Use the `message` tool with `poll` action (`to`, `pollQuestion`, `pollOption`, optional `pollMulti`, `pollDurationHours`, `channel`).
Use the `message` tool with `poll` action (`to`, `pollQuestion`, `pollOption`, optional `pollMulti`, `pollDurationHours`, `provider`).
Note: Discord has no “pick exactly N” mode; `pollMulti` maps to multi-select.
Teams polls are rendered as Adaptive Cards and require the gateway to stay online
+6 -6
View File
@@ -58,7 +58,7 @@ Payload:
"sessionKey": "hook:email:msg-123",
"wakeMode": "now",
"deliver": true,
"channel": "last",
"provider": "last",
"to": "+15551234567",
"model": "openai/gpt-5.2-mini",
"thinking": "low",
@@ -70,9 +70,9 @@ Payload:
- `name` optional (string): Human-readable name for the hook (e.g., "GitHub"), used as a prefix in session summaries.
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context.
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `msteams`. Defaults to `last`.
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack, conversation ID for MS Teams). Defaults to the last recipient in the main session.
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging provider. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
- `provider` optional (string): The messaging service for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `msteams`. Defaults to `last`.
- `to` optional (string): The recipient identifier for the provider (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack, conversation ID for MS Teams). Defaults to the last recipient in the main session.
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
- `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds.
@@ -94,8 +94,8 @@ Mapping options (summary):
- `hooks.transformsDir` + `transform.module` loads a JS/TS module for custom logic.
- Use `match.source` to keep a generic ingest endpoint (payload-driven routing).
- TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime.
- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface
(`channel` defaults to `last` and falls back to WhatsApp).
- Set `deliver: true` + `provider`/`to` on mappings to route replies to a chat surface
(`provider` defaults to `last` and falls back to WhatsApp).
- `clawdbot hooks gmail setup` writes `hooks.gmail` config for `clawdbot hooks gmail run`.
See [Gmail Pub/Sub](/automation/gmail-pubsub) for the full Gmail watch flow.
-71
View File
@@ -1,71 +0,0 @@
---
summary: "Use Amazon Bedrock (Converse API) models with Clawdbot"
read_when:
- You want to use Amazon Bedrock models with Clawdbot
- You need AWS credential/region setup for model calls
---
# Amazon Bedrock
Clawdbot can use **Amazon Bedrock** models via pi‑ai’s **Bedrock Converse**
streaming provider. Bedrock auth uses the **AWS SDK default credential chain**,
not an API key.
## What pi‑ai supports
- Provider: `amazon-bedrock`
- API: `bedrock-converse-stream`
- Auth: AWS credentials (env vars, shared config, or instance role)
- Region: `AWS_REGION` or `AWS_DEFAULT_REGION` (default: `us-east-1`)
## Setup (manual)
1) Ensure AWS credentials are available on the **gateway host**:
```bash
export AWS_ACCESS_KEY_ID="AKIA..."
export AWS_SECRET_ACCESS_KEY="..."
export AWS_REGION="us-east-1"
# Optional:
export AWS_SESSION_TOKEN="..."
export AWS_PROFILE="your-profile"
```
2) Add a Bedrock provider and model to your config:
```json5
{
models: {
providers: {
"amazon-bedrock": {
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
api: "bedrock-converse-stream",
models: [
{
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
name: "Claude 3.7 Sonnet (Bedrock)",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192
}
]
}
}
},
agents: {
defaults: {
model: { primary: "amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0" }
}
}
}
```
## Notes
- Bedrock requires **model access** enabled in your AWS account/region.
- If you use profiles, set `AWS_PROFILE` on the gateway host.
- Reasoning support depends on the model; check the Bedrock model card for
current capabilities.
- If you prefer a managed key flow, you can also place an OpenAI‑compatible
proxy in front of Bedrock and configure it as an OpenAI provider instead.
Loaded 100 of 2273 files, more files were not shown because too many files have changed in this diff. Show more