Compare commits

..
Author SHA1 Message Date
Peter Steinberger d0a79c19f9 fix: use gpt-5-nano in tests (#444) (thanks @grp06) 2026-01-13 07:05:23 +00:00
Peter Steinberger cb0f6cefa4 Deps: update Pi + Vitest and add Bedrock docs 2026-01-13 06:57:11 +00:00
Peter Steinberger 25ef01b74a docs: add FAQ table of contents 2026-01-13 06:55:00 +00:00
Peter Steinberger 6db0201fcd Merge pull request #583 from mitschabaude-bot/feat/agent-model-fallbacks
Config: per-agent model fallbacks
2026-01-13 06:54:00 +00:00
Peter Steinberger 40e508823f Merge pull request #444 from grp06/feature/xhigh-thinking-models
Thinking: gate xhigh by model
2026-01-13 06:51:39 +00:00
Peter Steinberger 3368284b2a fix: per-agent model fallbacks (#583) (thanks @mitschabaude-bot) 2026-01-13 06:50:41 +00:00
Gregor's Bot ece01d89fe Docs: document agents.list model fallbacks 2026-01-13 06:50:20 +00:00
Gregor's Bot 9c0c4f50ec Agents: test per-agent model fallbacks override 2026-01-13 06:50:20 +00:00
Gregor's Bot 6729637f61 Config: support per-agent model fallbacks 2026-01-13 06:50:20 +00:00
Peter Steinberger 18d22aa426 fix: gate xhigh by model (#444) (thanks @grp06) 2026-01-13 06:48:41 +00:00
George Pickett a3641526ab Thinking: gate xhigh by model 2026-01-13 06:48:26 +00:00
Peter Steinberger f50e06a1b6 test(tools): cover tool policy helpers 2026-01-13 06:32:59 +00:00
Peter Steinberger 2ae3b45ac1 docs: update changelog for tool schema and profiles 2026-01-13 06:31:12 +00:00
Peter Steinberger 780a43711f feat(tools): add tool profiles and group shorthands 2026-01-13 06:30:20 +00:00
Peter Steinberger d682b604de fix(tools): harden tool schemas for strict providers 2026-01-13 06:30:20 +00:00
Peter Steinberger fa75d84b75 docs: clarify clawdhub workdir defaults 2026-01-13 06:05:06 +00:00
Peter Steinberger bb2df13be0 Merge pull request #700 from clawdbot/shadow/compaction
Agents: safeguard compaction summarization
2026-01-13 05:59:15 +00:00
Peter Steinberger 5918def440 fix: honor gateway service override labels 2026-01-13 05:58:49 +00:00
Peter Steinberger 1fdd3592d3 fix: tune compaction safeguard schema (#700) (thanks @thewilloftheshadow) 2026-01-13 05:58:35 +00:00
Shadow a96d299971 Agents: safeguard compaction summarization 2026-01-13 05:55:30 +00:00
Peter Steinberger 42ff634a9d docs: add model routing faq 2026-01-13 05:47:57 +00:00
Peter Steinberger b45d7c3256 docs: add custom skills folder faq 2026-01-13 05:35:32 +00:00
Peter Steinberger 77ca508274 docs: clarify dashboard token auth 2026-01-13 05:31:26 +00:00
Peter Steinberger 61b7398cb7 refactor: centralize auth-choice model defaults 2026-01-13 05:25:16 +00:00
Peter Steinberger 0321d5ed74 Merge pull request #740 from jeffersonwarrior/main
feat: add Tailscale binary detection, custom gateway IP binding, and health probe auth fix
2026-01-13 05:22:48 +00:00
Peter Steinberger 78627ce7c2 fix: tighten custom bind probing (#740) (thanks @jeffersonwarrior) 2026-01-13 05:21:59 +00:00
Jefferson Warrior c851bdd47a feat: add Tailscale binary detection, IP binding modes, and health probe password fix
This PR includes three main improvements:

1. Tailscale Binary Detection with Fallback Strategies
   - Added findTailscaleBinary() with multi-strategy detection:
     * PATH lookup via 'which' command
     * Known macOS app path (/Applications/Tailscale.app/Contents/MacOS/Tailscale)
     * find /Applications for Tailscale.app
     * locate database lookup
   - Added getTailscaleBinary() with caching
   - Updated all Tailscale operations to use detected binary
   - Added TUI warning when Tailscale binary not found for serve/funnel modes

2. Custom Gateway IP Binding with Fallback
   - New bind mode "custom" allowing user-specified IP with fallback to 0.0.0.0
   - Removed "tailnet" mode (folded into "auto")
   - All modes now support graceful fallback: custom (if fail → 0.0.0.0), loopback (127.0.0.1 → 0.0.0.0), auto (tailnet → 0.0.0.0), lan (0.0.0.0)
   - Added customBindHost config option for custom bind mode
   - Added canBindTo() helper to test IP availability before binding
   - Updated configure and onboarding wizards with new bind mode options

3. Health Probe Password Auth Fix
   - Gateway probe now tries both new and old passwords
   - Fixes issue where password change fails health check if gateway hasn't restarted yet
   - Uses nextConfig password first, falls back to baseConfig password if needed

Files changed:
- src/infra/tailscale.ts: Binary detection + caching
- src/gateway/net.ts: IP binding with fallback logic
- src/config/types.ts: BridgeBindMode type + customBindHost field
- src/commands/configure.ts: Health probe dual-password try + Tailscale detection warning + bind mode UI
- src/wizard/onboarding.ts: Tailscale detection warning + bind mode UI
- src/gateway/server.ts: Use new resolveGatewayBindHost
- src/gateway/call.ts: Updated preferTailnet logic (removed "tailnet" mode)
- src/commands/onboard-types.ts: Updated GatewayBind type
- src/commands/onboard-helpers.ts: resolveControlUiLinks updated
- src/cli/*.ts: Updated bind mode casts
- src/gateway/call.test.ts: Removed "tailnet" mode test
2026-01-13 05:20:02 +00:00
Peter Steinberger 9ec0016258 chore: regenerate protocol models 2026-01-13 05:18:07 +00:00
Peter Steinberger 01776e0569 refactor: dedupe OAuth flow handlers 2026-01-13 05:14:05 +00:00
Peter Steinberger d8f14078f0 refactor: simplify cron job editor payloads 2026-01-13 05:13:49 +00:00
Peter Steinberger 38244b8e94 test: cover cron delete-after-run in macos 2026-01-13 05:12:48 +00:00
Peter Steinberger 9308762d0b style: swiftformat macos swift files 2026-01-13 05:12:48 +00:00
Peter Steinberger 8eb1c76337 fix: clean up api key validation 2026-01-13 05:12:42 +00:00
Peter Steinberger f94ad21f1e style: format cron cli flags 2026-01-13 05:05:39 +00:00
Peter Steinberger 8d640ccc68 Merge pull request #726 from FrieSei/feature/chutes-oauth
Auth: add Chutes OAuth
2026-01-13 05:02:25 +00:00
Peter Steinberger f566e6451f fix: harden Chutes OAuth flow (#726) (thanks @FrieSei) 2026-01-13 05:01:08 +00:00
Peter Steinberger 75a7855223 feat: cron ISO at + delete-after-run 2026-01-13 04:55:48 +00:00
Peter Steinberger 8f105288d2 Merge pull request #783 from ananth-vardhan-cn/fix/756-gemini-cli-id-field
fix(providers): strip unsupported id field from google-gemini-cli function calls
2026-01-13 04:51:50 +00:00
Peter Steinberger 696a6ec4d4 fix: strip gemini-cli tool ids (#783) (thanks @ananth-vardhan-cn) 2026-01-13 04:51:05 +00:00
Friederike Seiler 3271ff1d6e Tests: clean chutes fetch spies 2026-01-13 04:50:27 +00:00
Friederike Seiler 0efcfc0864 Chores: fix chutes oauth build 2026-01-13 04:50:27 +00:00
Friederike Seiler 0aba911912 Chores: format chutes oauth 2026-01-13 04:50:26 +00:00
Friederike Seiler 4efb5cc18e Auth: add Chutes OAuth 2026-01-13 04:50:26 +00:00
Peter Steinberger 57db3f22a1 fix: clean lint in auth-choice + tests 2026-01-13 04:49:04 +00:00
Peter Steinberger 9b44c80b30 docs: add X showcases 2026-01-13 04:41:01 +00:00
Peter Steinberger 7c7f4d0eb7 chore(discord): restore gateway log context 2026-01-13 04:40:22 +00:00
Peter Steinberger ccc24e2c26 chore(logging): strip redundant provider tags 2026-01-13 04:40:22 +00:00
Peter Steinberger 62bdbe1821 chore(logging): strip redundant console prefixes 2026-01-13 04:40:22 +00:00
Peter Steinberger 58d1d11762 chore(discord): trim gateway log prefixes 2026-01-13 04:40:22 +00:00
Peter Steinberger 8a9096cd52 Merge pull request #823 from roshanasingh4/fix/820-tailscale-allow-token
Fix allowTailscale bypass for token auth in Serve mode
2026-01-13 04:39:55 +00:00
Peter Steinberger b70298fbca fix: document Tailscale Serve auth headers (#823) (thanks @roshanasingh4) 2026-01-13 04:37:04 +00:00
Roshan Singh 7616b02bb1 Fix tailscale allowTailscale bypass in token mode 2026-01-13 04:34:28 +00:00
Peter Steinberger d4c205f8e1 fix: start typing on message start 2026-01-13 04:33:24 +00:00
Peter Steinberger 0ba60ff69c Merge pull request #794 from roshanasingh4/fix/777-windows-openurl-quotes
Fix Antigravity OAuth login on Windows (quote URL for cmd start)
2026-01-13 04:28:11 +00:00
Peter Steinberger 755a7e1b20 feat: add configurable bootstrap truncation 2026-01-13 04:27:03 +00:00
Peter Steinberger 3061d8e057 fix: preserve Windows cmd start URL quoting (#794) (thanks @roshanasingh4) 2026-01-13 04:26:43 +00:00
Peter Steinberger ea5597b483 fix: restore implicit providers + copilot auth choice 2026-01-13 04:26:08 +00:00
Peter Steinberger b41e75a15d feat: cron agent binding + doctor UI refresh 2026-01-13 04:25:41 +00:00
meaningfool bfdbaa5ab6 feat(doctor): add UI protocol freshness check 2026-01-13 04:25:41 +00:00
meaningfool 93ae3b8405 fix: sync protocol artifacts and resolve linting errors 2026-01-13 04:25:41 +00:00
meaningfool f249a82383 fix: resolve CI failures (test timeout & formatting) 2026-01-13 04:25:41 +00:00
Roshan Singh ea9486ae2d Fix: quote URLs when opening browser on Windows 2026-01-13 04:23:20 +00:00
Shadow da95b58a2a Typing: keep indicators active during tool runs
Closes #450
Closes #447
2026-01-12 22:20:29 -06:00
Shadow e15d5d0533 Cron: persist enabled=false patches
Closes #205
2026-01-12 22:16:17 -06:00
Peter Steinberger 1cf45f8439 Merge pull request #805 from marcmarg/fix/strip-thought-signatures
fix: strip thought_signature fields for cross-provider compatibility
2026-01-13 04:14:45 +00:00
Peter Steinberger 2a9ef806a0 fix: strip only msg_* thought_signature (#805) (thanks @marcmarg) 2026-01-13 04:13:24 +00:00
Peter Steinberger 32115a8b98 test: expand auth fallback coverage 2026-01-13 04:12:16 +00:00
Shadow 7ce902b096 Control UI: preserve chat scroll when scrolled up
Closes #217
2026-01-12 22:11:43 -06:00
MarcandClaude Opus 4.5 c4e8b60d2c fix: strip thought_signature fields for cross-provider compatibility
Claude's extended thinking feature generates thought_signature fields
(message IDs like "msg_abc123...") in content blocks. When these are
sent to Google's Gemini API, it expects Base64-encoded bytes and
rejects Claude's format with a 400 error.

This commit adds stripThoughtSignatures() to remove these fields from
assistant message content blocks during sanitization, enabling session
histories to be shared across different providers (e.g., Claude → Gemini).

Fixes cross-provider session bug where switching from Claude-thinking
to Gemini (or vice versa) would fail with:
"Invalid value at 'thought_signature' (TYPE_BYTES), Base64 decoding failed"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 04:11:02 +00:00
Shadow c9fdd68232 Telegram: keep forum topic thread ids in replies
Closes #727
2026-01-12 22:07:28 -06:00
Peter Steinberger 50260fd385 fix: sync protocol outputs 2026-01-13 04:04:22 +00:00
Peter Steinberger 0822f5610a Merge pull request #822 from clawdbot/fix/credential-fallback-761
fix: treat credential validation failures as auth errors for fallback
2026-01-13 04:03:02 +00:00
Peter Steinberger 2c2ca7f03b fix: treat credential validation errors as auth errors (#822) (thanks @sebslight) 2026-01-13 04:02:47 +00:00
Sebastian c4014c0092 fix: treat credential validation failures as auth errors for fallback (#761) 2026-01-12 22:53:21 -05:00
Shadow c08441c42c Telegram: persist polling update offsets
Closes #739
2026-01-12 21:52:20 -06:00
Peter Steinberger 980f274fc9 fix: stabilize docs and tests after system event timestamps 2026-01-13 03:51:34 +00:00
Shadow 9f1f65f0e3 Discord: dedupe listener registration on reload
Closes #744
2026-01-12 21:41:59 -06:00
Peter Steinberger cddd836909 Merge pull request #821 from gumadeiras/fix-bindings-telegram-webhook
Telegram: fix webhook multi-account routing (respect bindings.accountId)
2026-01-13 03:41:17 +00:00
Peter Steinberger bab7eeaf91 fix: respect telegram webhook bindings (#821) (thanks @gumadeiras) 2026-01-13 03:40:30 +00:00
Peter Steinberger d45915d39f fix: refine group intro prompt guidance 2026-01-13 03:40:27 +00:00
Shadow fcc814accd System events: add local timestamps in prompt injection
Closes #245
2026-01-12 21:38:56 -06:00
Peter Steinberger 78a3d965e0 chore: update clawtributors 2026-01-13 03:38:11 +00:00
Peter Steinberger 66ad8a9289 fix: apply lint fixes 2026-01-13 03:36:53 +00:00
Peter Steinberger df6634727e fix: refine synthetic provider + minimax probes 2026-01-13 03:36:53 +00:00
Travis Hinton 8b5cd97ceb Add Synthetic provider support 2026-01-13 03:36:53 +00:00
Shadow 25297ce3f5 Cron: accept jobId in gateway cron params
Closes #252
2026-01-12 21:35:43 -06:00
Gustavo Madeira Santana 3800fea962 Improve webhook test config verification
Refactors tests to use a shared config object and adds stricter assertions to verify that the config is passed correctly to createTelegramBot. Ensures the bindings property is checked in the test expectations.
2026-01-13 03:34:32 +00:00
Gustavo Madeira Santana ecb91bbb1a Add accountId and config support to Telegram webhook
The Telegram webhook and monitor now accept and pass through accountId and config parameters, enabling routing and configuration per Telegram account. Tests have been updated to verify correct bot instantiation and DM routing based on accountId bindings.
2026-01-13 03:34:32 +00:00
Shadow ab993904d7 Models: normalize Gemini 3 ids in runtime selection
Closes #795
2026-01-12 21:32:53 -06:00
Shadow 2467a103b2 TUI: keep streamed text when final output is empty
Closes #747
2026-01-12 21:29:15 -06:00
Shadow 68569afb4b Slack: accept slash command names with or without leading slash
Closes #798
2026-01-12 21:27:04 -06:00
Peter Steinberger 67d0ab3030 docs: simplify local models guidance 2026-01-13 03:26:00 +00:00
Peter Steinberger 4024bca55d docs: add custom memory endpoint example 2026-01-13 03:21:59 +00:00
Peter Steinbergerandmickahouan b928b96af5 fix: sync Moonshot Kimi K2 models (#818) (thanks @mickahouan)
Co-authored-by: mickahouan <mickahouan@users.noreply.github.com>
2026-01-13 03:19:49 +00:00
Peter Steinberger cb871a3fc1 Merge pull request #819 from mukhtharcm/feature/memory-search-custom-endpoint
feat(memory): support custom OpenAI-compatible embedding endpoints
2026-01-13 03:11:21 +00:00
Peter Steinberger da0a062fa7 fix: memory search remote overrides (#819) (thanks @mukhtharcm) 2026-01-13 03:11:03 +00:00
Muhammed Mukhthar CM ba316a10cc feat: add remote config overrides to memorySearch 2026-01-13 03:02:43 +00:00
Peter Steinberger 4086408b10 docs: add Microsoft Teams to README 2026-01-13 02:42:16 +00:00
Peter Steinberger c1f82d9ec1 refactor: dedupe enforceFinalTag resolution 2026-01-13 02:38:07 +00:00
Peter Steinberger eb6cace60f docs: update README providers and stars 2026-01-13 02:34:54 +00:00
Peter Steinberger 00f4331343 Merge pull request #796 from gabriel-trigo/bugfix/ai-snapshot-limit
Fix AI snapshot size overflow
2026-01-13 02:33:53 +00:00
Peter Steinberger cb469ecf45 Merge pull request #818 from mickahouan/chore-pre-update-2026-01-12
feat: add Kimi K2 variants to Moonshot preset
2026-01-13 02:33:48 +00:00
Peter Steinberger 46a694bbc7 fix: preserve explicit maxChars=0 (#796) (thanks @gabriel-trigo) 2026-01-13 02:33:38 +00:00
Peter SteinbergerandThomsenDrake 56c406b19e chore: credit @ThomsenDrake
Co-authored-by: ThomsenDrake <ThomsenDrake@users.noreply.github.com>
2026-01-13 02:33:38 +00:00
Gabriel Trigo 79a6506593 fix(browser): limit ai snapshot size
test(browser): cover ai snapshot limit
2026-01-13 02:33:38 +00:00
Peter Steinberger 99a6fcf3f7 fix: add Kimi K2 variants to Moonshot preset (#818) (thanks @mickahouan) 2026-01-13 02:32:29 +00:00
Mickaël Ahouansou abe9440096 feat: add Kimi K2 variants to Moonshot preset 2026-01-13 02:26:43 +00:00
Peter Steinberger 542c8020ec docs: clarify minimax lightning routing 2026-01-13 02:21:24 +00:00
Peter Steinberger 8edf2146ae fix: cleanup stale resume cli processes 2026-01-13 02:21:20 +00:00
Peter Steinberger 3c8d0083cb docs: note gh issue newline handling 2026-01-13 02:13:51 +00:00
Peter Steinberger 28248f9602 docs: add repo link to AGENTS 2026-01-13 02:09:03 +00:00
Peter Steinberger d225c4a7d1 docs: add new showcase cards 2026-01-13 02:08:51 +00:00
Peter Steinberger e05a8477b9 Merge pull request #816 from clawdbot/style/account-cards-polish
style: polish multi-account cards
2026-01-13 02:07:30 +00:00
Peter Steinberger 357a6e340a docs: update changelog for account card polish (#816) (thanks @steipete) 2026-01-13 02:07:09 +00:00
Peter Steinberger a87d37f26d style: polish multi-account cards 2026-01-13 02:05:00 +00:00
Peter Steinberger 958a4fd414 Merge pull request #782 from AbhisekBasu1/fix/antigravity-opus-tools-not-working
Fix - Opus on Antigravity Errors
2026-01-13 01:59:52 +00:00
Peter SteinbergerandAbhi a27efd57bd fix: drop null-only union variants (#782) (thanks @AbhisekBasu1)
Co-authored-by: Abhi <AbhisekBasu1@users.noreply.github.com>
2026-01-13 01:58:30 +00:00
Peter Steinberger d57db17300 docs: clarify semantic memory key requirements 2026-01-13 01:53:40 +00:00
Peter Steinberger 4f1c6e76fd fix: gate inline /status stripping 2026-01-13 01:53:40 +00:00
Peter Steinberger 2111d0c653 test: force real config module for lan onboarding test (#766) 2026-01-13 01:53:40 +00:00
Peter Steinberger 642e6acf49 test: unmock config for lan onboarding auto-token (#766) 2026-01-13 01:53:40 +00:00
Peter Steinberger 88716d8d2a fix: harden inline /status stripping (#766) 2026-01-13 01:53:40 +00:00
Peter Steinberger c2e37c78ff fix: trim sender ids before auth fallback 2026-01-13 01:53:40 +00:00
Peter Steinberger 5a2688c7b5 Merge pull request #813 from dbhurley/feat/multi-account-ui-clean
feat(ui): display per-account status for multi-account Telegram
2026-01-13 01:52:56 +00:00
Abhi ba1d80bd00 formatting fix 2026-01-13 01:48:56 +00:00
Abhi 4dfcd56893 Fix pi-tools test ordering and clean-for-gemini handling - which fixes the 400 error people are experiencing trying to use antigravity on opus 2026-01-13 01:48:34 +00:00
DB Hurley 95ed49ce9a feat(ui): display per-account status for multi-account Telegram
When multiple Telegram accounts are configured, the Connections UI now
displays individual status cards for each account showing:
- Bot username and account ID
- Running/configured status
- Last inbound message time
- Per-account errors

Falls back to the existing summary view for single-account configs.
No changes needed to types (already added upstream).
2026-01-12 20:03:06 -05:00
244 changed files with 10049 additions and 1964 deletions

No files matched your search

+3
View File
@@ -1,4 +1,6 @@
# Repository Guidelines
- Repo: https://github.com/clawdbot/clawdbot
- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
## Project Structure & Module Organization
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
@@ -98,6 +100,7 @@
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
+52 -1
View File
@@ -1,17 +1,64 @@
# Changelog
## 2026.1.13-2
### Changes
- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai.
- Testing: update Vitest + browser-playwright to 4.0.17.
- Docs: add Amazon Bedrock provider notes and link from models/FAQ.
## 2026.1.13-1
### Changes
- Cron: accept ISO timestamps for one-shot schedules (UTC) and allow optional delete-after-run; wired into CLI + macOS editor.
- Gateway: add Tailscale binary discovery, custom bind mode, and probe auth retry for password changes. (#740 — thanks @jeffersonwarrior)
- Agents: add compaction mode config with optional safeguard summarization for long histories. (#700 — thanks @thewilloftheshadow)
- Agents: support per-agent model fallbacks via `agents.list[].model`. (#583 — thanks @mitschabaude-bot)
- Tools: add tool profiles plus group shorthands for tool policy allow/deny (global, per-agent, sandbox).
- Thinking: allow xhigh for GPT-5.2 + Codex models and downgrade on unsupported switches. (#444 — thanks @grp06)
### Fixes
- Gateway: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides when checking or restarting the daemon.
- Tools: harden tool input schemas for strict providers (no unions; flattened actions; enum helpers + tests).
- Discord: allow clearing channel parents via message tool without nullable schema inputs.
## 2026.1.12-4
### Changes
- Models/Moonshot: add Kimi K2 0905 + turbo/thinking variants to the preset + docs. (#818 — thanks @mickahouan)
- Memory: allow custom OpenAI-compatible embedding endpoints for memory search (remote baseUrl/apiKey/headers). (#819 — thanks @mukhtharcm)
- Auth: add Chutes OAuth (PKCE + refresh + onboarding choice). (#726 — thanks @FrieSei)
- Agents: make workspace bootstrap truncation configurable (default 20k) and warn when files are truncated.
### Fixes
- Typing: keep typing indicators alive during tool execution. (#450, #447 — thanks @thewilloftheshadow)
- Cron: coerce enabled patches so disabling jobs persists correctly. (#205 — thanks @thewilloftheshadow)
- Control UI: keep chat scroll position unless user is near the bottom. (#217 — thanks @thewilloftheshadow)
- Fallback: treat credential validation failures ("no credentials found", "no API key found") as auth errors that trigger model fallback. (#822 — thanks @sebslight)
- Telegram: preserve forum topic thread ids, including General topic replies. (#727 — thanks @thewilloftheshadow)
- Telegram: persist polling update offsets across restarts to avoid duplicate updates. (#739 — thanks @thewilloftheshadow)
- Discord: avoid duplicate message/reaction listeners on monitor reloads. (#744 — thanks @thewilloftheshadow)
- System events: include local timestamps when events are injected into prompts. (#245 — thanks @thewilloftheshadow)
- Cron: accept `jobId` aliases for cron update/run/remove params in gateway validation. (#252 — thanks @thewilloftheshadow)
- Models/Google: normalize Gemini 3 model ids to preview variants before runtime selection. (#795 — thanks @thewilloftheshadow)
- Models/Google: strip Gemini CLI tool call/response ids in patched provider handling. (#783 — thanks @ananth-vardhan-cn)
- TUI: keep the last streamed response instead of replacing it with "(no output)". (#747 — thanks @thewilloftheshadow)
- Slack: accept slash commands with or without leading `/` for custom command configs. (#798 — thanks @thewilloftheshadow)
- Onboarding/Configure: refuse to proceed with invalid configs; run `clawdbot doctor` first to avoid wiping custom fields. (#764 — thanks @mukhtharcm)
- Onboarding: quote Windows browser URLs when launching via `cmd start` to preserve OAuth query params. (#794 — thanks @roshanasingh4)
- Gateway/Auth: allow Tailscale Serve identity headers to satisfy token auth when `allowTailscale` is enabled. (#823 — thanks @roshanasingh4)
- Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid “Incorrect role information” errors. (#804 — thanks @ThomsenDrake)
- Connections UI: show account counts for multi-account providers and keep Telegram per-account cards. (#813 — thanks @dbhurley)
- Discord/Slack: centralize reply-thread planning so auto-thread replies stay in the created thread without parent reply refs.
- Telegram: respect account-scoped bindings when webhook mode is enabled. (#821 — thanks @gumadeiras)
- Update: run `clawdbot doctor --non-interactive` during updates to avoid TTY hangs. (#781 — thanks @ronyrus)
- Browser tools: treat explicit `maxChars: 0` as unlimited while keeping the default limit only when omitted. (#796 — thanks @gabriel-trigo)
- Tools: allow Claude/Gemini tool param aliases (`file_path`, `old_string`, `new_string`) while enforcing required params at runtime. (#793 — thanks @hsrvc)
- Gemini: downgrade tool-call history missing `thought_signature` to avoid INVALID_ARGUMENT errors. (#793 — thanks @hsrvc)
- Messaging: enforce context isolation for message tool sends across providers (normalized targets + tests). (#793 — thanks @hsrvc)
- Auto-reply: re-evaluate reasoning tag enforcement on fallback providers to prevent leaked reasoning. (#810 — thanks @mcinteerj)
- Tools/Gemini: drop null-only union variants while cleaning tool schemas to avoid Cloud Code Assist schema errors. (#782 — thanks @AbhisekBasu1)
- Connections UI: polish multi-account account cards in the Connections view. (#816 — thanks @steipete)
- Gemini: strip Claude `msg_*` thought_signature fields from session history to avoid base64 decode errors. (#805 — thanks @marcmarg)
## 2026.1.12-3
@@ -52,10 +99,13 @@
- Memory: embedding providers support OpenAI or local `node-llama-cpp`; config adds defaults + per-agent overrides, provider/fallback metadata surfaced in tools/CLI.
- CLI/Tools: new `clawdbot memory` commands plus `memory_search`/`memory_get` tools returning snippets + line ranges and provider info.
- Runtime: memory index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default; inline status replies now stay auth-gated while inline prompts continue to the agent.
- Doctor: rebuild Control UI assets when protocol schema is newer to avoid stale UI connect errors. (#786) — thanks @meaningfool.
- Cron: allow jobs to target a specific agent and expose agent selection in the macOS app + Control UI.
- Discord: add `discord.allowBots` to permit bot-authored messages (still ignores its own messages) with docs warning about bot loops. (#802) — thanks @zknicker.
- CLI/Onboarding: `clawdbot dashboard` prints/copies the tokenized Control UI link and opens it; onboarding now auto-opens the dashboard with your token and keeps the link in the summary.
- Commands: native slash commands now default to `"auto"` (on for Discord/Telegram, off for Slack) with per-provider overrides (`discord/telegram/slack.commands.native`) and docs updated.
- Sandbox: allow Docker bind mounts via `docker.binds`; merges global + per-agent binds (per-agent ignored under shared scope) for custom host paths. (#790 — thanks @akonyer)
- Models: add Synthetic provider (Anthropic-compatible) and trim legacy MiniMax M2 from default catalogs. (#811 — thanks @siraht)
### Fixes
- Auto-reply: inline `/status` now honors allowlists (authorized stripped + replied inline; unauthorized leaves text for the agent) to match command gating tests.
@@ -144,6 +194,7 @@
- Auto-reply: align `/think` default display with model reasoning defaults. (#751) — thanks @gabriel-trigo.
- Auto-reply: flush block reply buffers on tool boundaries. (#750) — thanks @sebslight.
- Auto-reply: allow sender fallback for command authorization when `SenderId` is empty (WhatsApp self-chat). (#755) — thanks @juanpablodlc.
- Auto-reply: treat whitespace-only sender ids as missing for command authorization (WhatsApp self-chat). (#766) — thanks @steipete.
- Heartbeat: refresh prompt text for updated defaults.
- Agents/Tools: use PowerShell on Windows to capture system utility output. (#748) — thanks @myfunc.
- Docker: tolerate unset optional env vars in docker-setup.sh under strict mode. (#725) — thanks @petradonka.
+30 -21
View File
@@ -16,7 +16,7 @@
</p>
**Clawdbot** is a *personal AI assistant* you run on your own devices.
It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
@@ -64,7 +64,7 @@ clawdbot gateway --port 18789 --verbose
# Send a message
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord/Microsoft Teams)
clawdbot agent --message "Ship checklist" --thinking high
```
@@ -96,7 +96,7 @@ Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted i
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
Default behavior on Telegram/WhatsApp/Signal/iMessage/Discord/Slack:
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Slack:
- **DM pairing** (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
- Approve with: `clawdbot pairing approve <provider> <code>` (then the sender is added to a local allowlist store).
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the provider allowlist (`allowFrom` / `discord.dm.allowFrom` / `slack.dm.allowFrom`).
@@ -106,7 +106,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## Highlights
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, providers, tools, and events.
- **[Multi-provider inbox](https://docs.clawd.bot/providers)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
- **[Multi-provider inbox](https://docs.clawd.bot/providers)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat, macOS, iOS/Android.
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound providers/accounts/peers to isolated agents (workspaces + per-agent sessions).
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
@@ -114,6 +114,10 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
- **[Companion apps](https://docs.clawd.bot/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes).
- **[Onboarding](https://docs.clawd.bot/start/wizard) + [skills](https://docs.clawd.bot/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=clawdbot/clawdbot&type=date&legend=top-left)](https://www.star-history.com/#clawdbot/clawdbot&type=date&legend=top-left)
## Everything we built so far
### Core platform
@@ -124,7 +128,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
### Providers
- [Providers](https://docs.clawd.bot/providers): [WhatsApp](https://docs.clawd.bot/providers/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/providers/telegram) (grammY), [Slack](https://docs.clawd.bot/providers/slack) (Bolt), [Discord](https://docs.clawd.bot/providers/discord) (discord.js), [Signal](https://docs.clawd.bot/providers/signal) (signal-cli), [iMessage](https://docs.clawd.bot/providers/imessage) (imsg), [WebChat](https://docs.clawd.bot/web/webchat).
- [Providers](https://docs.clawd.bot/providers): [WhatsApp](https://docs.clawd.bot/providers/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/providers/telegram) (grammY), [Slack](https://docs.clawd.bot/providers/slack) (Bolt), [Discord](https://docs.clawd.bot/providers/discord) (discord.js), [Signal](https://docs.clawd.bot/providers/signal) (signal-cli), [iMessage](https://docs.clawd.bot/providers/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/providers/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/webchat).
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-provider chunking and routing. Provider rules: [Providers](https://docs.clawd.bot/providers).
### Apps + nodes
@@ -155,7 +159,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## How it works (short)
```
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Microsoft Teams / WebChat
│
▼
┌───────────────────────────────┐
@@ -238,12 +242,12 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc
## Chat commands
Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):
Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands are owner-only):
- `/status` — compact session status (model + tokens, cost when available)
- `/new` or `/reset` — reset the session
- `/compact` — compact session context (summary)
- `/think <level>` — off|minimal|low|medium|high
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
- `/verbose on|off`
- `/cost on|off` — append per-response token/cost usage lines
- `/restart` — restart the gateway (owner-only in groups)
@@ -356,6 +360,11 @@ Details: [Security guide](https://docs.clawd.bot/gateway/security) · [Docker +
- macOS only; Messages must be signed in.
- If `imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
### [Microsoft Teams](https://docs.clawd.bot/providers/msteams)
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
### [WebChat](https://docs.clawd.bot/web/webchat)
- Uses the Gateway WebSocket; no separate WebChat port/config.
@@ -459,20 +468,20 @@ 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/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a>
<a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a>
<a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a>
<a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a>
<a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="Sebastian Barrios" title="Sebastian Barrios"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
<a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a>
<a href="https://github.com/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/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
<a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="Sebastian Barrios" title="Sebastian Barrios"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
<a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a>
<a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a>
<a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a>
<a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a>
<a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a>
<a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a>
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a>
<a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a>
<a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a>
<a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a>
<a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a>
<a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a>
<a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
<a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a>
<a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
<a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
<a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/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>
</p>
@@ -13,7 +13,9 @@ extension CronJobEditor {
guard let job else { return }
self.name = job.name
self.description = job.description ?? ""
self.agentId = job.agentId ?? ""
self.enabled = job.enabled
self.deleteAfterRun = job.deleteAfterRun ?? false
self.sessionTarget = job.sessionTarget
self.wakeMode = job.wakeMode
@@ -59,18 +61,60 @@ extension CronJobEditor {
}
func buildPayload() throws -> [String: AnyCodable] {
let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
let name = try self.requireName()
let description = self.trimmed(self.description)
let agentId = self.trimmed(self.agentId)
let schedule = try self.buildSchedule()
let payload = try self.buildSelectedPayload()
try self.validateSessionTarget(payload)
try self.validatePayloadRequiredFields(payload)
var root: [String: Any] = [
"name": name,
"enabled": self.enabled,
"schedule": schedule,
"sessionTarget": self.sessionTarget.rawValue,
"wakeMode": self.wakeMode.rawValue,
"payload": payload,
]
self.applyDeleteAfterRun(to: &root)
if !description.isEmpty { root["description"] = description }
if !agentId.isEmpty {
root["agentId"] = agentId
} else if self.job?.agentId != nil {
root["agentId"] = NSNull()
}
if self.sessionTarget == .isolated {
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
root["isolation"] = [
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
]
}
return root.mapValues { AnyCodable($0) }
}
func trimmed(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines)
}
func requireName() throws -> String {
let name = self.trimmed(self.name)
if name.isEmpty {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Name is required."])
}
let description = self.description.trimmingCharacters(in: .whitespacesAndNewlines)
let schedule: [String: Any]
return name
}
func buildSchedule() throws -> [String: Any] {
switch self.scheduleKind {
case .at:
schedule = ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
return ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
case .every:
guard let ms = Self.parseDurationMs(self.everyText) else {
throw NSError(
@@ -78,34 +122,35 @@ extension CronJobEditor {
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."])
}
schedule = ["kind": "every", "everyMs": ms]
return ["kind": "every", "everyMs": ms]
case .cron:
let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines)
let expr = self.trimmed(self.cronExpr)
if expr.isEmpty {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
}
let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines)
let tz = self.trimmed(self.cronTz)
if tz.isEmpty {
schedule = ["kind": "cron", "expr": expr]
} else {
schedule = ["kind": "cron", "expr": expr, "tz": tz]
return ["kind": "cron", "expr": expr]
}
return ["kind": "cron", "expr": expr, "tz": tz]
}
}
let payload: [String: Any] = {
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
switch self.payloadKind {
case .systemEvent:
let text = self.systemEventText.trimmingCharacters(in: .whitespacesAndNewlines)
return ["kind": "systemEvent", "text": text]
case .agentTurn:
return self.buildAgentTurnPayload()
}
}()
func buildSelectedPayload() throws -> [String: Any] {
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
switch self.payloadKind {
case .systemEvent:
let text = self.trimmed(self.systemEventText)
return ["kind": "systemEvent", "text": text]
case .agentTurn:
return self.buildAgentTurnPayload()
}
}
func validateSessionTarget(_ payload: [String: Any]) throws {
if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" {
throw NSError(
domain: "Cron",
@@ -122,7 +167,9 @@ extension CronJobEditor {
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."])
}
}
func validatePayloadRequiredFields(_ payload: [String: Any]) throws {
if payload["kind"] as? String == "systemEvent" {
if (payload["text"] as? String ?? "").isEmpty {
throw NSError(
@@ -130,7 +177,8 @@ extension CronJobEditor {
code: 0,
userInfo: [NSLocalizedDescriptionKey: "System event text is required."])
}
} else if payload["kind"] as? String == "agentTurn" {
}
if payload["kind"] as? String == "agentTurn" {
if (payload["message"] as? String ?? "").isEmpty {
throw NSError(
domain: "Cron",
@@ -138,25 +186,14 @@ extension CronJobEditor {
userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
}
}
}
var root: [String: Any] = [
"name": name,
"enabled": self.enabled,
"schedule": schedule,
"sessionTarget": self.sessionTarget.rawValue,
"wakeMode": self.wakeMode.rawValue,
"payload": payload,
]
if !description.isEmpty { root["description"] = description }
if self.sessionTarget == .isolated {
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
root["isolation"] = [
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
]
func applyDeleteAfterRun(to root: inout [String: Any]) {
if self.scheduleKind == .at {
root["deleteAfterRun"] = self.deleteAfterRun
} else if self.job?.deleteAfterRun != nil {
root["deleteAfterRun"] = false
}
return root.mapValues { AnyCodable($0) }
}
func buildAgentTurnPayload() -> [String: Any] {
@@ -3,6 +3,7 @@ extension CronJobEditor {
mutating func exerciseForTesting() {
self.name = "Test job"
self.description = "Test description"
self.agentId = "ops"
self.enabled = true
self.sessionTarget = .isolated
self.wakeMode = .now
@@ -27,9 +27,11 @@ struct CronJobEditor: View {
@State var name: String = ""
@State var description: String = ""
@State var agentId: String = ""
@State var enabled: Bool = true
@State var sessionTarget: CronSessionTarget = .main
@State var wakeMode: CronWakeMode = .nextHeartbeat
@State var deleteAfterRun: Bool = false
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
@State var scheduleKind: ScheduleKind = .every
@@ -77,6 +79,12 @@ struct CronJobEditor: View {
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Agent ID")
TextField("Optional (default agent)", text: self.$agentId)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$enabled)
@@ -149,6 +157,11 @@ struct CronJobEditor: View {
.labelsHidden()
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Auto-delete")
Toggle("Delete after successful run", isOn: self.$deleteAfterRun)
.toggleStyle(.switch)
}
case .every:
GridRow {
self.gridLabel("Every")
@@ -145,9 +145,11 @@ struct CronJobState: Codable, Equatable {
struct CronJob: Identifiable, Codable, Equatable {
let id: String
let agentId: String?
var name: String
var description: String?
var enabled: Bool
var deleteAfterRun: Bool?
let createdAtMs: Int
let updatedAtMs: Int
let schedule: CronSchedule
@@ -20,6 +20,9 @@ extension CronSettings {
HStack(spacing: 6) {
StatusPill(text: job.sessionTarget.rawValue, tint: .secondary)
StatusPill(text: job.wakeMode.rawValue, tint: .secondary)
if let agentId = job.agentId, !agentId.isEmpty {
StatusPill(text: "agent \(agentId)", tint: .secondary)
}
if let status = job.state.lastStatus {
StatusPill(text: status, tint: status == "ok" ? .green : .orange)
}
@@ -91,9 +94,15 @@ extension CronSettings {
func detailCard(_ job: CronJob) -> some View {
VStack(alignment: .leading, spacing: 10) {
LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) }
if case .at = job.schedule, job.deleteAfterRun == true {
LabeledContent("Auto-delete") { Text("after success") }
}
if let desc = job.description, !desc.isEmpty {
LabeledContent("Description") { Text(desc).font(.callout) }
}
if let agentId = job.agentId, !agentId.isEmpty {
LabeledContent("Agent") { Text(agentId) }
}
LabeledContent("Session") { Text(job.sessionTarget.rawValue) }
LabeledContent("Wake") { Text(job.wakeMode.rawValue) }
LabeledContent("Next run") {
@@ -7,9 +7,11 @@ struct CronSettings_Previews: PreviewProvider {
store.jobs = [
CronJob(
id: "job-1",
agentId: "ops",
name: "Daily summary",
description: nil,
enabled: true,
deleteAfterRun: nil,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .every(everyMs: 86_400_000, anchorMs: nil),
@@ -59,9 +61,11 @@ extension CronSettings {
let job = CronJob(
id: "job-1",
agentId: "ops",
name: "Daily summary",
description: "Summary job",
enabled: true,
deleteAfterRun: nil,
createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_100_000,
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
@@ -149,8 +149,7 @@ final class OnboardingWizardModel {
onboardingWizardLogger.error("wizard step decode failed")
}
if res.done { self.currentStep = nil }
if res.done || status == "done" || status == "cancelled" || status == "error"
{
if res.done || status == "done" || status == "cancelled" || status == "error" {
self.sessionId = nil
}
}
@@ -1356,6 +1356,7 @@ public struct CronJob: Codable, Sendable {
public let name: String
public let description: String?
public let enabled: Bool
public let deleteafterrun: Bool?
public let createdatms: Int
public let updatedatms: Int
public let schedule: AnyCodable
@@ -1371,6 +1372,7 @@ public struct CronJob: Codable, Sendable {
name: String,
description: String?,
enabled: Bool,
deleteafterrun: Bool?,
createdatms: Int,
updatedatms: Int,
schedule: AnyCodable,
@@ -1385,6 +1387,7 @@ public struct CronJob: Codable, Sendable {
self.name = name
self.description = description
self.enabled = enabled
self.deleteafterrun = deleteafterrun
self.createdatms = createdatms
self.updatedatms = updatedatms
self.schedule = schedule
@@ -1400,6 +1403,7 @@ public struct CronJob: Codable, Sendable {
case name
case description
case enabled
case deleteafterrun = "deleteAfterRun"
case createdatms = "createdAtMs"
case updatedatms = "updatedAtMs"
case schedule
@@ -1432,6 +1436,7 @@ public struct CronAddParams: Codable, Sendable {
public let agentid: AnyCodable?
public let description: String?
public let enabled: Bool?
public let deleteafterrun: Bool?
public let schedule: AnyCodable
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
@@ -1443,6 +1448,7 @@ public struct CronAddParams: Codable, Sendable {
agentid: AnyCodable?,
description: String?,
enabled: Bool?,
deleteafterrun: Bool?,
schedule: AnyCodable,
sessiontarget: AnyCodable,
wakemode: AnyCodable,
@@ -1453,6 +1459,7 @@ public struct CronAddParams: Codable, Sendable {
self.agentid = agentid
self.description = description
self.enabled = enabled
self.deleteafterrun = deleteafterrun
self.schedule = schedule
self.sessiontarget = sessiontarget
self.wakemode = wakemode
@@ -1464,6 +1471,7 @@ public struct CronAddParams: Codable, Sendable {
case agentid = "agentId"
case description
case enabled
case deleteafterrun = "deleteAfterRun"
case schedule
case sessiontarget = "sessionTarget"
case wakemode = "wakeMode"
@@ -1472,70 +1480,6 @@ public struct CronAddParams: Codable, Sendable {
}
}
public struct CronUpdateParams: Codable, Sendable {
public let id: String
public let patch: [String: AnyCodable]
public init(
id: String,
patch: [String: AnyCodable]
) {
self.id = id
self.patch = patch
}
private enum CodingKeys: String, CodingKey {
case id
case patch
}
}
public struct CronRemoveParams: Codable, Sendable {
public let id: String
public init(
id: String
) {
self.id = id
}
private enum CodingKeys: String, CodingKey {
case id
}
}
public struct CronRunParams: Codable, Sendable {
public let id: String
public let mode: AnyCodable?
public init(
id: String,
mode: AnyCodable?
) {
self.id = id
self.mode = mode
}
private enum CodingKeys: String, CodingKey {
case id
case mode
}
}
public struct CronRunsParams: Codable, Sendable {
public let id: String
public let limit: Int?
public init(
id: String,
limit: Int?
) {
self.id = id
self.limit = limit
}
private enum CodingKeys: String, CodingKey {
case id
case limit
}
}
public struct CronRunLogEntry: Codable, Sendable {
public let ts: Int
public let jobid: String
@@ -22,17 +22,17 @@ struct WizardCliOptions {
case "--json":
opts.json = true
case "--url":
opts.url = nextValue(args, index: &i)
opts.url = self.nextValue(args, index: &i)
case "--token":
opts.token = nextValue(args, index: &i)
opts.token = self.nextValue(args, index: &i)
case "--password":
opts.password = nextValue(args, index: &i)
opts.password = self.nextValue(args, index: &i)
case "--mode":
if let value = nextValue(args, index: &i) {
opts.mode = value
}
case "--workspace":
opts.workspace = nextValue(args, index: &i)
opts.workspace = self.nextValue(args, index: &i)
default:
break
}
@@ -118,7 +118,8 @@ private func printUsage() {
clawdbot-mac-wizard
Usage:
clawdbot-mac-wizard [--url <ws://host:port>] [--token <token>] [--password <password>] [--mode <local|remote>] [--workspace <path>] [--json]
clawdbot-mac-wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--workspace <path>] [--json]
Options:
--url <url> Gateway WebSocket URL (overrides config)
@@ -23,8 +23,11 @@ struct CronJobEditorSmokeTests {
@Test func cronJobEditorBuildsBodyForExistingJob() {
let job = CronJob(
id: "job-1",
agentId: "ops",
name: "Daily summary",
description: nil,
enabled: true,
deleteAfterRun: nil,
createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_000_000,
schedule: .every(everyMs: 3_600_000, anchorMs: 1_700_000_000_000),
@@ -65,4 +68,24 @@ struct CronJobEditorSmokeTests {
onSave: { _ in })
view.exerciseForTesting()
}
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
var view = CronJobEditor(
job: nil,
isSaving: .constant(false),
error: .constant(nil),
onCancel: {},
onSave: { _ in })
view.name = "One-shot"
view.sessionTarget = .main
view.payloadKind = .systemEvent
view.systemEventText = "hello"
view.scheduleKind = .at
view.atDate = Date(timeIntervalSince1970: 1_700_000_000)
view.deleteAfterRun = true
let payload = try view.buildPayload()
let raw = payload["deleteAfterRun"]?.value as? Bool
#expect(raw == true)
}
}
@@ -39,6 +39,27 @@ struct CronModelsTests {
#expect(decoded == payload)
}
@Test func jobEncodesAndDecodesDeleteAfterRun() throws {
let job = CronJob(
id: "job-1",
agentId: nil,
name: "One-shot",
description: nil,
enabled: true,
deleteAfterRun: true,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .at(atMs: 1_700_000_000_000),
sessionTarget: .main,
wakeMode: .now,
payload: .systemEvent(text: "ping"),
isolation: nil,
state: CronJobState())
let data = try JSONEncoder().encode(job)
let decoded = try JSONDecoder().decode(CronJob.self, from: data)
#expect(decoded.deleteAfterRun == true)
}
@Test func scheduleDecodeRejectsUnknownKind() {
let json = """
{"kind":"wat","atMs":1}
@@ -60,9 +81,11 @@ struct CronModelsTests {
@Test func displayNameTrimsWhitespaceAndFallsBack() {
let base = CronJob(
id: "x",
agentId: nil,
name: " hello ",
description: nil,
enabled: true,
deleteAfterRun: nil,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .at(atMs: 0),
@@ -81,9 +104,11 @@ struct CronModelsTests {
@Test func nextRunDateAndLastRunDateDeriveFromState() {
let job = CronJob(
id: "x",
agentId: nil,
name: "t",
description: nil,
enabled: true,
deleteAfterRun: nil,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .at(atMs: 0),
@@ -19,22 +19,19 @@ import Testing
error: "gateway connect failed",
elapsedMs: 12,
bot: nil,
webhook: nil
),
lastProbeAt: 0
),
webhook: nil),
lastProbeAt: 0),
],
providerOrder: ["whatsapp"],
providerLabels: ["whatsapp": "WhatsApp"],
heartbeatSeconds: 60,
sessions: .init(path: "/tmp/sessions.json", count: 0, recent: [])
)
sessions: .init(path: "/tmp/sessions.json", count: 0, recent: []))
let store = HealthStore.shared
store.__setSnapshotForTest(snap, lastError: nil)
switch store.state {
case .degraded(let message):
case let .degraded(message):
#expect(!message.isEmpty)
default:
Issue.record("Expected degraded state when probe fails for linked provider")
@@ -43,4 +40,3 @@ import Testing
#expect(store.summaryLine.contains("probe degraded"))
}
}
@@ -12,9 +12,11 @@ struct SettingsViewSmokeTests {
let job1 = CronJob(
id: "job-1",
agentId: "ops",
name: " Morning Check-in ",
description: nil,
enabled: true,
deleteAfterRun: nil,
createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_100_000,
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
@@ -32,9 +34,11 @@ struct SettingsViewSmokeTests {
let job2 = CronJob(
id: "job-2",
agentId: nil,
name: "",
description: nil,
enabled: false,
deleteAfterRun: nil,
createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_100_000,
schedule: .every(everyMs: 30000, anchorMs: nil),
Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

+32 -2
View File
@@ -20,6 +20,24 @@ cron is the mechanism.
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, optionally deliver output.
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
## Beginner-friendly overview
Think of a cron job as: **when** to run + **what** to do.
1) **Choose a schedule**
- One-shot reminder → `schedule.kind = "at"` (CLI: `--at`)
- Repeating job → `schedule.kind = "every"` or `schedule.kind = "cron"`
- If your ISO timestamp omits a timezone, it is treated as **UTC**.
2) **Choose where it runs**
- `sessionTarget: "main"` → run during the next heartbeat with main context.
- `sessionTarget: "isolated"` → run a dedicated agent turn in `cron:<jobId>`.
3) **Choose the payload**
- Main session → `payload.kind = "systemEvent"`
- Isolated session → `payload.kind = "agentTurn"`
Optional: `deleteAfterRun: true` removes successful one-shot jobs from the store.
## Concepts
### Jobs
@@ -32,10 +50,11 @@ A cron job is a stored record with:
Jobs are identified by a stable `jobId` (used by CLI/Gateway APIs).
In agent tool calls, `jobId` is canonical; legacy `id` is accepted for compatibility.
Jobs can optionally auto-delete after a successful one-shot run via `deleteAfterRun: true`.
### Schedules
Cron supports three schedule kinds:
- `at`: one-shot timestamp (ms since epoch).
- `at`: one-shot timestamp (ms since epoch). Gateway accepts ISO 8601 and coerces to UTC.
- `every`: fixed interval (ms).
- `cron`: 5-field cron expression with optional IANA timezone.
@@ -86,7 +105,7 @@ Isolation options (only for `session=isolated`):
### Model and thinking overrides
Isolated jobs (`agentTurn`) can override the model and thinking level:
- `model`: Provider/model string (e.g., `anthropic/claude-sonnet-4-20250514`) or alias (e.g., `opus`)
- `thinking`: Thinking level (`off`, `minimal`, `low`, `medium`, `high`)
- `thinking`: Thinking level (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`; GPT-5.2 + Codex models only)
Note: You can set `model` on main-session jobs too, but it changes the shared main
session model. We recommend model overrides only for isolated jobs to avoid
@@ -143,6 +162,17 @@ Disable cron entirely:
## CLI quickstart
One-shot reminder (UTC ISO, auto-delete after success):
```bash
clawdbot cron add \
--name "Send reminder" \
--at "2026-01-12T18:00:00Z" \
--session main \
--system-event "Reminder: submit expense report." \
--wake now \
--delete-after-run
```
One-shot reminder (main session, wake immediately):
```bash
clawdbot cron add \
+71
View File
@@ -0,0 +1,71 @@
---
summary: "Use Amazon Bedrock (Converse API) models with Clawdbot"
read_when:
- You want to use Amazon Bedrock models with Clawdbot
- You need AWS credential/region setup for model calls
---
# Amazon Bedrock
Clawdbot can use **Amazon Bedrock** models via pi‑ai’s **Bedrock Converse**
streaming provider. Bedrock auth uses the **AWS SDK default credential chain**,
not an API key.
## What pi‑ai supports
- Provider: `amazon-bedrock`
- API: `bedrock-converse-stream`
- Auth: AWS credentials (env vars, shared config, or instance role)
- Region: `AWS_REGION` or `AWS_DEFAULT_REGION` (default: `us-east-1`)
## Setup (manual)
1) Ensure AWS credentials are available on the **gateway host**:
```bash
export AWS_ACCESS_KEY_ID="AKIA..."
export AWS_SECRET_ACCESS_KEY="..."
export AWS_REGION="us-east-1"
# Optional:
export AWS_SESSION_TOKEN="..."
export AWS_PROFILE="your-profile"
```
2) Add a Bedrock provider and model to your config:
```json5
{
models: {
providers: {
"amazon-bedrock": {
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
api: "bedrock-converse-stream",
models: [
{
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
name: "Claude 3.7 Sonnet (Bedrock)",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192
}
]
}
}
},
agents: {
defaults: {
model: { primary: "amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0" }
}
}
}
```
## Notes
- Bedrock requires **model access** enabled in your AWS account/region.
- If you use profiles, set `AWS_PROFILE` on the gateway host.
- Reasoning support depends on the model; check the Bedrock model card for
current capabilities.
- If you prefer a managed key flow, you can also place an OpenAI‑compatible
proxy in front of Bedrock and configure it as an OpenAI provider instead.
+1 -1
View File
@@ -398,7 +398,7 @@ Required:
Options:
- `--to <dest>` (for session key and optional delivery)
- `--session-id <id>`
- `--thinking <off|minimal|low|medium|high>`
- `--thinking <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only)
- `--verbose <on|off>`
- `--provider <whatsapp|telegram|discord|slack|signal|imessage>`
- `--local`
+40
View File
@@ -0,0 +1,40 @@
---
summary: "Compaction modes and configuration"
read_when:
- You want to configure compaction summarization behavior
- You are tuning compaction settings in clawdbot.json
---
# Compaction
Compaction summarizes older session history so the conversation stays within the model context window. The summary is stored in the session JSONL history and combined with the most recent messages.
## Modes
`agents.defaults.compaction.mode` controls how summaries are generated.
- `default` (default): use the built-in compaction summarizer.
- `safeguard`: uses a chunked summarization pass to avoid context overflow for very long histories. If chunked summarization fails, Clawdbot falls back to a minimal summary plus file-operation metadata.
## Configuration
```json5
{
agents: {
defaults: {
compaction: {
mode: "safeguard",
reserveTokensFloor: 20000,
memoryFlush: {
enabled: true,
softThresholdTokens: 4000
}
}
}
}
}
```
## Related docs
- [Context window + compaction behavior](/concepts/compaction)
- [Gateway configuration reference](/gateway/configuration)
+4 -2
View File
@@ -109,8 +109,10 @@ See [Memory](/concepts/memory) for the workflow and automatic memory flush.
- Canvas UI files for node displays (for example `canvas/index.html`).
If any bootstrap file is missing, Clawdbot injects a "missing file" marker into
the session and continues. `clawdbot setup` can recreate missing defaults
without overwriting existing files.
the session and continues. Large bootstrap files are truncated when injected;
adjust the limit with `agents.defaults.bootstrapMaxChars` (default: 20000).
`clawdbot setup` can recreate missing defaults without overwriting existing
files.
## What is NOT in the workspace
+3
View File
@@ -15,6 +15,9 @@ Compaction **summarizes older conversation** into a compact summary entry and ke
Compaction **persists** in the session’s JSONL history.
## Configuration
See [Compaction config & modes](/compaction) for the `agents.defaults.compaction` settings.
## Auto-compaction (default on)
When a session nears or exceeds the model’s context window, Clawdbot triggers auto-compaction and may retry the original request using the compacted context.
+1 -1
View File
@@ -212,7 +212,7 @@ Group inbound payloads set:
- `WasMentioned` (mention gating result)
- Telegram forum topics also include `MessageThreadId` and `IsForum`.
The agent system prompt includes a group intro on the first turn of a new group session.
The agent system prompt includes a group intro on the first turn of a new group session. It reminds the model to respond like a human, avoid Markdown tables, and avoid typing literal `\n` sequences.
## iMessage specifics
- Prefer `chat_id:<id>` when routing or allowlisting.
+51 -4
View File
@@ -79,10 +79,32 @@ Defaults:
- Uses remote embeddings (OpenAI) unless configured for local.
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
Remote embeddings **require** an OpenAI API key (`OPENAI_API_KEY` or
`models.providers.openai.apiKey`). Codex OAuth only covers chat/completions and
does **not** satisfy embeddings for memory search. If you don't want to set an
API key, use `memorySearch.provider = "local"` or set
Remote embeddings **require** an API key for the embedding provider. By default
this is OpenAI (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Codex
OAuth only covers chat/completions and does **not** satisfy embeddings for
memory search. When using a custom OpenAI-compatible endpoint, set
`memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
If you want to use a **custom OpenAI-compatible endpoint** (like Gemini, OpenRouter, or a proxy),
you can use the `remote` configuration:
```json5
agents: {
defaults: {
memorySearch: {
provider: "openai",
model: "text-embedding-3-small",
remote: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
apiKey: "YOUR_GEMINI_API_KEY",
headers: { "X-Custom-Header": "value" }
}
}
}
}
```
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
`memorySearch.fallback = "none"`.
Config example:
@@ -127,3 +149,28 @@ Local mode:
- When `memorySearch.provider = "local"`, `node-llama-cpp` resolves `modelPath`; if the GGUF is missing it **auto-downloads** to the cache (or `local.modelCacheDir` if set), then loads it. Downloads resume on retry.
- Native build requirement: run `pnpm approve-builds`, pick `node-llama-cpp`, then `pnpm rebuild node-llama-cpp`.
- Fallback: if local setup fails and `memorySearch.fallback = "openai"`, we automatically switch to remote embeddings (`openai/text-embedding-3-small` unless overridden) and record the reason.
### Custom OpenAI-compatible endpoint example
```json5
agents: {
defaults: {
memorySearch: {
provider: "openai",
model: "text-embedding-3-small",
remote: {
baseUrl: "https://api.example.com/v1/",
apiKey: "YOUR_REMOTE_API_KEY",
headers: {
"X-Organization": "org-id",
"X-Project": "project-id"
}
}
}
}
}
```
Notes:
- `remote.*` takes precedence over `models.providers.openai.*`.
- `remote.headers` merge with OpenAI headers; remote wins on key conflicts. Omit `remote.headers` to use the OpenAI defaults.
+35 -2
View File
@@ -117,8 +117,13 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
- Provider: `moonshot`
- Auth: `MOONSHOT_API_KEY`
- Example model: `moonshot/kimi-k2-0905-preview`
- CLI: `clawdbot onboard --auth-choice moonshot-api-key`
- Kimi K2 model IDs:
{/* moonshot-kimi-k2-model-refs:start */}
- `moonshot/kimi-k2-0905-preview`
- `moonshot/kimi-k2-turbo-preview`
- `moonshot/kimi-k2-thinking`
- `moonshot/kimi-k2-thinking-turbo`
{/* moonshot-kimi-k2-model-refs:end */}
```json5
{
agents: {
@@ -138,6 +143,34 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
}
```
### Synthetic
Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
- Provider: `synthetic`
- Auth: `SYNTHETIC_API_KEY`
- Example model: `synthetic/hf:MiniMaxAI/MiniMax-M2.1`
- CLI: `clawdbot onboard --auth-choice synthetic-api-key`
```json5
{
agents: {
defaults: { model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" } }
},
models: {
mode: "merge",
providers: {
synthetic: {
baseUrl: "https://api.synthetic.new/anthropic",
apiKey: "${SYNTHETIC_API_KEY}",
api: "anthropic-messages",
models: [{ id: "hf:MiniMaxAI/MiniMax-M2.1", name: "MiniMax M2.1" }]
}
}
}
}
```
### MiniMax
MiniMax is configured via `models.providers` because it uses custom endpoints:
+3 -1
View File
@@ -38,7 +38,9 @@ Bootstrap files are trimmed and appended under **Project Context** so the model
- `HEARTBEAT.md`
- `BOOTSTRAP.md` (only on brand-new workspaces)
Large files are truncated with a marker. Missing files inject a short missing-file marker.
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.
## Time handling
@@ -39,7 +39,7 @@ Add `hooks.gmail.model` config option to specify an optional cheaper model for G
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `hooks.gmail.model` | `string` | (none) | Model to use for Gmail hook processing. Accepts `provider/model` refs or aliases from `agents.defaults.models`. |
| `hooks.gmail.thinking` | `string` | (inherited) | Thinking level override (`off`, `minimal`, `low`, `medium`, `high`). If unset, inherits from `agents.defaults.thinkingDefault` or model's default. |
| `hooks.gmail.thinking` | `string` | (inherited) | Thinking level override (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`; GPT-5.2 + Codex models only). If unset, inherits from `agents.defaults.thinkingDefault` or model's default. |
### Alias Support
+8
View File
@@ -241,6 +241,14 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
prompt: "HEARTBEAT",
ackMaxChars: 300
},
memorySearch: {
provider: "openai",
model: "text-embedding-004",
remote: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
apiKey: "${GEMINI_API_KEY}"
}
},
sandbox: {
mode: "non-main",
perSession: true,
+108 -2
View File
@@ -569,7 +569,9 @@ Inbound messages are routed to an agent via bindings.
- `name`: display name for the agent.
- `workspace`: default `~/clawd-<agentId>` (for `main`, falls back to `agents.defaults.workspace`).
- `agentDir`: default `~/.clawdbot/agents/<agentId>/agent`.
- `model`: per-agent default model (provider/model), overrides `agents.defaults.model` for that agent.
- `model`: per-agent default model, overrides `agents.defaults.model` for that agent.
- string form: `"provider/model"`, overrides only `agents.defaults.model.primary`
- object form: `{ primary, fallbacks }` (fallbacks override `agents.defaults.model.fallbacks`; `[]` disables global fallbacks for that agent)
- `identity`: per-agent name/theme/emoji (used for mention patterns + ack reactions).
- `groupChat`: per-agent mention-gating (`mentionPatterns`).
- `sandbox`: per-agent sandbox config (overrides `agents.defaults.sandbox`).
@@ -583,6 +585,7 @@ Inbound messages are routed to an agent via bindings.
- `subagents`: per-agent sub-agent defaults.
- `allowAgents`: allowlist of agent ids for `sessions_spawn` from this agent (`["*"]` = allow any; default: only same agent)
- `tools`: per-agent tool restrictions (applied before sandbox tool policy).
- `profile`: base tool profile (applied before allow/deny)
- `allow`: array of allowed tool names
- `deny`: array of denied tool names (deny wins)
- `agents.defaults`: shared agent defaults (model, workspace, sandbox, etc.).
@@ -1075,6 +1078,20 @@ Use this for pre-seeded deployments where your workspace files come from a repo.
}
```
### `agents.defaults.bootstrapMaxChars`
Max characters of each workspace bootstrap file injected into the system prompt
before truncation. Default: `20000`.
When a file exceeds this limit, Clawdbot logs a warning and injects a truncated
head/tail with a marker.
```json5
{
agents: { defaults: { bootstrapMaxChars: 20000 } }
}
```
### `agents.defaults.userTimezone`
Sets the user’s timezone for **system prompt context** (not for timestamps in
@@ -1378,6 +1395,8 @@ See [/concepts/session-pruning](/concepts/session-pruning) for behavior details.
#### `agents.defaults.compaction` (reserve headroom + memory flush)
`agents.defaults.compaction.mode` selects the compaction summarization strategy. Defaults to `default`; set `safeguard` to enable chunked summarization for very long histories. See [/compaction](/compaction).
`agents.defaults.compaction.reserveTokensFloor` enforces a minimum `reserveTokens`
value for Pi compaction (default: `20000`). Set it to `0` to disable the floor.
@@ -1399,6 +1418,7 @@ Example (tuned):
agents: {
defaults: {
compaction: {
mode: "safeguard",
reserveTokensFloor: 24000,
memoryFlush: {
enabled: true,
@@ -1486,6 +1506,34 @@ Legacy: `tools.bash` is still accepted as an alias.
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins)
`tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`:
- `minimal`: `session_status` only
- `coding`: `group:fs`, `group:runtime`, `group:sessions`, `group:memory`, `image`
- `messaging`: `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status`
- `full`: no restriction (same as unset)
Per-agent override: `agents.list[].tools.profile`.
Example (messaging-only by default, allow Slack + Discord tools too):
```json5
{
tools: {
profile: "messaging",
allow: ["slack", "discord"]
}
}
```
Example (coding profile, but deny exec/process everywhere):
```json5
{
tools: {
profile: "coding",
deny: ["group:runtime"]
}
}
```
`tools.allow` / `tools.deny` configure a global tool allow/deny policy (deny wins).
This is applied even when the Docker sandbox is **off**.
@@ -1496,6 +1544,17 @@ Example (disable browser/canvas everywhere):
}
```
Tool groups (shorthands) work in **global** and **per-agent** tool policies:
- `group:runtime`: `exec`, `bash`, `process`
- `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:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
- `group:nodes`: `nodes`
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
`tools.elevated` controls elevated (host) exec access:
- `enabled`: allow elevated mode (default true)
- `allowFrom`: per-provider allowlists (empty = disabled)
@@ -1819,6 +1878,48 @@ Notes:
- Model ref: `moonshot/kimi-k2-0905-preview`.
- Use `https://api.moonshot.cn/v1` if you need the China endpoint.
### Synthetic (Anthropic-compatible)
Use Synthetic's Anthropic-compatible endpoint:
```json5
{
env: { SYNTHETIC_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" },
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" } }
}
},
models: {
mode: "merge",
providers: {
synthetic: {
baseUrl: "https://api.synthetic.new/anthropic",
apiKey: "${SYNTHETIC_API_KEY}",
api: "anthropic-messages",
models: [
{
id: "hf:MiniMaxAI/MiniMax-M2.1",
name: "MiniMax M2.1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 192000,
maxTokens: 65536
}
]
}
}
}
}
```
Notes:
- Set `SYNTHETIC_API_KEY` or use `clawdbot onboard --auth-choice synthetic-api-key`.
- Model ref: `synthetic/hf:MiniMaxAI/MiniMax-M2.1`.
- Base URL should omit `/v1` because the Anthropic client appends it.
### Local models (LM Studio) — recommended setup
See [/gateway/local-models](/gateway/local-models) for the current local guidance. TL;DR: run MiniMax M2.1 via LM Studio Responses API on serious hardware; keep hosted models merged for fallback.
@@ -2135,7 +2236,12 @@ Auth and Tailscale:
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine).
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
- `gateway.auth.allowTailscale` controls whether Tailscale identity headers can satisfy auth.
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
(`tailscale-user-login`) to satisfy auth when the request arrives on loopback
with `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`. When
`true`, Serve requests do not need a token/password; set `false` to require
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
auth mode is not `password`.
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
+1
View File
@@ -56,6 +56,7 @@ cat ~/.clawdbot/clawdbot.json
## What it does (summary)
- Optional pre-flight update for git installs (interactive only).
- UI protocol freshness check (rebuilds Control UI when the protocol schema is newer).
- Health check + restart prompt.
- Skills status summary (eligible/missing/blocked).
- Legacy config migration and normalization.
+8
View File
@@ -49,6 +49,8 @@ pnpm gateway:watch
## Multiple gateways (same host)
Usually unnecessary: one Gateway can serve multiple messaging channels and agents. Use multiple Gateways only for redundancy or strict isolation (ex: rescue bot).
Supported if you isolate state + config and use unique ports.
Service names are profile-aware:
@@ -96,6 +98,12 @@ Checklist per instance:
- unique `agents.defaults.workspace`
- separate WhatsApp numbers (if using WA)
Daemon install per profile:
```bash
clawdbot --profile main daemon install
clawdbot --profile rescue daemon install
```
Example:
```bash
CLAWDBOT_CONFIG_PATH=~/.clawdbot/a.json CLAWDBOT_STATE_DIR=~/.clawdbot-a clawdbot gateway --port 19001
+3 -14
View File
@@ -55,17 +55,6 @@ Best current local stack. Load MiniMax M2.1 in LM Studio, enable the local serve
- Adjust `contextWindow`/`maxTokens` if your LM Studio build differs.
- For WhatsApp, stick to Responses API so only final text is sent.
## Model catalog (remote + local)
| Tier | Model ID | Context | Who downloads | Notes |
| --- | --- | --- | --- | --- |
| Hosted | `anthropic/claude-opus-4-5` | 200k | Provider | Latest Claude; keep as high-quality fallback. |
| Hosted | `anthropic/claude-sonnet-4-5` | 200k | Provider | Cheaper Claude; good default. |
| Hosted | `openai/gpt-4.1` | 128k | Provider | Latest GPT-4; strong tools + reasoning. |
| Hosted | `openai/gpt-4.1-mini` | 128k | Provider | Fast/cheap GPT-4 family; good fallback. |
| Local | `lmstudio/minimax-m2.1-gs32` | ~196k (build-dependent) | You (LM Studio UI) | Recommended local heavy model; keep loaded. |
| Local | Custom `vllm` / `litellm` model | server-defined | You (server) | Any OpenAI-compatible endpoint; align context settings. |
Keep hosted models configured even when running local; use `models.mode: "merge"` so fallbacks stay available.
### Hybrid config: hosted primary, local fallback
@@ -76,12 +65,12 @@ Keep hosted models configured even when running local; use `models.mode: "merge"
defaults: {
model: {
primary: "anthropic/claude-sonnet-4-5",
fallbacks: ["lmstudio/minimax-m2.1-gs32", "openai/gpt-4.1-mini"]
fallbacks: ["lmstudio/minimax-m2.1-gs32", "anthropic/claude-opus-4-5"]
},
models: {
"anthropic/claude-sonnet-4-5": { alias: "Sonnet" },
"lmstudio/minimax-m2.1-gs32": { alias: "MiniMax Local" },
"openai/gpt-4.1-mini": { alias: "GPT-4.1 mini" }
"anthropic/claude-opus-4-5": { alias: "Opus" }
}
}
},
@@ -111,7 +100,7 @@ Keep hosted models configured even when running local; use `models.mode: "merge"
### Local-first with hosted safety net
Swap the primary and fallback order; keep the same providers block and `models.mode: "merge"` so you can fall back to Sonnet or GPT-4.1 when the local box is down.
Swap the primary and fallback order; keep the same providers block and `models.mode: "merge"` so you can fall back to Sonnet or Opus when the local box is down.
### Regional hosting / data routing
@@ -50,6 +50,7 @@ See [Sandboxing](/gateway/sandboxing) for the full matrix (scope, workspace moun
## Tool policy: which tools exist/are callable
Two layers matter:
- **Tool profile**: `tools.profile` and `agents.list[].tools.profile` (base allowlist)
- **Global/per-agent tool policy**: `tools.allow`/`tools.deny` and `agents.list[].tools.allow`/`agents.list[].tools.deny`
- **Sandbox tool policy** (only applies when sandboxed): `tools.sandbox.tools.allow`/`tools.sandbox.tools.deny` and `agents.list[].tools.sandbox.tools.*`
@@ -59,7 +60,7 @@ Rules of thumb:
### Tool groups (shorthands)
For sandbox tool policy, you can use `group:*` entries that expand to multiple tools:
Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools:
```json5
{
@@ -78,6 +79,11 @@ 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:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
- `group:nodes`: `nodes`
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
## Elevated: exec-only “run on host”
+14
View File
@@ -145,6 +145,20 @@ 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.
### 0.6) Tailscale Serve identity headers
When `gateway.auth.allowTailscale` is `true` (default for Serve), Clawdbot
accepts Tailscale Serve identity headers (`tailscale-user-login`) as
authentication. This only triggers for requests that hit loopback and include
`x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` as injected by
Tailscale.
**Security rule:** do not forward these headers from your own reverse proxy. If
you terminate TLS or proxy in front of the gateway, disable
`gateway.auth.allowTailscale` and use token/password auth instead.
See [Tailscale](/gateway/tailscale) and [Web overview](/web).
### 1) DMs: pairing by default
```json5
+7 -3
View File
@@ -23,9 +23,13 @@ Set `gateway.auth.mode` to control the handshake:
- `token` (default when `CLAWDBOT_GATEWAY_TOKEN` is set)
- `password` (shared secret via `CLAWDBOT_GATEWAY_PASSWORD` or config)
When `tailscale.mode = "serve"`, the gateway trusts Tailscale identity headers by
default unless you force `gateway.auth.mode` to `password` or set
`gateway.auth.allowTailscale: false`.
When `tailscale.mode = "serve"` and `gateway.auth.allowTailscale` is `true`,
valid Serve proxy requests can authenticate via Tailscale identity headers
(`tailscale-user-login`) without supplying a token/password. Clawdbot only
treats a request as Serve when it arrives from loopback with Tailscale’s
`x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` headers.
To require explicit credentials, set `gateway.auth.allowTailscale: false` or
force `gateway.auth.mode: "password"`.
## Config examples
+34 -5
View File
@@ -106,6 +106,28 @@ For debugging “why is this blocked?”, see [Sandbox vs Tool Policy vs Elevate
---
### Example 2b: Global coding profile + messaging-only agent
```json
{
"tools": { "profile": "coding" },
"agents": {
"list": [
{
"id": "support",
"tools": { "profile": "messaging", "allow": ["slack"] }
}
]
}
}
```
**Result:**
- default agents get coding tools
- `support` agent is messaging-only (+ Slack tool)
---
### Example 3: Different Sandbox Modes per Agent
```json
@@ -165,22 +187,29 @@ agents.list[].sandbox.prune.* > agents.defaults.sandbox.prune.*
### Tool Restrictions
The filtering order is:
1. **Global tool policy** (`tools.allow` / `tools.deny`)
2. **Agent-specific tool policy** (`agents.list[].tools`)
3. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`)
4. **Subagent tool policy** (`tools.subagents.tools`, if applicable)
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)
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.
### Tool groups (shorthands)
Sandbox tool policy supports `group:*` entries that expand to multiple concrete tools:
Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple concrete tools:
- `group:runtime`: `exec`, `bash`, `process`
- `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:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
- `group:nodes`: `nodes`
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
### Elevated Mode
`tools.elevated` is the global baseline (sender-based allowlist). `agents.list[].tools.elevated` can further restrict elevated for specific agents (both must allow).
+3
View File
@@ -29,6 +29,9 @@ MiniMax highlights these improvements in M2.1:
- **Speed:** Lightning is the “fast” variant in MiniMax’s pricing docs.
- **Cost:** Pricing shows the same input cost, but Lightning has higher output cost.
- **Coding plan routing:** The Lightning back-end isn’t directly available on the MiniMax
coding plan. MiniMax auto-routes most requests to Lightning, but falls back to the
regular M2.1 back-end during traffic spikes.
## Choose a setup
+2
View File
@@ -26,10 +26,12 @@ model as `provider/model`.
- [Anthropic (API + Claude CLI)](/providers/anthropic)
- [OpenRouter](/providers/openrouter)
- [Moonshot AI (Kimi)](/providers/moonshot)
- [Synthetic](/providers/synthetic)
- [OpenCode Zen](/providers/opencode)
- [Z.AI](/providers/zai)
- [GLM models](/providers/glm)
- [MiniMax](/providers/minimax)
- [Amazon Bedrock](/bedrock)
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
see [Model providers](/concepts/model-providers).
+44 -2
View File
@@ -9,7 +9,13 @@ read_when:
Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the
provider and set the default model to `moonshot/kimi-k2-0905-preview`.
## CLI setup
Current Kimi K2 model IDs:
{/* moonshot-kimi-k2-ids:start */}
- `kimi-k2-0905-preview`
- `kimi-k2-turbo-preview`
- `kimi-k2-thinking`
- `kimi-k2-thinking-turbo`
{/* moonshot-kimi-k2-ids:end */}
```bash
clawdbot onboard --auth-choice moonshot-api-key
@@ -24,7 +30,12 @@ clawdbot onboard --auth-choice moonshot-api-key
defaults: {
model: { primary: "moonshot/kimi-k2-0905-preview" },
models: {
"moonshot/kimi-k2-0905-preview": { alias: "Kimi K2" }
// moonshot-kimi-k2-aliases:start
"moonshot/kimi-k2-0905-preview": { alias: "Kimi K2" },
"moonshot/kimi-k2-turbo-preview": { alias: "Kimi K2 Turbo" },
"moonshot/kimi-k2-thinking": { alias: "Kimi K2 Thinking" },
"moonshot/kimi-k2-thinking-turbo": { alias: "Kimi K2 Thinking Turbo" }
// moonshot-kimi-k2-aliases:end
}
}
},
@@ -36,6 +47,7 @@ clawdbot onboard --auth-choice moonshot-api-key
apiKey: "${MOONSHOT_API_KEY}",
api: "openai-completions",
models: [
// moonshot-kimi-k2-models:start
{
id: "kimi-k2-0905-preview",
name: "Kimi K2 0905 Preview",
@@ -44,7 +56,35 @@ clawdbot onboard --auth-choice moonshot-api-key
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 256000,
maxTokens: 8192
},
{
id: "kimi-k2-turbo-preview",
name: "Kimi K2 Turbo",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 256000,
maxTokens: 8192
},
{
id: "kimi-k2-thinking",
name: "Kimi K2 Thinking",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 256000,
maxTokens: 8192
},
{
id: "kimi-k2-thinking-turbo",
name: "Kimi K2 Thinking Turbo",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 256000,
maxTokens: 8192
}
// moonshot-kimi-k2-models:end
]
}
}
@@ -56,4 +96,6 @@ clawdbot onboard --auth-choice moonshot-api-key
- Model refs use `moonshot/<modelId>`.
- Override pricing and context metadata in `models.providers` if needed.
- If Moonshot publishes different context limits for a model, adjust
`contextWindow` accordingly.
- Use `https://api.moonshot.cn/v1` if you need the China endpoint.
+97
View File
@@ -0,0 +1,97 @@
---
summary: "Use Synthetic's Anthropic-compatible API in Clawdbot"
read_when:
- You want to use Synthetic as a model provider
- You need a Synthetic API key or base URL setup
---
# Synthetic
Synthetic exposes Anthropic-compatible endpoints. Clawdbot registers it as the
`synthetic` provider and uses the Anthropic Messages API.
## Quick setup
1) Set `SYNTHETIC_API_KEY` (or run the wizard below).
2) Run onboarding:
```bash
clawdbot onboard --auth-choice synthetic-api-key
```
The default model is set to:
```
synthetic/hf:MiniMaxAI/MiniMax-M2.1
```
## Config example
```json5
{
env: { SYNTHETIC_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" },
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" } }
}
},
models: {
mode: "merge",
providers: {
synthetic: {
baseUrl: "https://api.synthetic.new/anthropic",
apiKey: "${SYNTHETIC_API_KEY}",
api: "anthropic-messages",
models: [
{
id: "hf:MiniMaxAI/MiniMax-M2.1",
name: "MiniMax M2.1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 192000,
maxTokens: 65536
}
]
}
}
}
}
```
Note: Clawdbot's Anthropic client appends `/v1` to the base URL, so use
`https://api.synthetic.new/anthropic` (not `/anthropic/v1`). If Synthetic changes
its base URL, override `models.providers.synthetic.baseUrl`.
## Model catalog
All models below use cost `0` (input/output/cache).
| Model ID | Context window | Max tokens | Reasoning | Input |
| --- | --- | --- | --- | --- |
| `hf:MiniMaxAI/MiniMax-M2.1` | 192000 | 65536 | false | text |
| `hf:moonshotai/Kimi-K2-Thinking` | 256000 | 8192 | true | text |
| `hf:zai-org/GLM-4.7` | 198000 | 128000 | false | text |
| `hf:deepseek-ai/DeepSeek-R1-0528` | 128000 | 8192 | false | text |
| `hf:deepseek-ai/DeepSeek-V3-0324` | 128000 | 8192 | false | text |
| `hf:deepseek-ai/DeepSeek-V3.1` | 128000 | 8192 | false | text |
| `hf:deepseek-ai/DeepSeek-V3.1-Terminus` | 128000 | 8192 | false | text |
| `hf:deepseek-ai/DeepSeek-V3.2` | 159000 | 8192 | false | text |
| `hf:meta-llama/Llama-3.3-70B-Instruct` | 128000 | 8192 | false | text |
| `hf:meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8` | 524000 | 8192 | false | text |
| `hf:moonshotai/Kimi-K2-Instruct-0905` | 256000 | 8192 | false | text |
| `hf:openai/gpt-oss-120b` | 128000 | 8192 | false | text |
| `hf:Qwen/Qwen3-235B-A22B-Instruct-2507` | 256000 | 8192 | false | text |
| `hf:Qwen/Qwen3-Coder-480B-A35B-Instruct` | 256000 | 8192 | false | text |
| `hf:Qwen/Qwen3-VL-235B-A22B-Instruct` | 250000 | 8192 | false | text + image |
| `hf:zai-org/GLM-4.5` | 128000 | 128000 | false | text |
| `hf:zai-org/GLM-4.6` | 198000 | 128000 | false | text |
| `hf:deepseek-ai/DeepSeek-V3` | 128000 | 8192 | false | text |
| `hf:Qwen/Qwen3-235B-A22B-Thinking-2507` | 256000 | 8192 | true | text |
## Notes
- Model refs use `synthetic/<modelId>`.
- If you enable a model allowlist (`agents.defaults.models`), add every model you
plan to use.
- See [Model providers](/concepts/model-providers) for provider rules.
+130 -11
View File
@@ -5,6 +5,90 @@ summary: "Frequently asked questions about Clawdbot setup, configuration, and us
Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, multi-agent, OAuth/API keys, model failover). For runtime diagnostics, see [Troubleshooting](/gateway/troubleshooting). For the full config reference, see [Configuration](/gateway/configuration).
## Table of contents
- [What is Clawdbot, in one paragraph?](#what-is-clawdbot-in-one-paragraph)
- [What’s the recommended way to install and set up Clawdbot?](#whats-the-recommended-way-to-install-and-set-up-clawdbot)
- [How do I open the dashboard after onboarding?](#how-do-i-open-the-dashboard-after-onboarding)
- [How do I authenticate the dashboard (token) on localhost vs remote?](#how-do-i-authenticate-the-dashboard-token-on-localhost-vs-remote)
- [What runtime do I need?](#what-runtime-do-i-need)
- [What does the onboarding wizard actually do?](#what-does-the-onboarding-wizard-actually-do)
- [How does Anthropic "setup-token" auth work?](#how-does-anthropic-setup-token-auth-work)
- [Do you support Claude subscription auth (Claude Code OAuth)?](#do-you-support-claude-subscription-auth-claude-code-oauth)
- [Is AWS Bedrock supported?](#is-aws-bedrock-supported)
- [How does Codex auth work?](#how-does-codex-auth-work)
- [Is a local model OK for casual chats?](#is-a-local-model-ok-for-casual-chats)
- [How do I keep hosted model traffic in a specific region?](#how-do-i-keep-hosted-model-traffic-in-a-specific-region)
- [Can I use Bun?](#can-i-use-bun)
- [Telegram: what goes in `allowFrom`?](#telegram-what-goes-in-allowfrom)
- [Can multiple people use one WhatsApp number with different Clawdbots?](#can-multiple-people-use-one-whatsapp-number-with-different-clawdbots)
- [Can I run a "fast chat" agent and an "Opus for coding" agent?](#can-i-run-a-fast-chat-agent-and-an-opus-for-coding-agent)
- [Does Homebrew work on Linux?](#does-homebrew-work-on-linux)
- [Can I switch between npm and git installs later?](#can-i-switch-between-npm-and-git-installs-later)
- [How do I customize skills without keeping the repo dirty?](#how-do-i-customize-skills-without-keeping-the-repo-dirty)
- [Can I load skills from a custom folder?](#can-i-load-skills-from-a-custom-folder)
- [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)
- [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)
- [How does memory work?](#how-does-memory-work)
- [Does semantic memory search require an OpenAI API key?](#does-semantic-memory-search-require-an-openai-api-key)
- [Where does Clawdbot store its data?](#where-does-clawdbot-store-its-data)
- [How do I completely uninstall Clawdbot?](#how-do-i-completely-uninstall-clawdbot)
- [Can agents work outside the workspace?](#can-agents-work-outside-the-workspace)
- [I’m in remote mode — where is the session store?](#im-in-remote-mode-where-is-the-session-store)
- [What format is the config? Where is it?](#what-format-is-the-config-where-is-it)
- [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 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)
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
- [How does Clawdbot load environment variables?](#how-does-clawdbot-load-environment-variables)
- [“I started the Gateway via a daemon and my env vars disappeared.” What now?](#i-started-the-gateway-via-a-daemon-and-my-env-vars-disappeared-what-now)
- [How do I start a fresh conversation?](#how-do-i-start-a-fresh-conversation)
- [How do I completely reset Clawdbot (but keep it installed)?](#how-do-i-completely-reset-clawdbot-but-keep-it-installed)
- [Do I need to add a “bot account” to a WhatsApp group?](#do-i-need-to-add-a-bot-account-to-a-whatsapp-group)
- [Why doesn’t Clawdbot reply in a group?](#why-doesnt-clawdbot-reply-in-a-group)
- [Do groups/threads share context with DMs?](#do-groupsthreads-share-context-with-dms)
- [What is the “default model”?](#what-is-the-default-model)
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
- [Why do I see “Model … is not allowed” and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply)
- [Are opus / sonnet / gpt built‑in shortcuts?](#are-opus-sonnet-gpt-builtin-shortcuts)
- [How do I define/override model shortcuts (aliases)?](#how-do-i-defineoverride-model-shortcuts-aliases)
- [How do I add models from other providers like OpenRouter or Z.AI?](#how-do-i-add-models-from-other-providers-like-openrouter-or-zai)
- [How does failover work?](#how-does-failover-work)
- [What does this error mean?](#what-does-this-error-mean)
- [Fix checklist for `No credentials found for profile "anthropic:default"`](#fix-checklist-for-no-credentials-found-for-profile-anthropicdefault)
- [Why did it also try Google Gemini and fail?](#why-did-it-also-try-google-gemini-and-fail)
- [What is an auth profile?](#what-is-an-auth-profile)
- [What are typical profile IDs?](#what-are-typical-profile-ids)
- [Can I control which auth profile is tried first?](#can-i-control-which-auth-profile-is-tried-first)
- [OAuth vs API key: what’s the difference?](#oauth-vs-api-key-whats-the-difference)
- [What port does the Gateway use?](#what-port-does-the-gateway-use)
- [Why does `clawdbot daemon status` say `Runtime: running` but `RPC probe: failed`?](#why-does-clawdbot-daemon-status-say-runtime-running-but-rpc-probe-failed)
- [Why does `clawdbot daemon status` show `Config (cli)` and `Config (daemon)` different?](#why-does-clawdbot-daemon-status-show-config-cli-and-config-daemon-different)
- [What does “another gateway instance is already listening” mean?](#what-does-another-gateway-instance-is-already-listening-mean)
- [How do I run Clawdbot in remote mode (client connects to a Gateway elsewhere)?](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere)
- [The Control UI says “unauthorized” (or keeps reconnecting). What now?](#the-control-ui-says-unauthorized-or-keeps-reconnecting-what-now)
- [I set `gateway.bind: "tailnet"` but it can’t bind / nothing listens](#i-set-gatewaybind-tailnet-but-it-cant-bind-nothing-listens)
- [Can I run multiple Gateways on the same host?](#can-i-run-multiple-gateways-on-the-same-host)
- [Where are logs?](#where-are-logs)
- [How do I start/stop/restart the Gateway daemon?](#how-do-i-startstoprestart-the-gateway-daemon)
- [What’s the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails)
- [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
- [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms)
- [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work)
- [How do I stop/cancel a running task?](#how-do-i-stopcancel-a-running-task)
- [Why does it feel like the bot “ignores” rapid‑fire messages?](#why-does-it-feel-like-the-bot-ignores-rapidfire-messages)
- [“All models failed” — what should I check first?](#all-models-failed-what-should-i-check-first)
- [I’m running on my personal WhatsApp number — why is self-chat weird?](#im-running-on-my-personal-whatsapp-number-why-is-self-chat-weird)
- [WhatsApp logged me out. How do I re‑auth?](#whatsapp-logged-me-out-how-do-i-reauth)
- [Build errors on `main` — what’s the standard fix path?](#build-errors-on-main-whats-the-standard-fix-path)
## First 60 seconds if something's broken
1) **Quick status (first check)**
@@ -87,6 +171,20 @@ The wizard can also build UI assets automatically. After onboarding, you typical
The wizard now opens your browser with a tokenized dashboard URL right after onboarding and also prints the full link (with token) in the summary. Keep that tab open; if it didn’t launch, copy/paste the printed URL on the same machine. Tokens stay local to your host—nothing is fetched from the browser.
### How do I authenticate the dashboard (token) on localhost vs remote?
**Localhost (same machine):**
- Open `http://127.0.0.1:18789/`.
- If it asks for auth, run `clawdbot dashboard` and use the tokenized link (`?token=...`).
- The token is the same value as `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`) and is stored by the UI after first load.
**Not on localhost:**
- **Tailscale Serve** (recommended): keep bind loopback, run `clawdbot gateway --tailscale serve`, open `https://<magicdns>/`. If `gateway.auth.allowTailscale` is `true`, identity headers satisfy auth (no token).
- **Tailnet bind**: run `clawdbot gateway --bind tailnet --token "<token>"`, open `http://<tailscale-ip>:18789/`, paste token in dashboard settings.
- **SSH tunnel**: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/?token=...` from `clawdbot dashboard`.
See [Dashboard](/web/dashboard) and [Web surfaces](/web) for bind modes and auth details.
### What runtime do I need?
Node **>= 22** is required. `pnpm` is recommended; `bun` is optional.
@@ -114,7 +212,7 @@ Yes. Clawdbot can **reuse Claude Code CLI credentials** (OAuth) and also support
### Is AWS Bedrock supported?
Not currently. Clawdbot doesn’t ship a Bedrock provider today. If you must use Bedrock, the common workaround is an OpenAI‑compatible proxy in front of Bedrock, then point Clawdbot at that endpoint. See [Model providers](/providers/models) and [Model providers (full list)](/concepts/model-providers).
Yes — via pi‑ai’s **Amazon Bedrock (Converse)** provider with **manual config**. You must supply AWS credentials/region on the gateway host and add a Bedrock provider entry in your models config. See [Amazon Bedrock](/bedrock) and [Model providers](/providers/models). If you prefer a managed key flow, an OpenAI‑compatible proxy in front of Bedrock is still a valid option.
### How does Codex auth work?
@@ -186,6 +284,19 @@ Doctor detects a gateway service entrypoint mismatch and offers to rewrite the s
Use managed overrides instead of editing the repo copy. Put your changes in `~/.clawdbot/skills/<name>/SKILL.md` (or add a folder via `skills.load.extraDirs` in `~/.clawdbot/clawdbot.json`). Precedence is `<workspace>/skills` > `~/.clawdbot/skills` > bundled, so managed overrides win without touching git. Only upstream-worthy edits should live in the repo and go out as PRs.
### Can I load skills from a custom folder?
Yes. Add extra directories via `skills.load.extraDirs` in `~/.clawdbot/clawdbot.json` (lowest precedence). Default precedence remains: `<workspace>/skills` → `~/.clawdbot/skills` → bundled → `skills.load.extraDirs`. `clawdhub` installs into `./skills` by default, which Clawdbot treats as `<workspace>/skills`.
### How can I use different models for different tasks?
Today the supported patterns are:
- **Cron jobs**: isolated jobs can set a `model` override per job.
- **Sub-agents**: route tasks to separate agents with different default models.
- **On-demand switch**: use `/model` to switch the current session model at any time.
See [Cron jobs](/automation/cron-jobs), [Multi-Agent Routing](/concepts/multi-agent), and [Slash commands](/tools/slash-commands).
### How do I install skills on Linux?
Use **ClawdHub** (CLI) or drop skills into your workspace. The macOS Skills UI isn’t available on Linux.
@@ -211,7 +322,7 @@ clawdhub install <skill-slug>
clawdhub update --all
```
ClawdHub installs into `./skills` under your current directory; 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).
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).
### Is there a dedicated sandboxing doc?
@@ -231,13 +342,16 @@ Clawdbot also runs a **silent pre-compaction memory flush** to remind the model
to write durable notes before auto-compaction. This only runs when the workspace
is writable (read-only sandboxes skip it). See [Memory](/concepts/memory).
### Why does memory search need an OpenAI API key if I already signed in with Codex?
### Does semantic memory search require an OpenAI API key?
Vector memory search uses **embeddings**. Codex OAuth only covers
chat/completions and does **not** grant embeddings access, so the upstream
memory indexer needs a real OpenAI API key (`OPENAI_API_KEY` or
`models.providers.openai.apiKey`). If you don’t want to set a key, switch to
`memorySearch.provider = "local"` or set `memorySearch.fallback = "none"`.
Only if you use **remote embeddings** (OpenAI). Codex OAuth covers
chat/completions and does **not** grant embeddings access, so **signing in with
Codex (OAuth or the Codex CLI login)** does not help for semantic memory search.
Remote memory search still needs a real OpenAI API key (`OPENAI_API_KEY` or
`models.providers.openai.apiKey`). If you’d rather stay local, set
`memorySearch.provider = "local"` (and optionally `memorySearch.fallback =
"none"`). We support **remote or local embedding models** — see [Memory](/concepts/memory)
for the setup details.
## Where things live on disk
@@ -799,6 +913,8 @@ Fix:
### Can I run multiple Gateways on the same host?
Usually no — one Gateway can run multiple messaging channels and agents. Use multiple Gateways only when you need redundancy (ex: rescue bot) or hard isolation.
Yes, but you must isolate:
- `CLAWDBOT_CONFIG_PATH` (per‑instance config)
@@ -806,9 +922,12 @@ Yes, but you must isolate:
- `agents.defaults.workspace` (workspace isolation)
- `gateway.port` (unique ports)
There are convenience CLI flags like `--dev` and `--profile <name>` that shift state dirs and ports.
When using profiles, service names are suffixed (`com.clawdbot.<profile>`, `clawdbot-gateway-<profile>.service`,
`Clawdbot Gateway (<profile>)`).
Quick setup (recommended):
- Use `clawdbot --profile <name> …` per instance (auto-creates `~/.clawdbot-<name>`).
- Set a unique `gateway.port` in each profile config (or pass `--port` for manual runs).
- Install a per-profile daemon: `clawdbot --profile <name> daemon install`.
Profiles also suffix service names (`com.clawdbot.<profile>`, `clawdbot-gateway-<profile>.service`, `Clawdbot Gateway (<profile>)`).
## Logging and debugging
+28
View File
@@ -42,6 +42,22 @@ Full setup walkthrough (28m) by VelvetShark.
<CardGroup cols={2}>
<Card title="PR Review → Telegram Feedback" icon="code-pull-request" href="https://x.com/i/status/2010878524543131691">
**@bangnokia** • `review` `github` `telegram`
OpenCode finishes the change → opens a PR → Clawdbot reviews the diff and replies in Telegram with “minor suggestions” plus a clear merge verdict (including critical fixes to apply first).
<img src="/assets/showcase/pr-review-telegram.jpg" alt="Clawdbot PR review feedback delivered in Telegram" />
</Card>
<Card title="Wine Cellar Skill in Minutes" icon="wine-glass" href="https://x.com/i/status/2010916352454791216">
**@prades_maxime** • `skills` `local` `csv`
Asked “Robby” (@clawdbot) for a local wine cellar skill. It requests a sample CSV export + where to store it, then builds/tests the skill fast (962 bottles in the example).
<img src="/assets/showcase/wine-cellar-skill.jpg" alt="Clawdbot building a local wine cellar skill from CSV" />
</Card>
<Card title="Tesco Shop Autopilot" icon="cart-shopping" href="https://x.com/i/status/2009724862470689131">
**@marchattonhere** • `automation` `browser` `shopping`
@@ -196,6 +212,18 @@ Full setup walkthrough (28m) by VelvetShark.
Searches job listings, matches against CV keywords, and returns relevant opportunities with links. Built in 30 minutes using JSearch API.
</Card>
<Card title="Jira Skill Builder" icon="diagram-project" href="https://x.com/jdrhyne/status/2008336434827002232">
**@jdrhyne** • `automation` `jira` `skill` `devtools`
Clawdbot connected to Jira, then generated a new skill on the fly (before it existed on ClawdHub).
</Card>
<Card title="Todoist Skill via Telegram" icon="list-check" href="https://x.com/iamsubhrajyoti/status/2009949389884920153">
**@iamsubhrajyoti** • `automation` `todoist` `skill` `telegram`
Automated Todoist tasks and had Clawdbot generate the skill directly in Telegram chat.
</Card>
<Card title="TradingView Analysis" icon="chart-line">
**@bheem1798** • `finance` `browser` `automation`
+13
View File
@@ -84,6 +84,8 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
- **API key**: stores the key for you.
- **MiniMax M2.1**: config is auto-written.
- More detail: [MiniMax](/providers/minimax)
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
- More detail: [Synthetic](/providers/synthetic)
- **Moonshot (Kimi K2)**: config is auto-written.
- More detail: [Moonshot AI](/providers/moonshot)
- **Skip**: no auth configured yet.
@@ -214,6 +216,17 @@ clawdbot onboard --non-interactive \
--gateway-bind loopback
```
Synthetic example:
```bash
clawdbot onboard --non-interactive \
--mode local \
--auth-choice synthetic-api-key \
--synthetic-api-key "$SYNTHETIC_API_KEY" \
--gateway-port 18789 \
--gateway-bind loopback
```
OpenCode Zen example:
```bash
+1 -1
View File
@@ -16,7 +16,7 @@ Clawdbot assembles its own system prompt on every run. It includes:
- Tool list + short descriptions
- Skills list (only metadata; instructions are loaded on demand with `read`)
- Self-update instructions
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new)
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000).
- Time (UTC + user timezone)
- Reply tags + heartbeat behavior
- Runtime metadata (host/OS/model/thinking)
+1 -1
View File
@@ -38,7 +38,7 @@ clawdbot agent --to +15555550123 --message "Summon reply" --deliver
- `--local`: run locally (requires provider keys in your shell)
- `--deliver`: send the reply to the chosen provider (requires `--to`)
- `--provider`: `whatsapp|telegram|discord|slack|signal|imessage` (default: `whatsapp`)
- `--thinking <off|minimal|low|medium|high>`: persist thinking level
- `--thinking <off|minimal|low|medium|high|xhigh>`: persist thinking level (GPT-5.2 + Codex models only)
- `--verbose <on|off>`: persist verbose level
- `--timeout <seconds>`: override agent timeout
- `--json`: output structured JSON
+3 -2
View File
@@ -48,7 +48,7 @@ bun add -g clawdhub
## How it fits into Clawdbot
By default, the CLI installs skills into `./skills` under your current working directory. Clawdbot loads workspace skills from `<workspace>/skills` and will pick them up in the **next** session. If you already use `~/.clawdbot/skills` or bundled skills, workspace skills take precedence.
By default, the CLI installs skills into `./skills` under your current working directory. If a Clawdbot workspace is configured, `clawdhub` falls back to that workspace unless you override `--workdir` (or `CLAWDHUB_WORKDIR`). Clawdbot loads workspace skills from `<workspace>/skills` and will pick them up in the **next** session. If you already use `~/.clawdbot/skills` or bundled skills, workspace skills take precedence.
For more detail on how skills are loaded, shared, and gated, see
[Skills](/tools/skills).
@@ -67,7 +67,7 @@ For more detail on how skills are loaded, shared, and gated, see
Global options (apply to all commands):
- `--workdir <dir>`: Working directory (default: current dir).
- `--workdir <dir>`: Working directory (default: current dir; falls back to Clawdbot workspace).
- `--dir <dir>`: Skills directory, relative to workdir (default: `skills`).
- `--site <url>`: Site base URL (browser login).
- `--registry <url>`: Registry API base URL.
@@ -201,4 +201,5 @@ export CLAWDHUB_DISABLE_TELEMETRY=1
- `CLAWDHUB_SITE`: Override the site URL.
- `CLAWDHUB_REGISTRY`: Override the registry API URL.
- `CLAWDHUB_CONFIG_PATH`: Override where the CLI stores the token/config.
- `CLAWDHUB_WORKDIR`: Override the default workdir.
- `CLAWDHUB_DISABLE_TELEMETRY=1`: Disable telemetry on `sync`.
+71
View File
@@ -22,6 +22,77 @@ You can globally allow/deny tools via `tools.allow` / `tools.deny` in `clawdbot.
}
```
## Tool profiles (base allowlist)
`tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`.
Per-agent override: `agents.list[].tools.profile`.
Profiles:
- `minimal`: `session_status` only
- `coding`: `group:fs`, `group:runtime`, `group:sessions`, `group:memory`, `image`
- `messaging`: `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status`
- `full`: no restriction (same as unset)
Example (messaging-only by default, allow Slack + Discord tools too):
```json5
{
tools: {
profile: "messaging",
allow: ["slack", "discord"]
}
}
```
Example (coding profile, but deny exec/process everywhere):
```json5
{
tools: {
profile: "coding",
deny: ["group:runtime"]
}
}
```
Example (global coding profile, messaging-only support agent):
```json5
{
tools: { profile: "coding" },
agents: {
list: [
{
id: "support",
tools: { profile: "messaging", allow: ["slack"] }
}
]
}
}
```
## Tool groups (shorthands)
Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools.
Use these in `tools.allow` / `tools.deny`.
Available groups:
- `group:runtime`: `exec`, `bash`, `process`
- `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:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
- `group:nodes`: `nodes`
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
Example (allow only file tools + browser):
```json5
{
tools: {
allow: ["group:fs", "browser"]
}
}
```
## Plugins + tools
Plugins can register **additional tools** (and CLI commands) beyond the core set.
+2 -1
View File
@@ -58,7 +58,8 @@ Common flows:
- `clawdhub sync --all`
By default, `clawdhub` installs into `./skills` under your current working
directory; Clawdbot picks that up as `<workspace>/skills` on the next session.
directory (or falls back to the configured Clawdbot workspace). Clawdbot picks
that up as `<workspace>/skills` on the next session.
## Format (AgentSkills + Pi-compatible)
+1 -1
View File
@@ -60,7 +60,7 @@ Text + native (when enabled):
- `/activation mention|always` (groups only)
- `/send on|off|inherit` (owner-only)
- `/reset` or `/new`
- `/think <level>` (aliases: `/thinking`, `/t`)
- `/think <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only; aliases: `/thinking`, `/t`)
- `/verbose on|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
- `/elevated on|off` (alias: `/elev`)
+2 -1
View File
@@ -7,11 +7,12 @@ read_when:
## What it does
- Inline directive in any inbound body: `/t <level>`, `/think:<level>`, or `/thinking <level>`.
- Levels (aliases): `off | minimal | low | medium | high`
- Levels (aliases): `off | minimal | low | medium | high | xhigh` (GPT-5.2 + Codex models only)
- minimal → “think”
- low → “think hard”
- medium → “think harder”
- high → “ultrathink” (max budget)
- xhigh → “ultrathink+” (GPT-5.2 + Codex models only)
- `highest`, `max` map to `high`.
## Resolution order
+6 -2
View File
@@ -77,8 +77,12 @@ clawdbot gateway --tailscale serve
Open:
- `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
By default, the gateway trusts Tailscale identity headers in serve mode. You can still set
`gateway.auth` (or `CLAWDBOT_GATEWAY_TOKEN`) if you want a shared secret instead.
By default, Serve requests can authenticate via Tailscale identity headers
(`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. Clawdbot
only accepts these when the request hits loopback with Tailscale’s
`x-forwarded-*` headers. Set `gateway.auth.allowTailscale: false` (or force
`gateway.auth.mode: "password"`) if you want to require a token/password even
for Serve traffic.
### Bind to tailnet + token
+6
View File
@@ -25,6 +25,12 @@ Authentication is enforced at the WebSocket handshake via `connect.params.auth`
- Re-open anytime: `clawdbot dashboard` (copies link, opens browser if possible, shows SSH hint if headless).
- The token stays local (query param only); the UI strips it after first load and saves it in localStorage.
## Token basics (local vs remote)
- **Localhost**: open `http://127.0.0.1:18789/`. If you see “unauthorized,” run `clawdbot dashboard` and use the tokenized link (`?token=...`).
- **Token source**: `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`); the UI stores it after first load.
- **Not localhost**: use Tailscale Serve (tokenless if `gateway.auth.allowTailscale: true`), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web).
## If you see “unauthorized” / 1008
- Run `clawdbot dashboard` to get a fresh tokenized link.
+4 -1
View File
@@ -94,7 +94,10 @@ Open:
- Binding the Gateway to a non-loopback address **requires** auth (`gateway.auth` or `CLAWDBOT_GATEWAY_TOKEN`).
- The wizard generates a gateway token by default (even on loopback).
- The UI sends `connect.params.auth.token` or `connect.params.auth.password`.
- Use `gateway.auth.allowTailscale: false` to require explicit credentials even in Serve mode.
- With Serve, Tailscale identity headers can satisfy auth when
`gateway.auth.allowTailscale` is `true` (no token/password required). Set
`gateway.auth.allowTailscale: false` to require explicit credentials. See
[Tailscale](/gateway/tailscale) and [Security](/gateway/security).
- `gateway.tailscale.mode: "funnel"` requires `gateway.auth.mode: "password"` (shared password).
## Building the UI
+1 -1
View File
@@ -50,7 +50,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
- `/agent <id>` (or `/agents`)
- `/session <key>` (or `/sessions`)
- `/model <provider/model>` (or `/model list`, `/models`)
- `/think <off|minimal|low|medium|high>`
- `/think <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only)
- `/verbose <on|off>`
- `/reasoning <on|off|stream>` (stream = Telegram draft only)
- `/cost <on|off>`
+5 -5
View File
@@ -132,10 +132,10 @@
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4",
"@mariozechner/pi-agent-core": "^0.43.0",
"@mariozechner/pi-ai": "^0.43.0",
"@mariozechner/pi-coding-agent": "^0.43.0",
"@mariozechner/pi-tui": "^0.43.0",
"@mariozechner/pi-agent-core": "^0.45.3",
"@mariozechner/pi-ai": "^0.45.3",
"@mariozechner/pi-coding-agent": "^0.45.3",
"@mariozechner/pi-tui": "^0.45.3",
"@microsoft/agents-hosting": "^1.1.1",
"@microsoft/agents-hosting-express": "^1.1.1",
"@microsoft/agents-hosting-extensions-teams": "^1.1.1",
@@ -207,7 +207,7 @@
"@sinclair/typebox": "0.34.47"
},
"patchedDependencies": {
"@mariozechner/pi-ai@0.43.0": "patches/@mariozechner__pi-ai@0.43.0.patch"
"@mariozechner/pi-ai@0.45.3": "patches/@mariozechner__pi-ai@0.45.3.patch"
}
},
"vitest": {
@@ -1,8 +1,8 @@
diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js
index 12540bb1069087a0d0a2967f792008627b9f79d9..f30b525620e6d8e45146b439ec3733e4053c9d2a 100644
index cc9e0cb..2b18ec4 100644
--- a/dist/providers/google-gemini-cli.js
+++ b/dist/providers/google-gemini-cli.js
@@ -248,6 +248,11 @@ export const streamGoogleGeminiCli = (model, context, options) => {
@@ -329,6 +329,11 @@ export const streamGoogleGeminiCli = (model, context, options) => {
break; // Success, exit retry loop
}
const errorText = await response.text();
@@ -14,41 +14,8 @@ index 12540bb1069087a0d0a2967f792008627b9f79d9..f30b525620e6d8e45146b439ec3733e4
// Check if retryable
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
// Use server-provided delay or exponential backoff
diff --git a/dist/providers/google-shared.js b/dist/providers/google-shared.js
index ae4710b0f134ac4a48f5b7053f454d1068bee71f..b1b5bd94586f68461ccc44e4a9cdf3acb4e0d084 100644
--- a/dist/providers/google-shared.js
+++ b/dist/providers/google-shared.js
@@ -42,6 +42,8 @@ export function retainThoughtSignature(existing, incoming) {
export function convertMessages(model, context) {
const contents = [];
const transformedMessages = transformMessages(context.messages, model);
+ const shouldStripFunctionId = typeof model.provider === "string" &&
+ model.provider.startsWith("google");
for (const msg of transformedMessages) {
if (msg.role === "user") {
if (typeof msg.content === "string") {
@@ -113,6 +115,9 @@ export function convertMessages(model, context) {
args: block.arguments,
},
};
+ if (shouldStripFunctionId && part?.functionCall?.id) {
+ delete part.functionCall.id; // Google Gemini/Vertex do not support 'id' in functionCall
+ }
if (block.thoughtSignature) {
part.thoughtSignature = block.thoughtSignature;
}
@@ -155,6 +160,9 @@ export function convertMessages(model, context) {
...(hasImages && supportsMultimodalFunctionResponse && { parts: imageParts }),
},
};
+ if (shouldStripFunctionId && functionResponsePart.functionResponse?.id) {
+ delete functionResponsePart.functionResponse.id; // Google Gemini/Vertex do not support 'id' in functionResponse
+ }
// Cloud Code Assist API requires all function responses to be in a single user turn.
// Check if the last content is already a user turn with function responses and merge.
const lastContent = contents[contents.length - 1];
diff --git a/dist/providers/openai-codex-responses.js b/dist/providers/openai-codex-responses.js
index ad0a2aabbe10382cee4e463b68a02864dd235e57..8c001acfd0b4e0743181c246f1bedcf8cd2ffb02 100644
index 7488c79..4c34587 100644
--- a/dist/providers/openai-codex-responses.js
+++ b/dist/providers/openai-codex-responses.js
@@ -517,7 +517,7 @@ function convertTools(tools) {
@@ -61,10 +28,10 @@ index ad0a2aabbe10382cee4e463b68a02864dd235e57..8c001acfd0b4e0743181c246f1bedcf8
}
function mapStopReason(status) {
diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js
index f07085c64390b211340d6a826b28ea9c2e77302f..7f758532246cc7b062df48e9cec4e6c904b76a99 100644
index c4714f4..4d1e6b0 100644
--- a/dist/providers/openai-responses.js
+++ b/dist/providers/openai-responses.js
@@ -396,10 +396,16 @@ function convertMessages(model, context) {
@@ -400,10 +400,16 @@ function convertMessages(model, context) {
}
else if (msg.role === "assistant") {
const output = [];
@@ -81,7 +48,7 @@ index f07085c64390b211340d6a826b28ea9c2e77302f..7f758532246cc7b062df48e9cec4e6c9
const reasoningItem = JSON.parse(block.thinkingSignature);
output.push(reasoningItem);
}
@@ -434,6 +440,16 @@ function convertMessages(model, context) {
@@ -438,6 +444,16 @@ function convertMessages(model, context) {
});
}
}
+1258 -315
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -10,4 +10,4 @@ onlyBuiltDependencies:
- sharp
patchedDependencies:
'@mariozechner/pi-ai@0.43.0': patches/@mariozechner__pi-ai@0.43.0.patch
'@mariozechner/pi-ai@0.45.3': patches/@mariozechner__pi-ai@0.45.3.patch
+128
View File
@@ -0,0 +1,128 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
MOONSHOT_KIMI_K2_CONTEXT_WINDOW,
MOONSHOT_KIMI_K2_COST,
MOONSHOT_KIMI_K2_INPUT,
MOONSHOT_KIMI_K2_MAX_TOKENS,
MOONSHOT_KIMI_K2_MODELS,
} from "../ui/src/ui/data/moonshot-kimi-k2";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "..");
function replaceBlockLines(
text: string,
startMarker: string,
endMarker: string,
lines: string[],
): string {
const startIndex = text.indexOf(startMarker);
if (startIndex === -1) {
throw new Error(`Missing start marker: ${startMarker}`);
}
const endIndex = text.indexOf(endMarker, startIndex);
if (endIndex === -1) {
throw new Error(`Missing end marker: ${endMarker}`);
}
const startLineStart = text.lastIndexOf("\n", startIndex);
const startLineStartIndex = startLineStart === -1 ? 0 : startLineStart + 1;
const indent = text.slice(startLineStartIndex, startIndex);
const endLineEnd = text.indexOf("\n", endIndex);
const endLineEndIndex = endLineEnd === -1 ? text.length : endLineEnd + 1;
const before = text.slice(0, startLineStartIndex);
const after = text.slice(endLineEndIndex);
const replacementLines = [
`${indent}${startMarker}`,
...lines.map((line) => `${indent}${line}`),
`${indent}${endMarker}`,
];
const replacement = replacementLines.join("\n");
if (!after) return `${before}${replacement}`;
return `${before}${replacement}\n${after}`;
}
function renderKimiK2Ids(prefix: string) {
return MOONSHOT_KIMI_K2_MODELS.map(
(model) => `- \`${prefix}${model.id}\``,
);
}
function renderMoonshotAliases() {
return MOONSHOT_KIMI_K2_MODELS.map((model, index) => {
const isLast = index === MOONSHOT_KIMI_K2_MODELS.length - 1;
const suffix = isLast ? "" : ",";
return `"moonshot/${model.id}": { alias: "${model.alias}" }${suffix}`;
});
}
function renderMoonshotModels() {
const input = JSON.stringify([...MOONSHOT_KIMI_K2_INPUT]);
const cost = `input: ${MOONSHOT_KIMI_K2_COST.input}, output: ${MOONSHOT_KIMI_K2_COST.output}, cacheRead: ${MOONSHOT_KIMI_K2_COST.cacheRead}, cacheWrite: ${MOONSHOT_KIMI_K2_COST.cacheWrite}`;
return MOONSHOT_KIMI_K2_MODELS.flatMap((model, index) => {
const isLast = index === MOONSHOT_KIMI_K2_MODELS.length - 1;
const closing = isLast ? "}" : "},";
return [
"{",
` id: "${model.id}",`,
` name: "${model.name}",`,
` reasoning: ${model.reasoning},`,
` input: ${input},`,
` cost: { ${cost} },`,
` contextWindow: ${MOONSHOT_KIMI_K2_CONTEXT_WINDOW},`,
` maxTokens: ${MOONSHOT_KIMI_K2_MAX_TOKENS}`,
closing,
];
});
}
async function syncMoonshotDocs() {
const moonshotDoc = path.join(repoRoot, "docs/providers/moonshot.md");
const conceptsDoc = path.join(
repoRoot,
"docs/concepts/model-providers.md",
);
let moonshotText = await readFile(moonshotDoc, "utf8");
moonshotText = replaceBlockLines(
moonshotText,
"<!-- moonshot-kimi-k2-ids:start -->",
"<!-- moonshot-kimi-k2-ids:end -->",
renderKimiK2Ids(""),
);
moonshotText = replaceBlockLines(
moonshotText,
"// moonshot-kimi-k2-aliases:start",
"// moonshot-kimi-k2-aliases:end",
renderMoonshotAliases(),
);
moonshotText = replaceBlockLines(
moonshotText,
"// moonshot-kimi-k2-models:start",
"// moonshot-kimi-k2-models:end",
renderMoonshotModels(),
);
let conceptsText = await readFile(conceptsDoc, "utf8");
conceptsText = replaceBlockLines(
conceptsText,
"<!-- moonshot-kimi-k2-model-refs:start -->",
"<!-- moonshot-kimi-k2-model-refs:end -->",
renderKimiK2Ids("moonshot/"),
);
await writeFile(moonshotDoc, moonshotText);
await writeFile(conceptsDoc, conceptsText);
}
syncMoonshotDocs().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -20,6 +20,10 @@ docker run --rm -t \
--entrypoint bash \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e HOME=/home/node \
-e CLAWDBOT_LIVE_TEST=1 \
-e CLAWDBOT_LIVE_GATEWAY_MODELS="${CLAWDBOT_LIVE_GATEWAY_MODELS:-all}" \
-e CLAWDBOT_LIVE_GATEWAY_PROVIDERS="${CLAWDBOT_LIVE_GATEWAY_PROVIDERS:-}" \
-e CLAWDBOT_LIVE_GATEWAY_MODEL_TIMEOUT_MS="${CLAWDBOT_LIVE_GATEWAY_MODEL_TIMEOUT_MS:-}" \
-v "$CONFIG_DIR":/home/node/.clawdbot \
-v "$WORKSPACE_DIR":/home/node/clawd \
"${PROFILE_MOUNT[@]}" \
+3 -1
View File
@@ -21,8 +21,10 @@ docker run --rm -t \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e HOME=/home/node \
-e CLAWDBOT_LIVE_TEST=1 \
-e CLAWDBOT_LIVE_ALL_MODELS=1 \
-e CLAWDBOT_LIVE_MODELS="${CLAWDBOT_LIVE_MODELS:-all}" \
-e CLAWDBOT_LIVE_PROVIDERS="${CLAWDBOT_LIVE_PROVIDERS:-}" \
-e CLAWDBOT_LIVE_MODEL_TIMEOUT_MS="${CLAWDBOT_LIVE_MODEL_TIMEOUT_MS:-}" \
-e CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS="${CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS:-}" \
-v "$CONFIG_DIR":/home/node/.clawdbot \
-v "$WORKSPACE_DIR":/home/node/clawd \
"${PROFILE_MOUNT[@]}" \
+1 -1
View File
@@ -49,5 +49,5 @@ clawdhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.2.0 --
Notes
- Default registry: https://clawdhub.com (override with CLAWDHUB_REGISTRY or --registry)
- Default workdir: cwd; install dir: ./skills (override with --workdir / --dir)
- Default workdir: cwd (falls back to Clawdbot workspace); install dir: ./skills (override with --workdir / --dir / CLAWDHUB_WORKDIR)
- Update command hashes local files, resolves matching version, and upgrades to latest unless --version is set
+67 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveAgentConfig } from "./agent-scope.js";
import {
resolveAgentConfig,
resolveAgentModelFallbacksOverride,
resolveAgentModelPrimary,
} from "./agent-scope.js";
describe("resolveAgentConfig", () => {
it("should return undefined when no agents config exists", () => {
@@ -47,6 +51,68 @@ describe("resolveAgentConfig", () => {
});
});
it("supports per-agent model primary+fallbacks", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
model: {
primary: "anthropic/claude-sonnet-4",
fallbacks: ["openai/gpt-5-nano"],
},
},
list: [
{
id: "linus",
model: {
primary: "anthropic/claude-opus-4",
fallbacks: ["openai/gpt-5.2"],
},
},
],
},
};
expect(resolveAgentModelPrimary(cfg, "linus")).toBe(
"anthropic/claude-opus-4",
);
expect(resolveAgentModelFallbacksOverride(cfg, "linus")).toEqual([
"openai/gpt-5.2",
]);
// If fallbacks isn't present, we don't override the global fallbacks.
const cfgNoOverride: ClawdbotConfig = {
agents: {
list: [
{
id: "linus",
model: {
primary: "anthropic/claude-opus-4",
},
},
],
},
};
expect(resolveAgentModelFallbacksOverride(cfgNoOverride, "linus")).toBe(
undefined,
);
// Explicit empty list disables global fallbacks for that agent.
const cfgDisable: ClawdbotConfig = {
agents: {
list: [
{
id: "linus",
model: {
primary: "anthropic/claude-opus-4",
fallbacks: [],
},
},
],
},
};
expect(resolveAgentModelFallbacksOverride(cfgDisable, "linus")).toEqual([]);
});
it("should return agent-specific sandbox config", () => {
const cfg: ClawdbotConfig = {
agents: {
+28 -2
View File
@@ -21,7 +21,7 @@ type ResolvedAgentConfig = {
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
model?: AgentEntry["model"];
memorySearch?: AgentEntry["memorySearch"];
humanDelay?: AgentEntry["humanDelay"];
identity?: AgentEntry["identity"];
@@ -95,7 +95,11 @@ export function resolveAgentConfig(
workspace:
typeof entry.workspace === "string" ? entry.workspace : undefined,
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
model: typeof entry.model === "string" ? entry.model : undefined,
model:
typeof entry.model === "string" ||
(entry.model && typeof entry.model === "object")
? entry.model
: undefined,
memorySearch: entry.memorySearch,
humanDelay: entry.humanDelay,
identity: entry.identity,
@@ -109,6 +113,28 @@ export function resolveAgentConfig(
};
}
export function resolveAgentModelPrimary(
cfg: ClawdbotConfig,
agentId: string,
): string | undefined {
const raw = resolveAgentConfig(cfg, agentId)?.model;
if (!raw) return undefined;
if (typeof raw === "string") return raw.trim() || undefined;
const primary = raw.primary?.trim();
return primary || undefined;
}
export function resolveAgentModelFallbacksOverride(
cfg: ClawdbotConfig,
agentId: string,
): string[] | undefined {
const raw = resolveAgentConfig(cfg, agentId)?.model;
if (!raw || typeof raw === "string") return undefined;
// Important: treat an explicitly provided empty array as an override to disable global fallbacks.
if (!Object.hasOwn(raw, "fallbacks")) return undefined;
return Array.isArray(raw.fallbacks) ? raw.fallbacks : undefined;
}
export function resolveAgentWorkspaceDir(cfg: ClawdbotConfig, agentId: string) {
const id = normalizeAgentId(agentId);
const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();
+106
View File
@@ -0,0 +1,106 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
type AuthProfileStore,
ensureAuthProfileStore,
resolveApiKeyForProfile,
} from "./auth-profiles.js";
import {
CHUTES_TOKEN_ENDPOINT,
type ChutesStoredOAuth,
} from "./chutes-oauth.js";
describe("auth-profiles (chutes)", () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
const previousChutesClientId = process.env.CHUTES_CLIENT_ID;
let tempDir: string | null = null;
afterEach(async () => {
vi.unstubAllGlobals();
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
tempDir = null;
}
if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
if (previousPiAgentDir === undefined)
delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
if (previousChutesClientId === undefined)
delete process.env.CHUTES_CLIENT_ID;
else process.env.CHUTES_CLIENT_ID = previousChutesClientId;
});
it("refreshes expired Chutes OAuth credentials", async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-chutes-"));
process.env.CLAWDBOT_STATE_DIR = tempDir;
process.env.CLAWDBOT_AGENT_DIR = path.join(
tempDir,
"agents",
"main",
"agent",
);
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
const authProfilePath = path.join(
tempDir,
"agents",
"main",
"agent",
"auth-profiles.json",
);
await fs.mkdir(path.dirname(authProfilePath), { recursive: true });
const store: AuthProfileStore = {
version: 1,
profiles: {
"chutes:default": {
type: "oauth",
provider: "chutes",
access: "at_old",
refresh: "rt_old",
expires: Date.now() - 60_000,
clientId: "cid_test",
} as unknown as ChutesStoredOAuth,
},
};
await fs.writeFile(authProfilePath, `${JSON.stringify(store)}\n`);
const fetchSpy = vi.fn(async (input: string | URL) => {
const url = typeof input === "string" ? input : input.toString();
if (url !== CHUTES_TOKEN_ENDPOINT)
return new Response("not found", { status: 404 });
return new Response(
JSON.stringify({
access_token: "at_new",
expires_in: 3600,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
vi.stubGlobal("fetch", fetchSpy);
const loaded = ensureAuthProfileStore();
const resolved = await resolveApiKeyForProfile({
store: loaded,
profileId: "chutes:default",
});
expect(resolved?.apiKey).toBe("at_new");
expect(fetchSpy).toHaveBeenCalled();
const persisted = JSON.parse(
await fs.readFile(authProfilePath, "utf8"),
) as {
profiles?: Record<string, { access?: string }>;
};
expect(persisted.profiles?.["chutes:default"]?.access).toBe("at_new");
});
});
+24 -16
View File
@@ -15,6 +15,7 @@ import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
import { createSubsystemLogger } from "../logging.js";
import { resolveUserPath } from "../utils.js";
import { resolveClawdbotAgentDir } from "./agent-paths.js";
import { refreshChutesTokens } from "./chutes-oauth.js";
import {
readClaudeCliCredentialsCached,
readCodexCliCredentialsCached,
@@ -67,7 +68,8 @@ export type TokenCredential = {
export type OAuthCredential = OAuthCredentials & {
type: "oauth";
provider: OAuthProvider;
provider: string;
clientId?: string;
email?: string;
};
@@ -171,7 +173,7 @@ async function updateAuthProfileStoreWithLock(params: {
}
function buildOAuthApiKey(
provider: OAuthProvider,
provider: string,
credentials: OAuthCredentials,
): string {
const needsProjectId =
@@ -186,7 +188,6 @@ function buildOAuthApiKey(
async function refreshOAuthTokenWithLock(params: {
profileId: string;
provider: OAuthProvider;
agentDir?: string;
}): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {
const authPath = resolveAuthStorePath(params.agentDir);
@@ -212,7 +213,16 @@ async function refreshOAuthTokenWithLock(params: {
const oauthCreds: Record<string, OAuthCredentials> = {
[cred.provider]: cred,
};
const result = await getOAuthApiKey(cred.provider, oauthCreds);
const result =
String(cred.provider) === "chutes"
? await (async () => {
const newCredentials = await refreshChutesTokens({
credential: cred,
});
return { apiKey: newCredentials.access, newCredentials };
})()
: await getOAuthApiKey(cred.provider as OAuthProvider, oauthCreds);
if (!result) return null;
store.profiles[params.profileId] = {
...cred,
@@ -259,7 +269,7 @@ function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
}
entries[key] = {
...typed,
provider: typed.provider ?? (key as OAuthProvider),
provider: String(typed.provider ?? key),
} as AuthProfileCredential;
}
return Object.keys(entries).length > 0 ? entries : null;
@@ -326,7 +336,7 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
if (store.profiles[profileId]) continue;
store.profiles[profileId] = {
type: "oauth",
provider: provider as OAuthProvider,
provider,
...creds,
};
mutated = true;
@@ -468,7 +478,7 @@ function syncExternalCliCredentials(
const existingCodex = store.profiles[CODEX_CLI_PROFILE_ID];
const shouldSyncCodex =
!existingCodex ||
existingCodex.provider !== ("openai-codex" as OAuthProvider) ||
existingCodex.provider !== "openai-codex" ||
!isExternalProfileFresh(existingCodex, now);
const codexCreds = shouldSyncCodex
? readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS })
@@ -480,7 +490,7 @@ function syncExternalCliCredentials(
// Codex creds don't carry expiry; use file mtime heuristic for freshness.
const shouldUpdate =
!existingOAuth ||
existingOAuth.provider !== ("openai-codex" as unknown as OAuthProvider) ||
existingOAuth.provider !== "openai-codex" ||
existingOAuth.expires <= now ||
codexCreds.expires > existingOAuth.expires;
@@ -525,14 +535,14 @@ export function loadAuthProfileStore(): AuthProfileStore {
if (cred.type === "api_key") {
store.profiles[profileId] = {
type: "api_key",
provider: cred.provider ?? (provider as OAuthProvider),
provider: String(cred.provider ?? provider),
key: cred.key,
...(cred.email ? { email: cred.email } : {}),
};
} else if (cred.type === "token") {
store.profiles[profileId] = {
type: "token",
provider: cred.provider ?? (provider as OAuthProvider),
provider: String(cred.provider ?? provider),
token: cred.token,
...(typeof cred.expires === "number"
? { expires: cred.expires }
@@ -542,7 +552,7 @@ export function loadAuthProfileStore(): AuthProfileStore {
} else {
store.profiles[profileId] = {
type: "oauth",
provider: cred.provider ?? (provider as OAuthProvider),
provider: String(cred.provider ?? provider),
access: cred.access,
refresh: cred.refresh,
expires: cred.expires,
@@ -590,14 +600,14 @@ export function ensureAuthProfileStore(
if (cred.type === "api_key") {
store.profiles[profileId] = {
type: "api_key",
provider: cred.provider ?? (provider as OAuthProvider),
provider: String(cred.provider ?? provider),
key: cred.key,
...(cred.email ? { email: cred.email } : {}),
};
} else if (cred.type === "token") {
store.profiles[profileId] = {
type: "token",
provider: cred.provider ?? (provider as OAuthProvider),
provider: String(cred.provider ?? provider),
token: cred.token,
...(typeof cred.expires === "number"
? { expires: cred.expires }
@@ -607,7 +617,7 @@ export function ensureAuthProfileStore(
} else {
store.profiles[profileId] = {
type: "oauth",
provider: cred.provider ?? (provider as OAuthProvider),
provider: String(cred.provider ?? provider),
access: cred.access,
refresh: cred.refresh,
expires: cred.expires,
@@ -1221,7 +1231,6 @@ export async function resolveApiKeyForProfile(params: {
try {
const result = await refreshOAuthTokenWithLock({
profileId,
provider: cred.provider,
agentDir: params.agentDir,
});
if (!result) return null;
@@ -1341,7 +1350,6 @@ async function tryResolveOAuthProfile(params: {
const refreshed = await refreshOAuthTokenWithLock({
profileId,
provider: cred.provider,
agentDir: params.agentDir,
});
if (!refreshed) return null;
-14
View File
@@ -40,20 +40,6 @@ const DEFAULT_PATH =
process.env.PATH ??
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
// NOTE: Using Type.Unsafe with enum instead of Type.Union([Type.Literal(...)])
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
// Type.Union of literals compiles to { anyOf: [{enum:["a"]}, {enum:["b"]}, ...] }
// which is valid but not accepted. A flat enum { type: "string", enum: [...] } works.
const _stringEnum = <T extends readonly string[]>(
values: T,
options?: { description?: string },
) =>
Type.Unsafe<T[number]>({
type: "string",
enum: values as unknown as string[],
...options,
});
export type ExecToolDefaults = {
backgroundMs?: number;
timeoutSec?: number;
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import {
CHUTES_TOKEN_ENDPOINT,
CHUTES_USERINFO_ENDPOINT,
exchangeChutesCodeForTokens,
refreshChutesTokens,
} from "./chutes-oauth.js";
describe("chutes-oauth", () => {
it("exchanges code for tokens and stores username as email", async () => {
const fetchFn: typeof fetch = async (input, init) => {
const url = String(input);
if (url === CHUTES_TOKEN_ENDPOINT) {
expect(init?.method).toBe("POST");
expect(
String(
init?.headers &&
(init.headers as Record<string, string>)["Content-Type"],
),
).toContain("application/x-www-form-urlencoded");
return new Response(
JSON.stringify({
access_token: "at_123",
refresh_token: "rt_123",
expires_in: 3600,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url === CHUTES_USERINFO_ENDPOINT) {
expect(
String(
init?.headers &&
(init.headers as Record<string, string>).Authorization,
),
).toBe("Bearer at_123");
return new Response(
JSON.stringify({ username: "fred", sub: "sub_1" }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
}
return new Response("not found", { status: 404 });
};
const now = 1_000_000;
const creds = await exchangeChutesCodeForTokens({
app: {
clientId: "cid_test",
redirectUri: "http://127.0.0.1:1456/oauth-callback",
scopes: ["openid"],
},
code: "code_123",
codeVerifier: "verifier_123",
fetchFn,
now,
});
expect(creds.access).toBe("at_123");
expect(creds.refresh).toBe("rt_123");
expect(creds.email).toBe("fred");
expect((creds as unknown as { accountId?: string }).accountId).toBe(
"sub_1",
);
expect((creds as unknown as { clientId?: string }).clientId).toBe(
"cid_test",
);
expect(creds.expires).toBe(now + 3600 * 1000 - 5 * 60 * 1000);
});
it("refreshes tokens using stored client id and falls back to old refresh token", async () => {
const fetchFn: typeof fetch = async (input, init) => {
const url = String(input);
if (url !== CHUTES_TOKEN_ENDPOINT)
return new Response("not found", { status: 404 });
expect(init?.method).toBe("POST");
const body = init?.body as URLSearchParams;
expect(String(body.get("grant_type"))).toBe("refresh_token");
expect(String(body.get("client_id"))).toBe("cid_test");
expect(String(body.get("refresh_token"))).toBe("rt_old");
return new Response(
JSON.stringify({
access_token: "at_new",
expires_in: 1800,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
};
const now = 2_000_000;
const refreshed = await refreshChutesTokens({
credential: {
access: "at_old",
refresh: "rt_old",
expires: now - 10_000,
email: "fred",
clientId: "cid_test",
} as unknown as Parameters<typeof refreshChutesTokens>[0]["credential"],
fetchFn,
now,
});
expect(refreshed.access).toBe("at_new");
expect(refreshed.refresh).toBe("rt_old");
expect(refreshed.expires).toBe(now + 1800 * 1000 - 5 * 60 * 1000);
});
});
+200
View File
@@ -0,0 +1,200 @@
import { createHash, randomBytes } from "node:crypto";
import type { OAuthCredentials } from "@mariozechner/pi-ai";
export const CHUTES_OAUTH_ISSUER = "https://api.chutes.ai";
export const CHUTES_AUTHORIZE_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/authorize`;
export const CHUTES_TOKEN_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/token`;
export const CHUTES_USERINFO_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/userinfo`;
const DEFAULT_EXPIRES_BUFFER_MS = 5 * 60 * 1000;
export type ChutesPkce = { verifier: string; challenge: string };
export type ChutesUserInfo = {
sub?: string;
username?: string;
created_at?: string;
};
export type ChutesOAuthAppConfig = {
clientId: string;
clientSecret?: string;
redirectUri: string;
scopes: string[];
};
export type ChutesStoredOAuth = OAuthCredentials & {
clientId?: string;
};
export function generateChutesPkce(): ChutesPkce {
const verifier = randomBytes(32).toString("hex");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
export function parseOAuthCallbackInput(
input: string,
expectedState: string,
): { code: string; state: string } | { error: string } {
const trimmed = input.trim();
if (!trimmed) return { error: "No input provided" };
try {
const url = new URL(trimmed);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code) return { error: "Missing 'code' parameter in URL" };
if (!state) {
return { error: "Missing 'state' parameter. Paste the full URL." };
}
return { code, state };
} catch {
if (!expectedState) {
return { error: "Paste the full redirect URL, not just the code." };
}
return { code: trimmed, state: expectedState };
}
}
function coerceExpiresAt(expiresInSeconds: number, now: number): number {
const value =
now +
Math.max(0, Math.floor(expiresInSeconds)) * 1000 -
DEFAULT_EXPIRES_BUFFER_MS;
return Math.max(value, now + 30_000);
}
export async function fetchChutesUserInfo(params: {
accessToken: string;
fetchFn?: typeof fetch;
}): Promise<ChutesUserInfo | null> {
const fetchFn = params.fetchFn ?? fetch;
const response = await fetchFn(CHUTES_USERINFO_ENDPOINT, {
headers: { Authorization: `Bearer ${params.accessToken}` },
});
if (!response.ok) return null;
const data = (await response.json()) as unknown;
if (!data || typeof data !== "object") return null;
const typed = data as ChutesUserInfo;
return typed;
}
export async function exchangeChutesCodeForTokens(params: {
app: ChutesOAuthAppConfig;
code: string;
codeVerifier: string;
fetchFn?: typeof fetch;
now?: number;
}): Promise<ChutesStoredOAuth> {
const fetchFn = params.fetchFn ?? fetch;
const now = params.now ?? Date.now();
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: params.app.clientId,
code: params.code,
redirect_uri: params.app.redirectUri,
code_verifier: params.codeVerifier,
});
if (params.app.clientSecret) {
body.set("client_secret", params.app.clientSecret);
}
const response = await fetchFn(CHUTES_TOKEN_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Chutes token exchange failed: ${text}`);
}
const data = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
const access = data.access_token?.trim();
const refresh = data.refresh_token?.trim();
const expiresIn = data.expires_in ?? 0;
if (!access)
throw new Error("Chutes token exchange returned no access_token");
if (!refresh) {
throw new Error("Chutes token exchange returned no refresh_token");
}
const info = await fetchChutesUserInfo({ accessToken: access, fetchFn });
return {
access,
refresh,
expires: coerceExpiresAt(expiresIn, now),
email: info?.username,
accountId: info?.sub,
clientId: params.app.clientId,
} as unknown as ChutesStoredOAuth;
}
export async function refreshChutesTokens(params: {
credential: ChutesStoredOAuth;
fetchFn?: typeof fetch;
now?: number;
}): Promise<ChutesStoredOAuth> {
const fetchFn = params.fetchFn ?? fetch;
const now = params.now ?? Date.now();
const refreshToken = params.credential.refresh?.trim();
if (!refreshToken) {
throw new Error("Chutes OAuth credential is missing refresh token");
}
const clientId =
params.credential.clientId?.trim() ?? process.env.CHUTES_CLIENT_ID?.trim();
if (!clientId) {
throw new Error(
"Missing CHUTES_CLIENT_ID for Chutes OAuth refresh (set env var or re-auth).",
);
}
const clientSecret = process.env.CHUTES_CLIENT_SECRET?.trim() || undefined;
const body = new URLSearchParams({
grant_type: "refresh_token",
client_id: clientId,
refresh_token: refreshToken,
});
if (clientSecret) body.set("client_secret", clientSecret);
const response = await fetchFn(CHUTES_TOKEN_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Chutes token refresh failed: ${text}`);
}
const data = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
const access = data.access_token?.trim();
const newRefresh = data.refresh_token?.trim();
const expiresIn = data.expires_in ?? 0;
if (!access) throw new Error("Chutes token refresh returned no access_token");
return {
...params.credential,
access,
refresh: newRefresh || refreshToken,
expires: coerceExpiresAt(expiresIn, now),
clientId,
} as unknown as ChutesStoredOAuth;
}
+56
View File
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { runCliAgent } from "./cli-runner.js";
const runCommandWithTimeoutMock = vi.fn();
const runExecMock = vi.fn();
vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: (...args: unknown[]) =>
runCommandWithTimeoutMock(...args),
runExec: (...args: unknown[]) => runExecMock(...args),
}));
describe("runCliAgent resume cleanup", () => {
beforeEach(() => {
runCommandWithTimeoutMock.mockReset();
runExecMock.mockReset();
});
it("kills stale resume processes for codex sessions", async () => {
runExecMock.mockResolvedValue({ stdout: "", stderr: "" });
runCommandWithTimeoutMock.mockResolvedValueOnce({
stdout: "ok",
stderr: "",
code: 0,
signal: null,
killed: false,
});
await runCliAgent({
sessionId: "s1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
prompt: "hi",
provider: "codex-cli",
model: "gpt-5.2-codex",
timeoutMs: 1_000,
runId: "run-1",
cliSessionId: "thread-123",
});
if (process.platform === "win32") {
expect(runExecMock).not.toHaveBeenCalled();
return;
}
expect(runExecMock).toHaveBeenCalledTimes(1);
const args = runExecMock.mock.calls[0] ?? [];
expect(args[0]).toBe("pkill");
const pkillArgs = args[1] as string[];
expect(pkillArgs[0]).toBe("-f");
expect(pkillArgs[1]).toContain("codex");
expect(pkillArgs[1]).toContain("resume");
expect(pkillArgs[1]).toContain("thread-123");
});
});
+42 -2
View File
@@ -11,7 +11,7 @@ import type { ClawdbotConfig } from "../config/config.js";
import type { CliBackendConfig } from "../config/types.js";
import { shouldLogVerbose } from "../globals.js";
import { createSubsystemLogger } from "../logging.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { runCommandWithTimeout, runExec } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
import { resolveSessionAgentIds } from "./agent-scope.js";
import { resolveCliBackendConfig } from "./cli-backends.js";
@@ -21,6 +21,7 @@ import {
classifyFailoverReason,
type EmbeddedContextFile,
isFailoverErrorMessage,
resolveBootstrapMaxChars,
} from "./pi-embedded-helpers.js";
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
import { buildAgentSystemPrompt } from "./system-prompt.js";
@@ -32,6 +33,37 @@ import {
const log = createSubsystemLogger("agent/claude-cli");
const CLI_RUN_QUEUE = new Map<string, Promise<unknown>>();
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function cleanupResumeProcesses(
backend: CliBackendConfig,
sessionId: string,
): Promise<void> {
if (process.platform === "win32") return;
const resumeArgs = backend.resumeArgs ?? [];
if (resumeArgs.length === 0) return;
if (!resumeArgs.some((arg) => arg.includes("{sessionId}"))) return;
const commandToken = path.basename(backend.command ?? "").trim();
if (!commandToken) return;
const resumeTokens = resumeArgs.map((arg) =>
arg.replaceAll("{sessionId}", sessionId),
);
const pattern = [commandToken, ...resumeTokens]
.filter(Boolean)
.map((token) => escapeRegex(token))
.join(".*");
if (!pattern) return;
try {
await runExec("pkill", ["-f", pattern]);
} catch {
// ignore missing pkill or no matches
}
}
function enqueueCliRun<T>(key: string, task: () => Promise<T>): Promise<T> {
const prior = CLI_RUN_QUEUE.get(key) ?? Promise.resolve();
const chained = prior.catch(() => undefined).then(task);
@@ -462,7 +494,11 @@ export async function runCliAgent(params: {
await loadWorkspaceBootstrapFiles(workspaceDir),
params.sessionKey ?? params.sessionId,
);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const sessionLabel = params.sessionKey ?? params.sessionId;
const contextFiles = buildBootstrapContextFiles(bootstrapFiles, {
maxChars: resolveBootstrapMaxChars(params.config),
warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`),
});
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
@@ -602,6 +638,10 @@ export async function runCliAgent(params: {
return next;
})();
if (useResume && cliSessionIdToSend) {
await cleanupResumeProcesses(backend, cliSessionIdToSend);
}
const result = await runCommandWithTimeout([backend.command, ...args], {
timeoutMs: params.timeoutMs,
cwd: workspaceDir,
+11
View File
@@ -48,3 +48,14 @@ export function isAnthropicRateLimitError(message: string): boolean {
if (lower.includes("429")) return true;
return false;
}
export function isAnthropicBillingError(message: string): boolean {
const lower = message.toLowerCase();
if (lower.includes("credit balance")) return true;
if (lower.includes("insufficient credit")) return true;
if (lower.includes("insufficient credits")) return true;
if (lower.includes("payment required")) return true;
if (lower.includes("billing") && lower.includes("disabled")) return true;
if (lower.includes("402")) return true;
return false;
}
+5
View File
@@ -20,6 +20,7 @@ const GOOGLE_PREFIXES = ["gemini-3"];
const ZAI_PREFIXES = ["glm-4.7"];
const MINIMAX_PREFIXES = ["minimax-m2.1"];
const XAI_PREFIXES = ["grok-4"];
const SYNTHETIC_PREFIXES = ["hf:minimaxai/minimax-m2.1"];
function matchesPrefix(id: string, prefixes: string[]): boolean {
return prefixes.some((prefix) => id.startsWith(prefix));
@@ -73,6 +74,10 @@ export function isModernModelRef(ref: ModelRef): boolean {
return matchesPrefix(id, XAI_PREFIXES);
}
if (provider === "synthetic") {
return matchesPrefix(id, SYNTHETIC_PREFIXES);
}
if (provider === "openrouter" || provider === "opencode") {
return matchesAny(id, [
...ANTHROPIC_PREFIXES,
+33
View File
@@ -53,4 +53,37 @@ describe("memory search config", () => {
expect(resolved?.query.maxResults).toBe(8);
expect(resolved?.query.minScore).toBe(0.2);
});
it("merges remote defaults with agent overrides", () => {
const cfg = {
agents: {
defaults: {
memorySearch: {
remote: {
baseUrl: "https://default.example/v1",
apiKey: "default-key",
headers: { "X-Default": "on" },
},
},
},
list: [
{
id: "main",
default: true,
memorySearch: {
remote: {
baseUrl: "https://agent.example/v1",
},
},
},
],
},
};
const resolved = resolveMemorySearchConfig(cfg, "main");
expect(resolved?.remote).toEqual({
baseUrl: "https://agent.example/v1",
apiKey: "default-key",
headers: { "X-Default": "on" },
});
});
});
+14
View File
@@ -9,6 +9,11 @@ import { resolveAgentConfig } from "./agent-scope.js";
export type ResolvedMemorySearchConfig = {
enabled: boolean;
provider: "openai" | "local";
remote?: {
baseUrl?: string;
apiKey?: string;
headers?: Record<string, string>;
};
fallback: "openai" | "none";
model: string;
local: {
@@ -60,6 +65,14 @@ function mergeConfig(
): ResolvedMemorySearchConfig {
const enabled = overrides?.enabled ?? defaults?.enabled ?? true;
const provider = overrides?.provider ?? defaults?.provider ?? "openai";
const hasRemote = Boolean(defaults?.remote || overrides?.remote);
const remote = hasRemote
? {
baseUrl: overrides?.remote?.baseUrl ?? defaults?.remote?.baseUrl,
apiKey: overrides?.remote?.apiKey ?? defaults?.remote?.apiKey,
headers: overrides?.remote?.headers ?? defaults?.remote?.headers,
}
: undefined;
const fallback = overrides?.fallback ?? defaults?.fallback ?? "openai";
const model = overrides?.model ?? defaults?.model ?? DEFAULT_MODEL;
const local = {
@@ -112,6 +125,7 @@ function mergeConfig(
return {
enabled,
provider,
remote,
fallback,
model,
local,
+1 -1
View File
@@ -17,7 +17,7 @@ describeLive("minimax live", () => {
api: "anthropic-messages",
provider: "minimax",
baseUrl: MINIMAX_BASE_URL,
reasoning: MINIMAX_MODEL === "MiniMax-M2",
reasoning: false,
input: ["text"],
// Pricing: placeholder values (per 1M tokens, multiplied by 1000 for display)
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
+24
View File
@@ -236,4 +236,28 @@ describe("getApiKeyForModel", () => {
}
}
});
it("resolves Synthetic API key from env", async () => {
const previousSynthetic = process.env.SYNTHETIC_API_KEY;
try {
process.env.SYNTHETIC_API_KEY = "synthetic-test-key";
vi.resetModules();
const { resolveApiKeyForProvider } = await import("./model-auth.js");
const resolved = await resolveApiKeyForProvider({
provider: "synthetic",
store: { version: 1, profiles: {} },
});
expect(resolved.apiKey).toBe("synthetic-test-key");
expect(resolved.source).toContain("SYNTHETIC_API_KEY");
} finally {
if (previousSynthetic === undefined) {
delete process.env.SYNTHETIC_API_KEY;
} else {
process.env.SYNTHETIC_API_KEY = previousSynthetic;
}
}
});
});
+5
View File
@@ -125,6 +125,10 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
return pick("ANTHROPIC_OAUTH_TOKEN") ?? pick("ANTHROPIC_API_KEY");
}
if (normalized === "chutes") {
return pick("CHUTES_OAUTH_TOKEN") ?? pick("CHUTES_API_KEY");
}
if (normalized === "zai") {
return pick("ZAI_API_KEY") ?? pick("Z_AI_API_KEY");
}
@@ -148,6 +152,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
openrouter: "OPENROUTER_API_KEY",
moonshot: "MOONSHOT_API_KEY",
minimax: "MINIMAX_API_KEY",
synthetic: "SYNTHETIC_API_KEY",
mistral: "MISTRAL_API_KEY",
opencode: "OPENCODE_API_KEY",
};
+169 -7
View File
@@ -8,7 +8,7 @@ function makeCfg(overrides: Partial<ClawdbotConfig> = {}): ClawdbotConfig {
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
primary: "openai/gpt-5-nano",
fallbacks: ["anthropic/claude-haiku-3-5"],
},
},
@@ -29,7 +29,7 @@ describe("runWithModelFallback", () => {
runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
model: "gpt-5-nano",
run,
}),
).rejects.toThrow("bad request");
@@ -46,7 +46,7 @@ describe("runWithModelFallback", () => {
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
model: "gpt-5-nano",
run,
});
@@ -68,7 +68,7 @@ describe("runWithModelFallback", () => {
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
model: "gpt-5-nano",
run,
});
@@ -92,7 +92,169 @@ describe("runWithModelFallback", () => {
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
model: "gpt-5-nano",
run,
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("falls back on credential validation errors", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(
new Error('No credentials found for profile "anthropic:claude-cli".'),
)
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-opus-4",
run,
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("does not append configured primary when fallbacksOverride is set", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-5-nano",
},
},
},
});
const run = vi
.fn()
.mockImplementation(() =>
Promise.reject(Object.assign(new Error("nope"), { status: 401 })),
);
await expect(
runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-opus-4-5",
fallbacksOverride: ["anthropic/claude-haiku-3-5"],
run,
}),
).rejects.toThrow("All models failed");
expect(run.mock.calls).toEqual([
["anthropic", "claude-opus-4-5"],
["anthropic", "claude-haiku-3-5"],
]);
});
it("uses fallbacksOverride instead of agents.defaults.model.fallbacks", async () => {
const cfg = {
agents: {
defaults: {
model: {
fallbacks: ["openai/gpt-5.2"],
},
},
},
} as ClawdbotConfig;
const calls: Array<{ provider: string; model: string }> = [];
const res = await runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-opus-4-5",
fallbacksOverride: ["openai/gpt-5-nano"],
run: async (provider, model) => {
calls.push({ provider, model });
if (provider === "anthropic") {
throw Object.assign(new Error("nope"), { status: 401 });
}
if (provider === "openai" && model === "gpt-5-nano") {
return "ok";
}
throw new Error(`unexpected candidate: ${provider}/${model}`);
},
});
expect(res.result).toBe("ok");
expect(calls).toEqual([
{ provider: "anthropic", model: "claude-opus-4-5" },
{ provider: "openai", model: "gpt-5-nano" },
]);
});
it("treats an empty fallbacksOverride as disabling global fallbacks", async () => {
const cfg = {
agents: {
defaults: {
model: {
fallbacks: ["openai/gpt-5.2"],
},
},
},
} as ClawdbotConfig;
const calls: Array<{ provider: string; model: string }> = [];
await expect(
runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-opus-4-5",
fallbacksOverride: [],
run: async (provider, model) => {
calls.push({ provider, model });
throw new Error("primary failed");
},
}),
).rejects.toThrow("primary failed");
expect(calls).toEqual([
{ provider: "anthropic", model: "claude-opus-4-5" },
]);
});
it("falls back on missing API key errors", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(new Error("No API key found for profile openai."))
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-5-nano",
run,
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("falls back on lowercase credential errors", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(new Error("no api key found for profile openai"))
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-5-nano",
run,
});
@@ -107,7 +269,7 @@ describe("runWithModelFallback", () => {
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
primary: "openai/gpt-5-nano",
fallbacks: [],
},
},
@@ -130,6 +292,6 @@ describe("runWithModelFallback", () => {
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(result.provider).toBe("openai");
expect(result.model).toBe("gpt-4.1-mini");
expect(result.model).toBe("gpt-5-nano");
});
});
+16 -2
View File
@@ -126,6 +126,8 @@ function resolveFallbackCandidates(params: {
cfg: ClawdbotConfig | undefined;
provider: string;
model: string;
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
}): ModelCandidate[] {
const provider = params.provider.trim() || DEFAULT_PROVIDER;
const model = params.model.trim() || DEFAULT_MODEL;
@@ -159,6 +161,7 @@ function resolveFallbackCandidates(params: {
addCandidate({ provider, model }, false);
const modelFallbacks = (() => {
if (params.fallbacksOverride !== undefined) return params.fallbacksOverride;
const model = params.cfg?.agents?.defaults?.model as
| { fallbacks?: string[] }
| string
@@ -177,7 +180,11 @@ function resolveFallbackCandidates(params: {
addCandidate(resolved.ref, true);
}
if (primary?.provider && primary.model) {
if (
params.fallbacksOverride === undefined &&
primary?.provider &&
primary.model
) {
addCandidate({ provider: primary.provider, model: primary.model }, false);
}
@@ -188,6 +195,8 @@ export async function runWithModelFallback<T>(params: {
cfg: ClawdbotConfig | undefined;
provider: string;
model: string;
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
run: (provider: string, model: string) => Promise<T>;
onError?: (attempt: {
provider: string;
@@ -202,7 +211,12 @@ export async function runWithModelFallback<T>(params: {
model: string;
attempts: FallbackAttempt[];
}> {
const candidates = resolveFallbackCandidates(params);
const candidates = resolveFallbackCandidates({
cfg: params.cfg,
provider: params.provider,
model: params.model,
fallbacksOverride: params.fallbacksOverride,
});
const attempts: FallbackAttempt[] = [];
let lastError: unknown;
+30 -8
View File
@@ -13,8 +13,8 @@ import {
const catalog = [
{
provider: "openai",
id: "gpt-4",
name: "GPT-4",
id: "gpt-5-nano",
name: "GPT-5 Nano",
},
];
@@ -24,7 +24,7 @@ describe("buildAllowedModelSet", () => {
agents: {
defaults: {
models: {
"openai/gpt-4": { alias: "gpt4" },
"openai/gpt-5-nano": { alias: "gpt5nano" },
},
},
},
@@ -38,7 +38,9 @@ describe("buildAllowedModelSet", () => {
});
expect(allowed.allowAny).toBe(false);
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-5-nano"))).toBe(
true,
);
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(
true,
);
@@ -57,7 +59,9 @@ describe("buildAllowedModelSet", () => {
});
expect(allowed.allowAny).toBe(true);
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-5-nano"))).toBe(
true,
);
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(
true,
);
@@ -107,6 +111,24 @@ describe("parseModelRef", () => {
model: "claude-opus-4-5",
});
});
it("normalizes google gemini 3 models to preview ids", () => {
expect(parseModelRef("google/gemini-3-pro", "anthropic")).toEqual({
provider: "google",
model: "gemini-3-pro-preview",
});
expect(parseModelRef("google/gemini-3-flash", "anthropic")).toEqual({
provider: "google",
model: "gemini-3-flash-preview",
});
});
it("normalizes default-provider google models", () => {
expect(parseModelRef("gemini-3-pro", "google")).toEqual({
provider: "google",
model: "gemini-3-pro-preview",
});
});
});
describe("resolveHooksGmailModel", () => {
@@ -218,7 +240,7 @@ describe("resolveAllowedModelRef", () => {
agents: {
defaults: {
models: {
"openai/gpt-4": { alias: "GPT4" },
"openai/gpt-5-nano": { alias: "GPT5NANO" },
},
},
},
@@ -226,12 +248,12 @@ describe("resolveAllowedModelRef", () => {
const resolved = resolveAllowedModelRef({
cfg,
catalog: [
{ provider: "openai", id: "gpt-4", name: "GPT-4" },
{ provider: "openai", id: "gpt-5-nano", name: "GPT-5 Nano" },
{ provider: "anthropic", id: "claude-sonnet-4-1", name: "Sonnet" },
],
raw: "anthropic/claude-sonnet-4-1",
defaultProvider: "openai",
defaultModel: "gpt-4",
defaultModel: "gpt-5-nano",
});
expect(resolved).toEqual({
error: "model not allowed: anthropic/claude-sonnet-4-1",
+16 -5
View File
@@ -1,12 +1,19 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { ModelCatalogEntry } from "./model-catalog.js";
import { normalizeGoogleModelId } from "./models-config.providers.js";
export type ModelRef = {
provider: string;
model: string;
};
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high";
export type ThinkLevel =
| "off"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh";
export type ModelAliasIndex = {
byAlias: Map<string, { alias: string; ref: ModelRef }>;
@@ -47,6 +54,12 @@ function normalizeAnthropicModelId(model: string): string {
return trimmed;
}
function normalizeProviderModelId(provider: string, model: string): string {
if (provider === "anthropic") return normalizeAnthropicModelId(model);
if (provider === "google") return normalizeGoogleModelId(model);
return model;
}
export function parseModelRef(
raw: string,
defaultProvider: string,
@@ -56,16 +69,14 @@ export function parseModelRef(
const slash = trimmed.indexOf("/");
if (slash === -1) {
const provider = normalizeProviderId(defaultProvider);
const model =
provider === "anthropic" ? normalizeAnthropicModelId(trimmed) : trimmed;
const model = normalizeProviderModelId(provider, trimmed);
return { provider, model };
}
const providerRaw = trimmed.slice(0, slash).trim();
const provider = normalizeProviderId(providerRaw);
const model = trimmed.slice(slash + 1).trim();
if (!provider || !model) return null;
const normalizedModel =
provider === "anthropic" ? normalizeAnthropicModelId(model) : model;
const normalizedModel = normalizeProviderModelId(provider, model);
return { provider, model: normalizedModel };
}
+82
View File
@@ -1,9 +1,18 @@
import type { ClawdbotConfig } from "../config/config.js";
import {
DEFAULT_COPILOT_API_BASE_URL,
resolveCopilotApiToken,
} from "../providers/github-copilot-token.js";
import {
ensureAuthProfileStore,
listProfilesForProvider,
} from "./auth-profiles.js";
import { resolveEnvApiKey } from "./model-auth.js";
import {
buildSyntheticModelDefinition,
SYNTHETIC_BASE_URL,
SYNTHETIC_MODEL_CATALOG,
} from "./synthetic-models.js";
type ModelsConfig = NonNullable<ClawdbotConfig["models"]>;
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
@@ -180,6 +189,14 @@ function buildMoonshotProvider(): ProviderConfig {
};
}
function buildSyntheticProvider(): ProviderConfig {
return {
baseUrl: SYNTHETIC_BASE_URL,
api: "anthropic-messages",
models: SYNTHETIC_MODEL_CATALOG.map(buildSyntheticModelDefinition),
};
}
export function resolveImplicitProviders(params: {
agentDir: string;
}): ModelsConfig["providers"] {
@@ -202,5 +219,70 @@ export function resolveImplicitProviders(params: {
providers.moonshot = { ...buildMoonshotProvider(), apiKey: moonshotKey };
}
const syntheticKey =
resolveEnvApiKeyVarName("synthetic") ??
resolveApiKeyFromProfiles({ provider: "synthetic", store: authStore });
if (syntheticKey) {
providers.synthetic = { ...buildSyntheticProvider(), apiKey: syntheticKey };
}
return providers;
}
export async function resolveImplicitCopilotProvider(params: {
agentDir: string;
env?: NodeJS.ProcessEnv;
}): Promise<ProviderConfig | null> {
const env = params.env ?? process.env;
const authStore = ensureAuthProfileStore(params.agentDir);
const hasProfile =
listProfilesForProvider(authStore, "github-copilot").length > 0;
const envToken = env.COPILOT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN;
const githubToken = (envToken ?? "").trim();
if (!hasProfile && !githubToken) return null;
let selectedGithubToken = githubToken;
if (!selectedGithubToken && hasProfile) {
// Use the first available profile as a default for discovery (it will be
// re-resolved per-run by the embedded runner).
const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
const profile = profileId ? authStore.profiles[profileId] : undefined;
if (profile && profile.type === "token") {
selectedGithubToken = profile.token;
}
}
let baseUrl = DEFAULT_COPILOT_API_BASE_URL;
if (selectedGithubToken) {
try {
const token = await resolveCopilotApiToken({
githubToken: selectedGithubToken,
env,
});
baseUrl = token.baseUrl;
} catch {
baseUrl = DEFAULT_COPILOT_API_BASE_URL;
}
}
// pi-coding-agent's ModelRegistry marks a model "available" only if its
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
// Our Copilot auth lives in Clawdbot's auth-profiles store instead, so we also
// write a runtime-only auth.json entry for pi-coding-agent to pick up.
//
// This is safe because it's (1) within Clawdbot's agent dir, (2) contains the
// GitHub token (not the exchanged Copilot token), and (3) matches existing
// patterns for OAuth-like providers in pi-coding-agent.
// Note: we deliberately do not write pi-coding-agent's `auth.json` here.
// Clawdbot uses its own auth store and exchanges tokens at runtime.
// `models list` uses Clawdbot's auth heuristics for availability.
// We intentionally do NOT define custom models for Copilot in models.json.
// pi-coding-agent treats providers with models as replacements requiring apiKey.
// We only override baseUrl; the model list comes from pi-ai built-ins.
return {
baseUrl,
models: [],
} satisfies ProviderConfig;
}
+43
View File
@@ -348,11 +348,13 @@ describe("models config", () => {
const previousGithub = process.env.GITHUB_TOKEN;
const previousMinimax = process.env.MINIMAX_API_KEY;
const previousMoonshot = process.env.MOONSHOT_API_KEY;
const previousSynthetic = process.env.SYNTHETIC_API_KEY;
delete process.env.COPILOT_GITHUB_TOKEN;
delete process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
delete process.env.MINIMAX_API_KEY;
delete process.env.MOONSHOT_API_KEY;
delete process.env.SYNTHETIC_API_KEY;
try {
vi.resetModules();
@@ -381,6 +383,9 @@ describe("models config", () => {
else process.env.MINIMAX_API_KEY = previousMinimax;
if (previousMoonshot === undefined) delete process.env.MOONSHOT_API_KEY;
else process.env.MOONSHOT_API_KEY = previousMoonshot;
if (previousSynthetic === undefined)
delete process.env.SYNTHETIC_API_KEY;
else process.env.SYNTHETIC_API_KEY = previousSynthetic;
}
});
});
@@ -451,6 +456,44 @@ describe("models config", () => {
});
});
it("adds synthetic provider when SYNTHETIC_API_KEY is set", async () => {
await withTempHome(async () => {
vi.resetModules();
const prevKey = process.env.SYNTHETIC_API_KEY;
process.env.SYNTHETIC_API_KEY = "sk-synthetic-test";
try {
const { ensureClawdbotModelsJson } = await import("./models-config.js");
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
await ensureClawdbotModelsJson({});
const modelPath = path.join(resolveClawdbotAgentDir(), "models.json");
const raw = await fs.readFile(modelPath, "utf8");
const parsed = JSON.parse(raw) as {
providers: Record<
string,
{
baseUrl?: string;
apiKey?: string;
models?: Array<{ id: string }>;
}
>;
};
expect(parsed.providers.synthetic?.baseUrl).toBe(
"https://api.synthetic.new/anthropic",
);
expect(parsed.providers.synthetic?.apiKey).toBe("SYNTHETIC_API_KEY");
const ids = parsed.providers.synthetic?.models?.map(
(model) => model.id,
);
expect(ids).toContain("hf:MiniMaxAI/MiniMax-M2.1");
} finally {
if (prevKey === undefined) delete process.env.SYNTHETIC_API_KEY;
else process.env.SYNTHETIC_API_KEY = prevKey;
}
});
});
it("fills missing provider.apiKey from env var name when models exist", async () => {
await withTempHome(async () => {
vi.resetModules();
+2 -67
View File
@@ -2,18 +2,11 @@ import fs from "node:fs/promises";
import path from "node:path";
import { type ClawdbotConfig, loadConfig } from "../config/config.js";
import {
DEFAULT_COPILOT_API_BASE_URL,
resolveCopilotApiToken,
} from "../providers/github-copilot-token.js";
import { resolveClawdbotAgentDir } from "./agent-paths.js";
import {
ensureAuthProfileStore,
listProfilesForProvider,
} from "./auth-profiles.js";
import {
normalizeProviders,
type ProviderConfig,
resolveImplicitCopilotProvider,
resolveImplicitProviders,
} from "./models-config.providers.js";
@@ -85,64 +78,6 @@ async function readJson(pathname: string): Promise<unknown> {
}
}
async function maybeBuildCopilotProvider(params: {
agentDir: string;
env?: NodeJS.ProcessEnv;
}): Promise<ProviderConfig | null> {
const env = params.env ?? process.env;
const authStore = ensureAuthProfileStore(params.agentDir);
const hasProfile =
listProfilesForProvider(authStore, "github-copilot").length > 0;
const envToken = env.COPILOT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN;
const githubToken = (envToken ?? "").trim();
if (!hasProfile && !githubToken) return null;
let selectedGithubToken = githubToken;
if (!selectedGithubToken && hasProfile) {
// Use the first available profile as a default for discovery (it will be
// re-resolved per-run by the embedded runner).
const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
const profile = profileId ? authStore.profiles[profileId] : undefined;
if (profile && profile.type === "token") {
selectedGithubToken = profile.token;
}
}
let baseUrl = DEFAULT_COPILOT_API_BASE_URL;
if (selectedGithubToken) {
try {
const token = await resolveCopilotApiToken({
githubToken: selectedGithubToken,
env,
});
baseUrl = token.baseUrl;
} catch {
baseUrl = DEFAULT_COPILOT_API_BASE_URL;
}
}
// pi-coding-agent's ModelRegistry marks a model "available" only if its
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
// Our Copilot auth lives in Clawdbot's auth-profiles store instead, so we also
// write a runtime-only auth.json entry for pi-coding-agent to pick up.
//
// This is safe because it's (1) within Clawdbot's agent dir, (2) contains the
// GitHub token (not the exchanged Copilot token), and (3) matches existing
// patterns for OAuth-like providers in pi-coding-agent.
// Note: we deliberately do not write pi-coding-agent's `auth.json` here.
// Clawdbot uses its own auth store and exchanges tokens at runtime.
// `models list` uses Clawdbot's auth heuristics for availability.
// We intentionally do NOT define custom models for Copilot in models.json.
// pi-coding-agent treats providers with models as replacements requiring apiKey.
// We only override baseUrl; the model list comes from pi-ai built-ins.
return {
baseUrl,
models: [],
} satisfies ProviderConfig;
}
export async function ensureClawdbotModelsJson(
config?: ClawdbotConfig,
agentDirOverride?: string,
@@ -161,7 +96,7 @@ export async function ensureClawdbotModelsJson(
implicit: implicitProviders,
explicit: explicitProviders,
});
const implicitCopilot = await maybeBuildCopilotProvider({ agentDir });
const implicitCopilot = await resolveImplicitCopilotProvider({ agentDir });
if (implicitCopilot && !providers["github-copilot"]) {
providers["github-copilot"] = implicitCopilot;
}
+50 -3
View File
@@ -9,6 +9,7 @@ import { loadConfig } from "../config/config.js";
import { resolveClawdbotAgentDir } from "./agent-paths.js";
import {
collectAnthropicApiKeys,
isAnthropicBillingError,
isAnthropicRateLimitError,
} from "./live-auth-keys.js";
import { isModernModelRef } from "./live-model-filter.js";
@@ -72,6 +73,18 @@ function toInt(value: string | undefined, fallback: number): number {
return Number.isFinite(parsed) ? parsed : fallback;
}
function resolveTestReasoning(
model: Model<Api>,
): "minimal" | "low" | "medium" | "high" | "xhigh" | undefined {
if (!model.reasoning) return undefined;
const id = model.id.toLowerCase();
if (model.provider === "openai" || model.provider === "openai-codex") {
if (id.includes("pro")) return "high";
return "medium";
}
return "low";
}
async function completeSimpleWithTimeout<TApi extends Api>(
model: Model<TApi>,
context: Parameters<typeof completeSimple<TApi>>[1],
@@ -110,7 +123,7 @@ async function completeOkWithRetry(params: {
},
{
apiKey: params.apiKey,
reasoning: params.model.reasoning ? "low" : undefined,
reasoning: resolveTestReasoning(params.model),
maxTokens: 64,
},
params.timeoutMs,
@@ -255,7 +268,7 @@ describeLive("live models (profile keys)", () => {
},
{
apiKey,
reasoning: model.reasoning ? "low" : undefined,
reasoning: resolveTestReasoning(model),
maxTokens: 128,
},
perModelTimeoutMs,
@@ -295,7 +308,7 @@ describeLive("live models (profile keys)", () => {
},
{
apiKey,
reasoning: model.reasoning ? "low" : undefined,
reasoning: resolveTestReasoning(model),
maxTokens: 64,
},
perModelTimeoutMs,
@@ -335,6 +348,17 @@ describeLive("live models (profile keys)", () => {
logProgress(`${progressLabel}: skip (google model not found)`);
break;
}
if (
ok.text.length === 0 &&
(model.provider === "openrouter" || model.provider === "opencode")
) {
skipped.push({
model: id,
reason: "no text returned (provider returned empty content)",
});
logProgress(`${progressLabel}: skip (empty response)`);
break;
}
expect(ok.text.length).toBeGreaterThan(0);
logProgress(`${progressLabel}: done`);
break;
@@ -350,6 +374,20 @@ describeLive("live models (profile keys)", () => {
);
continue;
}
if (
model.provider === "anthropic" &&
isAnthropicBillingError(message)
) {
if (attempt + 1 < attemptMax) {
logProgress(
`${progressLabel}: billing issue, retrying with next key`,
);
continue;
}
skipped.push({ model: id, reason: message });
logProgress(`${progressLabel}: skip (anthropic billing)`);
break;
}
if (
model.provider === "google" &&
isGoogleModelNotFoundError(err)
@@ -358,6 +396,15 @@ describeLive("live models (profile keys)", () => {
logProgress(`${progressLabel}: skip (google model not found)`);
break;
}
if (
allowNotFoundSkip &&
model.provider === "minimax" &&
message.includes("request ended without sending any chunks")
) {
skipped.push({ model: id, reason: message });
logProgress(`${progressLabel}: skip (minimax empty response)`);
break;
}
logProgress(`${progressLabel}: failed`);
failures.push({ model: id, error: message });
break;
+244 -6
View File
@@ -1,18 +1,25 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import {
buildBootstrapContextFiles,
classifyFailoverReason,
DEFAULT_BOOTSTRAP_MAX_CHARS,
formatAssistantErrorText,
isAuthErrorMessage,
isBillingErrorMessage,
isCloudCodeAssistFormatError,
isCompactionFailureError,
isContextOverflowError,
isFailoverErrorMessage,
isMessagingToolDuplicate,
normalizeTextForComparison,
resolveBootstrapMaxChars,
sanitizeGoogleTurnOrdering,
sanitizeSessionMessagesImages,
sanitizeToolCallId,
stripThoughtSignatures,
} from "./pi-embedded-helpers.js";
import {
DEFAULT_AGENTS_FILENAME,
@@ -45,17 +52,56 @@ describe("buildBootstrapContextFiles", () => {
});
it("truncates large bootstrap content", () => {
const head = `HEAD-${"a".repeat(6000)}`;
const tail = `${"b".repeat(3000)}-TAIL`;
const head = `HEAD-${"a".repeat(600)}`;
const tail = `${"b".repeat(300)}-TAIL`;
const long = `${head}${tail}`;
const files = [makeFile({ content: long })];
const [result] = buildBootstrapContextFiles(files);
const files = [makeFile({ name: "TOOLS.md", content: long })];
const warnings: string[] = [];
const maxChars = 200;
const expectedTailChars = Math.floor(maxChars * 0.2);
const [result] = buildBootstrapContextFiles(files, {
maxChars,
warn: (message) => warnings.push(message),
});
expect(result?.content).toContain(
"[...truncated, read AGENTS.md for full content...]",
"[...truncated, read TOOLS.md for full content...]",
);
expect(result?.content.length).toBeLessThan(long.length);
expect(result?.content.startsWith(long.slice(0, 120))).toBe(true);
expect(result?.content.endsWith(long.slice(-120))).toBe(true);
expect(result?.content.endsWith(long.slice(-expectedTailChars))).toBe(true);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("TOOLS.md");
expect(warnings[0]).toContain("limit 200");
});
it("keeps content under the default limit", () => {
const long = "a".repeat(DEFAULT_BOOTSTRAP_MAX_CHARS - 10);
const files = [makeFile({ content: long })];
const [result] = buildBootstrapContextFiles(files);
expect(result?.content).toBe(long);
expect(result?.content).not.toContain(
"[...truncated, read AGENTS.md for full content...]",
);
});
});
describe("resolveBootstrapMaxChars", () => {
it("returns default when unset", () => {
expect(resolveBootstrapMaxChars()).toBe(DEFAULT_BOOTSTRAP_MAX_CHARS);
});
it("uses configured value when valid", () => {
const cfg = {
agents: { defaults: { bootstrapMaxChars: 12345 } },
} as ClawdbotConfig;
expect(resolveBootstrapMaxChars(cfg)).toBe(12345);
});
it("falls back when invalid", () => {
const cfg = {
agents: { defaults: { bootstrapMaxChars: -1 } },
} as ClawdbotConfig;
expect(resolveBootstrapMaxChars(cfg)).toBe(DEFAULT_BOOTSTRAP_MAX_CHARS);
});
});
@@ -122,6 +168,23 @@ describe("isBillingErrorMessage", () => {
});
});
describe("isAuthErrorMessage", () => {
it("matches credential validation errors", () => {
const samples = [
'No credentials found for profile "anthropic:claude-cli".',
"No API key found for profile openai.",
];
for (const sample of samples) {
expect(isAuthErrorMessage(sample)).toBe(true);
}
});
it("ignores unrelated errors", () => {
expect(isAuthErrorMessage("rate limit exceeded")).toBe(false);
expect(isAuthErrorMessage("billing issue detected")).toBe(false);
});
});
describe("isFailoverErrorMessage", () => {
it("matches auth/rate/billing/timeout", () => {
const samples = [
@@ -140,6 +203,8 @@ describe("isFailoverErrorMessage", () => {
describe("classifyFailoverReason", () => {
it("returns a stable reason", () => {
expect(classifyFailoverReason("invalid api key")).toBe("auth");
expect(classifyFailoverReason("no credentials found")).toBe("auth");
expect(classifyFailoverReason("no api key found")).toBe("auth");
expect(classifyFailoverReason("429 too many requests")).toBe("rate_limit");
expect(classifyFailoverReason("resource has been exhausted")).toBe(
"rate_limit",
@@ -480,3 +545,176 @@ describe("sanitizeSessionMessagesImages", () => {
]);
});
});
describe("normalizeTextForComparison", () => {
it("lowercases text", () => {
expect(normalizeTextForComparison("Hello World")).toBe("hello world");
});
it("trims whitespace", () => {
expect(normalizeTextForComparison(" hello ")).toBe("hello");
});
it("collapses multiple spaces", () => {
expect(normalizeTextForComparison("hello world")).toBe("hello world");
});
it("strips emoji", () => {
expect(normalizeTextForComparison("Hello 👋 World 🌍")).toBe("hello world");
});
it("handles mixed normalization", () => {
expect(normalizeTextForComparison(" Hello 👋 WORLD 🌍 ")).toBe(
"hello world",
);
});
});
describe("stripThoughtSignatures", () => {
it("returns non-array content unchanged", () => {
expect(stripThoughtSignatures("hello")).toBe("hello");
expect(stripThoughtSignatures(null)).toBe(null);
expect(stripThoughtSignatures(undefined)).toBe(undefined);
expect(stripThoughtSignatures(123)).toBe(123);
});
it("removes msg_-prefixed thought_signature from content blocks", () => {
const input = [
{ type: "text", text: "hello", thought_signature: "msg_abc123" },
{ type: "thinking", thinking: "test", thought_signature: "AQID" },
];
const result = stripThoughtSignatures(input);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ type: "text", text: "hello" });
expect(result[1]).toEqual({
type: "thinking",
thinking: "test",
thought_signature: "AQID",
});
expect("thought_signature" in result[0]).toBe(false);
expect("thought_signature" in result[1]).toBe(true);
});
it("preserves blocks without thought_signature", () => {
const input = [
{ type: "text", text: "hello" },
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
];
const result = stripThoughtSignatures(input);
expect(result).toEqual(input);
});
it("handles mixed blocks with and without thought_signature", () => {
const input = [
{ type: "text", text: "hello", thought_signature: "msg_abc" },
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
{ type: "thinking", thinking: "hmm", thought_signature: "msg_xyz" },
];
const result = stripThoughtSignatures(input);
expect(result).toEqual([
{ type: "text", text: "hello" },
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
{ type: "thinking", thinking: "hmm" },
]);
});
it("handles empty array", () => {
expect(stripThoughtSignatures([])).toEqual([]);
});
it("handles null/undefined blocks in array", () => {
const input = [null, undefined, { type: "text", text: "hello" }];
const result = stripThoughtSignatures(input);
expect(result).toEqual([null, undefined, { type: "text", text: "hello" }]);
});
});
describe("sanitizeSessionMessagesImages - thought_signature stripping", () => {
it("strips msg_-prefixed thought_signature from assistant message content blocks", async () => {
const input = [
{
role: "assistant",
content: [
{ type: "text", text: "hello", thought_signature: "msg_abc123" },
{
type: "thinking",
thinking: "reasoning",
thought_signature: "AQID",
},
],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test");
expect(out).toHaveLength(1);
const content = (out[0] as { content?: unknown[] }).content;
expect(content).toHaveLength(2);
expect("thought_signature" in ((content?.[0] ?? {}) as object)).toBe(false);
expect(
(content?.[1] as { thought_signature?: unknown })?.thought_signature,
).toBe("AQID");
});
});
describe("isMessagingToolDuplicate", () => {
it("returns false for empty sentTexts", () => {
expect(isMessagingToolDuplicate("hello world", [])).toBe(false);
});
it("returns false for short texts", () => {
expect(isMessagingToolDuplicate("short", ["short"])).toBe(false);
});
it("detects exact duplicates", () => {
expect(
isMessagingToolDuplicate("Hello, this is a test message!", [
"Hello, this is a test message!",
]),
).toBe(true);
});
it("detects duplicates with different casing", () => {
expect(
isMessagingToolDuplicate("HELLO, THIS IS A TEST MESSAGE!", [
"hello, this is a test message!",
]),
).toBe(true);
});
it("detects duplicates with emoji variations", () => {
expect(
isMessagingToolDuplicate("Hello! 👋 This is a test message!", [
"Hello! This is a test message!",
]),
).toBe(true);
});
it("detects substring duplicates (LLM elaboration)", () => {
expect(
isMessagingToolDuplicate(
'I sent the message: "Hello, this is a test message!"',
["Hello, this is a test message!"],
),
).toBe(true);
});
it("detects when sent text contains block reply (reverse substring)", () => {
expect(
isMessagingToolDuplicate("Hello, this is a test message!", [
'I sent the message: "Hello, this is a test message!"',
]),
).toBe(true);
});
it("returns false for non-matching texts", () => {
expect(
isMessagingToolDuplicate("This is completely different content.", [
"Hello, this is a test message!",
]),
).toBe(false);
});
});
+93 -12
View File
@@ -22,23 +22,88 @@ import type { WorkspaceBootstrapFile } from "./workspace.js";
export type EmbeddedContextFile = { path: string; content: string };
const MAX_BOOTSTRAP_CHARS = 4000;
const BOOTSTRAP_HEAD_CHARS = 2800;
const BOOTSTRAP_TAIL_CHARS = 800;
// ── Cross-provider thought_signature sanitization ──────────────────────────────
// Claude's extended thinking feature generates thought_signature fields (message IDs
// like "msg_abc123...") in content blocks. When these are sent to Google's Gemini API,
// it expects Base64-encoded bytes and rejects Claude's format with a 400 error.
// This function strips thought_signature fields to enable cross-provider session sharing.
function trimBootstrapContent(content: string, fileName: string): string {
type ContentBlockWithSignature = {
thought_signature?: unknown;
[key: string]: unknown;
};
/**
* Strips Claude-style thought_signature fields from content blocks.
*
* Gemini expects thought signatures as base64-encoded bytes, but Claude stores message ids
* like "msg_abc123...". We only strip "msg_*" to preserve any provider-valid signatures.
*/
export function stripThoughtSignatures<T>(content: T): T {
if (!Array.isArray(content)) return content;
return content.map((block) => {
if (!block || typeof block !== "object") return block;
const rec = block as ContentBlockWithSignature;
const signature = rec.thought_signature;
if (typeof signature !== "string" || !signature.startsWith("msg_")) {
return block;
}
const { thought_signature: _signature, ...rest } = rec;
return rest;
}) as T;
}
export const DEFAULT_BOOTSTRAP_MAX_CHARS = 20_000;
const BOOTSTRAP_HEAD_RATIO = 0.7;
const BOOTSTRAP_TAIL_RATIO = 0.2;
type TrimBootstrapResult = {
content: string;
truncated: boolean;
maxChars: number;
originalLength: number;
};
export function resolveBootstrapMaxChars(cfg?: ClawdbotConfig): number {
const raw = cfg?.agents?.defaults?.bootstrapMaxChars;
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
return Math.floor(raw);
}
return DEFAULT_BOOTSTRAP_MAX_CHARS;
}
function trimBootstrapContent(
content: string,
fileName: string,
maxChars: number,
): TrimBootstrapResult {
const trimmed = content.trimEnd();
if (trimmed.length <= MAX_BOOTSTRAP_CHARS) return trimmed;
if (trimmed.length <= maxChars) {
return {
content: trimmed,
truncated: false,
maxChars,
originalLength: trimmed.length,
};
}
const head = trimmed.slice(0, BOOTSTRAP_HEAD_CHARS);
const tail = trimmed.slice(-BOOTSTRAP_TAIL_CHARS);
return [
const headChars = Math.max(1, Math.floor(maxChars * BOOTSTRAP_HEAD_RATIO));
const tailChars = Math.max(1, Math.floor(maxChars * BOOTSTRAP_TAIL_RATIO));
const head = trimmed.slice(0, headChars);
const tail = trimmed.slice(-tailChars);
const contentWithMarker = [
head,
"",
`[...truncated, read ${fileName} for full content...]`,
"",
tail,
].join("\n");
return {
content: contentWithMarker,
truncated: true,
maxChars,
originalLength: trimmed.length,
};
}
export async function ensureSessionHeader(params: {
@@ -138,7 +203,9 @@ export async function sanitizeSessionMessagesImages(
}
const content = assistantMsg.content;
if (Array.isArray(content)) {
const filteredContent = content.filter((block) => {
// Strip thought_signature fields to enable cross-provider session sharing
const strippedContent = stripThoughtSignatures(content);
const filteredContent = strippedContent.filter((block) => {
if (!block || typeof block !== "object") return true;
const rec = block as { type?: unknown; text?: unknown };
if (rec.type !== "text" || typeof rec.text !== "string") return true;
@@ -221,7 +288,9 @@ export function sanitizeGoogleTurnOrdering(
export function buildBootstrapContextFiles(
files: WorkspaceBootstrapFile[],
opts?: { warn?: (message: string) => void; maxChars?: number },
): EmbeddedContextFile[] {
const maxChars = opts?.maxChars ?? DEFAULT_BOOTSTRAP_MAX_CHARS;
const result: EmbeddedContextFile[] = [];
for (const file of files) {
if (file.missing) {
@@ -231,11 +300,20 @@ export function buildBootstrapContextFiles(
});
continue;
}
const trimmed = trimBootstrapContent(file.content ?? "", file.name);
if (!trimmed) continue;
const trimmed = trimBootstrapContent(
file.content ?? "",
file.name,
maxChars,
);
if (!trimmed.content) continue;
if (trimmed.truncated) {
opts?.warn?.(
`workspace bootstrap file ${file.name} is ${trimmed.originalLength} chars (limit ${trimmed.maxChars}); truncating in injected context`,
);
}
result.push({
path: file.name,
content: trimmed,
content: trimmed.content,
});
}
return result;
@@ -361,6 +439,9 @@ const ERROR_PATTERNS = {
"token has expired",
/\b401\b/,
/\b403\b/,
// Credential validation failures should trigger fallback (#761)
"no credentials found",
"no api key found",
],
format: [
"invalid_request_error",
@@ -207,11 +207,11 @@ describe("resolveExtraParams", () => {
expect(result).toBeUndefined();
});
it("returns undefined for openai/gpt-4 with no config", () => {
it("returns undefined for openai/gpt-5-nano with no config", () => {
const result = resolveExtraParams({
cfg: undefined,
provider: "openai",
modelId: "gpt-4",
modelId: "gpt-5-nano",
});
expect(result).toBeUndefined();
@@ -223,7 +223,7 @@ describe("resolveExtraParams", () => {
agents: {
defaults: {
models: {
"openai/gpt-4": {
"openai/gpt-5-nano": {
params: {
logprobs: true,
top_logprobs: 5,
@@ -234,7 +234,7 @@ describe("resolveExtraParams", () => {
},
},
provider: "openai",
modelId: "gpt-4",
modelId: "gpt-5-nano",
});
expect(result).toEqual({
+106 -3
View File
@@ -99,6 +99,7 @@ import {
isRateLimitAssistantError,
isTimeoutErrorMessage,
pickFallbackThinkingLevel,
resolveBootstrapMaxChars,
sanitizeGoogleTurnOrdering,
sanitizeSessionMessagesImages,
validateAnthropicTurns,
@@ -320,6 +321,12 @@ function buildContextPruningExtension(params: {
};
}
function resolveCompactionMode(cfg?: ClawdbotConfig): "default" | "safeguard" {
return cfg?.agents?.defaults?.compaction?.mode === "safeguard"
? "safeguard"
: "default";
}
function buildEmbeddedExtensionPaths(params: {
cfg: ClawdbotConfig | undefined;
sessionManager: SessionManager;
@@ -328,6 +335,9 @@ function buildEmbeddedExtensionPaths(params: {
model: Model<Api> | undefined;
}): string[] {
const paths = [resolvePiExtensionPath("transcript-sanitize")];
if (resolveCompactionMode(params.cfg) === "safeguard") {
paths.push(resolvePiExtensionPath("compaction-safeguard"));
}
const pruning = buildContextPruningExtension(params);
if (pruning.additionalExtensionPaths) {
paths.push(...pruning.additionalExtensionPaths);
@@ -416,6 +426,85 @@ type EmbeddedPiQueueHandle = {
const log = createSubsystemLogger("agent/embedded");
const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap";
const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
"patternProperties",
"additionalProperties",
"$schema",
"$id",
"$ref",
"$defs",
"definitions",
"examples",
"minLength",
"maxLength",
"minimum",
"maximum",
"multipleOf",
"pattern",
"format",
"minItems",
"maxItems",
"uniqueItems",
"minProperties",
"maxProperties",
]);
function findUnsupportedSchemaKeywords(
schema: unknown,
path: string,
): string[] {
if (!schema || typeof schema !== "object") return [];
if (Array.isArray(schema)) {
return schema.flatMap((item, index) =>
findUnsupportedSchemaKeywords(item, `${path}[${index}]`),
);
}
const record = schema as Record<string, unknown>;
const violations: string[] = [];
for (const [key, value] of Object.entries(record)) {
if (GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS.has(key)) {
violations.push(`${path}.${key}`);
}
if (value && typeof value === "object") {
violations.push(
...findUnsupportedSchemaKeywords(value, `${path}.${key}`),
);
}
}
return violations;
}
function logToolSchemasForGoogle(params: {
tools: AgentTool[];
provider: string;
}) {
if (
params.provider !== "google-antigravity" &&
params.provider !== "google-gemini-cli"
) {
return;
}
const toolNames = params.tools.map((tool, index) => `${index}:${tool.name}`);
log.info("google tool schema snapshot", {
provider: params.provider,
toolCount: params.tools.length,
tools: toolNames,
});
for (const [index, tool] of params.tools.entries()) {
const violations = findUnsupportedSchemaKeywords(
tool.parameters,
`${tool.name}.parameters`,
);
if (violations.length > 0) {
log.warn("google tool schema has unsupported keywords", {
index,
tool: tool.name,
violations: violations.slice(0, 12),
violationCount: violations.length,
});
}
}
}
registerUnhandledRejectionHandler((reason) => {
const message = describeUnknownError(reason);
@@ -957,7 +1046,7 @@ export function resolveEmbeddedSessionLane(key: string) {
}
function mapThinkingLevel(level?: ThinkLevel): ThinkingLevel {
// pi-agent-core supports "xhigh" too; Clawdbot doesn't surface it for now.
// pi-agent-core supports "xhigh"; Clawdbot enables it for specific models.
if (!level) return "off";
return level;
}
@@ -1152,7 +1241,12 @@ export async function compactEmbeddedPiSession(params: {
await loadWorkspaceBootstrapFiles(effectiveWorkspace),
params.sessionKey ?? params.sessionId,
);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const sessionLabel = params.sessionKey ?? params.sessionId;
const contextFiles = buildBootstrapContextFiles(bootstrapFiles, {
maxChars: resolveBootstrapMaxChars(params.config),
warn: (message) =>
log.warn(`${message} (sessionKey=${sessionLabel})`),
});
const runAbortController = new AbortController();
const tools = createClawdbotCodingTools({
exec: {
@@ -1172,6 +1266,7 @@ export async function compactEmbeddedPiSession(params: {
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
// No currentChannelId/currentThreadTs for compaction - not in message context
});
logToolSchemasForGoogle({ tools, provider });
const machineName = await getMachineDisplayName();
const runtimeProvider = normalizeMessageProvider(
params.messageProvider,
@@ -1377,6 +1472,7 @@ export async function runEmbeddedPiAgent(params: {
text?: string;
mediaUrls?: string[];
}) => void | Promise<void>;
onAssistantMessageStart?: () => void | Promise<void>;
onBlockReply?: (payload: {
text?: string;
mediaUrls?: string[];
@@ -1584,7 +1680,12 @@ export async function runEmbeddedPiAgent(params: {
await loadWorkspaceBootstrapFiles(effectiveWorkspace),
params.sessionKey ?? params.sessionId,
);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const sessionLabel = params.sessionKey ?? params.sessionId;
const contextFiles = buildBootstrapContextFiles(bootstrapFiles, {
maxChars: resolveBootstrapMaxChars(params.config),
warn: (message) =>
log.warn(`${message} (sessionKey=${sessionLabel})`),
});
// Tool schemas must be provider-compatible (OpenAI requires top-level `type: "object"`).
// `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged.
const tools = createClawdbotCodingTools({
@@ -1608,6 +1709,7 @@ export async function runEmbeddedPiAgent(params: {
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
});
logToolSchemasForGoogle({ tools, provider });
const machineName = await getMachineDisplayName();
const runtimeInfo = {
host: machineName,
@@ -1763,6 +1865,7 @@ export async function runEmbeddedPiAgent(params: {
blockReplyBreak: params.blockReplyBreak,
blockReplyChunking: params.blockReplyChunking,
onPartialReply: params.onPartialReply,
onAssistantMessageStart: params.onAssistantMessageStart,
onAgentEvent: params.onAgentEvent,
enforceFinalTag: params.enforceFinalTag,
});
+9 -2
View File
@@ -146,7 +146,7 @@ function extractMessagingToolSend(
: undefined;
}
export function subscribeEmbeddedPiSession(params: {
export type SubscribeEmbeddedPiSessionParams = {
session: AgentSession;
runId: string;
verboseLevel?: "off" | "on";
@@ -173,12 +173,17 @@ export function subscribeEmbeddedPiSession(params: {
text?: string;
mediaUrls?: string[];
}) => void | Promise<void>;
onAssistantMessageStart?: () => void | Promise<void>;
onAgentEvent?: (evt: {
stream: string;
data: Record<string, unknown>;
}) => void;
enforceFinalTag?: boolean;
}) {
};
export function subscribeEmbeddedPiSession(
params: SubscribeEmbeddedPiSessionParams,
) {
const assistantTexts: string[] = [];
const toolMetas: Array<{ toolName?: string; meta?: string }> = [];
const toolMetaById = new Map<string, string | undefined>();
@@ -492,6 +497,8 @@ export function subscribeEmbeddedPiSession(params: {
// may deliver late text_end updates after message_end, which would
// otherwise re-trigger block replies.
resetAssistantMessageState(assistantTexts.length);
// Use assistant message_start as the earliest "writing" signal for typing.
void params.onAssistantMessageStart?.();
}
}
@@ -0,0 +1,212 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type {
ExtensionAPI,
ExtensionContext,
FileOperations,
} from "@mariozechner/pi-coding-agent";
import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
const MAX_CHUNK_RATIO = 0.4;
const FALLBACK_SUMMARY =
"Summary unavailable due to context limits. Older messages were truncated.";
const TURN_PREFIX_INSTRUCTIONS =
"This summary covers the prefix of a split turn. Focus on the original request," +
" early progress, and any details needed to understand the retained suffix.";
function computeFileLists(fileOps: FileOperations): {
readFiles: string[];
modifiedFiles: string[];
} {
const modified = new Set([...fileOps.edited, ...fileOps.written]);
const readFiles = [...fileOps.read].filter((f) => !modified.has(f)).sort();
const modifiedFiles = [...modified].sort();
return { readFiles, modifiedFiles };
}
function formatFileOperations(
readFiles: string[],
modifiedFiles: string[],
): string {
const sections: string[] = [];
if (readFiles.length > 0) {
sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
}
if (modifiedFiles.length > 0) {
sections.push(
`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`,
);
}
if (sections.length === 0) return "";
return `\n\n${sections.join("\n\n")}`;
}
function chunkMessages(
messages: AgentMessage[],
maxTokens: number,
): AgentMessage[][] {
if (messages.length === 0) return [];
const chunks: AgentMessage[][] = [];
let currentChunk: AgentMessage[] = [];
let currentTokens = 0;
for (const message of messages) {
const messageTokens = estimateTokens(message);
if (currentChunk.length > 0 && currentTokens + messageTokens > maxTokens) {
chunks.push(currentChunk);
currentChunk = [];
currentTokens = 0;
}
currentChunk.push(message);
currentTokens += messageTokens;
if (messageTokens > maxTokens) {
// Split oversized messages to avoid unbounded chunk growth.
chunks.push(currentChunk);
currentChunk = [];
currentTokens = 0;
}
}
if (currentChunk.length > 0) {
chunks.push(currentChunk);
}
return chunks;
}
async function summarizeChunks(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
customInstructions?: string;
previousSummary?: string;
}): Promise<string> {
if (params.messages.length === 0) {
return params.previousSummary ?? "No prior history.";
}
const chunks = chunkMessages(params.messages, params.maxChunkTokens);
let summary = params.previousSummary;
for (const chunk of chunks) {
summary = await generateSummary(
chunk,
params.model,
params.reserveTokens,
params.apiKey,
params.signal,
params.customInstructions,
summary,
);
}
return summary ?? "No prior history.";
}
export default function compactionSafeguardExtension(api: ExtensionAPI): void {
api.on("session_before_compact", async (event, ctx) => {
const { preparation, customInstructions, signal } = event;
const { readFiles, modifiedFiles } = computeFileLists(preparation.fileOps);
const fileOpsSummary = formatFileOperations(readFiles, modifiedFiles);
const fallbackSummary = `${FALLBACK_SUMMARY}${fileOpsSummary}`;
const model = ctx.model;
if (!model) {
return {
compaction: {
summary: fallbackSummary,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: { readFiles, modifiedFiles },
},
};
}
const apiKey = await ctx.modelRegistry.getApiKey(model);
if (!apiKey) {
return {
compaction: {
summary: fallbackSummary,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: { readFiles, modifiedFiles },
},
};
}
try {
const contextWindowTokens = Math.max(
1,
Math.floor(model.contextWindow ?? DEFAULT_CONTEXT_TOKENS),
);
const maxChunkTokens = Math.max(
1,
Math.floor(contextWindowTokens * MAX_CHUNK_RATIO),
);
const reserveTokens = Math.max(
1,
Math.floor(preparation.settings.reserveTokens),
);
const historySummary = await summarizeChunks({
messages: preparation.messagesToSummarize,
model,
apiKey,
signal,
reserveTokens,
maxChunkTokens,
customInstructions,
previousSummary: preparation.previousSummary,
});
let summary = historySummary;
if (
preparation.isSplitTurn &&
preparation.turnPrefixMessages.length > 0
) {
const prefixSummary = await summarizeChunks({
messages: preparation.turnPrefixMessages,
model,
apiKey,
signal,
reserveTokens,
maxChunkTokens,
customInstructions: TURN_PREFIX_INSTRUCTIONS,
});
summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`;
}
summary += fileOpsSummary;
return {
compaction: {
summary,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: { readFiles, modifiedFiles },
},
};
} catch (error) {
console.warn(
`Compaction summarization failed; truncating history: ${
error instanceof Error ? error.message : String(error)
}`,
);
return {
compaction: {
summary: fallbackSummary,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: { readFiles, modifiedFiles },
},
};
}
});
}
+167 -1
View File
@@ -5,6 +5,7 @@ import type { AgentTool } from "@mariozechner/pi-agent-core";
import sharp from "sharp";
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
import { createBrowserTool } from "./tools/browser-tool.js";
@@ -16,7 +17,7 @@ describe("createClawdbotCodingTools", () => {
expect(schema.anyOf).toBeUndefined();
});
it("merges properties for union tool schemas", () => {
it("keeps browser tool schema properties after normalization", () => {
const tools = createClawdbotCodingTools();
const browser = tools.find((tool) => tool.name === "browser");
expect(browser).toBeDefined();
@@ -103,6 +104,31 @@ describe("createClawdbotCodingTools", () => {
});
});
it("drops null-only union variants without flattening other unions", () => {
const cleaned = __testing.cleanToolSchemaForGemini({
type: "object",
properties: {
parentId: { anyOf: [{ type: "string" }, { type: "null" }] },
count: { oneOf: [{ type: "string" }, { type: "number" }] },
},
}) as {
properties?: Record<string, unknown>;
};
const parentId = cleaned.properties?.parentId as
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
| undefined;
expect(parentId?.anyOf).toBeUndefined();
expect(parentId?.oneOf).toBeUndefined();
expect(parentId?.type).toBe("string");
const count = cleaned.properties?.count as
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
| undefined;
expect(count?.anyOf).toBeUndefined();
expect(Array.isArray(count?.oneOf)).toBe(true);
});
it("preserves action enums in normalized schemas", () => {
const tools = createClawdbotCodingTools();
const toolNames = [
@@ -241,6 +267,95 @@ describe("createClawdbotCodingTools", () => {
expect(offenders).toEqual([]);
});
it("avoids anyOf/oneOf/allOf in tool schemas", () => {
const tools = createClawdbotCodingTools();
const offenders: Array<{
name: string;
keyword: string;
path: string;
}> = [];
const keywords = new Set(["anyOf", "oneOf", "allOf"]);
const walk = (value: unknown, path: string, name: string): void => {
if (!value) return;
if (Array.isArray(value)) {
for (const [index, entry] of value.entries()) {
walk(entry, `${path}[${index}]`, name);
}
return;
}
if (typeof value !== "object") return;
const record = value as Record<string, unknown>;
for (const [key, entry] of Object.entries(record)) {
const nextPath = path ? `${path}.${key}` : key;
if (keywords.has(key)) {
offenders.push({ name, keyword: key, path: nextPath });
}
walk(entry, nextPath, name);
}
};
for (const tool of tools) {
walk(tool.parameters, "", tool.name);
}
expect(offenders).toEqual([]);
});
it("keeps raw core tool schemas union-free", () => {
const tools = createClawdbotTools();
const coreTools = new Set([
"browser",
"canvas",
"nodes",
"cron",
"message",
"gateway",
"agents_list",
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"session_status",
"memory_search",
"memory_get",
"image",
]);
const offenders: Array<{
name: string;
keyword: string;
path: string;
}> = [];
const keywords = new Set(["anyOf", "oneOf", "allOf"]);
const walk = (value: unknown, path: string, name: string): void => {
if (!value) return;
if (Array.isArray(value)) {
for (const [index, entry] of value.entries()) {
walk(entry, `${path}[${index}]`, name);
}
return;
}
if (typeof value !== "object") return;
const record = value as Record<string, unknown>;
for (const [key, entry] of Object.entries(record)) {
const nextPath = path ? `${path}.${key}` : key;
if (keywords.has(key)) {
offenders.push({ name, keyword: key, path: nextPath });
}
walk(entry, nextPath, name);
}
};
for (const tool of tools) {
if (!coreTools.has(tool.name)) continue;
walk(tool.parameters, "", tool.name);
}
expect(offenders).toEqual([]);
});
it("does not expose provider-specific message tools", () => {
const tools = createClawdbotCodingTools({ messageProvider: "discord" });
const names = new Set(tools.map((tool) => tool.name));
@@ -492,6 +607,57 @@ describe("createClawdbotCodingTools", () => {
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
});
it("applies tool profiles before allow/deny policies", () => {
const tools = createClawdbotCodingTools({
config: { tools: { profile: "messaging" } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("sessions_send")).toBe(true);
expect(names.has("sessions_spawn")).toBe(false);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("expands group shorthands in global tool policy", () => {
const tools = createClawdbotCodingTools({
config: { tools: { allow: ["group:fs"] } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(true);
expect(names.has("write")).toBe(true);
expect(names.has("edit")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("expands group shorthands in global tool deny policy", () => {
const tools = createClawdbotCodingTools({
config: { tools: { deny: ["group:fs"] } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(false);
expect(names.has("write")).toBe(false);
expect(names.has("edit")).toBe(false);
expect(names.has("exec")).toBe(true);
});
it("lets agent profiles override global profiles", () => {
const tools = createClawdbotCodingTools({
sessionKey: "agent:work:main",
config: {
tools: { profile: "coding" },
agents: {
list: [{ id: "work", tools: { profile: "messaging" } }],
},
},
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("read")).toBe(false);
});
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
const tools = createClawdbotCodingTools();
+26 -23
View File
@@ -28,6 +28,11 @@ import type { SandboxContext, SandboxToolPolicy } from "./sandbox.js";
import { assertSandboxPath } from "./sandbox-paths.js";
import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js";
import { sanitizeToolResultImages } from "./tool-images.js";
import {
expandToolGroups,
normalizeToolName,
resolveToolProfilePolicy,
} from "./tool-policy.js";
// NOTE(steipete): Upstream read now does file-magic MIME detection; we keep the wrapper
// to normalize payloads and sanitize oversized images before they hit providers.
@@ -291,21 +296,6 @@ function cleanToolSchemaForGemini(schema: Record<string, unknown>): unknown {
return cleanSchemaForGemini(schema);
}
const TOOL_NAME_ALIASES: Record<string, string> = {
bash: "exec",
"apply-patch": "apply_patch",
};
function normalizeToolName(name: string) {
const normalized = name.trim().toLowerCase();
return TOOL_NAME_ALIASES[normalized] ?? normalized;
}
function normalizeToolNames(list?: string[]) {
if (!list) return [];
return list.map(normalizeToolName).filter(Boolean);
}
function isOpenAIProvider(provider?: string) {
const normalized = provider?.trim().toLowerCase();
return normalized === "openai" || normalized === "openai-codex";
@@ -357,8 +347,8 @@ function isToolAllowedByPolicyName(
policy?: SandboxToolPolicy,
): boolean {
if (!policy) return true;
const deny = new Set(normalizeToolNames(policy.deny));
const allowRaw = normalizeToolNames(policy.allow);
const deny = new Set(expandToolGroups(policy.deny));
const allowRaw = expandToolGroups(policy.allow);
const allow = allowRaw.length > 0 ? new Set(allowRaw) : null;
const normalized = normalizeToolName(name);
if (deny.has(normalized)) return false;
@@ -391,11 +381,15 @@ function resolveEffectiveToolPolicy(params: {
: undefined;
const agentTools = agentConfig?.tools;
const hasAgentToolPolicy =
Array.isArray(agentTools?.allow) || Array.isArray(agentTools?.deny);
Array.isArray(agentTools?.allow) ||
Array.isArray(agentTools?.deny) ||
typeof agentTools?.profile === "string";
const globalTools = params.config?.tools;
const profile = agentTools?.profile ?? globalTools?.profile;
return {
agentId,
policy: hasAgentToolPolicy ? agentTools : globalTools,
profile,
};
}
@@ -703,10 +697,15 @@ export function createClawdbotCodingTools(options?: {
}): AnyAgentTool[] {
const execToolName = "exec";
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
const { agentId, policy: effectiveToolsPolicy } = resolveEffectiveToolPolicy({
const {
agentId,
policy: effectiveToolsPolicy,
profile,
} = resolveEffectiveToolPolicy({
config: options?.config,
sessionKey: options?.sessionKey,
});
const profilePolicy = resolveToolProfilePolicy(profile);
const scopeKey =
options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined);
const subagentPolicy =
@@ -714,6 +713,7 @@ export function createClawdbotCodingTools(options?: {
? resolveSubagentToolPolicy(options.config)
: undefined;
const allowBackground = isToolAllowedByPolicies("process", [
profilePolicy,
effectiveToolsPolicy,
sandbox?.tools,
subagentPolicy,
@@ -829,12 +829,15 @@ export function createClawdbotCodingTools(options?: {
hasRepliedRef: options?.hasRepliedRef,
}),
];
const toolsFiltered = effectiveToolsPolicy
? filterToolsByPolicy(tools, effectiveToolsPolicy)
const toolsFiltered = profilePolicy
? filterToolsByPolicy(tools, profilePolicy)
: tools;
const sandboxed = sandbox
? filterToolsByPolicy(toolsFiltered, sandbox.tools)
const policyFiltered = effectiveToolsPolicy
? filterToolsByPolicy(toolsFiltered, effectiveToolsPolicy)
: toolsFiltered;
const sandboxed = sandbox
? filterToolsByPolicy(policyFiltered, sandbox.tools)
: policyFiltered;
const subagentFiltered = subagentPolicy
? filterToolsByPolicy(sandboxed, subagentPolicy)
: sandboxed;
+5 -52
View File
@@ -33,6 +33,7 @@ import {
resolveSessionAgentId,
} from "./agent-scope.js";
import { syncSkillsToWorkspace } from "./skills.js";
import { expandToolGroups } from "./tool-policy.js";
import {
DEFAULT_AGENT_WORKSPACE_DIR,
DEFAULT_AGENTS_FILENAME,
@@ -239,58 +240,10 @@ const BROWSER_BRIDGES = new Map<
{ bridge: BrowserBridge; containerName: string }
>();
function normalizeToolList(values?: string[]) {
if (!values) return [];
return values
.map((value) => value.trim())
.filter(Boolean)
.map((value) => value.toLowerCase());
}
const TOOL_GROUPS: Record<string, string[]> = {
// NOTE: Keep canonical (lowercase) tool names here.
"group:memory": ["memory_search", "memory_get"],
// Basic workspace/file tools
"group:fs": ["read", "write", "edit", "apply_patch"],
// Session management tools
"group:sessions": [
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"session_status",
],
// Host/runtime execution tools
"group:runtime": ["exec", "bash", "process"],
};
function expandToolGroupEntry(entry: string): string[] {
const raw = entry.trim();
if (!raw) return [];
const lower = raw.toLowerCase();
const group = TOOL_GROUPS[lower];
if (group) return group;
return [raw];
}
function expandToolGroups(values?: string[]): string[] {
if (!values) return [];
const out: string[] = [];
for (const value of values) {
for (const expanded of expandToolGroupEntry(value)) {
const trimmed = expanded.trim();
if (!trimmed) continue;
out.push(trimmed);
}
}
return out;
}
function isToolAllowed(policy: SandboxToolPolicy, name: string) {
const deny = new Set(normalizeToolList(expandToolGroups(policy.deny)));
const deny = new Set(expandToolGroups(policy.deny));
if (deny.has(name.toLowerCase())) return false;
const allow = normalizeToolList(expandToolGroups(policy.allow));
const allow = expandToolGroups(policy.allow);
if (allow.length === 0) return true;
return allow.includes(name.toLowerCase());
}
@@ -687,8 +640,8 @@ export function formatSandboxToolPolicyBlockedMessage(params: {
});
if (!runtime.sandboxed) return undefined;
const deny = new Set(normalizeToolList(runtime.toolPolicy.deny));
const allow = normalizeToolList(runtime.toolPolicy.allow);
const deny = new Set(expandToolGroups(runtime.toolPolicy.deny));
const allow = expandToolGroups(runtime.toolPolicy.allow);
const allowSet = allow.length > 0 ? new Set(allow) : null;
const blockedByDeny = deny.has(tool);
const blockedByAllow = allowSet ? !allowSet.has(tool) : false;
+100 -8
View File
@@ -67,6 +67,39 @@ function tryFlattenLiteralAnyOf(
return null;
}
function isNullSchema(variant: unknown): boolean {
if (!variant || typeof variant !== "object" || Array.isArray(variant)) {
return false;
}
const record = variant as Record<string, unknown>;
if ("const" in record && record.const === null) return true;
if (Array.isArray(record.enum) && record.enum.length === 1) {
return record.enum[0] === null;
}
const typeValue = record.type;
if (typeValue === "null") return true;
if (
Array.isArray(typeValue) &&
typeValue.length === 1 &&
typeValue[0] === "null"
) {
return true;
}
return false;
}
function stripNullVariants(variants: unknown[]): {
variants: unknown[];
stripped: boolean;
} {
if (variants.length === 0) return { variants, stripped: false };
const nonNull = variants.filter((variant) => !isNullSchema(variant));
return {
variants: nonNull,
stripped: nonNull.length !== variants.length,
};
}
type SchemaDefs = Map<string, unknown>;
function extendSchemaDefs(
@@ -166,9 +199,24 @@ function cleanSchemaForGeminiWithDefs(
const hasAnyOf = "anyOf" in obj && Array.isArray(obj.anyOf);
const hasOneOf = "oneOf" in obj && Array.isArray(obj.oneOf);
let cleanedAnyOf = hasAnyOf
? (obj.anyOf as unknown[]).map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
)
: undefined;
let cleanedOneOf = hasOneOf
? (obj.oneOf as unknown[]).map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
)
: undefined;
if (hasAnyOf) {
const flattened = tryFlattenLiteralAnyOf(obj.anyOf as unknown[]);
const { variants: nonNullVariants, stripped } = stripNullVariants(
cleanedAnyOf ?? [],
);
if (stripped) cleanedAnyOf = nonNullVariants;
const flattened = tryFlattenLiteralAnyOf(nonNullVariants);
if (flattened) {
const result: Record<string, unknown> = {
type: flattened.type,
@@ -179,10 +227,28 @@ function cleanSchemaForGeminiWithDefs(
}
return result;
}
if (stripped && nonNullVariants.length === 1) {
const lone = nonNullVariants[0];
if (lone && typeof lone === "object" && !Array.isArray(lone)) {
const result: Record<string, unknown> = {
...(lone as Record<string, unknown>),
};
for (const key of ["description", "title", "default"]) {
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
}
return result;
}
return lone;
}
}
if (hasOneOf) {
const flattened = tryFlattenLiteralAnyOf(obj.oneOf as unknown[]);
const { variants: nonNullVariants, stripped } = stripNullVariants(
cleanedOneOf ?? [],
);
if (stripped) cleanedOneOf = nonNullVariants;
const flattened = tryFlattenLiteralAnyOf(nonNullVariants);
if (flattened) {
const result: Record<string, unknown> = {
type: flattened.type,
@@ -193,6 +259,19 @@ function cleanSchemaForGeminiWithDefs(
}
return result;
}
if (stripped && nonNullVariants.length === 1) {
const lone = nonNullVariants[0];
if (lone && typeof lone === "object" && !Array.isArray(lone)) {
const result: Record<string, unknown> = {
...(lone as Record<string, unknown>),
};
for (const key of ["description", "title", "default"]) {
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
}
return result;
}
return lone;
}
}
const cleaned: Record<string, unknown> = {};
@@ -206,6 +285,15 @@ function cleanSchemaForGeminiWithDefs(
}
if (key === "type" && (hasAnyOf || hasOneOf)) continue;
if (
key === "type" &&
Array.isArray(value) &&
value.every((entry) => typeof entry === "string")
) {
const types = value.filter((entry) => entry !== "null");
cleaned.type = types.length === 1 ? types[0] : types;
continue;
}
if (key === "properties" && value && typeof value === "object") {
const props = value as Record<string, unknown>;
@@ -218,13 +306,17 @@ function cleanSchemaForGeminiWithDefs(
} else if (key === "items" && value && typeof value === "object") {
cleaned[key] = cleanSchemaForGeminiWithDefs(value, nextDefs, refStack);
} else if (key === "anyOf" && Array.isArray(value)) {
cleaned[key] = value.map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
);
cleaned[key] =
cleanedAnyOf ??
value.map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
);
} else if (key === "oneOf" && Array.isArray(value)) {
cleaned[key] = value.map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
);
cleaned[key] =
cleanedOneOf ??
value.map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
);
} else if (key === "allOf" && Array.isArray(value)) {
cleaned[key] = value.map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
+27
View File
@@ -0,0 +1,27 @@
import { Type } from "@sinclair/typebox";
type StringEnumOptions<T extends readonly string[]> = {
description?: string;
title?: string;
default?: T[number];
};
// NOTE: Avoid Type.Union([Type.Literal(...)]) which compiles to anyOf.
// Some providers reject anyOf in tool schemas; a flat string enum is safer.
export function stringEnum<T extends readonly string[]>(
values: T,
options: StringEnumOptions<T> = {},
) {
return Type.Unsafe<T[number]>({
type: "string",
enum: [...values],
...options,
});
}
export function optionalStringEnum<T extends readonly string[]>(
values: T,
options: StringEnumOptions<T> = {},
) {
return Type.Optional(stringEnum(values, options));
}
+182
View File
@@ -0,0 +1,182 @@
import type { ModelDefinitionConfig } from "../config/types.js";
export const SYNTHETIC_BASE_URL = "https://api.synthetic.new/anthropic";
export const SYNTHETIC_DEFAULT_MODEL_ID = "hf:MiniMaxAI/MiniMax-M2.1";
export const SYNTHETIC_DEFAULT_MODEL_REF = `synthetic/${SYNTHETIC_DEFAULT_MODEL_ID}`;
export const SYNTHETIC_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
export const SYNTHETIC_MODEL_CATALOG = [
{
id: SYNTHETIC_DEFAULT_MODEL_ID,
name: "MiniMax M2.1",
reasoning: false,
input: ["text"],
contextWindow: 192000,
maxTokens: 65536,
},
{
id: "hf:moonshotai/Kimi-K2-Thinking",
name: "Kimi K2 Thinking",
reasoning: true,
input: ["text"],
contextWindow: 256000,
maxTokens: 8192,
},
{
id: "hf:zai-org/GLM-4.7",
name: "GLM-4.7",
reasoning: false,
input: ["text"],
contextWindow: 198000,
maxTokens: 128000,
},
{
id: "hf:deepseek-ai/DeepSeek-R1-0528",
name: "DeepSeek R1 0528",
reasoning: false,
input: ["text"],
contextWindow: 128000,
maxTokens: 8192,
},
{
id: "hf:deepseek-ai/DeepSeek-V3-0324",
name: "DeepSeek V3 0324",
reasoning: false,
input: ["text"],
contextWindow: 128000,
maxTokens: 8192,
},
{
id: "hf:deepseek-ai/DeepSeek-V3.1",
name: "DeepSeek V3.1",
reasoning: false,
input: ["text"],
contextWindow: 128000,
maxTokens: 8192,
},
{
id: "hf:deepseek-ai/DeepSeek-V3.1-Terminus",
name: "DeepSeek V3.1 Terminus",
reasoning: false,
input: ["text"],
contextWindow: 128000,
maxTokens: 8192,
},
{
id: "hf:deepseek-ai/DeepSeek-V3.2",
name: "DeepSeek V3.2",
reasoning: false,
input: ["text"],
contextWindow: 159000,
maxTokens: 8192,
},
{
id: "hf:meta-llama/Llama-3.3-70B-Instruct",
name: "Llama 3.3 70B Instruct",
reasoning: false,
input: ["text"],
contextWindow: 128000,
maxTokens: 8192,
},
{
id: "hf:meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
name: "Llama 4 Maverick 17B 128E Instruct FP8",
reasoning: false,
input: ["text"],
contextWindow: 524000,
maxTokens: 8192,
},
{
id: "hf:moonshotai/Kimi-K2-Instruct-0905",
name: "Kimi K2 Instruct 0905",
reasoning: false,
input: ["text"],
contextWindow: 256000,
maxTokens: 8192,
},
{
id: "hf:openai/gpt-oss-120b",
name: "GPT OSS 120B",
reasoning: false,
input: ["text"],
contextWindow: 128000,
maxTokens: 8192,
},
{
id: "hf:Qwen/Qwen3-235B-A22B-Instruct-2507",
name: "Qwen3 235B A22B Instruct 2507",
reasoning: false,
input: ["text"],
contextWindow: 256000,
maxTokens: 8192,
},
{
id: "hf:Qwen/Qwen3-Coder-480B-A35B-Instruct",
name: "Qwen3 Coder 480B A35B Instruct",
reasoning: false,
input: ["text"],
contextWindow: 256000,
maxTokens: 8192,
},
{
id: "hf:Qwen/Qwen3-VL-235B-A22B-Instruct",
name: "Qwen3 VL 235B A22B Instruct",
reasoning: false,
input: ["text", "image"],
contextWindow: 250000,
maxTokens: 8192,
},
{
id: "hf:zai-org/GLM-4.5",
name: "GLM-4.5",
reasoning: false,
input: ["text"],
contextWindow: 128000,
maxTokens: 128000,
},
{
id: "hf:zai-org/GLM-4.6",
name: "GLM-4.6",
reasoning: false,
input: ["text"],
contextWindow: 198000,
maxTokens: 128000,
},
{
id: "hf:deepseek-ai/DeepSeek-V3",
name: "DeepSeek V3",
reasoning: false,
input: ["text"],
contextWindow: 128000,
maxTokens: 8192,
},
{
id: "hf:Qwen/Qwen3-235B-A22B-Thinking-2507",
name: "Qwen3 235B A22B Thinking 2507",
reasoning: true,
input: ["text"],
contextWindow: 256000,
maxTokens: 8192,
},
] as const;
export type SyntheticCatalogEntry = (typeof SYNTHETIC_MODEL_CATALOG)[number];
export function buildSyntheticModelDefinition(
entry: SyntheticCatalogEntry,
): ModelDefinitionConfig {
return {
id: entry.id,
name: entry.name,
reasoning: entry.reasoning,
input: [...entry.input],
cost: SYNTHETIC_DEFAULT_COST,
contextWindow: entry.contextWindow,
maxTokens: entry.maxTokens,
};
}
Loaded 100 of 244 files, more files were not shown because too many files have changed in this diff. Show more