Compare commits

...
Author SHA1 Message Date
Peter Steinberger 1f9a96df8e fix: session store perms + codex prompt fallback (#1049) (thanks @YuriNachos)
chore: temp landpr 1049 staging
2026-01-17 00:07:13 +00:00
Peter Steinberger e31251293b fix: scope history injection to pending-only 2026-01-16 23:52:42 +00:00
Peter Steinberger 56ed5cc2d9 fix: prefer config over env for matrix creds 2026-01-16 23:24:18 +00:00
Peter Steinberger af31e0d969 docs: redirect /install/node to install section 2026-01-16 23:18:50 +00:00
Peter Steinberger 37fa4f7eef feat: improve web_fetch readability extraction 2026-01-16 23:18:01 +00:00
Peter Steinberger 9aad6dfe1b Merge pull request #1046 from YuriNachos/feature/web-search-localization
feat(web-search): add country and language parameters
2026-01-16 23:17:46 +00:00
Peter Steinberger 6b8db36a15 docs: clarify Brave web_search defaults (#1046) (thanks @YuriNachos) 2026-01-16 23:16:59 +00:00
Yurii ChukhlibandClaude 171060541a docs(web-search): document country and language parameters
Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-16 23:14:33 +00:00
Yurii ChukhlibandClaude 003547c818 test(web-search): add tests for country and language parameters
Added three new test cases to verify the new country, search_lang, and ui_lang
parameters are correctly passed to the Brave Search API.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-16 23:14:33 +00:00
Yurii Chukhlib c4e1064066 feat(web-search): pass country and language params to Brave API
- Add country, search_lang, and ui_lang optional parameters to runWebSearch
- Pass new parameters to Brave Search API as URL query parameters
- Update cache key to include localization parameters
- Wire parameters through createWebSearchTool execute function
2026-01-16 23:14:33 +00:00
Yurii Chukhlib d163dbcfcd feat(web-search): add country and language optional parameters to schema 2026-01-16 23:14:33 +00:00
Yurii Chukhlib 2acfeb1096 fix(openai-image-gen): remove deprecated response_format, use URL download 2026-01-16 23:14:33 +00:00
Yurii Chukhlib b3b6d421cc fix(session): reset compactionCount on /new and /reset 2026-01-16 23:14:33 +00:00
Yurii Chukhlib cf72b9db3c fix(sessions): preserve 0600 permissions on sessions.json writes 2026-01-16 23:14:33 +00:00
Peter Steinberger 106e308953 fix: prefer config tokens over env for discord/telegram 2026-01-16 23:13:00 +00:00
Peter Steinberger bf72a126d1 docs: add /help hub and Node/npm PATH guide 2026-01-16 23:10:29 +00:00
Peter Steinberger 28a5d124c3 fix: stabilize transport-ready test timing 2026-01-16 23:03:12 +00:00
Peter Steinberger b4045e6adb fix: remove stale pnpm patch entry 2026-01-16 22:56:08 +00:00
Peter Steinberger a7bec3340f fix: drop unsigned gemini tool calls from history 2026-01-16 22:43:16 +00:00
Peter Steinberger a4e99ecdaf chore: remove patch references 2026-01-16 22:41:57 +00:00
Peter Steinberger dcd20d564f docs: expand directory CLI guide 2026-01-16 22:40:36 +00:00
Peter Steinberger 59f6ea9b21 feat: directory for plugin channels 2026-01-16 22:40:36 +00:00
Peter Steinberger e44f28bd4f feat: unify directory across channels 2026-01-16 22:40:36 +00:00
Peter Steinberger 929b86e302 feat(ui): delete sessions from Control UI 2026-01-16 22:33:47 +00:00
Peter Steinberger 76d3d58b5c chore: oxfmt 2026-01-16 22:33:47 +00:00
Peter Steinberger 548a32c8d4 chore: drop unused patches 2026-01-16 22:31:21 +00:00
Peter Steinberger 500c75b4f0 fix: align ZAI thinking toggles 2026-01-16 22:26:43 +00:00
Peter Steinberger 3567dc4a47 fix: hard-stop sessions.delete cleanup 2026-01-16 22:24:13 +00:00
Peter Steinberger 7df37c2dbd fix: override tar to 7.5.3 2026-01-16 22:07:34 +00:00
Peter Steinberger 28a4cbc4ef docs: mention stopping sub-agents 2026-01-16 22:05:13 +00:00
Peter Steinberger 21fe4d9ded fix: bump tar to 7.5.3 2026-01-16 21:58:32 +00:00
Peter Steinberger 05d149a49b fix: treat reply-to-bot as implicit mention across channels 2026-01-16 21:51:01 +00:00
Peter Steinberger 97a41a6509 fix: suppress zero sub-agent stop note 2026-01-16 21:41:55 +00:00
Peter Steinberger a0be85c34c fix: /stop aborts subagents 2026-01-16 21:37:22 +00:00
tsu 390bd11f33 feat: add zalouser channel + directory CLI (#1032) (thanks @suminhthanh)
- Unified UX: channels login + message send; no plugin-specific top-level command\n- Added generic directory CLI for channel identity/groups\n- Docs: channel + plugin pages
2026-01-16 21:28:18 +00:00
Peter Steinberger 16768a9998 fix: start fresh cron sessions each run 2026-01-16 21:27:56 +00:00
e9d6869290 Telegram: Add reply-chain detection to bypass mention requirement (#1038)
* Telegram: add reply-chain detection to bypass mention requirement

* fix: allow telegram reply-chain mention bypass (#1038) (thanks @adityashaw2)

---------

Co-authored-by: Aditya Shaw <aditya@adityashaw.dev>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-16 21:24:45 +00:00
Peter Steinberger 9072e35f08 fix: hard-abort clears queues on /stop 2026-01-16 21:15:25 +00:00
Peter Steinberger d887027e95 style: run oxfmt 2026-01-16 21:11:55 +00:00
Peter Steinberger 168a0f4998 Merge pull request #1016 from timolins/main
Models: add Vercel AI Gateway auth
2026-01-16 21:06:56 +00:00
Peter Steinberger f3a37664d5 fix: tighten Vercel AI Gateway onboarding docs/tests (#1016) (thanks @timolins) 2026-01-16 21:06:21 +00:00
Peter Steinberger 8bcbe68637 chore: sync Peekaboo submodule on install 2026-01-16 21:04:58 +00:00
Timo Lins beb9eac5f7 Models: add Vercel AI Gateway auth 2026-01-16 21:00:15 +00:00
Peter Steinberger 0dcffcd5b0 fix: repair orphaned user turns before embedded prompts 2026-01-16 20:52:18 +00:00
Peter Steinberger 56efbce31e feat: enable telegram reaction notifications by default 2026-01-16 20:51:42 +00:00
Peter Steinberger e7c42884fc test: cover transport readiness waits 2026-01-16 20:48:43 +00:00
Peter Steinberger 08c0405f0f fix: quiet skill command normalization logs 2026-01-16 20:37:23 +00:00
Peter Steinberger 470add877c feat: default telegram reaction level minimal 2026-01-16 20:35:47 +00:00
Peter SteinbergerandSzpadel aaa310c047 fix: bound signal/imessage transport readiness waits
Co-authored-by: Szpadel <1857251+Szpadel@users.noreply.github.com>
2026-01-16 20:33:04 +00:00
RubyandShadow 0cd24137e8 feat: add session.identityLinks for cross-platform DM session linking (#1033)
Co-authored-by: Shadow <shadow@clawd.bot>
2026-01-16 14:23:22 -06:00
Peter Steinberger 8ffb8cc363 Merge pull request #1013 from marcmarg/fix/format-parameter-and-subagent-auth
Fix format parameter conflict and subagent auth inheritance
2026-01-16 20:20:40 +00:00
Peter Steinberger 7a9854cb06 chore: update clawtributors 2026-01-16 20:20:26 +00:00
Marc 5ee4456c6e fix: merge subagent auth profiles 2026-01-16 20:20:26 +00:00
Marcandmarcmarg de31583021 fix: avoid format keyword in tool schemas
Co-authored-by: marcmarg <marcmarg@users.noreply.github.com>
2026-01-16 20:20:26 +00:00
Peter Steinberger 38b49aa0f6 feat: expand skill command registration 2026-01-16 20:17:32 +00:00
Peter Steinberger 69761e8a51 feat: scope telegram inline buttons 2026-01-16 20:16:41 +00:00
Peter SteinbergerandChristoph Nakazawa 3431d3d115 chore: tweak tool call narration guidance (#1008)
Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
2026-01-16 19:56:04 +00:00
Peter Steinberger fe9e027d58 test: deflake background exec timeout 2026-01-16 19:48:52 +00:00
Peter Steinberger 33d17957e5 test: cover doctor launchctl env overrides (#1037)
* test: cover doctor launchctl env overrides

* style(macos): fix swiftformat lint
2026-01-16 19:40:45 +00:00
Nima KarimiandPeter Steinberger 25ae5f897e fix(macos): check config file mode for gateway token/password resolution (#1022)
* fix: honor config gateway mode for credentials

* chore: oxfmt doctor platform notes

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-16 19:29:48 +00:00
Peter Steinberger 624ff09314 test: expand gateway auth probe coverage 2026-01-16 19:16:03 +00:00
Peter Steinberger c8003ae472 docs: update changelog for gateway probe fix (#1011) (thanks @ivanrvpereira) 2026-01-16 19:07:44 +00:00
Peter Steinberger 6bf627bce8 Merge pull request #1011 from ivanrvpereira/fix/security-audit-gateway-auth
fix(security): resolve local auth for gateway probe
2026-01-16 19:05:07 +00:00
henrymascot 08525435e0 showcase: add Linear CLI and Beeper CLI
- @NessZerra Linear CLI - manage Linear issues from terminal, works with Claude Code/Clawdbot
- @jules Beeper CLI - read/send/archive messages via Beeper Desktop MCP API
2026-01-16 19:05:02 +00:00
MarcandClaude Opus 4.5 78279fb758 fix: inherit auth-profiles from main agent for subagents
When a subagent is spawned, it creates a new agent directory but has no
auth-profiles.json. This adds a fallback in ensureAuthProfileStore() to
inherit auth-profiles from the main agent when the subagent has none,
ensuring subagents can use OAuth tokens without manual file copying.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 15:51:50 +01:00
MarcandClaude Opus 4.5 38cf90f6db fix: rename format parameter to avoid JSON Schema keyword conflict
- Rename `format` to `snapshotFormat` in browser-tool schema and implementation
- Rename `format` to `outputFormat` in canvas-tool schema and implementation
- The parameter name `format` conflicts with JSON Schema keyword, causing
  Google Cloud Code Assist to reject the schema as invalid

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 15:50:02 +01:00
Ivan Pereira 544ca062a3 test(security): add coverage for gateway probe auth selection 2026-01-16 13:31:01 +00:00
Ivan Pereira be9aa5494a fix(security): resolve local auth for gateway probe 2026-01-16 13:19:55 +00:00
237 changed files with 8554 additions and 1343 deletions

No files matched your search

+1
View File
@@ -107,6 +107,7 @@
- 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`.
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
- 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.
+30 -1
View File
@@ -11,30 +11,43 @@
### Breaking
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
- **BREAKING:** Discord/Telegram channel tokens now prefer config over env (env is fallback only).
- **BREAKING:** Matrix channel credentials now prefer config over env (env is fallback only).
### Changes
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
- Telegram: scope inline buttons with allowlist default + callback gating in DMs/groups.
- Telegram: default reaction notifications to own.
- Tools: improve `web_fetch` extraction using Readability (with fallback).
- Channels: inject only pending (mention-gated) group history; clear history on any processed message.
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
- Sessions: add `session.identityLinks` for cross-platform DM session linking. (#1033) — thanks @thewilloftheshadow.
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
- Skills: add user-invocable skill commands with sanitized names and an opt-out for model invocation.
- Skills: add user-invocable skill commands (sanitized + unique), native skill registration gating, and an opt-out for model invocation.
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
- TUI: show provider/model labels for the active session and default model.
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
- UI: show gateway auth guidance + doc link on unauthorized Control UI connections.
- Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `clawdbot security audit`.
- Apps: store node auth tokens encrypted (Keychain/SecurePrefs).
- Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup.
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
- Docs: add `/help` hub, Node/npm PATH sanity guide, and installer PATH warnings (for “installed but command not found” setups). (#861)
- Docs: clarify web_search country defaults to Brave’s region choice. (#1046) — thanks @YuriNachos.
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
- Agents: default to no narration for routine tool calls. (#1008) — thanks @cpojer.
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
- macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`).
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
- Tools: fall back to local Codex prompt files for unknown slash commands (supports `$1`, `$2`, `$*`).
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
- Models: add Vercel AI Gateway auth choice + onboarding docs. (#1016) — thanks @timolins.
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
@@ -43,19 +56,28 @@
- Browser: increase remote CDP reachability timeouts + add `remoteCdpTimeoutMs`/`remoteCdpHandshakeTimeoutMs`.
- Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
- Telegram: default reaction level to minimal for more delightful baseline reactions.
- Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
- Channels/Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) via zca-cli. (#1032) — thanks @suminhthanh.
### Fixes
- Messages: make `/stop` clear queued followups and pending session lane work for a hard abort.
- Messages: make `/stop` abort active sub-agent runs spawned from the requester session and report how many were stopped.
- WhatsApp: default response prefix only for self-chat, using identity name when set.
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
- Auth: merge main auth profiles into per-agent stores for sub-agents and document inheritance. (#1013) — thanks @marcmarg.
- Agents: avoid JSON Schema `format` collisions in tool params by renaming snapshot format fields. (#1013) — thanks @marcmarg.
- Fix: make `clawdbot update` auto-update global installs when installed via a package manager.
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
- Sessions: keep session store writes private on disk even when `chmod` fails. (#1049) — thanks @YuriNachos.
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
- Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.
- Agents: drop unsigned Gemini tool calls (and matching results) from context to prevent tool-call markup leakage across models.
- Status: restore usage summary line for current provider when no OAuth profiles exist.
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
@@ -70,8 +92,15 @@
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.
- Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)
- Fix: repair orphaned user turns before embedded prompts to avoid role-ordering lockouts. (#1026) — thanks @odrobnik.
- Dev: auto-sync Peekaboo submodule on install when clean to avoid dirty checkouts.
- Fix: keep background exec aborts from killing backgrounded sessions while honoring timeouts.
- Fix: use local auth for gateway security probe unless remote mode has a URL. (#1011) — thanks @ivanrvpereira.
- Discord: truncate skill command descriptions for slash command limits. (#1018) — thanks @evalexpr.
- macOS: resolve gateway token/password using config mode/remote URL, and warn when `launchctl setenv` overrides config. (#1022, #1021) — thanks @kkarimi.
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
- Groups: treat replies to the bot as implicit mentions across supported channels.
- Security: bump dependency `tar` to 7.5.3 to fix GHSA-8qq5-rm4j-mr97.
## 2026.1.14-1
+19 -18
View File
@@ -474,23 +474,24 @@ Core contributors:
Thanks to all clawtributors:
<p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a>
<a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a>
<a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a>
<a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a>
<a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
<a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a>
<a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
<a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a>
<a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a>
<a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a>
<a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/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/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a>
<a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a>
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a>
<a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a>
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a>
<a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a>
<a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a>
<a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/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/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a>
<a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a>
<a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a>
<a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></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/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a>
<a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a>
<a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/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/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a>
<a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/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/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/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/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/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/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/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/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/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/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/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a>
<a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a>
<a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a>
<a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a>
<a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a>
<a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
<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/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/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/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
<a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/Szpadel"><img src="https://avatars.githubusercontent.com/u/1857251?v=4&s=48" width="48" height="48" alt="Szpadel" title="Szpadel"/></a>
</p>
+1 -23
View File
@@ -257,30 +257,8 @@ final class AppState {
let configRoot = ClawdbotConfigFile.loadDict()
let configGateway = configRoot["gateway"] as? [String: Any]
let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let configMode: ConnectionMode? = switch configModeRaw {
case "local":
.local
case "remote":
.remote
default:
nil
}
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
let configHasRemoteUrl = !(configRemoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
let resolvedConnectionMode: ConnectionMode = if let configMode {
configMode
} else if configHasRemoteUrl {
.remote
} else if let storedMode {
ConnectionMode(rawValue: storedMode) ?? .local
} else {
onboardingSeen ? .local : .unconfigured
}
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
self.connectionMode = resolvedConnectionMode
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
@@ -385,14 +385,8 @@ enum CommandResolver {
}
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
let modeRaw = defaults.string(forKey: connectionModeKey)
let mode: AppState.ConnectionMode
if let modeRaw {
mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
} else {
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
mode = seen ? .local : .unconfigured
}
let root = ClawdbotConfigFile.loadDict()
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
let target = defaults.string(forKey: remoteTargetKey) ?? ""
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? ""
@@ -0,0 +1,49 @@
import Foundation
enum EffectiveConnectionModeSource: Sendable, Equatable {
case configMode
case configRemoteURL
case userDefaults
case onboarding
}
struct EffectiveConnectionMode: Sendable, Equatable {
let mode: AppState.ConnectionMode
let source: EffectiveConnectionModeSource
}
enum ConnectionModeResolver {
static func resolve(
root: [String: Any],
defaults: UserDefaults = .standard) -> EffectiveConnectionMode
{
let gateway = root["gateway"] as? [String: Any]
let configModeRaw = (gateway?["mode"] as? String) ?? ""
let configMode = configModeRaw
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
switch configMode {
case "local":
return EffectiveConnectionMode(mode: .local, source: .configMode)
case "remote":
return EffectiveConnectionMode(mode: .remote, source: .configMode)
default:
break
}
let remoteURLRaw = ((gateway?["remote"] as? [String: Any])?["url"] as? String) ?? ""
let remoteURL = remoteURLRaw.trimmingCharacters(in: .whitespacesAndNewlines)
if !remoteURL.isEmpty {
return EffectiveConnectionMode(mode: .remote, source: .configRemoteURL)
}
if let storedModeRaw = defaults.string(forKey: connectionModeKey) {
let storedMode = AppState.ConnectionMode(rawValue: storedModeRaw) ?? .local
return EffectiveConnectionMode(mode: storedMode, source: .userDefaults)
}
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
return EffectiveConnectionMode(mode: seen ? .local : .unconfigured, source: .onboarding)
}
}
@@ -1,3 +1,4 @@
import ConcurrencyExtras
import Foundation
import OSLog
@@ -16,6 +17,13 @@ actor GatewayEndpointStore {
static let shared = GatewayEndpointStore()
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
private static let remoteConnectingDetail = "Connecting to remote gateway…"
private static let staticLogger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
private enum EnvOverrideWarningKind: Sendable {
case token
case password
}
private static let envOverrideWarnings = LockIsolated((token: false, password: false))
struct Deps: Sendable {
let mode: @Sendable () async -> AppState.ConnectionMode
@@ -30,16 +38,18 @@ actor GatewayEndpointStore {
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
token: {
let root = ClawdbotConfigFile.loadDict()
let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote
return GatewayEndpointStore.resolveGatewayToken(
isRemote: CommandResolver.connectionModeIsRemote(),
isRemote: isRemote,
root: root,
env: ProcessInfo.processInfo.environment,
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
},
password: {
let root = ClawdbotConfigFile.loadDict()
let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote
return GatewayEndpointStore.resolveGatewayPassword(
isRemote: CommandResolver.connectionModeIsRemote(),
isRemote: isRemote,
root: root,
env: ProcessInfo.processInfo.environment,
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
@@ -68,6 +78,14 @@ actor GatewayEndpointStore {
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
if let configPassword = self.resolveConfigPassword(isRemote: isRemote, root: root),
!configPassword.isEmpty
{
self.warnEnvOverrideOnce(
kind: .password,
envVar: "CLAWDBOT_GATEWAY_PASSWORD",
configKey: isRemote ? "gateway.remote.password" : "gateway.auth.password")
}
return trimmed
}
if isRemote {
@@ -99,6 +117,26 @@ actor GatewayEndpointStore {
return nil
}
private static func resolveConfigPassword(isRemote: Bool, root: [String: Any]) -> String? {
if isRemote {
if let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let password = remote["password"] as? String
{
return password.trimmingCharacters(in: .whitespacesAndNewlines)
}
return nil
}
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any],
let password = auth["password"] as? String
{
return password.trimmingCharacters(in: .whitespacesAndNewlines)
}
return nil
}
private static func resolveGatewayToken(
isRemote: Bool,
root: [String: Any],
@@ -108,6 +146,14 @@ actor GatewayEndpointStore {
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
!configToken.isEmpty
{
self.warnEnvOverrideOnce(
kind: .token,
envVar: "CLAWDBOT_GATEWAY_TOKEN",
configKey: isRemote ? "gateway.remote.token" : "gateway.auth.token")
}
return trimmed
}
if isRemote {
@@ -139,6 +185,49 @@ actor GatewayEndpointStore {
return nil
}
private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? {
if isRemote {
if let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let token = remote["token"] as? String
{
return token.trimmingCharacters(in: .whitespacesAndNewlines)
}
return nil
}
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any],
let token = auth["token"] as? String
{
return token.trimmingCharacters(in: .whitespacesAndNewlines)
}
return nil
}
private static func warnEnvOverrideOnce(
kind: EnvOverrideWarningKind,
envVar: String,
configKey: String)
{
let shouldWarn = Self.envOverrideWarnings.withValue { state in
switch kind {
case .token:
guard !state.token else { return false }
state.token = true
return true
case .password:
guard !state.password else { return false }
state.password = true
return true
}
}
guard shouldWarn else { return }
Self.staticLogger.warning(
"\(envVar, privacy: .public) is set and overrides \(configKey, privacy: .public). " +
"If this is unintentional, clear it with: launchctl unsetenv \(envVar, privacy: .public)")
}
private let deps: Deps
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
@@ -3,6 +3,13 @@ import Testing
@testable import Clawdbot
@Suite struct GatewayEndpointStoreTests {
private func makeDefaults() -> UserDefaults {
let suiteName = "GatewayEndpointStoreTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
return defaults
}
@Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() {
let snapshot = LaunchAgentPlistSnapshot(
programArguments: [],
@@ -66,4 +73,70 @@ import Testing
launchdSnapshot: snapshot)
#expect(password == "launchd-pass")
}
@Test func connectionModeResolverPrefersConfigModeOverDefaults() {
let defaults = self.makeDefaults()
defaults.set("remote", forKey: connectionModeKey)
let root: [String: Any] = [
"gateway": [
"mode": " local ",
],
]
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
#expect(resolved.mode == .local)
}
@Test func connectionModeResolverTrimsConfigMode() {
let defaults = self.makeDefaults()
defaults.set("local", forKey: connectionModeKey)
let root: [String: Any] = [
"gateway": [
"mode": " remote ",
],
]
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
#expect(resolved.mode == .remote)
}
@Test func connectionModeResolverFallsBackToDefaultsWhenMissingConfig() {
let defaults = self.makeDefaults()
defaults.set("remote", forKey: connectionModeKey)
let resolved = ConnectionModeResolver.resolve(root: [:], defaults: defaults)
#expect(resolved.mode == .remote)
}
@Test func connectionModeResolverFallsBackToDefaultsOnUnknownConfig() {
let defaults = self.makeDefaults()
defaults.set("local", forKey: connectionModeKey)
let root: [String: Any] = [
"gateway": [
"mode": "staging",
],
]
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
#expect(resolved.mode == .local)
}
@Test func connectionModeResolverPrefersRemoteURLWhenModeMissing() {
let defaults = self.makeDefaults()
defaults.set("local", forKey: connectionModeKey)
let root: [String: Any] = [
"gateway": [
"remote": [
"url": " ws://umbrel:18789 ",
],
],
]
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
#expect(resolved.mode == .remote)
}
}
+1
View File
@@ -78,6 +78,7 @@ Isolated jobs run a dedicated agent turn in session `cron:<jobId>`.
Key behaviors:
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
- Each run starts a **fresh session id** (no prior conversation carry-over).
- A summary is posted to the main session (prefix `Cron`, configurable).
- `wakeMode: "now"` triggers an immediate heartbeat after posting the summary.
- If `payload.deliver: true`, output is delivered to a channel; otherwise it stays internal.
+3 -2
View File
@@ -13,6 +13,7 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
2) Set the token for Clawdbot:
- Env: `DISCORD_BOT_TOKEN=...`
- Or config: `channels.discord.token: "..."`.
- If both are set, config wins; env is fallback.
3) Invite the bot to your server with message permissions.
4) Start the gateway.
5) DM access is pairing by default; approve the pairing code on first contact.
@@ -39,8 +40,8 @@ Minimal config:
1. Create a Discord application → Bot, enable the intents you need (DMs + guild messages + message content), and grab the bot token.
2. Invite the bot to your server with the permissions required to read/send messages where you want to use it.
3. Configure Clawdbot with `DISCORD_BOT_TOKEN` (or `channels.discord.token` in `~/.clawdbot/clawdbot.json`).
4. Run the gateway; it auto-starts the Discord channel when a token is available (env or config) and `channels.discord.enabled` is not `false`.
- If you prefer env vars, set `DISCORD_BOT_TOKEN` (a config block is optional).
4. Run the gateway; it auto-starts the Discord channel when a token is available (config first, env fallback) and `channels.discord.enabled` is not `false`.
- If you prefer env vars, set `DISCORD_BOT_TOKEN` (and omit config).
5. Direct chats: use `user:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session. Bare numeric IDs are ambiguous and rejected.
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default and can be set per guild or per channel.
7. Direct chats: secure by default via `channels.discord.dm.policy` (default: `"pairing"`). Unknown senders get a pairing code (expires after 1 hour); approve via `clawdbot pairing approve discord <code>`.
+1
View File
@@ -20,6 +20,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
## Notes
+1
View File
@@ -32,6 +32,7 @@ Details: [Plugins](/plugin)
2) Configure credentials:
- Env: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_PASSWORD`)
- Or config: `channels.matrix.*`
- Config takes precedence over env; env is fallback.
3) Restart the gateway (or finish onboarding).
4) DM access defaults to pairing; approve the pairing code on first contact.
+30 -15
View File
@@ -13,6 +13,7 @@ Status: production-ready for bot DMs + groups via grammY. Long-polling by defaul
2) Set the token:
- Env: `TELEGRAM_BOT_TOKEN=...`
- Or config: `channels.telegram.botToken: "..."`.
- If both are set, config wins; env is fallback.
3) Start the gateway.
4) DM access is pairing by default; approve the pairing code on first contact.
@@ -60,11 +61,11 @@ Example:
}
```
Env option: `TELEGRAM_BOT_TOKEN=...` (works for the default account).
Env option: `TELEGRAM_BOT_TOKEN=...` (works for the default account; used only when config is missing).
Multi-account support: use `channels.telegram.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
3) Start the gateway. Telegram starts when a token is resolved (env or config).
3) Start the gateway. Telegram starts when a token is resolved (config first, env fallback).
4) DM access defaults to pairing. Approve the code when the bot is first contacted.
5) For groups: add the bot, decide privacy/admin behavior (below), then set `channels.telegram.groups` to control mention gating + allowlists.
@@ -219,13 +220,15 @@ Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps
## Inline Buttons
Telegram supports inline keyboards with callback buttons. Enable this feature via capabilities:
Telegram supports inline keyboards with callback buttons.
```json5
{
"channels": {
"telegram": {
"capabilities": ["inlineButtons"]
"capabilities": {
"inlineButtons": "allowlist"
}
}
}
}
@@ -238,7 +241,9 @@ For per-account configuration:
"telegram": {
"accounts": {
"main": {
"capabilities": ["inlineButtons"]
"capabilities": {
"inlineButtons": "allowlist"
}
}
}
}
@@ -246,6 +251,16 @@ For per-account configuration:
}
```
Scopes:
- `off` — inline buttons disabled
- `dm` — only DMs (group targets blocked)
- `group` — only groups (DM targets blocked)
- `all` — DMs + groups
- `allowlist` — DMs + groups, but only senders allowed by `allowFrom`/`groupAllowFrom` (same rules as control commands)
Default: `allowlist`.
Legacy: `capabilities: ["inlineButtons"]` = `inlineButtons: "all"`.
### Sending buttons
Use the message tool with the `buttons` parameter:
@@ -273,12 +288,12 @@ When a user clicks a button, the callback data is sent back to the agent as a me
### Configuration options
Telegram capabilities can be configured at two levels:
Telegram capabilities can be configured at two levels (object form shown above; legacy string arrays still supported):
- `channels.telegram.capabilities`: Global default capability list applied to all Telegram accounts unless overridden.
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities that override or extend the global defaults for that specific account.
- `channels.telegram.capabilities`: Global default capability config applied to all Telegram accounts unless overridden.
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities that override the global defaults for that specific account.
Use the global setting when all Telegram bots/accounts should behave the same. Use per-account configuration when different bots need different behaviors (for example, one account only handles DMs while another is allowed in groups or has extra capabilities).
Use the global setting when all Telegram bots/accounts should behave the same. Use per-account configuration when different bots need different behaviors (for example, one account only handles DMs while another is allowed in groups).
## Access control (DMs + groups)
### DM access
@@ -397,8 +412,8 @@ The agent sees reactions as **system notifications** in the conversation history
**Configuration:**
- `channels.telegram.reactionNotifications`: Controls which reactions trigger notifications
- `"off"` — ignore all reactions (default when not set)
- `"own"` — notify when users react to bot messages (best-effort; in-memory)
- `"off"` — ignore all reactions
- `"own"` — notify when users react to bot messages (best-effort; in-memory) (default)
- `"all"` — notify for all reactions
- `channels.telegram.reactionLevel`: Controls agent's reaction capability
@@ -477,8 +492,8 @@ Provider options:
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
- `channels.telegram.capabilities`: Enable channel features (e.g., "inlineButtons").
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities.
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
@@ -491,8 +506,8 @@ Provider options:
- `channels.telegram.actions.reactions`: gate Telegram tool reactions.
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `off` when not set).
- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `ack` when not set).
- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `own` when not set).
- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `minimal` when not set).
Related global options:
- `agents.list[].groupChat.mentionPatterns` (mention gating patterns).
+3 -3
View File
@@ -202,9 +202,9 @@ The wizard uses it to set your **allowlist/owner** so your own DMs are permitted
- `always`: always triggers.
- `/activation mention|always` is owner-only and must be sent as a standalone message.
- Owner = `channels.whatsapp.allowFrom` (or self E.164 if unset).
- **History injection**:
- Recent messages (default 50) inserted under:
`[Chat messages since your last reply - for context]`
- **History injection** (pending-only):
- Recent *unprocessed* messages (default 50) inserted under:
`[Chat messages since your last reply - for context]` (messages already in the session are not re-injected)
- Current message under:
`[Current message - respond to this]`
- Sender suffix appended: `[from: Name (+E164)]`
+98
View File
@@ -0,0 +1,98 @@
---
summary: "Zalo personal account support via zca-cli (QR login), capabilities, and configuration"
read_when:
- Setting up Zalo Personal for Clawdbot
- Debugging Zalo Personal login or message flow
---
# Zalo Personal (unofficial)
Status: experimental. This integration automates a **personal Zalo account** via `zca-cli`.
> **Warning:** This is an unofficial integration and may result in account suspension/ban. Use at your own risk.
## Plugin required
Zalo Personal ships as a plugin and is not bundled with the core install.
- Install via CLI: `clawdbot plugins install @clawdbot/zalouser`
- Or from a source checkout: `clawdbot plugins install ./extensions/zalouser`
- Details: [Plugins](/plugin)
## Prerequisite: zca-cli
The Gateway machine must have the `zca` binary available in `PATH`.
- Verify: `zca --version`
- If missing, install zca-cli (see `extensions/zalouser/README.md` or the upstream zca-cli docs).
## Quick setup (beginner)
1) Install the plugin (see above).
2) Login (QR, on the Gateway machine):
- `clawdbot channels login --channel zalouser`
- Scan the QR code in the terminal with the Zalo mobile app.
3) Enable the channel:
```json5
{
channels: {
zalouser: {
enabled: true,
dmPolicy: "pairing"
}
}
}
```
4) Restart the Gateway (or finish onboarding).
5) DM access defaults to pairing; approve the pairing code on first contact.
## What it is
- Uses `zca listen` to receive inbound messages.
- Uses `zca msg ...` to send replies (text/media/link).
- Designed for “personal account” use cases where Zalo Bot API is not available.
## Naming
Channel id is `zalouser` to make it explicit this automates a **personal Zalo user account** (unofficial). We keep `zalo` reserved for a potential future official Zalo API integration.
## Finding IDs (directory)
Use the directory CLI to discover peers/groups and their IDs:
```bash
clawdbot directory self --channel zalouser
clawdbot directory peers list --channel zalouser --query "name"
clawdbot directory groups list --channel zalouser --query "work"
```
## Limits
- Outbound text is chunked to ~2000 characters (Zalo client limits).
- Streaming is blocked by default.
## Access control (DMs)
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
Approve via:
- `clawdbot pairing list zalouser`
- `clawdbot pairing approve zalouser <code>`
## Multi-account
Accounts map to zca profiles. Example:
```json5
{
channels: {
zalouser: {
enabled: true,
defaultAccount: "default",
accounts: {
work: { enabled: true, profile: "work" }
}
}
}
}
```
## Troubleshooting
**`zca` not found:**
- Install zca-cli and ensure it’s on `PATH` for the Gateway process.
**Login doesn’t stick:**
- `clawdbot channels status --probe`
- Re-login: `clawdbot channels logout --channel zalouser && clawdbot channels login --channel zalouser`
+60
View File
@@ -0,0 +1,60 @@
---
summary: "CLI reference for `clawdbot directory` (self, peers, groups)"
read_when:
- You want to look up contacts/groups/self ids for a channel
- You are developing a channel directory adapter
---
# `clawdbot directory`
Directory lookups for channels that support it (contacts/peers, groups, and “me”).
## Common flags
- `--channel <name>`: channel id/alias (required when multiple channels are configured; auto when only one is configured)
- `--account <id>`: account id (default: channel default)
- `--json`: output JSON
## Notes
- `directory` is meant to help you find IDs you can paste into other commands (especially `clawdbot message send --to ...`).
- For many channels, results are config-backed (allowlists / configured groups) rather than a live provider directory.
- Default output is `id` (and sometimes `name`) separated by a tab; use `--json` for scripting.
## Using results with `message send`
```bash
clawdbot directory peers list --channel slack --query "U0"
clawdbot message send --channel slack --to user:U012ABCDEF --message "hello"
```
## ID formats (by channel)
- WhatsApp: `+15551234567` (DM), `1234567890-1234567890@g.us` (group)
- Telegram: `@username` or numeric chat id; groups are numeric ids
- Slack: `user:U…` and `channel:C…`
- Discord: `user:<id>` and `channel:<id>`
- Matrix (plugin): `user:@user:server`, `room:!roomId:server`, or `#alias:server`
- Microsoft Teams (plugin): `user:<id>` and `conversation:<id>`
- Zalo (plugin): user id (Bot API)
- Zalo Personal / `zalouser` (plugin): thread id (DM/group) from `zca` (`me`, `friend list`, `group list`)
## Self (“me”)
```bash
clawdbot directory self --channel zalouser
```
## Peers (contacts/users)
```bash
clawdbot directory peers list --channel zalouser
clawdbot directory peers list --channel zalouser --query "name"
clawdbot directory peers list --channel zalouser --limit 50
```
## Groups
```bash
clawdbot directory groups list --channel zalouser
clawdbot directory groups list --channel zalouser --query "work"
clawdbot directory groups members --channel zalouser --group-id <id>
```
+11
View File
@@ -21,3 +21,14 @@ clawdbot doctor --repair
clawdbot doctor --deep
```
## macOS: `launchctl` env overrides
If you previously ran `launchctl setenv CLAWDBOT_GATEWAY_TOKEN ...` (or `...PASSWORD`), that value overrides your config file and can cause persistent “unauthorized” errors.
```bash
launchctl getenv CLAWDBOT_GATEWAY_TOKEN
launchctl getenv CLAWDBOT_GATEWAY_PASSWORD
launchctl unsetenv CLAWDBOT_GATEWAY_TOKEN
launchctl unsetenv CLAWDBOT_GATEWAY_PASSWORD
```
+2 -1
View File
@@ -283,7 +283,7 @@ Options:
- `--non-interactive`
- `--mode <local|remote>`
- `--flow <quickstart|advanced>`
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|moonshot-api-key|codex-cli|antigravity|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|codex-cli|antigravity|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
- `--token <token>` (non-interactive; used with `--auth-choice token`)
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
@@ -291,6 +291,7 @@ Options:
- `--anthropic-api-key <key>`
- `--openai-api-key <key>`
- `--openrouter-api-key <key>`
- `--ai-gateway-api-key <key>`
- `--moonshot-api-key <key>`
- `--gemini-api-key <key>`
- `--zai-api-key <key>`
+1 -1
View File
@@ -46,7 +46,7 @@ Target formats (`--to`):
- Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
- Required: `--to`, plus `--message` or `--media`
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
- Telegram only: `--buttons` (requires `"inlineButtons"` in `channels.telegram.capabilities` or `channels.telegram.accounts.<id>.capabilities`)
- Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
- Telegram only: `--thread-id` (forum topic id)
- Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field)
- WhatsApp only: `--gif-playback`
+1 -1
View File
@@ -13,7 +13,7 @@ Note: `agents.list[].groupChat.mentionPatterns` is now used by Telegram/Discord/
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`channels.whatsapp.groups`) and overridden per group via `/activation`. When `channels.whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all).
- Group policy: `channels.whatsapp.groupPolicy` controls whether group messages are accepted (`open|disabled|allowlist`). `allowlist` uses `channels.whatsapp.groupAllowFrom` (fallback: explicit `channels.whatsapp.allowFrom`). Default is `allowlist` (blocked until you add senders).
- Per-group sessions: session keys look like `agent:<agentId>:whatsapp:group:<jid>` so commands such as `/verbose on` or `/think high` (sent as standalone messages) are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads.
- Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`.
- Context injection: **pending-only** group messages (default 50) that *did not* trigger a run are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`. Messages already in the session are not re-injected.
- Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking.
- Ephemeral/view-once: we unwrap those before extracting text/mentions, so pings inside them still trigger.
- Group system prompt: on the first turn of a group session (and whenever `/activation` changes the mode) we inject a short blurb into the system prompt like `You are replying inside the WhatsApp group "<subject>". Group members: Alice (+44...), Bob (+43...), … Activation: trigger-only … Address the specific sender noted in the message context.` If metadata isn’t available we still tell the agent it’s a group chat.
+3 -1
View File
@@ -177,6 +177,8 @@ Quick mental model (evaluation order for group messages):
## Mention gating (default)
Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`.
Replying to a bot message counts as an implicit mention (when the channel supports reply metadata). This applies to Telegram, WhatsApp, Slack, Discord, and Microsoft Teams.
```json5
{
channels: {
@@ -219,7 +221,7 @@ Notes:
- Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group).
- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured).
- Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel).
- Group history context is wrapped uniformly across channels; use `messages.groupChat.historyLimit` for the global default and `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit`) for overrides. Set `0` to disable.
- Group history context is wrapped uniformly across channels and is **pending-only** (messages skipped due to mention gating); use `messages.groupChat.historyLimit` for the global default and `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit`) for overrides. Set `0` to disable.
## Group allowlists
When `channels.whatsapp.groups`, `channels.telegram.groups`, or `channels.imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior.
+4
View File
@@ -85,6 +85,10 @@ When a channel supplies history, it uses a shared wrapper:
- `[Chat messages since your last reply - for context]`
- `[Current message - respond to this]`
History buffers are **pending-only**: they include group messages that did *not*
trigger a run (for example, mention-gated messages) and **exclude** messages
already in the session transcript.
Directive stripping only applies to the **current message** section so history
remains intact. Channels that wrap history should set `CommandBody` (or
`RawBody`) to the original message text and keep `Body` as the combined prompt.
+7
View File
@@ -93,6 +93,13 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
- CLI: `clawdbot onboard --auth-choice zai-api-key`
- Aliases: `z.ai/*` and `z-ai/*` normalize to `zai/*`
### Vercel AI Gateway
- Provider: `vercel-ai-gateway`
- Auth: `AI_GATEWAY_API_KEY`
- Example model: `vercel-ai-gateway/anthropic/claude-opus-4.5`
- CLI: `clawdbot onboard --auth-choice ai-gateway-api-key`
### Other built-in providers
- OpenRouter: `openrouter` (`OPENROUTER_API_KEY`)
+11 -11
View File
@@ -1,28 +1,28 @@
---
summary: "Command queue design that serializes auto-reply command execution"
summary: "Command queue design that serializes inbound auto-reply runs"
read_when:
- Changing auto-reply execution or concurrency
---
# Command Queue (2026-01-03)
# Command Queue (2026-01-16)
We now serialize command-based auto-replies (WhatsApp Web listener) through a tiny in-process queue to prevent multiple commands from running at once, while allowing safe parallelism across sessions.
We serialize inbound auto-reply runs (all channels) through a tiny in-process queue to prevent multiple agent runs from colliding, while still allowing safe parallelism across sessions.
## Why
- Some auto-reply commands are expensive (LLM calls) and can collide when multiple inbound messages arrive close together.
- Serializing avoids competing for terminal/stdin, keeps logs readable, and reduces the chance of rate limits from upstream tools.
- Auto-reply runs can be expensive (LLM calls) and can collide when multiple inbound messages arrive close together.
- Serializing avoids competing for shared resources (session files, logs, CLI stdin) and reduces the chance of upstream rate limits.
## How it works
- A lane-aware FIFO queue drains each lane synchronously.
- A lane-aware FIFO queue drains each lane with a configurable concurrency cap (default 1).
- `runEmbeddedPiAgent` enqueues by **session key** (lane `session:<key>`) to guarantee only one active run per session.
- Each session run is then queued into a **global lane** (`main` by default) so overall parallelism is capped by `agents.defaults.maxConcurrent`.
- When verbose logging is enabled, queued commands emit a short notice if they waited more than ~2s before starting.
- Typing indicators (`onReplyStart`) still fire immediately on enqueue so user experience is unchanged while we wait our turn.
- When verbose logging is enabled, queued runs emit a short notice if they waited more than ~2s before starting.
- Typing indicators still fire immediately on enqueue (when supported by the channel) so user experience is unchanged while we wait our turn.
## Queue modes (per channel)
Inbound messages can steer the current run, wait for a followup turn, or do both:
- `steer`: inject immediately into the current run (cancels pending tool calls after the next tool boundary). If not streaming, falls back to followup.
- `followup`: enqueue for the next agent turn after the current run ends.
- `collect`: coalesce all queued messages into a **single** followup turn (default).
- `collect`: coalesce all queued messages into a **single** followup turn (default). If messages target different channels/threads, they drain individually to preserve routing.
- `steer-backlog` (aka `steer+backlog`): steer now **and** preserve the message for a followup turn.
- `interrupt` (legacy): abort the active run for that session, then run the newest message.
- `queue` (legacy alias): same as `steer`.
@@ -66,9 +66,9 @@ Defaults: `debounceMs: 1000`, `cap: 20`, `drop: summarize`.
- `/queue default` or `/queue reset` clears the session override.
## Scope and guarantees
- Applies only to config-driven command replies; plain text replies are unaffected.
- Applies to auto-reply agent runs across all inbound channels that use the gateway reply pipeline (WhatsApp web, Telegram, Slack, Discord, Signal, iMessage, webchat, etc.).
- Default lane (`main`) is process-wide for inbound + main heartbeats; set `agents.defaults.maxConcurrent` to allow multiple sessions in parallel.
- Additional lanes may exist (e.g. `cron`) so background jobs can run in parallel without blocking inbound replies.
- Additional lanes may exist (e.g. `cron`, `subagent`) so background jobs can run in parallel without blocking inbound replies.
- Per-session lanes guarantee that only one agent run touches a given session at a time.
- No external dependencies or background worker threads; pure TypeScript + promises.
+7 -1
View File
@@ -11,6 +11,7 @@ Use `session.dmScope` to control how **direct messages** are grouped:
- `main` (default): all DMs share the main session for continuity.
- `per-peer`: isolate by sender id across channels.
- `per-channel-peer`: isolate by channel + sender (recommended for multi-user inboxes).
Use `session.identityLinks` to map provider-prefixed peer ids to a canonical identity so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
## Gateway is the source of truth
All session state is **owned by the gateway** (the “master” Clawdbot). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files.
@@ -42,6 +43,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
- Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation.
- `per-peer`: `agent:<agentId>:dm:<peerId>`.
- `per-channel-peer`: `agent:<agentId>:<channel>:dm:<peerId>`.
- If `session.identityLinks` matches a provider-prefixed peer id (for example `telegram:123`), the canonical key replaces `<peerId>` so the same person shares a session across channels.
- Group chats isolate state: `agent:<agentId>:<channel>:group:<id>` (rooms/channels use `agent:<agentId>:<channel>:channel:<id>`).
- Telegram forum topics append `:topic:<threadId>` to the group id for isolation.
- Legacy `group:<id>` keys are still recognized for migration.
@@ -55,6 +57,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
## Send policy (optional)
Block delivery for specific session types without listing individual ids.
@@ -86,6 +89,9 @@ Send these as standalone messages so they register.
session: {
scope: "per-sender", // keep group keys separate
dmScope: "main", // DM continuity (set per-channel-peer for shared inboxes)
identityLinks: {
alice: ["telegram:123456789", "discord:987654321012345678"]
},
idleMinutes: 120,
resetTriggers: ["/new", "/reset"],
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
@@ -100,7 +106,7 @@ Send these as standalone messages so they register.
- `clawdbot gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access).
- Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs).
- Send `/context list` or `/context detail` to see what’s in the system prompt and injected workspace files (and the biggest context contributors).
- Send `/stop` as a standalone message to abort the current run.
- Send `/stop` as a standalone message to abort the current run, clear queued followups for that session, and stop any sub-agent runs spawned from it (the reply includes the stopped count).
- Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space. See [/concepts/compaction](/concepts/compaction).
- JSONL transcripts can be opened directly to review full turns.
+27 -1
View File
@@ -733,6 +733,22 @@
"source": "/wizard",
"destination": "/start/wizard"
},
{
"source": "/install/node",
"destination": "/install#nodejs--npm-path-sanity"
},
{
"source": "/install/node/",
"destination": "/install#nodejs--npm-path-sanity"
},
{
"source": "/start/faq",
"destination": "/help"
},
{
"source": "/start/faq/",
"destination": "/help"
},
{
"source": "/oauth",
"destination": "/concepts/oauth"
@@ -752,7 +768,6 @@
"start/wizard",
"start/setup",
"start/pairing",
"start/faq",
"start/clawd",
"start/showcase",
"start/hubs",
@@ -760,6 +775,14 @@
"start/lore"
]
},
{
"group": "Help",
"pages": [
"help/index",
"help/troubleshooting",
"help/faq"
]
},
{
"group": "Install & Updates",
"pages": [
@@ -792,6 +815,7 @@
"cli/health",
"cli/sessions",
"cli/channels",
"cli/directory",
"cli/skills",
"cli/plugins",
"cli/memory",
@@ -907,6 +931,7 @@
"channels/msteams",
"channels/matrix",
"channels/zalo",
"channels/zalouser",
"broadcast-groups",
"channels/troubleshooting",
"channels/location"
@@ -945,6 +970,7 @@
"tools",
"plugin",
"plugins/voice-call",
"plugins/zalouser",
"tools/exec",
"tools/web",
"tools/apply-patch",
+1 -1
View File
@@ -44,7 +44,7 @@ clawdbot doctor
If you’d rather not manage env vars yourself, the onboarding wizard can store
API keys for daemon use: `clawdbot onboard`.
See [/start/faq](/start/faq) for details on env inheritance (`env.shellEnv`,
See [Help](/help) for details on env inheritance (`env.shellEnv`,
`~/.clawdbot/.env`, systemd/launchd).
## Anthropic: Claude Code CLI setup-token (supported)
+8 -1
View File
@@ -943,6 +943,7 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w
breakPreference: "paragraph" // paragraph | newline | sentence
},
actions: { reactions: true, sendMessage: true }, // tool action gates (false disables)
reactionNotifications: "own", // off | own | all
mediaMaxMb: 5,
retry: { // outbound retry policy
attempts: 3,
@@ -1708,11 +1709,12 @@ Legacy: `tools.bash` is still accepted as an alias.
- `tools.web.search.maxResults` (1–10, default 5)
- `tools.web.search.timeoutSeconds` (default 30)
- `tools.web.search.cacheTtlMinutes` (default 15)
- `tools.web.fetch.enabled` (default false; sandboxed sessions auto-enable unless set to false)
- `tools.web.fetch.enabled` (default true)
- `tools.web.fetch.maxChars` (default 50000)
- `tools.web.fetch.timeoutSeconds` (default 30)
- `tools.web.fetch.cacheTtlMinutes` (default 15)
- `tools.web.fetch.userAgent` (optional override)
- `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only)
`agents.defaults.subagents` configures sub-agent defaults:
- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call.
@@ -2269,6 +2271,9 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
session: {
scope: "per-sender",
dmScope: "main",
identityLinks: {
alice: ["telegram:123456789", "discord:987654321012345678"]
},
idleMinutes: 60,
resetTriggers: ["/new", "/reset"],
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
@@ -2297,6 +2302,8 @@ Fields:
- `main`: all DMs share the main session for continuity.
- `per-peer`: isolate DMs by sender id across channels.
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
- Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`.
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
+1 -1
View File
@@ -133,7 +133,7 @@ By default, Clawdbot routes **all DMs into the main session** so your assistant
}
```
This prevents cross-user context leakage while keeping group chats isolated. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration).
This prevents cross-user context leakage while keeping group chats isolated. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration).
## Allowlists (DM + groups) — terminology
+2 -3
View File
@@ -365,7 +365,7 @@ clawdbot channels login
### npm install fails (allow-build-scripts / missing tar or yargs). What now?
If you’re running from source, use the repo’s package manager: **pnpm** (preferred).
The repo declares `packageManager: "pnpm@…"`, and pnpm patches are tracked in `pnpm.patchedDependencies`.
The repo declares `packageManager: "pnpm@…"`.
Typical recovery:
```bash
@@ -376,8 +376,7 @@ pnpm clawdbot doctor
clawdbot daemon restart
```
Why: pnpm is the configured package manager for this repo, and the dependency
patching workflow relies on it.
Why: pnpm is the configured package manager for this repo.
### How do I switch between git installs and npm installs?
+32
View File
@@ -0,0 +1,32 @@
---
summary: "FAQ (concepts): what Clawdbot is and how it fits together"
read_when:
- You’re new and want the mental model
- You’re not debugging a specific error
---
# FAQ (concepts)
If you’re here because something’s broken, start with: [Troubleshooting](/help/troubleshooting).
## What is Clawdbot?
Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always‑on control plane; the assistant is the product.
## What runtime do I need?
Node **>= 22** is required. `pnpm` is recommended. Bun is **not recommended** for the Gateway.
## What’s the recommended setup flow?
Use the onboarding wizard:
```bash
clawdbot onboard --install-daemon
```
Then use:
```bash
clawdbot dashboard
```
+20
View File
@@ -0,0 +1,20 @@
---
summary: "Help hub: common fixes, install sanity, and where to look when something breaks"
read_when:
- You’re new and want the “what do I click/run” guide
- Something broke and you want the fastest path to a fix
---
# Help
If you want a quick “get unstuck” flow, start here:
- **Troubleshooting:** [Start here](/help/troubleshooting)
- **Install sanity (Node/npm/PATH):** [Install](/install#nodejs--npm-path-sanity)
- **Gateway issues:** [Gateway troubleshooting](/gateway/troubleshooting)
- **Logs:** [Logging](/logging) and [Gateway logging](/gateway/logging)
- **Repairs:** [Doctor](/gateway/doctor)
If you’re looking for conceptual questions (not “something broke”):
- [FAQ (concepts)](/help/faq)
+59
View File
@@ -0,0 +1,59 @@
---
summary: "Troubleshooting hub: symptoms → checks → fixes"
read_when:
- You see an error and want the fix path
- The installer says “success” but the CLI doesn’t work
---
# Troubleshooting
## First 60 seconds
Run these in order:
```bash
clawdbot status
clawdbot status --all
clawdbot daemon status
clawdbot logs --follow
clawdbot doctor
```
If the gateway is reachable, deep probes:
```bash
clawdbot status --deep
```
## Common “it broke” cases
### `clawdbot: command not found`
Almost always a Node/npm PATH issue. Start here:
- [Install (Node/npm PATH sanity)](/install#nodejs--npm-path-sanity)
### Gateway “unauthorized”, can’t connect, or keeps reconnecting
- [Gateway troubleshooting](/gateway/troubleshooting)
- [Gateway authentication](/gateway/authentication)
### Daemon says running, but RPC probe fails
- [Gateway troubleshooting](/gateway/troubleshooting)
- [Background process / daemon](/gateway/background-process)
### Model/auth failures (rate limit, billing, “all models failed”)
- [Models](/cli/models)
- [OAuth / auth concepts](/concepts/oauth)
### When filing an issue
Paste a safe report:
```bash
clawdbot status --all
```
If you can, include the relevant log tail from `clawdbot logs --follow`.
+1 -1
View File
@@ -165,7 +165,7 @@ Example:
- Start here:
- [Docs hubs (all pages linked)](/start/hubs)
- [FAQ](/start/faq) ← *common questions answered*
- [Help](/help) ← *common fixes + troubleshooting*
- [Configuration](/gateway/configuration)
- [Configuration examples](/gateway/configuration-examples)
- [Slash commands](/tools/slash-commands)
+2 -16
View File
@@ -1,5 +1,5 @@
---
summary: "Bun workflow (experimental): installs, patches, and gotchas vs pnpm"
summary: "Bun workflow (experimental): installs and gotchas vs pnpm"
read_when:
- You want the fastest local dev loop (bun + watch)
- You hit Bun install/patch/lifecycle script issues
@@ -8,7 +8,7 @@ read_when:
# Bun (experimental)
Goal: run this repo with **Bun** (optional, not recommended for WhatsApp/Telegram)
without losing pnpm patch behavior.
without diverging from pnpm workflows.
⚠️ **Not recommended for Gateway runtime** (WhatsApp/Telegram bugs). Use Node for production.
@@ -39,20 +39,6 @@ bun run build
bun run vitest run
```
## pnpm patchedDependencies under Bun
pnpm supports `package.json#pnpm.patchedDependencies` and records it in `pnpm-lock.yaml`.
Bun (and npm/yarn) do not support pnpm patches, so we apply them in `postinstall` when pnpm is **not** the installer:
- [`scripts/postinstall.js`](https://github.com/clawdbot/clawdbot/blob/main/scripts/postinstall.js) detects the package manager via `npm_config_user_agent` and applies every entry from `package.json#pnpm.patchedDependencies` into `node_modules/...` using a built-in JS patcher (no `git`/system `patch` dependency).
- Under pnpm, this fallback is skipped because pnpm already applies `patchedDependencies` itself.
To add a new patch that works in both pnpm + Bun:
1. Add an entry to `package.json#pnpm.patchedDependencies`
2. Add the patch file under `patches/`
3. Run `pnpm install` (updates `pnpm-lock.yaml` patch hash)
## Bun lifecycle scripts (blocked by default)
Bun may block dependency lifecycle scripts unless explicitly trusted (`bun pm untrusted` / `bun pm trust`).
-1
View File
@@ -152,7 +152,6 @@ WORKDIR /app
# Cache dependencies unless package metadata changes
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY ui/package.json ./ui/package.json
COPY patches ./patches
COPY scripts ./scripts
RUN pnpm install --frozen-lockfile
+23
View File
@@ -9,6 +9,29 @@ read_when:
Runtime baseline: **Node >=22**.
If the installer says it succeeded but you later see `clawdbot: command not found`, it’s usually a Node/npm PATH issue (global npm bin dir not on PATH). See the section below.
## Node.js + npm (PATH sanity)
Quick diagnosis:
```bash
node -v
npm -v
npm bin -g
echo "$PATH"
```
If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`).
Fix: add it to your shell startup file (zsh: `~/.zshrc`, bash: `~/.bashrc`):
```bash
export PATH="/path/from/npm/bin/-g:$PATH"
```
Then open a new terminal (or `rehash` in zsh / `hash -r` in bash).
## Recommended (installer script)
```bash
+2 -6
View File
@@ -19,6 +19,8 @@ To see the current flags/behavior, run:
curl -fsSL https://clawd.bot/install.sh | bash -s -- --help
```
If the installer completes but `clawdbot` is not found in a new terminal, it’s usually a Node/npm PATH issue. See: [Install](/install#nodejs--npm-path-sanity).
## install.sh (recommended)
What it does (high level):
@@ -71,9 +73,3 @@ Help:
```bash
curl -fsSL https://clawd.bot/install-cli.sh | bash -s -- --help
```
## Patches (npm / pnpm / bun)
Clawdbot’s `postinstall` script includes a builtin JS patcher that can apply `pnpm.patchedDependencies` patches even when the package manager doesn’t support them (notably Bun). pnpm itself already applies `pnpm.patchedDependencies`, so the fallback skips pnpm installs to avoid double-applying.
See: [Bun notes](/install/bun).
+74
View File
@@ -0,0 +1,74 @@
---
summary: "Node.js + npm install sanity: versions, PATH, and global installs"
read_when:
- You installed Clawdbot but `clawdbot` is “command not found”
- You’re setting up Node.js/npm on a new machine
- `npm install -g ...` fails with permissions or PATH issues
---
# Node.js + npm (PATH sanity)
Clawdbot’s runtime baseline is **Node 22+**.
If you can run `npm install -g clawdbot@latest` but later see `clawdbot: command not found`, it’s almost always a **PATH** issue: the directory where npm puts global binaries isn’t on your shell’s PATH.
## Quick diagnosis
Run:
```bash
node -v
npm -v
npm bin -g
echo "$PATH"
```
If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`).
## Fix: put npm’s global bin dir on PATH
1) Find your global bin directory:
```bash
npm bin -g
```
2) Add it to your shell startup file:
- zsh: `~/.zshrc`
- bash: `~/.bashrc`
Example (replace the path with your `npm bin -g` output):
```bash
export PATH="/path/from/npm/bin/-g:$PATH"
```
Then open a **new terminal** (or run `rehash` in zsh / `hash -r` in bash).
## Fix: avoid `sudo npm install -g` / permission errors (Linux)
If `npm install -g ...` fails with `EACCES`, switch npm’s global prefix to a user-writable directory:
```bash
mkdir -p "$HOME/.npm-global"
npm config set prefix "$HOME/.npm-global"
export PATH="$HOME/.npm-global/bin:$PATH"
```
Persist the `export PATH=...` line in your shell startup file.
## Recommended Node install options
You’ll have the fewest surprises if Node/npm are installed in a way that:
- keeps Node updated (22+)
- makes `npm bin -g` stable and on PATH in new shells
Common choices:
- macOS: Homebrew (`brew install node`) or a version manager
- Linux: your preferred version manager, or a distro-supported install that provides Node 22+
- Windows: official Node installer, `winget`, or a Windows Node version manager
If you use a version manager (nvm/fnm/asdf/etc), ensure it’s initialized in the shell you use day-to-day (zsh vs bash) so the PATH it sets is present when you run installers.
+1 -1
View File
@@ -99,7 +99,7 @@ It can set up:
- model provider config/login
- Linux systemd **user** service (daemon)
If you’re doing OAuth on a headless VM: do OAuth on a normal machine first, then copy the auth profile to the VM (see [FAQ](/start/faq)).
If you’re doing OAuth on a headless VM: do OAuth on a normal machine first, then copy the auth profile to the VM (see [Help](/help)).
## 5) Remote access options
-1
View File
@@ -241,7 +241,6 @@ RUN curl -L https://github.com/steipete/wacli/releases/latest/download/wacli_Lin
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY ui/package.json ./ui/package.json
COPY patches ./patches
COPY scripts ./scripts
RUN corepack enable
+1
View File
@@ -37,6 +37,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
- [Zalo](/channels/zalo) — `@clawdbot/zalo`
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
+75
View File
@@ -0,0 +1,75 @@
---
summary: "Zalo Personal plugin: QR login + messaging via zca-cli (plugin install + channel config + CLI + tool)"
read_when:
- You want Zalo Personal (unofficial) support in Clawdbot
- You are configuring or developing the zalouser plugin
---
# Zalo Personal (plugin)
Zalo Personal support for Clawdbot via a plugin, using `zca-cli` to automate a normal Zalo user account.
> **Warning:** Unofficial automation may lead to account suspension/ban. Use at your own risk.
## Naming
Channel id is `zalouser` to make it explicit this automates a **personal Zalo user account** (unofficial). We keep `zalo` reserved for a potential future official Zalo API integration.
## Where it runs
This plugin runs **inside the Gateway process**.
If you use a remote Gateway, install/configure it on the **machine running the Gateway**, then restart the Gateway.
## Install
### Option A: install from npm
```bash
clawdbot plugins install @clawdbot/zalouser
```
Restart the Gateway afterwards.
### Option B: install from a local folder (dev)
```bash
clawdbot plugins install ./extensions/zalouser
cd ./extensions/zalouser && pnpm install
```
Restart the Gateway afterwards.
## Prerequisite: zca-cli
The Gateway machine must have `zca` on `PATH`:
```bash
zca --version
```
## Config
Channel config lives under `channels.zalouser` (not `plugins.entries.*`):
```json5
{
channels: {
zalouser: {
enabled: true,
dmPolicy: "pairing"
}
}
}
```
## CLI
```bash
clawdbot channels login --channel zalouser
clawdbot channels logout --channel zalouser
clawdbot channels status --probe
clawdbot message send --channel zalouser --to <threadId> --message "Hello from Clawdbot"
clawdbot directory peers list --channel zalouser --query "name"
```
## Agent tool
Tool name: `zalouser`
Actions: `send`, `image`, `link`, `friends`, `groups`, `me`, `status`
+1
View File
@@ -27,6 +27,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Chann
- [OpenAI (API + Codex)](/providers/openai)
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
- [OpenRouter](/providers/openrouter)
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Moonshot AI (Kimi)](/providers/moonshot)
- [OpenCode Zen](/providers/opencode)
- [Z.AI](/providers/zai)
+1
View File
@@ -25,6 +25,7 @@ model as `provider/model`.
- [OpenAI (API + Codex)](/providers/openai)
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
- [OpenRouter](/providers/openrouter)
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Moonshot AI (Kimi)](/providers/moonshot)
- [Synthetic](/providers/synthetic)
- [OpenCode Zen](/providers/opencode)
+49
View File
@@ -0,0 +1,49 @@
---
summary: "Vercel AI Gateway setup (auth + model selection)"
read_when:
- You want to use Vercel AI Gateway with Clawdbot
- You need the API key env var or CLI auth choice
---
# Vercel AI Gateway
The [Vercel AI Gateway](https://vercel.com/ai-gateway) provides a unified API to access hundreds of models through a single endpoint.
- Provider: `vercel-ai-gateway`
- Auth: `AI_GATEWAY_API_KEY`
- API: Anthropic Messages compatible
## Quick start
1) Set the API key (recommended: store it for the Gateway):
```bash
clawdbot onboard --auth-choice ai-gateway-api-key
```
2) Set a default model:
```json5
{
agents: {
defaults: {
model: { primary: "vercel-ai-gateway/anthropic/claude-opus-4.5" }
}
}
}
```
## Non-interactive example
```bash
clawdbot onboard --non-interactive \
--mode local \
--auth-choice ai-gateway-api-key \
--ai-gateway-api-key "$AI_GATEWAY_API_KEY"
```
## Environment note
If the Gateway runs as a daemon (launchd/systemd), make sure `AI_GATEWAY_API_KEY`
is available to that process (for example, in `~/.clawdbot/.env` or via
`env.shellEnv`).
+1 -1
View File
@@ -15,7 +15,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
- [Wizard](/start/wizard)
- [Setup](/start/setup)
- [Dashboard (local Gateway)](http://127.0.0.1:18789/)
- [FAQ](/start/faq)
- [Help](/help)
- [Configuration](/gateway/configuration)
- [Configuration examples](/gateway/configuration-examples)
- [Clawdbot assistant (Clawd)](/start/clawd)
+13
View File
@@ -146,6 +146,19 @@ Full setup walkthrough (28m) by VelvetShark.
14+ agents under one gateway with Opus 4.5 orchestrator delegating to Codex workers. Comprehensive [technical write-up](https://github.com/adam91holt/orchestrated-ai-articles) covering the Dream Team roster, model selection, sandboxing, webhooks, heartbeats, and delegation flows. [Clawdspace](https://github.com/adam91holt/clawdspace) for agent sandboxing. [Blog post](https://adams-ai-journey.ghost.io/2026-the-year-of-the-orchestrator/).
</Card>
<Card title="Linear CLI" icon="terminal" href="https://github.com/Finesssee/linear-cli">
**@NessZerra** • `devtools` `linear` `cli` `issues`
CLI for Linear that integrates with agentic workflows (Claude Code, Clawdbot). Manage issues, projects, and workflows from the terminal. First external PR merged!
</Card>
<Card title="Beeper CLI" icon="message" href="https://github.com/blqke/beepcli">
**@jules** • `messaging` `beeper` `cli` `automation`
Read, send, and archive messages via Beeper Desktop. Uses Beeper local MCP API so agents can manage all your chats (iMessage, WhatsApp, etc.) in one place.
</Card>
</CardGroup>
## 🤖 Automation & Workflows
+14 -1
View File
@@ -45,7 +45,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
## What the wizard does
**Local mode (default)** walks you through:
- Model/auth (OpenAI Code (Codex) subscription OAuth, Anthropic API key (recommended) or `claude setup-token`, plus MiniMax/GLM/Moonshot options)
- Model/auth (OpenAI Code (Codex) subscription OAuth, Anthropic API key (recommended) or `claude setup-token`, plus MiniMax/GLM/Moonshot/AI Gateway options)
- Workspace location + bootstrap files
- Gateway settings (port/bind/auth/tailscale)
- Providers (Telegram, WhatsApp, Discord, Signal)
@@ -88,6 +88,8 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then saves it to `~/.clawdbot/.env` so launchd can read it.
- **OpenCode Zen (multi-model proxy)**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth).
- **API key**: stores the key for you.
- **Vercel AI Gateway (multi-model proxy)**: prompts for `AI_GATEWAY_API_KEY`.
- More detail: [Vercel AI Gateway](/providers/vercel-ai-gateway)
- **MiniMax M2.1**: config is auto-written.
- More detail: [MiniMax](/providers/minimax)
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
@@ -211,6 +213,17 @@ clawdbot onboard --non-interactive \
--gateway-bind loopback
```
Vercel AI Gateway example:
```bash
clawdbot onboard --non-interactive \
--mode local \
--auth-choice ai-gateway-api-key \
--ai-gateway-api-key "$AI_GATEWAY_API_KEY" \
--gateway-port 18789 \
--gateway-bind loopback
```
Moonshot example:
```bash
+5
View File
@@ -26,6 +26,7 @@ They run immediately, are stripped before the model sees the message, and the re
{
commands: {
native: "auto",
nativeSkills: "auto",
text: true,
bash: false,
bashForegroundMs: 2000,
@@ -43,6 +44,9 @@ They run immediately, are stripped before the model sees the message, and the re
- Auto: on for Discord/Telegram; off for Slack (until you add slash commands); ignored for providers without native support.
- Set `channels.discord.commands.native`, `channels.telegram.commands.native`, or `channels.slack.commands.native` to override per provider (bool or `"auto"`).
- `false` clears previously registered commands on Discord/Telegram at startup. Slack commands are managed in the Slack app and are not removed automatically.
- `commands.nativeSkills` (default `"auto"`) registers **skill** commands natively when supported.
- Auto: on for Discord/Telegram; off for Slack (Slack requires creating a slash command per skill).
- Set `channels.discord.commands.nativeSkills`, `channels.telegram.commands.nativeSkills`, or `channels.slack.commands.nativeSkills` to override per provider (bool or `"auto"`).
- `commands.bash` (default `false`) enables `! <cmd>` to run host shell commands (`/bash <cmd>` is an alias; requires `tools.elevated` allowlists).
- `commands.bashForegroundMs` (default `2000`) controls how long bash waits before switching to background mode (`0` backgrounds immediately).
- `commands.config` (default `false`) enables `/config` (reads/writes `clawdbot.json`).
@@ -90,6 +94,7 @@ Notes:
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
- **Unknown slash commands:** if a command-only message starts with `/foo` and no built-in or skill command matches, Clawdbot looks for `~/.codex/prompts/foo.*` (or `$CODEX_HOME/prompts/foo.*`) and uses that file as the request body (front matter is stripped). Arguments are available as `$1`, `$2`, and `$*`.
- **Fast path:** command-only messages from allowlisted senders are handled immediately (bypass queue + model).
- **Group mention gating:** command-only messages from allowlisted senders bypass mention requirements.
- **Inline shortcuts (allowlisted senders only):** certain commands also work when embedded in a normal message and are stripped before the model sees the remaining text.
+13
View File
@@ -43,6 +43,15 @@ Auto-archive:
- Auto-archive is best-effort; pending timers are lost if the gateway restarts.
- `runTimeoutSeconds` does **not** auto-archive; it only stops the run. The session remains until auto-archive.
## Authentication
Sub-agent auth is resolved by **agent id**, not by session type:
- The sub-agent session key is `agent:<agentId>:subagent:<uuid>`.
- The auth store is loaded from that agent’s `agentDir`.
- The main agent’s auth profiles are merged in as a **fallback**; agent profiles override main profiles on conflicts.
Note: the merge is additive, so main profiles are always available as fallbacks. Fully isolated auth per agent is not supported yet.
## Announce
Sub-agents report back via an announce step:
@@ -99,6 +108,10 @@ Sub-agents use a dedicated in-process queue lane:
- Lane name: `subagent`
- Concurrency: `agents.defaults.subagents.maxConcurrent` (default `1`)
## Stopping
- Sending `/stop` in the requester chat aborts the requester session and stops any active sub-agent runs spawned from it.
## Limitations
- Sub-agent announce is **best-effort**. If the gateway restarts, pending “announce back” work is lost.
+2
View File
@@ -14,6 +14,8 @@ read_when:
- high → “ultrathink” (max budget)
- xhigh → “ultrathink+” (GPT-5.2 + Codex models only)
- `highest`, `max` map to `high`.
- Provider notes:
- Z.AI (`zai/*`) only supports binary thinking (`on`/`off`). Any non-`off` level is treated as `on` (mapped to `low`).
## Resolution order
1. Inline directive on the message (applies only to that message).
+27 -2
View File
@@ -73,6 +73,29 @@ Search the web with Brave’s API.
- `query` (required)
- `count` (1–10; default from config)
- `country` (optional): 2-letter country code for region-specific results (e.g., "DE", "US", "ALL"). If omitted, Brave chooses its default region.
- `search_lang` (optional): ISO language code for search results (e.g., "de", "en", "fr")
- `ui_lang` (optional): ISO language code for UI elements
**Examples:**
```javascript
// German-specific search
await web_search({
query: "TV online schauen",
count: 10,
country: "DE",
search_lang: "de"
});
// French search with French UI
await web_search({
query: "actualités",
country: "FR",
search_lang: "fr",
ui_lang: "fr"
});
```
## web_fetch
@@ -93,7 +116,8 @@ Fetch a URL and extract readable content.
maxChars: 50000,
timeoutSeconds: 30,
cacheTtlMinutes: 15,
userAgent: "clawdbot/2026.1.15"
userAgent: "clawdbot/2026.1.15",
readability: true
}
}
}
@@ -107,7 +131,8 @@ Fetch a URL and extract readable content.
- `maxChars` (truncate long pages)
Notes:
- `web_fetch` uses Readability (main-content extraction) by default and falls back to basic HTML cleanup if it fails.
- `web_fetch` is best-effort extraction; some sites will need the browser tool.
- Responses are cached (default 15 minutes) to reduce repeated fetches.
- If you use tool profiles/allowlists, add `web_search`/`web_fetch` or `group:web`.
- If the Brave key is missing, `web_search` returns a short setup hint with a docs link.
- If the Brave key is missing, `web_search` returns a short setup hint with a docs link.
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import type { CoreConfig } from "./types.js";
import { matrixPlugin } from "./channel.js";
describe("matrix directory", () => {
it("lists peers and groups from config", async () => {
const cfg = {
channels: {
matrix: {
dm: { allowFrom: ["matrix:@alice:example.org", "bob"] },
rooms: {
"!room1:example.org": { users: ["@carol:example.org"] },
"#alias:example.org": { users: [] },
},
},
},
} as unknown as CoreConfig;
expect(matrixPlugin.directory).toBeTruthy();
expect(matrixPlugin.directory?.listPeers).toBeTruthy();
expect(matrixPlugin.directory?.listGroups).toBeTruthy();
await expect(
matrixPlugin.directory!.listPeers({ cfg, accountId: undefined, query: undefined, limit: undefined }),
).resolves.toEqual(
expect.arrayContaining([
{ kind: "user", id: "user:@alice:example.org" },
{ kind: "user", id: "bob", name: "incomplete id; expected @user:server" },
{ kind: "user", id: "user:@carol:example.org" },
]),
);
await expect(
matrixPlugin.directory!.listGroups({ cfg, accountId: undefined, query: undefined, limit: undefined }),
).resolves.toEqual(
expect.arrayContaining([
{ kind: "group", id: "room:!room1:example.org" },
{ kind: "group", id: "#alias:example.org" },
]),
);
});
});
+61
View File
@@ -162,6 +162,67 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
messaging: {
normalizeTarget: normalizeMatrixMessagingTarget,
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const entry of account.config.dm?.allowFrom ?? []) {
const raw = String(entry).trim();
if (!raw || raw === "*") continue;
ids.add(raw.replace(/^matrix:/i, ""));
}
for (const room of Object.values(account.config.rooms ?? {})) {
for (const entry of room.users ?? []) {
const raw = String(entry).trim();
if (!raw || raw === "*") continue;
ids.add(raw.replace(/^matrix:/i, ""));
}
}
return Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.map((raw) => {
const lowered = raw.toLowerCase();
const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
if (cleaned.startsWith("@")) return `user:${cleaned}`;
return cleaned;
})
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => {
const raw = id.startsWith("user:") ? id.slice("user:".length) : id;
const incomplete = !raw.startsWith("@") || !raw.includes(":");
return {
kind: "user",
id,
...(incomplete ? { name: "incomplete id; expected @user:server" } : {}),
};
});
},
listGroups: async ({ cfg, accountId, query, limit }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const ids = Object.keys(account.config.rooms ?? {})
.map((raw) => raw.trim())
.filter((raw) => Boolean(raw) && raw !== "*")
.map((raw) => raw.replace(/^matrix:/i, ""))
.map((raw) => {
const lowered = raw.toLowerCase();
if (lowered.startsWith("room:") || lowered.startsWith("channel:")) return raw;
if (raw.startsWith("!")) return `room:${raw}`;
return raw;
})
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id }) as const);
return ids;
},
},
actions: matrixMessageActions,
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import type { CoreConfig } from "../types.js";
import { resolveMatrixConfig } from "./client.js";
describe("resolveMatrixConfig", () => {
it("prefers config over env", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://cfg.example.org",
userId: "@cfg:example.org",
accessToken: "cfg-token",
password: "cfg-pass",
deviceName: "CfgDevice",
initialSyncLimit: 5,
},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://env.example.org",
MATRIX_USER_ID: "@env:example.org",
MATRIX_ACCESS_TOKEN: "env-token",
MATRIX_PASSWORD: "env-pass",
MATRIX_DEVICE_NAME: "EnvDevice",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfig(cfg, env);
expect(resolved).toEqual({
homeserver: "https://cfg.example.org",
userId: "@cfg:example.org",
accessToken: "cfg-token",
password: "cfg-pass",
deviceName: "CfgDevice",
initialSyncLimit: 5,
});
});
it("uses env when config is missing", () => {
const cfg = {} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://env.example.org",
MATRIX_USER_ID: "@env:example.org",
MATRIX_ACCESS_TOKEN: "env-token",
MATRIX_PASSWORD: "env-pass",
MATRIX_DEVICE_NAME: "EnvDevice",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfig(cfg, env);
expect(resolved.homeserver).toBe("https://env.example.org");
expect(resolved.userId).toBe("@env:example.org");
expect(resolved.accessToken).toBe("env-token");
expect(resolved.password).toBe("env-pass");
expect(resolved.deviceName).toBe("EnvDevice");
expect(resolved.initialSyncLimit).toBeUndefined();
});
});
+5 -5
View File
@@ -50,13 +50,13 @@ export function resolveMatrixConfig(
env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig {
const matrix = cfg.channels?.matrix ?? {};
const homeserver = clean(env.MATRIX_HOMESERVER) || clean(matrix.homeserver);
const userId = clean(env.MATRIX_USER_ID) || clean(matrix.userId);
const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER);
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
const accessToken =
clean(env.MATRIX_ACCESS_TOKEN) || clean(matrix.accessToken) || undefined;
const password = clean(env.MATRIX_PASSWORD) || clean(matrix.password) || undefined;
clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
const deviceName =
clean(env.MATRIX_DEVICE_NAME) || clean(matrix.deviceName) || undefined;
clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined;
const initialSyncLimit =
typeof matrix.initialSyncLimit === "number"
? Math.max(0, Math.floor(matrix.initialSyncLimit))
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../../src/config/config.js";
import { msteamsPlugin } from "./channel.js";
describe("msteams directory", () => {
it("lists peers and groups from config", async () => {
const cfg = {
channels: {
msteams: {
allowFrom: ["alice", "user:Bob"],
dms: { carol: {}, bob: {} },
teams: {
team1: {
channels: {
"conversation:chan1": {},
chan2: {},
},
},
},
},
},
} as unknown as ClawdbotConfig;
expect(msteamsPlugin.directory).toBeTruthy();
expect(msteamsPlugin.directory?.listPeers).toBeTruthy();
expect(msteamsPlugin.directory?.listGroups).toBeTruthy();
await expect(msteamsPlugin.directory!.listPeers({ cfg, query: undefined, limit: undefined })).resolves.toEqual(
expect.arrayContaining([
{ kind: "user", id: "user:alice" },
{ kind: "user", id: "user:Bob" },
{ kind: "user", id: "user:carol" },
{ kind: "user", id: "user:bob" },
]),
);
await expect(msteamsPlugin.directory!.listGroups({ cfg, query: undefined, limit: undefined })).resolves.toEqual(
expect.arrayContaining([
{ kind: "group", id: "conversation:chan1" },
{ kind: "group", id: "conversation:chan2" },
]),
);
});
});
+64
View File
@@ -25,6 +25,21 @@ const meta = {
order: 60,
} as const;
function normalizeMSTeamsMessagingTarget(raw: string): string | undefined {
let trimmed = raw.trim();
if (!trimmed) return undefined;
if (/^(msteams|teams):/i.test(trimmed)) {
trimmed = trimmed.replace(/^(msteams|teams):/i, "");
}
if (/^conversation:/i.test(trimmed)) {
return `conversation:${trimmed.slice("conversation:".length).trim()}`;
}
if (/^user:/i.test(trimmed)) {
return `user:${trimmed.slice("user:".length).trim()}`;
}
return trimmed;
}
export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
id: "msteams",
meta: {
@@ -113,6 +128,55 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
},
}),
},
messaging: {
normalizeTarget: normalizeMSTeamsMessagingTarget,
},
directory: {
self: async () => null,
listPeers: async ({ cfg, query, limit }) => {
const q = query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const entry of cfg.channels?.msteams?.allowFrom ?? []) {
const trimmed = String(entry).trim();
if (trimmed && trimmed !== "*") ids.add(trimmed);
}
for (const userId of Object.keys(cfg.channels?.msteams?.dms ?? {})) {
const trimmed = userId.trim();
if (trimmed) ids.add(trimmed);
}
return Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.map((raw) => normalizeMSTeamsMessagingTarget(raw) ?? raw)
.map((raw) => {
const lowered = raw.toLowerCase();
if (lowered.startsWith("user:")) return raw;
if (lowered.startsWith("conversation:")) return raw;
return `user:${raw}`;
})
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "user", id }) as const);
},
listGroups: async ({ cfg, query, limit }) => {
const q = query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const team of Object.values(cfg.channels?.msteams?.teams ?? {})) {
for (const channelId of Object.keys(team.channels ?? {})) {
const trimmed = channelId.trim();
if (trimmed && trimmed !== "*") ids.add(trimmed);
}
}
return Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.map((raw) => raw.replace(/^conversation:/i, "").trim())
.map((id) => `conversation:${id}`)
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id }) as const);
},
},
actions: {
listActions: ({ cfg }) => {
const enabled =
@@ -6,11 +6,13 @@ import {
} from "../../../../src/auto-reply/inbound-debounce.js";
import { dispatchReplyFromConfig } from "../../../../src/auto-reply/reply/dispatch-from-config.js";
import {
buildHistoryContextFromMap,
buildPendingHistoryContextFromMap,
clearHistoryEntries,
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntry,
type HistoryEntry,
} from "../../../../src/auto-reply/reply/history.js";
import { resolveMentionGating } from "../../../../src/channels/mention-gating.js";
import { danger, logVerbose, shouldLogVerbose } from "../../../../src/globals.js";
import { enqueueSystemEvent } from "../../../../src/infra/system-events.js";
import {
@@ -42,6 +44,7 @@ import {
} from "../policy.js";
import { extractMSTeamsPollVote } from "../polls.js";
import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js";
import type { MSTeamsTurnContext } from "../sdk-types.js";
import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
@@ -74,6 +77,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
text: string;
attachments: MSTeamsAttachmentLike[];
wasMentioned: boolean;
implicitMention: boolean;
};
const handleTeamsMessageNow = async (params: MSTeamsDebounceEntry) => {
@@ -299,21 +303,40 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
teamConfig,
channelConfig,
});
const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
if (!isDirectMessage) {
const mentioned = params.wasMentioned;
if (requireMention && !mentioned) {
const mentionGate = resolveMentionGating({
requireMention: Boolean(requireMention),
canDetectMention: true,
wasMentioned: params.wasMentioned,
implicitMention: params.implicitMention,
shouldBypassMention: false,
});
const mentioned = mentionGate.effectiveWasMentioned;
if (requireMention && mentionGate.shouldSkip) {
log.debug("skipping message (mention required)", {
teamId,
channelId,
requireMention,
mentioned,
});
if (historyLimit > 0) {
recordPendingHistoryEntry({
historyMap: conversationHistories,
historyKey: conversationId,
limit: historyLimit,
entry: {
sender: senderName,
body: rawBody,
timestamp: timestamp?.getTime(),
messageId: activity.id ?? undefined,
},
});
}
return;
}
}
const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
const mediaList = await resolveMSTeamsInboundMedia({
attachments,
htmlSummary: htmlSummary ?? undefined,
@@ -342,16 +365,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const isRoomish = !isDirectMessage;
const historyKey = isRoomish ? conversationId : undefined;
if (isRoomish && historyKey && historyLimit > 0) {
combinedBody = buildHistoryContextFromMap({
combinedBody = buildPendingHistoryContextFromMap({
historyMap: conversationHistories,
historyKey,
limit: historyLimit,
entry: {
sender: senderName,
body: rawBody,
timestamp: timestamp?.getTime(),
messageId: activity.id ?? undefined,
},
currentMessage: combinedBody,
formatEntry: (entry) =>
formatAgentEnvelope({
@@ -379,7 +396,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
Surface: "msteams" as const,
MessageSid: activity.id,
Timestamp: timestamp?.getTime() ?? Date.now(),
WasMentioned: isDirectMessage || params.wasMentioned,
WasMentioned: isDirectMessage || params.wasMentioned || params.implicitMention,
CommandAuthorized: true,
OriginatingChannel: "msteams" as const,
OriginatingTo: teamsTo,
@@ -401,6 +418,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
context,
replyStyle,
textLimit,
onSentMessageIds: (ids) => {
for (const id of ids) {
recordMSTeamsSentMessage(conversationId, id);
}
},
});
log.info("dispatching to agent", { sessionKey: route.sessionKey });
@@ -417,7 +439,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const didSendReply = counts.final + counts.tool + counts.block > 0;
if (!queuedFinal) {
if (isRoomish && historyKey && historyLimit > 0 && didSendReply) {
if (isRoomish && historyKey && historyLimit > 0) {
clearHistoryEntries({
historyMap: conversationHistories,
historyKey,
@@ -431,7 +453,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
);
}
if (isRoomish && historyKey && historyLimit > 0 && didSendReply) {
if (isRoomish && historyKey && historyLimit > 0) {
clearHistoryEntries({ historyMap: conversationHistories, historyKey });
}
} catch (err) {
@@ -480,12 +502,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
.filter(Boolean)
.join("\n");
const wasMentioned = entries.some((entry) => entry.wasMentioned);
const implicitMention = entries.some((entry) => entry.implicitMention);
await handleTeamsMessageNow({
context: last.context,
rawText: combinedRawText,
text: combinedText,
attachments: [],
wasMentioned,
implicitMention,
});
},
onError: (err) => {
@@ -501,7 +525,19 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
? (activity.attachments as unknown as MSTeamsAttachmentLike[])
: [];
const wasMentioned = wasMSTeamsBotMentioned(activity);
const conversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
const replyToId = activity.replyToId ?? undefined;
const implicitMention = Boolean(
conversationId && replyToId && wasMSTeamsMessageSent(conversationId, replyToId),
);
await inboundDebouncer.enqueue({ context, rawText, text, attachments, wasMentioned });
await inboundDebouncer.enqueue({
context,
rawText,
text,
attachments,
wasMentioned,
implicitMention,
});
};
}
+3 -1
View File
@@ -28,6 +28,7 @@ export function createMSTeamsReplyDispatcher(params: {
context: MSTeamsTurnContext;
replyStyle: MSTeamsReplyStyle;
textLimit: number;
onSentMessageIds?: (ids: string[]) => void;
}) {
const sendTypingIndicator = async () => {
try {
@@ -46,7 +47,7 @@ export function createMSTeamsReplyDispatcher(params: {
chunkText: true,
mediaMode: "split",
});
await sendMSTeamsMessages({
const ids = await sendMSTeamsMessages({
replyStyle: params.replyStyle,
adapter: params.adapter,
appId: params.appId,
@@ -62,6 +63,7 @@ export function createMSTeamsReplyDispatcher(params: {
});
},
});
if (ids.length > 0) params.onSentMessageIds?.(ids);
},
onError: (err, info) => {
const errMsg = formatUnknownError(err);
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import {
clearMSTeamsSentMessageCache,
recordMSTeamsSentMessage,
wasMSTeamsMessageSent,
} from "./sent-message-cache.js";
describe("msteams sent message cache", () => {
it("records and resolves sent message ids", () => {
clearMSTeamsSentMessageCache();
recordMSTeamsSentMessage("conv-1", "msg-1");
expect(wasMSTeamsMessageSent("conv-1", "msg-1")).toBe(true);
expect(wasMSTeamsMessageSent("conv-1", "msg-2")).toBe(false);
});
});
@@ -0,0 +1,41 @@
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
type CacheEntry = {
messageIds: Set<string>;
timestamps: Map<string, number>;
};
const sentMessages = new Map<string, CacheEntry>();
function cleanupExpired(entry: CacheEntry): void {
const now = Date.now();
for (const [msgId, timestamp] of entry.timestamps) {
if (now - timestamp > TTL_MS) {
entry.messageIds.delete(msgId);
entry.timestamps.delete(msgId);
}
}
}
export function recordMSTeamsSentMessage(conversationId: string, messageId: string): void {
if (!conversationId || !messageId) return;
let entry = sentMessages.get(conversationId);
if (!entry) {
entry = { messageIds: new Set(), timestamps: new Map() };
sentMessages.set(conversationId, entry);
}
entry.messageIds.add(messageId);
entry.timestamps.set(messageId, Date.now());
if (entry.messageIds.size > 200) cleanupExpired(entry);
}
export function wasMSTeamsMessageSent(conversationId: string, messageId: string): boolean {
const entry = sentMessages.get(conversationId);
if (!entry) return false;
cleanupExpired(entry);
return entry.messageIds.has(messageId);
}
export function clearMSTeamsSentMessageCache(): void {
sentMessages.clear();
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import type { CoreConfig } from "./types.js";
import { zaloPlugin } from "./channel.js";
describe("zalo directory", () => {
it("lists peers from allowFrom", async () => {
const cfg = {
channels: {
zalo: {
allowFrom: ["zalo:123", "zl:234", "345"],
},
},
} as unknown as CoreConfig;
expect(zaloPlugin.directory).toBeTruthy();
expect(zaloPlugin.directory?.listPeers).toBeTruthy();
expect(zaloPlugin.directory?.listGroups).toBeTruthy();
await expect(
zaloPlugin.directory!.listPeers({ cfg, accountId: undefined, query: undefined, limit: undefined }),
).resolves.toEqual(
expect.arrayContaining([
{ kind: "user", id: "123" },
{ kind: "user", id: "234" },
{ kind: "user", id: "345" },
]),
);
await expect(zaloPlugin.directory!.listGroups({ cfg, accountId: undefined, query: undefined, limit: undefined })).resolves.toEqual(
[],
);
});
});
+20
View File
@@ -148,6 +148,26 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
messaging: {
normalizeTarget: normalizeZaloMessagingTarget,
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveZaloAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const peers = Array.from(
new Set(
(account.config.allowFrom ?? [])
.map((entry) => String(entry).trim())
.filter((entry) => Boolean(entry) && entry !== "*")
.map((entry) => entry.replace(/^(zalo|zl):/i, "")),
),
)
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "user", id }) as const);
return peers;
},
listGroups: async () => [],
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
+221
View File
@@ -0,0 +1,221 @@
# @clawdbot/zalouser
Clawdbot extension for Zalo Personal Account messaging via [zca-cli](https://zca-cli.dev).
> **Warning:** Using Zalo automation may result in account suspension or ban. Use at your own risk. This is an unofficial integration.
## Features
- **Channel Plugin Integration**: Appears in onboarding wizard with QR login
- **Gateway Integration**: Real-time message listening via the gateway
- **Multi-Account Support**: Manage multiple Zalo personal accounts
- **CLI Commands**: Full command-line interface for messaging
- **Agent Tool**: AI agent integration for automated messaging
## Prerequisites
Install `zca` CLI and ensure it's in your PATH:
**macOS / Linux:**
```bash
curl -fsSL https://get.zca-cli.dev/install.sh | bash
# Or with custom install directory
ZCA_INSTALL_DIR=~/.local/bin curl -fsSL https://get.zca-cli.dev/install.sh | bash
# Install specific version
curl -fsSL https://get.zca-cli.dev/install.sh | bash -s v1.0.0
# Uninstall
curl -fsSL https://get.zca-cli.dev/install.sh | bash -s uninstall
```
**Windows (PowerShell):**
```powershell
irm https://get.zca-cli.dev/install.ps1 | iex
# Or with custom install directory
$env:ZCA_INSTALL_DIR = "C:\Tools\zca"; irm https://get.zca-cli.dev/install.ps1 | iex
# Install specific version
iex "& { $(irm https://get.zca-cli.dev/install.ps1) } -Version v1.0.0"
# Uninstall
iex "& { $(irm https://get.zca-cli.dev/install.ps1) } -Uninstall"
```
### Manual Download
Download binary directly:
**macOS / Linux:**
```bash
curl -fsSL https://get.zca-cli.dev/latest/zca-darwin-arm64 -o zca && chmod +x zca
```
**Windows (PowerShell):**
```powershell
Invoke-WebRequest -Uri https://get.zca-cli.dev/latest/zca-windows-x64.exe -OutFile zca.exe
```
Available binaries:
- `zca-darwin-arm64` - macOS Apple Silicon
- `zca-darwin-x64` - macOS Intel
- `zca-linux-arm64` - Linux ARM64
- `zca-linux-x64` - Linux x86_64
- `zca-windows-x64.exe` - Windows
See [zca-cli](https://zca-cli.dev) for manual download (binaries for macOS/Linux/Windows) or building from source.
## Quick Start
### Option 1: Onboarding Wizard (Recommended)
```bash
clawdbot onboard
# Select "Zalo Personal" from channel list
# Follow QR code login flow
```
### Option 2: Login (QR, on the Gateway machine)
```bash
clawdbot channels login --channel zalouser
# Scan QR code with Zalo app
```
### Send a Message
```bash
clawdbot message send --channel zalouser --to <threadId> --message "Hello from Clawdbot!"
```
## Configuration
After onboarding, your config will include:
```yaml
channels:
zalouser:
enabled: true
dmPolicy: pairing # pairing | allowlist | open | disabled
```
For multi-account:
```yaml
channels:
zalouser:
enabled: true
defaultAccount: default
accounts:
default:
enabled: true
profile: default
work:
enabled: true
profile: work
```
## Commands
### Authentication
```bash
clawdbot channels login --channel zalouser # Login via QR
clawdbot channels login --channel zalouser --account work
clawdbot channels status --probe
clawdbot channels logout --channel zalouser
```
### Directory (IDs, contacts, groups)
```bash
clawdbot directory self --channel zalouser
clawdbot directory peers list --channel zalouser --query "name"
clawdbot directory groups list --channel zalouser --query "work"
clawdbot directory groups members --channel zalouser --group-id <id>
```
### Account Management
```bash
zca account list # List all profiles
zca account current # Show active profile
zca account switch <profile>
zca account remove <profile>
zca account label <profile> "Work Account"
```
### Messaging
```bash
# Text
clawdbot message send --channel zalouser --to <threadId> --message "message"
# Media (URL)
clawdbot message send --channel zalouser --to <threadId> --message "caption" --media-url "https://example.com/img.jpg"
```
### Listener
The listener runs inside the Gateway when the channel is enabled. For debugging,
use `clawdbot channels logs --channel zalouser` or run `zca listen` directly.
### Data Access
```bash
# Friends
zca friend list
zca friend list -j # JSON output
zca friend find "name"
zca friend online
# Groups
zca group list
zca group info <groupId>
zca group members <groupId>
# Profile
zca me info
zca me id
```
## Multi-Account Support
Use `--profile` or `-p` to work with multiple accounts:
```bash
clawdbot channels login --channel zalouser --account work
clawdbot message send --channel zalouser --account work --to <id> --message "Hello"
ZCA_PROFILE=work zca listen
```
Profile resolution order: `--profile` flag > `ZCA_PROFILE` env > default
## Agent Tool
The extension registers a `zalouser` tool for AI agents:
```json
{
"action": "send",
"threadId": "123456",
"message": "Hello from AI!",
"isGroup": false,
"profile": "default"
}
```
Available actions: `send`, `image`, `link`, `friends`, `groups`, `me`, `status`
## Troubleshooting
- **Login Issues:** Run `zca auth logout` then `zca auth login`
- **API Errors:** Try `zca auth cache-refresh` or re-login
- **File Uploads:** Check size (max 100MB) and path accessibility
## Credits
Built on [zca-cli](https://zca-cli.dev) which uses [zca-js](https://github.com/RFS-ADRENO/zca-js).
+28
View File
@@ -0,0 +1,28 @@
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
import { zalouserPlugin } from "./src/channel.js";
import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js";
const plugin = {
id: "zalouser",
name: "Zalo Personal",
description: "Zalo personal account messaging via zca-cli",
register(api: ClawdbotPluginApi) {
// Register channel plugin (for onboarding & gateway)
api.registerChannel(zalouserPlugin);
// Register agent tool
api.registerTool({
name: "zalouser",
label: "Zalo Personal",
description:
"Send messages and access data via Zalo personal account. " +
"Actions: send (text message), image (send image URL), link (send link), " +
"friends (list/search friends), groups (list groups), me (profile info), status (auth check).",
parameters: ZalouserToolSchema,
execute: executeZalouserTool,
});
},
};
export default plugin;
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@clawdbot/zalouser",
"version": "2026.1.15",
"type": "module",
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
"dependencies": {
"@sinclair/typebox": "0.34.47"
},
"clawdbot": {
"extensions": ["./index.ts"]
}
}
+121
View File
@@ -0,0 +1,121 @@
import { runZca, parseJsonOutput } from "./zca.js";
import {
DEFAULT_ACCOUNT_ID,
type CoreConfig,
type ResolvedZalouserAccount,
type ZalouserAccountConfig,
type ZalouserConfig,
} from "./types.js";
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
const accounts = (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts;
if (!accounts || typeof accounts !== "object") return [];
return Object.keys(accounts).filter(Boolean);
}
export function listZalouserAccountIds(cfg: CoreConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
return ids.sort((a, b) => a.localeCompare(b));
}
export function resolveDefaultZalouserAccountId(cfg: CoreConfig): string {
const zalouserConfig = cfg.channels?.zalouser as ZalouserConfig | undefined;
if (zalouserConfig?.defaultAccount?.trim()) return zalouserConfig.defaultAccount.trim();
const ids = listZalouserAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
export function normalizeAccountId(accountId?: string | null): string {
const trimmed = accountId?.trim();
if (!trimmed) return DEFAULT_ACCOUNT_ID;
return trimmed.toLowerCase();
}
function resolveAccountConfig(
cfg: CoreConfig,
accountId: string,
): ZalouserAccountConfig | undefined {
const accounts = (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts;
if (!accounts || typeof accounts !== "object") return undefined;
return accounts[accountId] as ZalouserAccountConfig | undefined;
}
function mergeZalouserAccountConfig(cfg: CoreConfig, accountId: string): ZalouserAccountConfig {
const raw = (cfg.channels?.zalouser ?? {}) as ZalouserConfig;
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
const account = resolveAccountConfig(cfg, accountId) ?? {};
return { ...base, ...account };
}
function resolveZcaProfile(config: ZalouserAccountConfig, accountId: string): string {
if (config.profile?.trim()) return config.profile.trim();
if (process.env.ZCA_PROFILE?.trim()) return process.env.ZCA_PROFILE.trim();
if (accountId !== DEFAULT_ACCOUNT_ID) return accountId;
return "default";
}
export async function checkZcaAuthenticated(profile: string): Promise<boolean> {
const result = await runZca(["auth", "status"], { profile, timeout: 5000 });
return result.ok;
}
export async function resolveZalouserAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
}): Promise<ResolvedZalouserAccount> {
const accountId = normalizeAccountId(params.accountId);
const baseEnabled = (params.cfg.channels?.zalouser as ZalouserConfig | undefined)?.enabled !== false;
const merged = mergeZalouserAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const profile = resolveZcaProfile(merged, accountId);
const authenticated = await checkZcaAuthenticated(profile);
return {
accountId,
name: merged.name?.trim() || undefined,
enabled,
profile,
authenticated,
config: merged,
};
}
export function resolveZalouserAccountSync(params: {
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedZalouserAccount {
const accountId = normalizeAccountId(params.accountId);
const baseEnabled = (params.cfg.channels?.zalouser as ZalouserConfig | undefined)?.enabled !== false;
const merged = mergeZalouserAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const profile = resolveZcaProfile(merged, accountId);
return {
accountId,
name: merged.name?.trim() || undefined,
enabled,
profile,
authenticated: false, // unknown without async check
config: merged,
};
}
export async function listEnabledZalouserAccounts(cfg: CoreConfig): Promise<ResolvedZalouserAccount[]> {
const ids = listZalouserAccountIds(cfg);
const accounts = await Promise.all(
ids.map((accountId) => resolveZalouserAccount({ cfg, accountId }))
);
return accounts.filter((account) => account.enabled);
}
export async function getZcaUserInfo(profile: string): Promise<{ userId?: string; displayName?: string } | null> {
const result = await runZca(["me", "info", "-j"], { profile, timeout: 10000 });
if (!result.ok) return null;
return parseJsonOutput<{ userId?: string; displayName?: string }>(result.stdout);
}
export type { ResolvedZalouserAccount } from "./types.js";
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { zalouserPlugin } from "./channel.js";
describe("zalouser outbound chunker", () => {
it("chunks without empty strings and respects limit", () => {
const chunker = zalouserPlugin.outbound?.chunker;
expect(chunker).toBeTypeOf("function");
if (!chunker) return;
const limit = 10;
const chunks = chunker("hello world\nthis is a test", limit);
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((c) => c.length > 0)).toBe(true);
expect(chunks.every((c) => c.length <= limit)).toBe(true);
});
});
+525
View File
@@ -0,0 +1,525 @@
import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js";
import type {
ChannelAccountSnapshot,
ChannelDirectoryEntry,
} from "../../../src/channels/plugins/types.core.js";
import { formatPairingApproveHint } from "../../../src/channels/plugins/helpers.js";
import {
listZalouserAccountIds,
resolveDefaultZalouserAccountId,
resolveZalouserAccountSync,
getZcaUserInfo,
checkZcaAuthenticated,
type ResolvedZalouserAccount,
} from "./accounts.js";
import { zalouserOnboardingAdapter } from "./onboarding.js";
import { sendMessageZalouser } from "./send.js";
import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js";
import {
DEFAULT_ACCOUNT_ID,
type CoreConfig,
type ZalouserConfig,
type ZcaFriend,
type ZcaGroup,
type ZcaUserInfo,
} from "./types.js";
const meta = {
id: "zalouser",
label: "Zalo Personal",
selectionLabel: "Zalo (Personal Account)",
docsPath: "/channels/zalouser",
docsLabel: "zalouser",
blurb: "Zalo personal account via QR code login.",
aliases: ["zlu"],
order: 85,
quickstartAllowFrom: true,
};
function resolveZalouserQrProfile(accountId?: string | null): string {
const normalized = String(accountId ?? "").trim();
if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
return process.env.ZCA_PROFILE?.trim() || "default";
}
return normalized;
}
function mapUser(params: {
id: string;
name?: string | null;
avatarUrl?: string | null;
raw?: unknown;
}): ChannelDirectoryEntry {
return {
kind: "user",
id: params.id,
name: params.name ?? undefined,
avatarUrl: params.avatarUrl ?? undefined,
raw: params.raw,
};
}
function mapGroup(params: {
id: string;
name?: string | null;
raw?: unknown;
}): ChannelDirectoryEntry {
return {
kind: "group",
id: params.id,
name: params.name ?? undefined,
raw: params.raw,
};
}
function deleteAccountFromConfigSection(params: {
cfg: CoreConfig;
accountId: string;
}): CoreConfig {
const { cfg, accountId } = params;
if (accountId === DEFAULT_ACCOUNT_ID) {
const { zalouser: _removed, ...restChannels } = cfg.channels ?? {};
return { ...cfg, channels: restChannels };
}
const accounts = { ...(cfg.channels?.zalouser?.accounts ?? {}) };
delete accounts[accountId];
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
accounts,
},
},
};
}
function setAccountEnabledInConfigSection(params: {
cfg: CoreConfig;
accountId: string;
enabled: boolean;
}): CoreConfig {
const { cfg, accountId, enabled } = params;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
accounts: {
...(cfg.channels?.zalouser?.accounts ?? {}),
[accountId]: {
...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
enabled,
},
},
},
},
};
}
export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
id: "zalouser",
meta,
onboarding: zalouserOnboardingAdapter,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: true,
threads: false,
polls: false,
nativeCommands: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.zalouser"] },
config: {
listAccountIds: (cfg) => listZalouserAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) =>
resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultZalouserAccountId(cfg as CoreConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as CoreConfig,
accountId,
enabled,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as CoreConfig,
accountId,
}),
isConfigured: async (account) => {
// Check if zca auth status is OK for this profile
const result = await runZca(["auth", "status"], {
profile: account.profile,
timeout: 5000,
});
return result.ok;
},
describeAccount: (account): ChannelAccountSnapshot => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: undefined,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.replace(/^(zalouser|zlu):/i, ""))
.map((entry) => entry.toLowerCase()),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
(cfg as CoreConfig).channels?.zalouser?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.zalouser.accounts.${resolvedAccountId}.`
: "channels.zalouser.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint("zalouser"),
normalizeEntry: (raw) => raw.replace(/^(zalouser|zlu):/i, ""),
};
},
},
groups: {
resolveRequireMention: () => true,
},
threading: {
resolveReplyToMode: () => "off",
},
messaging: {
normalizeTarget: (raw) => {
const trimmed = raw?.trim();
if (!trimmed) return undefined;
return trimmed.replace(/^(zalouser|zlu):/i, "");
},
},
directory: {
self: async ({ cfg, accountId, runtime }) => {
const ok = await checkZcaInstalled();
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const result = await runZca(["me", "info", "-j"], { profile: account.profile, timeout: 10000 });
if (!result.ok) {
runtime.error(result.stderr || "Failed to fetch profile");
return null;
}
const parsed = parseJsonOutput<ZcaUserInfo>(result.stdout);
if (!parsed?.userId) return null;
return mapUser({
id: String(parsed.userId),
name: parsed.displayName ?? null,
avatarUrl: parsed.avatar ?? null,
raw: parsed,
});
},
listPeers: async ({ cfg, accountId, query, limit }) => {
const ok = await checkZcaInstalled();
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const args = query?.trim()
? ["friend", "find", query.trim()]
: ["friend", "list", "-j"];
const result = await runZca(args, { profile: account.profile, timeout: 15000 });
if (!result.ok) {
throw new Error(result.stderr || "Failed to list peers");
}
const parsed = parseJsonOutput<ZcaFriend[]>(result.stdout);
const rows = Array.isArray(parsed)
? parsed.map((f) =>
mapUser({
id: String(f.userId),
name: f.displayName ?? null,
avatarUrl: f.avatar ?? null,
raw: f,
}),
)
: [];
return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
},
listGroups: async ({ cfg, accountId, query, limit }) => {
const ok = await checkZcaInstalled();
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const result = await runZca(["group", "list", "-j"], { profile: account.profile, timeout: 15000 });
if (!result.ok) {
throw new Error(result.stderr || "Failed to list groups");
}
const parsed = parseJsonOutput<ZcaGroup[]>(result.stdout);
let rows = Array.isArray(parsed)
? parsed.map((g) =>
mapGroup({
id: String(g.groupId),
name: g.name ?? null,
raw: g,
}),
)
: [];
const q = query?.trim().toLowerCase();
if (q) {
rows = rows.filter((g) => (g.name ?? "").toLowerCase().includes(q) || g.id.includes(q));
}
return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
},
listGroupMembers: async ({ cfg, accountId, groupId, limit }) => {
const ok = await checkZcaInstalled();
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const result = await runZca(["group", "members", groupId, "-j"], {
profile: account.profile,
timeout: 20000,
});
if (!result.ok) {
throw new Error(result.stderr || "Failed to list group members");
}
const parsed = parseJsonOutput<Array<Partial<ZcaFriend> & { userId?: string | number }>>(result.stdout);
const rows = Array.isArray(parsed)
? parsed
.map((m) => {
const id = m.userId ?? (m as { id?: string | number }).id;
if (!id) return null;
return mapUser({
id: String(id),
name: (m as { displayName?: string }).displayName ?? null,
avatarUrl: (m as { avatar?: string }).avatar ?? null,
raw: m,
});
})
.filter(Boolean)
: [];
const sliced = typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
return sliced as ChannelDirectoryEntry[];
},
},
pairing: {
idLabel: "zalouserUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(zalouser|zlu):/i, ""),
notifyApproval: async ({ cfg, id }) => {
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig });
const authenticated = await checkZcaAuthenticated(account.profile);
if (!authenticated) throw new Error("Zalouser not authenticated");
await sendMessageZalouser(id, "Your pairing request has been approved.", {
profile: account.profile,
});
},
},
auth: {
login: async ({ cfg, accountId, runtime }) => {
const account = resolveZalouserAccountSync({
cfg: cfg as CoreConfig,
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
});
const ok = await checkZcaInstalled();
if (!ok) {
throw new Error(
"Missing dependency: `zca` not found in PATH. See docs.clawd.bot/channels/zalouser",
);
}
runtime.log(
`Scan the QR code in this terminal to link Zalo Personal (account: ${account.accountId}, profile: ${account.profile}).`,
);
const result = await runZcaInteractive(["auth", "login"], { profile: account.profile });
if (!result.ok) {
throw new Error(result.stderr || "Zalouser login failed");
}
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => {
if (!text) return [];
if (limit <= 0 || text.length <= limit) return [text];
const chunks: string[] = [];
let remaining = text;
while (remaining.length > limit) {
const window = remaining.slice(0, limit);
const lastNewline = window.lastIndexOf("\n");
const lastSpace = window.lastIndexOf(" ");
let breakIdx = lastNewline > 0 ? lastNewline : lastSpace;
if (breakIdx <= 0) breakIdx = limit;
const rawChunk = remaining.slice(0, breakIdx);
const chunk = rawChunk.trimEnd();
if (chunk.length > 0) chunks.push(chunk);
const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0));
remaining = remaining.slice(nextStart).trimStart();
}
if (remaining.length) chunks.push(remaining);
return chunks;
},
textChunkLimit: 2000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error("Delivering to Zalouser requires --to <threadId>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, cfg }) => {
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const result = await sendMessageZalouser(to, text, { profile: account.profile });
return {
channel: "zalouser",
ok: result.ok,
messageId: result.messageId ?? "",
error: result.error ? new Error(result.error) : undefined,
};
},
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const result = await sendMessageZalouser(to, text, {
profile: account.profile,
mediaUrl,
});
return {
channel: "zalouser",
ok: result.ok,
messageId: result.messageId ?? "",
error: result.error ? new Error(result.error) : undefined,
};
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) => {
const result = await runZca(["me", "info", "-j"], {
profile: account.profile,
timeout: timeoutMs,
});
if (!result.ok) {
return { ok: false, error: result.stderr };
}
try {
return { ok: true, user: JSON.parse(result.stdout) };
} catch {
return { ok: false, error: "Failed to parse user info" };
}
},
buildAccountSnapshot: async ({ account, runtime }) => {
const configured = await checkZcaAuthenticated(account.profile);
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: configured ? (runtime?.lastError ?? null) : "not configured",
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
dmPolicy: account.config.dmPolicy ?? "pairing",
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
let userLabel = "";
try {
const userInfo = await getZcaUserInfo(account.profile);
if (userInfo?.displayName) userLabel = ` (${userInfo.displayName})`;
ctx.setStatus({
accountId: account.accountId,
user: userInfo,
});
} catch {
// ignore probe errors
}
ctx.log?.info(`[${account.accountId}] starting zalouser provider${userLabel}`);
const { monitorZalouserProvider } = await import("./monitor.js");
return monitorZalouserProvider({
account,
config: ctx.cfg as CoreConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
});
},
loginWithQrStart: async (params) => {
const profile = resolveZalouserQrProfile(params.accountId);
// Start login and get QR code
const result = await runZca(["auth", "login", "--qr-base64"], {
profile,
timeout: params.timeoutMs ?? 30000,
});
if (!result.ok) {
return { message: result.stderr || "Failed to start QR login" };
}
// The stdout should contain the base64 QR data URL
const qrMatch = result.stdout.match(/data:image\/png;base64,[A-Za-z0-9+/=]+/);
if (qrMatch) {
return { qrDataUrl: qrMatch[0], message: "Scan QR code with Zalo app" };
}
return { message: result.stdout || "QR login started" };
},
loginWithQrWait: async (params) => {
const profile = resolveZalouserQrProfile(params.accountId);
// Check if already authenticated
const statusResult = await runZca(["auth", "status"], {
profile,
timeout: params.timeoutMs ?? 60000,
});
return {
connected: statusResult.ok,
message: statusResult.ok ? "Login successful" : statusResult.stderr || "Login pending",
};
},
logoutAccount: async (ctx) => {
const result = await runZca(["auth", "logout"], {
profile: ctx.account.profile,
timeout: 10000,
});
return {
cleared: result.ok,
loggedOut: result.ok,
message: result.ok ? "Logged out" : result.stderr,
};
},
},
};
export type { ResolvedZalouserAccount };
+171
View File
@@ -0,0 +1,171 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
export type CoreChannelDeps = {
chunkMarkdownText: (text: string, limit: number) => string[];
formatAgentEnvelope: (params: {
channel: string;
from: string;
timestamp?: number;
body: string;
}) => string;
dispatchReplyWithBufferedBlockDispatcher: (params: {
ctx: unknown;
cfg: unknown;
dispatcherOptions: {
deliver: (payload: unknown) => Promise<void>;
onError?: (err: unknown, info: { kind: string }) => void;
};
}) => Promise<void>;
resolveAgentRoute: (params: {
cfg: unknown;
channel: string;
accountId: string;
peer: { kind: "dm" | "group" | "channel"; id: string };
}) => { sessionKey: string; accountId: string };
buildPairingReply: (params: { channel: string; idLine: string; code: string }) => string;
readChannelAllowFromStore: (channel: string) => Promise<string[]>;
upsertChannelPairingRequest: (params: {
channel: string;
id: string;
meta?: { name?: string };
}) => Promise<{ code: string; created: boolean }>;
fetchRemoteMedia: (params: { url: string }) => Promise<{ buffer: Buffer; contentType?: string }>;
saveMediaBuffer: (
buffer: Buffer,
contentType: string | undefined,
type: "inbound" | "outbound",
maxBytes: number,
) => Promise<{ path: string; contentType: string }>;
shouldLogVerbose: () => boolean;
};
let coreRootCache: string | null = null;
let coreDepsPromise: Promise<CoreChannelDeps> | null = null;
function findPackageRoot(startDir: string, name: string): string | null {
let dir = startDir;
for (;;) {
const pkgPath = path.join(dir, "package.json");
try {
if (fs.existsSync(pkgPath)) {
const raw = fs.readFileSync(pkgPath, "utf8");
const pkg = JSON.parse(raw) as { name?: string };
if (pkg.name === name) return dir;
}
} catch {
// ignore parse errors
}
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function resolveClawdbotRoot(): string {
if (coreRootCache) return coreRootCache;
const override = process.env.CLAWDBOT_ROOT?.trim();
if (override) {
coreRootCache = override;
return override;
}
const candidates = new Set<string>();
if (process.argv[1]) {
candidates.add(path.dirname(process.argv[1]));
}
candidates.add(process.cwd());
try {
const urlPath = fileURLToPath(import.meta.url);
candidates.add(path.dirname(urlPath));
} catch {
// ignore
}
for (const start of candidates) {
const found = findPackageRoot(start, "clawdbot");
if (found) {
coreRootCache = found;
return found;
}
}
throw new Error(
"Unable to resolve Clawdbot root. Set CLAWDBOT_ROOT to the package root.",
);
}
async function importCoreModule<T>(relativePath: string): Promise<T> {
const root = resolveClawdbotRoot();
const distPath = path.join(root, "dist", relativePath);
if (!fs.existsSync(distPath)) {
throw new Error(
`Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`,
);
}
return (await import(pathToFileURL(distPath).href)) as T;
}
export async function loadCoreChannelDeps(): Promise<CoreChannelDeps> {
if (coreDepsPromise) return coreDepsPromise;
coreDepsPromise = (async () => {
const [
chunk,
envelope,
dispatcher,
routing,
pairingMessages,
pairingStore,
mediaFetch,
mediaStore,
globals,
] = await Promise.all([
importCoreModule<{ chunkMarkdownText: CoreChannelDeps["chunkMarkdownText"] }>(
"auto-reply/chunk.js",
),
importCoreModule<{ formatAgentEnvelope: CoreChannelDeps["formatAgentEnvelope"] }>(
"auto-reply/envelope.js",
),
importCoreModule<{
dispatchReplyWithBufferedBlockDispatcher: CoreChannelDeps["dispatchReplyWithBufferedBlockDispatcher"];
}>("auto-reply/reply/provider-dispatcher.js"),
importCoreModule<{ resolveAgentRoute: CoreChannelDeps["resolveAgentRoute"] }>(
"routing/resolve-route.js",
),
importCoreModule<{ buildPairingReply: CoreChannelDeps["buildPairingReply"] }>(
"pairing/pairing-messages.js",
),
importCoreModule<{
readChannelAllowFromStore: CoreChannelDeps["readChannelAllowFromStore"];
upsertChannelPairingRequest: CoreChannelDeps["upsertChannelPairingRequest"];
}>("pairing/pairing-store.js"),
importCoreModule<{ fetchRemoteMedia: CoreChannelDeps["fetchRemoteMedia"] }>(
"media/fetch.js",
),
importCoreModule<{ saveMediaBuffer: CoreChannelDeps["saveMediaBuffer"] }>(
"media/store.js",
),
importCoreModule<{ shouldLogVerbose: CoreChannelDeps["shouldLogVerbose"] }>(
"globals.js",
),
]);
return {
chunkMarkdownText: chunk.chunkMarkdownText,
formatAgentEnvelope: envelope.formatAgentEnvelope,
dispatchReplyWithBufferedBlockDispatcher:
dispatcher.dispatchReplyWithBufferedBlockDispatcher,
resolveAgentRoute: routing.resolveAgentRoute,
buildPairingReply: pairingMessages.buildPairingReply,
readChannelAllowFromStore: pairingStore.readChannelAllowFromStore,
upsertChannelPairingRequest: pairingStore.upsertChannelPairingRequest,
fetchRemoteMedia: mediaFetch.fetchRemoteMedia,
saveMediaBuffer: mediaStore.saveMediaBuffer,
shouldLogVerbose: globals.shouldLogVerbose,
};
})();
return coreDepsPromise;
}
+348
View File
@@ -0,0 +1,348 @@
import type { ChildProcess } from "node:child_process";
import type { RuntimeEnv } from "../../../src/runtime.js";
import { loadCoreChannelDeps, type CoreChannelDeps } from "./core-bridge.js";
import { sendMessageZalouser } from "./send.js";
import type { CoreConfig, ResolvedZalouserAccount, ZcaMessage } from "./types.js";
import { runZcaStreaming } from "./zca.js";
export type ZalouserMonitorOptions = {
account: ResolvedZalouserAccount;
config: CoreConfig;
runtime: RuntimeEnv;
abortSignal: AbortSignal;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
export type ZalouserMonitorResult = {
stop: () => void;
};
const ZALOUSER_TEXT_LIMIT = 2000;
function logVerbose(deps: CoreChannelDeps, runtime: RuntimeEnv, message: string): void {
if (deps.shouldLogVerbose()) {
runtime.log(`[zalouser] ${message}`);
}
}
function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
if (allowFrom.includes("*")) return true;
const normalizedSenderId = senderId.toLowerCase();
return allowFrom.some((entry) => {
const normalized = entry.toLowerCase().replace(/^(zalouser|zlu):/i, "");
return normalized === normalizedSenderId;
});
}
function startZcaListener(
runtime: RuntimeEnv,
profile: string,
onMessage: (msg: ZcaMessage) => void,
onError: (err: Error) => void,
abortSignal: AbortSignal,
): ChildProcess {
let buffer = "";
const { proc, promise } = runZcaStreaming(["listen", "-r", "-k"], {
profile,
onData: (chunk) => {
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed) as ZcaMessage;
onMessage(parsed);
} catch {
// ignore non-JSON lines
}
}
},
onError,
});
proc.stderr?.on("data", (data: Buffer) => {
const text = data.toString().trim();
if (text) runtime.error(`[zalouser] zca stderr: ${text}`);
});
void promise.then((result) => {
if (!result.ok && !abortSignal.aborted) {
onError(new Error(result.stderr || `zca listen exited with code ${result.exitCode}`));
}
});
abortSignal.addEventListener(
"abort",
() => {
proc.kill("SIGTERM");
},
{ once: true },
);
return proc;
}
async function processMessage(
message: ZcaMessage,
account: ResolvedZalouserAccount,
config: CoreConfig,
deps: CoreChannelDeps,
runtime: RuntimeEnv,
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
): Promise<void> {
const { threadId, content, timestamp, metadata } = message;
if (!content?.trim()) return;
const isGroup = metadata?.isGroup ?? false;
const senderId = metadata?.fromId ?? threadId;
const senderName = metadata?.senderName ?? "";
const chatId = threadId;
const dmPolicy = account.config.dmPolicy ?? "pairing";
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
if (!isGroup) {
if (dmPolicy === "disabled") {
logVerbose(deps, runtime, `Blocked zalouser DM from ${senderId} (dmPolicy=disabled)`);
return;
}
if (dmPolicy !== "open") {
const storeAllowFrom = await deps.readChannelAllowFromStore("zalouser").catch(() => []);
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const allowed = isSenderAllowed(senderId, effectiveAllowFrom);
if (!allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await deps.upsertChannelPairingRequest({
channel: "zalouser",
id: senderId,
meta: { name: senderName || undefined },
});
if (created) {
logVerbose(deps, runtime, `zalouser pairing request sender=${senderId}`);
try {
await sendMessageZalouser(
chatId,
deps.buildPairingReply({
channel: "zalouser",
idLine: `Your Zalo user id: ${senderId}`,
code,
}),
{ profile: account.profile },
);
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
logVerbose(
deps,
runtime,
`zalouser pairing reply failed for ${senderId}: ${String(err)}`,
);
}
}
} else {
logVerbose(
deps,
runtime,
`Blocked unauthorized zalouser sender ${senderId} (dmPolicy=${dmPolicy})`,
);
}
return;
}
}
}
const peer = isGroup ? { kind: "group" as const, id: chatId } : { kind: "group" as const, id: senderId };
const route = deps.resolveAgentRoute({
cfg: config,
channel: "zalouser",
accountId: account.accountId,
peer: {
// Use "group" kind to avoid dmScope=main collapsing all DMs into the main session.
kind: peer.kind,
id: peer.id,
},
});
const rawBody = content.trim();
const fromLabel = isGroup
? `group:${chatId} from ${senderName || senderId}`
: senderName || `user:${senderId}`;
const body = deps.formatAgentEnvelope({
channel: "Zalo Personal",
from: fromLabel,
timestamp: timestamp ? timestamp * 1000 : undefined,
body: rawBody,
});
const ctxPayload = {
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
From: isGroup ? `group:${chatId}` : `zalouser:${senderId}`,
To: `zalouser:${chatId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
SenderName: senderName || undefined,
SenderId: senderId,
Provider: "zalouser",
Surface: "zalouser",
MessageSid: message.msgId ?? `${timestamp}`,
OriginatingChannel: "zalouser",
OriginatingTo: `zalouser:${chatId}`,
};
await deps.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
deliver: async (payload) => {
await deliverZalouserReply({
payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string },
profile: account.profile,
chatId,
isGroup,
runtime,
deps,
statusSink,
});
},
onError: (err, info) => {
runtime.error(
`[${account.accountId}] Zalouser ${info.kind} reply failed: ${String(err)}`,
);
},
},
});
}
async function deliverZalouserReply(params: {
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string };
profile: string;
chatId: string;
isGroup: boolean;
runtime: RuntimeEnv;
deps: CoreChannelDeps;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
}): Promise<void> {
const { payload, profile, chatId, isGroup, runtime, deps, statusSink } = params;
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
if (mediaList.length > 0) {
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? payload.text : undefined;
first = false;
try {
logVerbose(deps, runtime, `Sending media to ${chatId}`);
await sendMessageZalouser(chatId, caption ?? "", {
profile,
mediaUrl,
isGroup,
});
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error(`Zalouser media send failed: ${String(err)}`);
}
}
return;
}
if (payload.text) {
const chunks = deps.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT);
logVerbose(deps, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
for (const chunk of chunks) {
try {
await sendMessageZalouser(chatId, chunk, { profile, isGroup });
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error(`Zalouser message send failed: ${String(err)}`);
}
}
}
}
export async function monitorZalouserProvider(
options: ZalouserMonitorOptions,
): Promise<ZalouserMonitorResult> {
const { account, config, abortSignal, statusSink, runtime } = options;
const deps = await loadCoreChannelDeps();
let stopped = false;
let proc: ChildProcess | null = null;
let restartTimer: ReturnType<typeof setTimeout> | null = null;
let resolveRunning: (() => void) | null = null;
const stop = () => {
stopped = true;
if (restartTimer) {
clearTimeout(restartTimer);
restartTimer = null;
}
if (proc) {
proc.kill("SIGTERM");
proc = null;
}
resolveRunning?.();
};
const startListener = () => {
if (stopped || abortSignal.aborted) {
resolveRunning?.();
return;
}
logVerbose(
deps,
runtime,
`[${account.accountId}] starting zca listener (profile=${account.profile})`,
);
proc = startZcaListener(
runtime,
account.profile,
(msg) => {
logVerbose(deps, runtime, `[${account.accountId}] inbound message`);
statusSink?.({ lastInboundAt: Date.now() });
processMessage(msg, account, config, deps, runtime, statusSink).catch((err) => {
runtime.error(`[${account.accountId}] Failed to process message: ${String(err)}`);
});
},
(err) => {
runtime.error(`[${account.accountId}] zca listener error: ${String(err)}`);
if (!stopped && !abortSignal.aborted) {
logVerbose(deps, runtime, `[${account.accountId}] restarting listener in 5s...`);
restartTimer = setTimeout(startListener, 5000);
} else {
resolveRunning?.();
}
},
abortSignal,
);
};
// Create a promise that stays pending until abort or stop
const runningPromise = new Promise<void>((resolve) => {
resolveRunning = resolve;
abortSignal.addEventListener("abort", () => resolve(), { once: true });
});
startListener();
// Wait for the running promise to resolve (on abort/stop)
await runningPromise;
return { stop };
}
+312
View File
@@ -0,0 +1,312 @@
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
import {
listZalouserAccountIds,
resolveDefaultZalouserAccountId,
resolveZalouserAccountSync,
normalizeAccountId,
checkZcaAuthenticated,
} from "./accounts.js";
import { runZcaInteractive, checkZcaInstalled } from "./zca.js";
import { DEFAULT_ACCOUNT_ID, type CoreConfig } from "./types.js";
const channel = "zalouser" as const;
function setZalouserDmPolicy(
cfg: CoreConfig,
dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
): CoreConfig {
const allowFrom =
dmPolicy === "open"
? [...(cfg.channels?.zalouser?.allowFrom ?? []), "*"].filter(
(v, i, a) => a.indexOf(v) === i,
)
: undefined;
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
} as CoreConfig;
}
async function noteZalouserHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"Zalo Personal Account login via QR code.",
"",
"Prerequisites:",
"1) Install zca-cli",
"2) You'll scan a QR code with your Zalo app",
"",
"Docs: https://docs.clawd.bot/channels/zalouser",
].join("\n"),
"Zalo Personal Setup",
);
}
async function promptZalouserAllowFrom(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<CoreConfig> {
const { cfg, prompter, accountId } = params;
const resolved = resolveZalouserAccountSync({ cfg, accountId });
const existingAllowFrom = resolved.config.allowFrom ?? [];
const entry = await prompter.text({
message: "Zalouser allowFrom (user id)",
placeholder: "123456789",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
if (!/^\d+$/.test(raw)) return "Use a numeric Zalo user id";
return undefined;
},
});
const normalized = String(entry).trim();
const merged = [
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
normalized,
];
const unique = [...new Set(merged)];
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
} as CoreConfig;
}
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
accounts: {
...(cfg.channels?.zalouser?.accounts ?? {}),
[accountId]: {
...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
},
},
} as CoreConfig;
}
async function promptAccountId(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
label: string;
currentId: string;
listAccountIds: (cfg: CoreConfig) => string[];
defaultAccountId: string;
}): Promise<string> {
const { cfg, prompter, label, currentId, listAccountIds, defaultAccountId } = params;
const existingIds = listAccountIds(cfg);
const options = [
...existingIds.map((id) => ({
value: id,
label: id === defaultAccountId ? `${id} (default)` : id,
})),
{ value: "__new__", label: "Create new account" },
];
const selected = await prompter.select({
message: `${label} account`,
options,
initialValue: currentId,
});
if (selected === "__new__") {
const newId = await prompter.text({
message: "New account ID",
placeholder: "work",
validate: (value) => {
const raw = String(value ?? "").trim().toLowerCase();
if (!raw) return "Required";
if (!/^[a-z0-9_-]+$/.test(raw)) return "Use lowercase alphanumeric, dash, or underscore";
if (existingIds.includes(raw)) return "Account already exists";
return undefined;
},
});
return String(newId).trim().toLowerCase();
}
return selected as string;
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Zalo Personal",
channel,
policyKey: "channels.zalouser.dmPolicy",
allowFromKey: "channels.zalouser.allowFrom",
getCurrent: (cfg) => ((cfg as CoreConfig).channels?.zalouser?.dmPolicy ?? "pairing") as "pairing",
setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as CoreConfig, policy),
};
export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
dmPolicy,
getStatus: async ({ cfg }) => {
const ids = listZalouserAccountIds(cfg as CoreConfig);
let configured = false;
for (const accountId of ids) {
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const isAuth = await checkZcaAuthenticated(account.profile);
if (isAuth) {
configured = true;
break;
}
}
return {
channel,
configured,
statusLines: [`Zalo Personal: ${configured ? "logged in" : "needs QR login"}`],
selectionHint: configured ? "recommended · logged in" : "recommended · QR login",
quickstartScore: configured ? 1 : 15,
};
},
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds, forceAllowFrom }) => {
// Check zca is installed
const zcaInstalled = await checkZcaInstalled();
if (!zcaInstalled) {
await prompter.note(
[
"The `zca` binary was not found in PATH.",
"",
"Install zca-cli, then re-run onboarding:",
"Docs: https://docs.clawd.bot/channels/zalouser",
].join("\n"),
"Missing Dependency",
);
return { cfg, accountId: DEFAULT_ACCOUNT_ID };
}
const zalouserOverride = accountOverrides.zalouser?.trim();
const defaultAccountId = resolveDefaultZalouserAccountId(cfg as CoreConfig);
let accountId = zalouserOverride
? normalizeAccountId(zalouserOverride)
: defaultAccountId;
if (shouldPromptAccountIds && !zalouserOverride) {
accountId = await promptAccountId({
cfg: cfg as CoreConfig,
prompter,
label: "Zalo Personal",
currentId: accountId,
listAccountIds: listZalouserAccountIds,
defaultAccountId,
});
}
let next = cfg as CoreConfig;
const account = resolveZalouserAccountSync({ cfg: next, accountId });
const alreadyAuthenticated = await checkZcaAuthenticated(account.profile);
if (!alreadyAuthenticated) {
await noteZalouserHelp(prompter);
const wantsLogin = await prompter.confirm({
message: "Login via QR code now?",
initialValue: true,
});
if (wantsLogin) {
await prompter.note(
"A QR code will appear in your terminal.\nScan it with your Zalo app to login.",
"QR Login",
);
// Run interactive login
const result = await runZcaInteractive(["auth", "login"], {
profile: account.profile,
});
if (!result.ok) {
await prompter.note(
`Login failed: ${result.stderr || "Unknown error"}`,
"Error",
);
} else {
const isNowAuth = await checkZcaAuthenticated(account.profile);
if (isNowAuth) {
await prompter.note("Login successful!", "Success");
}
}
}
} else {
const keepSession = await prompter.confirm({
message: "Zalo Personal already logged in. Keep session?",
initialValue: true,
});
if (!keepSession) {
await runZcaInteractive(["auth", "logout"], { profile: account.profile });
await runZcaInteractive(["auth", "login"], { profile: account.profile });
}
}
// Enable the channel
if (accountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
zalouser: {
...next.channels?.zalouser,
enabled: true,
profile: account.profile !== "default" ? account.profile : undefined,
},
},
} as CoreConfig;
} else {
next = {
...next,
channels: {
...next.channels,
zalouser: {
...next.channels?.zalouser,
enabled: true,
accounts: {
...(next.channels?.zalouser?.accounts ?? {}),
[accountId]: {
...(next.channels?.zalouser?.accounts?.[accountId] ?? {}),
enabled: true,
profile: account.profile,
},
},
},
},
} as CoreConfig;
}
if (forceAllowFrom) {
next = await promptZalouserAllowFrom({
cfg: next,
prompter,
accountId,
});
}
return { cfg: next, accountId };
},
};
+150
View File
@@ -0,0 +1,150 @@
import { runZca } from "./zca.js";
export type ZalouserSendOptions = {
profile?: string;
mediaUrl?: string;
caption?: string;
isGroup?: boolean;
};
export type ZalouserSendResult = {
ok: boolean;
messageId?: string;
error?: string;
};
export async function sendMessageZalouser(
threadId: string,
text: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
const profile = options.profile || process.env.ZCA_PROFILE || "default";
if (!threadId?.trim()) {
return { ok: false, error: "No threadId provided" };
}
// Handle media sending
if (options.mediaUrl) {
return sendMediaZalouser(threadId, options.mediaUrl, {
...options,
caption: text || options.caption,
});
}
// Send text message
const args = ["msg", "send", threadId.trim(), text.slice(0, 2000)];
if (options.isGroup) args.push("-g");
try {
const result = await runZca(args, { profile });
if (result.ok) {
return { ok: true, messageId: extractMessageId(result.stdout) };
}
return { ok: false, error: result.stderr || "Failed to send message" };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
async function sendMediaZalouser(
threadId: string,
mediaUrl: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
const profile = options.profile || process.env.ZCA_PROFILE || "default";
if (!threadId?.trim()) {
return { ok: false, error: "No threadId provided" };
}
if (!mediaUrl?.trim()) {
return { ok: false, error: "No media URL provided" };
}
// Determine media type from URL
const lowerUrl = mediaUrl.toLowerCase();
let command: string;
if (lowerUrl.match(/\.(mp4|mov|avi|webm)$/)) {
command = "video";
} else if (lowerUrl.match(/\.(mp3|wav|ogg|m4a)$/)) {
command = "voice";
} else {
command = "image";
}
const args = ["msg", command, threadId.trim(), "-u", mediaUrl.trim()];
if (options.caption) {
args.push("-m", options.caption.slice(0, 2000));
}
if (options.isGroup) args.push("-g");
try {
const result = await runZca(args, { profile });
if (result.ok) {
return { ok: true, messageId: extractMessageId(result.stdout) };
}
return { ok: false, error: result.stderr || `Failed to send ${command}` };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function sendImageZalouser(
threadId: string,
imageUrl: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
const profile = options.profile || process.env.ZCA_PROFILE || "default";
const args = ["msg", "image", threadId.trim(), "-u", imageUrl.trim()];
if (options.caption) {
args.push("-m", options.caption.slice(0, 2000));
}
if (options.isGroup) args.push("-g");
try {
const result = await runZca(args, { profile });
if (result.ok) {
return { ok: true, messageId: extractMessageId(result.stdout) };
}
return { ok: false, error: result.stderr || "Failed to send image" };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function sendLinkZalouser(
threadId: string,
url: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
const profile = options.profile || process.env.ZCA_PROFILE || "default";
const args = ["msg", "link", threadId.trim(), url.trim()];
if (options.isGroup) args.push("-g");
try {
const result = await runZca(args, { profile });
if (result.ok) {
return { ok: true, messageId: extractMessageId(result.stdout) };
}
return { ok: false, error: result.stderr || "Failed to send link" };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
function extractMessageId(stdout: string): string | undefined {
// Try to extract message ID from output
const match = stdout.match(/message[_\s]?id[:\s]+(\S+)/i);
if (match) return match[1];
// Return first word if it looks like an ID
const firstWord = stdout.trim().split(/\s+/)[0];
if (firstWord && /^[a-zA-Z0-9_-]+$/.test(firstWord)) {
return firstWord;
}
return undefined;
}
+156
View File
@@ -0,0 +1,156 @@
import { Type } from "@sinclair/typebox";
import { runZca, parseJsonOutput } from "./zca.js";
const ACTIONS = ["send", "image", "link", "friends", "groups", "me", "status"] as const;
function stringEnum<T extends readonly string[]>(
values: T,
options: { description?: string } = {},
) {
return Type.Unsafe<T[number]>({
type: "string",
enum: [...values],
...options,
});
}
// Tool schema - avoiding Type.Union per tool schema guardrails
export const ZalouserToolSchema = Type.Object({
action: stringEnum(ACTIONS, { description: `Action to perform: ${ACTIONS.join(", ")}` }),
threadId: Type.Optional(
Type.String({ description: "Thread ID for messaging" }),
),
message: Type.Optional(Type.String({ description: "Message text" })),
isGroup: Type.Optional(Type.Boolean({ description: "Is group chat" })),
profile: Type.Optional(Type.String({ description: "Profile name" })),
query: Type.Optional(Type.String({ description: "Search query" })),
url: Type.Optional(Type.String({ description: "URL for media/link" })),
}, { additionalProperties: false });
type ToolParams = {
action: (typeof ACTIONS)[number];
threadId?: string;
message?: string;
isGroup?: boolean;
profile?: string;
query?: string;
url?: string;
};
type ToolResult = {
content: Array<{ type: string; text: string }>;
details: unknown;
};
function json(payload: unknown): ToolResult {
return {
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
details: payload,
};
}
export async function executeZalouserTool(
_toolCallId: string,
params: ToolParams,
): Promise<ToolResult> {
try {
switch (params.action) {
case "send": {
if (!params.threadId || !params.message) {
throw new Error("threadId and message required for send action");
}
const args = ["msg", "send", params.threadId, params.message];
if (params.isGroup) args.push("-g");
const result = await runZca(args, { profile: params.profile });
if (!result.ok) {
throw new Error(result.stderr || "Failed to send message");
}
return json({ success: true, output: result.stdout });
}
case "image": {
if (!params.threadId) {
throw new Error("threadId required for image action");
}
if (!params.url) {
throw new Error("url required for image action");
}
const args = ["msg", "image", params.threadId, "-u", params.url];
if (params.message) args.push("-m", params.message);
if (params.isGroup) args.push("-g");
const result = await runZca(args, { profile: params.profile });
if (!result.ok) {
throw new Error(result.stderr || "Failed to send image");
}
return json({ success: true, output: result.stdout });
}
case "link": {
if (!params.threadId || !params.url) {
throw new Error("threadId and url required for link action");
}
const args = ["msg", "link", params.threadId, params.url];
if (params.isGroup) args.push("-g");
const result = await runZca(args, { profile: params.profile });
if (!result.ok) {
throw new Error(result.stderr || "Failed to send link");
}
return json({ success: true, output: result.stdout });
}
case "friends": {
const args = params.query
? ["friend", "find", params.query]
: ["friend", "list", "-j"];
const result = await runZca(args, { profile: params.profile });
if (!result.ok) {
throw new Error(result.stderr || "Failed to get friends");
}
const parsed = parseJsonOutput(result.stdout);
return json(parsed ?? { raw: result.stdout });
}
case "groups": {
const result = await runZca(["group", "list", "-j"], {
profile: params.profile,
});
if (!result.ok) {
throw new Error(result.stderr || "Failed to get groups");
}
const parsed = parseJsonOutput(result.stdout);
return json(parsed ?? { raw: result.stdout });
}
case "me": {
const result = await runZca(["me", "info", "-j"], {
profile: params.profile,
});
if (!result.ok) {
throw new Error(result.stderr || "Failed to get profile");
}
const parsed = parseJsonOutput(result.stdout);
return json(parsed ?? { raw: result.stdout });
}
case "status": {
const result = await runZca(["auth", "status"], {
profile: params.profile,
});
return json({
authenticated: result.ok,
output: result.stdout || result.stderr,
});
}
default:
throw new Error(
`Unknown action: ${params.action}. Valid actions: send, image, link, friends, groups, me, status`,
);
}
} catch (err) {
return json({
error: err instanceof Error ? err.message : String(err),
});
}
}
+109
View File
@@ -0,0 +1,109 @@
// zca-cli wrapper types
export type ZcaRunOptions = {
profile?: string;
cwd?: string;
timeout?: number;
};
export type ZcaResult = {
ok: boolean;
stdout: string;
stderr: string;
exitCode: number;
};
export type ZcaProfile = {
name: string;
label?: string;
isDefault?: boolean;
};
export type ZcaFriend = {
userId: string;
displayName: string;
avatar?: string;
};
export type ZcaGroup = {
groupId: string;
name: string;
memberCount?: number;
};
export type ZcaMessage = {
threadId: string;
msgId?: string;
cliMsgId?: string;
type: number;
content: string;
timestamp: number;
metadata?: {
isGroup: boolean;
threadName?: string;
senderName?: string;
fromId?: string;
};
};
export type ZcaUserInfo = {
userId: string;
displayName: string;
avatar?: string;
};
export type CommonOptions = {
profile?: string;
json?: boolean;
};
export type SendOptions = CommonOptions & {
group?: boolean;
};
export type ListenOptions = CommonOptions & {
raw?: boolean;
keepAlive?: boolean;
webhook?: string;
echo?: boolean;
prefix?: string;
};
// Channel plugin config types
export const DEFAULT_ACCOUNT_ID = "default";
export type ZalouserAccountConfig = {
enabled?: boolean;
name?: string;
profile?: string;
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
messagePrefix?: string;
};
export type ZalouserConfig = {
enabled?: boolean;
name?: string;
profile?: string;
defaultAccount?: string;
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
messagePrefix?: string;
accounts?: Record<string, ZalouserAccountConfig>;
};
export type CoreConfig = {
channels?: {
zalouser?: ZalouserConfig;
[key: string]: unknown;
};
[key: string]: unknown;
};
export type ResolvedZalouserAccount = {
accountId: string;
name?: string;
enabled: boolean;
profile: string;
authenticated: boolean;
config: ZalouserAccountConfig;
};
+183
View File
@@ -0,0 +1,183 @@
import { spawn, type SpawnOptions } from "node:child_process";
import type { ZcaResult, ZcaRunOptions } from "./types.js";
const ZCA_BINARY = "zca";
const DEFAULT_TIMEOUT = 30000;
function buildArgs(args: string[], options?: ZcaRunOptions): string[] {
const result: string[] = [];
// Profile flag comes first (before subcommand)
const profile = options?.profile || process.env.ZCA_PROFILE;
if (profile) {
result.push("--profile", profile);
}
result.push(...args);
return result;
}
export async function runZca(
args: string[],
options?: ZcaRunOptions,
): Promise<ZcaResult> {
const fullArgs = buildArgs(args, options);
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
return new Promise((resolve) => {
const spawnOpts: SpawnOptions = {
cwd: options?.cwd,
env: { ...process.env },
stdio: ["pipe", "pipe", "pipe"],
};
const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts);
let stdout = "";
let stderr = "";
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
proc.kill("SIGTERM");
}, timeout);
proc.stdout?.on("data", (data: Buffer) => {
stdout += data.toString();
});
proc.stderr?.on("data", (data: Buffer) => {
stderr += data.toString();
});
proc.on("close", (code) => {
clearTimeout(timer);
if (timedOut) {
resolve({
ok: false,
stdout,
stderr: stderr || "Command timed out",
exitCode: code ?? 124,
});
return;
}
resolve({
ok: code === 0,
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: code ?? 1,
});
});
proc.on("error", (err) => {
clearTimeout(timer);
resolve({
ok: false,
stdout: "",
stderr: err.message,
exitCode: 1,
});
});
});
}
export function runZcaInteractive(
args: string[],
options?: ZcaRunOptions,
): Promise<ZcaResult> {
const fullArgs = buildArgs(args, options);
return new Promise((resolve) => {
const spawnOpts: SpawnOptions = {
cwd: options?.cwd,
env: { ...process.env },
stdio: "inherit",
};
const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts);
proc.on("close", (code) => {
resolve({
ok: code === 0,
stdout: "",
stderr: "",
exitCode: code ?? 1,
});
});
proc.on("error", (err) => {
resolve({
ok: false,
stdout: "",
stderr: err.message,
exitCode: 1,
});
});
});
}
export function parseJsonOutput<T>(stdout: string): T | null {
try {
return JSON.parse(stdout) as T;
} catch {
return null;
}
}
export async function checkZcaInstalled(): Promise<boolean> {
const result = await runZca(["--version"], { timeout: 5000 });
return result.ok;
}
export type ZcaStreamingOptions = ZcaRunOptions & {
onData?: (data: string) => void;
onError?: (err: Error) => void;
};
export function runZcaStreaming(
args: string[],
options?: ZcaStreamingOptions,
): { proc: ReturnType<typeof spawn>; promise: Promise<ZcaResult> } {
const fullArgs = buildArgs(args, options);
const spawnOpts: SpawnOptions = {
cwd: options?.cwd,
env: { ...process.env },
stdio: ["pipe", "pipe", "pipe"],
};
const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts);
let stdout = "";
let stderr = "";
proc.stdout?.on("data", (data: Buffer) => {
const text = data.toString();
stdout += text;
options?.onData?.(text);
});
proc.stderr?.on("data", (data: Buffer) => {
stderr += data.toString();
});
const promise = new Promise<ZcaResult>((resolve) => {
proc.on("close", (code) => {
resolve({
ok: code === 0,
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: code ?? 1,
});
});
proc.on("error", (err) => {
options?.onError?.(err);
resolve({
ok: false,
stdout: "",
stderr: err.message,
exitCode: 1,
});
});
});
return { proc, promise };
}
+5 -6
View File
@@ -47,7 +47,6 @@
"README-header.png",
"CHANGELOG.md",
"LICENSE",
"patches/**",
"scripts/postinstall.js",
"dist/terminal/**",
"dist/routing/**",
@@ -143,6 +142,7 @@
"@mariozechner/pi-ai": "0.46.0",
"@mariozechner/pi-coding-agent": "^0.46.0",
"@mariozechner/pi-tui": "^0.46.0",
"@mozilla/readability": "^0.6.0",
"@sinclair/typebox": "0.34.47",
"@slack/bolt": "^4.6.0",
"@slack/web-api": "^7.13.0",
@@ -163,6 +163,7 @@
"hono": "4.11.4",
"jiti": "^2.6.1",
"json5": "^2.2.3",
"linkedom": "^0.18.12",
"long": "5.3.2",
"markdown-it": "^14.1.0",
"osc-progress": "^0.2.0",
@@ -170,7 +171,7 @@
"proper-lockfile": "^4.1.2",
"qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5",
"tar": "^7.5.2",
"tar": "^7.5.3",
"tslog": "^4.10.2",
"undici": "^7.18.2",
"ws": "^8.19.0",
@@ -212,10 +213,8 @@
"minimumReleaseAge": 2880,
"overrides": {
"@sinclair/typebox": "0.34.47",
"hono": "4.11.4"
},
"patchedDependencies": {
"@mariozechner/pi-agent-core@0.46.0": "patches/@mariozechner__pi-agent-core.patch"
"hono": "4.11.4",
"tar": "7.5.3"
}
},
"vitest": {
@@ -1,54 +0,0 @@
diff --git a/dist/agent.d.ts b/dist/agent.d.ts
index fcfb19924ef6ce233aa55795e3687ce23938c5a6..a63daea868c5b3b7f7bb9272576c65c6ad95da8a 100644
--- a/dist/agent.d.ts
+++ b/dist/agent.d.ts
@@ -38,6 +38,10 @@ export interface AgentOptions {
* Useful for expiring tokens (e.g., GitHub Copilot OAuth).
*/
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
+ /**
+ * Extra params to pass to the provider API (e.g., Z.AI GLM thinking mode params).
+ */
+ extraParams?: Record<string, unknown>;
/**
* Custom token budgets for thinking levels (token-based providers only).
*/
@@ -56,6 +60,8 @@ export declare class Agent {
streamFn: StreamFn;
private _sessionId?;
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
+ /** Extra params to pass to the provider API. */
+ extraParams?: Record<string, unknown>;
private runningPrompt?;
private resolveRunningPrompt?;
private _thinkingBudgets?;
diff --git a/dist/agent.js b/dist/agent.js
index 34ceb4ddcbc53d83edd82d774a76d9bf469b42f3..ecd8b7641c71523296890e11ac0cf0855a0dadd5 100644
--- a/dist/agent.js
+++ b/dist/agent.js
@@ -33,6 +33,7 @@ export class Agent {
streamFn;
_sessionId;
getApiKey;
+ extraParams;
runningPrompt;
resolveRunningPrompt;
_thinkingBudgets;
@@ -45,6 +46,8 @@ export class Agent {
this.streamFn = opts.streamFn || streamSimple;
this._sessionId = opts.sessionId;
this.getApiKey = opts.getApiKey;
+ // PATCH: Support extraParams for provider-specific features (e.g., GLM-4.7 thinking mode)
+ this.extraParams = opts.extraParams;
this._thinkingBudgets = opts.thinkingBudgets;
}
/**
@@ -225,6 +228,8 @@ export class Agent {
convertToLlm: this.convertToLlm,
transformContext: this.transformContext,
getApiKey: this.getApiKey,
+ // PATCH: Pass extraParams through to stream function
+ extraParams: this.extraParams,
getSteeringMessages: async () => {
if (this.steeringMode === "one-at-a-time") {
if (this.steeringQueue.length > 0) {
@@ -1,48 +0,0 @@
diff --git a/dist/core/sdk.js b/dist/core/sdk.js
index 0000000..1111111 100644
--- a/dist/core/sdk.js
+++ b/dist/core/sdk.js
@@ -441,6 +441,8 @@ export async function createAgentSession(options = {}) {
}
return key;
},
+ // PATCH: Pass extraParams through for provider-specific features (e.g., GLM-4.7 thinking mode)
+ extraParams: options.extraParams,
});
time("createAgent");
// Restore messages if session has existing data
diff --git a/dist/core/sdk.d.ts b/dist/core/sdk.d.ts
index 0000000..1111111 100644
--- a/dist/core/sdk.d.ts
+++ b/dist/core/sdk.d.ts
@@ -79,6 +79,10 @@ export interface CreateAgentSessionOptions {
sessionManager?: SessionManager;
/** Settings manager. Default: SettingsManager.create(cwd, agentDir) */
settingsManager?: SettingsManager;
+ /**
+ * Extra params to pass to the provider API (e.g., Z.AI GLM thinking mode params).
+ */
+ extraParams?: Record<string, unknown>;
}
/** Result from createAgentSession */
export interface CreateAgentSessionResult {
diff --git a/dist/core/session-manager.js b/dist/core/session-manager.js
index b2aba5280d002253b0938b75aedbb9e6e6c4dcf8..67464efff535dbd7a8e6ed825aab2b305ca2aee2 100644
--- a/dist/core/session-manager.js
+++ b/dist/core/session-manager.js
@@ -161,6 +161,15 @@ export function buildSessionContext(entries, leafId, byId) {
const messages = [];
const appendMessage = (entry) => {
if (entry.type === "message") {
+ // PATCH: Filter out empty error assistant messages to prevent session corruption
+ // When 429/500 errors occur during tool execution, empty error messages get persisted
+ // to the session file. These break the tool_use -> tool_result chain for Claude/Gemini.
+ const msg = entry.message;
+ if (msg.role === "assistant" &&
+ msg.stopReason === "error" &&
+ (!msg.content || msg.content.length === 0)) {
+ return; // Skip empty error messages
+ }
messages.push(entry.message);
}
else if (entry.type === "custom_message") {
-13
View File
@@ -1,13 +0,0 @@
diff --git a/lib/utilsBundle.js b/lib/utilsBundle.js
index 7dd8831f29c19f2e20468508b77b0a3f9d204ae6..c50a1ac2b3439a5b2fbf8afa61c369360710071f 100644
--- a/lib/utilsBundle.js
+++ b/lib/utilsBundle.js
@@ -59,7 +59,7 @@ const program = require("./utilsBundleImpl").program;
const ProgramOption = require("./utilsBundleImpl").ProgramOption;
const progress = require("./utilsBundleImpl").progress;
const SocksProxyAgent = require("./utilsBundleImpl").SocksProxyAgent;
-const ws = require("./utilsBundleImpl").ws;
+const ws = "Bun" in globalThis ? require("ws") : require("./utilsBundleImpl").ws;
const wsServer = require("./utilsBundleImpl").wsServer;
const wsReceiver = require("./utilsBundleImpl").wsReceiver;
const wsSender = require("./utilsBundleImpl").wsSender;
-12
View File
@@ -1,12 +0,0 @@
diff --git a/lib/main.js b/lib/main.js
index 488cc1aea9802b3d6ae13aee27556403bec55d1c..3de1f934868d81e8204f00e6a4bf2696a05f7340 100644
--- a/lib/main.js
+++ b/lib/main.js
@@ -1,5 +1,5 @@
-var QRCode = require('./../vendor/QRCode'),
- QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel'),
+var QRCode = require('./../vendor/QRCode/index.js'),
+ QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel.js'),
black = "\033[40m \033[0m",
white = "\033[47m \033[0m",
toCell = function (isBlack) {
+136 -78
View File
@@ -7,11 +7,7 @@ settings:
overrides:
'@sinclair/typebox': 0.34.47
hono: 4.11.4
patchedDependencies:
'@mariozechner/pi-agent-core@0.46.0':
hash: 01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4
path: patches/@mariozechner__pi-agent-core.patch
tar: 7.5.3
importers:
@@ -34,7 +30,7 @@ importers:
version: 1.3.4
'@mariozechner/pi-agent-core':
specifier: 0.46.0
version: 0.46.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
version: 0.46.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai':
specifier: 0.46.0
version: 0.46.0(ws@8.19.0)(zod@4.3.5)
@@ -44,6 +40,9 @@ importers:
'@mariozechner/pi-tui':
specifier: ^0.46.0
version: 0.46.0
'@mozilla/readability':
specifier: ^0.6.0
version: 0.6.0
'@sinclair/typebox':
specifier: 0.34.47
version: 0.34.47
@@ -104,6 +103,9 @@ importers:
json5:
specifier: ^2.2.3
version: 2.2.3
linkedom:
specifier: ^0.18.12
version: 0.18.12
long:
specifier: 5.3.2
version: 5.3.2
@@ -126,8 +128,8 @@ importers:
specifier: ^0.34.5
version: 0.34.5
tar:
specifier: ^7.5.2
version: 7.5.2
specifier: 7.5.3
version: 7.5.3
tslog:
specifier: ^4.10.2
version: 4.10.2
@@ -272,6 +274,12 @@ importers:
specifier: 7.18.2
version: 7.18.2
extensions/zalouser:
dependencies:
'@sinclair/typebox':
specifier: 0.34.47
version: 0.34.47
ui:
dependencies:
dompurify:
@@ -1034,6 +1042,10 @@ packages:
'@mistralai/mistralai@1.10.0':
resolution: {integrity: sha512-tdIgWs4Le8vpvPiUEWne6tK0qbVc+jMenujnvTqOjogrJUsCSQhus0tHTU1avDDh5//Rq2dFgP9mWRAdIEoBqg==}
'@mozilla/readability@0.6.0':
resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==}
engines: {node: '>=14.0.0'}
'@napi-rs/wasm-runtime@1.1.1':
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
@@ -2219,6 +2231,9 @@ packages:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'}
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
bottleneck@2.19.5:
resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==}
@@ -2292,10 +2307,6 @@ packages:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
chownr@2.0.0:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
engines: {node: '>=10'}
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
@@ -2407,6 +2418,16 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
css-what@6.2.2:
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
engines: {node: '>= 6'}
cssom@0.5.0:
resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==}
curve25519-js@0.0.4:
resolution: {integrity: sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==}
@@ -2458,9 +2479,22 @@ packages:
docx-preview@0.3.7:
resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==}
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
dompurify@3.3.1:
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
dotenv@17.2.3:
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
engines: {node: '>=12'}
@@ -2495,6 +2529,10 @@ packages:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
env-var@7.5.0:
resolution: {integrity: sha512-mKZOzLRN0ETzau2W2QXefbFjo5EF4yWq28OyKb9ICdeNhHJlOE/pHHnz4hdYJ9cNZXcJHo5xN4OT4pzuSHSNvA==}
engines: {node: '>=10'}
@@ -2648,10 +2686,6 @@ packages:
resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==}
engines: {node: '>=14.14'}
fs-minipass@2.1.0:
resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
engines: {node: '>= 8'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2773,9 +2807,15 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
html-escaper@3.0.3:
resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
html-parse-string@0.0.9:
resolution: {integrity: sha512-wyGnsOolHbNrcb8N6bdJF4EHyzd3zVGCb9/mBxeNjAYBDOZqD7YkqLBz7kXtdgHwNnV8lN/BpSDpsI1zm8Sd8g==}
htmlparser2@10.0.0:
resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==}
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
@@ -3043,6 +3083,15 @@ packages:
limiter@1.1.5:
resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==}
linkedom@0.18.12:
resolution: {integrity: sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==}
engines: {node: '>=16'}
peerDependencies:
canvas: '>= 2'
peerDependenciesMeta:
canvas:
optional: true
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
@@ -3223,22 +3272,10 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@3.3.6:
resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
engines: {node: '>=8'}
minipass@5.0.0:
resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==}
engines: {node: '>=8'}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
minizlib@2.1.2:
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
engines: {node: '>= 8'}
minizlib@3.1.0:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
@@ -3246,11 +3283,6 @@ packages:
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'}
hasBin: true
mpg123-decoder@1.0.3:
resolution: {integrity: sha512-+fjxnWigodWJm3+4pndi+KUg9TBojgn31DPk85zEsim7C6s0X5Ztc/hQYdytXkwuGXH+aB0/aEkG40Emukv6oQ==}
@@ -3330,6 +3362,9 @@ packages:
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
deprecated: This package is no longer supported.
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -3912,12 +3947,8 @@ packages:
tailwindcss@4.1.17:
resolution: {integrity: sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==}
tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
tar@7.5.2:
resolution: {integrity: sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==}
tar@7.5.3:
resolution: {integrity: sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==}
engines: {node: '>=18'}
thenify-all@1.6.0:
@@ -4009,6 +4040,9 @@ packages:
uhtml@5.0.9:
resolution: {integrity: sha512-qPyu3vGilaLe6zrjOCD/xezWEHLwdevxmbY3hzyhT25KBDF4F7YYW3YZcL3kylD/6dMoVISHjn8ggV3+9FY+5g==}
uhyphen@0.2.0:
resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==}
uint8array-extras@1.5.0:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'}
@@ -5169,7 +5203,7 @@ snapshots:
transitivePeerDependencies:
- tailwindcss
'@mariozechner/pi-agent-core@0.46.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)':
'@mariozechner/pi-agent-core@0.46.0(ws@8.19.0)(zod@4.3.5)':
dependencies:
'@mariozechner/pi-ai': 0.46.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.46.0
@@ -5208,7 +5242,7 @@ snapshots:
dependencies:
'@mariozechner/clipboard': 0.3.0
'@mariozechner/jiti': 2.6.2
'@mariozechner/pi-agent-core': 0.46.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-agent-core': 0.46.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai': 0.46.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.46.0
'@silvia-odwyer/photon-node': 0.3.4
@@ -5280,6 +5314,8 @@ snapshots:
zod: 3.25.76
zod-to-json-schema: 3.25.1(zod@3.25.76)
'@mozilla/readability@0.6.0': {}
'@napi-rs/wasm-runtime@1.1.1':
dependencies:
'@emnapi/core': 1.8.1
@@ -6571,6 +6607,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
boolbase@1.0.0: {}
bottleneck@2.19.5: {}
bowser@2.13.1: {}
@@ -6655,9 +6693,6 @@ snapshots:
dependencies:
readdirp: 5.0.0
chownr@2.0.0:
optional: true
chownr@3.0.0: {}
chromium-bidi@12.0.1(devtools-protocol@0.0.1561482):
@@ -6715,7 +6750,7 @@ snapshots:
npmlog: 6.0.2
rc: 1.2.8
semver: 7.7.3
tar: 6.2.1
tar: 7.5.3
url-join: 4.0.1
which: 2.0.2
yargs: 17.7.2
@@ -6775,6 +6810,18 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
css-select@5.2.2:
dependencies:
boolbase: 1.0.0
css-what: 6.2.2
domhandler: 5.0.3
domutils: 3.2.2
nth-check: 2.1.1
css-what@6.2.2: {}
cssom@0.5.0: {}
curve25519-js@0.0.4: {}
data-uri-to-buffer@4.0.1: {}
@@ -6807,10 +6854,28 @@ snapshots:
dependencies:
jszip: 3.10.1
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.5.0
domelementtype@2.3.0: {}
domhandler@5.0.3:
dependencies:
domelementtype: 2.3.0
dompurify@3.3.1:
optionalDependencies:
'@types/trusted-types': 2.0.7
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
dotenv@17.2.3: {}
dunder-proto@1.0.1:
@@ -6838,6 +6903,8 @@ snapshots:
entities@4.5.0: {}
entities@6.0.1: {}
env-var@7.5.0:
optional: true
@@ -7038,11 +7105,6 @@ snapshots:
universalify: 2.0.1
optional: true
fs-minipass@2.1.0:
dependencies:
minipass: 3.3.6
optional: true
fsevents@2.3.2:
optional: true
@@ -7192,8 +7254,17 @@ snapshots:
html-escaper@2.0.2: {}
html-escaper@3.0.3: {}
html-parse-string@0.0.9: {}
htmlparser2@10.0.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 6.0.1
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -7471,6 +7542,14 @@ snapshots:
limiter@1.1.5: {}
linkedom@0.18.12:
dependencies:
css-select: 5.2.2
cssom: 0.5.0
html-escaper: 3.0.3
htmlparser2: 10.0.0
uhyphen: 0.2.0
linkify-it@5.0.0:
dependencies:
uc.micro: 2.1.0
@@ -7651,31 +7730,14 @@ snapshots:
minimist@1.2.8:
optional: true
minipass@3.3.6:
dependencies:
yallist: 4.0.0
optional: true
minipass@5.0.0:
optional: true
minipass@7.1.2: {}
minizlib@2.1.2:
dependencies:
minipass: 3.3.6
yallist: 4.0.0
optional: true
minizlib@3.1.0:
dependencies:
minipass: 7.1.2
mitt@3.0.1: {}
mkdirp@1.0.4:
optional: true
mpg123-decoder@1.0.3:
dependencies:
'@wasm-audio-decoders/common': 9.0.7
@@ -7793,6 +7855,10 @@ snapshots:
set-blocking: 2.0.0
optional: true
nth-check@2.1.1:
dependencies:
boolbase: 1.0.0
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -8506,17 +8572,7 @@ snapshots:
tailwindcss@4.1.17: {}
tar@6.2.1:
dependencies:
chownr: 2.0.0
fs-minipass: 2.1.0
minipass: 5.0.0
minizlib: 2.1.2
mkdirp: 1.0.4
yallist: 4.0.0
optional: true
tar@7.5.2:
tar@7.5.3:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0
@@ -8599,6 +8655,8 @@ snapshots:
dependencies:
'@webreflection/alien-signals': 0.3.2
uhyphen@0.2.0: {}
uint8array-extras@1.5.0: {}
undici-types@7.16.0: {}
-3
View File
@@ -9,6 +9,3 @@ onlyBuiltDependencies:
- esbuild
- protobufjs
- sharp
patchedDependencies:
'@mariozechner/pi-ai@0.45.7': patches/@mariozechner__pi-ai@0.45.7.patch
-1
View File
@@ -9,7 +9,6 @@ RUN apt-get update \
WORKDIR /repo
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY patches ./patches
COPY scripts/postinstall.js ./scripts/postinstall.js
RUN corepack enable \
&& pnpm install --frozen-lockfile
-1
View File
@@ -7,7 +7,6 @@ WORKDIR /app
ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning"
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json vitest.config.ts ./
COPY patches ./patches
COPY src ./src
COPY scripts ./scripts
COPY docs ./docs
+48
View File
@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
function detectPackageManager(ua = process.env.npm_config_user_agent ?? "") {
@@ -37,6 +38,52 @@ function ensureExecutable(targetPath) {
}
}
function hasGit(repoRoot) {
const result = spawnSync("git", ["--version"], { cwd: repoRoot, stdio: "ignore" });
return result.status === 0;
}
function isSubmoduleDirty(submodulePath, repoRoot) {
const result = spawnSync("git", ["-C", submodulePath, "status", "--porcelain"], {
cwd: repoRoot,
encoding: "utf-8",
});
if (result.status !== 0) return false;
return result.stdout.trim().length > 0;
}
function shouldSyncSubmodule(name, repoRoot) {
const result = spawnSync("git", ["submodule", "status", "--recursive", "--", name], {
cwd: repoRoot,
encoding: "utf-8",
});
if (result.status !== 0) return false;
const line = result.stdout.trim().split("\n")[0];
if (!line) return false;
const marker = line[0];
return marker === "-" || marker === "+";
}
function syncPeekabooSubmodule(repoRoot) {
if (!hasGit(repoRoot)) return;
if (!fs.existsSync(path.join(repoRoot, ".gitmodules"))) return;
const peekabooPath = path.join(repoRoot, "Peekaboo");
if (fs.existsSync(peekabooPath) && isSubmoduleDirty(peekabooPath, repoRoot)) {
console.warn("[postinstall] Peekaboo submodule dirty; skipping update");
return;
}
if (!shouldSyncSubmodule("Peekaboo", repoRoot)) return;
const result = spawnSync(
"git",
["submodule", "update", "--init", "--recursive", "--", "Peekaboo"],
{ cwd: repoRoot, encoding: "utf-8" },
);
if (result.status !== 0) {
const stderr = result.stderr?.toString().trim();
console.warn(`[postinstall] Peekaboo submodule update failed: ${stderr || "unknown error"}`);
}
}
function extractPackageName(key) {
if (key.startsWith("@")) {
const idx = key.indexOf("@", 1);
@@ -246,6 +293,7 @@ function main() {
process.chdir(repoRoot);
ensureExecutable(path.join(repoRoot, "dist", "entry.js"));
syncPeekabooSubmodule(repoRoot);
if (!shouldApplyPnpmPatchedDependenciesFallback()) {
return;
+13 -7
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env python3
import argparse
import base64
import datetime as dt
import json
import os
@@ -96,8 +95,8 @@ def request_images(
if model != "dall-e-2":
args["quality"] = quality
if model.startswith("dall-e"):
args["response_format"] = "b64_json"
# Note: response_format no longer supported by OpenAI Images API
# dall-e models now return URLs by default
if model.startswith("gpt-image"):
if background:
@@ -212,12 +211,19 @@ def main() -> int:
args.output_format,
args.style,
)
b64 = res.get("data", [{}])[0].get("b64_json")
if not b64:
# OpenAI Images API now returns URLs by default
image_url = res.get("data", [{}])[0].get("url")
if not image_url:
raise RuntimeError(f"Unexpected response: {json.dumps(res)[:400]}")
image_bytes = base64.b64decode(b64)
# Download image from URL
filename = f"{idx:03d}-{slugify(prompt)[:40]}.{file_ext}"
(out_dir / filename).write_bytes(image_bytes)
filepath = out_dir / filename
try:
urllib.request.urlretrieve(image_url, filepath)
except urllib.error.URLError as e:
raise RuntimeError(f"Failed to download image from {image_url}: {e}") from e
items.append({"prompt": prompt, "file": filename})
(out_dir / "prompts.json").write_text(json.dumps(items, indent=2), encoding="utf-8")
+14
View File
@@ -38,6 +38,20 @@ function listAgents(cfg: ClawdbotConfig): AgentEntry[] {
return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
}
export function listAgentIds(cfg: ClawdbotConfig): string[] {
const agents = listAgents(cfg);
if (agents.length === 0) return [DEFAULT_AGENT_ID];
const seen = new Set<string>();
const ids: string[] = [];
for (const entry of agents) {
const id = normalizeAgentId(entry?.id);
if (seen.has(id)) continue;
seen.add(id);
ids.push(id);
}
return ids.length > 0 ? ids : [DEFAULT_AGENT_ID];
}
export function resolveDefaultAgentId(cfg: ClawdbotConfig): string {
const agents = listAgents(cfg);
if (agents.length === 0) return DEFAULT_AGENT_ID;
@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { ensureAuthProfileStore } from "./auth-profiles.js";
import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
describe("ensureAuthProfileStore", () => {
it("migrates legacy auth.json and deletes it (PR #368)", () => {
@@ -45,4 +46,80 @@ describe("ensureAuthProfileStore", () => {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("merges main auth profiles into agent store and keeps agent overrides", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-merge-"));
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
try {
const mainDir = path.join(root, "main-agent");
const agentDir = path.join(root, "agent-x");
fs.mkdirSync(mainDir, { recursive: true });
fs.mkdirSync(agentDir, { recursive: true });
process.env.CLAWDBOT_AGENT_DIR = mainDir;
process.env.PI_CODING_AGENT_DIR = mainDir;
const mainStore = {
version: AUTH_STORE_VERSION,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "main-key",
},
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "main-anthropic-key",
},
},
};
fs.writeFileSync(
path.join(mainDir, "auth-profiles.json"),
`${JSON.stringify(mainStore, null, 2)}\n`,
"utf8",
);
const agentStore = {
version: AUTH_STORE_VERSION,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "agent-key",
},
},
};
fs.writeFileSync(
path.join(agentDir, "auth-profiles.json"),
`${JSON.stringify(agentStore, null, 2)}\n`,
"utf8",
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles["anthropic:default"]).toMatchObject({
type: "api_key",
provider: "anthropic",
key: "main-anthropic-key",
});
expect(store.profiles["openai:default"]).toMatchObject({
type: "api_key",
provider: "openai",
key: "agent-key",
});
} finally {
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;
}
fs.rmSync(root, { recursive: true, force: true });
}
});
});
+60 -1
View File
@@ -111,6 +111,37 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null {
};
}
function mergeRecord<T>(
base?: Record<string, T>,
override?: Record<string, T>,
): Record<string, T> | undefined {
if (!base && !override) return undefined;
if (!base) return { ...override };
if (!override) return { ...base };
return { ...base, ...override };
}
function mergeAuthProfileStores(
base: AuthProfileStore,
override: AuthProfileStore,
): AuthProfileStore {
if (
Object.keys(override.profiles).length === 0 &&
!override.order &&
!override.lastGood &&
!override.usageStats
) {
return base;
}
return {
version: Math.max(base.version, override.version ?? base.version),
profiles: { ...base.profiles, ...override.profiles },
order: mergeRecord(base.order, override.order),
lastGood: mergeRecord(base.lastGood, override.lastGood),
usageStats: mergeRecord(base.usageStats, override.usageStats),
};
}
function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
const oauthPath = resolveOAuthPath();
const oauthRaw = loadJsonFile(oauthPath);
@@ -191,7 +222,7 @@ export function loadAuthProfileStore(): AuthProfileStore {
return store;
}
export function ensureAuthProfileStore(
function loadAuthProfileStoreForAgent(
agentDir?: string,
options?: { allowKeychainPrompt?: boolean },
): AuthProfileStore {
@@ -207,6 +238,19 @@ export function ensureAuthProfileStore(
return asStore;
}
// Fallback: inherit auth-profiles from main agent if subagent has none
if (agentDir) {
const mainAuthPath = resolveAuthStorePath(); // without agentDir = main
const mainRaw = loadJsonFile(mainAuthPath);
const mainStore = coerceAuthStore(mainRaw);
if (mainStore && Object.keys(mainStore.profiles).length > 0) {
// Clone main store to subagent directory for auth inheritance
saveJsonFile(authPath, mainStore);
log.info("inherited auth-profiles from main agent", { agentDir });
return mainStore;
}
}
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir));
const legacy = coerceLegacyStore(legacyRaw);
const store: AuthProfileStore = {
@@ -274,6 +318,21 @@ export function ensureAuthProfileStore(
return store;
}
export function ensureAuthProfileStore(
agentDir?: string,
options?: { allowKeychainPrompt?: boolean },
): AuthProfileStore {
const store = loadAuthProfileStoreForAgent(agentDir, options);
const authPath = resolveAuthStorePath(agentDir);
const mainAuthPath = resolveAuthStorePath();
if (!agentDir || authPath === mainAuthPath) {
return store;
}
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
return mergeAuthProfileStores(mainStore, store);
}
export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void {
const authPath = resolveAuthStorePath(agentDir);
const payload = {
@@ -63,7 +63,7 @@ test("background exec still times out after tool signal abort", async () => {
abortController.abort();
let finished = getFinishedSession(sessionId);
const deadline = Date.now() + 2000;
const deadline = Date.now() + (process.platform === "win32" ? 10_000 : 2_000);
while (!finished && Date.now() < deadline) {
await sleep(20);
finished = getFinishedSession(sessionId);
@@ -72,6 +72,7 @@ test("background exec still times out after tool signal abort", async () => {
const running = getSession(sessionId);
try {
expect(finished).toBeTruthy();
expect(finished?.status).toBe("failed");
} finally {
const pid = running?.pid;
@@ -121,7 +122,7 @@ test("yielded background exec still times out", async () => {
const sessionId = (result.details as { sessionId: string }).sessionId;
let finished = getFinishedSession(sessionId);
const deadline = Date.now() + 2000;
const deadline = Date.now() + (process.platform === "win32" ? 10_000 : 2_000);
while (!finished && Date.now() < deadline) {
await sleep(20);
finished = getFinishedSession(sessionId);
@@ -130,6 +131,7 @@ test("yielded background exec still times out", async () => {
const running = getSession(sessionId);
try {
expect(finished).toBeTruthy();
expect(finished?.status).toBe("failed");
} finally {
const pid = running?.pid;
+24
View File
@@ -256,4 +256,28 @@ describe("getApiKeyForModel", () => {
}
}
});
it("resolves Vercel AI Gateway API key from env", async () => {
const previousGatewayKey = process.env.AI_GATEWAY_API_KEY;
try {
process.env.AI_GATEWAY_API_KEY = "gateway-test-key";
vi.resetModules();
const { resolveApiKeyForProvider } = await import("./model-auth.js");
const resolved = await resolveApiKeyForProvider({
provider: "vercel-ai-gateway",
store: { version: 1, profiles: {} },
});
expect(resolved.apiKey).toBe("gateway-test-key");
expect(resolved.source).toContain("AI_GATEWAY_API_KEY");
} finally {
if (previousGatewayKey === undefined) {
delete process.env.AI_GATEWAY_API_KEY;
} else {
process.env.AI_GATEWAY_API_KEY = previousGatewayKey;
}
}
});
});
+1
View File
@@ -154,6 +154,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
cerebras: "CEREBRAS_API_KEY",
xai: "XAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
moonshot: "MOONSHOT_API_KEY",
minimax: "MINIMAX_API_KEY",
synthetic: "SYNTHETIC_API_KEY",
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { downgradeGeminiHistory } from "./pi-embedded-helpers.js";
describe("downgradeGeminiHistory", () => {
it("drops unsigned tool calls and matching tool results", () => {
const input = [
{
role: "assistant",
content: [
{ type: "text", text: "hello" },
{ type: "toolCall", id: "call_1", name: "read", arguments: { path: "/tmp" } },
],
},
{
role: "toolResult",
toolCallId: "call_1",
content: [{ type: "text", text: "ok" }],
},
{ role: "user", content: "next" },
];
expect(downgradeGeminiHistory(input)).toEqual([
{
role: "assistant",
content: [{ type: "text", text: "hello" }],
},
{ role: "user", content: "next" },
]);
});
it("keeps signed tool calls and results", () => {
const input = [
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call_2",
name: "read",
arguments: { path: "/tmp" },
thought_signature: "sig_123",
},
],
},
{
role: "toolResult",
toolCallId: "call_2",
content: [{ type: "text", text: "ok" }],
},
];
expect(downgradeGeminiHistory(input)).toEqual(input);
});
it("drops assistant messages that only contain unsigned tool calls", () => {
const input = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_3", name: "read", arguments: {} }],
},
{
role: "toolResult",
toolCallId: "call_3",
content: [{ type: "text", text: "ok" }],
},
{ role: "user", content: "after" },
];
expect(downgradeGeminiHistory(input)).toEqual([{ role: "user", content: "after" }]);
});
});
+17 -55
View File
@@ -16,9 +16,9 @@ export function isAntigravityClaude(api?: string | null, modelId?: string): bool
export { sanitizeGoogleTurnOrdering };
/**
* Downgrades tool calls that are missing `thought_signature` (required by Gemini)
* into text representations, to prevent 400 INVALID_ARGUMENT errors.
* Also converts corresponding tool results into user messages.
* Drops tool calls that are missing `thought_signature` (required by Gemini)
* to prevent 400 INVALID_ARGUMENT errors. Matching tool results are dropped
* so they don't become orphaned in the transcript.
*/
type GeminiToolCallBlock = {
type?: unknown;
@@ -86,7 +86,7 @@ export function downgradeGeminiThinkingBlocks(messages: AgentMessage[]): AgentMe
}
export function downgradeGeminiHistory(messages: AgentMessage[]): AgentMessage[] {
const downgradedIds = new Set<string>();
const droppedToolCallIds = new Set<string>();
const out: AgentMessage[] = [];
const resolveToolResultId = (
@@ -113,9 +113,9 @@ export function downgradeGeminiHistory(messages: AgentMessage[]): AgentMessage[]
continue;
}
let hasDowngraded = false;
const newContent = assistantMsg.content.map((block) => {
if (!block || typeof block !== "object") return block;
let dropped = false;
const nextContent = assistantMsg.content.filter((block) => {
if (!block || typeof block !== "object") return true;
const blockRecord = block as GeminiToolCallBlock;
const type = blockRecord.type;
if (type === "toolCall" || type === "functionCall" || type === "toolUse") {
@@ -128,64 +128,26 @@ export function downgradeGeminiHistory(messages: AgentMessage[]): AgentMessage[]
: typeof blockRecord.toolCallId === "string"
? blockRecord.toolCallId
: undefined;
const name =
typeof blockRecord.name === "string"
? blockRecord.name
: typeof blockRecord.toolName === "string"
? blockRecord.toolName
: undefined;
const args =
blockRecord.arguments !== undefined ? blockRecord.arguments : blockRecord.input;
if (id) downgradedIds.add(id);
hasDowngraded = true;
const argsText = typeof args === "string" ? args : JSON.stringify(args, null, 2);
return {
type: "text",
text: `[Tool Call: ${name ?? "unknown"}${
id ? ` (ID: ${id})` : ""
}]\nArguments: ${argsText}`,
};
if (id) droppedToolCallIds.add(id);
dropped = true;
return false;
}
}
return block;
return true;
});
out.push(hasDowngraded ? ({ ...assistantMsg, content: newContent } as AgentMessage) : msg);
if (dropped && nextContent.length === 0) {
continue;
}
out.push(dropped ? ({ ...assistantMsg, content: nextContent } as AgentMessage) : msg);
continue;
}
if (role === "toolResult") {
const toolMsg = msg as Extract<AgentMessage, { role: "toolResult" }>;
const toolResultId = resolveToolResultId(toolMsg);
if (toolResultId && downgradedIds.has(toolResultId)) {
let textContent = "";
if (Array.isArray(toolMsg.content)) {
textContent = toolMsg.content
.map((entry) => {
if (entry && typeof entry === "object") {
const text = (entry as { text?: unknown }).text;
if (typeof text === "string") return text;
}
return JSON.stringify(entry);
})
.join("\n");
} else {
textContent = JSON.stringify(toolMsg.content);
}
out.push({
role: "user",
content: [
{
type: "text",
text: `[Tool Result for ID ${toolResultId}]\n${textContent}`,
},
],
} as AgentMessage);
if (toolResultId && droppedToolCallIds.has(toolResultId)) {
continue;
}
}
@@ -30,7 +30,7 @@ describeLive("pi embedded extra params (live)", () => {
const agent = { streamFn: streamSimple };
applyExtraParamsToAgent(agent, cfg, "openai", model.id, "off");
applyExtraParamsToAgent(agent, cfg, "openai", model.id);
const stream = agent.streamFn(
model,
+42 -440
View File
@@ -1,460 +1,62 @@
import { describe, expect, it } from "vitest";
import { resolveExtraParams } from "./pi-embedded-runner.js";
/**
* Tests for resolveExtraParams - the function that auto-enables GLM-4.x thinking mode.
*
* Z.AI Cloud API format: thinking: { type: "enabled", clear_thinking: boolean }
* - GLM-4.7: Preserved thinking (clear_thinking: false) - reasoning kept across turns
* - GLM-4.5/4.6: Interleaved thinking (clear_thinking: true) - reasoning cleared each turn
*
* @see https://docs.z.ai/guides/capabilities/thinking-mode
*/
describe("resolveExtraParams", () => {
describe("GLM-4.7 preserved thinking (clear_thinking: false)", () => {
it("auto-enables preserved thinking for zai/glm-4.7 with no config", () => {
const result = resolveExtraParams({
cfg: undefined,
provider: "zai",
modelId: "glm-4.7",
});
expect(result).toEqual({
thinking: {
type: "enabled",
clear_thinking: false, // Preserved thinking for GLM-4.7
},
});
it("returns undefined with no model config", () => {
const result = resolveExtraParams({
cfg: undefined,
provider: "zai",
modelId: "glm-4.7",
});
it("auto-enables preserved thinking for zai/GLM-4.7 (case insensitive)", () => {
const result = resolveExtraParams({
cfg: undefined,
provider: "zai",
modelId: "GLM-4.7",
});
expect(result).toBeUndefined();
});
expect(result).toEqual({
thinking: {
type: "enabled",
clear_thinking: false,
it("returns params for exact provider/model key", () => {
const result = resolveExtraParams({
cfg: {
agents: {
defaults: {
models: {
"openai/gpt-4": {
params: {
temperature: 0.7,
maxTokens: 2048,
},
},
},
},
},
});
},
provider: "openai",
modelId: "gpt-4",
});
expect(result).toEqual({
temperature: 0.7,
maxTokens: 2048,
});
});
describe("GLM-4.5/4.6 interleaved thinking (clear_thinking: true)", () => {
it("auto-enables interleaved thinking for zai/glm-4.5", () => {
const result = resolveExtraParams({
cfg: undefined,
provider: "zai",
modelId: "glm-4.5",
});
expect(result).toEqual({
thinking: {
type: "enabled",
clear_thinking: true, // Interleaved thinking for GLM-4.5
},
});
});
it("auto-enables interleaved thinking for zai/glm-4.6", () => {
const result = resolveExtraParams({
cfg: undefined,
provider: "zai",
modelId: "glm-4.6",
});
expect(result).toEqual({
thinking: {
type: "enabled",
clear_thinking: true, // Interleaved thinking for GLM-4.6
},
});
});
it("auto-enables interleaved thinking for zai/glm-4-flash", () => {
const result = resolveExtraParams({
cfg: undefined,
provider: "zai",
modelId: "glm-4-flash",
});
expect(result).toEqual({
thinking: {
type: "enabled",
clear_thinking: true, // Non-4.7 gets interleaved
},
});
});
it("auto-enables interleaved thinking for zai/glm-4.5-air", () => {
const result = resolveExtraParams({
cfg: undefined,
provider: "zai",
modelId: "glm-4.5-air",
});
expect(result).toEqual({
thinking: {
type: "enabled",
clear_thinking: true,
},
});
});
});
describe("config overrides", () => {
it("respects explicit thinking config from user (disable thinking)", () => {
const result = resolveExtraParams({
cfg: {
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
thinking: {
type: "disabled",
},
},
it("ignores unrelated model entries", () => {
const result = resolveExtraParams({
cfg: {
agents: {
defaults: {
models: {
"openai/gpt-4": {
params: {
temperature: 0.7,
},
},
},
},
},
provider: "zai",
modelId: "glm-4.7",
});
expect(result).toEqual({
thinking: {
type: "disabled",
},
});
},
provider: "openai",
modelId: "gpt-4.1-mini",
});
it("preserves other params while adding thinking config", () => {
const result = resolveExtraParams({
cfg: {
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
temperature: 0.7,
max_tokens: 4096,
},
},
},
},
},
},
provider: "zai",
modelId: "glm-4.7",
});
expect(result).toEqual({
temperature: 0.7,
max_tokens: 4096,
thinking: {
type: "enabled",
clear_thinking: false,
},
});
});
it("does not override explicit thinking config even if partial", () => {
const result = resolveExtraParams({
cfg: {
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
thinking: {
type: "enabled",
// User explicitly omitted clear_thinking
},
},
},
},
},
},
},
provider: "zai",
modelId: "glm-4.7",
});
// Should use user's config exactly, not merge defaults
expect(result).toEqual({
thinking: {
type: "enabled",
},
});
});
});
describe("non-GLM models", () => {
it("returns undefined for anthropic/claude with no config", () => {
const result = resolveExtraParams({
cfg: undefined,
provider: "anthropic",
modelId: "claude-3-opus",
});
expect(result).toBeUndefined();
});
it("returns undefined for openai/gpt-4 with no config", () => {
const result = resolveExtraParams({
cfg: undefined,
provider: "openai",
modelId: "gpt-4",
});
expect(result).toBeUndefined();
});
it("passes through params for non-GLM models without modification", () => {
const result = resolveExtraParams({
cfg: {
agents: {
defaults: {
models: {
"openai/gpt-4": {
params: {
logprobs: true,
top_logprobs: 5,
},
},
},
},
},
},
provider: "openai",
modelId: "gpt-4",
});
expect(result).toEqual({
logprobs: true,
top_logprobs: 5,
});
});
it("does not auto-enable thinking for non-zai provider even with glm-4 model id", () => {
const result = resolveExtraParams({
cfg: undefined,
provider: "openai",
modelId: "glm-4.7", // Even if model ID contains glm-4
});
expect(result).toBeUndefined();
});
});
describe("edge cases", () => {
it("handles empty config gracefully", () => {
const result = resolveExtraParams({
cfg: {},
provider: "zai",
modelId: "glm-4.7",
});
expect(result).toEqual({
thinking: {
type: "enabled",
clear_thinking: false,
},
});
});
it("handles config with empty models gracefully", () => {
const result = resolveExtraParams({
cfg: { agents: { defaults: { models: {} } } },
provider: "zai",
modelId: "glm-4.7",
});
expect(result).toEqual({
thinking: {
type: "enabled",
clear_thinking: false,
},
});
});
it("model alias lookup uses exact provider/model key", () => {
const result = resolveExtraParams({
cfg: {
agents: {
defaults: {
models: {
"zai/glm-4.7": {
alias: "smart",
params: {
custom_param: "value",
},
},
},
},
},
},
provider: "zai",
modelId: "glm-4.7",
});
expect(result).toEqual({
custom_param: "value",
thinking: {
type: "enabled",
clear_thinking: false,
},
});
});
it("treats thinking: null as explicit config (no auto-enable)", () => {
const result = resolveExtraParams({
cfg: {
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
thinking: null,
},
},
},
},
},
},
provider: "zai",
modelId: "glm-4.7",
});
// null is !== undefined, so we respect the explicit null config
expect(result).toEqual({
thinking: null,
});
});
it("handles GLM-4.7 variants (glm-4.7-flash, glm-4.7-plus)", () => {
// GLM-4.7-flash should get preserved thinking (contains "glm-4.7")
const flashResult = resolveExtraParams({
cfg: undefined,
provider: "zai",
modelId: "glm-4.7-flash",
});
expect(flashResult).toEqual({
thinking: {
type: "enabled",
clear_thinking: false, // Preserved thinking for GLM-4.7 variants
},
});
// GLM-4.7-plus should also get preserved thinking
const plusResult = resolveExtraParams({
cfg: undefined,
provider: "zai",
modelId: "glm-4.7-plus",
});
expect(plusResult).toEqual({
thinking: {
type: "enabled",
clear_thinking: false,
},
});
});
});
describe("thinkLevel parameter", () => {
it("thinkLevel: 'off' disables auto-enable for GLM-4.x", () => {
const result = resolveExtraParams({
cfg: undefined,
provider: "zai",
modelId: "glm-4.7",
thinkLevel: "off",
});
// Should NOT auto-enable thinking when user explicitly disabled it
expect(result).toBeUndefined();
});
it("thinkLevel: 'off' still passes through explicit config", () => {
const result = resolveExtraParams({
cfg: {
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
custom_param: "value",
},
},
},
},
},
},
provider: "zai",
modelId: "glm-4.7",
thinkLevel: "off",
});
// Should pass through config params but NOT auto-add thinking
expect(result).toEqual({
custom_param: "value",
});
});
it("thinkLevel: 'low' allows auto-enable", () => {
const result = resolveExtraParams({
cfg: undefined,
provider: "zai",
modelId: "glm-4.7",
thinkLevel: "low",
});
expect(result).toEqual({
thinking: {
type: "enabled",
clear_thinking: false,
},
});
});
it("thinkLevel: 'high' allows auto-enable", () => {
const result = resolveExtraParams({
cfg: undefined,
provider: "zai",
modelId: "glm-4.5",
thinkLevel: "high",
});
expect(result).toEqual({
thinking: {
type: "enabled",
clear_thinking: true,
},
});
});
it("thinkLevel: undefined (not specified) allows auto-enable", () => {
const result = resolveExtraParams({
cfg: undefined,
provider: "zai",
modelId: "glm-4.7",
// thinkLevel not specified
});
expect(result).toEqual({
thinking: {
type: "enabled",
clear_thinking: false,
},
});
});
expect(result).toBeUndefined();
});
});
@@ -190,10 +190,6 @@ describe("sanitizeSessionHistory (google thinking)", () => {
expect(assistant.content).toEqual([
{ type: "text", text: "hello" },
{ type: "text", text: "ok" },
{
type: "text",
text: '[Tool Call: read (ID: call_1)]\nArguments: {\n "path": "/tmp/foo"\n}',
},
{
type: "toolCall",
id: "call_2",
Loaded 100 of 237 files, more files were not shown because too many files have changed in this diff. Show more