Compare commits
174
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f0603df54 | ||
|
|
f32fc1d5d0 | ||
|
|
d34a1f1d25 | ||
|
|
31aa46dea9 | ||
|
|
b3a1024f65 | ||
|
|
7fbd2ab07b | ||
|
|
3908ea25bc | ||
|
|
1fe8df85cb | ||
|
|
139f80a291 | ||
|
|
2d066b8715 | ||
|
|
757243993c | ||
|
|
4fb114dcb3 | ||
|
|
612cdac4c3 | ||
|
|
57c66fe813 | ||
|
|
9c02ea9098 | ||
|
|
042b65dfcc | ||
|
|
f6a72ef3c2 | ||
|
|
568cc368ae | ||
|
|
5abe3c2145 | ||
|
|
0e76d21f11 | ||
|
|
5e8693bc42 | ||
|
|
ef78b198cb | ||
|
|
5fdaef3646 | ||
|
|
fa4670c5fe | ||
|
|
c4402a1ce5 | ||
|
|
edd8c613d6 | ||
|
|
0a7f5bf6a5 | ||
|
|
eb3e865f15 | ||
|
|
1baa55c145 | ||
|
|
2e0325e3bf | ||
|
|
dcadaad228 | ||
|
|
1533868680 | ||
|
|
f275cc180b | ||
|
|
31d3aef8d6 | ||
|
|
bd467ff765 | ||
|
|
429f973280 | ||
|
|
6320f739d4 | ||
|
|
1732932c57 | ||
|
|
574b6ab5b1 | ||
|
|
1c737f88fe | ||
|
|
fa8d9b9189 | ||
|
|
512dbedee3 | ||
|
|
db21c2d397 | ||
|
|
1078d178d7 | ||
|
|
a6e780b2f6 | ||
|
|
4e48d0a431 | ||
|
|
c289a88f50 | ||
|
|
4ce495cb2c | ||
|
|
dfea2991c9 | ||
|
|
6f5fc2276a | ||
|
|
d51a9ebb0e | ||
|
|
536d3d76a3 | ||
|
|
5c52dbf661 | ||
|
|
8f797f213e | ||
|
|
ed68f378d7 | ||
|
|
7e8de907f8 | ||
|
|
f3c9252840 | ||
|
|
da9e27f466 | ||
|
|
aa74e28112 | ||
|
|
e569f15631 | ||
|
|
eaace34233 | ||
|
|
7a839e7eb6 | ||
|
|
1b24b6a02b | ||
|
|
2b4a68e276 | ||
|
|
b1e3d79eaa | ||
|
|
2fb2035dbf | ||
|
|
3c51290e0d | ||
|
|
765196d5c3 | ||
|
|
12c2d37e62 | ||
|
|
151a551be9 | ||
|
|
09ce6ff99e | ||
|
|
6ffd7111a6 | ||
|
|
7904a14af1 | ||
|
|
1b79730db8 | ||
|
|
ad8799522c | ||
|
|
f65668cb5f | ||
|
|
393d21d86c | ||
|
|
232c512502 | ||
|
|
8c1e6a82b2 | ||
|
|
2d54efe851 | ||
|
|
c2a4f256c8 | ||
|
|
c91c85532a | ||
|
|
326d4049da | ||
|
|
9b7c4b3884 | ||
|
|
bcde09ae91 | ||
|
|
632651aee2 | ||
|
|
d2b76acb72 | ||
|
|
daf69e8154 | ||
|
|
e5c8abab9e | ||
|
|
2c312e20f1 | ||
|
|
9f1a8be2bf | ||
|
|
bd7d362d3b | ||
|
|
0d0b77ded6 | ||
|
|
83a25d26fc | ||
|
|
9b7df414e6 | ||
|
|
f87016a5fe | ||
|
|
5894ffe82e | ||
|
|
50fa106d87 | ||
|
|
983e1b2303 | ||
|
|
de7f567b9a | ||
|
|
0eabc89840 | ||
|
|
400e901c9c | ||
|
|
57b4865ab3 | ||
|
|
fd41000bc3 | ||
|
|
a70937c926 | ||
|
|
4ec2222fa9 | ||
|
|
fe974f420d | ||
|
|
e65e5f40c9 | ||
|
|
0235eb6c72 | ||
|
|
e943e63174 | ||
|
|
b4ba6e4eaf | ||
|
|
0adcb68092 | ||
|
|
dadef27d7a | ||
|
|
53465a4d2d | ||
|
|
4e837cfa2d | ||
|
|
964e6169cb | ||
|
|
c379191f80 | ||
|
|
912ebffc63 | ||
|
|
b7a11b7bd4 | ||
|
|
95bdb28a05 | ||
|
|
9930ba91c5 | ||
|
|
802c02eb74 | ||
|
|
2d4e3253ca | ||
|
|
da8d45d6c6 | ||
|
|
18b4575e4d | ||
|
|
40fb59e5f7 | ||
|
|
e3ff8c4d28 | ||
|
|
ce59e2dd76 | ||
|
|
32cfc49002 | ||
|
|
d19bc1562b | ||
|
|
ea018a68cc | ||
|
|
1089444807 | ||
|
|
4af8228c34 | ||
|
|
ebea98b8ec | ||
|
|
51683071e8 | ||
|
|
bfa46b2471 | ||
|
|
de62797128 | ||
|
|
05673fb6cf | ||
|
|
350f4709b7 | ||
|
|
b5f7ba502d | ||
|
|
8ba80d2dac | ||
|
|
b11eea07b0 | ||
|
|
35cea9be25 | ||
|
|
3e0e608110 | ||
|
|
e2f8909982 | ||
|
|
ac613b6632 | ||
|
|
5323652cfd | ||
|
|
a58ff1ac63 | ||
|
|
2b60ee96f2 | ||
|
|
da6f07b7c1 | ||
|
|
f0b624d6c9 | ||
|
|
e4c3c02a36 | ||
|
|
ae796b1194 | ||
|
|
c892f38d3c | ||
|
|
d98b6beb4d | ||
|
|
b80abf8dd1 | ||
|
|
acfa762617 | ||
|
|
a44f1912b3 | ||
|
|
bcbfb357be | ||
|
|
b2179de839 | ||
|
|
b1102cedd7 | ||
|
|
571f8c78bd | ||
|
|
93fbd103ba | ||
|
|
a740d563d7 | ||
|
|
8f6e67553f | ||
|
|
0a8be132b9 | ||
|
|
4c932edabc | ||
|
|
45c314fbe6 | ||
|
|
119e53967b | ||
|
|
d36a004468 | ||
|
|
8778c39ed0 | ||
|
|
b071f73fef | ||
|
|
afde0a17b7 | ||
|
|
714de9d996 |
No files matched your search
@@ -0,0 +1,26 @@
|
||||
# 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"
|
||||
@@ -74,9 +74,9 @@ jobs:
|
||||
- runtime: node
|
||||
task: protocol
|
||||
command: pnpm protocol:check
|
||||
- runtime: bun
|
||||
task: lint
|
||||
command: bunx biome check src
|
||||
- runtime: node
|
||||
task: format
|
||||
command: pnpm format
|
||||
- runtime: bun
|
||||
task: test
|
||||
command: bunx vitest run
|
||||
@@ -141,6 +141,31 @@ 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:
|
||||
|
||||
@@ -29,4 +29,5 @@ 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
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"indentWidth": 2,
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/oxlintrc",
|
||||
"extends": ["recommended"]
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -25,12 +25,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` (biome check), `pnpm format` (biome format)
|
||||
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
|
||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||
- Formatting/linting via Biome; run `pnpm lint` before commits.
|
||||
- Formatting/linting via Oxlint and Oxfmt; 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.
|
||||
@@ -76,6 +76,7 @@
|
||||
- Vocabulary: "makeup" = "mac app".
|
||||
- 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.**
|
||||
|
||||
+70
-1
@@ -1,9 +1,76 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.14 (unreleased)
|
||||
|
||||
### 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.
|
||||
- Messages: add responsePrefix template variables (model/provider/identity/think), including routed replies. (#928) — thanks @sebslight.
|
||||
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor`.
|
||||
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor` (includes browser control exposure checks).
|
||||
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
|
||||
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
|
||||
- Docs: expand gateway security hardening guidance and incident response checklist.
|
||||
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
|
||||
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
|
||||
- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill.
|
||||
- CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips.
|
||||
- Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control via `clawdbot browser serve` + `browser.controlToken`.
|
||||
|
||||
### Fixes
|
||||
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
|
||||
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
|
||||
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
|
||||
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
|
||||
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
||||
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
|
||||
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
|
||||
- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman.
|
||||
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
|
||||
- Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer.
|
||||
- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer.
|
||||
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
|
||||
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
|
||||
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
|
||||
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
|
||||
|
||||
## 2026.1.14
|
||||
|
||||
### Changes
|
||||
- Usage: add MiniMax coding plan usage tracking.
|
||||
- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.
|
||||
- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)
|
||||
- Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.
|
||||
- Config: add `channels.<provider>.configWrites` gating for channel-initiated config writes; migrate Slack channel IDs.
|
||||
|
||||
### Fixes
|
||||
- Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
|
||||
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor.
|
||||
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.
|
||||
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.
|
||||
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
|
||||
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
|
||||
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
|
||||
- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
|
||||
- Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.
|
||||
- Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.
|
||||
- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4.
|
||||
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.
|
||||
- WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.
|
||||
- WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.
|
||||
|
||||
## 2026.1.13
|
||||
|
||||
### Fixes
|
||||
- Postinstall: treat already-applied pnpm patches as no-ops to avoid npm/bun install failures.
|
||||
- Packaging: pin `@mariozechner/pi-ai` to 0.45.7 and refresh patched dependency to match npm resolution.
|
||||
|
||||
## 2026.1.12-2
|
||||
|
||||
### Fixes
|
||||
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`).
|
||||
- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4.
|
||||
|
||||
## 2026.1.12-1
|
||||
|
||||
@@ -25,6 +92,7 @@
|
||||
- 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.
|
||||
@@ -37,6 +105,7 @@
|
||||
|
||||
### 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.
|
||||
@@ -302,7 +371,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 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 Code CLI credentials (`anthropic:claude-cli`) with bidirectional sync back to Claude Code storage (file on Linux/Windows, Keychain on macOS). This allows long-running agents to operate autonomously without manual re-authentication (#654 — thanks @radek-paclt).
|
||||
|
||||
## 2026.1.8
|
||||
|
||||
|
||||
+1
-1
Submodule Peekaboo updated: c1243a7978...95ad7532c1.
@@ -471,21 +471,22 @@ Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/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/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a>
|
||||
<a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/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/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/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/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a>
|
||||
<a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a>
|
||||
<a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a>
|
||||
<a href="https://github.com/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/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/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/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a>
|
||||
<a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/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/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/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/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/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/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a>
|
||||
<a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=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/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>
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a>
|
||||
<a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a>
|
||||
<a href="https://github.com/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/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a>
|
||||
<a href="https://github.com/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/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a>
|
||||
<a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a>
|
||||
<a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a>
|
||||
<a href="https://github.com/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/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/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/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
|
||||
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a>
|
||||
<a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a>
|
||||
<a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
|
||||
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a>
|
||||
<a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
@@ -34,8 +34,7 @@ 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,8 +13,7 @@ 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,8 +24,7 @@ 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
|
||||
@@ -57,6 +56,12 @@ 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],
|
||||
@@ -68,7 +73,7 @@ public enum WakeWordGate {
|
||||
let tokens = normalizeSegments(segments)
|
||||
guard !tokens.isEmpty else { return nil }
|
||||
|
||||
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
|
||||
var best: MatchCandidate?
|
||||
|
||||
for trigger in triggerTokens {
|
||||
let count = trigger.tokens.count
|
||||
@@ -84,7 +89,7 @@ public enum WakeWordGate {
|
||||
|
||||
if let best, i <= best.index { continue }
|
||||
|
||||
best = (i, triggerEnd, gap)
|
||||
best = MatchCandidate(index: i, triggerEnd: triggerEnd, gap: 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)
|
||||
|
||||
@@ -25,78 +25,123 @@ private func dispatch(invocation: CommandInvocation) async throws {
|
||||
|
||||
switch first {
|
||||
case "swabble":
|
||||
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)
|
||||
}
|
||||
try await dispatchSwabble(parsed: parsed, path: path)
|
||||
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)
|
||||
|
||||
+16
-16
@@ -2,6 +2,22 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>Clawdbot</title>
|
||||
<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>
|
||||
@@ -36,21 +52,5 @@
|
||||
]]></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>
|
||||
@@ -86,7 +86,7 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
thinking: "low",
|
||||
deliver: false,
|
||||
to: nil,
|
||||
provider: .last,
|
||||
channel: .last,
|
||||
idempotencyKey: actionId))
|
||||
|
||||
await MainActor.run {
|
||||
|
||||
@@ -19,6 +19,7 @@ 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
|
||||
@@ -75,6 +76,7 @@ enum ConfigStore {
|
||||
method: .configGet,
|
||||
params: nil,
|
||||
timeoutMs: 8000)
|
||||
self.lastHash = snap.hash
|
||||
return snap.config?.mapValues { $0.foundationValue } ?? [:]
|
||||
} catch {
|
||||
return nil
|
||||
@@ -83,17 +85,24 @@ 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.",
|
||||
])
|
||||
}
|
||||
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||
var params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||
if let baseHash = self.lastHash {
|
||||
params["baseHash"] = AnyCodable(baseHash)
|
||||
}
|
||||
_ = try await GatewayConnection.shared.requestRaw(
|
||||
method: .configSet,
|
||||
params: params,
|
||||
timeoutMs: 10000)
|
||||
_ = await self.loadFromGateway()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
@@ -12,6 +12,7 @@ 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)
|
||||
@@ -34,10 +35,22 @@ 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 = snap.config?["telegram"]?.dictionaryValue
|
||||
let telegram = self.resolveChannelConfig(snap, key: "telegram")
|
||||
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
|
||||
self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true
|
||||
let groups = telegram?["groups"]?.dictionaryValue
|
||||
let defaultGroup = groups?["*"]?.dictionaryValue
|
||||
self.telegramRequireMention = defaultGroup?["requireMention"]?.boolValue
|
||||
?? telegram?["requireMention"]?.boolValue
|
||||
?? true
|
||||
self.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue)
|
||||
self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
|
||||
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
|
||||
@@ -46,7 +59,7 @@ extension ConnectionsStore {
|
||||
}
|
||||
|
||||
private func applyDiscordConfig(_ snap: ConfigSnapshot) {
|
||||
let discord = snap.config?["discord"]?.dictionaryValue
|
||||
let discord = self.resolveChannelConfig(snap, key: "discord")
|
||||
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
|
||||
self.discordToken = discord?["token"]?.stringValue ?? ""
|
||||
|
||||
@@ -122,7 +135,7 @@ extension ConnectionsStore {
|
||||
}
|
||||
|
||||
private func applySignalConfig(_ snap: ConfigSnapshot) {
|
||||
let signal = snap.config?["signal"]?.dictionaryValue
|
||||
let signal = self.resolveChannelConfig(snap, key: "signal")
|
||||
self.signalEnabled = signal?["enabled"]?.boolValue ?? true
|
||||
self.signalAccount = signal?["account"]?.stringValue ?? ""
|
||||
self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? ""
|
||||
@@ -139,7 +152,7 @@ extension ConnectionsStore {
|
||||
}
|
||||
|
||||
private func applyIMessageConfig(_ snap: ConfigSnapshot) {
|
||||
let imessage = snap.config?["imessage"]?.dictionaryValue
|
||||
let imessage = self.resolveChannelConfig(snap, key: "imessage")
|
||||
self.imessageEnabled = imessage?["enabled"]?.boolValue ?? true
|
||||
self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? ""
|
||||
self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? ""
|
||||
@@ -150,6 +163,15 @@ 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
|
||||
@@ -158,30 +180,24 @@ extension ConnectionsStore {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
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
|
||||
var telegram: [String: Any] = [:]
|
||||
if !self.isTelegramTokenLocked {
|
||||
self.setPatchString(&telegram, key: "botToken", value: self.telegramToken)
|
||||
}
|
||||
|
||||
telegram["requireMention"] = self.telegramRequireMention
|
||||
|
||||
telegram["requireMention"] = NSNull()
|
||||
telegram["groups"] = [
|
||||
"*": [
|
||||
"requireMention": self.telegramRequireMention,
|
||||
],
|
||||
]
|
||||
let allow = self.splitCsv(self.telegramAllowFrom)
|
||||
if allow.isEmpty {
|
||||
telegram.removeValue(forKey: "allowFrom")
|
||||
} else {
|
||||
telegram["allowFrom"] = allow
|
||||
}
|
||||
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)
|
||||
|
||||
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()
|
||||
await self.persistChannelPatch("telegram", payload: telegram)
|
||||
}
|
||||
|
||||
func saveDiscordConfig() async {
|
||||
@@ -192,9 +208,9 @@ extension ConnectionsStore {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
let discord = self.buildDiscordConfig()
|
||||
self.setSection("discord", payload: discord)
|
||||
await self.persistConfig()
|
||||
let base = self.channelConfigRoot(for: "discord")
|
||||
let discord = self.buildDiscordPatch(base: base)
|
||||
await self.persistChannelPatch("discord", payload: discord)
|
||||
}
|
||||
|
||||
func saveSignalConfig() async {
|
||||
@@ -205,42 +221,23 @@ extension ConnectionsStore {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
var signal: [String: Any] = (self.configRoot["signal"] as? [String: Any]) ?? [:]
|
||||
if self.signalEnabled {
|
||||
signal.removeValue(forKey: "enabled")
|
||||
} else {
|
||||
signal["enabled"] = false
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
if allow.isEmpty {
|
||||
signal.removeValue(forKey: "allowFrom")
|
||||
} else {
|
||||
signal["allowFrom"] = allow
|
||||
}
|
||||
self.setPatchList(&signal, key: "allowFrom", values: allow)
|
||||
self.setPatchNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
|
||||
|
||||
self.setOptionalNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
|
||||
|
||||
self.setSection("signal", payload: signal)
|
||||
await self.persistConfig()
|
||||
await self.persistChannelPatch("signal", payload: signal)
|
||||
}
|
||||
|
||||
func saveIMessageConfig() async {
|
||||
@@ -251,147 +248,168 @@ extension ConnectionsStore {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
let service = self.trimmed(self.imessageService)
|
||||
if service.isEmpty || service == "auto" {
|
||||
imessage.removeValue(forKey: "service")
|
||||
imessage["service"] = NSNull()
|
||||
} else {
|
||||
imessage["service"] = service
|
||||
}
|
||||
|
||||
self.setOptionalString(&imessage, key: "region", value: self.imessageRegion)
|
||||
self.setPatchString(&imessage, key: "region", value: self.imessageRegion)
|
||||
|
||||
let allow = self.splitCsv(self.imessageAllowFrom)
|
||||
if allow.isEmpty {
|
||||
imessage.removeValue(forKey: "allowFrom")
|
||||
} else {
|
||||
imessage["allowFrom"] = allow
|
||||
}
|
||||
self.setPatchList(&imessage, key: "allowFrom", values: allow)
|
||||
|
||||
self.setOptionalBool(&imessage, key: "includeAttachments", value: self.imessageIncludeAttachments)
|
||||
self.setOptionalNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
|
||||
self.setPatchBool(
|
||||
&imessage,
|
||||
key: "includeAttachments",
|
||||
value: self.imessageIncludeAttachments,
|
||||
defaultValue: false)
|
||||
self.setPatchNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
|
||||
|
||||
self.setSection("imessage", payload: imessage)
|
||||
await self.persistConfig()
|
||||
await self.persistChannelPatch("imessage", payload: imessage)
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
self.setOptionalString(&discord, key: "token", value: self.discordToken)
|
||||
|
||||
if let dm = self.buildDiscordDmConfig(base: discord["dm"] as? [String: Any] ?? [:]) {
|
||||
if let dm = self.buildDiscordDmPatch() {
|
||||
discord["dm"] = dm
|
||||
} else {
|
||||
discord.removeValue(forKey: "dm")
|
||||
discord["dm"] = NSNull()
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
let replyToMode = self.trimmed(self.discordReplyToMode)
|
||||
if replyToMode.isEmpty || replyToMode == "off" {
|
||||
discord.removeValue(forKey: "replyToMode")
|
||||
} else if ["first", "all"].contains(replyToMode) {
|
||||
discord["replyToMode"] = replyToMode
|
||||
if replyToMode.isEmpty || replyToMode == "off" || !["first", "all"].contains(replyToMode) {
|
||||
discord["replyToMode"] = NSNull()
|
||||
} else {
|
||||
discord.removeValue(forKey: "replyToMode")
|
||||
discord["replyToMode"] = replyToMode
|
||||
}
|
||||
|
||||
if let guilds = self.buildDiscordGuildsConfig() {
|
||||
let baseGuilds = base["guilds"] as? [String: Any] ?? [:]
|
||||
if let guilds = self.buildDiscordGuildsPatch(base: baseGuilds) {
|
||||
discord["guilds"] = guilds
|
||||
} else {
|
||||
discord.removeValue(forKey: "guilds")
|
||||
discord["guilds"] = NSNull()
|
||||
}
|
||||
|
||||
if let actions = self.buildDiscordActionsConfig(base: discord["actions"] as? [String: Any] ?? [:]) {
|
||||
if let actions = self.buildDiscordActionsPatch() {
|
||||
discord["actions"] = actions
|
||||
} else {
|
||||
discord.removeValue(forKey: "actions")
|
||||
discord["actions"] = NSNull()
|
||||
}
|
||||
|
||||
if let slash = self.buildDiscordSlashConfig(base: discord["slashCommand"] as? [String: Any] ?? [:]) {
|
||||
if let slash = self.buildDiscordSlashPatch() {
|
||||
discord["slashCommand"] = slash
|
||||
} else {
|
||||
discord.removeValue(forKey: "slashCommand")
|
||||
discord["slashCommand"] = NSNull()
|
||||
}
|
||||
|
||||
return discord
|
||||
}
|
||||
|
||||
private func buildDiscordDmConfig(base: [String: Any]) -> [String: Any]? {
|
||||
var dm = base
|
||||
if self.discordDmEnabled {
|
||||
dm.removeValue(forKey: "enabled")
|
||||
} else {
|
||||
dm["enabled"] = false
|
||||
}
|
||||
private func buildDiscordDmPatch() -> [String: Any]? {
|
||||
var dm: [String: Any] = [:]
|
||||
self.setPatchBool(&dm, key: "enabled", value: self.discordDmEnabled, defaultValue: true)
|
||||
let allow = self.splitCsv(self.discordAllowFrom)
|
||||
if allow.isEmpty {
|
||||
dm.removeValue(forKey: "allowFrom")
|
||||
} else {
|
||||
dm["allowFrom"] = allow
|
||||
}
|
||||
|
||||
if self.discordGroupEnabled {
|
||||
dm["groupEnabled"] = true
|
||||
} else {
|
||||
dm.removeValue(forKey: "groupEnabled")
|
||||
}
|
||||
|
||||
self.setPatchList(&dm, key: "allowFrom", values: allow)
|
||||
self.setPatchBool(&dm, key: "groupEnabled", value: self.discordGroupEnabled, defaultValue: false)
|
||||
let groupChannels = self.splitCsv(self.discordGroupChannels)
|
||||
if groupChannels.isEmpty {
|
||||
dm.removeValue(forKey: "groupChannels")
|
||||
} else {
|
||||
dm["groupChannels"] = groupChannels
|
||||
}
|
||||
|
||||
self.setPatchList(&dm, key: "groupChannels", values: groupChannels)
|
||||
return dm.isEmpty ? nil : dm
|
||||
}
|
||||
|
||||
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 { 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
|
||||
private func buildDiscordGuildsPatch(base: [String: Any]) -> Any? {
|
||||
if self.discordGuilds.isEmpty {
|
||||
return NSNull()
|
||||
}
|
||||
return guilds.isEmpty ? nil : guilds
|
||||
var patch: [String: Any] = [:]
|
||||
let baseKeys = Set(base.keys)
|
||||
var formKeys = Set<String>()
|
||||
for entry in self.discordGuilds {
|
||||
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)
|
||||
}
|
||||
for key in baseKeys.subtracting(formKeys) {
|
||||
patch[key] = NSNull()
|
||||
}
|
||||
return patch.isEmpty ? NSNull() : patch
|
||||
}
|
||||
|
||||
private func buildDiscordActionsConfig(base: [String: Any]) -> [String: Any]? {
|
||||
var actions = base
|
||||
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] = [:]
|
||||
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)
|
||||
@@ -410,52 +428,44 @@ extension ConnectionsStore {
|
||||
return actions.isEmpty ? nil : actions
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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)
|
||||
return slash.isEmpty ? nil : slash
|
||||
}
|
||||
|
||||
private func persistConfig() async {
|
||||
private func persistChannelPatch(_ channelId: String, payload: [String: Any]) async {
|
||||
do {
|
||||
guard let baseHash = self.configHash else {
|
||||
self.configStatus = "Config hash missing; reload and retry."
|
||||
return
|
||||
}
|
||||
let data = try JSONSerialization.data(
|
||||
withJSONObject: self.configRoot,
|
||||
withJSONObject: ["channels": [channelId: payload]],
|
||||
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)]
|
||||
let params: [String: AnyCodable] = [
|
||||
"raw": AnyCodable(raw),
|
||||
"baseHash": AnyCodable(baseHash),
|
||||
]
|
||||
_ = try await GatewayConnection.shared.requestRaw(
|
||||
method: .configSet,
|
||||
method: .configPatch,
|
||||
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
|
||||
@@ -492,25 +502,29 @@ extension ConnectionsStore {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func setOptionalString(_ target: inout [String: Any], key: String, value: String) {
|
||||
private func setPatchString(_ target: inout [String: Any], key: String, value: String) {
|
||||
let trimmed = self.trimmed(value)
|
||||
if trimmed.isEmpty {
|
||||
target.removeValue(forKey: key)
|
||||
target[key] = NSNull()
|
||||
} else {
|
||||
target[key] = trimmed
|
||||
}
|
||||
}
|
||||
|
||||
private func setOptionalNumber(_ target: inout [String: Any], key: String, value: String) {
|
||||
private func setPatchNumber(_ target: inout [String: Any], key: String, value: String) {
|
||||
let trimmed = self.trimmed(value)
|
||||
if trimmed.isEmpty {
|
||||
target.removeValue(forKey: key)
|
||||
} else if let number = Double(trimmed) {
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
if let number = Double(trimmed) {
|
||||
target[key] = number
|
||||
} else {
|
||||
target[key] = NSNull()
|
||||
}
|
||||
}
|
||||
|
||||
private func setOptionalInt(
|
||||
private func setPatchInt(
|
||||
_ target: inout [String: Any],
|
||||
key: String,
|
||||
value: String,
|
||||
@@ -518,26 +532,39 @@ extension ConnectionsStore {
|
||||
{
|
||||
let trimmed = self.trimmed(value)
|
||||
if trimmed.isEmpty {
|
||||
target.removeValue(forKey: key)
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
guard let number = Int(trimmed) else {
|
||||
target.removeValue(forKey: key)
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
let isValid = allowZero ? number >= 0 : number > 0
|
||||
guard isValid else {
|
||||
target.removeValue(forKey: key)
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
target[key] = number
|
||||
}
|
||||
|
||||
private func setOptionalBool(_ target: inout [String: Any], key: String, value: Bool) {
|
||||
if value {
|
||||
target[key] = true
|
||||
private func setPatchBool(
|
||||
_ target: inout [String: Any],
|
||||
key: String,
|
||||
value: Bool,
|
||||
defaultValue: Bool)
|
||||
{
|
||||
if value == defaultValue {
|
||||
target[key] = NSNull()
|
||||
} else {
|
||||
target.removeValue(forKey: key)
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
private func setPatchList(_ target: inout [String: Any], key: String, values: [String]) {
|
||||
if values.isEmpty {
|
||||
target[key] = NSNull()
|
||||
} else {
|
||||
target[key] = values
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,7 +575,7 @@ extension ConnectionsStore {
|
||||
defaultValue: Bool)
|
||||
{
|
||||
if value == defaultValue {
|
||||
actions.removeValue(forKey: key)
|
||||
actions[key] = NSNull()
|
||||
} else {
|
||||
actions[key] = value
|
||||
}
|
||||
|
||||
@@ -180,6 +180,7 @@ 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]?
|
||||
@@ -307,6 +308,7 @@ 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
|
||||
|
||||
@@ -67,20 +67,20 @@ enum CronSchedule: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
enum CronPayload: Codable, Equatable {
|
||||
case systemEvent(text: String)
|
||||
case agentTurn(
|
||||
message: String,
|
||||
thinking: String?,
|
||||
timeoutSeconds: Int?,
|
||||
deliver: Bool?,
|
||||
channel: String?,
|
||||
to: String?,
|
||||
bestEffortDeliver: Bool?)
|
||||
enum CronPayload: Codable, Equatable {
|
||||
case systemEvent(text: String)
|
||||
case agentTurn(
|
||||
message: String,
|
||||
thinking: String?,
|
||||
timeoutSeconds: Int?,
|
||||
deliver: Bool?,
|
||||
channel: String?,
|
||||
to: String?,
|
||||
bestEffortDeliver: Bool?)
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver
|
||||
}
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver
|
||||
}
|
||||
|
||||
var kind: String {
|
||||
switch self {
|
||||
@@ -95,16 +95,16 @@ enum CronSchedule: Codable, Equatable {
|
||||
switch kind {
|
||||
case "systemEvent":
|
||||
self = try .systemEvent(text: container.decode(String.self, forKey: .text))
|
||||
case "agentTurn":
|
||||
self = try .agentTurn(
|
||||
message: container.decode(String.self, forKey: .message),
|
||||
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),
|
||||
to: container.decodeIfPresent(String.self, forKey: .to),
|
||||
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
|
||||
case "agentTurn":
|
||||
self = try .agentTurn(
|
||||
message: container.decode(String.self, forKey: .message),
|
||||
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),
|
||||
to: container.decodeIfPresent(String.self, forKey: .to),
|
||||
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .kind,
|
||||
@@ -119,17 +119,17 @@ enum CronSchedule: Codable, Equatable {
|
||||
switch self {
|
||||
case let .systemEvent(text):
|
||||
try container.encode(text, forKey: .text)
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, 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(to, forKey: .to)
|
||||
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
|
||||
}
|
||||
}
|
||||
}
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, 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(to, forKey: .to)
|
||||
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CronIsolation: Codable, Equatable {
|
||||
var postToMainPrefix: String?
|
||||
|
||||
@@ -22,7 +22,7 @@ struct CronSettings_Previews: PreviewProvider {
|
||||
thinking: "low",
|
||||
timeoutSeconds: 600,
|
||||
deliver: true,
|
||||
provider: "last",
|
||||
channel: "last",
|
||||
to: nil,
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
||||
@@ -76,7 +76,7 @@ extension CronSettings {
|
||||
thinking: "low",
|
||||
timeoutSeconds: 120,
|
||||
deliver: true,
|
||||
provider: "whatsapp",
|
||||
channel: "whatsapp",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ actor GatewayConnection {
|
||||
case channelsStatus = "channels.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"
|
||||
|
||||
@@ -388,6 +388,43 @@ 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(
|
||||
|
||||
@@ -313,27 +313,7 @@ struct MenuContent: View {
|
||||
private func openDashboard() async {
|
||||
do {
|
||||
let config = try await GatewayEndpointStore.shared.requireConfig()
|
||||
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",
|
||||
])
|
||||
}
|
||||
let url = try GatewayEndpointStore.dashboardURL(for: config)
|
||||
NSWorkspace.shared.open(url)
|
||||
} catch {
|
||||
let alert = NSAlert()
|
||||
|
||||
@@ -845,42 +845,67 @@ public struct ConfigGetParams: Codable, Sendable {
|
||||
|
||||
public struct ConfigSetParams: Codable, Sendable {
|
||||
public let raw: String
|
||||
public let basehash: String?
|
||||
|
||||
public init(
|
||||
raw: String
|
||||
raw: String,
|
||||
basehash: 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 {
|
||||
}
|
||||
|
||||
|
||||
@@ -4,22 +4,22 @@ import Testing
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct ConnectionsSettingsSmokeTests {
|
||||
@Test func connectionsSettingsBuildsBodyWithSnapshot() {
|
||||
let store = ConnectionsStore(isPreview: true)
|
||||
store.snapshot = ChannelsStatusSnapshot(
|
||||
ts: 1_700_000_000_000,
|
||||
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
||||
channelLabels: [
|
||||
"whatsapp": "WhatsApp",
|
||||
"telegram": "Telegram",
|
||||
"signal": "Signal",
|
||||
"imessage": "iMessage",
|
||||
],
|
||||
channels: [
|
||||
"whatsapp": AnyCodable([
|
||||
"configured": true,
|
||||
"linked": true,
|
||||
struct ConnectionsSettingsSmokeTests {
|
||||
@Test func connectionsSettingsBuildsBodyWithSnapshot() {
|
||||
let store = ConnectionsStore(isPreview: true)
|
||||
store.snapshot = ChannelsStatusSnapshot(
|
||||
ts: 1_700_000_000_000,
|
||||
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
||||
channelLabels: [
|
||||
"whatsapp": "WhatsApp",
|
||||
"telegram": "Telegram",
|
||||
"signal": "Signal",
|
||||
"imessage": "iMessage",
|
||||
],
|
||||
channels: [
|
||||
"whatsapp": AnyCodable([
|
||||
"configured": true,
|
||||
"linked": true,
|
||||
"authAgeMs": 86_400_000,
|
||||
"self": ["e164": "+15551234567"],
|
||||
"running": true,
|
||||
@@ -70,13 +70,13 @@ import Testing
|
||||
"lastError": "not configured",
|
||||
"probe": ["ok": false, "error": "imsg not found (imsg)"],
|
||||
"lastProbeAt": 1_700_000_050_000,
|
||||
]),
|
||||
],
|
||||
channelAccounts: [:],
|
||||
channelDefaultAccountId: [
|
||||
"whatsapp": "default",
|
||||
"telegram": "default",
|
||||
"signal": "default",
|
||||
]),
|
||||
],
|
||||
channelAccounts: [:],
|
||||
channelDefaultAccountId: [
|
||||
"whatsapp": "default",
|
||||
"telegram": "default",
|
||||
"signal": "default",
|
||||
"imessage": "default",
|
||||
])
|
||||
|
||||
@@ -93,23 +93,23 @@ import Testing
|
||||
|
||||
let view = ConnectionsSettings(store: store)
|
||||
_ = view.body
|
||||
}
|
||||
}
|
||||
|
||||
@Test func connectionsSettingsBuildsBodyWithoutSnapshot() {
|
||||
let store = ConnectionsStore(isPreview: true)
|
||||
store.snapshot = ChannelsStatusSnapshot(
|
||||
ts: 1_700_000_000_000,
|
||||
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
||||
channelLabels: [
|
||||
"whatsapp": "WhatsApp",
|
||||
"telegram": "Telegram",
|
||||
"signal": "Signal",
|
||||
"imessage": "iMessage",
|
||||
],
|
||||
channels: [
|
||||
"whatsapp": AnyCodable([
|
||||
"configured": false,
|
||||
"linked": false,
|
||||
@Test func connectionsSettingsBuildsBodyWithoutSnapshot() {
|
||||
let store = ConnectionsStore(isPreview: true)
|
||||
store.snapshot = ChannelsStatusSnapshot(
|
||||
ts: 1_700_000_000_000,
|
||||
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
||||
channelLabels: [
|
||||
"whatsapp": "WhatsApp",
|
||||
"telegram": "Telegram",
|
||||
"signal": "Signal",
|
||||
"imessage": "iMessage",
|
||||
],
|
||||
channels: [
|
||||
"whatsapp": AnyCodable([
|
||||
"configured": false,
|
||||
"linked": false,
|
||||
"running": false,
|
||||
"connected": false,
|
||||
"reconnectAttempts": 0,
|
||||
@@ -146,13 +146,13 @@ import Testing
|
||||
"cliPath": "imsg",
|
||||
"probe": ["ok": false, "error": "imsg not found (imsg)"],
|
||||
"lastProbeAt": 1_700_000_200_000,
|
||||
]),
|
||||
],
|
||||
channelAccounts: [:],
|
||||
channelDefaultAccountId: [
|
||||
"whatsapp": "default",
|
||||
"telegram": "default",
|
||||
"signal": "default",
|
||||
]),
|
||||
],
|
||||
channelAccounts: [:],
|
||||
channelDefaultAccountId: [
|
||||
"whatsapp": "default",
|
||||
"telegram": "default",
|
||||
"signal": "default",
|
||||
"imessage": "default",
|
||||
])
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite struct HealthDecodeTests {
|
||||
private let sampleJSON: String = // minimal but complete payload
|
||||
"""
|
||||
@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}]}}
|
||||
"""
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,438 @@
|
||||
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.
|
After Width: | Height: | Size: 614 B |
Binary file not shown.
|
After Width: | Height: | Size: 265 B |
Binary file not shown.
|
After Width: | Height: | Size: 318 B |
Binary file not shown.
|
After Width: | Height: | Size: 370 B |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"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 }
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<!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:<port>/</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>
|
||||
@@ -0,0 +1,59 @@
|
||||
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
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,16 @@ Note: Discord does not provide a simple username → id lookup without extra gui
|
||||
Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`.
|
||||
Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-ready replies easy.
|
||||
|
||||
## Config writes
|
||||
By default, Discord is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`).
|
||||
|
||||
Disable with:
|
||||
```json5
|
||||
{
|
||||
channels: { discord: { configWrites: false } }
|
||||
}
|
||||
```
|
||||
|
||||
## How to create your own bot
|
||||
|
||||
This is the “Discord Developer Portal” setup for running Clawdbot in a server (guild) channel like `#help`.
|
||||
@@ -290,6 +300,7 @@ ack reaction after the bot replies.
|
||||
- `maxLinesPerMessage`: soft max line count per message. Default: 17.
|
||||
- `mediaMaxMb`: clamp inbound media saved to disk.
|
||||
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables).
|
||||
- `dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `dms["<user_id>"].historyLimit`.
|
||||
- `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
|
||||
- `reactions` (covers react + read reactions)
|
||||
|
||||
@@ -35,6 +35,16 @@ Minimal config:
|
||||
- DMs share the agent's main session; groups are isolated (`agent:<agentId>:imessage:group:<chat_id>`).
|
||||
- If a multi-participant thread arrives with `is_group=false`, you can still isolate it by `chat_id` using `channels.imessage.groups` (see “Group-ish threads” below).
|
||||
|
||||
## Config writes
|
||||
By default, iMessage is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`).
|
||||
|
||||
Disable with:
|
||||
```json5
|
||||
{
|
||||
channels: { imessage: { configWrites: false } }
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
- macOS with Messages signed in.
|
||||
- Full Disk Access for Clawdbot + `imsg` (Messages DB access).
|
||||
@@ -179,6 +189,7 @@ Provider options:
|
||||
- `channels.imessage.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.imessage.groupAllowFrom`: group sender allowlist.
|
||||
- `channels.imessage.historyLimit` / `channels.imessage.accounts.*.historyLimit`: max group messages to include as context (0 disables).
|
||||
- `channels.imessage.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.imessage.dms["<handle>"].historyLimit`.
|
||||
- `channels.imessage.groups`: per-group defaults + allowlist (use `"*"` for global defaults).
|
||||
- `channels.imessage.includeAttachments`: ingest attachments into context.
|
||||
- `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
|
||||
@@ -18,6 +18,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
||||
- [iMessage](/channels/imessage) — macOS only; native integration.
|
||||
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support.
|
||||
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin).
|
||||
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -39,6 +39,16 @@ Note: group chats are blocked by default (`channels.msteams.groupPolicy: "allowl
|
||||
- Keep routing deterministic: replies always go back to the channel they arrived on.
|
||||
- Default to safe channel behavior (mentions required unless configured otherwise).
|
||||
|
||||
## Config writes
|
||||
By default, Microsoft Teams is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`).
|
||||
|
||||
Disable with:
|
||||
```json5
|
||||
{
|
||||
channels: { msteams: { configWrites: false } }
|
||||
}
|
||||
```
|
||||
|
||||
## Access control (DMs + groups)
|
||||
|
||||
**DM access**
|
||||
@@ -198,6 +208,7 @@ This is often easier than hand-editing JSON manifests.
|
||||
## History context
|
||||
- `channels.msteams.historyLimit` controls how many recent channel/group messages are wrapped into the prompt.
|
||||
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||
- DM history can be limited with `channels.msteams.dmHistoryLimit` (user turns). Per-user overrides: `channels.msteams.dms["<user_id>"].historyLimit`.
|
||||
|
||||
## Current Teams RSC Permissions (Manifest)
|
||||
These are the **existing resourceSpecific permissions** in our Teams app manifest. They only apply inside the team/chat where the app is installed.
|
||||
|
||||
@@ -36,6 +36,16 @@ Minimal config:
|
||||
- Deterministic routing: replies always go back to Signal.
|
||||
- DMs share the agent's main session; groups are isolated (`agent:<agentId>:signal:group:<groupId>`).
|
||||
|
||||
## Config writes
|
||||
By default, Signal is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`).
|
||||
|
||||
Disable with:
|
||||
```json5
|
||||
{
|
||||
channels: { signal: { configWrites: false } }
|
||||
}
|
||||
```
|
||||
|
||||
## The number model (important)
|
||||
- The gateway connects to a **Signal device** (the `signal-cli` account).
|
||||
- If you run the bot on **your personal Signal account**, it will ignore your own messages (loop protection).
|
||||
@@ -114,6 +124,7 @@ Provider options:
|
||||
- `channels.signal.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.signal.groupAllowFrom`: group sender allowlist.
|
||||
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
|
||||
- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms["<phone_or_uuid>"].historyLimit`.
|
||||
- `channels.signal.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ Minimal config:
|
||||
- `app_mention`
|
||||
- `reaction_added`, `reaction_removed`
|
||||
- `member_joined_channel`, `member_left_channel`
|
||||
- `channel_id_changed`
|
||||
- `channel_rename`
|
||||
- `pin_added`, `pin_removed`
|
||||
5) Invite the bot to channels you want it to read.
|
||||
@@ -65,6 +66,21 @@ Or via config:
|
||||
## History context
|
||||
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
|
||||
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||
- DM history can be limited with `channels.slack.dmHistoryLimit` (user turns). Per-user overrides: `channels.slack.dms["<user_id>"].historyLimit`.
|
||||
|
||||
## Config writes
|
||||
By default, Slack is allowed to write config updates triggered by channel events or `/config set|unset`.
|
||||
|
||||
This happens when:
|
||||
- Slack emits `channel_id_changed` (e.g. Slack Connect channel ID changes). Clawdbot can migrate `channels.slack.channels` automatically.
|
||||
- You run `/config set` or `/config unset` in Slack (requires `commands.config: true`).
|
||||
|
||||
Disable with:
|
||||
```json5
|
||||
{
|
||||
channels: { slack: { configWrites: false } }
|
||||
}
|
||||
```
|
||||
|
||||
## Manifest (optional)
|
||||
Use this Slack app manifest to create the app quickly (adjust the name/command if you want).
|
||||
@@ -133,6 +149,7 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i
|
||||
"reaction_removed",
|
||||
"member_joined_channel",
|
||||
"member_left_channel",
|
||||
"channel_id_changed",
|
||||
"channel_rename",
|
||||
"pin_added",
|
||||
"pin_removed"
|
||||
|
||||
@@ -103,7 +103,9 @@ group messages, so use admin if you need full visibility.
|
||||
## Limits
|
||||
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
|
||||
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5).
|
||||
- Telegram Bot API requests time out after `channels.telegram.timeoutSeconds` (default 500 via grammY). Set lower to avoid long hangs.
|
||||
- Group history context uses `channels.telegram.historyLimit` (or `channels.telegram.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||
- DM history can be limited with `channels.telegram.dmHistoryLimit` (user turns). Per-user overrides: `channels.telegram.dms["<user_id>"].historyLimit`.
|
||||
|
||||
## Group activation modes
|
||||
|
||||
@@ -167,6 +169,20 @@ Forward any message from the group to `@userinfobot` or `@getidsbot` on Telegram
|
||||
|
||||
**Privacy note:** `@userinfobot` is a third-party bot. If you prefer, use gateway logs (`clawdbot logs`) or Telegram developer tools to find user/chat IDs.
|
||||
|
||||
## Config writes
|
||||
By default, Telegram is allowed to write config updates triggered by channel events or `/config set|unset`.
|
||||
|
||||
This happens when:
|
||||
- A group is upgraded to a supergroup and Telegram emits `migrate_to_chat_id` (chat ID changes). Clawdbot can migrate `channels.telegram.groups` automatically.
|
||||
- You run `/config set` or `/config unset` in a Telegram chat (requires `commands.config: true`).
|
||||
|
||||
Disable with:
|
||||
```json5
|
||||
{
|
||||
channels: { telegram: { configWrites: false } }
|
||||
}
|
||||
```
|
||||
|
||||
## Topics (forum supergroups)
|
||||
Telegram forum topics include a `message_thread_id` per message. Clawdbot:
|
||||
- Appends `:topic:<threadId>` to the Telegram group session key so each topic is isolated.
|
||||
@@ -261,8 +277,9 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti
|
||||
## Agent tool (messages + reactions)
|
||||
- Tool: `telegram` with `sendMessage` action (`to`, `content`, optional `mediaUrl`, `replyToMessageId`, `messageThreadId`).
|
||||
- Tool: `telegram` with `react` action (`chatId`, `messageId`, `emoji`).
|
||||
- Tool: `telegram` with `deleteMessage` action (`chatId`, `messageId`).
|
||||
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
|
||||
- Tool gating: `channels.telegram.actions.reactions` and `channels.telegram.actions.sendMessage` (default: enabled).
|
||||
- Tool gating: `channels.telegram.actions.reactions`, `channels.telegram.actions.sendMessage`, `channels.telegram.actions.deleteMessage` (default: enabled).
|
||||
|
||||
## Delivery targets (CLI/cron)
|
||||
- Use a chat id (`123456789`) or a username (`@name`) as the target.
|
||||
@@ -321,6 +338,7 @@ Provider options:
|
||||
- `channels.telegram.webhookPath`: local webhook path (default `/telegram-webhook`).
|
||||
- `channels.telegram.actions.reactions`: gate Telegram tool reactions.
|
||||
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
|
||||
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
|
||||
|
||||
Related global options:
|
||||
- `agents.list[].groupChat.mentionPatterns` (mention gating patterns).
|
||||
|
||||
@@ -31,6 +31,16 @@ Minimal config:
|
||||
- Deterministic routing: replies return to WhatsApp, no model routing.
|
||||
- Model sees enough context to understand quoted replies.
|
||||
|
||||
## Config writes
|
||||
By default, WhatsApp is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`).
|
||||
|
||||
Disable with:
|
||||
```json5
|
||||
{
|
||||
channels: { whatsapp: { configWrites: false } }
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture (who owns what)
|
||||
- **Gateway** owns the Baileys socket and inbox loop.
|
||||
- **CLI / macOS app** talk to the gateway; no direct Baileys use.
|
||||
@@ -282,6 +292,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
||||
- `channels.whatsapp.groupAllowFrom` (group sender allowlist).
|
||||
- `channels.whatsapp.groupPolicy` (group policy).
|
||||
- `channels.whatsapp.historyLimit` / `channels.whatsapp.accounts.<accountId>.historyLimit` (group history context; `0` disables).
|
||||
- `channels.whatsapp.dmHistoryLimit` (DM history limit in user turns). Per-user overrides: `channels.whatsapp.dms["<phone>"].historyLimit`.
|
||||
- `channels.whatsapp.groups` (group allowlist + mention gating defaults; use `"*"` to allow all)
|
||||
- `channels.whatsapp.actions.reactions` (gate WhatsApp tool reactions).
|
||||
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`)
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
summary: "Zalo bot support status, capabilities, and configuration"
|
||||
read_when:
|
||||
- Working on Zalo features or webhooks
|
||||
---
|
||||
# Zalo (Bot API)
|
||||
|
||||
Status: experimental. Direct messages only; groups coming soon per Zalo docs.
|
||||
|
||||
## Quick setup (beginner)
|
||||
1) Install the Zalo plugin:
|
||||
- From a source checkout: `clawdbot plugins install ./extensions/zalo`
|
||||
- From npm (if published): `clawdbot plugins install @clawdbot/zalo`
|
||||
- Or pick **Zalo** in onboarding and confirm the install prompt
|
||||
2) Set the token:
|
||||
- Env: `ZALO_BOT_TOKEN=...`
|
||||
- Or config: `channels.zalo.botToken: "..."`.
|
||||
3) Restart the gateway (or finish onboarding).
|
||||
4) DM access is pairing by default; approve the pairing code on first contact.
|
||||
|
||||
Minimal config:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
zalo: {
|
||||
enabled: true,
|
||||
botToken: "12345689:abc-xyz",
|
||||
dmPolicy: "pairing"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## What it is
|
||||
- A Zalo Bot API channel owned by the Gateway.
|
||||
- Deterministic routing: replies go back to Zalo; the model never chooses channels.
|
||||
- DMs share the agent's main session.
|
||||
- Groups are not yet supported (Zalo docs state "coming soon").
|
||||
|
||||
## Setup (fast path)
|
||||
|
||||
### 1) Create a bot token (Zalo Bot Platform)
|
||||
1) Go to **https://bot.zaloplatforms.com** and sign in.
|
||||
2) Create a new bot and configure its settings.
|
||||
3) Copy the bot token (format: `12345689:abc-xyz`).
|
||||
|
||||
### 2) Configure the token (env or config)
|
||||
Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
zalo: {
|
||||
enabled: true,
|
||||
botToken: "12345689:abc-xyz",
|
||||
dmPolicy: "pairing"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Env option: `ZALO_BOT_TOKEN=...` (works for the default account only).
|
||||
|
||||
Multi-account support: use `channels.zalo.accounts` with per-account tokens and optional `name`.
|
||||
|
||||
3) Restart the gateway. Zalo starts when a token is resolved (env or config).
|
||||
4) DM access defaults to pairing. Approve the code when the bot is first contacted.
|
||||
|
||||
## How it works (behavior)
|
||||
- Inbound messages are normalized into the shared channel envelope with media placeholders.
|
||||
- Replies always route back to the same Zalo chat.
|
||||
- Long-polling by default; webhook mode available with `channels.zalo.webhookUrl`.
|
||||
|
||||
## Limits
|
||||
- Outbound text is chunked to 2000 characters (Zalo API limit).
|
||||
- Media downloads/uploads are capped by `channels.zalo.mediaMaxMb` (default 5).
|
||||
- Streaming is blocked by default due to the 2000 char limit making streaming less useful.
|
||||
|
||||
## Access control (DMs)
|
||||
|
||||
### DM access
|
||||
- Default: `channels.zalo.dmPolicy = "pairing"`. Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour).
|
||||
- Approve via:
|
||||
- `clawdbot pairing list zalo`
|
||||
- `clawdbot pairing approve zalo <CODE>`
|
||||
- Pairing is the default token exchange. Details: [Pairing](/start/pairing)
|
||||
- `channels.zalo.allowFrom` accepts numeric user IDs.
|
||||
|
||||
## Long-polling vs webhook
|
||||
- Default: long-polling (no public URL required).
|
||||
- Webhook mode: set `channels.zalo.webhookUrl` and `channels.zalo.webhookSecret`.
|
||||
- The webhook secret must be 8-256 characters.
|
||||
- Webhook URL must use HTTPS.
|
||||
- Zalo sends events with `X-Bot-Api-Secret-Token` header for verification.
|
||||
- Gateway HTTP handles webhook requests at `channels.zalo.webhookPath` (defaults to the webhook URL path).
|
||||
|
||||
**Note:** getUpdates (polling) and webhook are mutually exclusive per Zalo API docs.
|
||||
|
||||
## Supported message types
|
||||
- **Text messages**: Full support with 2000 character chunking.
|
||||
- **Image messages**: Download and process inbound images; send images via `sendPhoto`.
|
||||
- **Stickers**: Logged but not fully processed (no agent response).
|
||||
- **Unsupported types**: Logged (e.g., messages from protected users).
|
||||
|
||||
## Capabilities
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Direct messages | ✅ Supported |
|
||||
| Groups | ❌ Coming soon (per Zalo docs) |
|
||||
| Media (images) | ✅ Supported |
|
||||
| Reactions | ❌ Not supported |
|
||||
| Threads | ❌ Not supported |
|
||||
| Polls | ❌ Not supported |
|
||||
| Native commands | ❌ Not supported |
|
||||
| Streaming | ⚠️ Blocked (2000 char limit) |
|
||||
|
||||
## Delivery targets (CLI/cron)
|
||||
- Use a chat id as the target.
|
||||
- Example: `clawdbot message send --channel zalo --to 123456789 --message "hi"`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Bot doesn't respond:**
|
||||
- Check that the token is valid: `clawdbot channels status --probe`
|
||||
- Verify the sender is approved (pairing or allowFrom)
|
||||
- Check gateway logs: `clawdbot logs --follow`
|
||||
|
||||
**Webhook not receiving events:**
|
||||
- Ensure webhook URL uses HTTPS
|
||||
- Verify secret token is 8-256 characters
|
||||
- Confirm the gateway HTTP endpoint is reachable on the configured path
|
||||
- Check that getUpdates polling is not running (they're mutually exclusive)
|
||||
|
||||
## Configuration reference (Zalo)
|
||||
Full configuration: [Configuration](/gateway/configuration)
|
||||
|
||||
Provider options:
|
||||
- `channels.zalo.enabled`: enable/disable channel startup.
|
||||
- `channels.zalo.botToken`: bot token from Zalo Bot Platform.
|
||||
- `channels.zalo.tokenFile`: read token from file path.
|
||||
- `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`.
|
||||
- `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5).
|
||||
- `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required).
|
||||
- `channels.zalo.webhookSecret`: webhook secret (8-256 chars).
|
||||
- `channels.zalo.webhookPath`: webhook path on the gateway HTTP server.
|
||||
- `channels.zalo.proxy`: proxy URL for API requests.
|
||||
|
||||
Multi-account options:
|
||||
- `channels.zalo.accounts.<id>.botToken`: per-account token.
|
||||
- `channels.zalo.accounts.<id>.tokenFile`: per-account token file.
|
||||
- `channels.zalo.accounts.<id>.name`: display name.
|
||||
- `channels.zalo.accounts.<id>.enabled`: enable/disable account.
|
||||
- `channels.zalo.accounts.<id>.dmPolicy`: per-account DM policy.
|
||||
- `channels.zalo.accounts.<id>.allowFrom`: per-account allowlist.
|
||||
- `channels.zalo.accounts.<id>.webhookUrl`: per-account webhook URL.
|
||||
- `channels.zalo.accounts.<id>.webhookSecret`: per-account webhook secret.
|
||||
- `channels.zalo.accounts.<id>.webhookPath`: per-account webhook path.
|
||||
- `channels.zalo.accounts.<id>.proxy`: per-account proxy URL.
|
||||
@@ -48,6 +48,8 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
onboard
|
||||
configure (alias: config)
|
||||
doctor
|
||||
security
|
||||
audit
|
||||
reset
|
||||
uninstall
|
||||
update
|
||||
@@ -180,6 +182,12 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
|
||||
Note: plugins can add additional top-level commands (for example `clawdbot voicecall`).
|
||||
|
||||
## Security
|
||||
|
||||
- `clawdbot security audit` — audit config + local state for common security foot-guns.
|
||||
- `clawdbot security audit --deep` — best-effort live Gateway probe.
|
||||
- `clawdbot security audit --fix` — tighten safe defaults and chmod state/config.
|
||||
|
||||
## Plugins
|
||||
|
||||
Manage extensions and their config:
|
||||
@@ -579,8 +587,12 @@ Subcommands:
|
||||
|
||||
Common RPCs:
|
||||
- `config.apply` (validate + write config + restart + wake)
|
||||
- `config.patch` (merge a partial update without clobbering unrelated keys)
|
||||
- `update.run` (run update + restart + wake)
|
||||
|
||||
Tip: when calling `config.set`/`config.apply`/`config.patch` directly, pass `baseHash` from
|
||||
`config.get` if a config already exists.
|
||||
|
||||
## Models
|
||||
|
||||
See [/concepts/models](/concepts/models) for fallback behavior and scanning strategy.
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ Target formats (`--to`):
|
||||
- Optional: `--channel-id`
|
||||
|
||||
- `delete`
|
||||
- Channels: Discord/Slack
|
||||
- Channels: Discord/Slack/Telegram
|
||||
- Required: `--message-id`, `--to` or `--channel-id`
|
||||
- Optional: `--channel-id`
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
summary: "Context: what the model sees, how it is built, and how to inspect it"
|
||||
read_when:
|
||||
- You want to understand what “context” means in Clawdbot
|
||||
- You are debugging why the model “knows” something (or forgot it)
|
||||
- You want to reduce context overhead (/context, /status, /compact)
|
||||
---
|
||||
# Context
|
||||
|
||||
“Context” is **everything Clawdbot sends to the model for a run**. It is bounded by the model’s **context window** (token limit).
|
||||
|
||||
Beginner mental model:
|
||||
- **System prompt** (Clawdbot-built): rules, tools, skills list, time/runtime, and injected workspace files.
|
||||
- **Conversation history**: your messages + the assistant’s messages for this session.
|
||||
- **Tool calls/results + attachments**: command output, file reads, images/audio, etc.
|
||||
|
||||
Context is *not the same thing* as “memory”: memory can be stored on disk and reloaded later; context is what’s inside the model’s current window.
|
||||
|
||||
## Quick start (inspect context)
|
||||
|
||||
- `/status` → quick “how full is my window?” view + session settings.
|
||||
- `/context list` → what’s injected + rough sizes (per file + totals).
|
||||
- `/context detail` → deeper breakdown: per-file, per-tool schema sizes, per-skill entry sizes, and system prompt size.
|
||||
- `/cost on` → append per-reply usage line to normal replies.
|
||||
- `/compact` → summarize older history into a compact entry to free window space.
|
||||
|
||||
See also: [Slash commands](/tools/slash-commands), [Token use & costs](/token-use), [Compaction](/concepts/compaction).
|
||||
|
||||
## Example output
|
||||
|
||||
Values vary by model, provider, tool policy, and what’s in your workspace.
|
||||
|
||||
### `/context list`
|
||||
|
||||
```
|
||||
🧠 Context breakdown
|
||||
Workspace: <workspaceDir>
|
||||
Bootstrap max/file: 20,000 chars
|
||||
Sandbox: mode=non-main sandboxed=false
|
||||
System prompt (run): 38,412 chars (~9,603 tok) (Project Context 23,901 chars (~5,976 tok))
|
||||
|
||||
Injected workspace files:
|
||||
- AGENTS.md: OK | raw 1,742 chars (~436 tok) | injected 1,742 chars (~436 tok)
|
||||
- SOUL.md: OK | raw 912 chars (~228 tok) | injected 912 chars (~228 tok)
|
||||
- TOOLS.md: TRUNCATED | raw 54,210 chars (~13,553 tok) | injected 20,962 chars (~5,241 tok)
|
||||
- IDENTITY.md: OK | raw 211 chars (~53 tok) | injected 211 chars (~53 tok)
|
||||
- USER.md: OK | raw 388 chars (~97 tok) | injected 388 chars (~97 tok)
|
||||
- HEARTBEAT.md: MISSING | raw 0 | injected 0
|
||||
- BOOTSTRAP.md: OK | raw 0 chars (~0 tok) | injected 0 chars (~0 tok)
|
||||
|
||||
Skills list (system prompt text): 2,184 chars (~546 tok) (12 skills)
|
||||
Tools: read, edit, write, exec, process, browser, message, sessions_send, …
|
||||
Tool list (system prompt text): 1,032 chars (~258 tok)
|
||||
Tool schemas (JSON): 31,988 chars (~7,997 tok) (counts toward context; not shown as text)
|
||||
Tools: (same as above)
|
||||
|
||||
Session tokens (cached): 14,250 total / ctx=32,000
|
||||
```
|
||||
|
||||
### `/context detail`
|
||||
|
||||
```
|
||||
🧠 Context breakdown (detailed)
|
||||
…
|
||||
Top skills (prompt entry size):
|
||||
- frontend-design: 412 chars (~103 tok)
|
||||
- oracle: 401 chars (~101 tok)
|
||||
… (+10 more skills)
|
||||
|
||||
Top tools (schema size):
|
||||
- browser: 9,812 chars (~2,453 tok)
|
||||
- exec: 6,240 chars (~1,560 tok)
|
||||
… (+N more tools)
|
||||
```
|
||||
|
||||
## What counts toward the context window
|
||||
|
||||
Everything the model receives counts, including:
|
||||
- System prompt (all sections).
|
||||
- Conversation history.
|
||||
- Tool calls + tool results.
|
||||
- Attachments/transcripts (images/audio/files).
|
||||
- Compaction summaries and pruning artifacts.
|
||||
- Provider “wrappers” or hidden headers (not visible, still counted).
|
||||
|
||||
## How Clawdbot builds the system prompt
|
||||
|
||||
The system prompt is **Clawdbot-owned** and rebuilt each run. It includes:
|
||||
- Tool list + short descriptions.
|
||||
- Skills list (metadata only; see below).
|
||||
- Workspace location.
|
||||
- Time (UTC + converted user time if configured).
|
||||
- Runtime metadata (host/OS/model/thinking).
|
||||
- Injected workspace bootstrap files under **Project Context**.
|
||||
|
||||
Full breakdown: [System Prompt](/concepts/system-prompt).
|
||||
|
||||
## Injected workspace files (Project Context)
|
||||
|
||||
By default, Clawdbot injects a fixed set of workspace files (if present):
|
||||
- `AGENTS.md`
|
||||
- `SOUL.md`
|
||||
- `TOOLS.md`
|
||||
- `IDENTITY.md`
|
||||
- `USER.md`
|
||||
- `HEARTBEAT.md`
|
||||
- `BOOTSTRAP.md` (first-run only)
|
||||
|
||||
Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `20000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened.
|
||||
|
||||
## Skills: what’s injected vs loaded on-demand
|
||||
|
||||
The system prompt includes a compact **skills list** (name + description + location). This list has real overhead.
|
||||
|
||||
Skill instructions are *not* included by default. The model is expected to `read` the skill’s `SKILL.md` **only when needed**.
|
||||
|
||||
## Tools: there are two costs
|
||||
|
||||
Tools affect context in two ways:
|
||||
1) **Tool list text** in the system prompt (what you see as “Tooling”).
|
||||
2) **Tool schemas** (JSON). These are sent to the model so it can call tools. They count toward context even though you don’t see them as plain text.
|
||||
|
||||
`/context detail` breaks down the biggest tool schemas so you can see what dominates.
|
||||
|
||||
## Commands, directives, and “inline shortcuts”
|
||||
|
||||
Slash commands are handled by the Gateway. There are a few different behaviors:
|
||||
- **Standalone commands**: a message that is only `/...` runs as a command.
|
||||
- **Directives**: `/think`, `/verbose`, `/reasoning`, `/elevated`, `/model`, `/queue` are stripped before the model sees the message.
|
||||
- Directive-only messages persist session settings.
|
||||
- Inline directives in a normal message act as per-message hints.
|
||||
- **Inline shortcuts** (allowlisted senders only): certain `/...` tokens inside a normal message can run immediately (example: “hey /status”), and are stripped before the model sees the remaining text.
|
||||
|
||||
Details: [Slash commands](/tools/slash-commands).
|
||||
|
||||
## Sessions, compaction, and pruning (what persists)
|
||||
|
||||
What persists across messages depends on the mechanism:
|
||||
- **Normal history** persists in the session transcript until compacted/pruned by policy.
|
||||
- **Compaction** persists a summary into the transcript and keeps recent messages intact.
|
||||
- **Pruning** removes old tool results from the *in-memory* prompt for a run, but does not rewrite the transcript.
|
||||
|
||||
Docs: [Session](/concepts/session), [Compaction](/concepts/compaction), [Session pruning](/concepts/session-pruning).
|
||||
|
||||
## What `/context` actually reports
|
||||
|
||||
`/context` prefers the latest **run-built** system prompt report when available:
|
||||
- `System prompt (run)` = captured from the last embedded (tool-capable) run and persisted in the session store.
|
||||
- `System prompt (estimate)` = computed on the fly when no run report exists (or when running via a CLI backend that doesn’t generate the report).
|
||||
|
||||
Either way, it reports sizes and top contributors; it does **not** dump the full system prompt or tool schemas.
|
||||
|
||||
@@ -46,6 +46,70 @@ If you want...
|
||||
- Direct chats use the main session (or per-sender if configured).
|
||||
- Heartbeats are skipped for group sessions.
|
||||
|
||||
## Pattern: personal DMs + public groups (single agent)
|
||||
|
||||
Yes — this works well if your “personal” traffic is **DMs** and your “public” traffic is **groups**.
|
||||
|
||||
Why: in single-agent mode, DMs typically land in the **main** session key (`agent:main:main`), while groups always use **non-main** session keys (`agent:main:<channel>:group:<id>`). If you enable sandboxing with `mode: "non-main"`, those group sessions run in Docker while your main DM session stays on-host.
|
||||
|
||||
This gives you one agent “brain” (shared workspace + memory), but two execution postures:
|
||||
- **DMs**: full tools (host)
|
||||
- **Groups**: sandbox + restricted tools (Docker)
|
||||
|
||||
> If you need truly separate workspaces/personas (“personal” and “public” must never mix), use a second agent + bindings. See [Multi-Agent Routing](/concepts/multi-agent).
|
||||
|
||||
Example (DMs on host, groups sandboxed + messaging-only tools):
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "non-main", // groups/channels are non-main -> sandboxed
|
||||
scope: "session", // strongest isolation (one container per group/channel)
|
||||
workspaceAccess: "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
tools: {
|
||||
sandbox: {
|
||||
tools: {
|
||||
// If allow is non-empty, everything else is blocked (deny still wins).
|
||||
allow: ["group:messaging", "group:sessions"],
|
||||
deny: ["group:runtime", "group:fs", "group:ui", "nodes", "cron", "gateway"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Want “groups can only see folder X” instead of “no host access”? Keep `workspaceAccess: "none"` and mount only allowlisted paths into the sandbox:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "non-main",
|
||||
scope: "session",
|
||||
workspaceAccess: "none",
|
||||
docker: {
|
||||
binds: [
|
||||
// hostPath:containerPath:mode
|
||||
"~/FriendsShared:/data:ro"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Related:
|
||||
- Configuration keys and defaults: [Gateway configuration](/gateway/configuration#agentsdefaultssandbox)
|
||||
- Debugging why a tool is blocked: [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated)
|
||||
- Bind mounts details: [Sandboxing](/gateway/sandboxing#custom-bind-mounts)
|
||||
|
||||
## Display labels
|
||||
- UI labels use `displayName` when available, formatted as `<channel>:<token>`.
|
||||
- `#room` is reserved for rooms/channels; group chats use `g-<slug>` (lowercase, spaces -> `-`, keep `#@+._-`).
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
summary: "Markdown formatting pipeline for outbound channels"
|
||||
read_when:
|
||||
- You are changing markdown formatting or chunking for outbound channels
|
||||
- You are adding a new channel formatter or style mapping
|
||||
- You are debugging formatting regressions across channels
|
||||
---
|
||||
# Markdown formatting
|
||||
|
||||
Clawdbot formats outbound Markdown by converting it into a shared intermediate
|
||||
representation (IR) before rendering channel-specific output. The IR keeps the
|
||||
source text intact while carrying style/link spans so chunking and rendering can
|
||||
stay consistent across channels.
|
||||
|
||||
## Goals
|
||||
|
||||
- **Consistency:** one parse step, multiple renderers.
|
||||
- **Safe chunking:** split text before rendering so inline formatting never
|
||||
breaks across chunks.
|
||||
- **Channel fit:** map the same IR to Slack mrkdwn, Telegram HTML, and Signal
|
||||
style ranges without re-parsing Markdown.
|
||||
|
||||
## Pipeline
|
||||
|
||||
1. **Parse Markdown -> IR**
|
||||
- IR is plain text plus style spans (bold/italic/strike/code/spoiler) and link spans.
|
||||
- Offsets are UTF-16 code units so Signal style ranges align with its API.
|
||||
2. **Chunk IR (format-first)**
|
||||
- Chunking happens on the IR text before rendering.
|
||||
- Inline formatting does not split across chunks; spans are sliced per chunk.
|
||||
3. **Render per channel**
|
||||
- **Slack:** mrkdwn tokens (bold/italic/strike/code), links as `<url|label>`.
|
||||
- **Telegram:** HTML tags (`<b>`, `<i>`, `<s>`, `<code>`, `<pre><code>`, `<a href>`).
|
||||
- **Signal:** plain text + `text-style` ranges; links become `label (url)` when label differs.
|
||||
|
||||
## IR example
|
||||
|
||||
Input Markdown:
|
||||
|
||||
```markdown
|
||||
Hello **world** — see [docs](https://docs.clawd.bot).
|
||||
```
|
||||
|
||||
IR (schematic):
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "Hello world — see docs.",
|
||||
"styles": [
|
||||
{ "start": 6, "end": 11, "style": "bold" }
|
||||
],
|
||||
"links": [
|
||||
{ "start": 19, "end": 23, "href": "https://docs.clawd.bot" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Where it is used
|
||||
|
||||
- Slack, Telegram, and Signal outbound adapters render from the IR.
|
||||
- Other channels (WhatsApp, iMessage, MS Teams, Discord) still use plain text or
|
||||
their own formatting rules.
|
||||
|
||||
## Chunking rules
|
||||
|
||||
- Chunk limits come from channel adapters/config and are applied to the IR text.
|
||||
- Code fences are preserved as a single block with a trailing newline so channels
|
||||
render them correctly.
|
||||
- List prefixes and blockquote prefixes are part of the IR text, so chunking
|
||||
does not split mid-prefix.
|
||||
- Inline styles (bold/italic/strike/inline-code/spoiler) are never split across
|
||||
chunks; the renderer reopens styles inside each chunk.
|
||||
|
||||
If you need more on chunking behavior across channels, see
|
||||
[Streaming + chunking](/concepts/streaming).
|
||||
|
||||
## Link policy
|
||||
|
||||
- **Slack:** `[label](url)` -> `<url|label>`; bare URLs remain bare. Autolink
|
||||
is disabled during parse to avoid double-linking.
|
||||
- **Telegram:** `[label](url)` -> `<a href="url">label</a>` (HTML parse mode).
|
||||
- **Signal:** `[label](url)` -> `label (url)` unless label matches the URL.
|
||||
|
||||
## Spoilers
|
||||
|
||||
Spoiler markers (`||spoiler||`) are parsed only for Signal, where they map to
|
||||
SPOILER style ranges. Other channels treat them as plain text.
|
||||
|
||||
## How to add or update a channel formatter
|
||||
|
||||
1. **Parse once:** use the shared `markdownToIR(...)` helper with channel-appropriate
|
||||
options (autolink, heading style, blockquote prefix).
|
||||
2. **Render:** implement a renderer with `renderMarkdownWithMarkers(...)` and a
|
||||
style marker map (or Signal style ranges).
|
||||
3. **Chunk:** call `chunkMarkdownIR(...)` before rendering; render each chunk.
|
||||
4. **Wire adapter:** update the channel outbound adapter to use the new chunker
|
||||
and renderer.
|
||||
5. **Test:** add or update format tests and an outbound delivery test if the
|
||||
channel uses chunking.
|
||||
|
||||
## Common gotchas
|
||||
|
||||
- Slack angle-bracket tokens (`<@U123>`, `<#C123>`, `<https://...>`) must be
|
||||
preserved; escape raw HTML safely.
|
||||
- Telegram HTML requires escaping text outside tags to avoid broken markup.
|
||||
- Signal style ranges depend on UTF-16 offsets; do not use code point offsets.
|
||||
- Preserve trailing newlines for fenced code blocks so closing markers land on
|
||||
their own line.
|
||||
@@ -152,7 +152,7 @@ JSON includes `auth.oauth` (warn window + profiles) and `auth.providers`
|
||||
(effective auth per provider).
|
||||
Use `--check` for automation (exit `1` when missing/expired, `2` when expiring).
|
||||
|
||||
Preferred Anthropic auth is the Claude CLI setup-token (run on the gateway host):
|
||||
Preferred Anthropic auth is the Claude Code CLI setup-token (run on the gateway host):
|
||||
|
||||
```bash
|
||||
claude setup-token
|
||||
|
||||
@@ -17,6 +17,16 @@ An **agent** is a fully scoped brain with its own:
|
||||
- **State directory** (`agentDir`) for auth profiles, model registry, and per-agent config.
|
||||
- **Session store** (chat history + routing state) under `~/.clawdbot/agents/<agentId>/sessions`.
|
||||
|
||||
Auth profiles are **per-agent**. Each agent reads from its own:
|
||||
|
||||
```
|
||||
~/.clawdbot/agents/<agentId>/agent/auth-profiles.json
|
||||
```
|
||||
|
||||
Main agent credentials are **not** shared automatically. Never reuse `agentDir`
|
||||
across agents (it causes auth/session collisions). If you want to share creds,
|
||||
copy `auth-profiles.json` into the other agent's `agentDir`.
|
||||
|
||||
Skills are per-agent via each workspace’s `skills/` folder, with shared skills
|
||||
available from `~/.clawdbot/skills`. See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills).
|
||||
|
||||
|
||||
@@ -145,6 +145,7 @@ Behavior:
|
||||
- Always non-blocking: returns `{ status: "accepted", runId, childSessionKey }` immediately.
|
||||
- After completion, Clawdbot runs a sub-agent **announce step** and posts the result to the requester chat channel.
|
||||
- Reply exactly `ANNOUNCE_SKIP` during the announce step to stay silent.
|
||||
- Announce replies are normalized to `Status`/`Result`/`Notes`; `Status` comes from runtime outcome (not model text).
|
||||
- Sub-agent sessions are auto-archived after `agents.defaults.subagents.archiveAfterMinutes` (default: 60).
|
||||
- Announce replies include a stats line (runtime, tokens, sessionKey/sessionId, transcript path, and optional cost).
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ Send these as standalone messages so they register.
|
||||
- `pnpm clawdbot sessions --json` — dumps every entry (filter with `--active <minutes>`).
|
||||
- `clawdbot gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access).
|
||||
- Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs).
|
||||
- Send `/context list` or `/context detail` to see what’s in the system prompt and injected workspace files (and the biggest context contributors).
|
||||
- Send `/stop` as a standalone message to abort the current run.
|
||||
- Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space. See [/concepts/compaction](/concepts/compaction).
|
||||
- JSONL transcripts can be opened directly to review full turns.
|
||||
|
||||
@@ -42,6 +42,8 @@ Large files are truncated with a marker. The max per-file size is controlled by
|
||||
`agents.defaults.bootstrapMaxChars` (default: 20000). Missing files inject a
|
||||
short missing-file marker.
|
||||
|
||||
To inspect how much each injected file contributes (raw vs injected, truncation, plus tool schema overhead), use `/context list` or `/context detail`. See [Context](/concepts/context).
|
||||
|
||||
## Time handling
|
||||
|
||||
The Time line is compact and explicit:
|
||||
|
||||
@@ -23,6 +23,7 @@ read_when:
|
||||
- **Gemini CLI**: OAuth tokens in auth profiles.
|
||||
- **Antigravity**: OAuth tokens in auth profiles.
|
||||
- **OpenAI Codex**: OAuth tokens in auth profiles (accountId used when present).
|
||||
- **MiniMax**: API key (coding plan key; `MINIMAX_CODE_PLAN_KEY` or `MINIMAX_API_KEY`); uses the 5‑hour coding plan window.
|
||||
- **z.ai**: API key via env/config/auth store.
|
||||
|
||||
Usage is hidden if no matching OAuth/API credentials exist.
|
||||
@@ -45,6 +45,14 @@
|
||||
"source": "/messages/",
|
||||
"destination": "/concepts/messages"
|
||||
},
|
||||
{
|
||||
"source": "/context",
|
||||
"destination": "/concepts/context"
|
||||
},
|
||||
{
|
||||
"source": "/context/",
|
||||
"destination": "/concepts/context"
|
||||
},
|
||||
{
|
||||
"source": "/minimax",
|
||||
"destination": "/providers/minimax"
|
||||
@@ -765,6 +773,7 @@
|
||||
"concepts/agent",
|
||||
"concepts/agent-loop",
|
||||
"concepts/system-prompt",
|
||||
"concepts/context",
|
||||
"token-use",
|
||||
"concepts/oauth",
|
||||
"concepts/agent-workspace",
|
||||
@@ -880,9 +889,11 @@
|
||||
"tools",
|
||||
"plugin",
|
||||
"tools/exec",
|
||||
"tools/web",
|
||||
"tools/apply-patch",
|
||||
"tools/elevated",
|
||||
"tools/browser",
|
||||
"tools/chrome-extension",
|
||||
"tools/browser-linux-troubleshooting",
|
||||
"tools/slash-commands",
|
||||
"tools/thinking",
|
||||
|
||||
@@ -47,10 +47,10 @@ API keys for daemon use: `clawdbot onboard`.
|
||||
See [/start/faq](/start/faq) for details on env inheritance (`env.shellEnv`,
|
||||
`~/.clawdbot/.env`, systemd/launchd).
|
||||
|
||||
## Anthropic: Claude CLI setup-token (supported)
|
||||
## Anthropic: Claude Code CLI setup-token (supported)
|
||||
|
||||
For Anthropic, the recommended path is an **API key**. If you’re already using
|
||||
Claude Code, the Claude CLI setup-token is also supported.
|
||||
Claude Code CLI, the setup-token flow is also supported.
|
||||
Run it on the **gateway host**:
|
||||
|
||||
```bash
|
||||
@@ -67,6 +67,10 @@ clawdbot doctor
|
||||
This should create (or refresh) an auth profile like `anthropic:claude-cli` in
|
||||
the agent auth store.
|
||||
|
||||
Clawdbot config sets `auth.profiles["anthropic:claude-cli"].mode` to `"oauth"` so
|
||||
the profile accepts both OAuth and setup-token credentials. Older configs that
|
||||
used `"token"` are auto-migrated on load.
|
||||
|
||||
If you see an Anthropic error like:
|
||||
|
||||
```
|
||||
@@ -138,7 +142,7 @@ Use `--agent <id>` to target a specific agent; omit it to use the configured def
|
||||
`~/.clawdbot/agents/<agentId>/agent/auth-profiles.json` when the auth store is
|
||||
loaded.
|
||||
3. Refreshable OAuth profiles can be refreshed automatically on use. Static
|
||||
token profiles (including Claude CLI setup-token) are not refreshable by
|
||||
token profiles (including Claude Code CLI setup-token) are not refreshable by
|
||||
Clawdbot.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
summary: "CLI backends: text-only fallback via local AI CLIs"
|
||||
read_when:
|
||||
- You want a reliable fallback when API providers fail
|
||||
- You are running Claude CLI or other local AI CLIs and want to reuse them
|
||||
- You are running Claude Code CLI or other local AI CLIs and want to reuse them
|
||||
- You need a text-only, tool-free path that still supports sessions and images
|
||||
---
|
||||
# CLI backends (fallback runtime)
|
||||
@@ -20,7 +20,7 @@ want “always works” text responses without relying on external APIs.
|
||||
|
||||
## Beginner-friendly quick start
|
||||
|
||||
You can use Claude CLI **without any config** (Clawdbot ships a built-in default):
|
||||
You can use Claude Code CLI **without any config** (Clawdbot ships a built-in default):
|
||||
|
||||
```bash
|
||||
clawdbot agent --message "hi" --model claude-cli/opus-4.5
|
||||
@@ -162,7 +162,7 @@ imageMode: "repeat"
|
||||
Clawdbot will write base64 images to temp files. If `imageArg` is set, those
|
||||
paths are passed as CLI args. If `imageArg` is missing, Clawdbot appends the
|
||||
file paths to the prompt (path injection), which is enough for CLIs that auto-
|
||||
load local files from plain paths (Claude CLI behavior).
|
||||
load local files from plain paths (Claude Code CLI behavior).
|
||||
|
||||
## Inputs / outputs
|
||||
|
||||
|
||||
@@ -384,7 +384,7 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
||||
},
|
||||
|
||||
skills: {
|
||||
allowBundled: ["brave-search", "gemini"],
|
||||
allowBundled: ["gemini", "peekaboo"],
|
||||
load: {
|
||||
extraDirs: ["~/Projects/agent-scripts/skills"]
|
||||
},
|
||||
|
||||
@@ -32,19 +32,44 @@ It writes a restart sentinel and pings the last active session after the Gateway
|
||||
|
||||
Params:
|
||||
- `raw` (string) — JSON5 payload for the entire config
|
||||
- `baseHash` (optional) — config hash from `config.get` (required when a config already exists)
|
||||
- `sessionKey` (optional) — last active session key for the wake-up ping
|
||||
- `restartDelayMs` (optional) — delay before restart (default 2000)
|
||||
|
||||
Example (via `gateway call`):
|
||||
|
||||
```bash
|
||||
clawdbot gateway call config.get --params '{}' # capture payload.hash
|
||||
clawdbot gateway call config.apply --params '{
|
||||
"raw": "{\\n agents: { defaults: { workspace: \\"~/clawd\\" } }\\n}\\n",
|
||||
"baseHash": "<hash-from-config.get>",
|
||||
"sessionKey": "agent:main:whatsapp:dm:+15555550123",
|
||||
"restartDelayMs": 1000
|
||||
}'
|
||||
```
|
||||
|
||||
## Partial updates (RPC)
|
||||
|
||||
Use `config.patch` to merge a partial update into the existing config without clobbering
|
||||
unrelated keys. It applies JSON merge patch semantics:
|
||||
- objects merge recursively
|
||||
- `null` deletes a key
|
||||
- arrays replace
|
||||
|
||||
Params:
|
||||
- `raw` (string) — JSON5 payload containing just the keys to change
|
||||
- `baseHash` (required) — config hash from `config.get`
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
clawdbot gateway call config.get --params '{}' # capture payload.hash
|
||||
clawdbot gateway call config.patch --params '{
|
||||
"raw": "{\\n channels: { telegram: { groups: { \\"*\\": { requireMention: false } } } }\\n}\\n",
|
||||
"baseHash": "<hash-from-config.get>"
|
||||
}'
|
||||
```
|
||||
|
||||
## Minimal config (recommended starting point)
|
||||
|
||||
```json5
|
||||
@@ -306,6 +331,10 @@ rotation order used for failover.
|
||||
}
|
||||
```
|
||||
|
||||
Note: `anthropic:claude-cli` should use `mode: "oauth"` even when the stored
|
||||
credential is a setup-token. Clawdbot auto-migrates older configs that used
|
||||
`mode: "token"`.
|
||||
|
||||
### `agents.list[].identity`
|
||||
|
||||
Optional per-agent identity used for defaults and UX. This is written by the macOS onboarding assistant.
|
||||
@@ -479,6 +508,30 @@ Group messages default to **require mention** (either metadata mention or regex
|
||||
|
||||
`messages.groupChat.historyLimit` sets the global default for group history context. Channels can override with `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit` for multi-account). Set `0` to disable history wrapping.
|
||||
|
||||
#### DM history limits
|
||||
|
||||
DM conversations use session-based history managed by the agent. You can limit the number of user turns retained per DM session:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
dmHistoryLimit: 30, // limit DM sessions to 30 user turns
|
||||
dms: {
|
||||
"123456789": { historyLimit: 50 } // per-user override (user ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Resolution order:
|
||||
1. Per-DM override: `channels.<provider>.dms[userId].historyLimit`
|
||||
2. Provider default: `channels.<provider>.dmHistoryLimit`
|
||||
3. No limit (all history retained)
|
||||
|
||||
Supported providers: `telegram`, `whatsapp`, `discord`, `slack`, `signal`, `imessage`, `msteams`.
|
||||
|
||||
Per-agent override (takes precedence when set, even `[]`):
|
||||
```json5
|
||||
{
|
||||
@@ -780,6 +833,7 @@ Notes:
|
||||
- `commands.bash: true` enables `! <cmd>` to run host shell commands (`/bash <cmd>` also works as an alias). Requires `tools.elevated.enabled` and allowlisting the sender in `tools.elevated.allowFrom.<channel>`.
|
||||
- `commands.bashForegroundMs` controls how long bash waits before backgrounding. While a bash job is running, new `! <cmd>` requests are rejected (one at a time).
|
||||
- `commands.config: true` enables `/config` (reads/writes `clawdbot.json`).
|
||||
- `channels.<provider>.configWrites` gates config mutations initiated by that channel (default: true). This applies to `/config set|unset` plus provider-specific auto-migrations (Telegram supergroup ID changes, Slack channel ID changes).
|
||||
- `commands.debug: true` enables `/debug` (runtime-only overrides).
|
||||
- `commands.restart: true` enables `/restart` and the gateway tool restart action.
|
||||
- `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies.
|
||||
@@ -810,6 +864,7 @@ Set `web.enabled: false` to keep it off by default.
|
||||
Clawdbot starts Telegram only when a `channels.telegram` config section exists. The bot token is resolved from `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken`.
|
||||
Set `channels.telegram.enabled: false` to disable automatic startup.
|
||||
Multi-account support lives under `channels.telegram.accounts` (see the multi-account section above). Env tokens only apply to the default account.
|
||||
Set `channels.telegram.configWrites: false` to block Telegram-initiated config writes (including supergroup ID migrations and `/config set|unset`).
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -1002,6 +1057,7 @@ Slack runs in Socket Mode and requires both a bot token and app token:
|
||||
Multi-account support lives under `channels.slack.accounts` (see the multi-account section above). Env tokens only apply to the default account.
|
||||
|
||||
Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:<id>` (DM) or `channel:<id>` when specifying delivery targets for cron/CLI commands.
|
||||
Set `channels.slack.configWrites: false` to block Slack-initiated config writes (including channel ID migrations and `/config set|unset`).
|
||||
|
||||
Bot-authored messages are ignored by default. Enable with `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`.
|
||||
|
||||
@@ -1153,6 +1209,31 @@ streaming, final replies) across channels unless already present.
|
||||
If `messages.responsePrefix` is unset, no prefix is applied by default.
|
||||
Set it to `"auto"` to derive `[{identity.name}]` for the routed agent (when set).
|
||||
|
||||
#### Template variables
|
||||
|
||||
The `responsePrefix` string can include template variables that resolve dynamically:
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `{model}` | Short model name | `claude-opus-4-5`, `gpt-4o` |
|
||||
| `{modelFull}` | Full model identifier | `anthropic/claude-opus-4-5` |
|
||||
| `{provider}` | Provider name | `anthropic`, `openai` |
|
||||
| `{thinkingLevel}` | Current thinking level | `high`, `low`, `off` |
|
||||
| `{identity.name}` | Agent identity name | (same as `"auto"` mode) |
|
||||
|
||||
Variables are case-insensitive (`{MODEL}` = `{model}`). `{think}` is an alias for `{thinkingLevel}`.
|
||||
Unresolved variables remain as literal text.
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
responsePrefix: "[{model} | think:{thinkingLevel}]"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example output: `[claude-opus-4-5 | think:high] Here's my response...`
|
||||
|
||||
WhatsApp inbound prefix is configured via `channels.whatsapp.messagePrefix` (deprecated:
|
||||
`messages.messagePrefix`). Default stays **unchanged**: `"[clawdbot]"` when
|
||||
`channels.whatsapp.allowFrom` is empty, otherwise `""` (no prefix). When using
|
||||
@@ -1549,6 +1630,18 @@ of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
|
||||
Note: `applyPatch` is only under `tools.exec` (no `tools.bash` alias).
|
||||
Legacy: `tools.bash` is still accepted as an alias.
|
||||
|
||||
`tools.web` configures web search + fetch tools:
|
||||
- `tools.web.search.enabled` (default: true when key is present)
|
||||
- `tools.web.search.apiKey` (recommended: set via `clawdbot configure --section web`, or use `BRAVE_API_KEY` env var)
|
||||
- `tools.web.search.maxResults` (1–10, default 5)
|
||||
- `tools.web.search.timeoutSeconds` (default 30)
|
||||
- `tools.web.search.cacheTtlMinutes` (default 15)
|
||||
- `tools.web.fetch.enabled` (default false; sandboxed sessions auto-enable unless set to false)
|
||||
- `tools.web.fetch.maxChars` (default 50000)
|
||||
- `tools.web.fetch.timeoutSeconds` (default 30)
|
||||
- `tools.web.fetch.cacheTtlMinutes` (default 15)
|
||||
- `tools.web.fetch.userAgent` (optional override)
|
||||
|
||||
`agents.defaults.subagents` configures sub-agent defaults:
|
||||
- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call.
|
||||
- `maxConcurrent`: max concurrent sub-agent runs (default 1)
|
||||
@@ -1583,6 +1676,37 @@ Example (coding profile, but deny exec/process everywhere):
|
||||
}
|
||||
```
|
||||
|
||||
`tools.byProvider` lets you **further restrict** tools for specific providers (or a single `provider/model`).
|
||||
Per-agent override: `agents.list[].tools.byProvider`.
|
||||
|
||||
Order: base profile → provider profile → allow/deny policies.
|
||||
Provider keys accept either `provider` (e.g. `google-antigravity`) or `provider/model`
|
||||
(e.g. `openai/gpt-5.2`).
|
||||
|
||||
Example (keep global coding profile, but minimal tools for Google Antigravity):
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
profile: "coding",
|
||||
byProvider: {
|
||||
"google-antigravity": { profile: "minimal" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example (provider/model-specific allowlist):
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
allow: ["group:fs", "group:runtime", "sessions_list"],
|
||||
byProvider: {
|
||||
"openai/gpt-5.2": { allow: ["group:fs", "sessions_list"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`tools.allow` / `tools.deny` configure a global tool allow/deny policy (deny wins).
|
||||
This is applied even when the Docker sandbox is **off**.
|
||||
|
||||
@@ -1598,6 +1722,7 @@ Tool groups (shorthands) work in **global** and **per-agent** tool policies:
|
||||
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
|
||||
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
|
||||
- `group:memory`: `memory_search`, `memory_get`
|
||||
- `group:web`: `web_search`, `web_fetch`
|
||||
- `group:ui`: `browser`, `canvas`
|
||||
- `group:automation`: `cron`, `gateway`
|
||||
- `group:messaging`: `message`
|
||||
@@ -2123,7 +2248,7 @@ Example:
|
||||
```json5
|
||||
{
|
||||
skills: {
|
||||
allowBundled: ["brave-search", "gemini"],
|
||||
allowBundled: ["gemini", "peekaboo"],
|
||||
load: {
|
||||
extraDirs: [
|
||||
"~/Projects/agent-scripts/skills",
|
||||
|
||||
@@ -51,12 +51,15 @@ See [Sandboxing](/gateway/sandboxing) for the full matrix (scope, workspace moun
|
||||
|
||||
Two layers matter:
|
||||
- **Tool profile**: `tools.profile` and `agents.list[].tools.profile` (base allowlist)
|
||||
- **Provider tool profile**: `tools.byProvider[provider].profile` and `agents.list[].tools.byProvider[provider].profile`
|
||||
- **Global/per-agent tool policy**: `tools.allow`/`tools.deny` and `agents.list[].tools.allow`/`agents.list[].tools.deny`
|
||||
- **Provider tool policy**: `tools.byProvider[provider].allow/deny` and `agents.list[].tools.byProvider[provider].allow/deny`
|
||||
- **Sandbox tool policy** (only applies when sandboxed): `tools.sandbox.tools.allow`/`tools.sandbox.tools.deny` and `agents.list[].tools.sandbox.tools.*`
|
||||
|
||||
Rules of thumb:
|
||||
- `deny` always wins.
|
||||
- If `allow` is non-empty, everything else is treated as blocked.
|
||||
Provider tool keys accept either `provider` (e.g. `google-antigravity`) or `provider/model` (e.g. `openai/gpt-5.2`).
|
||||
|
||||
### Tool groups (shorthands)
|
||||
|
||||
@@ -90,6 +93,7 @@ Available groups:
|
||||
Elevated does **not** grant extra tools; it only affects `exec`.
|
||||
- If you’re sandboxed, `/elevated on` (or `exec` with `elevated: true`) runs on the host.
|
||||
- If you’re already running direct, elevated is effectively a no-op (still gated).
|
||||
- Elevated is **not** skill-scoped and does **not** override tool allow/deny.
|
||||
|
||||
Gates:
|
||||
- Enablement: `tools.elevated.enabled` (and optionally `agents.list[].tools.elevated.enabled`)
|
||||
|
||||
+179
-6
@@ -12,6 +12,23 @@ Clawdbot is both a product and an experiment: you’re wiring frontier-model beh
|
||||
- where the bot is allowed to act
|
||||
- what the bot can touch
|
||||
|
||||
## Quick check: `clawdbot security audit`
|
||||
|
||||
Run this regularly (especially after changing config or exposing network surfaces):
|
||||
|
||||
```bash
|
||||
clawdbot security audit
|
||||
clawdbot security audit --deep
|
||||
clawdbot security audit --fix
|
||||
```
|
||||
|
||||
It flags common footguns (Gateway auth exposure, browser control exposure, elevated allowlists, filesystem permissions).
|
||||
|
||||
`--fix` applies safe guardrails:
|
||||
- Tighten `groupPolicy="open"` to `groupPolicy="allowlist"` (and per-account variants) for common channels.
|
||||
- Turn `logging.redactSensitive="off"` back to `"tools"`.
|
||||
- Tighten local perms (`~/.clawdbot` → `700`, config file → `600`).
|
||||
|
||||
## The Threat Model
|
||||
|
||||
Your AI assistant can:
|
||||
@@ -42,6 +59,12 @@ Plugins run **in-process** with the Gateway. Treat them as trusted code:
|
||||
- Prefer explicit `plugins.allow` allowlists.
|
||||
- Review plugin config before enabling.
|
||||
- Restart the Gateway after plugin changes.
|
||||
- If you install plugins from npm (`clawdbot plugins install <npm-spec>`), treat it like running untrusted code:
|
||||
- The install path is `~/.clawdbot/extensions/<pluginId>/` (or `$CLAWDBOT_STATE_DIR/extensions/<pluginId>/`).
|
||||
- Clawdbot uses `npm pack` and then runs `npm install --omit=dev` in that directory (npm lifecycle scripts can execute code during install).
|
||||
- Prefer pinned, exact versions (`@scope/pkg@1.2.3`), and inspect the unpacked code on disk before enabling.
|
||||
|
||||
Details: [Plugins](/plugin)
|
||||
|
||||
## DM access model (pairing / allowlist / open / disabled)
|
||||
|
||||
@@ -120,6 +143,21 @@ Keep config + state private on the gateway host:
|
||||
|
||||
`clawdbot doctor` can warn and offer to tighten these permissions.
|
||||
|
||||
### 0.4) Network exposure (bind + port + firewall)
|
||||
|
||||
The Gateway multiplexes **WebSocket + HTTP** on a single port:
|
||||
- Default: `18789`
|
||||
- Config/flags/env: `gateway.port`, `--port`, `CLAWDBOT_GATEWAY_PORT`
|
||||
|
||||
Bind mode controls where the Gateway listens:
|
||||
- `gateway.bind: "loopback"` (default): only local clients can connect.
|
||||
- Non-loopback binds (`"lan"`, `"tailnet"`, `"auto"`) expand the attack surface. Only use them with `gateway.auth` enabled and a real firewall.
|
||||
|
||||
Rules of thumb:
|
||||
- Prefer Tailscale Serve over LAN binds (Serve keeps the Gateway on loopback, and Tailscale handles access).
|
||||
- If you must bind to LAN, firewall the port to a tight allowlist of source IPs; do not port-forward it broadly.
|
||||
- Never expose the Gateway unauthenticated on `0.0.0.0`.
|
||||
|
||||
### 0.5) Lock down the Gateway WebSocket (local auth)
|
||||
|
||||
Gateway auth is **only** enforced when you set `gateway.auth`. If it’s unset,
|
||||
@@ -145,6 +183,16 @@ Doctor can generate one for you: `clawdbot doctor --generate-gateway-token`.
|
||||
Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
|
||||
protect local WS access.
|
||||
|
||||
Auth modes:
|
||||
- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups).
|
||||
- `gateway.auth.mode: "password"`: password auth (prefer setting via env: `CLAWDBOT_GATEWAY_PASSWORD`).
|
||||
|
||||
Rotation checklist (token/password):
|
||||
1. Generate/set a new secret (`gateway.auth.token` or `CLAWDBOT_GATEWAY_PASSWORD`).
|
||||
2. Restart the Gateway (or restart the macOS app if it supervises the Gateway).
|
||||
3. Update any remote clients (`gateway.remote.token` / `.password` on machines that call into the Gateway).
|
||||
4. Verify you can no longer connect with the old credentials.
|
||||
|
||||
### 0.6) Tailscale Serve identity headers
|
||||
|
||||
When `gateway.auth.allowTailscale` is `true` (default for Serve), Clawdbot
|
||||
@@ -159,6 +207,57 @@ you terminate TLS or proxy in front of the gateway, disable
|
||||
|
||||
See [Tailscale](/gateway/tailscale) and [Web overview](/web).
|
||||
|
||||
### 0.6.1) Browser control server over Tailscale (recommended)
|
||||
|
||||
If your Gateway is remote but the browser runs on another machine, you’ll often run a **separate browser control server**
|
||||
on the browser machine (see [Browser tool](/tools/browser)). Treat this like an admin API.
|
||||
|
||||
Recommended pattern:
|
||||
|
||||
```bash
|
||||
# on the machine that runs Chrome
|
||||
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
|
||||
tailscale serve https / http://127.0.0.1:18791
|
||||
```
|
||||
|
||||
Then on the Gateway, set:
|
||||
- `browser.controlUrl` to the `https://…` Serve URL (MagicDNS/ts.net)
|
||||
- and authenticate with the same token (`CLAWDBOT_BROWSER_CONTROL_TOKEN` env preferred)
|
||||
|
||||
Avoid:
|
||||
- `--bind 0.0.0.0` (LAN-visible surface)
|
||||
- Tailscale Funnel for browser control endpoints (public exposure)
|
||||
|
||||
### 0.7) Secrets on disk (what’s sensitive)
|
||||
|
||||
Assume anything under `~/.clawdbot/` (or `$CLAWDBOT_STATE_DIR/`) may contain secrets or private data:
|
||||
|
||||
- `clawdbot.json`: config may include tokens (gateway, remote gateway), provider settings, and allowlists.
|
||||
- `credentials/**`: channel credentials (example: WhatsApp creds), pairing allowlists, legacy OAuth imports.
|
||||
- `agents/<agentId>/agent/auth-profiles.json`: API keys + OAuth tokens (imported from legacy `credentials/oauth.json`).
|
||||
- `agents/<agentId>/sessions/**`: session transcripts (`*.jsonl`) + routing metadata (`sessions.json`) that can contain private messages and tool output.
|
||||
- `extensions/**`: installed plugins (plus their `node_modules/`).
|
||||
- `sandboxes/**`: tool sandbox workspaces; can accumulate copies of files you read/write inside the sandbox.
|
||||
|
||||
Hardening tips:
|
||||
- Keep permissions tight (`700` on dirs, `600` on files).
|
||||
- Use full-disk encryption on the gateway host.
|
||||
- Prefer a dedicated OS user account for the Gateway if the host is shared.
|
||||
|
||||
### 0.8) Logs + transcripts (redaction + retention)
|
||||
|
||||
Logs and transcripts can leak sensitive info even when access controls are correct:
|
||||
- Gateway logs may include tool summaries, errors, and URLs.
|
||||
- Session transcripts can include pasted secrets, file contents, command output, and links.
|
||||
|
||||
Recommendations:
|
||||
- Keep tool summary redaction on (`logging.redactSensitive: "tools"`; default).
|
||||
- Add custom patterns for your environment via `logging.redactPatterns` (tokens, hostnames, internal URLs).
|
||||
- When sharing diagnostics, prefer `clawdbot status --all` (pasteable, secrets redacted) over raw logs.
|
||||
- Prune old session transcripts and log files if you don’t need long retention.
|
||||
|
||||
Details: [Logging](/gateway/logging)
|
||||
|
||||
### 1) DMs: pairing by default
|
||||
|
||||
```json5
|
||||
@@ -205,6 +304,29 @@ You can already build a read-only profile by combining:
|
||||
|
||||
We may add a single `readOnlyMode` flag later to simplify this configuration.
|
||||
|
||||
### 5) Secure baseline (copy/paste)
|
||||
|
||||
One “safe default” config that keeps the Gateway private, requires DM pairing, and avoids always-on group bots:
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
mode: "local",
|
||||
bind: "loopback",
|
||||
port: 18789,
|
||||
auth: { mode: "token", token: "your-long-random-token" }
|
||||
},
|
||||
channels: {
|
||||
whatsapp: {
|
||||
dmPolicy: "pairing",
|
||||
groups: { "*": { requireMention: true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you want “safer by default” tool execution too, add a sandbox + deny dangerous tools for any non-owner agent (example below under “Per-agent access profiles”).
|
||||
|
||||
## Sandboxing (recommended)
|
||||
|
||||
Dedicated doc: [Sandboxing](/gateway/sandboxing)
|
||||
@@ -233,6 +355,12 @@ access those accounts and data. Treat browser profiles as **sensitive state**:
|
||||
- Prefer a dedicated profile for the agent (the default `clawd` profile).
|
||||
- Avoid pointing the agent at your personal daily-driver profile.
|
||||
- Keep host browser control disabled for sandboxed agents unless you trust them.
|
||||
- Treat browser downloads as untrusted input; prefer an isolated downloads directory.
|
||||
- Disable browser sync/password managers in the agent profile if possible (reduces blast radius).
|
||||
- For remote gateways, assume “browser control” is equivalent to “operator access” to whatever that profile can reach.
|
||||
- Treat `browser.controlUrl` endpoints as an admin API: tailnet-only + token auth. Prefer Tailscale Serve over LAN binds.
|
||||
- Keep `browser.controlToken` separate from `gateway.auth.token` (you can reuse it, but that increases blast radius).
|
||||
- Chrome extension relay mode is **not** “safer”; it can take over your existing Chrome tabs. Assume it can act as you in whatever that tab/profile can reach.
|
||||
|
||||
## Per-agent access profiles (multi-agent)
|
||||
|
||||
@@ -301,7 +429,7 @@ Common use cases:
|
||||
workspaceAccess: "none"
|
||||
},
|
||||
tools: {
|
||||
allow: ["sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "session_status", "whatsapp", "telegram", "slack", "discord", "gateway"],
|
||||
allow: ["sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "session_status", "whatsapp", "telegram", "slack", "discord"],
|
||||
deny: ["read", "write", "edit", "apply_patch", "exec", "process", "browser", "canvas", "nodes", "cron", "gateway", "image"]
|
||||
}
|
||||
}
|
||||
@@ -327,11 +455,56 @@ Include security guidelines in your agent's system prompt:
|
||||
|
||||
If your AI does something bad:
|
||||
|
||||
1. **Stop it:** stop the macOS app (if it’s supervising the Gateway) or terminate your `clawdbot gateway` process
|
||||
2. **Check logs:** `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` (or your configured `logging.file`)
|
||||
3. **Review session:** Check `~/.clawdbot/agents/<agentId>/sessions/` for what happened
|
||||
4. **Rotate secrets:** If credentials were exposed
|
||||
5. **Update rules:** Add to your security prompt
|
||||
### Contain
|
||||
|
||||
1. **Stop it:** stop the macOS app (if it supervises the Gateway) or terminate your `clawdbot gateway` process.
|
||||
2. **Close exposure:** set `gateway.bind: "loopback"` (or disable Tailscale Funnel/Serve) until you understand what happened.
|
||||
3. **Freeze access:** switch risky DMs/groups to `dmPolicy: "disabled"` / require mentions, and remove `"*"` allow-all entries if you had them.
|
||||
|
||||
### Rotate (assume compromise if secrets leaked)
|
||||
|
||||
1. Rotate Gateway auth (`gateway.auth.token` / `CLAWDBOT_GATEWAY_PASSWORD`) and restart.
|
||||
2. Rotate remote client secrets (`gateway.remote.token` / `.password`) on any machine that can call the Gateway.
|
||||
3. Rotate provider/API credentials (WhatsApp creds, Slack/Discord tokens, model/API keys in `auth-profiles.json`).
|
||||
|
||||
### Audit
|
||||
|
||||
1. Check Gateway logs: `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` (or `logging.file`).
|
||||
2. Review the relevant transcript(s): `~/.clawdbot/agents/<agentId>/sessions/*.jsonl`.
|
||||
3. Review recent config changes (anything that could have widened access: `gateway.bind`, `gateway.auth`, dm/group policies, `tools.elevated`, plugin changes).
|
||||
|
||||
### Collect for a report
|
||||
|
||||
- Timestamp, gateway host OS + Clawdbot version
|
||||
- The session transcript(s) + a short log tail (after redacting)
|
||||
- What the attacker sent + what the agent did
|
||||
- Whether the Gateway was exposed beyond loopback (LAN/Tailscale Funnel/Serve)
|
||||
|
||||
## Secret Scanning (detect-secrets)
|
||||
|
||||
CI runs `detect-secrets scan --baseline .secrets.baseline` in the `secrets` job.
|
||||
If it fails, there are new candidates not yet in the baseline.
|
||||
|
||||
### If CI fails
|
||||
|
||||
1. Reproduce locally:
|
||||
```bash
|
||||
detect-secrets scan --baseline .secrets.baseline
|
||||
```
|
||||
2. Understand the tools:
|
||||
- `detect-secrets scan` finds candidates and compares them to the baseline.
|
||||
- `detect-secrets audit` opens an interactive review to mark each baseline
|
||||
item as real or false positive.
|
||||
3. For real secrets: rotate/remove them, then re-run the scan to update the baseline.
|
||||
4. For false positives: run the interactive audit and mark them as false:
|
||||
```bash
|
||||
detect-secrets audit .secrets.baseline
|
||||
```
|
||||
5. If you need new excludes, add them to `.detect-secrets.cfg` and regenerate the
|
||||
baseline with matching `--exclude-files` / `--exclude-lines` flags (the config
|
||||
file is reference-only; detect-secrets doesn’t read it automatically).
|
||||
|
||||
Commit the updated `.secrets.baseline` once it reflects the intended state.
|
||||
|
||||
## The Trust Hierarchy
|
||||
|
||||
|
||||
@@ -76,6 +76,36 @@ clawdbot gateway --tailscale funnel --auth password
|
||||
- Serve/Funnel only expose the **Gateway control UI + WS**. Node **bridge** traffic
|
||||
uses the separate bridge port (default `18790`) and is **not** proxied by Serve.
|
||||
|
||||
## Browser control server (remote Gateway + local browser)
|
||||
|
||||
If you run the Gateway on one machine but want to drive a browser on another machine, use a **separate browser control server**
|
||||
and publish it through Tailscale **Serve** (tailnet-only):
|
||||
|
||||
```bash
|
||||
# on the machine that runs Chrome
|
||||
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
|
||||
tailscale serve https / http://127.0.0.1:18791
|
||||
```
|
||||
|
||||
Then point the Gateway config at the HTTPS URL:
|
||||
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: "https://<magicdns>/"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And authenticate from the Gateway with the same token (prefer env):
|
||||
|
||||
```bash
|
||||
export CLAWDBOT_BROWSER_CONTROL_TOKEN="<token>"
|
||||
```
|
||||
|
||||
Avoid Funnel for browser control endpoints unless you explicitly want public exposure.
|
||||
|
||||
## Tailscale prerequisites + limits
|
||||
|
||||
- Serve requires HTTPS enabled for your tailnet; the CLI prompts if it is missing.
|
||||
|
||||
@@ -31,6 +31,11 @@ See also: [Health checks](/gateway/health) and [Logging](/logging).
|
||||
|
||||
## Common Issues
|
||||
|
||||
### CI Secrets Scan Failed
|
||||
|
||||
This means `detect-secrets` found new candidates not yet in the baseline.
|
||||
Follow [Secret scanning](/gateway/security#secret-scanning-detect-secrets).
|
||||
|
||||
### Service Installed but Nothing is Running
|
||||
|
||||
If the gateway service is installed but the process exits immediately, the daemon
|
||||
|
||||
@@ -434,3 +434,7 @@ Example:
|
||||
- Container not running: it will auto-create per session on demand.
|
||||
- Permission errors in sandbox: set `docker.user` to a UID:GID that matches your
|
||||
mounted workspace ownership (or chown the workspace folder).
|
||||
- Custom tools not found: Clawdbot runs commands with `sh -lc` (login shell), which
|
||||
sources `/etc/profile` and may reset PATH. Set `docker.env.PATH` to prepend your
|
||||
custom tool paths (e.g., `/custom/bin:/usr/local/share/npm-global/bin`), or add
|
||||
a script under `/etc/profile.d/` in your Dockerfile.
|
||||
@@ -18,6 +18,15 @@ This allows you to run multiple agents with different security profiles:
|
||||
- Family/work agents with restricted tools
|
||||
- Public-facing agents in sandboxes
|
||||
|
||||
Auth is per-agent: each agent reads from its own `agentDir` auth store at:
|
||||
|
||||
```
|
||||
~/.clawdbot/agents/<agentId>/agent/auth-profiles.json
|
||||
```
|
||||
|
||||
Credentials are **not** shared between agents. Never reuse `agentDir` across agents.
|
||||
If you want to share creds, copy `auth-profiles.json` into the other agent's `agentDir`.
|
||||
|
||||
For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing).
|
||||
For debugging “why is this blocked?”, see [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) and `clawdbot sandbox explain`.
|
||||
|
||||
@@ -188,14 +197,18 @@ agents.list[].sandbox.prune.* > agents.defaults.sandbox.prune.*
|
||||
### Tool Restrictions
|
||||
The filtering order is:
|
||||
1. **Tool profile** (`tools.profile` or `agents.list[].tools.profile`)
|
||||
2. **Global tool policy** (`tools.allow` / `tools.deny`)
|
||||
3. **Agent-specific tool policy** (`agents.list[].tools`)
|
||||
4. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`)
|
||||
5. **Subagent tool policy** (`tools.subagents.tools`, if applicable)
|
||||
2. **Provider tool profile** (`tools.byProvider[provider].profile` or `agents.list[].tools.byProvider[provider].profile`)
|
||||
3. **Global tool policy** (`tools.allow` / `tools.deny`)
|
||||
4. **Provider tool policy** (`tools.byProvider[provider].allow/deny`)
|
||||
5. **Agent-specific tool policy** (`agents.list[].tools.allow/deny`)
|
||||
6. **Agent provider policy** (`agents.list[].tools.byProvider[provider].allow/deny`)
|
||||
7. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`)
|
||||
8. **Subagent tool policy** (`tools.subagents.tools`, if applicable)
|
||||
|
||||
Each level can further restrict tools, but cannot grant back denied tools from earlier levels.
|
||||
If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` for that agent.
|
||||
If `agents.list[].tools.profile` is set, it overrides `tools.profile` for that agent.
|
||||
Provider tool keys accept either `provider` (e.g. `google-antigravity`) or `provider/model` (e.g. `openai/gpt-5.2`).
|
||||
|
||||
### Tool groups (shorthands)
|
||||
|
||||
|
||||
@@ -29,17 +29,17 @@ Notes:
|
||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||
BUNDLE_ID=com.clawdbot.mac \
|
||||
APP_VERSION=2026.1.11-4 \
|
||||
APP_VERSION=2026.1.13 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-app.sh
|
||||
|
||||
# Zip for distribution (includes resource forks for Sparkle delta support)
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.11-4.zip
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.13.zip
|
||||
|
||||
# Optional: also build a styled DMG for humans (drag to /Applications)
|
||||
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.11-4.dmg
|
||||
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.13.dmg
|
||||
|
||||
# Recommended: build + notarize/staple zip + DMG
|
||||
# First, create a keychain profile once:
|
||||
@@ -47,26 +47,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.11-4.dmg
|
||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
|
||||
BUNDLE_ID=com.clawdbot.mac \
|
||||
APP_VERSION=2026.1.11-4 \
|
||||
APP_VERSION=2026.1.13 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
# Optional: ship dSYM alongside the release
|
||||
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.11-4.dSYM.zip
|
||||
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.13.dSYM.zip
|
||||
```
|
||||
|
||||
## Appcast entry
|
||||
Use the release note generator so Sparkle renders formatted HTML notes:
|
||||
```bash
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.11-4.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.13.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
||||
```
|
||||
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
||||
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
|
||||
|
||||
## Publish & verify
|
||||
- Upload `Clawdbot-2026.1.11-4.zip` (and `Clawdbot-2026.1.11-4.dSYM.zip`) to the GitHub release for tag `v2026.1.11-4`.
|
||||
- Upload `Clawdbot-2026.1.13.zip` (and `Clawdbot-2026.1.13.dSYM.zip`) to the GitHub release for tag `v2026.1.13`.
|
||||
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
|
||||
- Sanity checks:
|
||||
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
|
||||
|
||||
+116
@@ -37,6 +37,7 @@ Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
||||
register:
|
||||
|
||||
- Gateway RPC methods
|
||||
- Gateway HTTP handlers
|
||||
- Agent tools
|
||||
- CLI commands
|
||||
- Background services
|
||||
@@ -165,6 +166,121 @@ Plugins export either:
|
||||
- A function: `(api) => { ... }`
|
||||
- An object: `{ id, name, configSchema, register(api) { ... } }`
|
||||
|
||||
### Register a messaging channel
|
||||
|
||||
Plugins can register **channel plugins** that behave like built‑in channels
|
||||
(WhatsApp, Telegram, etc.). Channel config lives under `channels.<id>` and is
|
||||
validated by your channel plugin code.
|
||||
|
||||
```ts
|
||||
const myChannel = {
|
||||
id: "acmechat",
|
||||
meta: {
|
||||
id: "acmechat",
|
||||
label: "AcmeChat",
|
||||
selectionLabel: "AcmeChat (API)",
|
||||
docsPath: "/channels/acmechat",
|
||||
blurb: "demo channel plugin.",
|
||||
aliases: ["acme"],
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: (cfg) => Object.keys(cfg.channels?.acmechat?.accounts ?? {}),
|
||||
resolveAccount: (cfg, accountId) =>
|
||||
(cfg.channels?.acmechat?.accounts?.[accountId ?? "default"] ?? { accountId }),
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
sendText: async () => ({ ok: true }),
|
||||
},
|
||||
};
|
||||
|
||||
export default function (api) {
|
||||
api.registerChannel({ plugin: myChannel });
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Put config under `channels.<id>` (not `plugins.entries`).
|
||||
- `meta.label` is used for labels in CLI/UI lists.
|
||||
- `meta.aliases` adds alternate ids for normalization and CLI inputs.
|
||||
|
||||
### Write a new messaging channel (step‑by‑step)
|
||||
|
||||
Use this when you want a **new chat surface** (a “messaging channel”), not a model provider.
|
||||
Model provider docs live under `/providers/*`.
|
||||
|
||||
1) Pick an id + config shape
|
||||
- All channel config lives under `channels.<id>`.
|
||||
- Prefer `channels.<id>.accounts.<accountId>` for multi‑account setups.
|
||||
|
||||
2) Define the channel metadata
|
||||
- `meta.label`, `meta.selectionLabel`, `meta.docsPath`, `meta.blurb` control CLI/UI lists.
|
||||
- `meta.docsPath` should point at a docs page like `/channels/<id>`.
|
||||
|
||||
3) Implement the required adapters
|
||||
- `config.listAccountIds` + `config.resolveAccount`
|
||||
- `capabilities` (chat types, media, threads, etc.)
|
||||
- `outbound.deliveryMode` + `outbound.sendText` (for basic send)
|
||||
|
||||
4) Add optional adapters as needed
|
||||
- `setup` (wizard), `security` (DM policy), `status` (health/diagnostics)
|
||||
- `gateway` (start/stop/login), `mentions`, `threading`, `streaming`
|
||||
- `actions` (message actions), `commands` (native command behavior)
|
||||
|
||||
5) Register the channel in your plugin
|
||||
- `api.registerChannel({ plugin })`
|
||||
|
||||
Minimal config example:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
acmechat: {
|
||||
accounts: {
|
||||
default: { token: "ACME_TOKEN", enabled: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Minimal channel plugin (outbound‑only):
|
||||
|
||||
```ts
|
||||
const plugin = {
|
||||
id: "acmechat",
|
||||
meta: {
|
||||
id: "acmechat",
|
||||
label: "AcmeChat",
|
||||
selectionLabel: "AcmeChat (API)",
|
||||
docsPath: "/channels/acmechat",
|
||||
blurb: "AcmeChat messaging channel.",
|
||||
aliases: ["acme"],
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: (cfg) => Object.keys(cfg.channels?.acmechat?.accounts ?? {}),
|
||||
resolveAccount: (cfg, accountId) =>
|
||||
(cfg.channels?.acmechat?.accounts?.[accountId ?? "default"] ?? { accountId }),
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
sendText: async ({ text }) => {
|
||||
// deliver `text` to your channel here
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function (api) {
|
||||
api.registerChannel({ plugin });
|
||||
}
|
||||
```
|
||||
|
||||
Load the plugin (extensions dir or `plugins.load.paths`), restart the gateway,
|
||||
then configure `channels.<id>` in your config.
|
||||
|
||||
### Register a tool
|
||||
|
||||
```ts
|
||||
|
||||
+193
-44
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Voice Call plugin: outbound + inbound calls via Twilio/Telnyx (plugin install + config + CLI)"
|
||||
summary: "Voice Call plugin: outbound and inbound calls via Twilio/Telnyx, with CLI, tools, and streaming"
|
||||
read_when:
|
||||
- You want to place an outbound voice call from Clawdbot
|
||||
- You are configuring or developing the voice-call plugin
|
||||
@@ -7,25 +7,34 @@ read_when:
|
||||
|
||||
# Voice Call (plugin)
|
||||
|
||||
Voice calls for Clawdbot via a plugin. Supports outbound notifications and
|
||||
multi-turn conversations with inbound policies.
|
||||
Voice calls for Clawdbot. Use it to place outbound notifications, run multi-turn
|
||||
phone conversations, and accept inbound calls with an explicit policy.
|
||||
|
||||
Current providers:
|
||||
- `twilio` (Programmable Voice + Media Streams)
|
||||
- `telnyx` (Call Control v2)
|
||||
- `mock` (dev/no network)
|
||||
|
||||
What you get:
|
||||
- Outbound calls in notify or conversation mode
|
||||
- Inbound calls with allowlist or open policies
|
||||
- Provider webhooks with signature verification
|
||||
- Optional streaming (Twilio Media Streams + OpenAI Realtime STT)
|
||||
- CLI commands, a tool surface, and JSONL call logs
|
||||
|
||||
Quick mental model:
|
||||
- Install plugin
|
||||
- Restart Gateway
|
||||
- Configure under `plugins.entries.voice-call.config`
|
||||
- Use `clawdbot voicecall ...` or the `voice_call` tool
|
||||
1. Install plugin
|
||||
2. Restart Gateway
|
||||
3. Configure `plugins.entries.voice-call.config`
|
||||
4. Expose a public webhook URL
|
||||
5. Call via `clawdbot voicecall ...` or the `voice_call` tool
|
||||
|
||||
## Where it runs (local vs remote)
|
||||
|
||||
The Voice Call plugin runs **inside the Gateway process**.
|
||||
The Voice Call plugin runs inside the Gateway process.
|
||||
|
||||
If you use a remote Gateway, install/configure the plugin on the **machine running the Gateway**, then restart the Gateway to load it.
|
||||
If you use a remote Gateway, install and configure the plugin on the machine
|
||||
running the Gateway, then restart the Gateway to load it.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -46,9 +55,15 @@ cd ./extensions/voice-call && pnpm install
|
||||
|
||||
Restart the Gateway afterwards.
|
||||
|
||||
## Config
|
||||
Note: use `pnpm` for repo work. Bun is not recommended and can cause issues in
|
||||
other Clawdbot channels (especially WhatsApp and Telegram).
|
||||
|
||||
Set config under `plugins.entries.voice-call.config`:
|
||||
## Config overview
|
||||
|
||||
All config lives under `plugins.entries.voice-call.config`. Phone numbers must
|
||||
be in E.164 format (`+15550001234`).
|
||||
|
||||
Minimal example (Twilio outbound only):
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -57,34 +72,16 @@ Set config under `plugins.entries.voice-call.config`:
|
||||
"voice-call": {
|
||||
enabled: true,
|
||||
config: {
|
||||
provider: "twilio", // or "telnyx" | "mock"
|
||||
provider: "twilio",
|
||||
fromNumber: "+15550001234",
|
||||
toNumber: "+15550005678",
|
||||
|
||||
twilio: {
|
||||
accountSid: "ACxxxxxxxx",
|
||||
authToken: "..."
|
||||
},
|
||||
|
||||
// Webhook server
|
||||
serve: {
|
||||
port: 3334,
|
||||
path: "/voice/webhook"
|
||||
},
|
||||
|
||||
// Public exposure (pick one)
|
||||
// publicUrl: "https://example.ngrok.app/voice/webhook",
|
||||
// tunnel: { provider: "ngrok" },
|
||||
// tailscale: { mode: "funnel", path: "/voice/webhook" }
|
||||
|
||||
outbound: {
|
||||
defaultMode: "notify" // notify | conversation
|
||||
},
|
||||
|
||||
streaming: {
|
||||
enabled: true,
|
||||
streamPath: "/voice/stream"
|
||||
}
|
||||
serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
|
||||
publicUrl: "https://example.ngrok.app/voice/webhook",
|
||||
outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,26 +90,178 @@ Set config under `plugins.entries.voice-call.config`:
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Twilio/Telnyx require a **publicly reachable** webhook URL.
|
||||
- Twilio/Telnyx require a publicly reachable webhook URL.
|
||||
- `mock` is a local dev provider (no network calls).
|
||||
- `skipSignatureVerification` is for local testing only.
|
||||
|
||||
## Inbound calls
|
||||
## Public URL and webhook exposure
|
||||
|
||||
Inbound policy defaults to `disabled`. To enable inbound calls, set:
|
||||
Providers send webhooks from the public internet. Your `serve.path` must be
|
||||
reachable from them.
|
||||
|
||||
You have three options:
|
||||
- `publicUrl`: you already have a public HTTPS URL pointing at the Gateway host.
|
||||
- `tunnel`: use ngrok or Tailscale (recommended for quick setup).
|
||||
- `tailscale`: legacy Tailscale serve/funnel config (still supported, but
|
||||
`tunnel` is preferred).
|
||||
|
||||
Example using ngrok:
|
||||
|
||||
```json5
|
||||
{
|
||||
inboundPolicy: "allowlist",
|
||||
allowFrom: ["+15550001234"],
|
||||
inboundGreeting: "Hello! How can I help?"
|
||||
tunnel: {
|
||||
provider: "ngrok",
|
||||
ngrokAuthToken: "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Auto-responses use the agent system. Tune with:
|
||||
- `responseModel`
|
||||
- `responseSystemPrompt`
|
||||
- `responseTimeoutMs`
|
||||
Example using Tailscale Funnel:
|
||||
|
||||
```json5
|
||||
{
|
||||
tunnel: { provider: "tailscale-funnel" }
|
||||
}
|
||||
```
|
||||
|
||||
CLI helper (Tailscale only):
|
||||
|
||||
```bash
|
||||
clawdbot voicecall expose --mode funnel
|
||||
```
|
||||
|
||||
If you use Tailscale Serve without Funnel, the URL is private to your tailnet,
|
||||
so Twilio/Telnyx will not be able to reach it.
|
||||
|
||||
## Providers
|
||||
|
||||
### Twilio
|
||||
|
||||
Twilio uses Programmable Voice with optional Media Streams for real-time audio.
|
||||
|
||||
Required config:
|
||||
- `twilio.accountSid` and `twilio.authToken`
|
||||
- (or `TWILIO_ACCOUNT_SID` / `TWILIO_AUTH_TOKEN`)
|
||||
- A Twilio phone number that can reach your webhook
|
||||
|
||||
Inbound setup:
|
||||
- In the Twilio Console for your phone number, set the Voice webhook to your
|
||||
public `serve.path` URL (HTTP POST).
|
||||
|
||||
Outbound setup:
|
||||
- Outbound calls are created via Twilio API; the plugin supplies the webhook URL
|
||||
per call.
|
||||
|
||||
Streaming (optional, Twilio only):
|
||||
- Enable `streaming.enabled` and set `streaming.streamPath`
|
||||
- Provide `OPENAI_API_KEY` or `streaming.openaiApiKey`
|
||||
- The stream WebSocket URL is derived from your `publicUrl` host + `streamPath`
|
||||
(https -> wss)
|
||||
|
||||
Signature verification:
|
||||
- Webhooks are verified by default.
|
||||
- If you are using ngrok free tier, leave `tunnel.allowNgrokFreeTier` as `true`
|
||||
so URL rewriting does not break verification.
|
||||
- Use `skipSignatureVerification` only for local dev.
|
||||
|
||||
### Telnyx
|
||||
|
||||
Telnyx uses Call Control v2.
|
||||
|
||||
Required config:
|
||||
- `telnyx.apiKey` and `telnyx.connectionId`
|
||||
- (or `TELNYX_API_KEY` / `TELNYX_CONNECTION_ID`)
|
||||
|
||||
Inbound setup:
|
||||
- In your Telnyx Call Control App, set the webhook URL to your public
|
||||
`serve.path`.
|
||||
|
||||
Signature verification:
|
||||
- Set `telnyx.publicKey` to enable Ed25519 signature verification.
|
||||
- If you do not set a public key, webhooks are accepted without verification
|
||||
(not recommended for production).
|
||||
|
||||
Transcription:
|
||||
- Telnyx uses its own transcription events for `continue` responses.
|
||||
|
||||
### Mock (dev)
|
||||
|
||||
`mock` is for local testing and does not make network calls.
|
||||
|
||||
## Call modes
|
||||
|
||||
Outbound calls support two modes:
|
||||
- `notify`: speak a message and auto-hangup after `notifyHangupDelaySec`.
|
||||
- `conversation`: keep the call open and allow back-and-forth.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
clawdbot voicecall call --to "+15555550123" --message "Hello" --mode notify
|
||||
clawdbot voicecall call --to "+15555550123" --message "Ready to talk?" --mode conversation
|
||||
```
|
||||
|
||||
## Inbound calls and policy
|
||||
|
||||
Inbound calls are blocked by default.
|
||||
|
||||
Policies:
|
||||
- `disabled`: block all inbound calls
|
||||
- `allowlist`: allow only numbers in `allowFrom`
|
||||
- `pairing`: currently behaves like `allowlist`
|
||||
- `open`: accept all inbound calls
|
||||
|
||||
Inbound greeting:
|
||||
- `inboundGreeting` controls the first message spoken when a call is accepted.
|
||||
|
||||
## Auto-responses and models
|
||||
|
||||
When a caller speaks, the plugin can auto-respond using the embedded Clawdbot
|
||||
agent.
|
||||
|
||||
Key settings:
|
||||
- `responseModel`: model reference for voice responses (default `openai/gpt-4o-mini`)
|
||||
- `responseSystemPrompt`: optional override for the voice system prompt
|
||||
- `responseTimeoutMs`: response generation timeout
|
||||
|
||||
Responses use the same agent system as messaging, including tool access.
|
||||
The default system prompt keeps replies short and conversational (about 1-2 sentences).
|
||||
|
||||
## Streaming (Twilio only)
|
||||
|
||||
When `streaming.enabled` is on:
|
||||
- The webhook server also accepts WebSocket upgrades at `streaming.streamPath`.
|
||||
- Audio is forwarded to OpenAI Realtime STT.
|
||||
- Final transcripts are fed into the call manager and used by `continue` and
|
||||
auto-responses.
|
||||
|
||||
Required:
|
||||
- A public HTTPS URL for the Gateway (used to derive `wss://...`).
|
||||
- `OPENAI_API_KEY` or `streaming.openaiApiKey`.
|
||||
|
||||
If no OpenAI key is available, streaming does not start and real-time transcripts
|
||||
will not arrive.
|
||||
|
||||
## Limits and timeouts
|
||||
|
||||
These settings are enforced by the call manager:
|
||||
- `maxDurationSeconds`: auto-hangup after this many seconds (starts when answered).
|
||||
- `maxConcurrentCalls`: max simultaneous active calls.
|
||||
- `transcriptTimeoutMs`: how long `continue` waits for a final transcript.
|
||||
|
||||
## Logs and debugging
|
||||
|
||||
Calls are appended as JSONL to:
|
||||
- `${store}/calls.jsonl`, or
|
||||
- `~/clawd/voice-calls/calls.jsonl` by default
|
||||
|
||||
Set `store` if you want a different base directory for call logs.
|
||||
|
||||
Use:
|
||||
|
||||
```bash
|
||||
clawdbot voicecall tail
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
@@ -137,7 +286,7 @@ Actions:
|
||||
- `end_call` (callId)
|
||||
- `get_status` (callId)
|
||||
|
||||
This repo ships a matching skill doc at `skills/voice-call/SKILL.md`.
|
||||
If you want a ready-made skill entry, grab it from [ClawdHub.com](https://ClawdHub.com).
|
||||
|
||||
## Gateway RPC
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
summary: "Use Anthropic Claude via API keys or Claude CLI auth in Clawdbot"
|
||||
summary: "Use Anthropic Claude via API keys or Claude Code CLI auth in Clawdbot"
|
||||
read_when:
|
||||
- You want to use Anthropic models in Clawdbot
|
||||
- You want setup-token or Claude CLI auth instead of API keys
|
||||
- You want setup-token or Claude Code CLI auth instead of API keys
|
||||
---
|
||||
# Anthropic (Claude)
|
||||
|
||||
Anthropic builds the **Claude** model family and provides access via an API.
|
||||
In Clawdbot you can authenticate with an API key or reuse **Claude Code / Claude CLI**
|
||||
credentials (setup-token or OAuth).
|
||||
In Clawdbot you can authenticate with an API key or reuse **Claude Code CLI** credentials
|
||||
(setup-token or OAuth).
|
||||
|
||||
## Option A: Anthropic API key
|
||||
|
||||
@@ -34,9 +34,9 @@ clawdbot onboard --anthropic-api-key "$ANTHROPIC_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
## Option B: Claude CLI (setup-token or OAuth)
|
||||
## Option B: Claude Code CLI (setup-token or OAuth)
|
||||
|
||||
**Best for:** using your Claude subscription or existing Claude CLI login.
|
||||
**Best for:** using your Claude subscription or existing Claude Code CLI login.
|
||||
|
||||
### CLI setup
|
||||
|
||||
@@ -44,7 +44,7 @@ clawdbot onboard --anthropic-api-key "$ANTHROPIC_API_KEY"
|
||||
# Run setup-token on the gateway host (wizard can run it for you)
|
||||
clawdbot onboard --auth-choice setup-token
|
||||
|
||||
# Reuse Claude CLI OAuth credentials if already logged in
|
||||
# Reuse Claude Code CLI OAuth credentials if already logged in
|
||||
clawdbot onboard --auth-choice claude-cli
|
||||
```
|
||||
|
||||
@@ -59,4 +59,7 @@ clawdbot onboard --auth-choice claude-cli
|
||||
## Notes
|
||||
|
||||
- The wizard can run `claude setup-token` on the gateway host and store the token.
|
||||
- Clawdbot writes `auth.profiles["anthropic:claude-cli"].mode` as `"oauth"` so the profile
|
||||
accepts both OAuth and setup-token credentials. Older configs using `"token"` are
|
||||
auto-migrated on load.
|
||||
- Auth details + reuse rules are in [/concepts/oauth](/concepts/oauth).
|
||||
@@ -25,7 +25,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Chann
|
||||
## Provider docs
|
||||
|
||||
- [OpenAI (API + Codex)](/providers/openai)
|
||||
- [Anthropic (API + Claude CLI)](/providers/anthropic)
|
||||
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
|
||||
- [OpenRouter](/providers/openrouter)
|
||||
- [Moonshot AI (Kimi)](/providers/moonshot)
|
||||
- [OpenCode Zen](/providers/opencode)
|
||||
|
||||
@@ -23,7 +23,7 @@ model as `provider/model`.
|
||||
## Supported providers (starter set)
|
||||
|
||||
- [OpenAI (API + Codex)](/providers/openai)
|
||||
- [Anthropic (API + Claude CLI)](/providers/anthropic)
|
||||
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
|
||||
- [OpenRouter](/providers/openrouter)
|
||||
- [Moonshot AI (Kimi)](/providers/moonshot)
|
||||
- [Synthetic](/providers/synthetic)
|
||||
|
||||
@@ -30,6 +30,8 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
|
||||
- [ ] `pnpm test` (or `pnpm test:coverage` if you need coverage output)
|
||||
- [ ] `pnpm run build` (last sanity check after tests)
|
||||
- [ ] `pnpm release:check` (verifies npm pack contents)
|
||||
- [ ] `pnpm test:install:smoke` (Docker install smoke test; required before release)
|
||||
- If the immediate previous npm release is known broken, set `CLAWDBOT_INSTALL_SMOKE_PREVIOUS=<last-good-version>` or `CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS=1` for the preinstall step.
|
||||
- [ ] (Optional) Installer E2E (Docker, runs `curl -fsSL https://clawd.bot/install.sh | bash`, onboards, then runs real tool calls):
|
||||
- `pnpm test:install:e2e:openai` (requires `OPENAI_API_KEY`)
|
||||
- `pnpm test:install:e2e:anthropic` (requires `ANTHROPIC_API_KEY`)
|
||||
|
||||
+88
-3
@@ -34,6 +34,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [How can I use different models for different tasks?](#how-can-i-use-different-models-for-different-tasks)
|
||||
- [How do I install skills on Linux?](#how-do-i-install-skills-on-linux)
|
||||
- [Do you have a Notion or HeyGen integration?](#do-you-have-a-notion-or-heygen-integration)
|
||||
- [How do I install the Chrome extension for browser takeover?](#how-do-i-install-the-chrome-extension-for-browser-takeover)
|
||||
- [Sandboxing and memory](#sandboxing-and-memory)
|
||||
- [Is there a dedicated sandboxing doc?](#is-there-a-dedicated-sandboxing-doc)
|
||||
- [How do I bind a host folder into the sandbox?](#how-do-i-bind-a-host-folder-into-the-sandbox)
|
||||
@@ -50,6 +51,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [I set `gateway.bind: "lan"` (or `"tailnet"`) and now nothing listens / the UI says unauthorized](#i-set-gatewaybind-lan-or-tailnet-and-now-nothing-listens-the-ui-says-unauthorized)
|
||||
- [Why do I need a token on localhost now?](#why-do-i-need-a-token-on-localhost-now)
|
||||
- [Do I have to restart after changing config?](#do-i-have-to-restart-after-changing-config)
|
||||
- [How do I enable web search (and web fetch)?](#how-do-i-enable-web-search-and-web-fetch)
|
||||
- [How do I run a central Gateway with specialized workers across devices?](#how-do-i-run-a-central-gateway-with-specialized-workers-across-devices)
|
||||
- [Can the Clawdbot browser run headless?](#can-the-clawdbot-browser-run-headless)
|
||||
- [Remote gateways + nodes](#remote-gateways-nodes)
|
||||
@@ -239,6 +241,10 @@ It also warns if your configured model is unknown or missing auth.
|
||||
|
||||
The wizard can run `claude setup-token` on the gateway host (or you run it yourself), then stores the token as an auth profile for the **anthropic** provider. That profile is used for model calls the same way an API key or OAuth profile would be. If you already ran `claude setup-token`, pick **Anthropic token (paste setup-token)** and paste it. More detail: [OAuth](/concepts/oauth).
|
||||
|
||||
Clawdbot keeps `auth.profiles["anthropic:claude-cli"].mode` set to `"oauth"` so
|
||||
the profile accepts both OAuth and setup-token credentials; older `"token"` mode
|
||||
entries auto-migrate.
|
||||
|
||||
### Do you support Claude subscription auth (Claude Code OAuth)?
|
||||
|
||||
Yes. Clawdbot can **reuse Claude Code CLI credentials** (OAuth) and also supports **setup-token**. If you have a Claude subscription, we recommend **setup-token** on the gateway host for the most reliable long‑running setup (requires Claude Pro/Max + the `claude` CLI). OAuth reuse is supported, but avoid logging in separately via Clawdbot and Claude Code to prevent token conflicts. See [Anthropic](/providers/anthropic) and [OAuth](/concepts/oauth).
|
||||
@@ -394,12 +400,38 @@ clawdhub update --all
|
||||
|
||||
ClawdHub installs into `./skills` under your current directory (or falls back to your configured Clawdbot workspace); Clawdbot treats that as `<workspace>/skills` on the next session. For shared skills across agents, place them in `~/.clawdbot/skills/<name>/SKILL.md`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills) and [ClawdHub](/tools/clawdhub).
|
||||
|
||||
### How do I install the Chrome extension for browser takeover?
|
||||
|
||||
Use the built-in installer, then load the unpacked extension in Chrome:
|
||||
|
||||
```bash
|
||||
clawdbot browser extension install
|
||||
clawdbot browser extension path
|
||||
```
|
||||
|
||||
Then Chrome → `chrome://extensions` → enable “Developer mode” → “Load unpacked” → pick that folder.
|
||||
|
||||
Full guide (including remote Gateway via Tailscale + security notes): [Chrome extension](/tools/chrome-extension)
|
||||
|
||||
If the Gateway runs on the same machine as Chrome (default setup), you usually **do not** need `clawdbot browser serve`.
|
||||
You still need to click the extension button on the tab you want to control (it doesn’t auto-attach).
|
||||
|
||||
## Sandboxing and memory
|
||||
|
||||
### Is there a dedicated sandboxing doc?
|
||||
|
||||
Yes. See [Sandboxing](/gateway/sandboxing). For Docker-specific setup (full gateway in Docker or sandbox images), see [Docker](/install/docker).
|
||||
|
||||
### Can I keep DMs “personal” but make groups “public/sandboxed” with one agent?
|
||||
|
||||
Yes — if your private traffic is **DMs** and your public traffic is **groups**.
|
||||
|
||||
Use `agents.defaults.sandbox.mode: "non-main"` so group/channel sessions (non-main keys) run in Docker, while the main DM session stays on-host. Then restrict what tools are available in sandboxed sessions via `tools.sandbox.tools`.
|
||||
|
||||
Setup walkthrough + example config: [Groups: personal DMs + public groups](/concepts/groups#pattern-personal-dms-public-groups-single-agent)
|
||||
|
||||
Key config reference: [Gateway configuration](/gateway/configuration#agentsdefaultssandbox)
|
||||
|
||||
### How do I bind a host folder into the sandbox?
|
||||
|
||||
Set `agents.defaults.sandbox.docker.binds` to `["host:path:mode"]` (e.g., `"/home/user/src:/src:ro"`). Global + per-agent binds merge; per-agent binds are ignored when `scope: "shared"`. Use `:ro` for anything sensitive and remember binds bypass the sandbox filesystem walls. See [Sandboxing](/gateway/sandboxing#custom-bind-mounts) and [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated#bind-mounts-security-quick-check) for examples and safety notes.
|
||||
@@ -544,6 +576,37 @@ The Gateway watches the config and supports hot‑reload:
|
||||
- `gateway.reload.mode: "hybrid"` (default): hot‑apply safe changes, restart for critical ones
|
||||
- `hot`, `restart`, `off` are also supported
|
||||
|
||||
### How do I enable web search (and web fetch)?
|
||||
|
||||
`web_fetch` works without an API key. `web_search` requires a Brave Search API
|
||||
key. **Recommended:** run `clawdbot configure --section web` to store it in
|
||||
`tools.web.search.apiKey`. Environment alternative: set `BRAVE_API_KEY` for the
|
||||
Gateway process.
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
apiKey: "BRAVE_API_KEY_HERE",
|
||||
maxResults: 5
|
||||
},
|
||||
fetch: {
|
||||
enabled: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- If you use allowlists, add `web_search`/`web_fetch` or `group:web`.
|
||||
- In sandboxed sessions, `web_fetch` auto-enables unless explicitly disabled.
|
||||
- Daemons read env vars from `~/.clawdbot/.env` (or the service environment).
|
||||
|
||||
Docs: [Web tools](/tools/web).
|
||||
|
||||
### How do I run a central Gateway with specialized workers across devices?
|
||||
|
||||
The common pattern is **one Gateway** (e.g. Raspberry Pi) plus **nodes** and **agents**:
|
||||
@@ -984,6 +1047,21 @@ Z.AI (GLM models):
|
||||
|
||||
If you reference a provider/model but the required provider key is missing, you’ll get a runtime auth error (e.g. `No API key found for provider "zai"`).
|
||||
|
||||
### “No API key found for provider …” after adding a new agent
|
||||
|
||||
This usually means the **new agent** has an empty auth store. Auth is per-agent and
|
||||
stored in:
|
||||
|
||||
```
|
||||
~/.clawdbot/agents/<agentId>/agent/auth-profiles.json
|
||||
```
|
||||
|
||||
Fix options:
|
||||
- Run `clawdbot agents add <id>` and configure auth during the wizard.
|
||||
- Or copy `auth-profiles.json` from the main agent’s `agentDir` into the new agent’s `agentDir`.
|
||||
|
||||
Do **not** reuse `agentDir` across agents; it causes auth/session collisions.
|
||||
|
||||
## Model failover and “All models failed”
|
||||
|
||||
### How does failover work?
|
||||
@@ -1017,11 +1095,11 @@ It means the system attempted to use the auth profile ID `anthropic:default`, bu
|
||||
|
||||
### Fix checklist for `No credentials found for profile "anthropic:claude-cli"`
|
||||
|
||||
This means the run is pinned to the **Claude CLI** profile, but the Gateway
|
||||
This means the run is pinned to the **Claude Code CLI** profile, but the Gateway
|
||||
can’t find that profile in its auth store.
|
||||
|
||||
- **Sync the Claude CLI token on the gateway host**
|
||||
- Run `clawdbot models status` (it loads + syncs Claude CLI credentials).
|
||||
- **Sync the Claude Code CLI token on the gateway host**
|
||||
- Run `clawdbot models status` (it loads + syncs Claude Code CLI credentials).
|
||||
- If it still says missing: run `claude setup-token` (or `clawdbot models auth setup-token --provider anthropic`) and retry.
|
||||
- **If you want to use an API key instead**
|
||||
- Put `ANTHROPIC_API_KEY` in `~/.clawdbot/.env` on the **gateway host**.
|
||||
@@ -1038,6 +1116,13 @@ If your model config includes Google Gemini as a fallback (or you switched to a
|
||||
|
||||
Fix: either provide Google auth, or remove/avoid Google models in `agents.defaults.model.fallbacks` / aliases so fallback doesn’t route there.
|
||||
|
||||
### “LLM request rejected: messages.*.thinking.signature required (google‑antigravity)”
|
||||
|
||||
Cause: the session history contains **thinking blocks without signatures** (often from
|
||||
an aborted/partial stream). Google Antigravity requires signatures for thinking blocks.
|
||||
|
||||
Fix: start a **new session** or set `/thinking off` for that agent.
|
||||
|
||||
## Auth profiles: what they are and how to manage them
|
||||
|
||||
Related: [/concepts/oauth](/concepts/oauth) (OAuth flows, token storage, multi-account patterns, CLI sync)
|
||||
|
||||
@@ -40,6 +40,9 @@ run on host, set an explicit per-agent override:
|
||||
|
||||
- Node `>=22`
|
||||
- `pnpm` (optional; recommended if you build from source)
|
||||
- **Recommended:** Brave Search API key for web search. Easiest path:
|
||||
`clawdbot configure --section web` (stores `tools.web.search.apiKey`).
|
||||
See [Web tools](/tools/web).
|
||||
|
||||
macOS: if you plan to build the apps, install Xcode / CLT. For the CLI + gateway only, Node is enough.
|
||||
Windows: use **WSL2** (Ubuntu recommended). WSL2 is strongly recommended; native Windows is untested and more problematic. Install WSL2 first, then run the Linux steps inside WSL. See [Windows (WSL2)](/platforms/windows).
|
||||
|
||||
@@ -24,6 +24,10 @@ Follow‑up reconfiguration:
|
||||
clawdbot configure
|
||||
```
|
||||
|
||||
Recommended: set up a Brave Search API key so the agent can use `web_search`
|
||||
(`web_fetch` works without a key). Easiest path: `clawdbot configure --section web`
|
||||
which stores `tools.web.search.apiKey`. Docs: [Web tools](/tools/web).
|
||||
|
||||
## QuickStart vs Advanced
|
||||
|
||||
The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
|
||||
@@ -74,7 +78,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
||||
2) **Model/Auth**
|
||||
- **Anthropic API key (recommended)**: uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.
|
||||
- **Anthropic token (setup-token)**: run `claude setup-token` on the gateway host (the wizard can run it for you and reuse the token).
|
||||
- **Anthropic OAuth (Claude CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present.
|
||||
- **Anthropic OAuth (Claude Code CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present.
|
||||
- **Anthropic token (paste setup-token)**: run `claude setup-token` in your terminal, then paste the token (you can name it; blank = default).
|
||||
- **OpenAI Code (Codex) subscription (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
|
||||
- **OpenAI Code (Codex) subscription (OAuth)**: browser flow; paste the `code#state`.
|
||||
@@ -285,6 +289,9 @@ Typical fields in `~/.clawdbot/clawdbot.json`:
|
||||
WhatsApp credentials go under `~/.clawdbot/credentials/whatsapp/<accountId>/`.
|
||||
Sessions are stored under `~/.clawdbot/agents/<agentId>/sessions/`.
|
||||
|
||||
Some channels are delivered as plugins. When you pick one during onboarding, the wizard
|
||||
will prompt to install it (npm or a local path) before it can be configured.
|
||||
|
||||
## Related docs
|
||||
|
||||
- macOS app onboarding: [Onboarding](/start/onboarding)
|
||||
|
||||
+3
-3
@@ -157,7 +157,7 @@ pnpm clawdbot models list --json
|
||||
## Live: Anthropic setup-token smoke
|
||||
|
||||
- Test: `src/agents/anthropic.setup-token.live.test.ts`
|
||||
- Goal: verify Claude CLI setup-token (or a pasted setup-token profile) can complete an Anthropic prompt.
|
||||
- Goal: verify Claude Code CLI setup-token (or a pasted setup-token profile) can complete an Anthropic prompt.
|
||||
- Enable:
|
||||
- `pnpm test:live` (or `CLAWDBOT_LIVE_TEST=1` if invoking Vitest directly)
|
||||
- `CLAWDBOT_LIVE_SETUP_TOKEN=1`
|
||||
@@ -174,7 +174,7 @@ clawdbot models auth paste-token --provider anthropic --profile-id anthropic:set
|
||||
CLAWDBOT_LIVE_SETUP_TOKEN=1 CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE=anthropic:setup-token-test pnpm test:live src/agents/anthropic.setup-token.live.test.ts
|
||||
```
|
||||
|
||||
## Live: CLI backend smoke (Claude CLI or other local CLIs)
|
||||
## Live: CLI backend smoke (Claude Code CLI or other local CLIs)
|
||||
|
||||
- Test: `src/gateway/gateway-cli-backend.live.test.ts`
|
||||
- Goal: validate the Gateway + agent pipeline using a local CLI backend, without touching your default config.
|
||||
@@ -195,7 +195,7 @@ CLAWDBOT_LIVE_SETUP_TOKEN=1 CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE=anthropic:setup-to
|
||||
- `CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_ARG="--image"` to pass image file paths as CLI args instead of prompt injection.
|
||||
- `CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_MODE="repeat"` (or `"list"`) to control how image args are passed when `IMAGE_ARG` is set.
|
||||
- `CLAWDBOT_LIVE_CLI_BACKEND_RESUME_PROBE=1` to send a second turn and validate resume flow.
|
||||
- `CLAWDBOT_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG=0` to keep Claude CLI MCP config enabled (default disables MCP config with a temporary empty file).
|
||||
- `CLAWDBOT_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG=0` to keep Claude Code CLI MCP config enabled (default disables MCP config with a temporary empty file).
|
||||
|
||||
Example:
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ Everything the model receives counts toward the context limit:
|
||||
- Compaction summaries and pruning artifacts
|
||||
- Provider wrappers or safety headers (not visible, but still counted)
|
||||
|
||||
For a practical breakdown (per injected file, tools, skills, and system prompt size), use `/context list` or `/context detail`. See [Context](/concepts/context).
|
||||
|
||||
## How to see current token usage
|
||||
|
||||
Use these in chat:
|
||||
|
||||
+144
-1
@@ -106,6 +106,95 @@ Example:
|
||||
Use `profiles.<name>.cdpUrl` for **remote CDP** if you want the Gateway to talk
|
||||
directly to a Chrome instance without a remote control server.
|
||||
|
||||
### Running the control server on the browser machine
|
||||
|
||||
Run a standalone browser control server (recommended when your Gateway is remote):
|
||||
|
||||
```bash
|
||||
# on the machine that runs Chrome
|
||||
clawdbot browser serve --bind <browser-host> --port 18791 --token <token>
|
||||
```
|
||||
|
||||
Then point your Gateway at it:
|
||||
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: "http://<browser-host>:18791",
|
||||
|
||||
// Option A (recommended): keep token in env on the Gateway
|
||||
// (avoid writing secrets into config files)
|
||||
// controlToken: "<token>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And set the auth token in the Gateway environment:
|
||||
|
||||
```bash
|
||||
export CLAWDBOT_BROWSER_CONTROL_TOKEN="<token>"
|
||||
```
|
||||
|
||||
Option B: store the token in the Gateway config instead (same shared token):
|
||||
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: "http://<browser-host>:18791",
|
||||
controlToken: "<token>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
This section covers the **browser control server** (`browser.controlUrl`) used for agent browser automation.
|
||||
|
||||
Key ideas:
|
||||
- Treat the browser control server like an admin API: **private network only**.
|
||||
- Use **token auth** always when the server is reachable off-machine.
|
||||
- Prefer **Tailnet-only** connectivity over LAN exposure.
|
||||
|
||||
### Tokens (what is shared with what?)
|
||||
|
||||
- `browser.controlToken` / `CLAWDBOT_BROWSER_CONTROL_TOKEN` is **only** for authenticating browser control HTTP requests to `browser.controlUrl`.
|
||||
- It is **not** the Gateway token (`gateway.auth.token`) and **not** a node pairing token.
|
||||
- You *can* reuse the same string value, but it’s better to keep them separate to reduce blast radius.
|
||||
|
||||
### Binding (don’t expose to your LAN by accident)
|
||||
|
||||
Recommended:
|
||||
- Keep `clawdbot browser serve` bound to loopback (`127.0.0.1`) and publish it via Tailscale.
|
||||
- Or bind to a Tailnet IP only (never `0.0.0.0`) and require a token.
|
||||
|
||||
Avoid:
|
||||
- `--bind 0.0.0.0` (LAN-visible). Even with token auth, traffic is plain HTTP unless you also add TLS.
|
||||
|
||||
### TLS / HTTPS (recommended approach: terminate in front)
|
||||
|
||||
Best practice here: keep `clawdbot browser serve` on HTTP and terminate TLS in front.
|
||||
|
||||
If you’re already using Tailscale, you have two good options:
|
||||
|
||||
1) **Tailnet-only, still HTTP** (transport is encrypted by Tailscale):
|
||||
- Keep `controlUrl` as `http://…` but ensure it’s only reachable over your tailnet.
|
||||
|
||||
2) **Serve HTTPS via Tailscale** (nice UX: `https://…` URL):
|
||||
|
||||
```bash
|
||||
# on the browser machine
|
||||
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
|
||||
tailscale serve https / http://127.0.0.1:18791
|
||||
```
|
||||
|
||||
Then set your Gateway config `browser.controlUrl` to the HTTPS URL (MagicDNS/ts.net) and keep using the same token.
|
||||
|
||||
Notes:
|
||||
- Do **not** use Tailscale Funnel for this unless you explicitly want to make the endpoint public.
|
||||
- For Tailnet setup/background, see [Gateway web surfaces](/web/index) and the [Gateway CLI](/cli/gateway).
|
||||
|
||||
## Profiles (multi-browser)
|
||||
|
||||
Clawdbot supports multiple named profiles. Each profile has its own:
|
||||
@@ -120,6 +209,54 @@ Defaults:
|
||||
|
||||
All control endpoints accept `?profile=<name>`; the CLI uses `--browser-profile`.
|
||||
|
||||
## Chrome extension relay (use your existing Chrome)
|
||||
|
||||
Clawdbot can also drive **your existing Chrome tabs** (no separate “clawd” Chrome instance) via a local CDP relay + a Chrome extension.
|
||||
|
||||
Full guide: [Chrome extension](/tools/chrome-extension)
|
||||
|
||||
Flow:
|
||||
- You run a **browser control server** (Gateway on the same machine, or `clawdbot browser serve`).
|
||||
- A local **relay server** listens at a loopback `cdpUrl` (default: `http://127.0.0.1:18792`).
|
||||
- You click the **Clawdbot Browser Relay** extension icon on a tab to attach (it does not auto-attach).
|
||||
- The agent controls that tab via the normal `browser` tool, by selecting the right profile.
|
||||
|
||||
If the Gateway runs on the same machine as Chrome (default setup), you usually **do not** need `clawdbot browser serve`.
|
||||
Use `browser serve` only when the Gateway runs elsewhere (remote mode).
|
||||
|
||||
### Sandboxed sessions
|
||||
|
||||
If the agent session is sandboxed, the `browser` tool may default to `target="sandbox"` (sandbox browser).
|
||||
Chrome extension relay takeover requires host browser control, so either:
|
||||
- run the session unsandboxed, or
|
||||
- set `agents.defaults.sandbox.browser.allowHostControl: true` and use `target="host"` when calling the tool.
|
||||
|
||||
### Setup
|
||||
|
||||
1) Create a profile that uses the extension driver:
|
||||
|
||||
```bash
|
||||
clawdbot browser create-profile \
|
||||
--name chrome \
|
||||
--driver extension \
|
||||
--cdp-url http://127.0.0.1:18792 \
|
||||
--color "#00AA00"
|
||||
```
|
||||
|
||||
2) Load the extension (dev/unpacked):
|
||||
- Chrome → `chrome://extensions` → enable “Developer mode”
|
||||
- `clawdbot browser extension install`
|
||||
- “Load unpacked” → select the directory printed by `clawdbot browser extension path`
|
||||
- Pin the extension, then click it on the tab you want to control (badge shows `ON`).
|
||||
|
||||
3) Use it:
|
||||
- CLI: `clawdbot browser --browser-profile chrome tabs`
|
||||
- Agent tool: `browser` with `profile="chrome"`
|
||||
|
||||
Notes:
|
||||
- This mode relies on Playwright-on-CDP for most operations (screenshots/snapshots/actions).
|
||||
- Detach by clicking the extension icon again.
|
||||
|
||||
## Isolation guarantees
|
||||
|
||||
- **Dedicated user data dir**: never touches your personal Chrome profile.
|
||||
@@ -164,7 +301,8 @@ All endpoints accept `?profile=<name>`.
|
||||
|
||||
Some features (navigate/act/AI snapshot/role snapshot, element screenshots, PDF) require
|
||||
Playwright. If Playwright isn’t installed, those endpoints return a clear 501
|
||||
error. ARIA snapshots and basic screenshots still work.
|
||||
error. ARIA snapshots and basic screenshots still work for clawd-managed Chrome.
|
||||
For the Chrome extension relay driver, ARIA snapshots and screenshots require Playwright.
|
||||
|
||||
## How it works (internal)
|
||||
|
||||
@@ -204,6 +342,8 @@ Inspection:
|
||||
- `clawdbot browser snapshot`
|
||||
- `clawdbot browser snapshot --format aria --limit 200`
|
||||
- `clawdbot browser snapshot --interactive --compact --depth 6`
|
||||
- `clawdbot browser snapshot --efficient`
|
||||
- `clawdbot browser snapshot --labels`
|
||||
- `clawdbot browser snapshot --selector "#main" --interactive`
|
||||
- `clawdbot browser snapshot --frame "iframe#main" --interactive`
|
||||
- `clawdbot browser console --level error`
|
||||
@@ -260,9 +400,11 @@ Notes:
|
||||
- `snapshot`:
|
||||
- `--format ai` (default when Playwright is installed): returns an AI snapshot with numeric refs (`aria-ref="<n>"`).
|
||||
- `--format aria`: returns the accessibility tree (no refs; inspection only).
|
||||
- `--efficient` (or `--mode efficient`): compact role snapshot preset (interactive + compact + depth + lower maxChars).
|
||||
- Role snapshot options (`--interactive`, `--compact`, `--depth`, `--selector`) force a role-based snapshot with refs like `ref=e12`.
|
||||
- `--frame "<iframe selector>"` scopes role snapshots to an iframe (pairs with role refs like `e12`).
|
||||
- `--interactive` outputs a flat, easy-to-pick list of interactive elements (best for driving actions).
|
||||
- `--labels` adds a viewport-only screenshot with overlayed ref labels (prints `MEDIA:<path>`).
|
||||
- `click`/`type`/etc require a `ref` from `snapshot` (either numeric `12` or role ref `e12`).
|
||||
CSS selectors are intentionally not supported for actions.
|
||||
|
||||
@@ -279,6 +421,7 @@ Clawdbot supports two “snapshot” styles:
|
||||
- Output: a role-based list/tree with `[ref=e12]` (and optional `[nth=1]`).
|
||||
- Actions: `clawdbot browser click e12`, `clawdbot browser highlight e12`.
|
||||
- Internally, the ref is resolved via `getByRole(...)` (plus `nth()` for duplicates).
|
||||
- Add `--labels` to include a viewport screenshot with overlayed `e12` labels.
|
||||
|
||||
Ref behavior:
|
||||
- Refs are **not stable across navigations**; if something fails, re-run `snapshot` and use a fresh ref.
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
---
|
||||
summary: "Chrome extension: let Clawdbot drive your existing Chrome tab"
|
||||
read_when:
|
||||
- You want the agent to drive an existing Chrome tab (toolbar button)
|
||||
- You need remote Gateway + local browser automation via Tailscale
|
||||
- You want to understand the security implications of browser takeover
|
||||
---
|
||||
|
||||
# Chrome extension (browser relay)
|
||||
|
||||
The Clawdbot Chrome extension lets the agent control your **existing Chrome tabs** (your normal Chrome window) instead of launching a separate clawd-managed Chrome profile.
|
||||
|
||||
Attach/detach happens via a **single Chrome toolbar button**.
|
||||
|
||||
## What it is (concept)
|
||||
|
||||
There are three parts:
|
||||
- **Browser control server** (HTTP): the API the agent/tool calls (`browser.controlUrl`)
|
||||
- **Local relay server** (loopback CDP): bridges between the control server and the extension (`http://127.0.0.1:18792` by default)
|
||||
- **Chrome MV3 extension**: attaches to the active tab using `chrome.debugger` and pipes CDP messages to the relay
|
||||
|
||||
Clawdbot then controls the attached tab through the normal `browser` tool surface (selecting the right profile).
|
||||
|
||||
## Install / load (unpacked)
|
||||
|
||||
1) Install the extension to a stable local path:
|
||||
|
||||
```bash
|
||||
clawdbot browser extension install
|
||||
```
|
||||
|
||||
2) Print the installed extension directory path:
|
||||
|
||||
```bash
|
||||
clawdbot browser extension path
|
||||
```
|
||||
|
||||
3) Chrome → `chrome://extensions`
|
||||
- Enable “Developer mode”
|
||||
- “Load unpacked” → select the directory printed above
|
||||
|
||||
4) Pin the extension.
|
||||
|
||||
## Updates (no build step)
|
||||
|
||||
The extension ships inside the Clawdbot release (npm package) as static files. There is no separate “build” step.
|
||||
|
||||
After upgrading Clawdbot:
|
||||
- Re-run `clawdbot browser extension install` to refresh the installed files under your Clawdbot state directory.
|
||||
- Chrome → `chrome://extensions` → click “Reload” on the extension.
|
||||
|
||||
## Create a browser profile for the extension
|
||||
|
||||
```bash
|
||||
clawdbot browser create-profile \
|
||||
--name chrome \
|
||||
--driver extension \
|
||||
--cdp-url http://127.0.0.1:18792 \
|
||||
--color "#00AA00"
|
||||
```
|
||||
|
||||
Then target it:
|
||||
- CLI: `clawdbot browser --browser-profile chrome tabs`
|
||||
- Agent tool: `browser` with `profile="chrome"`
|
||||
|
||||
## Attach / detach (toolbar button)
|
||||
|
||||
- Open the tab you want Clawdbot to control.
|
||||
- Click the extension icon.
|
||||
- Badge shows `ON` when attached.
|
||||
- Click again to detach.
|
||||
|
||||
## Which tab does it control?
|
||||
|
||||
- It does **not** automatically control “whatever tab you’re looking at”.
|
||||
- It controls **only the tab(s) you explicitly attached** by clicking the toolbar button.
|
||||
- To switch: open the other tab and click the extension icon there.
|
||||
|
||||
## Badge + common errors
|
||||
|
||||
- `ON`: attached; Clawdbot can drive that tab.
|
||||
- `…`: connecting to the local relay.
|
||||
- `!`: relay not reachable (most common: browser relay server isn’t running on this machine).
|
||||
|
||||
If you see `!`:
|
||||
- Make sure the Gateway is running locally (default setup), or run `clawdbot browser serve` on this machine (remote gateway setup).
|
||||
- Open the extension Options page; it shows whether the relay is reachable.
|
||||
|
||||
## Do I need `clawdbot browser serve`?
|
||||
|
||||
### Local Gateway (same machine as Chrome) — usually **no**
|
||||
|
||||
If the Gateway is running on the same machine as Chrome and your `browser.controlUrl` is loopback (default),
|
||||
you typically **do not** need `clawdbot browser serve`.
|
||||
|
||||
The Gateway’s built-in browser control server will start on `http://127.0.0.1:18791/` and Clawdbot will
|
||||
auto-start the local relay server when you use a profile with `driver="extension"`.
|
||||
|
||||
### Remote Gateway (Gateway runs elsewhere) — **yes**
|
||||
|
||||
If your Gateway runs on another machine, run `clawdbot browser serve` on the machine that runs Chrome
|
||||
(and publish it via Tailscale Serve / TLS). See the section below.
|
||||
|
||||
## Sandboxing (tool containers)
|
||||
|
||||
If your agent session is sandboxed (`agents.defaults.sandbox.mode != "off"`), the `browser` tool can be restricted:
|
||||
|
||||
- By default, sandboxed sessions often target the **sandbox browser** (`target="sandbox"`), not your host Chrome.
|
||||
- Chrome extension relay takeover requires controlling the **host** browser control server.
|
||||
|
||||
Options:
|
||||
- Easiest: use the extension from a **non-sandboxed** session/agent.
|
||||
- Or allow host browser control for sandboxed sessions:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
browser: {
|
||||
allowHostControl: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then ensure the tool isn’t denied by tool policy, and (if needed) call `browser` with `target="host"`.
|
||||
|
||||
Debugging: `clawdbot sandbox explain`
|
||||
|
||||
## Remote Gateway (recommended: Tailscale Serve)
|
||||
|
||||
Goal: Gateway runs on one machine, but Chrome runs somewhere else.
|
||||
|
||||
On the **browser machine**:
|
||||
|
||||
```bash
|
||||
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
|
||||
tailscale serve https / http://127.0.0.1:18791
|
||||
```
|
||||
|
||||
On the **Gateway machine**:
|
||||
- Set `browser.controlUrl` to the HTTPS Serve URL (MagicDNS/ts.net).
|
||||
- Provide the token (prefer env):
|
||||
|
||||
```bash
|
||||
export CLAWDBOT_BROWSER_CONTROL_TOKEN="<token>"
|
||||
```
|
||||
|
||||
Then the agent can drive the browser by calling the remote `browser.controlUrl` API, while the extension + relay stay local on the browser machine.
|
||||
|
||||
## How “extension path” works
|
||||
|
||||
`clawdbot browser extension path` prints the **installed** on-disk directory containing the extension files.
|
||||
|
||||
The CLI intentionally does **not** print a `node_modules` path. Always run `clawdbot browser extension install` first to copy the extension to a stable location under your Clawdbot state directory.
|
||||
|
||||
If you move or delete that install directory, Chrome will mark the extension as broken until you reload it from a valid path.
|
||||
|
||||
## Security implications (read this)
|
||||
|
||||
This is powerful and risky. Treat it like giving the model “hands on your browser”.
|
||||
|
||||
- The extension uses Chrome’s debugger API (`chrome.debugger`). When attached, the model can:
|
||||
- click/type/navigate in that tab
|
||||
- read page content
|
||||
- access whatever the tab’s logged-in session can access
|
||||
- **This is not isolated** like the dedicated clawd-managed profile.
|
||||
- If you attach to your daily-driver profile/tab, you’re granting access to that account state.
|
||||
|
||||
Recommendations:
|
||||
- Prefer a dedicated Chrome profile (separate from your personal browsing) for extension relay usage.
|
||||
- Keep the browser control server tailnet-only (Tailscale) and require a token.
|
||||
- Avoid exposing browser control over LAN (`0.0.0.0`) and avoid Funnel (public).
|
||||
|
||||
Related:
|
||||
- Browser tool overview: [Browser](/tools/browser)
|
||||
- Security audit: [Security](/gateway/security)
|
||||
- Tailscale setup: [Tailscale](/gateway/tailscale)
|
||||
@@ -20,6 +20,7 @@ read_when:
|
||||
- **Host execution**: elevated runs `exec` on the host (bypasses sandbox).
|
||||
- **Unsandboxed agents**: when there is no sandbox to bypass, elevated does not change where `exec` runs.
|
||||
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
|
||||
- **Not skill-scoped**: elevated cannot be limited to a specific skill; it only changes `exec` location.
|
||||
|
||||
Note:
|
||||
- Sandbox on: `/elevated on` runs that `exec` command on the host.
|
||||
|
||||
@@ -68,6 +68,59 @@ Example (global coding profile, messaging-only support agent):
|
||||
}
|
||||
```
|
||||
|
||||
## Provider-specific tool policy
|
||||
|
||||
Use `tools.byProvider` to **further restrict** tools for specific providers
|
||||
(or a single `provider/model`) without changing your global defaults.
|
||||
Per-agent override: `agents.list[].tools.byProvider`.
|
||||
|
||||
This is applied **after** the base tool profile and **before** allow/deny lists,
|
||||
so it can only narrow the tool set.
|
||||
Provider keys accept either `provider` (e.g. `google-antigravity`) or
|
||||
`provider/model` (e.g. `openai/gpt-5.2`).
|
||||
|
||||
Example (keep global coding profile, but minimal tools for Google Antigravity):
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
profile: "coding",
|
||||
byProvider: {
|
||||
"google-antigravity": { profile: "minimal" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example (provider/model-specific allowlist for a flaky endpoint):
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
allow: ["group:fs", "group:runtime", "sessions_list"],
|
||||
byProvider: {
|
||||
"openai/gpt-5.2": { allow: ["group:fs", "sessions_list"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example (agent-specific override for a single provider):
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "support",
|
||||
tools: {
|
||||
byProvider: {
|
||||
"google-antigravity": { allow: ["message", "sessions_list"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tool groups (shorthands)
|
||||
|
||||
Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools.
|
||||
@@ -78,6 +131,7 @@ Available groups:
|
||||
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
|
||||
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
|
||||
- `group:memory`: `memory_search`, `memory_get`
|
||||
- `group:web`: `web_search`, `web_fetch`
|
||||
- `group:ui`: `browser`, `canvas`
|
||||
- `group:automation`: `cron`, `gateway`
|
||||
- `group:messaging`: `message`
|
||||
@@ -135,6 +189,33 @@ Notes:
|
||||
- `log` supports line-based `offset`/`limit` (omit `offset` to grab the last N lines).
|
||||
- `process` is scoped per agent; sessions from other agents are not visible.
|
||||
|
||||
### `web_search`
|
||||
Search the web using Brave Search API.
|
||||
|
||||
Core parameters:
|
||||
- `query` (required)
|
||||
- `count` (1–10; default from `tools.web.search.maxResults`)
|
||||
|
||||
Notes:
|
||||
- Requires a Brave API key (recommended: `clawdbot configure --section web`, or set `BRAVE_API_KEY`).
|
||||
- Enable via `tools.web.search.enabled`.
|
||||
- Responses are cached (default 15 min).
|
||||
- See [Web tools](/tools/web) for setup.
|
||||
|
||||
### `web_fetch`
|
||||
Fetch and extract readable content from a URL (HTML → markdown/text).
|
||||
|
||||
Core parameters:
|
||||
- `url` (required)
|
||||
- `extractMode` (`markdown` | `text`)
|
||||
- `maxChars` (truncate long pages)
|
||||
|
||||
Notes:
|
||||
- Enable via `tools.web.fetch.enabled`.
|
||||
- Responses are cached (default 15 min).
|
||||
- For JS-heavy sites, prefer the browser tool.
|
||||
- See [Web tools](/tools/web) for setup.
|
||||
|
||||
### `browser`
|
||||
Control the dedicated clawd browser.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ All skills-related configuration lives under `skills` in `~/.clawdbot/clawdbot.j
|
||||
```json5
|
||||
{
|
||||
skills: {
|
||||
allowBundled: ["brave-search", "gemini"],
|
||||
allowBundled: ["gemini", "peekaboo"],
|
||||
load: {
|
||||
extraDirs: [
|
||||
"~/Projects/agent-scripts/skills",
|
||||
|
||||
@@ -105,6 +105,13 @@ Fields under `metadata.clawdbot`:
|
||||
- `primaryEnv` — env var name associated with `skills.entries.<name>.apiKey`.
|
||||
- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv).
|
||||
|
||||
Note on sandboxing:
|
||||
- `requires.bins` is checked on the **host** at skill load time.
|
||||
- If an agent is sandboxed, the binary must also exist **inside the container**.
|
||||
Install it via `agents.defaults.sandbox.docker.setupCommand` (or a custom image).
|
||||
Example: the `summarize` skill (`skills/summarize/SKILL.md`) needs the `summarize` CLI
|
||||
in the sandbox container to run there.
|
||||
|
||||
Installer example:
|
||||
|
||||
```markdown
|
||||
|
||||
@@ -55,6 +55,7 @@ Text + native (when enabled):
|
||||
- `/help`
|
||||
- `/commands`
|
||||
- `/status` (show current status; includes a short provider usage/quota line when available)
|
||||
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||
- `/usage` (alias: `/status`)
|
||||
- `/whoami` (show your sender id; alias: `/id`)
|
||||
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
|
||||
@@ -62,6 +63,9 @@ Text + native (when enabled):
|
||||
- `/cost on|off` (toggle per-response usage line)
|
||||
- `/stop`
|
||||
- `/restart`
|
||||
- `/dock-telegram` (switch replies to Telegram)
|
||||
- `/dock-discord` (switch replies to Discord)
|
||||
- `/dock-slack` (switch replies to Slack)
|
||||
- `/activation mention|always` (groups only)
|
||||
- `/send on|off|inherit` (owner-only)
|
||||
- `/reset` or `/new`
|
||||
@@ -87,7 +91,9 @@ Notes:
|
||||
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
|
||||
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
|
||||
- **Fast path:** command-only messages from allowlisted senders are handled immediately (bypass queue + model).
|
||||
- **Inline shortcuts (allowlisted senders only):** `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`) also work when embedded in text.
|
||||
- **Inline shortcuts (allowlisted senders only):** certain commands also work when embedded in a normal message and are stripped before the model sees the remaining text.
|
||||
- Example: `hey /status` triggers a status reply, and the remaining text continues through the normal flow.
|
||||
- Currently: `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`).
|
||||
- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text.
|
||||
|
||||
## Usage vs cost (what shows where)
|
||||
|
||||
@@ -49,8 +49,13 @@ Sub-agents report back via an announce step:
|
||||
- The announce step runs inside the sub-agent session (not the requester session).
|
||||
- If the sub-agent replies exactly `ANNOUNCE_SKIP`, nothing is posted.
|
||||
- Otherwise the announce reply is posted to the requester chat channel via the gateway `send` method.
|
||||
- Announce messages are normalized to a stable template:
|
||||
- `Status:` derived from the run outcome (`success`, `error`, `timeout`, or `unknown`).
|
||||
- `Result:` the summary content from the announce step (or `(not available)` if missing).
|
||||
- `Notes:` error details and other useful context.
|
||||
- `Status` is not inferred from model output; it comes from runtime outcome signals.
|
||||
|
||||
Announce payloads include a stats line at the end:
|
||||
Announce payloads include a stats line at the end (even when wrapped):
|
||||
- Runtime (e.g., `runtime 5m12s`)
|
||||
- Token usage (input/output/total)
|
||||
- Estimated cost when model pricing is configured (`models.providers.*.models[].cost`)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
summary: "Web search + fetch tools (Brave Search API)"
|
||||
read_when:
|
||||
- You want to enable web_search or web_fetch
|
||||
- You need Brave Search API key setup
|
||||
---
|
||||
|
||||
# Web tools
|
||||
|
||||
Clawdbot ships two lightweight web tools:
|
||||
|
||||
- `web_search` — Brave Search API queries (fast, structured results).
|
||||
- `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text).
|
||||
|
||||
These are **not** browser automation. For JS-heavy sites or logins, use the
|
||||
[Browser tool](/tools/browser).
|
||||
|
||||
## How it works
|
||||
|
||||
- `web_search` calls Brave’s Search API and returns structured results
|
||||
(title, URL, snippet). No browser is involved.
|
||||
- Results are cached by query for 15 minutes (configurable).
|
||||
- `web_fetch` does a plain HTTP GET and extracts readable content
|
||||
(HTML → markdown/text). It does **not** execute JavaScript.
|
||||
- In sandboxed sessions, `web_fetch` is enabled automatically (unless explicitly disabled).
|
||||
|
||||
## Getting a Brave API key
|
||||
|
||||
1) Create a Brave Search API account at https://brave.com/search/api/
|
||||
2) Generate an API key in the dashboard.
|
||||
3) Run `clawdbot configure --section web` to store the key in config (recommended), or set `BRAVE_API_KEY` in your environment.
|
||||
|
||||
Brave provides a free tier plus paid plans; check the Brave API portal for the
|
||||
current limits and pricing.
|
||||
|
||||
### Where to set the key (recommended)
|
||||
|
||||
**Recommended:** run `clawdbot configure --section web`. It stores the key in
|
||||
`~/.clawdbot/clawdbot.json` under `tools.web.search.apiKey`.
|
||||
|
||||
**Environment alternative:** set `BRAVE_API_KEY` in the Gateway process
|
||||
environment. For a daemon install, put it in `~/.clawdbot/.env` (or your
|
||||
service environment). See [Env vars](/start/faq#how-does-clawdbot-load-environment-variables).
|
||||
|
||||
## web_search
|
||||
|
||||
Search the web with Brave’s API.
|
||||
|
||||
### Requirements
|
||||
|
||||
- `tools.web.search.enabled: true`
|
||||
- Brave API key (recommended: `clawdbot configure --section web`, or set `BRAVE_API_KEY`)
|
||||
|
||||
### Config
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
apiKey: "BRAVE_API_KEY_HERE", // optional if BRAVE_API_KEY is set
|
||||
maxResults: 5,
|
||||
timeoutSeconds: 30,
|
||||
cacheTtlMinutes: 15
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tool parameters
|
||||
|
||||
- `query` (required)
|
||||
- `count` (1–10; default from config)
|
||||
|
||||
## web_fetch
|
||||
|
||||
Fetch a URL and extract readable content.
|
||||
|
||||
### Requirements
|
||||
|
||||
- `tools.web.fetch.enabled: true`
|
||||
|
||||
### Config
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
fetch: {
|
||||
enabled: true,
|
||||
maxChars: 50000,
|
||||
timeoutSeconds: 30,
|
||||
cacheTtlMinutes: 15,
|
||||
userAgent: "clawdbot/2026.1.14"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tool parameters
|
||||
|
||||
- `url` (required, http/https only)
|
||||
- `extractMode` (`markdown` | `text`)
|
||||
- `maxChars` (truncate long pages)
|
||||
|
||||
Notes:
|
||||
- `web_fetch` is best-effort extraction; some sites will need the browser tool.
|
||||
- Responses are cached (default 15 minutes) to reduce repeated fetches.
|
||||
- If you use tool profiles/allowlists, add `web_search`/`web_fetch` or `group:web`.
|
||||
@@ -30,7 +30,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
|
||||
## What it can do (today)
|
||||
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`)
|
||||
- Stream tool calls + live tool output cards in Chat (agent events)
|
||||
- Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.set`)
|
||||
- Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.patch`)
|
||||
- Instances: presence list + refresh (`system-presence`)
|
||||
- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`)
|
||||
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
|
||||
@@ -38,6 +38,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
|
||||
- Nodes: list + caps (`node.list`)
|
||||
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
|
||||
- Config: apply + restart with validation (`config.apply`) and wake the last active session
|
||||
- Config writes include a base-hash guard to prevent clobbering concurrent edits
|
||||
- Config schema + form rendering (`config.schema`); Raw JSON editor remains available
|
||||
- Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`)
|
||||
- Logs: live tail of gateway file logs with filter/export (`logs.tail`)
|
||||
|
||||
@@ -3,17 +3,18 @@
|
||||
## 0.1.0
|
||||
|
||||
### Highlights
|
||||
- Initial release of the @clawdbot/voice-call plugin.
|
||||
- First public release of the @clawdbot/voice-call plugin.
|
||||
|
||||
### Changes
|
||||
- Providers: Twilio (Programmable Voice + Media Streams), Telnyx (Call Control v2), and mock dev provider.
|
||||
- Outbound calls: notify mode with auto-hangup + conversation mode for multi-turn calls.
|
||||
- Inbound calls: policies (disabled/allowlist/open), allowlist matching, and inbound greeting.
|
||||
- Webhooks: local server with configurable bind/port/path plus publicUrl override.
|
||||
- Exposure: ngrok and Tailscale (serve/funnel) tunnel helpers; dev-only signature bypass for ngrok free tier.
|
||||
- Streaming: OpenAI Realtime STT with media stream WebSocket + partial/final transcripts.
|
||||
- Speech: OpenAI TTS (model/voice/instructions) with Twilio <Say> fallback.
|
||||
- Tooling: `voice_call` tool with action-based API for initiate/continue/speak/end/status.
|
||||
### Features
|
||||
- Providers: Twilio (Programmable Voice + Media Streams), Telnyx (Call Control v2), and mock provider for local dev.
|
||||
- Call flows: outbound notify vs. conversation modes, configurable auto‑hangup, and multi‑turn continuation.
|
||||
- Inbound handling: policy controls (disabled/allowlist/open), allowlist matching, and inbound greeting.
|
||||
- Webhooks: built‑in server with configurable bind/port/path plus `publicUrl` override.
|
||||
- Exposure helpers: ngrok + Tailscale serve/funnel; dev‑only signature bypass for ngrok free tier.
|
||||
- Streaming: OpenAI Realtime STT over media WebSocket with partial + final transcripts.
|
||||
- Speech: OpenAI TTS (model/voice/instructions) with Twilio `<Say>` fallback.
|
||||
- Tooling: `voice_call` tool actions for initiate/continue/speak/end/status.
|
||||
- Gateway RPC: `voicecall.initiate|continue|speak|end|status` (+ legacy `voicecall.start`).
|
||||
- CLI: `clawdbot voicecall` commands (call/start/continue/speak/end/status/tail/expose).
|
||||
- RPC: gateway methods (`voicecall.initiate|continue|speak|end|status` + legacy `voicecall.start`).
|
||||
- Persistence: JSONL call logs with `voicecall tail` for live inspection.
|
||||
- Observability: JSONL call logs and `voicecall tail` for live inspection.
|
||||
- Response controls: `responseModel`, `responseSystemPrompt`, and `responseTimeoutMs` for auto‑responses.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/voice-call",
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Clawdbot voice-call plugin",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveUserPath } from "./utils.js";
|
||||
import type { CallMode, VoiceCallConfig } from "./config.js";
|
||||
import type { VoiceCallConfig } from "./config.js";
|
||||
import type { VoiceCallProvider } from "./providers/base.js";
|
||||
import {
|
||||
type CallId,
|
||||
type CallRecord,
|
||||
CallRecordSchema,
|
||||
type CallState,
|
||||
type NormalizedEvent,
|
||||
type OutboundCallOptions,
|
||||
TerminalStates,
|
||||
type TranscriptEntry,
|
||||
} from "./types.js";
|
||||
import { escapeXml, mapVoiceToPolly } from "./voice-mapping.js";
|
||||
import type { CallManagerContext } from "./manager/context.js";
|
||||
import { processEvent } from "./manager/events.js";
|
||||
import { getCallByProviderCallId } from "./manager/lookup.js";
|
||||
import {
|
||||
continueCall,
|
||||
endCall,
|
||||
initiateCall,
|
||||
speak,
|
||||
speakInitialMessage,
|
||||
} from "./manager/outbound.js";
|
||||
import { getCallHistoryFromStore, loadActiveCallsFromStore } from "./manager/store.js";
|
||||
|
||||
/**
|
||||
* Manages voice calls: state machine, persistence, and provider coordination.
|
||||
@@ -51,6 +55,20 @@ export class CallManager {
|
||||
this.storePath = resolveUserPath(rawPath);
|
||||
}
|
||||
|
||||
private buildContext(): CallManagerContext {
|
||||
return {
|
||||
activeCalls: this.activeCalls,
|
||||
providerCallIdMap: this.providerCallIdMap,
|
||||
processedEventIds: this.processedEventIds,
|
||||
provider: this.provider,
|
||||
config: this.config,
|
||||
storePath: this.storePath,
|
||||
webhookUrl: this.webhookUrl,
|
||||
transcriptWaiters: this.transcriptWaiters,
|
||||
maxDurationTimers: this.maxDurationTimers,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the call manager with a provider.
|
||||
*/
|
||||
@@ -62,7 +80,10 @@ export class CallManager {
|
||||
fs.mkdirSync(this.storePath, { recursive: true });
|
||||
|
||||
// Load any persisted active calls
|
||||
this.loadActiveCalls();
|
||||
const restored = loadActiveCallsFromStore(this.storePath);
|
||||
this.activeCalls = restored.activeCalls;
|
||||
this.providerCallIdMap = restored.providerCallIdMap;
|
||||
this.processedEventIds = restored.processedEventIds;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,102 +104,7 @@ export class CallManager {
|
||||
sessionKey?: string,
|
||||
options?: OutboundCallOptions | string,
|
||||
): Promise<{ callId: CallId; success: boolean; error?: string }> {
|
||||
// Support legacy string argument for initialMessage
|
||||
const opts: OutboundCallOptions =
|
||||
typeof options === "string" ? { message: options } : (options ?? {});
|
||||
const initialMessage = opts.message;
|
||||
const mode = opts.mode ?? this.config.outbound.defaultMode;
|
||||
if (!this.provider) {
|
||||
return { callId: "", success: false, error: "Provider not initialized" };
|
||||
}
|
||||
|
||||
if (!this.webhookUrl) {
|
||||
return {
|
||||
callId: "",
|
||||
success: false,
|
||||
error: "Webhook URL not configured",
|
||||
};
|
||||
}
|
||||
|
||||
// Check concurrent call limit
|
||||
const activeCalls = this.getActiveCalls();
|
||||
if (activeCalls.length >= this.config.maxConcurrentCalls) {
|
||||
return {
|
||||
callId: "",
|
||||
success: false,
|
||||
error: `Maximum concurrent calls (${this.config.maxConcurrentCalls}) reached`,
|
||||
};
|
||||
}
|
||||
|
||||
const callId = crypto.randomUUID();
|
||||
const from =
|
||||
this.config.fromNumber ||
|
||||
(this.provider?.name === "mock" ? "+15550000000" : undefined);
|
||||
if (!from) {
|
||||
return { callId: "", success: false, error: "fromNumber not configured" };
|
||||
}
|
||||
|
||||
// Create call record with mode in metadata
|
||||
const callRecord: CallRecord = {
|
||||
callId,
|
||||
provider: this.provider.name,
|
||||
direction: "outbound",
|
||||
state: "initiated",
|
||||
from,
|
||||
to,
|
||||
sessionKey,
|
||||
startedAt: Date.now(),
|
||||
transcript: [],
|
||||
processedEventIds: [],
|
||||
metadata: {
|
||||
...(initialMessage && { initialMessage }),
|
||||
mode,
|
||||
},
|
||||
};
|
||||
|
||||
this.activeCalls.set(callId, callRecord);
|
||||
this.persistCallRecord(callRecord);
|
||||
|
||||
try {
|
||||
// For notify mode with a message, use inline TwiML with <Say>
|
||||
let inlineTwiml: string | undefined;
|
||||
if (mode === "notify" && initialMessage) {
|
||||
const pollyVoice = mapVoiceToPolly(this.config.tts.voice);
|
||||
inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice);
|
||||
console.log(
|
||||
`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.provider.initiateCall({
|
||||
callId,
|
||||
from,
|
||||
to,
|
||||
webhookUrl: this.webhookUrl,
|
||||
inlineTwiml,
|
||||
});
|
||||
|
||||
callRecord.providerCallId = result.providerCallId;
|
||||
this.providerCallIdMap.set(result.providerCallId, callId); // Map providerCallId to internal callId
|
||||
this.persistCallRecord(callRecord);
|
||||
|
||||
return { callId, success: true };
|
||||
} catch (err) {
|
||||
callRecord.state = "failed";
|
||||
callRecord.endedAt = Date.now();
|
||||
callRecord.endReason = "failed";
|
||||
this.persistCallRecord(callRecord);
|
||||
this.activeCalls.delete(callId);
|
||||
if (callRecord.providerCallId) {
|
||||
this.providerCallIdMap.delete(callRecord.providerCallId);
|
||||
}
|
||||
|
||||
return {
|
||||
callId,
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
return await initiateCall(this.buildContext(), to, sessionKey, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,42 +114,7 @@ export class CallManager {
|
||||
callId: CallId,
|
||||
text: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const call = this.activeCalls.get(callId);
|
||||
if (!call) {
|
||||
return { success: false, error: "Call not found" };
|
||||
}
|
||||
|
||||
if (!this.provider || !call.providerCallId) {
|
||||
return { success: false, error: "Call not connected" };
|
||||
}
|
||||
|
||||
if (TerminalStates.has(call.state)) {
|
||||
return { success: false, error: "Call has ended" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Update state
|
||||
call.state = "speaking";
|
||||
this.persistCallRecord(call);
|
||||
|
||||
// Add to transcript
|
||||
this.addTranscriptEntry(call, "bot", text);
|
||||
|
||||
// Play TTS
|
||||
await this.provider.playTts({
|
||||
callId,
|
||||
providerCallId: call.providerCallId,
|
||||
text,
|
||||
voice: this.config.tts.voice,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
return await speak(this.buildContext(), callId, text);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -232,135 +123,7 @@ export class CallManager {
|
||||
* In notify mode, auto-hangup after the message is delivered.
|
||||
*/
|
||||
async speakInitialMessage(providerCallId: string): Promise<void> {
|
||||
const call = this.getCallByProviderCallId(providerCallId);
|
||||
if (!call) {
|
||||
console.warn(
|
||||
`[voice-call] speakInitialMessage: no call found for ${providerCallId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const initialMessage = call.metadata?.initialMessage as string | undefined;
|
||||
const mode = (call.metadata?.mode as CallMode) ?? "conversation";
|
||||
|
||||
if (!initialMessage) {
|
||||
console.log(
|
||||
`[voice-call] speakInitialMessage: no initial message for ${call.callId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the initial message so we don't speak it again
|
||||
if (call.metadata) {
|
||||
delete call.metadata.initialMessage;
|
||||
this.persistCallRecord(call);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[voice-call] Speaking initial message for call ${call.callId} (mode: ${mode})`,
|
||||
);
|
||||
const result = await this.speak(call.callId, initialMessage);
|
||||
if (!result.success) {
|
||||
console.warn(
|
||||
`[voice-call] Failed to speak initial message: ${result.error}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// In notify mode, auto-hangup after delay
|
||||
if (mode === "notify") {
|
||||
const delaySec = this.config.outbound.notifyHangupDelaySec;
|
||||
console.log(
|
||||
`[voice-call] Notify mode: auto-hangup in ${delaySec}s for call ${call.callId}`,
|
||||
);
|
||||
setTimeout(async () => {
|
||||
const currentCall = this.getCall(call.callId);
|
||||
if (currentCall && !TerminalStates.has(currentCall.state)) {
|
||||
console.log(
|
||||
`[voice-call] Notify mode: hanging up call ${call.callId}`,
|
||||
);
|
||||
await this.endCall(call.callId);
|
||||
}
|
||||
}, delaySec * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start max duration timer for a call.
|
||||
* Auto-hangup when maxDurationSeconds is reached.
|
||||
*/
|
||||
private startMaxDurationTimer(callId: CallId): void {
|
||||
// Clear any existing timer
|
||||
this.clearMaxDurationTimer(callId);
|
||||
|
||||
const maxDurationMs = this.config.maxDurationSeconds * 1000;
|
||||
console.log(
|
||||
`[voice-call] Starting max duration timer (${this.config.maxDurationSeconds}s) for call ${callId}`,
|
||||
);
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
this.maxDurationTimers.delete(callId);
|
||||
const call = this.getCall(callId);
|
||||
if (call && !TerminalStates.has(call.state)) {
|
||||
console.log(
|
||||
`[voice-call] Max duration reached (${this.config.maxDurationSeconds}s), ending call ${callId}`,
|
||||
);
|
||||
call.endReason = "timeout";
|
||||
this.persistCallRecord(call);
|
||||
await this.endCall(callId);
|
||||
}
|
||||
}, maxDurationMs);
|
||||
|
||||
this.maxDurationTimers.set(callId, timer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear max duration timer for a call.
|
||||
*/
|
||||
private clearMaxDurationTimer(callId: CallId): void {
|
||||
const timer = this.maxDurationTimers.get(callId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.maxDurationTimers.delete(callId);
|
||||
}
|
||||
}
|
||||
|
||||
private clearTranscriptWaiter(callId: CallId): void {
|
||||
const waiter = this.transcriptWaiters.get(callId);
|
||||
if (!waiter) return;
|
||||
clearTimeout(waiter.timeout);
|
||||
this.transcriptWaiters.delete(callId);
|
||||
}
|
||||
|
||||
private rejectTranscriptWaiter(callId: CallId, reason: string): void {
|
||||
const waiter = this.transcriptWaiters.get(callId);
|
||||
if (!waiter) return;
|
||||
this.clearTranscriptWaiter(callId);
|
||||
waiter.reject(new Error(reason));
|
||||
}
|
||||
|
||||
private resolveTranscriptWaiter(callId: CallId, transcript: string): void {
|
||||
const waiter = this.transcriptWaiters.get(callId);
|
||||
if (!waiter) return;
|
||||
this.clearTranscriptWaiter(callId);
|
||||
waiter.resolve(transcript);
|
||||
}
|
||||
|
||||
private waitForFinalTranscript(callId: CallId): Promise<string> {
|
||||
// Only allow one in-flight waiter per call.
|
||||
this.rejectTranscriptWaiter(callId, "Transcript waiter replaced");
|
||||
|
||||
const timeoutMs = this.config.transcriptTimeoutMs;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.transcriptWaiters.delete(callId);
|
||||
reject(
|
||||
new Error(`Timed out waiting for transcript after ${timeoutMs}ms`),
|
||||
);
|
||||
}, timeoutMs);
|
||||
|
||||
this.transcriptWaiters.set(callId, { resolve, reject, timeout });
|
||||
});
|
||||
await speakInitialMessage(this.buildContext(), providerCallId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -370,287 +133,21 @@ export class CallManager {
|
||||
callId: CallId,
|
||||
prompt: string,
|
||||
): Promise<{ success: boolean; transcript?: string; error?: string }> {
|
||||
const call = this.activeCalls.get(callId);
|
||||
if (!call) {
|
||||
return { success: false, error: "Call not found" };
|
||||
}
|
||||
|
||||
if (!this.provider || !call.providerCallId) {
|
||||
return { success: false, error: "Call not connected" };
|
||||
}
|
||||
|
||||
if (TerminalStates.has(call.state)) {
|
||||
return { success: false, error: "Call has ended" };
|
||||
}
|
||||
|
||||
try {
|
||||
await this.speak(callId, prompt);
|
||||
|
||||
call.state = "listening";
|
||||
this.persistCallRecord(call);
|
||||
|
||||
await this.provider.startListening({
|
||||
callId,
|
||||
providerCallId: call.providerCallId,
|
||||
});
|
||||
|
||||
const transcript = await this.waitForFinalTranscript(callId);
|
||||
|
||||
// Best-effort: stop listening after final transcript.
|
||||
await this.provider.stopListening({
|
||||
callId,
|
||||
providerCallId: call.providerCallId,
|
||||
});
|
||||
|
||||
return { success: true, transcript };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
} finally {
|
||||
this.clearTranscriptWaiter(callId);
|
||||
}
|
||||
return await continueCall(this.buildContext(), callId, prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* End an active call.
|
||||
*/
|
||||
async endCall(callId: CallId): Promise<{ success: boolean; error?: string }> {
|
||||
const call = this.activeCalls.get(callId);
|
||||
if (!call) {
|
||||
return { success: false, error: "Call not found" };
|
||||
}
|
||||
|
||||
if (!this.provider || !call.providerCallId) {
|
||||
return { success: false, error: "Call not connected" };
|
||||
}
|
||||
|
||||
if (TerminalStates.has(call.state)) {
|
||||
return { success: true }; // Already ended
|
||||
}
|
||||
|
||||
try {
|
||||
await this.provider.hangupCall({
|
||||
callId,
|
||||
providerCallId: call.providerCallId,
|
||||
reason: "hangup-bot",
|
||||
});
|
||||
|
||||
call.state = "hangup-bot";
|
||||
call.endedAt = Date.now();
|
||||
call.endReason = "hangup-bot";
|
||||
this.persistCallRecord(call);
|
||||
this.clearMaxDurationTimer(callId);
|
||||
this.rejectTranscriptWaiter(callId, "Call ended: hangup-bot");
|
||||
this.activeCalls.delete(callId);
|
||||
if (call.providerCallId) {
|
||||
this.providerCallIdMap.delete(call.providerCallId);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an inbound call should be accepted based on policy.
|
||||
*/
|
||||
private shouldAcceptInbound(from: string | undefined): boolean {
|
||||
const { inboundPolicy: policy, allowFrom } = this.config;
|
||||
|
||||
switch (policy) {
|
||||
case "disabled":
|
||||
console.log("[voice-call] Inbound call rejected: policy is disabled");
|
||||
return false;
|
||||
|
||||
case "open":
|
||||
console.log("[voice-call] Inbound call accepted: policy is open");
|
||||
return true;
|
||||
|
||||
case "allowlist":
|
||||
case "pairing": {
|
||||
const normalized = from?.replace(/\D/g, "") || "";
|
||||
const allowed = (allowFrom || []).some((num) => {
|
||||
const normalizedAllow = num.replace(/\D/g, "");
|
||||
return (
|
||||
normalized.endsWith(normalizedAllow) ||
|
||||
normalizedAllow.endsWith(normalized)
|
||||
);
|
||||
});
|
||||
const status = allowed ? "accepted" : "rejected";
|
||||
console.log(
|
||||
`[voice-call] Inbound call ${status}: ${from} ${allowed ? "is in" : "not in"} allowlist`,
|
||||
);
|
||||
return allowed;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a call record for an inbound call.
|
||||
*/
|
||||
private createInboundCall(
|
||||
providerCallId: string,
|
||||
from: string,
|
||||
to: string,
|
||||
): CallRecord {
|
||||
const callId = crypto.randomUUID();
|
||||
|
||||
const callRecord: CallRecord = {
|
||||
callId,
|
||||
providerCallId,
|
||||
provider: this.provider?.name || "twilio",
|
||||
direction: "inbound",
|
||||
state: "ringing",
|
||||
from,
|
||||
to,
|
||||
startedAt: Date.now(),
|
||||
transcript: [],
|
||||
processedEventIds: [],
|
||||
metadata: {
|
||||
initialMessage:
|
||||
this.config.inboundGreeting || "Hello! How can I help you today?",
|
||||
},
|
||||
};
|
||||
|
||||
this.activeCalls.set(callId, callRecord);
|
||||
this.providerCallIdMap.set(providerCallId, callId); // Map providerCallId to internal callId
|
||||
this.persistCallRecord(callRecord);
|
||||
|
||||
console.log(
|
||||
`[voice-call] Created inbound call record: ${callId} from ${from}`,
|
||||
);
|
||||
return callRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a call by either internal callId or providerCallId.
|
||||
*/
|
||||
private findCall(callIdOrProviderCallId: string): CallRecord | undefined {
|
||||
// Try direct lookup by internal callId
|
||||
const directCall = this.activeCalls.get(callIdOrProviderCallId);
|
||||
if (directCall) return directCall;
|
||||
|
||||
// Try lookup by providerCallId
|
||||
return this.getCallByProviderCallId(callIdOrProviderCallId);
|
||||
return await endCall(this.buildContext(), callId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a webhook event.
|
||||
*/
|
||||
processEvent(event: NormalizedEvent): void {
|
||||
// Idempotency check
|
||||
if (this.processedEventIds.has(event.id)) {
|
||||
return;
|
||||
}
|
||||
this.processedEventIds.add(event.id);
|
||||
|
||||
let call = this.findCall(event.callId);
|
||||
|
||||
// Handle inbound calls - create record if it doesn't exist
|
||||
if (!call && event.direction === "inbound" && event.providerCallId) {
|
||||
// Check if we should accept this inbound call
|
||||
if (!this.shouldAcceptInbound(event.from)) {
|
||||
// TODO: Could hang up the call here
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new call record for this inbound call
|
||||
call = this.createInboundCall(
|
||||
event.providerCallId,
|
||||
event.from || "unknown",
|
||||
event.to || this.config.fromNumber || "unknown",
|
||||
);
|
||||
|
||||
// Update the event's callId to use our internal ID
|
||||
event.callId = call.callId;
|
||||
}
|
||||
|
||||
if (!call) {
|
||||
// Still no call record - ignore event
|
||||
return;
|
||||
}
|
||||
|
||||
// Update provider call ID if we got it
|
||||
if (event.providerCallId && !call.providerCallId) {
|
||||
call.providerCallId = event.providerCallId;
|
||||
}
|
||||
|
||||
// Track processed event
|
||||
call.processedEventIds.push(event.id);
|
||||
|
||||
// Process event based on type
|
||||
switch (event.type) {
|
||||
case "call.initiated":
|
||||
this.transitionState(call, "initiated");
|
||||
break;
|
||||
|
||||
case "call.ringing":
|
||||
this.transitionState(call, "ringing");
|
||||
break;
|
||||
|
||||
case "call.answered":
|
||||
call.answeredAt = event.timestamp;
|
||||
this.transitionState(call, "answered");
|
||||
// Start max duration timer when call is answered
|
||||
this.startMaxDurationTimer(call.callId);
|
||||
break;
|
||||
|
||||
case "call.active":
|
||||
this.transitionState(call, "active");
|
||||
break;
|
||||
|
||||
case "call.speaking":
|
||||
this.transitionState(call, "speaking");
|
||||
break;
|
||||
|
||||
case "call.speech":
|
||||
if (event.isFinal) {
|
||||
this.addTranscriptEntry(call, "user", event.transcript);
|
||||
this.resolveTranscriptWaiter(call.callId, event.transcript);
|
||||
}
|
||||
this.transitionState(call, "listening");
|
||||
break;
|
||||
|
||||
case "call.ended":
|
||||
call.endedAt = event.timestamp;
|
||||
call.endReason = event.reason;
|
||||
this.transitionState(call, event.reason as CallState);
|
||||
this.clearMaxDurationTimer(call.callId);
|
||||
this.rejectTranscriptWaiter(call.callId, `Call ended: ${event.reason}`);
|
||||
this.activeCalls.delete(call.callId);
|
||||
if (call.providerCallId) {
|
||||
this.providerCallIdMap.delete(call.providerCallId);
|
||||
}
|
||||
break;
|
||||
|
||||
case "call.error":
|
||||
if (!event.retryable) {
|
||||
call.endedAt = event.timestamp;
|
||||
call.endReason = "error";
|
||||
this.transitionState(call, "error");
|
||||
this.clearMaxDurationTimer(call.callId);
|
||||
this.rejectTranscriptWaiter(
|
||||
call.callId,
|
||||
`Call error: ${event.error}`,
|
||||
);
|
||||
this.activeCalls.delete(call.callId);
|
||||
if (call.providerCallId) {
|
||||
this.providerCallIdMap.delete(call.providerCallId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
this.persistCallRecord(call);
|
||||
processEvent(this.buildContext(), event);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -664,20 +161,11 @@ export class CallManager {
|
||||
* Get an active call by provider call ID (e.g., Twilio CallSid).
|
||||
*/
|
||||
getCallByProviderCallId(providerCallId: string): CallRecord | undefined {
|
||||
// Fast path: use the providerCallIdMap for O(1) lookup
|
||||
const callId = this.providerCallIdMap.get(providerCallId);
|
||||
if (callId) {
|
||||
return this.activeCalls.get(callId);
|
||||
}
|
||||
|
||||
// Fallback: linear search for cases where map wasn't populated
|
||||
// (e.g., providerCallId set directly on call record)
|
||||
for (const call of this.activeCalls.values()) {
|
||||
if (call.providerCallId === providerCallId) {
|
||||
return call;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
return getCallByProviderCallId({
|
||||
activeCalls: this.activeCalls,
|
||||
providerCallIdMap: this.providerCallIdMap,
|
||||
providerCallId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -691,156 +179,6 @@ export class CallManager {
|
||||
* Get call history (from persisted logs).
|
||||
*/
|
||||
async getCallHistory(limit = 50): Promise<CallRecord[]> {
|
||||
const logPath = path.join(this.storePath, "calls.jsonl");
|
||||
|
||||
try {
|
||||
await fsp.access(logPath);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const content = await fsp.readFile(logPath, "utf-8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
const calls: CallRecord[] = [];
|
||||
|
||||
// Parse last N lines
|
||||
for (const line of lines.slice(-limit)) {
|
||||
try {
|
||||
const parsed = CallRecordSchema.parse(JSON.parse(line));
|
||||
calls.push(parsed);
|
||||
} catch {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
|
||||
return calls;
|
||||
}
|
||||
|
||||
// States that can cycle during multi-turn conversations
|
||||
private static readonly ConversationStates = new Set<CallState>([
|
||||
"speaking",
|
||||
"listening",
|
||||
]);
|
||||
|
||||
// Non-terminal state order for monotonic transitions
|
||||
private static readonly StateOrder: readonly CallState[] = [
|
||||
"initiated",
|
||||
"ringing",
|
||||
"answered",
|
||||
"active",
|
||||
"speaking",
|
||||
"listening",
|
||||
];
|
||||
|
||||
/**
|
||||
* Transition call state with monotonic enforcement.
|
||||
*/
|
||||
private transitionState(call: CallRecord, newState: CallState): void {
|
||||
// No-op for same state or already terminal
|
||||
if (call.state === newState || TerminalStates.has(call.state)) return;
|
||||
|
||||
// Terminal states can always be reached from non-terminal
|
||||
if (TerminalStates.has(newState)) {
|
||||
call.state = newState;
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow cycling between speaking and listening (multi-turn conversations)
|
||||
if (
|
||||
CallManager.ConversationStates.has(call.state) &&
|
||||
CallManager.ConversationStates.has(newState)
|
||||
) {
|
||||
call.state = newState;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only allow forward transitions in state order
|
||||
const currentIndex = CallManager.StateOrder.indexOf(call.state);
|
||||
const newIndex = CallManager.StateOrder.indexOf(newState);
|
||||
|
||||
if (newIndex > currentIndex) {
|
||||
call.state = newState;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an entry to the call transcript.
|
||||
*/
|
||||
private addTranscriptEntry(
|
||||
call: CallRecord,
|
||||
speaker: "bot" | "user",
|
||||
text: string,
|
||||
): void {
|
||||
const entry: TranscriptEntry = {
|
||||
timestamp: Date.now(),
|
||||
speaker,
|
||||
text,
|
||||
isFinal: true,
|
||||
};
|
||||
call.transcript.push(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a call record to disk (fire-and-forget async).
|
||||
*/
|
||||
private persistCallRecord(call: CallRecord): void {
|
||||
const logPath = path.join(this.storePath, "calls.jsonl");
|
||||
const line = `${JSON.stringify(call)}\n`;
|
||||
// Fire-and-forget async write to avoid blocking event loop
|
||||
fsp.appendFile(logPath, line).catch((err) => {
|
||||
console.error("[voice-call] Failed to persist call record:", err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load active calls from persistence (for crash recovery).
|
||||
* Uses streaming to handle large log files efficiently.
|
||||
*/
|
||||
private loadActiveCalls(): void {
|
||||
const logPath = path.join(this.storePath, "calls.jsonl");
|
||||
if (!fs.existsSync(logPath)) return;
|
||||
|
||||
// Read file synchronously and parse lines
|
||||
const content = fs.readFileSync(logPath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
// Build map of latest state per call
|
||||
const callMap = new Map<CallId, CallRecord>();
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const call = CallRecordSchema.parse(JSON.parse(line));
|
||||
callMap.set(call.callId, call);
|
||||
} catch {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
|
||||
// Only keep non-terminal calls
|
||||
for (const [callId, call] of callMap) {
|
||||
if (!TerminalStates.has(call.state)) {
|
||||
this.activeCalls.set(callId, call);
|
||||
// Populate providerCallId mapping for lookups
|
||||
if (call.providerCallId) {
|
||||
this.providerCallIdMap.set(call.providerCallId, callId);
|
||||
}
|
||||
// Populate processed event IDs
|
||||
for (const eventId of call.processedEventIds) {
|
||||
this.processedEventIds.add(eventId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate TwiML for notify mode (speak message and hang up).
|
||||
*/
|
||||
private generateNotifyTwiml(message: string, voice: string): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say voice="${voice}">${escapeXml(message)}</Say>
|
||||
<Hangup/>
|
||||
</Response>`;
|
||||
return await getCallHistoryFromStore(this.storePath, limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { CallId, CallRecord } from "../types.js";
|
||||
import type { VoiceCallConfig } from "../config.js";
|
||||
import type { VoiceCallProvider } from "../providers/base.js";
|
||||
|
||||
export type TranscriptWaiter = {
|
||||
resolve: (text: string) => void;
|
||||
reject: (err: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
export type CallManagerContext = {
|
||||
activeCalls: Map<CallId, CallRecord>;
|
||||
providerCallIdMap: Map<string, CallId>;
|
||||
processedEventIds: Set<string>;
|
||||
provider: VoiceCallProvider | null;
|
||||
config: VoiceCallConfig;
|
||||
storePath: string;
|
||||
webhookUrl: string | null;
|
||||
transcriptWaiters: Map<CallId, TranscriptWaiter>;
|
||||
maxDurationTimers: Map<CallId, NodeJS.Timeout>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import type { CallId, CallRecord, CallState, NormalizedEvent } from "../types.js";
|
||||
import { TerminalStates } from "../types.js";
|
||||
import type { CallManagerContext } from "./context.js";
|
||||
import { findCall } from "./lookup.js";
|
||||
import { addTranscriptEntry, transitionState } from "./state.js";
|
||||
import { persistCallRecord } from "./store.js";
|
||||
import {
|
||||
clearMaxDurationTimer,
|
||||
rejectTranscriptWaiter,
|
||||
resolveTranscriptWaiter,
|
||||
startMaxDurationTimer,
|
||||
} from "./timers.js";
|
||||
import { endCall } from "./outbound.js";
|
||||
|
||||
function shouldAcceptInbound(config: CallManagerContext["config"], from: string | undefined): boolean {
|
||||
const { inboundPolicy: policy, allowFrom } = config;
|
||||
|
||||
switch (policy) {
|
||||
case "disabled":
|
||||
console.log("[voice-call] Inbound call rejected: policy is disabled");
|
||||
return false;
|
||||
|
||||
case "open":
|
||||
console.log("[voice-call] Inbound call accepted: policy is open");
|
||||
return true;
|
||||
|
||||
case "allowlist":
|
||||
case "pairing": {
|
||||
const normalized = from?.replace(/\D/g, "") || "";
|
||||
const allowed = (allowFrom || []).some((num) => {
|
||||
const normalizedAllow = num.replace(/\D/g, "");
|
||||
return normalized.endsWith(normalizedAllow) || normalizedAllow.endsWith(normalized);
|
||||
});
|
||||
const status = allowed ? "accepted" : "rejected";
|
||||
console.log(
|
||||
`[voice-call] Inbound call ${status}: ${from} ${allowed ? "is in" : "not in"} allowlist`,
|
||||
);
|
||||
return allowed;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createInboundCall(params: {
|
||||
ctx: CallManagerContext;
|
||||
providerCallId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}): CallRecord {
|
||||
const callId = crypto.randomUUID();
|
||||
|
||||
const callRecord: CallRecord = {
|
||||
callId,
|
||||
providerCallId: params.providerCallId,
|
||||
provider: params.ctx.provider?.name || "twilio",
|
||||
direction: "inbound",
|
||||
state: "ringing",
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
startedAt: Date.now(),
|
||||
transcript: [],
|
||||
processedEventIds: [],
|
||||
metadata: {
|
||||
initialMessage: params.ctx.config.inboundGreeting || "Hello! How can I help you today?",
|
||||
},
|
||||
};
|
||||
|
||||
params.ctx.activeCalls.set(callId, callRecord);
|
||||
params.ctx.providerCallIdMap.set(params.providerCallId, callId);
|
||||
persistCallRecord(params.ctx.storePath, callRecord);
|
||||
|
||||
console.log(`[voice-call] Created inbound call record: ${callId} from ${params.from}`);
|
||||
return callRecord;
|
||||
}
|
||||
|
||||
export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): void {
|
||||
if (ctx.processedEventIds.has(event.id)) return;
|
||||
ctx.processedEventIds.add(event.id);
|
||||
|
||||
let call = findCall({
|
||||
activeCalls: ctx.activeCalls,
|
||||
providerCallIdMap: ctx.providerCallIdMap,
|
||||
callIdOrProviderCallId: event.callId,
|
||||
});
|
||||
|
||||
if (!call && event.direction === "inbound" && event.providerCallId) {
|
||||
if (!shouldAcceptInbound(ctx.config, event.from)) {
|
||||
// TODO: Could hang up the call here.
|
||||
return;
|
||||
}
|
||||
|
||||
call = createInboundCall({
|
||||
ctx,
|
||||
providerCallId: event.providerCallId,
|
||||
from: event.from || "unknown",
|
||||
to: event.to || ctx.config.fromNumber || "unknown",
|
||||
});
|
||||
|
||||
// Normalize event to internal ID for downstream consumers.
|
||||
event.callId = call.callId;
|
||||
}
|
||||
|
||||
if (!call) return;
|
||||
|
||||
if (event.providerCallId && !call.providerCallId) {
|
||||
call.providerCallId = event.providerCallId;
|
||||
ctx.providerCallIdMap.set(event.providerCallId, call.callId);
|
||||
}
|
||||
|
||||
call.processedEventIds.push(event.id);
|
||||
|
||||
switch (event.type) {
|
||||
case "call.initiated":
|
||||
transitionState(call, "initiated");
|
||||
break;
|
||||
|
||||
case "call.ringing":
|
||||
transitionState(call, "ringing");
|
||||
break;
|
||||
|
||||
case "call.answered":
|
||||
call.answeredAt = event.timestamp;
|
||||
transitionState(call, "answered");
|
||||
startMaxDurationTimer({
|
||||
ctx,
|
||||
callId: call.callId,
|
||||
onTimeout: async (callId) => {
|
||||
await endCall(ctx, callId);
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
case "call.active":
|
||||
transitionState(call, "active");
|
||||
break;
|
||||
|
||||
case "call.speaking":
|
||||
transitionState(call, "speaking");
|
||||
break;
|
||||
|
||||
case "call.speech":
|
||||
if (event.isFinal) {
|
||||
addTranscriptEntry(call, "user", event.transcript);
|
||||
resolveTranscriptWaiter(ctx, call.callId, event.transcript);
|
||||
}
|
||||
transitionState(call, "listening");
|
||||
break;
|
||||
|
||||
case "call.ended":
|
||||
call.endedAt = event.timestamp;
|
||||
call.endReason = event.reason;
|
||||
transitionState(call, event.reason as CallState);
|
||||
clearMaxDurationTimer(ctx, call.callId);
|
||||
rejectTranscriptWaiter(ctx, call.callId, `Call ended: ${event.reason}`);
|
||||
ctx.activeCalls.delete(call.callId);
|
||||
if (call.providerCallId) ctx.providerCallIdMap.delete(call.providerCallId);
|
||||
break;
|
||||
|
||||
case "call.error":
|
||||
if (!event.retryable) {
|
||||
call.endedAt = event.timestamp;
|
||||
call.endReason = "error";
|
||||
transitionState(call, "error");
|
||||
clearMaxDurationTimer(ctx, call.callId);
|
||||
rejectTranscriptWaiter(ctx, call.callId, `Call error: ${event.error}`);
|
||||
ctx.activeCalls.delete(call.callId);
|
||||
if (call.providerCallId) ctx.providerCallIdMap.delete(call.providerCallId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
persistCallRecord(ctx.storePath, call);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { CallId, CallRecord } from "../types.js";
|
||||
|
||||
export function getCallByProviderCallId(params: {
|
||||
activeCalls: Map<CallId, CallRecord>;
|
||||
providerCallIdMap: Map<string, CallId>;
|
||||
providerCallId: string;
|
||||
}): CallRecord | undefined {
|
||||
const callId = params.providerCallIdMap.get(params.providerCallId);
|
||||
if (callId) {
|
||||
return params.activeCalls.get(callId);
|
||||
}
|
||||
|
||||
for (const call of params.activeCalls.values()) {
|
||||
if (call.providerCallId === params.providerCallId) {
|
||||
return call;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findCall(params: {
|
||||
activeCalls: Map<CallId, CallRecord>;
|
||||
providerCallIdMap: Map<string, CallId>;
|
||||
callIdOrProviderCallId: string;
|
||||
}): CallRecord | undefined {
|
||||
const directCall = params.activeCalls.get(params.callIdOrProviderCallId);
|
||||
if (directCall) return directCall;
|
||||
return getCallByProviderCallId({
|
||||
activeCalls: params.activeCalls,
|
||||
providerCallIdMap: params.providerCallIdMap,
|
||||
providerCallId: params.callIdOrProviderCallId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { TerminalStates, type CallId, type CallRecord, type OutboundCallOptions } from "../types.js";
|
||||
import type { CallMode } from "../config.js";
|
||||
import { mapVoiceToPolly } from "../voice-mapping.js";
|
||||
import type { CallManagerContext } from "./context.js";
|
||||
import { getCallByProviderCallId } from "./lookup.js";
|
||||
import { generateNotifyTwiml } from "./twiml.js";
|
||||
import { addTranscriptEntry, transitionState } from "./state.js";
|
||||
import { persistCallRecord } from "./store.js";
|
||||
import { clearMaxDurationTimer, clearTranscriptWaiter, rejectTranscriptWaiter, waitForFinalTranscript } from "./timers.js";
|
||||
|
||||
export async function initiateCall(
|
||||
ctx: CallManagerContext,
|
||||
to: string,
|
||||
sessionKey?: string,
|
||||
options?: OutboundCallOptions | string,
|
||||
): Promise<{ callId: CallId; success: boolean; error?: string }> {
|
||||
const opts: OutboundCallOptions =
|
||||
typeof options === "string" ? { message: options } : (options ?? {});
|
||||
const initialMessage = opts.message;
|
||||
const mode = opts.mode ?? ctx.config.outbound.defaultMode;
|
||||
|
||||
if (!ctx.provider) {
|
||||
return { callId: "", success: false, error: "Provider not initialized" };
|
||||
}
|
||||
if (!ctx.webhookUrl) {
|
||||
return { callId: "", success: false, error: "Webhook URL not configured" };
|
||||
}
|
||||
|
||||
if (ctx.activeCalls.size >= ctx.config.maxConcurrentCalls) {
|
||||
return {
|
||||
callId: "",
|
||||
success: false,
|
||||
error: `Maximum concurrent calls (${ctx.config.maxConcurrentCalls}) reached`,
|
||||
};
|
||||
}
|
||||
|
||||
const callId = crypto.randomUUID();
|
||||
const from =
|
||||
ctx.config.fromNumber ||
|
||||
(ctx.provider?.name === "mock" ? "+15550000000" : undefined);
|
||||
if (!from) {
|
||||
return { callId: "", success: false, error: "fromNumber not configured" };
|
||||
}
|
||||
|
||||
const callRecord: CallRecord = {
|
||||
callId,
|
||||
provider: ctx.provider.name,
|
||||
direction: "outbound",
|
||||
state: "initiated",
|
||||
from,
|
||||
to,
|
||||
sessionKey,
|
||||
startedAt: Date.now(),
|
||||
transcript: [],
|
||||
processedEventIds: [],
|
||||
metadata: {
|
||||
...(initialMessage && { initialMessage }),
|
||||
mode,
|
||||
},
|
||||
};
|
||||
|
||||
ctx.activeCalls.set(callId, callRecord);
|
||||
persistCallRecord(ctx.storePath, callRecord);
|
||||
|
||||
try {
|
||||
// For notify mode with a message, use inline TwiML with <Say>.
|
||||
let inlineTwiml: string | undefined;
|
||||
if (mode === "notify" && initialMessage) {
|
||||
const pollyVoice = mapVoiceToPolly(ctx.config.tts.voice);
|
||||
inlineTwiml = generateNotifyTwiml(initialMessage, pollyVoice);
|
||||
console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`);
|
||||
}
|
||||
|
||||
const result = await ctx.provider.initiateCall({
|
||||
callId,
|
||||
from,
|
||||
to,
|
||||
webhookUrl: ctx.webhookUrl,
|
||||
inlineTwiml,
|
||||
});
|
||||
|
||||
callRecord.providerCallId = result.providerCallId;
|
||||
ctx.providerCallIdMap.set(result.providerCallId, callId);
|
||||
persistCallRecord(ctx.storePath, callRecord);
|
||||
|
||||
return { callId, success: true };
|
||||
} catch (err) {
|
||||
callRecord.state = "failed";
|
||||
callRecord.endedAt = Date.now();
|
||||
callRecord.endReason = "failed";
|
||||
persistCallRecord(ctx.storePath, callRecord);
|
||||
ctx.activeCalls.delete(callId);
|
||||
if (callRecord.providerCallId) {
|
||||
ctx.providerCallIdMap.delete(callRecord.providerCallId);
|
||||
}
|
||||
|
||||
return {
|
||||
callId,
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function speak(
|
||||
ctx: CallManagerContext,
|
||||
callId: CallId,
|
||||
text: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const call = ctx.activeCalls.get(callId);
|
||||
if (!call) return { success: false, error: "Call not found" };
|
||||
if (!ctx.provider || !call.providerCallId) return { success: false, error: "Call not connected" };
|
||||
if (TerminalStates.has(call.state)) return { success: false, error: "Call has ended" };
|
||||
|
||||
try {
|
||||
transitionState(call, "speaking");
|
||||
persistCallRecord(ctx.storePath, call);
|
||||
|
||||
addTranscriptEntry(call, "bot", text);
|
||||
|
||||
await ctx.provider.playTts({
|
||||
callId,
|
||||
providerCallId: call.providerCallId,
|
||||
text,
|
||||
voice: ctx.config.tts.voice,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function speakInitialMessage(
|
||||
ctx: CallManagerContext,
|
||||
providerCallId: string,
|
||||
): Promise<void> {
|
||||
const call = getCallByProviderCallId({
|
||||
activeCalls: ctx.activeCalls,
|
||||
providerCallIdMap: ctx.providerCallIdMap,
|
||||
providerCallId,
|
||||
});
|
||||
if (!call) {
|
||||
console.warn(`[voice-call] speakInitialMessage: no call found for ${providerCallId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const initialMessage = call.metadata?.initialMessage as string | undefined;
|
||||
const mode = (call.metadata?.mode as CallMode) ?? "conversation";
|
||||
|
||||
if (!initialMessage) {
|
||||
console.log(`[voice-call] speakInitialMessage: no initial message for ${call.callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear so we don't speak it again if the provider reconnects.
|
||||
if (call.metadata) {
|
||||
delete call.metadata.initialMessage;
|
||||
persistCallRecord(ctx.storePath, call);
|
||||
}
|
||||
|
||||
console.log(`[voice-call] Speaking initial message for call ${call.callId} (mode: ${mode})`);
|
||||
const result = await speak(ctx, call.callId, initialMessage);
|
||||
if (!result.success) {
|
||||
console.warn(`[voice-call] Failed to speak initial message: ${result.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "notify") {
|
||||
const delaySec = ctx.config.outbound.notifyHangupDelaySec;
|
||||
console.log(`[voice-call] Notify mode: auto-hangup in ${delaySec}s for call ${call.callId}`);
|
||||
setTimeout(async () => {
|
||||
const currentCall = ctx.activeCalls.get(call.callId);
|
||||
if (currentCall && !TerminalStates.has(currentCall.state)) {
|
||||
console.log(`[voice-call] Notify mode: hanging up call ${call.callId}`);
|
||||
await endCall(ctx, call.callId);
|
||||
}
|
||||
}, delaySec * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
export async function continueCall(
|
||||
ctx: CallManagerContext,
|
||||
callId: CallId,
|
||||
prompt: string,
|
||||
): Promise<{ success: boolean; transcript?: string; error?: string }> {
|
||||
const call = ctx.activeCalls.get(callId);
|
||||
if (!call) return { success: false, error: "Call not found" };
|
||||
if (!ctx.provider || !call.providerCallId) return { success: false, error: "Call not connected" };
|
||||
if (TerminalStates.has(call.state)) return { success: false, error: "Call has ended" };
|
||||
|
||||
try {
|
||||
await speak(ctx, callId, prompt);
|
||||
|
||||
transitionState(call, "listening");
|
||||
persistCallRecord(ctx.storePath, call);
|
||||
|
||||
await ctx.provider.startListening({ callId, providerCallId: call.providerCallId });
|
||||
|
||||
const transcript = await waitForFinalTranscript(ctx, callId);
|
||||
|
||||
// Best-effort: stop listening after final transcript.
|
||||
await ctx.provider.stopListening({ callId, providerCallId: call.providerCallId });
|
||||
|
||||
return { success: true, transcript };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
} finally {
|
||||
clearTranscriptWaiter(ctx, callId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function endCall(
|
||||
ctx: CallManagerContext,
|
||||
callId: CallId,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const call = ctx.activeCalls.get(callId);
|
||||
if (!call) return { success: false, error: "Call not found" };
|
||||
if (!ctx.provider || !call.providerCallId) return { success: false, error: "Call not connected" };
|
||||
if (TerminalStates.has(call.state)) return { success: true };
|
||||
|
||||
try {
|
||||
await ctx.provider.hangupCall({
|
||||
callId,
|
||||
providerCallId: call.providerCallId,
|
||||
reason: "hangup-bot",
|
||||
});
|
||||
|
||||
call.state = "hangup-bot";
|
||||
call.endedAt = Date.now();
|
||||
call.endReason = "hangup-bot";
|
||||
persistCallRecord(ctx.storePath, call);
|
||||
|
||||
clearMaxDurationTimer(ctx, callId);
|
||||
rejectTranscriptWaiter(ctx, callId, "Call ended: hangup-bot");
|
||||
|
||||
ctx.activeCalls.delete(callId);
|
||||
if (call.providerCallId) ctx.providerCallIdMap.delete(call.providerCallId);
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 1933 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user