Compare commits

...
Author SHA1 Message Date
Peter Steinberger ec9ba5b784 fix: show full gateway version string in status (#1111) (thanks @artuskg) 2026-01-17 17:45:14 +00:00
Artus KG cee4149884 macos: handle empty install version safely 2026-01-17 17:45:14 +00:00
Artus KG 5599e4cf35 test: make session update timestamp UTC 2026-01-17 17:45:14 +00:00
Artus KG 84cdd2df73 changelog: note CLI install build suffix fix 2026-01-17 17:45:14 +00:00
Artus KG 7929f57460 macos: keep CLI install build suffix 2026-01-17 17:45:04 +00:00
Peter Steinberger 7876679c5d style: apply oxfmt 2026-01-17 17:44:54 +00:00
Peter Steinberger bc6928525d docs: note discord interaction logging fix 2026-01-17 17:42:56 +00:00
Peter Steinberger 755c847d9a fix: soften discord interaction logging 2026-01-17 17:42:46 +00:00
Peter Steinberger 80a8639940 refactor: centralize telegram send param parsing 2026-01-17 17:36:37 +00:00
Peter Steinberger 6cb5704291 Merge pull request #1085 from dan-dr/chore/kimi-code-provider
Add Kimi Code provider onboarding
2026-01-17 17:36:30 +00:00
Peter Steinberger 4a987c836d fix: add Kimi Code docs + defaults (#1085) (thanks @dan-dr) 2026-01-17 17:35:40 +00:00
Peter Steinberger a2fb55326c Merge pull request #1099 from mukhtharcm/feat/message-tool-voice-support
feat(telegram): support sending audio as native voice notes via asVoice param in message tool
2026-01-17 17:33:20 +00:00
Peter Steinberger af29c6a980 fix: allow media-only telegram voice sends (#1099) (thanks @mukhtharcm) 2026-01-17 17:33:08 +00:00
Muhammed Mukhthar CM f2a0e8e5bb feat(telegram): support sending audio as native voice notes via asVoice param in message tool 2026-01-17 17:32:50 +00:00
Peter Steinberger f6456c2883 Merge pull request #1106 from gumadeiras/patch-2
Remove extra 'logging' page in the docs
2026-01-17 17:32:15 +00:00
Peter Steinberger 39f0d000d1 Merge pull request #1088 from sibbl/fix-matrix
feat(matrix): fix sending bug, add specific support for voice messages and images
2026-01-17 17:27:44 +00:00
Peter Steinberger a8d9d630bc fix: handle legacy matrix polls (#1088) (thanks @sibbl) 2026-01-17 17:27:12 +00:00
ddyo e93a1d8138 feat: add kimi code provider onboarding 2026-01-17 17:25:07 +00:00
Peter Steinberger f6681be6f4 style: tidy macOS config UI formatting 2026-01-17 17:22:42 +00:00
Peter Steinberger c79ac3fe81 test: cover semver suffix variants 2026-01-17 17:15:08 +00:00
Sebastian Schubotz b78b06353a feat(matrix): add specific voice message + image sending extending generic attachment sending 2026-01-17 17:12:38 +00:00
Sebastian Schubotz c49b6cc241 fix(matrix): fix sending being broken by normalizing thread ID normalization in message sending functions; improve matrix types 2026-01-17 17:12:38 +00:00
Peter Steinberger 30c945fe92 fix: cover semver patch suffix parsing (#1110) (thanks @zerone0x) 2026-01-17 16:50:05 +00:00
Peter Steinberger dfd511c310 Merge pull request #1110 from zerone0x/fix/issue-1107-semver-prerelease-suffix
fix(macos): parse semver patch correctly when version has prerelease suffix
2026-01-17 16:45:49 +00:00
Peter Steinberger 1657525201 chore: prep 2026.1.17 and onboard flow 2026-01-17 16:41:25 +00:00
zerone0xandClaude 3e4b0d0505 fix(macos): parse semver patch correctly when version has prerelease suffix
Strip prerelease (`-beta.1`) and build (`-4`) suffixes from the patch
component before parsing as integer. Previously `2026.1.11-4` parsed to
`patch: 0` because `Int("11-4")` returns nil; now correctly yields
`patch: 11`.

Fixes #1107

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-18 00:31:20 +08:00
Michael Behr 003c6c9ae1 add patches/.gitkeep (#1104) 2026-01-17 08:50:50 -06:00
Gustavo Madeira Santana 41fbb4d8b0 Remove extra 'logging' page in the docs
Removed 'logging' from the list of gateways.
2026-01-17 09:49:39 -05:00
Peter Steinberger eecb340f64 style: format update-cli 2026-01-17 12:51:08 +00:00
Peter Steinberger d029eaa0bb docs: tighten release preflight 2026-01-17 12:47:54 +00:00
Peter Steinberger 9c7dcc1ed7 chore: update appcast for 2026.1.16-2 2026-01-17 12:46:42 +00:00
Peter Steinberger be37b39782 docs: clarify build-info release check 2026-01-17 12:34:37 +00:00
Peter Steinberger 49c35c752c fix: stamp build commit metadata 2026-01-17 12:30:11 +00:00
Peter Steinberger 25d8043b9d feat: add gateway update check on start 2026-01-17 12:07:17 +00:00
Peter Steinberger f9f4a953fc docs: restore tmux skill 2026-01-17 11:52:50 +00:00
Peter Steinberger 34c3fbc66c chore: set extension versions to 2026.1.16 2026-01-17 11:40:25 +00:00
Peter Steinberger a9f21b3d3a feat: add update channel support 2026-01-17 11:40:05 +00:00
Peter Steinberger ed5c5629f6 fix: cut 2026.1.16-1 beta 2026-01-17 11:12:43 +00:00
Peter Steinberger 868952f958 docs: add release guardrails 2026-01-17 11:12:27 +00:00
Peter Steinberger 9b9836be71 fix: repair 2026.1.16 beta pack 2026-01-17 11:08:37 +00:00
Peter Steinberger 22cd839cb2 fix: include media-understanding in npm pack 2026-01-17 11:03:46 +00:00
Peter Steinberger dc3ac9fa28 docs: update coding-agent skill guidance 2026-01-17 10:59:23 +00:00
Peter Steinberger c874fa9712 chore: bump 2026.1.16 for beta 2026-01-17 10:49:49 +00:00
Peter Steinberger 6b784a9771 style: oxfmt 2026-01-17 10:26:08 +00:00
Peter SteinbergerandMuhammed Mukhthar CM f8e673cdbc fix: block invalid config startup
Co-authored-by: Muhammed Mukhthar CM <mukhtharcm@gmail.com>
2026-01-17 10:25:24 +00:00
Peter Steinberger ad360b4d18 docs(discord): clarify slash command visibility 2026-01-17 10:19:34 +00:00
Peter Steinberger 69ba2765de refactor(security): harden CommandAuthorized plumbing 2026-01-17 10:19:34 +00:00
Peter Steinberger 31e8ecca10 fix: format verbose tool output by channel 2026-01-17 10:17:57 +00:00
Peter SteinbergerandItzR3NO 4ca38286d8 chore: fix lint/format and update changelog
Co-authored-by: ItzR3NO <ItzR3NO@users.noreply.github.com>
2026-01-17 10:16:35 +00:00
Peter Steinberger fbf1c3ca3c test: cover plugin enable/disable semantics 2026-01-17 10:16:35 +00:00
Peter Steinberger 1a4313c2aa fix: avoid crash on memory embeddings errors (#1004) 2026-01-17 09:45:53 +00:00
Peter SteinbergerandItzR3NO a6deb0d9d5 feat: bundle provider auth plugins
Co-authored-by: ItzR3NO <ItzR3NO@users.noreply.github.com>
2026-01-17 09:38:53 +00:00
Peter Steinberger b6ea5895b6 fix: gate image tool and deepgram audio payload 2026-01-17 09:34:40 +00:00
Peter Steinberger d66bc65ca6 refactor: unify media provider options 2026-01-17 09:28:05 +00:00
Peter Steinberger 89f85ddeab fix: normalize deepgram audio upload bytes 2026-01-17 09:19:27 +00:00
Peter Steinberger bbb71c9198 refactor: prune legacy group targets 2026-01-17 09:01:47 +00:00
Peter Steinberger ae6792522d feat: add deepgram audio options 2026-01-17 08:53:42 +00:00
Peter SteinbergerandSafzan Pirani e637bbdfb5 feat: add Deepgram audio transcription
Co-authored-by: Safzan Pirani <safzanpirani@users.noreply.github.com>
2026-01-17 08:53:42 +00:00
Peter Steinberger 869ef0c5ba refactor(macos): centralize process pipe draining 2026-01-17 08:53:10 +00:00
Peter Steinberger 1002c74d9c refactor: share inbound envelope label helper 2026-01-17 08:51:31 +00:00
Peter Steinberger 61e60f3b84 docs: update changelog for 2026.1.16 2026-01-17 08:50:33 +00:00
Peter Steinberger 13b931c006 refactor: prune legacy group prefixes 2026-01-17 08:47:25 +00:00
Peter SteinbergerandTyler Yust ab49fe0e92 fix: tidy iMessage/Signal sender envelopes (#1080) - thanks @tyler6204
Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM>
2026-01-17 08:29:54 +00:00
Tyler Yust d0bc08a934 fix: reduce redundant envelope formatting for iMessage and Signal 2026-01-17 08:29:54 +00:00
Tyler Yust 64d21f5ea8 fix: improve message handling by logging sender issues 2026-01-17 08:29:54 +00:00
Peter Steinberger 0a95d8a840 fix(skills): fix watcher ignored typing 2026-01-17 08:28:09 +00:00
Peter Steinberger 56f3a2de25 fix(security): default-deny command execution 2026-01-17 08:28:09 +00:00
Peter Steinberger d8b463d0b3 fix: cap pending process output 2026-01-17 08:26:12 +00:00
Peter SteinbergerandSash Catanzarite eef3df9fa5 fix(macos): drain subprocess pipes before wait (#1081)
Thanks @thesash.

Co-authored-by: Sash Catanzarite <sashcatanzarite@Sash-MacBook-Pro-14in-3.local>
2026-01-17 08:24:59 +00:00
Peter Steinberger 837eea4ebd fix: refresh TUI session info after runs 2026-01-17 08:22:32 +00:00
Peter Steinberger 55622bac06 Merge pull request #1079 from d-ploutarchos/fix/tui-token-refresh
TUI: refresh token counts after agent runs complete
2026-01-17 08:17:03 +00:00
Peter Steinberger f172ccfcf6 fix: remove stray skills watcher bracket 2026-01-17 08:15:21 +00:00
Peter Steinberger a2a6893566 fix: allow skills watcher ignore list 2026-01-17 08:12:57 +00:00
Peter Steinberger 616ee3075c fix: repair skills watcher ignored typing 2026-01-17 08:12:00 +00:00
Dimitrios Ploutarchos cddf198321 TUI: refresh token counts after agent runs complete. Closes #1078 2026-01-17 07:40:59 +00:00
251 changed files with 5433 additions and 1453 deletions

No files matched your search

+2
View File
@@ -73,6 +73,7 @@
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
- Environment variables: see `~/.profile`.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
## Troubleshooting
- Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
@@ -117,6 +118,7 @@
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step.
## NPM + 1Password (publish/verify)
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
+49 -12
View File
@@ -1,40 +1,62 @@
# Changelog
## 2026.1.16 (unreleased)
Docs: https://docs.clawd.bot
## 2026.1.17 (Unreleased)
### Changes
- macOS: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x.
- macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg.
## 2026.1.16-2
### Changes
- CLI: stamp build commit into dist metadata so banners show the commit in npm installs.
## 2026.1.16-1
### Highlights
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos.
- Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh.
- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins.
- Sessions: add `session.identityLinks` for cross-platform DM session linking. (#1033) — thanks @thewilloftheshadow.
- Hooks: add hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake.
- Hooks: add hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake. https://docs.clawd.bot/hooks
- Media: add inbound media understanding (image/audio/video) with provider + CLI fallbacks. https://docs.clawd.bot/nodes/media-understanding
- Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh. https://docs.clawd.bot/plugins/zalouser
- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins. https://docs.clawd.bot/providers/vercel-ai-gateway
- Sessions: add `session.identityLinks` for cross-platform DM session li nking. (#1033) — thanks @thewilloftheshadow. https://docs.clawd.bot/concepts/session
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos. https://docs.clawd.bot/tools/web
### Breaking
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
- **BREAKING:** `clawdbot message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan.
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
- **BREAKING:** drop legacy target normalization helpers; use outbound target normalization and resolver flows.
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; hooks live under `clawdbot hooks`.
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; hooks live under `clawdbot hooks`. https://docs.clawd.bot/cli/webhooks
- **BREAKING:** `clawdbot plugins install <path>` now copies into `~/.clawdbot/extensions` (use `--link` to keep path-based loading).
### Changes
- Plugins: ship bundled plugins disabled by default and allow overrides by installed versions. (#1066) — thanks @ItzR3NO.
- Plugins: add bundled Antigravity + Gemini CLI OAuth + Copilot Proxy provider plugins. (#1066) — thanks @ItzR3NO.
- Tools: improve `web_fetch` extraction using Readability (with fallback).
- Tools: add Firecrawl fallback for `web_fetch` when configured.
- Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites.
- Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails.
- Tools: default `exec` exit notifications and auto-migrate legacy `tools.bash` to `tools.exec`.
- Tools: add `exec` PTY support for interactive sessions. https://docs.clawd.bot/tools/exec
- Tools: add tmux-style `process send-keys` and bracketed paste helpers for PTY sessions.
- Tools: add `process submit` helper to send CR for PTY sessions.
- Tools: respond to PTY cursor position queries to unblock interactive TUIs.
- Tools: include tool outputs in verbose mode and expand verbose tool feedback.
- Skills: update coding-agent guidance to prefer PTY-enabled exec runs and simplify tmux usage.
- TUI: refresh session token counts after runs complete or fail. (#1079) — thanks @d-ploutarchos.
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
- Directory: unify `clawdbot directory` across channels and plugin channels.
- UI: allow deleting sessions from the Control UI.
- Skills: add user-invocable skill commands and expanded skill command registration.
- Telegram: default reaction level to minimal and enable reaction notifications by default.
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
- iMessage: add remote attachment support for VM/SSH deployments.
- Messages: refresh live directory cache results when resolving targets.
- Messages: mirror delivered outbound text/media into session transcripts. (#1031) — thanks @TSavo.
- Messages: avoid redundant sender envelopes for iMessage + Signal group chats. (#1080) — thanks @tyler6204.
- Media: normalize Deepgram audio upload bytes for fetch compatibility.
- Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup.
- Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs.
- Config: support env var substitution in config values. (#1044) — thanks @sebslight.
@@ -43,31 +65,46 @@
- Plugins: add zip installs and `--link` to avoid copying local paths.
### Fixes
- macOS: drain subprocess pipes before waiting to avoid deadlocks. (#1081) — thanks @thesash.
- Verbose: wrap tool summaries/output in markdown only for markdown-capable channels.
- Telegram: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z.
- Telegram: split long captions into follow-up messages.
- Config: block startup on invalid config, preserve best-effort doctor config, and keep rolling config backups. (#1083) — thanks @mukhtharcm.
- Sub-agents: normalize announce delivery origin + queue bucketing by accountId to keep multi-account routing stable. (#1061, #1058) — thanks @adam91holt.
- Sessions: include deliveryContext in sessions.list and reuse normalized delivery routing for announce/restart fallbacks. (#1058)
- Sessions: propagate deliveryContext into last-route updates to keep account/channel routing stable. (#1058)
- Sessions: preserve overrides on `/new` reset.
- Memory: prevent unhandled rejections when watch/interval sync fails. (#1076) — thanks @roshanasingh4.
- Memory: avoid gateway crash when embeddings return 429/insufficient_quota (disable tool + surface error). (#1004)
- Gateway: honor explicit delivery targets without implicit accountId fallback; preserve lastAccountId for implicit routing.
- Gateway: avoid reusing last-to/accountId when the requested channel differs; sync deliveryContext with last route fields.
- Build: allow `@lydell/node-pty` builds on supported platforms.
- Repo: fix oxlint config filename and move ignore pattern into config. (#1064) — thanks @connorshea.
- Messages: `/stop` now hard-aborts queued followups and sub-agent runs; suppress zero-count stop notes.
- Messages: honor message tool channel when deduping sends.
- Messages: include sender labels for live group messages across channels, matching queued/history formatting. (#1059)
- Sessions: reset `compactionCount` on `/new` and `/reset`, and preserve `sessions.json` file mode (0600).
- Sessions: repair orphaned user turns before embedded prompts.
- Sessions: hard-stop `sessions.delete` cleanup.
- Channels: treat replies to the bot as implicit mentions across supported channels.
- Security: lock down slash/control commands to sender allowlists across Discord/Slack/Telegram/Signal/iMessage/WhatsApp (+ plugin channels like Matrix/Teams) and add stable `clawdbot security audit` checkIds for Slack/Discord command allowlists.
- Channels: normalize object-format capabilities in channel capability parsing.
- Security: default-deny slash/control commands unless a channel computed `CommandAuthorized` (fixes accidental “open” behavior), and ensure WhatsApp + Zalo plugin channels gate inline `/…` tokens correctly. https://docs.clawd.bot/gateway/security
- Security: redact sensitive text in gateway WS logs.
- Tools: cap pending `exec` process output to avoid unbounded buffers.
- CLI: speed up `clawdbot sandbox-explain` by avoiding heavy plugin imports when normalizing channel ids.
- Browser: remote profile tab operations prefer persistent Playwright and avoid silent HTTP fallbacks. (#1057) — thanks @mukhtharcm.
- Browser: remote profile tab ops follow-up: shared Playwright loader, Playwright-based focus, and more coverage (incl. opt-in live Browserless test). (follow-up to #1057) — thanks @mukhtharcm.
- Browser: refresh extension relay tab metadata after navigation so `/json/list` stays current. (#1073) — thanks @roshanasingh4.
- WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message.
- WhatsApp: include `linked` field in `describeAccount`.
- Agents: drop unsigned Gemini tool calls and avoid JSON Schema `format` keyword collisions.
- Agents: hide the image tool when the primary model already supports images.
- Agents: avoid duplicate sends by replying with `NO_REPLY` after `message` tool sends.
- Auth: inherit/merge sub-agent auth profiles from the main agent.
- Gateway: resolve local auth for security probe and validate gateway token/password file modes. (#1011, #1022) — thanks @ivanrvpereira, @kkarimi.
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
- OpenAI image-gen: remove deprecated `response_format` and use URL downloads.
- iMessage: avoid RPC restart loops.
- OpenAI image-gen: handle URL + `b64_json` responses and remove deprecated `response_format` (use URL downloads).
- CLI: auto-update global installs when installed via a package manager.
- Routing: migrate legacy `accountID` bindings to `accountId` and remove legacy fallback lookups. (#1047) — thanks @gumadeiras.
- Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr.
@@ -75,6 +112,7 @@
- Models: align ZAI thinking toggles.
- iMessage/Signal: include sender metadata for non-queued group messages. (#1059)
- Discord: preserve whitespace when chunking long lines so message splits keep spacing intact.
- Skills: fix skills watcher ignored list typing (tsc).
## 2026.1.15
@@ -115,7 +153,6 @@
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
- Media: add optional inbound media understanding for image/audio/video with provider + CLI fallbacks. (#1005) — thanks @tristanmanchester.
- 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.
- 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.
+16 -16
View File
@@ -2,6 +2,22 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdbot</title>
<item>
<title>2026.1.16-2</title>
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>6273</sparkle:version>
<sparkle:shortVersionString>2026.1.16-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
<h3>Changes</h3>
<ul>
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
</item>
<item>
<title>2026.1.15</title>
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
@@ -255,21 +271,5 @@
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
</item>
<item>
<title>2026.1.12-2</title>
<pubDate>Tue, 13 Jan 2026 10:05:25 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5534</sparkle:version>
<sparkle:shortVersionString>2026.1.12-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.12-2</h2>
<h3>Fixes</h3>
<ul>
<li>Packaging: include <code>dist/memory/**</code> in the npm tarball (fixes <code>ERR_MODULE_NOT_FOUND</code> for <code>dist/memory/index.js</code>).</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/>
</item>
</channel>
</rss>
@@ -35,7 +35,7 @@ enum CLIInstaller {
}
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async {
let expected = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
let prefix = Self.installPrefix()
await statusHandler("Installing clawdbot CLI…")
let cmd = self.installScriptCommand(version: expected, prefix: prefix)
@@ -6,11 +6,11 @@ struct ConfigSchemaForm: View {
let path: ConfigPath
var body: some View {
self.renderNode(schema, path: path)
self.renderNode(self.schema, path: self.path)
}
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
let storedValue = store.configValue(at: path)
let storedValue = self.store.configValue(at: path)
let value = storedValue ?? schema.explicitDefault
let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description
@@ -21,7 +21,7 @@ struct ConfigSchemaForm: View {
if nonNull.count == 1, let only = nonNull.first {
return self.renderNode(only, path: path)
}
let literals = nonNull.compactMap { $0.literalValue }
let literals = nonNull.compactMap(\.literalValue)
if !literals.isEmpty, literals.count == nonNull.count {
return AnyView(
VStack(alignment: .leading, spacing: 6) {
@@ -31,15 +31,20 @@ struct ConfigSchemaForm: View {
.font(.caption)
.foregroundStyle(.secondary)
}
Picker("", selection: self.enumBinding(path, options: literals, defaultValue: schema.explicitDefault)) {
Picker(
"",
selection: self.enumBinding(
path,
options: literals,
defaultValue: schema.explicitDefault))
{
Text("Select…").tag(-1)
ForEach(literals.indices, id: \ .self) { index in
Text(String(describing: literals[index])).tag(index)
}
}
.pickerStyle(.menu)
}
)
})
}
}
@@ -71,8 +76,7 @@ struct ConfigSchemaForm: View {
if schema.allowsAdditionalProperties {
self.renderAdditionalProperties(schema, path: path, value: value)
}
}
)
})
case "array":
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
case "boolean":
@@ -80,8 +84,7 @@ struct ConfigSchemaForm: View {
Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) {
if let label { Text(label) } else { Text("Enabled") }
}
.help(help ?? "")
)
.help(help ?? ""))
case "number", "integer":
return AnyView(self.renderNumberField(schema, path: path, label: label, help: help))
case "string":
@@ -93,8 +96,7 @@ struct ConfigSchemaForm: View {
Text("Unsupported field type.")
.font(.caption)
.foregroundStyle(.secondary)
}
)
})
}
}
@@ -155,9 +157,7 @@ struct ConfigSchemaForm: View {
text: self.numberBinding(
path,
isInteger: schema.schemaType == "integer",
defaultValue: defaultValue
)
)
defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
}
}
@@ -189,7 +189,7 @@ struct ConfigSchemaForm: View {
Button("Remove") {
var next = items
next.remove(at: index)
store.updateConfigValue(path: path, value: next)
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
@@ -202,7 +202,7 @@ struct ConfigSchemaForm: View {
} else {
next.append("")
}
store.updateConfigValue(path: path, value: next)
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
@@ -238,7 +238,7 @@ struct ConfigSchemaForm: View {
Button("Remove") {
var next = dict
next.removeValue(forKey: key)
store.updateConfigValue(path: path, value: next)
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
@@ -254,7 +254,7 @@ struct ConfigSchemaForm: View {
key = "new-\(index)"
}
next[key] = additionalSchema.defaultValue
store.updateConfigValue(path: path, value: next)
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
@@ -270,9 +270,8 @@ struct ConfigSchemaForm: View {
},
set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
}
)
self.store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
})
}
private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> {
@@ -282,16 +281,15 @@ struct ConfigSchemaForm: View {
return defaultValue ?? false
},
set: { newValue in
store.updateConfigValue(path: path, value: newValue)
}
)
self.store.updateConfigValue(path: path, value: newValue)
})
}
private func numberBinding(
_ path: ConfigPath,
isInteger: Bool,
defaultValue: Double?
) -> Binding<String> {
defaultValue: Double?) -> Binding<String>
{
Binding(
get: {
if let value = store.configValue(at: path) { return String(describing: value) }
@@ -301,22 +299,21 @@ struct ConfigSchemaForm: View {
set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
store.updateConfigValue(path: path, value: nil)
self.store.updateConfigValue(path: path, value: nil)
} else if let value = Double(trimmed) {
store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
self.store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
}
}
)
})
}
private func enumBinding(
_ path: ConfigPath,
options: [Any],
defaultValue: Any?
) -> Binding<Int> {
defaultValue: Any?) -> Binding<Int>
{
Binding(
get: {
let value = store.configValue(at: path) ?? defaultValue
let value = self.store.configValue(at: path) ?? defaultValue
guard let value else { return -1 }
return options.firstIndex { option in
String(describing: option) == String(describing: value)
@@ -324,12 +321,11 @@ struct ConfigSchemaForm: View {
},
set: { index in
guard index >= 0, index < options.count else {
store.updateConfigValue(path: path, value: nil)
self.store.updateConfigValue(path: path, value: nil)
return
}
store.updateConfigValue(path: path, value: options[index])
}
)
self.store.updateConfigValue(path: path, value: options[index])
})
}
private func mapKeyBinding(path: ConfigPath, key: String) -> Binding<String> {
@@ -339,14 +335,13 @@ struct ConfigSchemaForm: View {
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard trimmed != key else { return }
let current = store.configValue(at: path) as? [String: Any] ?? [:]
let current = self.store.configValue(at: path) as? [String: Any] ?? [:]
guard current[trimmed] == nil else { return }
var next = current
next[trimmed] = current[key]
next.removeValue(forKey: key)
store.updateConfigValue(path: path, value: next)
}
)
self.store.updateConfigValue(path: path, value: next)
})
}
}
@@ -355,10 +350,10 @@ struct ChannelConfigForm: View {
let channelId: String
var body: some View {
if store.configSchemaLoading {
if self.store.configSchemaLoading {
ProgressView().controlSize(.small)
} else if let schema = store.channelConfigSchema(for: channelId) {
ConfigSchemaForm(store: store, schema: schema, path: [.key("channels"), .key(channelId)])
ConfigSchemaForm(store: self.store, schema: schema, path: [.key("channels"), .key(self.channelId)])
} else {
Text("Schema unavailable for this channel.")
.font(.caption)
@@ -434,25 +434,25 @@ extension ChannelsSettings {
private func resolveChannelDetailTitle(_ id: String) -> String {
switch id {
case "whatsapp": return "WhatsApp Web"
case "telegram": return "Telegram Bot"
case "discord": return "Discord Bot"
case "slack": return "Slack Bot"
case "signal": return "Signal REST"
case "imessage": return "iMessage"
default: return self.resolveChannelTitle(id)
case "whatsapp": "WhatsApp Web"
case "telegram": "Telegram Bot"
case "discord": "Discord Bot"
case "slack": "Slack Bot"
case "signal": "Signal REST"
case "imessage": "iMessage"
default: self.resolveChannelTitle(id)
}
}
private func resolveChannelSystemImage(_ id: String) -> String {
switch id {
case "whatsapp": return "message"
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right"
case "slack": return "number"
case "signal": return "antenna.radiowaves.left.and.right"
case "imessage": return "message.fill"
default: return "message"
case "whatsapp": "message"
case "telegram": "paperplane"
case "discord": "bubble.left.and.bubble.right"
case "slack": "number"
case "signal": "antenna.radiowaves.left.and.right"
case "imessage": "message.fill"
default: "message"
}
}
@@ -57,7 +57,7 @@ extension ChannelsStore {
return value
}
guard path.count >= 2 else { return nil }
if case .key("channels") = path[0], case .key(_) = path[1] {
if case .key("channels") = path[0], case .key = path[1] {
let fallbackPath = Array(path.dropFirst())
return valueAtPath(self.configDraft, path: fallbackPath)
}
@@ -93,10 +93,10 @@ private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
var current: Any? = root
for segment in path {
switch segment {
case .key(let key):
case let .key(key):
guard let dict = current as? [String: Any] else { return nil }
current = dict[key]
case .index(let index):
case let .index(index):
guard let array = current as? [Any], array.indices.contains(index) else { return nil }
current = array[index]
}
@@ -107,7 +107,7 @@ private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
guard let segment = path.first else { return }
switch segment {
case .key(let key):
case let .key(key):
var dict = root as? [String: Any] ?? [:]
if path.count == 1 {
if let value {
@@ -122,7 +122,7 @@ private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
setValue(&child, path: Array(path.dropFirst()), value: value)
dict[key] = child
root = dict
case .index(let index):
case let .index(index):
var array = root as? [Any] ?? []
if index >= array.count {
array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1))
@@ -133,7 +133,7 @@ struct ConfigSchemaNode {
for segment in path {
guard let node = current else { return nil }
switch segment {
case .key(let key):
case let .key(key):
if node.schemaType == "object" {
if let next = node.properties[key] {
current = next
@@ -174,7 +174,7 @@ func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiH
var match = true
for (index, seg) in segments.enumerated() {
let hintSegment = hintSegments[index]
if hintSegment != "*" && hintSegment != seg {
if hintSegment != "*", hintSegment != seg {
match = false
break
}
@@ -196,7 +196,7 @@ func isSensitivePath(_ path: ConfigPath) -> Bool {
func pathKey(_ path: ConfigPath) -> String {
path.compactMap { segment -> String? in
switch segment {
case .key(let key): return key
case let .key(key): return key
case .index: return nil
}
}
@@ -47,7 +47,7 @@ extension ConfigSettings {
.foregroundStyle(.secondary)
}
}
if self.store.configDirty && !self.isNixMode {
if self.store.configDirty, !self.isNixMode {
Text("Unsaved changes")
.font(.caption)
.foregroundStyle(.secondary)
@@ -25,8 +25,10 @@ struct Semver: Comparable, CustomStringConvertible, Sendable {
let major = Int(parts[0]),
let minor = Int(parts[1])
else { return nil }
let patch = Int(parts[2]) ?? 0
return Semver(major: major, minor: minor, patch: patch)
// Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5")
let patchRaw = String(parts[2])
let patchNumeric = patchRaw.split { $0 == "-" || $0 == "+" }.first.flatMap { Int($0) } ?? 0
return Semver(major: major, minor: minor, patch: patchNumeric)
}
func compatible(with required: Semver) -> Bool {
@@ -78,8 +80,13 @@ enum GatewayEnvironment {
}
static func expectedGatewayVersion() -> Semver? {
Semver.parse(self.expectedGatewayVersionString())
}
static func expectedGatewayVersionString() -> String? {
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
return Semver.parse(bundleVersion)
let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines)
return (trimmed?.isEmpty == false) ? trimmed : nil
}
// Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
@@ -98,6 +105,7 @@ enum GatewayEnvironment {
}
}
let expected = self.expectedGatewayVersion()
let expectedString = self.expectedGatewayVersionString()
let projectRoot = CommandResolver.projectRoot()
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
@@ -108,8 +116,8 @@ enum GatewayEnvironment {
kind: .missingNode,
nodeVersion: nil,
gatewayVersion: nil,
requiredGateway: expected?.description,
message: RuntimeLocator.describeFailure(err))
requiredGateway: expectedString,
message: RuntimeLocator.describeFailure(err))
case let .success(runtime):
let gatewayBin = CommandResolver.clawdbotExecutable()
@@ -118,7 +126,7 @@ enum GatewayEnvironment {
kind: .missingGateway,
nodeVersion: runtime.version.description,
gatewayVersion: nil,
requiredGateway: expected?.description,
requiredGateway: expectedString,
message: "clawdbot CLI not found in PATH; install the CLI.")
}
@@ -126,13 +134,14 @@ enum GatewayEnvironment {
?? self.readLocalGatewayVersion(projectRoot: projectRoot)
if let expected, let installed, !installed.compatible(with: expected) {
let expectedText = expectedString ?? expected.description
return GatewayEnvironmentStatus(
kind: .incompatible(found: installed.description, required: expected.description),
kind: .incompatible(found: installed.description, required: expectedText),
nodeVersion: runtime.version.description,
gatewayVersion: installed.description,
requiredGateway: expected.description,
requiredGateway: expectedText,
message: """
Gateway version \(installed.description) is incompatible with app \(expected.description);
Gateway version \(installed.description) is incompatible with app \(expectedText);
install or update the global package.
""")
}
@@ -150,7 +159,7 @@ enum GatewayEnvironment {
kind: .ok,
nodeVersion: runtime.version.description,
gatewayVersion: gatewayVersionText,
requiredGateway: expected?.description,
requiredGateway: expectedString,
message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)")
}
}
@@ -218,8 +227,18 @@ enum GatewayEnvironment {
}
static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async {
await self.installGlobal(versionString: version?.description, statusHandler: statusHandler)
}
static func installGlobal(versionString: String?, statusHandler: @escaping @Sendable (String) -> Void) async {
let preferred = CommandResolver.preferredPaths().joined(separator: ":")
let target = version?.description ?? "latest"
let trimmed = versionString?.trimmingCharacters(in: .whitespacesAndNewlines)
let target: String
if let trimmed, !trimmed.isEmpty {
target = trimmed
} else {
target = "latest"
}
let npm = CommandResolver.findExecutable(named: "npm")
let pnpm = CommandResolver.findExecutable(named: "pnpm")
let bun = CommandResolver.findExecutable(named: "bun")
@@ -278,8 +297,7 @@ enum GatewayEnvironment {
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = try process.runAndReadToEnd(from: pipe)
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
if elapsedMs > 500 {
self.logger.warning(
@@ -294,7 +312,6 @@ enum GatewayEnvironment {
bin=\(binary, privacy: .public)
""")
}
let data = pipe.fileHandleForReading.readToEndSafely()
let raw = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return Semver.parse(raw)
@@ -72,11 +72,11 @@ enum LaunchAgentManager {
let process = Process()
process.launchPath = "/bin/launchctl"
process.arguments = args
process.standardOutput = Pipe()
process.standardError = Pipe()
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
_ = try process.runAndReadToEnd(from: pipe)
return process.terminationStatus
} catch {
return -1
+1 -3
View File
@@ -16,9 +16,7 @@ enum Launchctl {
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readToEndSafely()
let data = try process.runAndReadToEnd(from: pipe)
let output = String(data: data, encoding: .utf8) ?? ""
return Result(status: process.terminationStatus, output: output)
} catch {
@@ -580,11 +580,10 @@ final class NodePairingApprovalPrompter {
process.standardError = pipe
do {
try process.run()
_ = try process.runAndReadToEnd(from: pipe)
} catch {
return false
}
process.waitUntilExit()
return process.terminationStatus == 0
}.value
}
+1 -1
View File
@@ -155,7 +155,7 @@ struct OnboardingView: View {
var canAdvance: Bool { !self.isWizardBlocking }
var devLinkCommand: String {
let version = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
let version = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
return "npm install -g clawdbot@\(version)"
}
@@ -203,15 +203,13 @@ actor PortGuardian {
proc.standardOutput = pipe
proc.standardError = Pipe()
do {
try proc.run()
proc.waitUntilExit()
let data = try proc.runAndReadToEnd(from: pipe)
guard !data.isEmpty else { return nil }
return String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
} catch {
return nil
}
let data = pipe.fileHandleForReading.readToEndSafely()
guard !data.isEmpty else { return nil }
return String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func parseListeners(from text: String) -> [Listener] {
@@ -0,0 +1,11 @@
import Foundation
extension Process {
/// Runs the process and drains the given pipe before waiting to avoid blocking on full buffers.
func runAndReadToEnd(from pipe: Pipe) throws -> Data {
try self.run()
let data = pipe.fileHandleForReading.readToEndSafely()
self.waitUntilExit()
return data
}
}
@@ -133,8 +133,7 @@ enum RuntimeLocator {
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = try process.runAndReadToEnd(from: pipe)
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
if elapsedMs > 500 {
self.logger.warning(
@@ -149,7 +148,6 @@ enum RuntimeLocator {
bin=\(binary, privacy: .public)
""")
}
let data = pipe.fileHandleForReading.readToEndSafely()
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
} catch {
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
@@ -6,9 +6,17 @@ import Testing
@Test func semverParsesCommonForms() {
#expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0))
#expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 0)) // patch drops trailing text
#expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 5)) // prerelease suffix stripped
#expect(Semver.parse("2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11)) // build suffix stripped
#expect(Semver.parse("1.0.5+build.123") == Semver(major: 1, minor: 0, patch: 5)) // metadata suffix stripped
#expect(Semver.parse("1.2.3+build.123") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("1.2.3-rc.1+build.7") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("v1.2.3-rc.1") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("1.2.0") == Semver(major: 1, minor: 2, patch: 0))
#expect(Semver.parse(nil) == nil)
#expect(Semver.parse("invalid") == nil)
#expect(Semver.parse("1.2") == nil)
#expect(Semver.parse("1.2.x") == nil)
}
@Test func semverCompatibilityRequiresSameMajorAndNotOlder() {
@@ -36,6 +44,7 @@ import Testing
@Test func expectedGatewayVersionFromStringUsesParser() {
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11))
#expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil)
}
}
+1
View File
@@ -365,6 +365,7 @@ Allowlist matching notes:
Native command notes:
- The registered commands mirror Clawdbot’s chat commands.
- Native commands honor the same allowlists as DMs/guild messages (`channels.discord.dm.allowFrom`, `channels.discord.guilds`, per-channel rules).
- Slash commands may still be visible in Discord UI to users who aren’t allowlisted; Clawdbot enforces allowlists on execution and replies “not authorized”.
## Tool actions
The agent can call `discord` with actions like:
+13
View File
@@ -360,6 +360,19 @@ To force a voice note bubble in agent replies, include this tag anywhere in the
The tag is stripped from the delivered text. Other channels ignore this tag.
For message tool sends, set `asVoice: true` with a voice-compatible audio `media` URL
(`message` is optional when media is present):
```json5
{
"action": "send",
"channel": "telegram",
"to": "123456789",
"media": "https://example.com/voice.ogg",
"asVoice": true
}
```
## Streaming (drafts)
Telegram can stream **draft bubbles** while the agent is generating a response.
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
+3 -2
View File
@@ -292,7 +292,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|ai-gateway-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|kimi-code-api-key|codex-cli|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`)
@@ -302,6 +302,7 @@ Options:
- `--openrouter-api-key <key>`
- `--ai-gateway-api-key <key>`
- `--moonshot-api-key <key>`
- `--kimi-code-api-key <key>`
- `--gemini-api-key <key>`
- `--zai-api-key <key>`
- `--minimax-api-key <key>`
@@ -527,7 +528,7 @@ Surfaces:
Notes:
- Data comes directly from provider usage endpoints (no estimates).
- Providers: Anthropic, GitHub Copilot, Gemini CLI, Antigravity, OpenAI Codex OAuth, plus z.ai when an API key is configured.
- Providers: Anthropic, GitHub Copilot, OpenAI Codex OAuth, plus Gemini CLI/Antigravity when those provider plugins are enabled.
- If no matching credentials exist, usage is hidden.
- Details: see [Usage tracking](/concepts/usage-tracking).
+3
View File
@@ -25,6 +25,9 @@ clawdbot plugins update <id>
clawdbot plugins update --all
```
Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to
activate them.
### Install
```bash
+6
View File
@@ -15,6 +15,8 @@ If you installed via **npm/pnpm** (global install, no git metadata), use the pac
```bash
clawdbot update
clawdbot update --channel beta
clawdbot update --tag beta
clawdbot update --restart
clawdbot update --json
clawdbot --update
@@ -23,9 +25,13 @@ clawdbot --update
## Options
- `--restart`: restart the Gateway daemon after a successful update.
- `--channel <stable|beta>`: set the update channel for npm installs (persisted in config).
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
- `--json`: print machine-readable `UpdateRunResult` JSON.
- `--timeout <seconds>`: per-step timeout (default is 1200s).
Note: downgrades require confirmation because older versions can break configuration.
## What it does (git checkout)
High-level:
+34 -1
View File
@@ -83,7 +83,12 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
- Providers: `google-vertex`, `google-antigravity`, `google-gemini-cli`
- Auth: Vertex uses gcloud ADC; Antigravity/Gemini CLI use their respective auth flows
- CLI: `clawdbot onboard --auth-choice antigravity` (others via interactive wizard)
- Antigravity OAuth is shipped as a bundled plugin (`google-antigravity-auth`, disabled by default).
- Enable: `clawdbot plugins enable google-antigravity-auth`
- Login: `clawdbot models auth login --provider google-antigravity --set-default`
- Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default).
- Enable: `clawdbot plugins enable google-gemini-cli-auth`
- Login: `clawdbot models auth login --provider google-gemini-cli --set-default`
### Z.AI (GLM)
@@ -150,6 +155,34 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
}
```
### Kimi Code
Kimi Code uses a dedicated endpoint and key (separate from Moonshot):
- Provider: `kimi-code`
- Auth: `KIMICODE_API_KEY`
- Example model: `kimi-code/kimi-for-coding`
```json5
{
env: { KIMICODE_API_KEY: "sk-..." },
agents: {
defaults: { model: { primary: "kimi-code/kimi-for-coding" } }
},
models: {
mode: "merge",
providers: {
"kimi-code": {
baseUrl: "https://api.kimi.com/coding/v1",
apiKey: "${KIMICODE_API_KEY}",
api: "openai-completions",
models: [{ id: "kimi-for-coding", name: "Kimi For Coding" }]
}
}
}
}
```
### Synthetic
Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
-1
View File
@@ -894,7 +894,6 @@
"gateway/heartbeat",
"gateway/doctor",
"gateway/logging",
"logging",
"gateway/security",
"gateway/sandbox-vs-tool-policy-vs-elevated",
"gateway/sandboxing",
+1
View File
@@ -33,6 +33,7 @@ When spawning long-running child processes outside the exec/process tools (for e
Environment overrides:
- `PI_BASH_YIELD_MS`: default yield (ms)
- `PI_BASH_MAX_OUTPUT_CHARS`: in‑memory output cap (chars)
- `CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS`: pending stdout/stderr cap per stream (chars)
- `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m–3h)
Config (preferred):
+43
View File
@@ -2234,6 +2234,49 @@ Notes:
- Model ref: `moonshot/kimi-k2-0905-preview`.
- Use `https://api.moonshot.cn/v1` if you need the China endpoint.
### Kimi Code
Use Kimi Code's dedicated OpenAI-compatible endpoint (separate from Moonshot):
```json5
{
env: { KIMICODE_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "kimi-code/kimi-for-coding" },
models: { "kimi-code/kimi-for-coding": { alias: "Kimi Code" } }
}
},
models: {
mode: "merge",
providers: {
"kimi-code": {
baseUrl: "https://api.kimi.com/coding/v1",
apiKey: "${KIMICODE_API_KEY}",
api: "openai-completions",
models: [
{
id: "kimi-for-coding",
name: "Kimi For Coding",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 32768,
headers: { "User-Agent": "KimiCLI/0.77" },
compat: { supportsDeveloperRole: false }
}
]
}
}
}
}
```
Notes:
- Set `KIMICODE_API_KEY` in the environment or use `clawdbot onboard --auth-choice kimi-code-api-key`.
- Model ref: `kimi-code/kimi-for-coding`.
### Synthetic (Anthropic-compatible)
Use Synthetic's Anthropic-compatible endpoint:
+17 -1
View File
@@ -50,6 +50,22 @@ pnpm add -g clawdbot@latest
```
We do **not** recommend Bun for the Gateway runtime (WhatsApp/Telegram bugs).
To stay on the beta channel for CLI updates:
```bash
clawdbot update --channel beta
```
Switch back to stable later:
```bash
clawdbot update --channel stable
```
Use `--tag <dist-tag|version>` for a one-off install tag/version.
Note: on npm installs, the gateway logs an update hint on startup (checks the current channel tag). Disable via `update.checkOnStart: false`.
Then:
```bash
@@ -75,7 +91,7 @@ It runs a safe-ish update flow:
- Fetches + rebases against the configured upstream.
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will skip. Use “Update (global install)” instead.
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead.
## Update (Control UI / RPC)
+17
View File
@@ -62,8 +62,25 @@ read_when:
}
```
### Provider-only (Deepgram)
```json5
{
tools: {
media: {
audio: {
enabled: true,
models: [{ provider: "deepgram", model: "nova-3" }]
}
}
}
}
```
## Notes & limits
- Provider auth follows the standard model auth order (auth profiles, env vars, `models.providers.*.apiKey`).
- Deepgram picks up `DEEPGRAM_API_KEY` when `provider: "deepgram"` is used.
- Deepgram setup details: [Deepgram (audio transcription)](/providers/deepgram).
- Audio providers can override `baseUrl`, `headers`, and `providerOptions` via `tools.media.audio`.
- Default size cap is 20MB (`tools.media.audio.maxBytes`). Oversize audio is skipped for that model and the next entry is tried.
- Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or per-entry `maxChars` to trim output.
- Use `tools.media.audio.attachments` to process multiple voice notes (`mode: "all"` + `maxAttachments`).
+6 -2
View File
@@ -32,6 +32,8 @@ If understanding fails or is disabled, **the reply flow continues** with the ori
- `tools.media.models`: shared model list (use `capabilities` to gate).
- `tools.media.image` / `tools.media.audio` / `tools.media.video`:
- defaults (`prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`)
- provider overrides (`baseUrl`, `headers`, `providerOptions`)
- Deepgram audio options via `tools.media.audio.providerOptions.deepgram`
- optional **per‑capability `models` list** (preferred before shared models)
- `attachments` policy (`mode`, `maxAttachments`, `prefer`)
- `scope` (optional gating by channel/chatType/session key)
@@ -108,6 +110,7 @@ lists, Clawdbot can infer defaults:
- `openai`, `anthropic`, `minimax`: **image**
- `google` (Gemini API): **image + audio + video**
- `groq`: **audio**
- `deepgram`: **audio**
For CLI entries, **set `capabilities` explicitly** to avoid surprising matches.
If you omit `capabilities`, the entry is eligible for the list it appears in.
@@ -116,7 +119,7 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
| Capability | Provider integration | Notes |
|------------|----------------------|-------|
| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. |
| Audio | OpenAI, Groq | Provider transcription (Whisper). |
| Audio | OpenAI, Groq, Deepgram | Provider transcription (Whisper/Deepgram). |
| Video | Google (Gemini API) | Provider video understanding. |
## Recommended providers
@@ -125,8 +128,9 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
- Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-5`, `google/gemini-3-pro-preview`.
**Audio**
- `openai/whisper-1` or `groq/whisper-large-v3-turbo`.
- `openai/whisper-1`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`.
- CLI fallback: `whisper` binary.
- Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram).
**Video**
- `google/gemini-3-flash-preview` (fast), `google/gemini-3-pro-preview` (richer).
+18 -5
View File
@@ -41,6 +41,9 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
- [Zalo](/channels/zalo) — `@clawdbot/zalo`
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
- Copilot Proxy (provider auth) — bundled as `copilot-proxy` (disabled by default)
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
register:
@@ -58,16 +61,26 @@ Plugins run **in‑process** with the Gateway, so treat them as trusted code.
Clawdbot scans, in order:
1) Global extensions
- `~/.clawdbot/extensions/*.ts`
- `~/.clawdbot/extensions/*/index.ts`
1) Config paths
- `plugins.load.paths` (file or directory)
2) Workspace extensions
- `<workspace>/.clawdbot/extensions/*.ts`
- `<workspace>/.clawdbot/extensions/*/index.ts`
3) Config paths
- `plugins.load.paths` (file or directory)
3) Global extensions
- `~/.clawdbot/extensions/*.ts`
- `~/.clawdbot/extensions/*/index.ts`
4) Bundled extensions (shipped with Clawdbot, **disabled by default**)
- `<clawdbot>/extensions/*`
Bundled plugins must be enabled explicitly via `plugins.entries.<id>.enabled`
or `clawdbot plugins enable <id>`. Installed plugins are enabled by default,
but can be disabled the same way.
If multiple plugins resolve to the same id, the first match in the order above
wins and lower-precedence copies are ignored.
### Package packs
+89
View File
@@ -0,0 +1,89 @@
---
summary: "Deepgram transcription for inbound voice notes"
read_when:
- You want Deepgram speech-to-text for audio attachments
- You need a quick Deepgram config example
---
# Deepgram (Audio Transcription)
Deepgram is a speech-to-text API. In Clawdbot it is used for **inbound audio/voice note
transcription** via `tools.media.audio`.
When enabled, Clawdbot uploads the audio file to Deepgram and injects the transcript
into the reply pipeline (`{{Transcript}}` + `[Audio]` block). This is **not streaming**;
it uses the pre-recorded transcription endpoint.
Website: https://deepgram.com
Docs: https://developers.deepgram.com
## Quick start
1) Set your API key:
```
DEEPGRAM_API_KEY=dg_...
```
2) Enable the provider:
```json5
{
tools: {
media: {
audio: {
enabled: true,
models: [{ provider: "deepgram", model: "nova-3" }]
}
}
}
}
```
## Options
- `model`: Deepgram model id (default: `nova-3`)
- `language`: language hint (optional)
- `tools.media.audio.providerOptions.deepgram.detect_language`: enable language detection (optional)
- `tools.media.audio.providerOptions.deepgram.punctuate`: enable punctuation (optional)
- `tools.media.audio.providerOptions.deepgram.smart_format`: enable smart formatting (optional)
Example with language:
```json5
{
tools: {
media: {
audio: {
enabled: true,
models: [
{ provider: "deepgram", model: "nova-3", language: "en" }
]
}
}
}
}
```
Example with Deepgram options:
```json5
{
tools: {
media: {
audio: {
enabled: true,
providerOptions: {
deepgram: {
detect_language: true,
punctuate: true,
smart_format: true
}
},
models: [{ provider: "deepgram", model: "nova-3" }]
}
}
}
}
```
## Notes
- Authentication follows the standard provider auth order; `DEEPGRAM_API_KEY` is the simplest path.
- Override endpoints or headers with `tools.media.audio.baseUrl` and `tools.media.audio.headers` when using a proxy.
- Output follows the same audio rules as other providers (size caps, timeouts, transcript injection).
+5 -1
View File
@@ -28,11 +28,15 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Chann
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
- [OpenRouter](/providers/openrouter)
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Moonshot AI (Kimi)](/providers/moonshot)
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
- [OpenCode Zen](/providers/opencode)
- [Z.AI](/providers/zai)
- [GLM models](/providers/glm)
- [MiniMax](/providers/minimax)
## Transcription providers
- [Deepgram (audio transcription)](/providers/deepgram)
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
see [Model providers](/concepts/model-providers).
+1 -1
View File
@@ -26,7 +26,7 @@ model as `provider/model`.
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
- [OpenRouter](/providers/openrouter)
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Moonshot AI (Kimi)](/providers/moonshot)
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
- [Synthetic](/providers/synthetic)
- [OpenCode Zen](/providers/opencode)
- [Z.AI](/providers/zai)
+56 -6
View File
@@ -1,13 +1,16 @@
---
summary: "Use Moonshot AI (Kimi K2) with Clawdbot"
summary: "Configure Moonshot K2 vs Kimi Code (separate providers + keys)"
read_when:
- You want to use Moonshot/Kimi models in Clawdbot
- You need the Moonshot auth + config example
- You want Moonshot K2 (Moonshot Open Platform) vs Kimi Code setup
- You need to understand separate endpoints, keys, and model refs
- You want copy/paste config for either provider
---
# Moonshot AI (Kimi)
Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the
provider and set the default model to `moonshot/kimi-k2-0905-preview`.
provider and set the default model to `moonshot/kimi-k2-0905-preview`, or use
Kimi Code with `kimi-code/kimi-for-coding`.
Current Kimi K2 model IDs:
{/* moonshot-kimi-k2-ids:start */}
@@ -21,7 +24,15 @@ Current Kimi K2 model IDs:
clawdbot onboard --auth-choice moonshot-api-key
```
## Config snippet
Kimi Code:
```bash
clawdbot onboard --auth-choice kimi-code-api-key
```
Note: Moonshot and Kimi Code are separate providers. Keys are not interchangeable, endpoints differ, and model refs differ (Moonshot uses `moonshot/...`, Kimi Code uses `kimi-code/...`).
## Config snippet (Moonshot API)
```json5
{
@@ -92,9 +103,48 @@ clawdbot onboard --auth-choice moonshot-api-key
}
```
## Kimi Code
```json5
{
env: { KIMICODE_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "kimi-code/kimi-for-coding" },
models: {
"kimi-code/kimi-for-coding": { alias: "Kimi Code" }
}
}
},
models: {
mode: "merge",
providers: {
"kimi-code": {
baseUrl: "https://api.kimi.com/coding/v1",
apiKey: "${KIMICODE_API_KEY}",
api: "openai-completions",
models: [
{
id: "kimi-for-coding",
name: "Kimi For Coding",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 32768,
headers: { "User-Agent": "KimiCLI/0.77" },
compat: { supportsDeveloperRole: false }
}
]
}
}
}
}
```
## Notes
- Model refs use `moonshot/<modelId>`.
- Moonshot model refs use `moonshot/<modelId>`. Kimi Code model refs use `kimi-code/<modelId>`.
- Override pricing and context metadata in `models.providers` if needed.
- If Moonshot publishes different context limits for a model, adjust
`contextWindow` accordingly.
+8 -1
View File
@@ -10,6 +10,12 @@ read_when:
Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tagging/publishing.
## Operator trigger
When the operator says “release”, immediately do this preflight (no extra questions unless blocked):
- Read this doc and `docs/platforms/mac/release.md`.
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set.
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
1) **Version & metadata**
- [ ] Bump `package.json` version (e.g., `1.1.0`).
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
@@ -20,6 +26,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
2) **Build & artifacts**
- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/clawdbot/clawdbot/blob/main/src/canvas-host/a2ui/a2ui.bundle.js).
- [ ] `pnpm run build` (regenerates `dist/`).
- [ ] Confirm `dist/build-info.json` exists and includes the expected `commit` hash (CLI banner uses this for npm installs).
- [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it).
3) **Changelog & docs**
@@ -51,7 +58,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
- [ ] Confirm git status is clean; commit and push as needed.
- [ ] `npm login` (verify 2FA) if needed.
- [ ] `npm publish --access public` (use `--tag beta` for pre-releases).
- [ ] Verify the registry: `npm view clawdbot version` and `npx -y clawdbot@X.Y.Z --version` (or `--help`).
- [ ] Verify the registry: `npm view clawdbot version`, `npm view clawdbot dist-tags`, and `npx -y clawdbot@X.Y.Z --version` (or `--help`).
### Troubleshooting (notes from 2.0.0-beta2 release)
- **npm pack/publish hangs or produces huge tarball**: the macOS app bundle in `dist/Clawdbot.app` (and release zips) get swept into the package. Fix by whitelisting publish contents via `package.json` `files` (include dist subdirs, docs, skills; exclude app bundles). Confirm with `npm pack --dry-run` that `dist/Clawdbot.app` is not listed.
+2 -1
View File
@@ -95,7 +95,8 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
- More detail: [Synthetic](/providers/synthetic)
- **Moonshot (Kimi K2)**: config is auto-written.
- More detail: [Moonshot AI](/providers/moonshot)
- **Kimi Code**: config is auto-written.
- More detail: [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
- **Skip**: no auth configured yet.
- Pick a default model from detected options (or enter provider/model manually).
- Wizard runs a model check and warns if the configured model is unknown or missing auth.
+5
View File
@@ -290,6 +290,11 @@ Live tests discover credentials the same way the CLI does. Practical implication
If you want to rely on env keys (e.g. exported in your `~/.profile`), run local tests after `source ~/.profile`, or use the Docker runners below (they can mount `~/.profile` into the container).
## Deepgram live (audio transcription)
- Test: `src/media-understanding/providers/deepgram/audio.live.test.ts`
- Enable: `DEEPGRAM_API_KEY=... DEEPGRAM_LIVE_TEST=1 pnpm test:live src/media-understanding/providers/deepgram/audio.live.test.ts`
## Docker runners (optional “works in Linux” checks)
These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted):
+24
View File
@@ -0,0 +1,24 @@
# Copilot Proxy (Clawdbot plugin)
Provider plugin for the **Copilot Proxy** VS Code extension.
## Enable
Bundled plugins are disabled by default. Enable this one:
```bash
clawdbot plugins enable copilot-proxy
```
Restart the Gateway after enabling.
## Authenticate
```bash
clawdbot models auth login --provider copilot-proxy --set-default
```
## Notes
- Copilot Proxy must be running in VS Code.
- Base URL must include `/v1`.
+139
View File
@@ -0,0 +1,139 @@
const DEFAULT_BASE_URL = "http://localhost:3000/v1";
const DEFAULT_API_KEY = "n/a";
const DEFAULT_CONTEXT_WINDOW = 128_000;
const DEFAULT_MAX_TOKENS = 8192;
const DEFAULT_MODEL_IDS = [
"gpt-5.2",
"gpt-5.2-codex",
"gpt-5.1",
"gpt-5.1-codex",
"gpt-5.1-codex-max",
"gpt-5-mini",
"claude-opus-4.5",
"claude-sonnet-4.5",
"claude-haiku-4.5",
"gemini-3-pro",
"gemini-3-flash",
"grok-code-fast-1",
] as const;
function normalizeBaseUrl(value: string): string {
const trimmed = value.trim();
if (!trimmed) return DEFAULT_BASE_URL;
let normalized = trimmed;
while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
if (!normalized.endsWith("/v1")) normalized = `${normalized}/v1`;
return normalized;
}
function validateBaseUrl(value: string): string | undefined {
const normalized = normalizeBaseUrl(value);
try {
new URL(normalized);
} catch {
return "Enter a valid URL";
}
return undefined;
}
function parseModelIds(input: string): string[] {
const parsed = input
.split(/[\n,]/)
.map((model) => model.trim())
.filter(Boolean);
return Array.from(new Set(parsed));
}
function buildModelDefinition(modelId: string) {
return {
id: modelId,
name: modelId,
api: "openai-completions",
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_WINDOW,
maxTokens: DEFAULT_MAX_TOKENS,
};
}
const copilotProxyPlugin = {
id: "copilot-proxy",
name: "Copilot Proxy",
description: "Local Copilot Proxy (VS Code LM) provider plugin",
register(api) {
api.registerProvider({
id: "copilot-proxy",
label: "Copilot Proxy",
docsPath: "/providers/models",
auth: [
{
id: "local",
label: "Local proxy",
hint: "Configure base URL + models for the Copilot Proxy server",
kind: "custom",
run: async (ctx) => {
const baseUrlInput = await ctx.prompter.text({
message: "Copilot Proxy base URL",
initialValue: DEFAULT_BASE_URL,
validate: validateBaseUrl,
});
const modelInput = await ctx.prompter.text({
message: "Model IDs (comma-separated)",
initialValue: DEFAULT_MODEL_IDS.join(", "),
validate: (value) =>
parseModelIds(value).length > 0 ? undefined : "Enter at least one model id",
});
const baseUrl = normalizeBaseUrl(baseUrlInput);
const modelIds = parseModelIds(modelInput);
const defaultModelId = modelIds[0] ?? DEFAULT_MODEL_IDS[0];
const defaultModelRef = `copilot-proxy/${defaultModelId}`;
return {
profiles: [
{
profileId: "copilot-proxy:local",
credential: {
type: "token",
provider: "copilot-proxy",
token: DEFAULT_API_KEY,
},
},
],
configPatch: {
models: {
providers: {
"copilot-proxy": {
baseUrl,
apiKey: DEFAULT_API_KEY,
api: "openai-completions",
authHeader: false,
models: modelIds.map((modelId) => buildModelDefinition(modelId)),
},
},
},
agents: {
defaults: {
models: Object.fromEntries(
modelIds.map((modelId) => [`copilot-proxy/${modelId}`, {}]),
),
},
},
},
defaultModel: defaultModelRef,
notes: [
"Start the Copilot Proxy VS Code extension before using these models.",
"Copilot Proxy serves /v1/chat/completions; base URL must include /v1.",
"Model availability depends on your Copilot plan; edit models.providers.copilot-proxy if needed.",
],
};
},
},
],
});
},
};
export default copilotProxyPlugin;
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@clawdbot/copilot-proxy",
"version": "2026.1.16-1",
"type": "module",
"description": "Clawdbot Copilot Proxy provider plugin",
"clawdbot": {
"extensions": [
"./index.ts"
]
}
}
@@ -0,0 +1,24 @@
# Google Antigravity Auth (Clawdbot plugin)
OAuth provider plugin for **Google Antigravity** (Cloud Code Assist).
## Enable
Bundled plugins are disabled by default. Enable this one:
```bash
clawdbot plugins enable google-antigravity-auth
```
Restart the Gateway after enabling.
## Authenticate
```bash
clawdbot models auth login --provider google-antigravity --set-default
```
## Notes
- Antigravity uses Google Cloud project quotas.
- If requests fail, ensure Gemini for Google Cloud is enabled.
+428
View File
@@ -0,0 +1,428 @@
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync
const decode = (s: string) => Buffer.from(s, "base64").toString();
const CLIENT_ID = decode(
"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
);
const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
const REDIRECT_URI = "http://localhost:51121/oauth-callback";
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const TOKEN_URL = "https://oauth2.googleapis.com/token";
const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
const DEFAULT_MODEL = "google-antigravity/claude-opus-4-5-thinking";
const SCOPES = [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs",
];
const CODE_ASSIST_ENDPOINTS = [
"https://cloudcode-pa.googleapis.com",
"https://daily-cloudcode-pa.sandbox.googleapis.com",
];
const RESPONSE_PAGE = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Clawdbot Antigravity OAuth</title>
</head>
<body>
<main>
<h1>Authentication complete</h1>
<p>You can return to the terminal.</p>
</main>
</body>
</html>`;
function generatePkce(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("hex");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
function isWSL(): boolean {
if (process.platform !== "linux") return false;
try {
const release = readFileSync("/proc/version", "utf8").toLowerCase();
return release.includes("microsoft") || release.includes("wsl");
} catch {
return false;
}
}
function isWSL2(): boolean {
if (!isWSL()) return false;
try {
const version = readFileSync("/proc/version", "utf8").toLowerCase();
return version.includes("wsl2") || version.includes("microsoft-standard");
} catch {
return false;
}
}
function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
return isRemote || isWSL2();
}
function buildAuthUrl(params: { challenge: string; state: string }): string {
const url = new URL(AUTH_URL);
url.searchParams.set("client_id", CLIENT_ID);
url.searchParams.set("response_type", "code");
url.searchParams.set("redirect_uri", REDIRECT_URI);
url.searchParams.set("scope", SCOPES.join(" "));
url.searchParams.set("code_challenge", params.challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", params.state);
url.searchParams.set("access_type", "offline");
url.searchParams.set("prompt", "consent");
return url.toString();
}
function parseCallbackInput(
input: string,
): { code: string; state: string } | { error: string } {
const trimmed = input.trim();
if (!trimmed) return { error: "No input provided" };
try {
const url = new URL(trimmed);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code) return { error: "Missing 'code' parameter in URL" };
if (!state) return { error: "Missing 'state' parameter in URL" };
return { code, state };
} catch {
return { error: "Paste the full redirect URL (not just the code)." };
}
}
async function startCallbackServer(params: { timeoutMs: number }) {
const redirect = new URL(REDIRECT_URI);
const port = redirect.port ? Number(redirect.port) : 51121;
let settled = false;
let resolveCallback: (url: URL) => void;
let rejectCallback: (err: Error) => void;
const callbackPromise = new Promise<URL>((resolve, reject) => {
resolveCallback = (url) => {
if (settled) return;
settled = true;
resolve(url);
};
rejectCallback = (err) => {
if (settled) return;
settled = true;
reject(err);
};
});
const timeout = setTimeout(() => {
rejectCallback(new Error("Timed out waiting for OAuth callback"));
}, params.timeoutMs);
timeout.unref?.();
const server = createServer((request, response) => {
if (!request.url) {
response.writeHead(400, { "Content-Type": "text/plain" });
response.end("Missing URL");
return;
}
const url = new URL(request.url, `${redirect.protocol}//${redirect.host}`);
if (url.pathname !== redirect.pathname) {
response.writeHead(404, { "Content-Type": "text/plain" });
response.end("Not found");
return;
}
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end(RESPONSE_PAGE);
resolveCallback(url);
setImmediate(() => {
server.close();
});
});
await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => {
server.off("error", onError);
reject(err);
};
server.once("error", onError);
server.listen(port, "127.0.0.1", () => {
server.off("error", onError);
resolve();
});
});
return {
waitForCallback: () => callbackPromise,
close: () =>
new Promise<void>((resolve) => {
server.close(() => resolve());
}),
};
}
async function exchangeCode(params: {
code: string;
verifier: string;
}): Promise<{ access: string; refresh: string; expires: number }> {
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code: params.code,
grant_type: "authorization_code",
redirect_uri: REDIRECT_URI,
code_verifier: params.verifier,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Token exchange failed: ${text}`);
}
const data = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
const access = data.access_token?.trim();
const refresh = data.refresh_token?.trim();
const expiresIn = data.expires_in ?? 0;
if (!access) throw new Error("Token exchange returned no access_token");
if (!refresh) throw new Error("Token exchange returned no refresh_token");
const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000;
return { access, refresh, expires };
}
async function fetchUserEmail(accessToken: string): Promise<string | undefined> {
try {
const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) return undefined;
const data = (await response.json()) as { email?: string };
return data.email;
} catch {
return undefined;
}
}
async function fetchProjectId(accessToken: string): Promise<string> {
const headers = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "google-api-nodejs-client/9.15.1",
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"Client-Metadata": JSON.stringify({
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
}),
};
for (const endpoint of CODE_ASSIST_ENDPOINTS) {
try {
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
method: "POST",
headers,
body: JSON.stringify({
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
}),
});
if (!response.ok) continue;
const data = (await response.json()) as {
cloudaicompanionProject?: string | { id?: string };
};
if (typeof data.cloudaicompanionProject === "string") {
return data.cloudaicompanionProject;
}
if (
data.cloudaicompanionProject &&
typeof data.cloudaicompanionProject === "object" &&
data.cloudaicompanionProject.id
) {
return data.cloudaicompanionProject.id;
}
} catch {
// ignore
}
}
return DEFAULT_PROJECT_ID;
}
async function loginAntigravity(params: {
isRemote: boolean;
openUrl: (url: string) => Promise<void>;
prompt: (message: string) => Promise<string>;
note: (message: string, title?: string) => Promise<void>;
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
}): Promise<{
access: string;
refresh: string;
expires: number;
email?: string;
projectId: string;
}> {
const { verifier, challenge } = generatePkce();
const state = randomBytes(16).toString("hex");
const authUrl = buildAuthUrl({ challenge, state });
let callbackServer: Awaited<ReturnType<typeof startCallbackServer>> | null = null;
const needsManual = shouldUseManualOAuthFlow(params.isRemote);
if (!needsManual) {
try {
callbackServer = await startCallbackServer({ timeoutMs: 5 * 60 * 1000 });
} catch {
callbackServer = null;
}
}
if (!callbackServer) {
await params.note(
[
"Open the URL in your local browser.",
"After signing in, copy the full redirect URL and paste it back here.",
"",
`Auth URL: ${authUrl}`,
`Redirect URI: ${REDIRECT_URI}`,
].join("\n"),
"Google Antigravity OAuth",
);
}
if (!needsManual) {
params.progress.update("Opening Google sign-in…");
try {
await params.openUrl(authUrl);
} catch {
// ignore
}
}
let code = "";
let returnedState = "";
if (callbackServer) {
params.progress.update("Waiting for OAuth callback…");
const callback = await callbackServer.waitForCallback();
code = callback.searchParams.get("code") ?? "";
returnedState = callback.searchParams.get("state") ?? "";
await callbackServer.close();
} else {
params.progress.update("Waiting for redirect URL…");
const input = await params.prompt("Paste the redirect URL: ");
const parsed = parseCallbackInput(input);
if ("error" in parsed) throw new Error(parsed.error);
code = parsed.code;
returnedState = parsed.state;
}
if (!code) throw new Error("Missing OAuth code");
if (returnedState !== state) {
throw new Error("OAuth state mismatch. Please try again.");
}
params.progress.update("Exchanging code for tokens…");
const tokens = await exchangeCode({ code, verifier });
const email = await fetchUserEmail(tokens.access);
const projectId = await fetchProjectId(tokens.access);
params.progress.stop("Antigravity OAuth complete");
return { ...tokens, email, projectId };
}
const antigravityPlugin = {
id: "google-antigravity-auth",
name: "Google Antigravity Auth",
description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
register(api) {
api.registerProvider({
id: "google-antigravity",
label: "Google Antigravity",
docsPath: "/providers/models",
aliases: ["antigravity"],
auth: [
{
id: "oauth",
label: "Google OAuth",
hint: "PKCE + localhost callback",
kind: "oauth",
run: async (ctx) => {
const spin = ctx.prompter.progress("Starting Antigravity OAuth…");
try {
const result = await loginAntigravity({
isRemote: ctx.isRemote,
openUrl: ctx.openUrl,
prompt: async (message) => String(await ctx.prompter.text({ message })),
note: ctx.prompter.note,
progress: spin,
});
const profileId = `google-antigravity:${result.email ?? "default"}`;
return {
profiles: [
{
profileId,
credential: {
type: "oauth",
provider: "google-antigravity",
access: result.access,
refresh: result.refresh,
expires: result.expires,
email: result.email,
projectId: result.projectId,
},
},
],
configPatch: {
agents: {
defaults: {
models: {
[DEFAULT_MODEL]: {},
},
},
},
},
defaultModel: DEFAULT_MODEL,
notes: [
"Antigravity uses Google Cloud project quotas.",
"Enable Gemini for Google Cloud on your project if requests fail.",
],
};
} catch (err) {
spin.stop("Antigravity OAuth failed");
throw err;
}
},
},
],
});
},
};
export default antigravityPlugin;
@@ -0,0 +1,11 @@
{
"name": "@clawdbot/google-antigravity-auth",
"version": "2026.1.16-1",
"type": "module",
"description": "Clawdbot Google Antigravity OAuth provider plugin",
"clawdbot": {
"extensions": [
"./index.ts"
]
}
}
@@ -0,0 +1,24 @@
# Google Gemini CLI Auth (Clawdbot plugin)
OAuth provider plugin for **Gemini CLI** (Google Code Assist).
## Enable
Bundled plugins are disabled by default. Enable this one:
```bash
clawdbot plugins enable google-gemini-cli-auth
```
Restart the Gateway after enabling.
## Authenticate
```bash
clawdbot models auth login --provider google-gemini-cli --set-default
```
## Env vars
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID`
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET`
@@ -0,0 +1,88 @@
import { loginGeminiCliOAuth } from "./oauth.js";
const PROVIDER_ID = "google-gemini-cli";
const PROVIDER_LABEL = "Gemini CLI OAuth";
const DEFAULT_MODEL = "google-gemini-cli/gemini-3-pro-preview";
const ENV_VARS = [
"CLAWDBOT_GEMINI_OAUTH_CLIENT_ID",
"CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET",
"GEMINI_CLI_OAUTH_CLIENT_ID",
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
];
const geminiCliPlugin = {
id: "google-gemini-cli-auth",
name: "Google Gemini CLI Auth",
description: "OAuth flow for Gemini CLI (Google Code Assist)",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: PROVIDER_LABEL,
docsPath: "/providers/models",
aliases: ["gemini-cli"],
envVars: ENV_VARS,
auth: [
{
id: "oauth",
label: "Google OAuth",
hint: "PKCE + localhost callback",
kind: "oauth",
run: async (ctx) => {
const spin = ctx.prompter.progress("Starting Gemini CLI OAuth…");
try {
const result = await loginGeminiCliOAuth({
isRemote: ctx.isRemote,
openUrl: ctx.openUrl,
log: (msg) => ctx.runtime.log(msg),
note: ctx.prompter.note,
prompt: async (message) => String(await ctx.prompter.text({ message })),
progress: spin,
});
spin.stop("Gemini CLI OAuth complete");
const profileId = `google-gemini-cli:${result.email ?? "default"}`;
return {
profiles: [
{
profileId,
credential: {
type: "oauth",
provider: PROVIDER_ID,
access: result.access,
refresh: result.refresh,
expires: result.expires,
email: result.email,
projectId: result.projectId,
},
},
],
configPatch: {
agents: {
defaults: {
models: {
[DEFAULT_MODEL]: {},
},
},
},
},
defaultModel: DEFAULT_MODEL,
notes: [
"If requests fail, set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.",
],
};
} catch (err) {
spin.stop("Gemini CLI OAuth failed");
await ctx.prompter.note(
"Trouble with OAuth? Ensure your Google account has Gemini CLI access.",
"OAuth help",
);
throw err;
}
},
},
],
});
},
};
export default geminiCliPlugin;
+496
View File
@@ -0,0 +1,496 @@
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
const CLIENT_ID_KEYS = ["CLAWDBOT_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"];
const CLIENT_SECRET_KEYS = [
"CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET",
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
];
const REDIRECT_URI = "http://localhost:8085/oauth2callback";
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const TOKEN_URL = "https://oauth2.googleapis.com/token";
const USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json";
const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
const SCOPES = [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
];
const TIER_FREE = "free-tier";
const TIER_LEGACY = "legacy-tier";
const TIER_STANDARD = "standard-tier";
export type GeminiCliOAuthCredentials = {
access: string;
refresh: string;
expires: number;
email?: string;
projectId: string;
};
export type GeminiCliOAuthContext = {
isRemote: boolean;
openUrl: (url: string) => Promise<void>;
log: (msg: string) => void;
note: (message: string, title?: string) => Promise<void>;
prompt: (message: string) => Promise<string>;
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
};
function resolveEnv(keys: string[]): string | undefined {
for (const key of keys) {
const value = process.env[key]?.trim();
if (value) return value;
}
return undefined;
}
function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } {
const clientId = resolveEnv(CLIENT_ID_KEYS);
if (!clientId) {
throw new Error(
"Missing Gemini OAuth client ID. Set CLAWDBOT_GEMINI_OAUTH_CLIENT_ID (or GEMINI_CLI_OAUTH_CLIENT_ID).",
);
}
const clientSecret = resolveEnv(CLIENT_SECRET_KEYS);
return { clientId, clientSecret };
}
function isWSL(): boolean {
if (process.platform !== "linux") return false;
try {
const release = readFileSync("/proc/version", "utf8").toLowerCase();
return release.includes("microsoft") || release.includes("wsl");
} catch {
return false;
}
}
function isWSL2(): boolean {
if (!isWSL()) return false;
try {
const version = readFileSync("/proc/version", "utf8").toLowerCase();
return version.includes("wsl2") || version.includes("microsoft-standard");
} catch {
return false;
}
}
function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
return isRemote || isWSL2();
}
function generatePkce(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("hex");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
function buildAuthUrl(challenge: string, verifier: string): string {
const { clientId } = resolveOAuthClientConfig();
const params = new URLSearchParams({
client_id: clientId,
response_type: "code",
redirect_uri: REDIRECT_URI,
scope: SCOPES.join(" "),
code_challenge: challenge,
code_challenge_method: "S256",
state: verifier,
access_type: "offline",
prompt: "consent",
});
return `${AUTH_URL}?${params.toString()}`;
}
function parseCallbackInput(
input: string,
expectedState: string,
): { code: string; state: string } | { error: string } {
const trimmed = input.trim();
if (!trimmed) return { error: "No input provided" };
try {
const url = new URL(trimmed);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state") ?? expectedState;
if (!code) return { error: "Missing 'code' parameter in URL" };
if (!state) return { error: "Missing 'state' parameter. Paste the full URL." };
return { code, state };
} catch {
if (!expectedState) return { error: "Paste the full redirect URL, not just the code." };
return { code: trimmed, state: expectedState };
}
}
async function waitForLocalCallback(params: {
expectedState: string;
timeoutMs: number;
onProgress?: (message: string) => void;
}): Promise<{ code: string; state: string }> {
const port = 8085;
const hostname = "localhost";
const expectedPath = "/oauth2callback";
return new Promise<{ code: string; state: string }>((resolve, reject) => {
let timeout: NodeJS.Timeout | null = null;
const server = createServer((req, res) => {
try {
const requestUrl = new URL(req.url ?? "/", `http://${hostname}:${port}`);
if (requestUrl.pathname !== expectedPath) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain");
res.end("Not found");
return;
}
const error = requestUrl.searchParams.get("error");
const code = requestUrl.searchParams.get("code")?.trim();
const state = requestUrl.searchParams.get("state")?.trim();
if (error) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain");
res.end(`Authentication failed: ${error}`);
finish(new Error(`OAuth error: ${error}`));
return;
}
if (!code || !state) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain");
res.end("Missing code or state");
finish(new Error("Missing OAuth code or state"));
return;
}
if (state !== params.expectedState) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain");
res.end("Invalid state");
finish(new Error("OAuth state mismatch"));
return;
}
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(
"<!doctype html><html><head><meta charset='utf-8'/></head>" +
"<body><h2>Gemini CLI OAuth complete</h2>" +
"<p>You can close this window and return to Clawdbot.</p></body></html>",
);
finish(undefined, { code, state });
} catch (err) {
finish(err instanceof Error ? err : new Error("OAuth callback failed"));
}
});
const finish = (err?: Error, result?: { code: string; state: string }) => {
if (timeout) clearTimeout(timeout);
try {
server.close();
} catch {
// ignore close errors
}
if (err) {
reject(err);
} else if (result) {
resolve(result);
}
};
server.once("error", (err) => {
finish(err instanceof Error ? err : new Error("OAuth callback server error"));
});
server.listen(port, hostname, () => {
params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}…`);
});
timeout = setTimeout(() => {
finish(new Error("OAuth callback timeout"));
}, params.timeoutMs);
});
}
async function exchangeCodeForTokens(code: string, verifier: string): Promise<GeminiCliOAuthCredentials> {
const { clientId, clientSecret } = resolveOAuthClientConfig();
const body = new URLSearchParams({
client_id: clientId,
code,
grant_type: "authorization_code",
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
});
if (clientSecret) {
body.set("client_secret", clientSecret);
}
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${errorText}`);
}
const data = (await response.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
if (!data.refresh_token) {
throw new Error("No refresh token received. Please try again.");
}
const email = await getUserEmail(data.access_token);
const projectId = await discoverProject(data.access_token);
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
return {
refresh: data.refresh_token,
access: data.access_token,
expires: expiresAt,
projectId,
email,
};
}
async function getUserEmail(accessToken: string): Promise<string | undefined> {
try {
const response = await fetch(USERINFO_URL, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (response.ok) {
const data = (await response.json()) as { email?: string };
return data.email;
}
} catch {
// ignore
}
return undefined;
}
async function discoverProject(accessToken: string): Promise<string> {
const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID;
const headers = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "google-api-nodejs-client/9.15.1",
"X-Goog-Api-Client": "gl-node/clawdbot",
};
const loadBody = {
cloudaicompanionProject: envProject,
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
duetProject: envProject,
},
};
let data: {
currentTier?: { id?: string };
cloudaicompanionProject?: string | { id?: string };
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
} = {};
try {
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, {
method: "POST",
headers,
body: JSON.stringify(loadBody),
});
if (!response.ok) {
const errorPayload = await response.json().catch(() => null);
if (isVpcScAffected(errorPayload)) {
data = { currentTier: { id: TIER_STANDARD } };
} else {
throw new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`);
}
} else {
data = (await response.json()) as typeof data;
}
} catch (err) {
if (err instanceof Error) {
throw err;
}
throw new Error("loadCodeAssist failed");
}
if (data.currentTier) {
const project = data.cloudaicompanionProject;
if (typeof project === "string" && project) return project;
if (typeof project === "object" && project?.id) return project.id;
if (envProject) return envProject;
throw new Error(
"This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.",
);
}
const tier = getDefaultTier(data.allowedTiers);
const tierId = tier?.id || TIER_FREE;
if (tierId !== TIER_FREE && !envProject) {
throw new Error(
"This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.",
);
}
const onboardBody: Record<string, unknown> = {
tierId,
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
};
if (tierId !== TIER_FREE && envProject) {
onboardBody.cloudaicompanionProject = envProject;
(onboardBody.metadata as Record<string, unknown>).duetProject = envProject;
}
const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, {
method: "POST",
headers,
body: JSON.stringify(onboardBody),
});
if (!onboardResponse.ok) {
throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}`);
}
let lro = (await onboardResponse.json()) as {
done?: boolean;
name?: string;
response?: { cloudaicompanionProject?: { id?: string } };
};
if (!lro.done && lro.name) {
lro = await pollOperation(lro.name, headers);
}
const projectId = lro.response?.cloudaicompanionProject?.id;
if (projectId) return projectId;
if (envProject) return envProject;
throw new Error(
"Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.",
);
}
function isVpcScAffected(payload: unknown): boolean {
if (!payload || typeof payload !== "object") return false;
const error = (payload as { error?: unknown }).error;
if (!error || typeof error !== "object") return false;
const details = (error as { details?: unknown[] }).details;
if (!Array.isArray(details)) return false;
return details.some(
(item) =>
typeof item === "object" && item && (item as { reason?: string }).reason === "SECURITY_POLICY_VIOLATED",
);
}
function getDefaultTier(
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>,
): { id?: string } | undefined {
if (!allowedTiers?.length) return { id: TIER_LEGACY };
return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY };
}
async function pollOperation(
operationName: string,
headers: Record<string, string>,
): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> {
for (let attempt = 0; attempt < 24; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 5000));
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, {
headers,
});
if (!response.ok) continue;
const data = (await response.json()) as {
done?: boolean;
response?: { cloudaicompanionProject?: { id?: string } };
};
if (data.done) return data;
}
throw new Error("Operation polling timeout");
}
export async function loginGeminiCliOAuth(ctx: GeminiCliOAuthContext): Promise<GeminiCliOAuthCredentials> {
const needsManual = shouldUseManualOAuthFlow(ctx.isRemote);
await ctx.note(
needsManual
? [
"You are running in a remote/VPS environment.",
"A URL will be shown for you to open in your LOCAL browser.",
"After signing in, copy the redirect URL and paste it back here.",
].join("\n")
: [
"Browser will open for Google authentication.",
"Sign in with your Google account for Gemini CLI access.",
"The callback will be captured automatically on localhost:8085.",
].join("\n"),
"Gemini CLI OAuth",
);
const { verifier, challenge } = generatePkce();
const authUrl = buildAuthUrl(challenge, verifier);
if (needsManual) {
ctx.progress.update("OAuth URL ready");
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
ctx.progress.update("Waiting for you to paste the callback URL...");
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
const parsed = parseCallbackInput(callbackInput, verifier);
if ("error" in parsed) throw new Error(parsed.error);
if (parsed.state !== verifier) {
throw new Error("OAuth state mismatch - please try again");
}
ctx.progress.update("Exchanging authorization code for tokens...");
return exchangeCodeForTokens(parsed.code, verifier);
}
ctx.progress.update("Complete sign-in in browser...");
try {
await ctx.openUrl(authUrl);
} catch {
ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`);
}
try {
const { code } = await waitForLocalCallback({
expectedState: verifier,
timeoutMs: 5 * 60 * 1000,
onProgress: (msg) => ctx.progress.update(msg),
});
ctx.progress.update("Exchanging authorization code for tokens...");
return await exchangeCodeForTokens(code, verifier);
} catch (err) {
if (
err instanceof Error &&
(err.message.includes("EADDRINUSE") ||
err.message.includes("port") ||
err.message.includes("listen"))
) {
ctx.progress.update("Local callback server failed. Switching to manual mode...");
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
const parsed = parseCallbackInput(callbackInput, verifier);
if ("error" in parsed) throw new Error(parsed.error);
if (parsed.state !== verifier) {
throw new Error("OAuth state mismatch - please try again");
}
ctx.progress.update("Exchanging authorization code for tokens...");
return exchangeCodeForTokens(parsed.code, verifier);
}
throw err;
}
}
@@ -0,0 +1,11 @@
{
"name": "@clawdbot/google-gemini-cli-auth",
"version": "2026.1.16-1",
"type": "module",
"description": "Clawdbot Gemini CLI OAuth provider plugin",
"clawdbot": {
"extensions": [
"./index.ts"
]
}
}
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 2026.1.16
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.15
### Changes
+4 -2
View File
@@ -1,10 +1,12 @@
{
"name": "@clawdbot/matrix",
"version": "2026.1.15",
"version": "2026.1.16",
"type": "module",
"description": "Clawdbot Matrix channel plugin",
"clawdbot": {
"extensions": ["./index.ts"]
"extensions": [
"./index.ts"
]
},
"dependencies": {
"markdown-it": "14.1.0",
@@ -2,7 +2,7 @@ import type { MatrixClient } from "matrix-js-sdk";
import { chunkMarkdownText } from "../../../../../src/auto-reply/chunk.js";
import type { ReplyPayload } from "../../../../../src/auto-reply/types.js";
import { danger } from "../../../../../src/globals.js";
import { danger, logVerbose } from "../../../../../src/globals.js";
import type { RuntimeEnv } from "../../../../../src/runtime.js";
import { sendMessageMatrix } from "../send.js";
@@ -18,7 +18,12 @@ export async function deliverMatrixReplies(params: {
const chunkLimit = Math.min(params.textLimit, 4000);
let hasReplied = false;
for (const reply of params.replies) {
if (!reply?.text && !reply?.mediaUrl && !(reply?.mediaUrls?.length ?? 0)) {
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
if (!reply?.text && !hasMedia) {
if (reply?.audioAsVoice) {
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
continue;
}
params.runtime.error?.(danger("matrix reply missing text/media"));
continue;
}
@@ -57,6 +62,7 @@ export async function deliverMatrixReplies(params: {
mediaUrl,
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
threadId: params.threadId,
audioAsVoice: reply.audioAsVoice,
});
if (shouldIncludeReply(replyToId)) {
hasReplied = true;
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { parsePollStartContent } from "./poll-types.js";
describe("parsePollStartContent", () => {
it("parses legacy m.poll payloads", () => {
const summary = parsePollStartContent({
"m.poll": {
question: { "m.text": "Lunch?" },
kind: "m.poll.disclosed",
max_selections: 1,
answers: [
{ id: "answer1", "m.text": "Yes" },
{ id: "answer2", "m.text": "No" },
],
},
});
expect(summary?.question).toBe("Lunch?");
expect(summary?.answers).toEqual(["Yes", "No"]);
});
});
+35 -28
View File
@@ -7,15 +7,17 @@
* - m.poll.end - Closes a poll
*/
import type { TimelineEvents } from "matrix-js-sdk/lib/@types/event.js";
import type { ExtensibleAnyMessageEventContent } from "matrix-js-sdk/lib/@types/extensible_events.js";
import type { PollInput } from "../../../../src/polls.js";
export const M_POLL_START = "m.poll.start";
export const M_POLL_RESPONSE = "m.poll.response";
export const M_POLL_END = "m.poll.end";
export const M_POLL_START = "m.poll.start" as const;
export const M_POLL_RESPONSE = "m.poll.response" as const;
export const M_POLL_END = "m.poll.end" as const;
export const ORG_POLL_START = "org.matrix.msc3381.poll.start";
export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response";
export const ORG_POLL_END = "org.matrix.msc3381.poll.end";
export const ORG_POLL_START = "org.matrix.msc3381.poll.start" as const;
export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response" as const;
export const ORG_POLL_END = "org.matrix.msc3381.poll.end" as const;
export const POLL_EVENT_TYPES = [
M_POLL_START,
@@ -32,9 +34,7 @@ export const POLL_END_TYPES = [M_POLL_END, ORG_POLL_END];
export type PollKind = "m.poll.disclosed" | "m.poll.undisclosed";
export type TextContent = {
"m.text"?: string;
"org.matrix.msc1767.text"?: string;
export type TextContent = ExtensibleAnyMessageEventContent & {
body?: string;
};
@@ -42,25 +42,19 @@ export type PollAnswer = {
id: string;
} & TextContent;
export type PollStartContent = {
"m.poll"?: {
question: TextContent;
kind?: PollKind;
max_selections?: number;
answers: PollAnswer[];
};
"org.matrix.msc3381.poll.start"?: {
question: TextContent;
kind?: PollKind;
max_selections?: number;
answers: PollAnswer[];
};
"m.relates_to"?: {
rel_type: "m.reference";
event_id: string;
};
export type PollStartSubtype = {
question: TextContent;
kind?: PollKind;
max_selections?: number;
answers: PollAnswer[];
};
export type LegacyPollStartContent = {
"m.poll"?: PollStartSubtype;
};
export type PollStartContent = TimelineEvents[typeof M_POLL_START] | LegacyPollStartContent;
export type PollSummary = {
eventId: string;
roomId: string;
@@ -82,7 +76,9 @@ export function getTextContent(text?: TextContent): string {
}
export function parsePollStartContent(content: PollStartContent): PollSummary | null {
const poll = content["m.poll"] ?? content["org.matrix.msc3381.poll.start"];
const poll = (content as Record<string, PollStartSubtype | undefined>)[M_POLL_START]
?? (content as Record<string, PollStartSubtype | undefined>)[ORG_POLL_START]
?? (content as Record<string, PollStartSubtype | undefined>)["m.poll"];
if (!poll) return null;
const question = getTextContent(poll.question);
@@ -121,6 +117,11 @@ function buildTextContent(body: string): TextContent {
};
}
function buildPollFallbackText(question: string, answers: string[]): string {
if (answers.length === 0) return question;
return `${question}\n${answers.map((answer, idx) => `${idx + 1}. ${answer}`).join("\n")}`;
}
export function buildPollStartContent(poll: PollInput): PollStartContent {
const question = poll.question.trim();
const answers = poll.options
@@ -132,13 +133,19 @@ export function buildPollStartContent(poll: PollInput): PollStartContent {
}));
const maxSelections = poll.multiple ? Math.max(1, answers.length) : 1;
const fallbackText = buildPollFallbackText(
question,
answers.map((answer) => getTextContent(answer)),
);
return {
"m.poll": {
[M_POLL_START]: {
question: buildTextContent(question),
kind: poll.multiple ? "m.poll.undisclosed" : "m.poll.disclosed",
max_selections: maxSelections,
answers,
},
"m.text": fallbackText,
"org.matrix.msc1767.text": fallbackText,
};
}
+7 -2
View File
@@ -31,6 +31,11 @@ vi.mock("../../../../src/web/media.js", () => ({
}),
}));
vi.mock("../../../../src/media/image-ops.js", () => ({
getImageMetadata: vi.fn().mockResolvedValue(null),
resizeToJpeg: vi.fn(),
}));
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
const makeClient = () => {
@@ -65,13 +70,13 @@ describe("sendMessageMatrix media", () => {
const uploadArg = uploadContent.mock.calls[0]?.[0];
expect(Buffer.isBuffer(uploadArg)).toBe(true);
const content = sendMessage.mock.calls[0]?.[2] as {
const content = sendMessage.mock.calls[0]?.[1] as {
url?: string;
msgtype?: string;
format?: string;
formatted_body?: string;
};
expect(content.msgtype).toBe("m.file");
expect(content.msgtype).toBe("m.image");
expect(content.format).toBe("org.matrix.custom.html");
expect(content.formatted_body).toContain("caption");
expect(content.url).toBe("mxc://example/file");
+145 -22
View File
@@ -1,12 +1,15 @@
import type { AccountDataEvents, MatrixClient } from "matrix-js-sdk";
import { EventType, MsgType, RelationType } from "matrix-js-sdk";
import type {
ReactionEventContent,
RoomMessageEventContent,
ReactionEventContent,
} from "matrix-js-sdk/lib/@types/events.js";
import { chunkMarkdownText, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js";
import { loadConfig } from "../../../../src/config/config.js";
import { isVoiceCompatibleAudio } from "../../../../src/media/audio.js";
import { mediaKindFromMime } from "../../../../src/media/constants.js";
import { getImageMetadata, resizeToJpeg } from "../../../../src/media/image-ops.js";
import type { PollInput } from "../../../../src/polls.js";
import { loadWebMedia } from "../../../../src/web/media.js";
import { getActiveMatrixClient } from "./active-client.js";
@@ -47,6 +50,8 @@ export type MatrixSendOpts = {
replyToId?: string;
threadId?: string | number | null;
timeoutMs?: number;
/** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */
audioAsVoice?: boolean;
};
function ensureNodeRuntime() {
@@ -71,6 +76,12 @@ function normalizeTarget(raw: string): string {
return trimmed;
}
function normalizeThreadId(raw?: string | number | null): string | null {
if (raw === undefined || raw === null) return null;
const trimmed = String(raw).trim();
return trimmed ? trimmed : null;
}
async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<string> {
const trimmed = userId.trim();
if (!trimmed.startsWith("@")) {
@@ -119,6 +130,18 @@ export async function resolveMatrixRoomId(
return target;
}
type MatrixImageInfo = {
w?: number;
h?: number;
thumbnail_url?: string;
thumbnail_info?: {
w: number;
h: number;
mimetype: string;
size: number;
};
};
function buildMediaContent(params: {
msgtype: MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File;
body: string;
@@ -127,8 +150,24 @@ function buildMediaContent(params: {
mimetype?: string;
size: number;
relation?: MatrixReplyRelation;
isVoice?: boolean;
durationMs?: number;
imageInfo?: MatrixImageInfo;
}): RoomMessageEventContent {
const info = { mimetype: params.mimetype, size: params.size };
const info: Record<string, unknown> = { mimetype: params.mimetype, size: params.size };
if (params.durationMs !== undefined) {
info.duration = params.durationMs;
}
if (params.imageInfo) {
if (params.imageInfo.w) info.w = params.imageInfo.w;
if (params.imageInfo.h) info.h = params.imageInfo.h;
if (params.imageInfo.thumbnail_url) {
info.thumbnail_url = params.imageInfo.thumbnail_url;
if (params.imageInfo.thumbnail_info) {
info.thumbnail_info = params.imageInfo.thumbnail_info;
}
}
}
const base: MatrixMessageContent = {
msgtype: params.msgtype,
body: params.body,
@@ -136,6 +175,12 @@ function buildMediaContent(params: {
info,
url: params.url,
};
if (params.isVoice) {
base["org.matrix.msc3245.voice"] = {};
base["org.matrix.msc1767.audio"] = {
duration: params.durationMs,
};
}
if (params.relation) {
base["m.relates_to"] = params.relation;
}
@@ -171,6 +216,75 @@ function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined
return { "m.in_reply_to": { event_id: trimmed } };
}
function resolveMatrixMsgType(
contentType?: string,
fileName?: string,
): MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File {
const kind = mediaKindFromMime(contentType ?? "");
switch (kind) {
case "image":
return MsgType.Image;
case "audio":
return MsgType.Audio;
case "video":
return MsgType.Video;
default:
return MsgType.File;
}
}
function resolveMatrixVoiceDecision(opts: {
wantsVoice: boolean;
contentType?: string;
fileName?: string;
}): { useVoice: boolean } {
if (!opts.wantsVoice) return { useVoice: false };
if (isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) {
return { useVoice: true };
}
return { useVoice: false };
}
const THUMBNAIL_MAX_SIDE = 800;
const THUMBNAIL_QUALITY = 80;
async function prepareImageInfo(params: {
buffer: Buffer;
client: MatrixClient;
}): Promise<MatrixImageInfo | undefined> {
const meta = await getImageMetadata(params.buffer).catch(() => null);
if (!meta) return undefined;
const imageInfo: MatrixImageInfo = { w: meta.width, h: meta.height };
const maxDim = Math.max(meta.width, meta.height);
if (maxDim > THUMBNAIL_MAX_SIDE) {
try {
const thumbBuffer = await resizeToJpeg({
buffer: params.buffer,
maxSide: THUMBNAIL_MAX_SIDE,
quality: THUMBNAIL_QUALITY,
withoutEnlargement: true,
});
const thumbMeta = await getImageMetadata(thumbBuffer).catch(() => null);
const thumbUri = await params.client.uploadContent(thumbBuffer as MatrixUploadContent, {
type: "image/jpeg",
name: "thumbnail.jpg",
});
imageInfo.thumbnail_url = thumbUri.content_uri;
if (thumbMeta) {
imageInfo.thumbnail_info = {
w: thumbMeta.width,
h: thumbMeta.height,
mimetype: "image/jpeg",
size: thumbBuffer.byteLength,
};
}
} catch {
// Thumbnail generation failed, continue without it
}
}
return imageInfo;
}
async function uploadFile(
client: MatrixClient,
file: MatrixUploadContent | Buffer,
@@ -238,14 +352,10 @@ export async function sendMessageMatrix(
const textLimit = resolveTextChunkLimit(cfg, "matrix");
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
const chunks = chunkMarkdownText(trimmedMessage, chunkLimit);
const rawThreadId = opts.threadId;
const threadId =
rawThreadId !== undefined && rawThreadId !== null
? String(rawThreadId).trim()
: null;
const threadId = normalizeThreadId(opts.threadId);
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
const sendContent = (content: RoomMessageEventContent) =>
client.sendMessage(roomId, threadId ?? undefined, content);
threadId ? client.sendMessage(roomId, threadId, content) : client.sendMessage(roomId, content);
let lastMessageId = "";
if (opts.mediaUrl) {
@@ -255,9 +365,17 @@ export async function sendMessageMatrix(
contentType: media.contentType,
filename: media.fileName,
});
const msgtype = MsgType.File;
const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName);
const { useVoice } = resolveMatrixVoiceDecision({
wantsVoice: opts.audioAsVoice === true,
contentType: media.contentType,
fileName: media.fileName,
});
const msgtype = useVoice ? MsgType.Audio : baseMsgType;
const isImage = msgtype === MsgType.Image;
const imageInfo = isImage ? await prepareImageInfo({ buffer: media.buffer, client }) : undefined;
const [firstChunk, ...rest] = chunks;
const body = firstChunk ?? media.fileName ?? "(file)";
const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)");
const content = buildMediaContent({
msgtype,
body,
@@ -266,10 +384,13 @@ export async function sendMessageMatrix(
mimetype: media.contentType,
size: media.buffer.byteLength,
relation,
isVoice: useVoice,
imageInfo,
});
const response = await sendContent(content);
lastMessageId = response.event_id ?? lastMessageId;
for (const chunk of rest) {
const textChunks = useVoice ? chunks : rest;
for (const chunk of textChunks) {
const text = chunk.trim();
if (!text) continue;
const followup = buildTextContent(text);
@@ -316,17 +437,19 @@ export async function sendPollMatrix(
try {
const roomId = await resolveMatrixRoomId(client, to);
const pollContent = buildPollStartContent(poll);
const rawThreadId = opts.threadId;
const threadId =
rawThreadId !== undefined && rawThreadId !== null
? String(rawThreadId).trim()
: null;
const response = await client.sendEvent(
roomId,
threadId ?? undefined,
M_POLL_START as EventType.RoomMessage,
pollContent as unknown as RoomMessageEventContent,
);
const threadId = normalizeThreadId(opts.threadId);
const response = threadId
? await client.sendEvent(
roomId,
threadId,
M_POLL_START,
pollContent,
)
: await client.sendEvent(
roomId,
M_POLL_START,
pollContent,
);
return {
eventId: response.event_id ?? "unknown",
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 2026.1.16
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.15
### Features
+4 -2
View File
@@ -1,10 +1,12 @@
{
"name": "@clawdbot/msteams",
"version": "2026.1.15",
"version": "2026.1.16",
"type": "module",
"description": "Clawdbot Microsoft Teams channel plugin",
"clawdbot": {
"extensions": ["./index.ts"]
"extensions": [
"./index.ts"
]
},
"dependencies": {
"@microsoft/agents-hosting": "^1.1.1",
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 2026.1.16
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.15
### Changes
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/voice-call",
"version": "2026.1.15",
"version": "2026.1.16",
"type": "module",
"description": "Clawdbot voice-call plugin",
"dependencies": {
@@ -9,6 +9,8 @@
"zod": "^4.3.5"
},
"clawdbot": {
"extensions": ["./index.ts"]
"extensions": [
"./index.ts"
]
}
}
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 2026.1.16
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.15
### Changes
+4 -2
View File
@@ -1,10 +1,12 @@
{
"name": "@clawdbot/zalo",
"version": "2026.1.15",
"version": "2026.1.16",
"type": "module",
"description": "Clawdbot Zalo channel plugin",
"clawdbot": {
"extensions": ["./index.ts"]
"extensions": [
"./index.ts"
]
},
"dependencies": {
"undici": "7.18.2"
+28 -5
View File
@@ -1,7 +1,12 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ResolvedZaloAccount } from "./accounts.js";
import {
isControlCommandMessage,
shouldComputeCommandAuthorized,
} from "../../../src/auto-reply/command-detection.js";
import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../../src/channels/command-gating.js";
import {
ZaloApiError,
deleteWebhook,
@@ -437,6 +442,21 @@ async function processMessageWithPipeline(params: {
const dmPolicy = account.config.dmPolicy ?? "pairing";
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
const shouldComputeAuth = shouldComputeCommandAuthorized(rawBody, config);
const storeAllowFrom =
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
? await deps.readChannelAllowFromStore("zalo").catch(() => [])
: [];
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const useAccessGroups = config.commands?.useAccessGroups !== false;
const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
const commandAuthorized = shouldComputeAuth
? resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }],
})
: undefined;
if (!isGroup) {
if (dmPolicy === "disabled") {
@@ -445,9 +465,7 @@ async function processMessageWithPipeline(params: {
}
if (dmPolicy !== "open") {
const storeAllowFrom = await deps.readChannelAllowFromStore("zalo").catch(() => []);
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const allowed = isSenderAllowed(senderId, effectiveAllowFrom);
const allowed = senderAllowedForCommands;
if (!allowed) {
if (dmPolicy === "pairing") {
@@ -496,7 +514,11 @@ async function processMessageWithPipeline(params: {
},
});
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
if (isGroup && isControlCommandMessage(rawBody, config) && commandAuthorized !== true) {
logVerbose(deps, `zalo: drop control command from unauthorized sender ${senderId}`);
return;
}
const fromLabel = isGroup
? `group:${chatId}`
: senderName || `user:${senderId}`;
@@ -511,7 +533,7 @@ async function processMessageWithPipeline(params: {
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
From: isGroup ? `group:${chatId}` : `zalo:${senderId}`,
From: isGroup ? `zalo:group:${chatId}` : `zalo:${senderId}`,
To: `zalo:${chatId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
@@ -519,6 +541,7 @@ async function processMessageWithPipeline(params: {
ConversationLabel: fromLabel,
SenderName: senderName || undefined,
SenderId: senderId,
CommandAuthorized: commandAuthorized,
Provider: "zalo",
Surface: "zalo",
MessageSid: message_id,
+4 -2
View File
@@ -1,12 +1,14 @@
{
"name": "@clawdbot/zalouser",
"version": "2026.1.15",
"version": "2026.1.16",
"type": "module",
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
"dependencies": {
"@sinclair/typebox": "0.34.47"
},
"clawdbot": {
"extensions": ["./index.ts"]
"extensions": [
"./index.ts"
]
}
}
+29 -7
View File
@@ -1,7 +1,12 @@
import type { ChildProcess } from "node:child_process";
import type { RuntimeEnv } from "../../../src/runtime.js";
import {
isControlCommandMessage,
shouldComputeCommandAuthorized,
} from "../../../src/auto-reply/command-detection.js";
import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../../src/channels/command-gating.js";
import { loadCoreChannelDeps, type CoreChannelDeps } from "./core-bridge.js";
import { sendMessageZalouser } from "./send.js";
import type { CoreConfig, ResolvedZalouserAccount, ZcaMessage } from "./types.js";
@@ -105,6 +110,21 @@ async function processMessage(
const dmPolicy = account.config.dmPolicy ?? "pairing";
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
const rawBody = content.trim();
const shouldComputeAuth = shouldComputeCommandAuthorized(rawBody, config);
const storeAllowFrom =
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
? await deps.readChannelAllowFromStore("zalouser").catch(() => [])
: [];
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const useAccessGroups = config.commands?.useAccessGroups !== false;
const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
const commandAuthorized = shouldComputeAuth
? resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }],
})
: undefined;
if (!isGroup) {
if (dmPolicy === "disabled") {
@@ -113,9 +133,7 @@ async function processMessage(
}
if (dmPolicy !== "open") {
const storeAllowFrom = await deps.readChannelAllowFromStore("zalouser").catch(() => []);
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const allowed = isSenderAllowed(senderId, effectiveAllowFrom);
const allowed = senderAllowedForCommands;
if (!allowed) {
if (dmPolicy === "pairing") {
@@ -158,6 +176,11 @@ async function processMessage(
}
}
if (isGroup && isControlCommandMessage(rawBody, config) && commandAuthorized !== true) {
logVerbose(deps, runtime, `zalouser: drop control command from unauthorized sender ${senderId}`);
return;
}
const peer = isGroup ? { kind: "group" as const, id: chatId } : { kind: "group" as const, id: senderId };
const route = deps.resolveAgentRoute({
@@ -172,9 +195,7 @@ async function processMessage(
});
const rawBody = content.trim();
const fromLabel = isGroup
? `group:${chatId}`
: senderName || `user:${senderId}`;
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
const body = deps.formatAgentEnvelope({
channel: "Zalo Personal",
from: fromLabel,
@@ -186,7 +207,7 @@ async function processMessage(
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
From: isGroup ? `group:${chatId}` : `zalouser:${senderId}`,
From: isGroup ? `zalouser:group:${chatId}` : `zalouser:${senderId}`,
To: `zalouser:${chatId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
@@ -194,6 +215,7 @@ async function processMessage(
ConversationLabel: fromLabel,
SenderName: senderName || undefined,
SenderId: senderId,
CommandAuthorized: commandAuthorized,
Provider: "zalouser",
Surface: "zalouser",
MessageSid: message.msgId ?? `${timestamp}`,
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "clawdbot",
"version": "2026.1.15",
"version": "2026.1.16-2",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
@@ -26,6 +26,7 @@
"dist/infra/**",
"dist/macos/**",
"dist/media/**",
"dist/media-understanding/**",
"dist/process/**",
"dist/plugins/**",
"dist/security/**",
@@ -65,7 +66,7 @@
"docs:bin": "bun build scripts/docs-list.ts --compile --outfile bin/docs-list",
"docs:dev": "cd docs && mint dev",
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
"build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts && tsx scripts/copy-hook-metadata.ts",
"build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts && tsx scripts/copy-hook-metadata.ts && tsx scripts/write-build-info.ts",
"plugins:sync": "tsx scripts/sync-plugin-versions.ts",
"release:check": "tsx scripts/release-check.ts",
"ui:install": "node scripts/ui.js install",
View File
Whitespace-only changes.
+6
View File
@@ -232,6 +232,12 @@ importers:
specifier: 3.14.5
version: 3.14.5(typescript@5.9.3)
extensions/copilot-proxy: {}
extensions/google-antigravity-auth: {}
extensions/google-gemini-cli-auth: {}
extensions/matrix:
dependencies:
markdown-it:
+26 -15
View File
@@ -168,24 +168,33 @@ TRASH
fi
}
select_skip_hooks() {
# Hooks multiselect: pick "Skip for now".
wait_for_log "Enable hooks?" 60 || true
send $'"'"' \r'"'"' 0.6
}
send_local_basic() {
# Risk acknowledgement (default is "No").
send $'"'"'y\r'"'"' 0.6
# Choose local gateway, accept defaults, skip channels/skills/daemon, skip UI.
send $'"'"'\r'"'"' 0.5
select_skip_hooks
}
send_reset_config_only() {
# Risk acknowledgement (default is "No").
send $'"'"'y\r'"'"' 0.8
# Reset config + reuse the local defaults flow.
send $'"'"'\e[B'"'"' 0.3
send $'"'"'\e[B'"'"' 0.3
send $'"'"'\r'"'"' 0.4
send $'"'"'\r'"'"' 0.4
send "" 1.2
send_local_basic
}
send_reset_config_only() {
# Risk acknowledgement (default is "No").
send $'"'"'y\r'"'"' 0.8
# Select reset flow for existing config.
wait_for_log "Config handling" 40 || true
send $'"'"'\e[B'"'"' 0.3
send $'"'"'\e[B'"'"' 0.3
send $'"'"'\r'"'"' 0.4
# Reset scope -> Config only (default).
wait_for_log "Reset scope" 40 || true
send $'"'"'\r'"'"' 0.4
select_skip_hooks
}
send_channels_flow() {
# Configure channels via configure wizard.
@@ -202,11 +211,13 @@ TRASH
send_skills_flow() {
# Select skills section and skip optional installs.
send $'"'"'\r'"'"' 1.2
send "" 1.0
wait_for_log "Where will the Gateway run?" 40 || true
send $'"'"'\r'"'"' 0.8
# Configure skills now? -> No
send $'"'"'n\r'"'"' 1.2
send "" 2.0
wait_for_log "Configure skills now?" 40 || true
send $'"'"'n\r'"'"' 0.8
wait_for_log "Configure complete." 40 || true
send "" 0.8
}
run_case_local_basic() {
+48
View File
@@ -0,0 +1,48 @@
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const distDir = path.join(rootDir, "dist");
const pkgPath = path.join(rootDir, "package.json");
const readPackageVersion = () => {
try {
const raw = fs.readFileSync(pkgPath, "utf8");
const parsed = JSON.parse(raw) as { version?: string };
return parsed.version ?? null;
} catch {
return null;
}
};
const resolveCommit = () => {
const envCommit = process.env.GIT_COMMIT?.trim() || process.env.GIT_SHA?.trim();
if (envCommit) return envCommit;
try {
return execSync("git rev-parse HEAD", {
cwd: rootDir,
stdio: ["ignore", "pipe", "ignore"],
})
.toString()
.trim();
} catch {
return null;
}
};
const version = readPackageVersion();
const commit = resolveCommit();
const buildInfo = {
version,
commit,
builtAt: new Date().toISOString(),
};
fs.mkdirSync(distDir, { recursive: true });
fs.writeFileSync(
path.join(distDir, "build-info.json"),
`${JSON.stringify(buildInfo, null, 2)}\n`,
);
+131 -167
View File
@@ -4,20 +4,73 @@ description: Run Codex CLI, Claude Code, OpenCode, or Pi Coding Agent via backgr
metadata: {"clawdbot":{"emoji":"🧩","requires":{"anyBins":["claude","codex","opencode","pi"]}}}
---
# Coding Agent (background-first)
# Coding Agent (bash-first)
Use **bash background mode** for non-interactive coding work. For interactive coding sessions, use the **tmux** skill (always, except very simple one-shot prompts).
Use **bash** (with optional background mode) for all coding agent work. Simple and effective.
## The Pattern: workdir + background
## ⚠️ PTY Mode Required!
Coding agents (Codex, Claude Code, Pi) are **interactive terminal applications** that need a pseudo-terminal (PTY) to work correctly. Without PTY, you'll get broken output, missing colors, or the agent may hang.
**Always use `pty:true`** when running coding agents:
```bash
# Create temp space for chats/scratch work
SCRATCH=$(mktemp -d)
# ✅ Correct - with PTY
bash pty:true command:"codex exec 'Your prompt'"
# Start agent in target directory ("little box" - only sees relevant files)
bash workdir:$SCRATCH background:true command:"<agent command>"
# Or for project work:
bash workdir:~/project/folder background:true command:"<agent command>"
# ❌ Wrong - no PTY, agent may break
bash command:"codex exec 'Your prompt'"
```
### Bash Tool Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `command` | string | The shell command to run |
| `pty` | boolean | **Use for coding agents!** Allocates a pseudo-terminal for interactive CLIs |
| `workdir` | string | Working directory (agent sees only this folder's context) |
| `background` | boolean | Run in background, returns sessionId for monitoring |
| `timeout` | number | Timeout in seconds (kills process on expiry) |
| `elevated` | boolean | Run on host instead of sandbox (if allowed) |
### Process Tool Actions (for background sessions)
| Action | Description |
|--------|-------------|
| `list` | List all running/recent sessions |
| `poll` | Check if session is still running |
| `log` | Get session output (with optional offset/limit) |
| `write` | Send raw data to stdin |
| `submit` | Send data + newline (like typing and pressing Enter) |
| `send-keys` | Send key tokens or hex bytes |
| `paste` | Paste text (with optional bracketed mode) |
| `kill` | Terminate the session |
---
## Quick Start: One-Shot Tasks
For quick prompts/chats, create a temp git repo and run:
```bash
# Quick chat (Codex needs a git repo!)
SCRATCH=$(mktemp -d) && cd $SCRATCH && git init && codex exec "Your prompt here"
# Or in a real project - with PTY!
bash pty:true workdir:~/Projects/myproject command:"codex exec 'Add error handling to the API calls'"
```
**Why git init?** Codex refuses to run outside a trusted git directory. Creating a temp repo solves this for scratch work.
---
## The Pattern: workdir + background + pty
For longer tasks, use background mode with PTY:
```bash
# Start agent in target directory (with PTY!)
bash pty:true workdir:~/project background:true command:"codex exec --full-auto 'Build a snake game'"
# Returns sessionId for tracking
# Monitor progress
@@ -29,6 +82,9 @@ process action:poll sessionId:XXX
# Send input (if agent asks a question)
process action:write sessionId:XXX data:"y"
# Submit with Enter (like typing "yes" and pressing Enter)
process action:submit sessionId:XXX data:"yes"
# Kill if needed
process action:kill sessionId:XXX
```
@@ -41,72 +97,67 @@ process action:kill sessionId:XXX
**Model:** `gpt-5.2-codex` is the default (set in ~/.codex/config.toml)
### Building/Creating (use --full-auto or --yolo)
### Flags
| Flag | Effect |
|------|--------|
| `exec "prompt"` | One-shot execution, exits when done |
| `--full-auto` | Sandboxed but auto-approves in workspace |
| `--yolo` | NO sandbox, NO approvals (fastest, most dangerous) |
### Building/Creating
```bash
# --full-auto: sandboxed but auto-approves in workspace
bash workdir:~/project background:true command:"codex exec --full-auto \"Build a snake game with dark theme\""
# Quick one-shot (auto-approves) - remember PTY!
bash pty:true workdir:~/project command:"codex exec --full-auto 'Build a dark mode toggle'"
# --yolo: NO sandbox, NO approvals (fastest, most dangerous)
bash workdir:~/project background:true command:"codex --yolo \"Build a snake game with dark theme\""
# Note: --yolo is a shortcut for --dangerously-bypass-approvals-and-sandbox
# Background for longer work
bash pty:true workdir:~/project background:true command:"codex --yolo 'Refactor the auth module'"
```
### Reviewing PRs (vanilla, no flags)
### Reviewing PRs
**⚠️ CRITICAL: Never review PRs in Clawdbot's own project folder!**
- Either use the project where the PR is submitted (if it's NOT ~/Projects/clawdbot)
- Or clone to a temp folder first
Clone to temp folder or use git worktree.
```bash
# Option 1: Review in the actual project (if NOT clawdbot)
bash workdir:~/Projects/some-other-repo background:true command:"codex review --base main"
# Option 2: Clone to temp folder for safe review (REQUIRED for clawdbot PRs!)
# Clone to temp for safe review
REVIEW_DIR=$(mktemp -d)
git clone https://github.com/clawdbot/clawdbot.git $REVIEW_DIR
git clone https://github.com/user/repo.git $REVIEW_DIR
cd $REVIEW_DIR && gh pr checkout 130
bash workdir:$REVIEW_DIR background:true command:"codex review --base origin/main"
# Clean up after: rm -rf $REVIEW_DIR
bash pty:true workdir:$REVIEW_DIR command:"codex review --base origin/main"
# Clean up after: trash $REVIEW_DIR
# Option 3: Use git worktree (keeps main intact)
# Or use git worktree (keeps main intact)
git worktree add /tmp/pr-130-review pr-130-branch
bash workdir:/tmp/pr-130-review background:true command:"codex review --base main"
bash pty:true workdir:/tmp/pr-130-review command:"codex review --base main"
```
**Why?** Checking out branches in the running Clawdbot repo can break the live instance!
### Batch PR Reviews (parallel army!)
```bash
# Fetch all PR refs first
git fetch origin '+refs/pull/*/head:refs/remotes/origin/pr/*'
# Deploy the army - one Codex per PR!
bash workdir:~/project background:true command:"codex exec \"Review PR #86. git diff origin/main...origin/pr/86\""
bash workdir:~/project background:true command:"codex exec \"Review PR #87. git diff origin/main...origin/pr/87\""
bash workdir:~/project background:true command:"codex exec \"Review PR #95. git diff origin/main...origin/pr/95\""
# ... repeat for all PRs
# Deploy the army - one Codex per PR (all with PTY!)
bash pty:true workdir:~/project background:true command:"codex exec 'Review PR #86. git diff origin/main...origin/pr/86'"
bash pty:true workdir:~/project background:true command:"codex exec 'Review PR #87. git diff origin/main...origin/pr/87'"
# Monitor all
process action:list
# Get results and post to GitHub
process action:log sessionId:XXX
# Post results to GitHub
gh pr comment <PR#> --body "<review content>"
```
### Tips for PR Reviews
- **Fetch refs first:** `git fetch origin '+refs/pull/*/head:refs/remotes/origin/pr/*'`
- **Use git diff:** Tell Codex to use `git diff origin/main...origin/pr/XX`
- **Don't checkout:** Multiple parallel reviews = don't let them change branches
- **Post results:** Use `gh pr comment` to post reviews to GitHub
---
## Claude Code
```bash
bash workdir:~/project background:true command:"claude \"Your task\""
# With PTY for proper terminal output
bash pty:true workdir:~/project command:"claude 'Your task'"
# Background
bash pty:true workdir:~/project background:true command:"claude 'Your task'"
```
---
@@ -114,7 +165,7 @@ bash workdir:~/project background:true command:"claude \"Your task\""
## OpenCode
```bash
bash workdir:~/project background:true command:"opencode run \"Your task\""
bash pty:true workdir:~/project command:"opencode run 'Your task'"
```
---
@@ -123,152 +174,65 @@ bash workdir:~/project background:true command:"opencode run \"Your task\""
```bash
# Install: npm install -g @mariozechner/pi-coding-agent
bash workdir:~/project background:true command:"pi \"Your task\""
bash pty:true workdir:~/project command:"pi 'Your task'"
# Non-interactive mode (PTY still recommended)
bash pty:true command:"pi -p 'Summarize src/'"
# Different provider/model
bash pty:true command:"pi --provider openai --model gpt-4o-mini -p 'Your task'"
```
**Note:** Pi now has Anthropic prompt caching enabled (PR #584, merged Jan 2026)!
---
## Pi flags (common)
## Parallel Issue Fixing with git worktrees
- `--print` / `-p`: non-interactive; runs prompt and exits.
- `--provider <name>`: pick provider (default: google).
- `--model <id>`: pick model (default: gemini-2.5-flash).
- `--api-key <key>`: override API key (defaults to env vars).
Examples:
For fixing multiple issues in parallel, use git worktrees:
```bash
# Set provider + model, non-interactive
bash workdir:~/project background:true command:"pi --provider openai --model gpt-4o-mini -p \"Summarize src/\""
```
---
## tmux (interactive sessions)
Use the tmux skill for interactive coding sessions (always, except very simple one-shot prompts). Prefer bash background mode for non-interactive runs.
---
## Parallel Issue Fixing with git worktrees + tmux
For fixing multiple issues in parallel, use git worktrees (isolated branches) + tmux sessions:
```bash
# 1. Clone repo to temp location
cd /tmp && git clone git@github.com:user/repo.git repo-worktrees
cd repo-worktrees
# 2. Create worktrees for each issue (isolated branches!)
# 1. Create worktrees for each issue
git worktree add -b fix/issue-78 /tmp/issue-78 main
git worktree add -b fix/issue-99 /tmp/issue-99 main
# 3. Set up tmux sessions
SOCKET="${TMPDIR:-/tmp}/codex-fixes.sock"
tmux -S "$SOCKET" new-session -d -s fix-78
tmux -S "$SOCKET" new-session -d -s fix-99
# 2. Launch Codex in each (background + PTY!)
bash pty:true workdir:/tmp/issue-78 background:true command:"pnpm install && codex --yolo 'Fix issue #78: <description>. Commit and push.'"
bash pty:true workdir:/tmp/issue-99 background:true command:"pnpm install && codex --yolo 'Fix issue #99: <description>. Commit and push.'"
# 4. Launch Codex in each (after pnpm install!)
tmux -S "$SOCKET" send-keys -t fix-78 "cd /tmp/issue-78 && pnpm install && codex --yolo 'Fix issue #78: <description>. Commit and push.'" Enter
tmux -S "$SOCKET" send-keys -t fix-99 "cd /tmp/issue-99 && pnpm install && codex --yolo 'Fix issue #99: <description>. Commit and push.'" Enter
# 3. Monitor progress
process action:list
process action:log sessionId:XXX
# 5. Monitor progress
tmux -S "$SOCKET" capture-pane -p -t fix-78 -S -30
tmux -S "$SOCKET" capture-pane -p -t fix-99 -S -30
# 6. Check if done (prompt returned)
tmux -S "$SOCKET" capture-pane -p -t fix-78 -S -3 | grep -q "❯" && echo "Done!"
# 7. Create PRs after fixes
# 4. Create PRs after fixes
cd /tmp/issue-78 && git push -u origin fix/issue-78
gh pr create --repo user/repo --head fix/issue-78 --title "fix: ..." --body "..."
# 8. Cleanup
tmux -S "$SOCKET" kill-server
# 5. Cleanup
git worktree remove /tmp/issue-78
git worktree remove /tmp/issue-99
```
**Why worktrees?** Each Codex works in isolated branch, no conflicts. Can run 5+ parallel fixes!
**Why tmux over bash background?** Codex is interactive — needs TTY for proper output. tmux provides persistent sessions with full history capture.
---
## ⚠️ Rules
1. **Respect tool choice** — if user asks for Codex, use Codex. NEVER offer to build it yourself!
2. **Be patient** — don't kill sessions because they're "slow"
3. **Monitor with process:log** — check progress without interfering
4. **--full-auto for building** — auto-approves changes
5. **vanilla for reviewing** — no special flags needed
6. **Parallel is OK** — run many Codex processes at once for batch work
7. **NEVER start Codex in ~/clawd/** — it'll read your soul docs and get weird ideas about the org chart! Use the target project dir or /tmp for blank slate chats
8. **NEVER checkout branches in ~/Projects/clawdbot/** — that's the LIVE Clawdbot instance! Clone to /tmp or use git worktree for PR reviews
1. **Always use pty:true** — coding agents need a terminal!
2. **Respect tool choice** — if user asks for Codex, use Codex. NEVER offer to build it yourself!
3. **Be patient** — don't kill sessions because they're "slow"
4. **Monitor with process:log** — check progress without interfering
5. **--full-auto for building** — auto-approves changes
6. **vanilla for reviewing** — no special flags needed
7. **Parallel is OK** — run many Codex processes at once for batch work
8. **NEVER start Codex in ~/clawd/** — it'll read your soul docs and get weird ideas about the org chart!
9. **NEVER checkout branches in ~/Projects/clawdbot/** — that's the LIVE Clawdbot instance!
---
## PR Template (The Razor Standard)
## Learnings (Jan 2026)
When submitting PRs to external repos, use this format for quality & maintainer-friendliness:
````markdown
## Original Prompt
[Exact request/problem statement]
## What this does
[High-level description]
**Features:**
- [Key feature 1]
- [Key feature 2]
**Example usage:**
```bash
# Example
command example
```
## Feature intent (maintainer-friendly)
[Why useful, how it fits, workflows it enables]
## Prompt history (timestamped)
- YYYY-MM-DD HH:MM UTC: [Step 1]
- YYYY-MM-DD HH:MM UTC: [Step 2]
## How I tested
**Manual verification:**
1. [Test step] - Output: `[result]`
2. [Test step] - Result: [result]
**Files tested:**
- [Detail]
- [Edge cases]
## Session logs (implementation)
- [What was researched]
- [What was discovered]
- [Time spent]
## Implementation details
**New files:**
- `path/file.ts` - [description]
**Modified files:**
- `path/file.ts` - [change]
**Technical notes:**
- [Detail 1]
- [Detail 2]
---
*Submitted by Razor 🥷 - Mariano's AI agent*
````
**Key principles:**
1. Human-written description (no AI slop)
2. Feature intent for maintainers
3. Timestamped prompt history
4. Session logs if using Codex/agent
**Example:** https://github.com/steipete/bird/pull/22
- **PTY is essential:** Coding agents are interactive terminal apps. Without `pty:true`, output breaks or agent hangs.
- **Git repo required:** Codex won't run outside a git directory. Use `mktemp -d && git init` for scratch work.
- **exec is your friend:** `codex exec "prompt"` runs and exits cleanly - perfect for one-shots.
- **submit vs write:** Use `submit` to send input + Enter, `write` for raw data without newline.
- **Sass works:** Codex responds well to playful prompts. Asked it to write a haiku about being second fiddle to a space lobster, got: *"Second chair, I code / Space lobster sets the tempo / Keys glow, I follow"* 🦞
+106
View File
@@ -4,6 +4,7 @@ import type { ProcessSession } from "./bash-process-registry.js";
import {
addSession,
appendOutput,
drainSession,
listFinishedSessions,
markBackgrounded,
markExited,
@@ -23,9 +24,12 @@ describe("bash process registry", () => {
startedAt: Date.now(),
cwd: "/tmp",
maxOutputChars: 10,
pendingMaxOutputChars: 30_000,
totalOutputChars: 0,
pendingStdout: [],
pendingStderr: [],
pendingStdoutChars: 0,
pendingStderrChars: 0,
aggregated: "",
tail: "",
exited: false,
@@ -43,6 +47,105 @@ describe("bash process registry", () => {
expect(session.truncated).toBe(true);
});
it("caps pending output to avoid runaway polls", () => {
const session: ProcessSession = {
id: "sess",
command: "echo test",
child: { pid: 123 } as ChildProcessWithoutNullStreams,
startedAt: Date.now(),
cwd: "/tmp",
maxOutputChars: 100_000,
pendingMaxOutputChars: 20_000,
totalOutputChars: 0,
pendingStdout: [],
pendingStderr: [],
pendingStdoutChars: 0,
pendingStderrChars: 0,
aggregated: "",
tail: "",
exited: false,
exitCode: undefined,
exitSignal: undefined,
truncated: false,
backgrounded: true,
};
addSession(session);
const payload = `${"a".repeat(70_000)}${"b".repeat(20_000)}`;
appendOutput(session, "stdout", payload);
const drained = drainSession(session);
expect(drained.stdout).toBe("b".repeat(20_000));
expect(session.pendingStdout).toHaveLength(0);
expect(session.pendingStdoutChars).toBe(0);
expect(session.truncated).toBe(true);
});
it("respects max output cap when pending cap is larger", () => {
const session: ProcessSession = {
id: "sess",
command: "echo test",
child: { pid: 123 } as ChildProcessWithoutNullStreams,
startedAt: Date.now(),
cwd: "/tmp",
maxOutputChars: 5_000,
pendingMaxOutputChars: 30_000,
totalOutputChars: 0,
pendingStdout: [],
pendingStderr: [],
pendingStdoutChars: 0,
pendingStderrChars: 0,
aggregated: "",
tail: "",
exited: false,
exitCode: undefined,
exitSignal: undefined,
truncated: false,
backgrounded: true,
};
addSession(session);
appendOutput(session, "stdout", "x".repeat(10_000));
const drained = drainSession(session);
expect(drained.stdout.length).toBe(5_000);
expect(session.truncated).toBe(true);
});
it("caps stdout and stderr independently", () => {
const session: ProcessSession = {
id: "sess",
command: "echo test",
child: { pid: 123 } as ChildProcessWithoutNullStreams,
startedAt: Date.now(),
cwd: "/tmp",
maxOutputChars: 100,
pendingMaxOutputChars: 10,
totalOutputChars: 0,
pendingStdout: [],
pendingStderr: [],
pendingStdoutChars: 0,
pendingStderrChars: 0,
aggregated: "",
tail: "",
exited: false,
exitCode: undefined,
exitSignal: undefined,
truncated: false,
backgrounded: true,
};
addSession(session);
appendOutput(session, "stdout", "a".repeat(6));
appendOutput(session, "stdout", "b".repeat(6));
appendOutput(session, "stderr", "c".repeat(12));
const drained = drainSession(session);
expect(drained.stdout).toBe("a".repeat(4) + "b".repeat(6));
expect(drained.stderr).toBe("c".repeat(10));
expect(session.truncated).toBe(true);
});
it("only persists finished sessions when backgrounded", () => {
const session: ProcessSession = {
id: "sess",
@@ -51,9 +154,12 @@ describe("bash process registry", () => {
startedAt: Date.now(),
cwd: "/tmp",
maxOutputChars: 100,
pendingMaxOutputChars: 30_000,
totalOutputChars: 0,
pendingStdout: [],
pendingStderr: [],
pendingStdoutChars: 0,
pendingStderrChars: 0,
aggregated: "",
tail: "",
exited: false,
+49
View File
@@ -4,6 +4,7 @@ import { createSessionSlug as createSessionSlugId } from "./session-slug.js";
const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes
const MIN_JOB_TTL_MS = 60 * 1000; // 1 minute
const MAX_JOB_TTL_MS = 3 * 60 * 60 * 1000; // 3 hours
const DEFAULT_PENDING_OUTPUT_CHARS = 30_000;
function clampTtl(value: number | undefined) {
if (!value || Number.isNaN(value)) return DEFAULT_JOB_TTL_MS;
@@ -33,9 +34,12 @@ export interface ProcessSession {
startedAt: number;
cwd?: string;
maxOutputChars: number;
pendingMaxOutputChars?: number;
totalOutputChars: number;
pendingStdout: string[];
pendingStderr: string[];
pendingStdoutChars: number;
pendingStderrChars: number;
aggregated: string;
tail: string;
exitCode?: number | null;
@@ -95,8 +99,25 @@ export function deleteSession(id: string) {
export function appendOutput(session: ProcessSession, stream: "stdout" | "stderr", chunk: string) {
session.pendingStdout ??= [];
session.pendingStderr ??= [];
session.pendingStdoutChars ??= sumPendingChars(session.pendingStdout);
session.pendingStderrChars ??= sumPendingChars(session.pendingStderr);
const buffer = stream === "stdout" ? session.pendingStdout : session.pendingStderr;
const bufferChars = stream === "stdout" ? session.pendingStdoutChars : session.pendingStderrChars;
const pendingCap = Math.min(
session.pendingMaxOutputChars ?? DEFAULT_PENDING_OUTPUT_CHARS,
session.maxOutputChars,
);
buffer.push(chunk);
let pendingChars = bufferChars + chunk.length;
if (pendingChars > pendingCap) {
session.truncated = true;
pendingChars = capPendingBuffer(buffer, pendingChars, pendingCap);
}
if (stream === "stdout") {
session.pendingStdoutChars = pendingChars;
} else {
session.pendingStderrChars = pendingChars;
}
session.totalOutputChars += chunk.length;
const aggregated = trimWithCap(session.aggregated + chunk, session.maxOutputChars);
session.truncated =
@@ -110,6 +131,8 @@ export function drainSession(session: ProcessSession) {
const stderr = session.pendingStderr.join("");
session.pendingStdout = [];
session.pendingStderr = [];
session.pendingStdoutChars = 0;
session.pendingStderrChars = 0;
return { stdout, stderr };
}
@@ -155,6 +178,32 @@ export function tail(text: string, max = 2000) {
return text.slice(text.length - max);
}
function sumPendingChars(buffer: string[]) {
let total = 0;
for (const chunk of buffer) total += chunk.length;
return total;
}
function capPendingBuffer(buffer: string[], pendingChars: number, cap: number) {
if (pendingChars <= cap) return pendingChars;
const last = buffer.at(-1);
if (last && last.length >= cap) {
buffer.length = 0;
buffer.push(last.slice(last.length - cap));
return cap;
}
while (buffer.length && pendingChars - buffer[0].length >= cap) {
pendingChars -= buffer[0].length;
buffer.shift();
}
if (buffer.length && pendingChars > cap) {
const overflow = pendingChars - cap;
buffer[0] = buffer[0].slice(overflow);
pendingChars = cap;
}
return pendingChars;
}
export function trimWithCap(text: string, max: number) {
if (text.length <= max) return text;
return text.slice(text.length - max);
+10
View File
@@ -37,6 +37,12 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
1_000,
150_000,
);
const DEFAULT_PENDING_MAX_OUTPUT = clampNumber(
readEnvInt("CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS"),
30_000,
1_000,
150_000,
);
const DEFAULT_PATH =
process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
const DEFAULT_NOTIFY_TAIL_CHARS = 400;
@@ -189,6 +195,7 @@ export function createExecTool(
}
const maxOutput = DEFAULT_MAX_OUTPUT;
const pendingMaxOutput = DEFAULT_PENDING_MAX_OUTPUT;
const startedAt = Date.now();
const sessionId = createSessionSlug();
const warnings: string[] = [];
@@ -350,9 +357,12 @@ export function createExecTool(
startedAt,
cwd: workdir,
maxOutputChars: maxOutput,
pendingMaxOutputChars: pendingMaxOutput,
totalOutputChars: 0,
pendingStdout: [],
pendingStderr: [],
pendingStdoutChars: 0,
pendingStderrChars: 0,
aggregated: "",
tail: "",
exited: false,
+2
View File
@@ -151,11 +151,13 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
openai: "OPENAI_API_KEY",
google: "GEMINI_API_KEY",
groq: "GROQ_API_KEY",
deepgram: "DEEPGRAM_API_KEY",
cerebras: "CEREBRAS_API_KEY",
xai: "XAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
moonshot: "MOONSHOT_API_KEY",
"kimi-code": "KIMICODE_API_KEY",
minimax: "MINIMAX_API_KEY",
synthetic: "SYNTHETIC_API_KEY",
mistral: "MISTRAL_API_KEY",
+39
View File
@@ -37,6 +37,18 @@ const MOONSHOT_DEFAULT_COST = {
cacheRead: 0,
cacheWrite: 0,
};
const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
const KIMI_CODE_MODEL_ID = "kimi-for-coding";
const KIMI_CODE_CONTEXT_WINDOW = 262144;
const KIMI_CODE_MAX_TOKENS = 32768;
const KIMI_CODE_HEADERS = { "User-Agent": "KimiCLI/0.77" } as const;
const KIMI_CODE_COMPAT = { supportsDeveloperRole: false } as const;
const KIMI_CODE_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
function normalizeApiKeyConfig(value: string): string {
const trimmed = value.trim();
@@ -184,6 +196,26 @@ function buildMoonshotProvider(): ProviderConfig {
};
}
function buildKimiCodeProvider(): ProviderConfig {
return {
baseUrl: KIMI_CODE_BASE_URL,
api: "openai-completions",
models: [
{
id: KIMI_CODE_MODEL_ID,
name: "Kimi For Coding",
reasoning: true,
input: ["text"],
cost: KIMI_CODE_DEFAULT_COST,
contextWindow: KIMI_CODE_CONTEXT_WINDOW,
maxTokens: KIMI_CODE_MAX_TOKENS,
headers: KIMI_CODE_HEADERS,
compat: KIMI_CODE_COMPAT,
},
],
};
}
function buildSyntheticProvider(): ProviderConfig {
return {
baseUrl: SYNTHETIC_BASE_URL,
@@ -212,6 +244,13 @@ export function resolveImplicitProviders(params: { agentDir: string }): ModelsCo
providers.moonshot = { ...buildMoonshotProvider(), apiKey: moonshotKey };
}
const kimiCodeKey =
resolveEnvApiKeyVarName("kimi-code") ??
resolveApiKeyFromProfiles({ provider: "kimi-code", store: authStore });
if (kimiCodeKey) {
providers["kimi-code"] = { ...buildKimiCodeProvider(), apiKey: kimiCodeKey };
}
const syntheticKey =
resolveEnvApiKeyVarName("synthetic") ??
resolveApiKeyFromProfiles({ provider: "synthetic", store: authStore });
+11
View File
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import type { ThinkLevel } from "../../auto-reply/thinking.js";
import { enqueueCommandInLane } from "../../process/command-queue.js";
import { resolveUserPath } from "../../utils.js";
import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js";
import { resolveClawdbotAgentDir } from "../agent-paths.js";
import {
markAuthProfileFailure,
@@ -58,6 +59,14 @@ export async function runEmbeddedPiAgent(
const globalLane = resolveGlobalLane(params.lane);
const enqueueGlobal =
params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts));
const channelHint = params.messageChannel ?? params.messageProvider;
const resolvedToolResultFormat =
params.toolResultFormat ??
(channelHint
? isMarkdownCapableMessageChannel(channelHint)
? "markdown"
: "plain"
: "markdown");
return enqueueCommandInLane(sessionLane, () =>
enqueueGlobal(async () => {
@@ -208,6 +217,7 @@ export async function runEmbeddedPiAgent(
thinkLevel,
verboseLevel: params.verboseLevel,
reasoningLevel: params.reasoningLevel,
toolResultFormat: resolvedToolResultFormat,
bashElevated: params.bashElevated,
timeoutMs: params.timeoutMs,
runId: params.runId,
@@ -408,6 +418,7 @@ export async function runEmbeddedPiAgent(
sessionKey: params.sessionKey ?? params.sessionId,
verboseLevel: params.verboseLevel,
reasoningLevel: params.reasoningLevel,
toolResultFormat: resolvedToolResultFormat,
inlineToolResultsAllowed: !params.onPartialReply && !params.onToolResult,
});
@@ -365,6 +365,7 @@ export async function runEmbeddedAttempt(
runId: params.runId,
verboseLevel: params.verboseLevel,
reasoningMode: params.reasoningLevel ?? "off",
toolResultFormat: params.toolResultFormat,
shouldEmitToolResult: params.shouldEmitToolResult,
shouldEmitToolOutput: params.shouldEmitToolOutput,
onToolResult: params.onToolResult,
+2 -1
View File
@@ -3,7 +3,7 @@ import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-rep
import type { ClawdbotConfig } from "../../../config/config.js";
import type { enqueueCommand } from "../../../process/command-queue.js";
import type { ExecElevatedDefaults } from "../../bash-tools.js";
import type { BlockReplyChunking } from "../../pi-embedded-subscribe.js";
import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js";
import type { SkillSnapshot } from "../../skills.js";
export type RunEmbeddedPiAgentParams = {
@@ -33,6 +33,7 @@ export type RunEmbeddedPiAgentParams = {
thinkLevel?: ThinkLevel;
verboseLevel?: VerboseLevel;
reasoningLevel?: ReasoningLevel;
toolResultFormat?: ToolResultFormat;
bashElevated?: ExecElevatedDefaults;
timeoutMs: number;
runId: string;
@@ -15,6 +15,7 @@ import {
extractAssistantThinking,
formatReasoningMessage,
} from "../../pi-embedded-utils.js";
import type { ToolResultFormat } from "../../pi-embedded-subscribe.js";
type ToolMetaEntry = { toolName: string; meta?: string };
@@ -26,6 +27,7 @@ export function buildEmbeddedRunPayloads(params: {
sessionKey: string;
verboseLevel?: VerboseLevel;
reasoningLevel?: ReasoningLevel;
toolResultFormat?: ToolResultFormat;
inlineToolResultsAllowed: boolean;
}): Array<{
text?: string;
@@ -47,6 +49,7 @@ export function buildEmbeddedRunPayloads(params: {
replyToCurrent?: boolean;
}> = [];
const useMarkdown = params.toolResultFormat === "markdown";
const lastAssistantErrored = params.lastAssistant?.stopReason === "error";
const errorText = params.lastAssistant
? formatAssistantErrorText(params.lastAssistant, {
@@ -71,7 +74,9 @@ export function buildEmbeddedRunPayloads(params: {
params.inlineToolResultsAllowed && params.verboseLevel !== "off" && params.toolMetas.length > 0;
if (inlineToolResults) {
for (const { toolName, meta } of params.toolMetas) {
const agg = formatToolAggregate(toolName, meta ? [meta] : []);
const agg = formatToolAggregate(toolName, meta ? [meta] : [], {
markdown: useMarkdown,
});
const {
text: cleanedText,
mediaUrls,
+2 -1
View File
@@ -6,7 +6,7 @@ import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-rep
import type { ClawdbotConfig } from "../../../config/config.js";
import type { ExecElevatedDefaults } from "../../bash-tools.js";
import type { MessagingToolSend } from "../../pi-embedded-messaging.js";
import type { BlockReplyChunking } from "../../pi-embedded-subscribe.js";
import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js";
import type { SkillSnapshot } from "../../skills.js";
import type { SessionSystemPromptReport } from "../../../config/sessions/types.js";
@@ -38,6 +38,7 @@ export type EmbeddedRunAttemptParams = {
thinkLevel: ThinkLevel;
verboseLevel?: VerboseLevel;
reasoningLevel?: ReasoningLevel;
toolResultFormat?: ToolResultFormat;
bashElevated?: ExecElevatedDefaults;
timeoutMs: number;
runId: string;
+10 -2
View File
@@ -23,10 +23,13 @@ const log = createSubsystemLogger("agent/embedded");
export type {
BlockReplyChunking,
SubscribeEmbeddedPiSessionParams,
ToolResultFormat,
} from "./pi-embedded-subscribe.types.js";
export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionParams) {
const reasoningMode = params.reasoningMode ?? "off";
const toolResultFormat = params.toolResultFormat ?? "markdown";
const useMarkdown = toolResultFormat === "markdown";
const state: EmbeddedPiSubscribeState = {
assistantTexts: [],
toolMetas: [],
@@ -180,11 +183,14 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
const formatToolOutputBlock = (text: string) => {
const trimmed = text.trim();
if (!trimmed) return "(no output)";
if (!useMarkdown) return trimmed;
return `\`\`\`txt\n${trimmed}\n\`\`\``;
};
const emitToolSummary = (toolName?: string, meta?: string) => {
if (!params.onToolResult) return;
const agg = formatToolAggregate(toolName, meta ? [meta] : undefined);
const agg = formatToolAggregate(toolName, meta ? [meta] : undefined, {
markdown: useMarkdown,
});
const { text: cleanedText, mediaUrls } = parseReplyDirectives(agg);
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) return;
try {
@@ -198,7 +204,9 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
};
const emitToolOutput = (toolName?: string, meta?: string, output?: string) => {
if (!params.onToolResult || !output) return;
const agg = formatToolAggregate(toolName, meta ? [meta] : undefined);
const agg = formatToolAggregate(toolName, meta ? [meta] : undefined, {
markdown: useMarkdown,
});
const message = `${agg}\n${formatToolOutputBlock(output)}`;
const { text: cleanedText, mediaUrls } = parseReplyDirectives(message);
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) return;
@@ -3,11 +3,14 @@ import type { AgentSession } from "@mariozechner/pi-coding-agent";
import type { ReasoningLevel, VerboseLevel } from "../auto-reply/thinking.js";
import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
export type ToolResultFormat = "markdown" | "plain";
export type SubscribeEmbeddedPiSessionParams = {
session: AgentSession;
runId: string;
verboseLevel?: VerboseLevel;
reasoningMode?: ReasoningLevel;
toolResultFormat?: ToolResultFormat;
shouldEmitToolResult?: () => boolean;
shouldEmitToolOutput?: () => boolean;
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
+2 -2
View File
@@ -26,11 +26,11 @@ const workspaceVersions = new Map<string, number>();
const watchers = new Map<string, SkillsWatchState>();
let globalVersion = 0;
export const DEFAULT_SKILLS_WATCH_IGNORED = [
export const DEFAULT_SKILLS_WATCH_IGNORED: RegExp[] = [
/(^|[\\/])\.git([\\/]|$)/,
/(^|[\\/])node_modules([\\/]|$)/,
/(^|[\\/])dist([\\/]|$)/,
] as const;
];
function bumpVersion(current: number): number {
const now = Date.now();
+21
View File
@@ -102,6 +102,27 @@ describe("image tool implicit imageModel config", () => {
});
});
it("disables image tool when primary model already supports images", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-image-"));
const cfg: ClawdbotConfig = {
agents: {
defaults: {
model: { primary: "acme/vision-1" },
imageModel: { primary: "openai/gpt-5-mini" },
},
},
models: {
providers: {
acme: {
models: [{ id: "vision-1", input: ["text", "image"] }],
},
},
},
};
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toBeNull();
expect(createImageTool({ config: cfg, agentDir })).toBeNull();
});
it("sandboxes image paths like the read tool", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-image-sandbox-"));
const agentDir = path.join(stateDir, "agent");
+87 -7
View File
@@ -1,3 +1,4 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
@@ -19,7 +20,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
import { minimaxUnderstandImage } from "../minimax-vlm.js";
import { getApiKeyForModel, resolveEnvApiKey } from "../model-auth.js";
import { runWithImageModelFallback } from "../model-fallback.js";
import { parseModelRef } from "../model-selection.js";
import { normalizeProviderId, resolveConfiguredModelRef } from "../model-selection.js";
import { ensureClawdbotModelsJson } from "../models-config.js";
import { assertSandboxPath } from "../sandbox-paths.js";
import type { AnyAgentTool } from "./common.js";
@@ -42,12 +43,15 @@ function resolveDefaultModelRef(cfg?: ClawdbotConfig): {
provider: string;
model: string;
} {
const modelConfig = cfg?.agents?.defaults?.model as { primary?: string } | string | undefined;
const raw = typeof modelConfig === "string" ? modelConfig.trim() : modelConfig?.primary?.trim();
const parsed =
parseModelRef(raw ?? "", DEFAULT_PROVIDER) ??
({ provider: DEFAULT_PROVIDER, model: DEFAULT_MODEL } as const);
return { provider: parsed.provider, model: parsed.model };
if (cfg) {
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
return { provider: resolved.provider, model: resolved.model };
}
return { provider: DEFAULT_PROVIDER, model: DEFAULT_MODEL };
}
function hasAuthForProvider(params: { provider: string; agentDir: string }): boolean {
@@ -58,6 +62,77 @@ function hasAuthForProvider(params: { provider: string; agentDir: string }): boo
return listProfilesForProvider(store, params.provider).length > 0;
}
type ProviderModelEntry = {
id?: string;
input?: string[];
};
type ProviderConfigLike = {
models?: ProviderModelEntry[];
};
function resolveProviderConfig(
providers: Record<string, ProviderConfigLike> | undefined,
provider: string,
): ProviderConfigLike | null {
if (!providers) return null;
const normalized = normalizeProviderId(provider);
for (const [key, value] of Object.entries(providers)) {
if (normalizeProviderId(key) === normalized) return value;
}
return null;
}
function resolveModelSupportsImages(params: {
providerConfig: ProviderConfigLike | null;
modelId: string;
}): boolean | null {
const models = params.providerConfig?.models;
if (!Array.isArray(models) || models.length === 0) return null;
const trimmedId = params.modelId.trim();
if (!trimmedId) return null;
const match =
models.find((model) => String(model?.id ?? "").trim() === trimmedId) ??
models.find(
(model) =>
String(model?.id ?? "")
.trim()
.toLowerCase() === trimmedId.toLowerCase(),
);
if (!match) return null;
const input = Array.isArray(match.input) ? match.input : [];
return input.includes("image");
}
function resolvePrimaryModelSupportsImages(params: {
cfg?: ClawdbotConfig;
agentDir: string;
}): boolean | null {
if (!params.cfg) return null;
const primary = resolveDefaultModelRef(params.cfg);
const providerConfig = resolveProviderConfig(
params.cfg.models?.providers as Record<string, ProviderConfigLike> | undefined,
primary.provider,
);
const fromConfig = resolveModelSupportsImages({
providerConfig,
modelId: primary.model,
});
if (fromConfig !== null) return fromConfig;
try {
const modelsPath = path.join(params.agentDir, "models.json");
const raw = fsSync.readFileSync(modelsPath, "utf8");
const parsed = JSON.parse(raw) as { providers?: Record<string, ProviderConfigLike> };
const provider = resolveProviderConfig(parsed.providers, primary.provider);
return resolveModelSupportsImages({
providerConfig: provider,
modelId: primary.model,
});
} catch {
return null;
}
}
/**
* Resolve the effective image model config for the `image` tool.
*
@@ -70,6 +145,11 @@ export function resolveImageModelConfigForTool(params: {
cfg?: ClawdbotConfig;
agentDir: string;
}): ImageModelConfig | null {
const primarySupportsImages = resolvePrimaryModelSupportsImages({
cfg: params.cfg,
agentDir: params.agentDir,
});
if (primarySupportsImages === true) return null;
const explicit = coerceImageModelConfig(params.cfg);
if (explicit.primary?.trim() || (explicit.fallbacks?.length ?? 0) > 0) {
return explicit;
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../memory/index.js", () => {
return {
getMemorySearchManager: async () => {
return {
manager: {
search: async () => {
throw new Error("openai embeddings failed: 429 insufficient_quota");
},
readFile: async () => {
throw new Error("path required");
},
status: () => ({
files: 0,
chunks: 0,
dirty: true,
workspaceDir: "/tmp",
dbPath: "/tmp/index.sqlite",
provider: "openai",
model: "text-embedding-3-small",
requestedProvider: "openai",
}),
},
};
},
};
});
import { createMemoryGetTool, createMemorySearchTool } from "./memory-tool.js";
describe("memory tools", () => {
it("does not throw when memory_search fails (e.g. embeddings 429)", async () => {
const cfg = { agents: { list: [{ id: "main", default: true }] } };
const tool = createMemorySearchTool({ config: cfg });
expect(tool).not.toBeNull();
if (!tool) throw new Error("tool missing");
const result = await tool.execute("call_1", { query: "hello" });
expect(result.details).toEqual({
results: [],
disabled: true,
error: "openai embeddings failed: 429 insufficient_quota",
});
});
it("does not throw when memory_get fails", async () => {
const cfg = { agents: { list: [{ id: "main", default: true }] } };
const tool = createMemoryGetTool({ config: cfg });
expect(tool).not.toBeNull();
if (!tool) throw new Error("tool missing");
const result = await tool.execute("call_2", { path: "memory/NOPE.md" });
expect(result.details).toEqual({
path: "memory/NOPE.md",
text: "",
disabled: true,
error: "path required",
});
});
});
+28 -18
View File
@@ -47,18 +47,23 @@ export function createMemorySearchTool(options: {
if (!manager) {
return jsonResult({ results: [], disabled: true, error });
}
const results = await manager.search(query, {
maxResults,
minScore,
sessionKey: options.agentSessionKey,
});
const status = manager.status();
return jsonResult({
results,
provider: status.provider,
model: status.model,
fallback: status.fallback,
});
try {
const results = await manager.search(query, {
maxResults,
minScore,
sessionKey: options.agentSessionKey,
});
const status = manager.status();
return jsonResult({
results,
provider: status.provider,
model: status.model,
fallback: status.fallback,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonResult({ results: [], disabled: true, error: message });
}
},
};
}
@@ -91,12 +96,17 @@ export function createMemoryGetTool(options: {
if (!manager) {
return jsonResult({ path: relPath, text: "", disabled: true, error });
}
const result = await manager.readFile({
relPath,
from: from ?? undefined,
lines: lines ?? undefined,
});
return jsonResult(result);
try {
const result = await manager.readFile({
relPath,
from: from ?? undefined,
lines: lines ?? undefined,
});
return jsonResult(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonResult({ path: relPath, text: "", disabled: true, error: message });
}
},
};
}
+1
View File
@@ -39,6 +39,7 @@ function buildSendSchema(options: { includeButtons: boolean }) {
media: Type.Optional(Type.String()),
replyTo: Type.Optional(Type.String()),
threadId: Type.Optional(Type.String()),
asVoice: Type.Optional(Type.Boolean()),
bestEffort: Type.Optional(Type.Boolean()),
gifPlayback: Type.Optional(Type.Boolean()),
buttons: Type.Optional(
-1
View File
@@ -309,7 +309,6 @@ export function createSessionStatusTool(opts?: {
const isGroup =
resolved.entry.chatType === "group" ||
resolved.entry.chatType === "channel" ||
resolved.key.startsWith("group:") ||
resolved.key.includes(":group:") ||
resolved.key.includes(":channel:");
const groupActivation = isGroup
+1 -1
View File
@@ -68,7 +68,7 @@ export function classifySessionKind(params: {
if (key.startsWith("hook:")) return "hook";
if (key.startsWith("node-") || key.startsWith("node:")) return "node";
if (params.gatewayKind === "group") return "group";
if (key.startsWith("group:") || key.includes(":group:") || key.includes(":channel:")) {
if (key.includes(":group:") || key.includes(":channel:")) {
return "group";
}
return "other";
+7 -5
View File
@@ -23,11 +23,13 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget
if (!channelRaw) return null;
const normalizedChannel = normalizeChannelId(channelRaw);
const channel = normalizedChannel ?? channelRaw.toLowerCase();
const kindTarget = normalizedChannel
? kind === "channel"
? `channel:${id}`
: `group:${id}`
: id;
const kindTarget = (() => {
if (!normalizedChannel) return id;
if (normalizedChannel === "discord" || normalizedChannel === "slack") {
return `channel:${id}`;
}
return kind === "channel" ? `channel:${id}` : `group:${id}`;
})();
const normalized = normalizedChannel
? getChannelPlugin(normalizedChannel)?.messaging?.normalizeTarget?.(kindTarget)
: undefined;
+1
View File
@@ -175,6 +175,7 @@ export async function handleTelegramAction(
buttons,
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,
asVoice: typeof params.asVoice === "boolean" ? params.asVoice : undefined,
});
return jsonResult({
ok: true,
+9 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { hasControlCommand } from "./command-detection.js";
import { hasControlCommand, hasInlineCommandTokens } from "./command-detection.js";
import { listChatCommands } from "./commands-registry.js";
import { parseActivationCommand } from "./group-activation.js";
import { parseSendPolicyCommand } from "./send-policy.js";
@@ -72,6 +72,14 @@ describe("control command parsing", () => {
expect(hasControlCommand("/send on")).toBe(true);
});
it("detects inline command tokens", () => {
expect(hasInlineCommandTokens("hello /status")).toBe(true);
expect(hasInlineCommandTokens("hey /think high")).toBe(true);
expect(hasInlineCommandTokens("plain text")).toBe(false);
expect(hasInlineCommandTokens("http://example.com/path")).toBe(false);
expect(hasInlineCommandTokens("stop")).toBe(false);
});
it("ignores telegram commands addressed to other bots", () => {
expect(
hasControlCommand("/help@otherbot", undefined, {
+21
View File
@@ -45,3 +45,24 @@ export function isControlCommandMessage(
const normalized = normalizeCommandBody(trimmed, options).trim().toLowerCase();
return isAbortTrigger(normalized);
}
/**
* Coarse detection for inline directives/shortcuts (e.g. "hey /status") so channel monitors
* can decide whether to compute CommandAuthorized for a message.
*
* This intentionally errs on the side of false positives; CommandAuthorized only gates
* command/directive execution, not normal chat replies.
*/
export function hasInlineCommandTokens(text?: string): boolean {
const body = text ?? "";
if (!body.trim()) return false;
return /(?:^|\s)[/!][a-z]/i.test(body);
}
export function shouldComputeCommandAuthorized(
text?: string,
cfg?: ClawdbotConfig,
options?: CommandNormalizeOptions,
): boolean {
return isControlCommandMessage(text, cfg, options) || hasInlineCommandTokens(text);
}
+21
View File
@@ -59,6 +59,27 @@ export function formatInboundEnvelope(params: {
});
}
export function formatInboundFromLabel(params: {
isGroup: boolean;
groupLabel?: string;
groupId?: string;
directLabel: string;
directId?: string;
groupFallback?: string;
}): string {
// Keep envelope headers compact: group labels include id, DMs only add id when it differs.
if (params.isGroup) {
const label = params.groupLabel?.trim() || params.groupFallback || "Group";
const id = params.groupId?.trim();
return id ? `${label} id:${id}` : label;
}
const directLabel = params.directLabel.trim();
const directId = params.directId?.trim();
if (!directId || directId === directLabel) return directLabel;
return `${directLabel} id:${directId}`;
}
export function formatThreadStarterEnvelope(params: {
channel: string;
author?: string;
@@ -81,6 +81,7 @@ describe("directive behavior", () => {
Body: "/thinking xhigh",
From: "+1004",
To: "+2000",
CommandAuthorized: true,
},
{},
{
@@ -90,7 +91,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
@@ -108,6 +109,7 @@ describe("directive behavior", () => {
Body: "/thinking xhigh",
From: "+1004",
To: "+2000",
CommandAuthorized: true,
},
{},
{
@@ -117,7 +119,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
@@ -135,6 +137,7 @@ describe("directive behavior", () => {
Body: "/thinking xhigh",
From: "+1004",
To: "+2000",
CommandAuthorized: true,
},
{},
{
@@ -144,7 +147,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
@@ -164,6 +167,7 @@ describe("directive behavior", () => {
Body: "/help",
From: "+1222",
To: "+1222",
CommandAuthorized: true,
},
{},
{
@@ -201,6 +205,7 @@ describe("directive behavior", () => {
Body: "/demo_skill",
From: "+1222",
To: "+1222",
CommandAuthorized: true,
},
{},
{
@@ -232,6 +237,7 @@ describe("directive behavior", () => {
Body: "/queue collect debounce:bogus cap:zero drop:maybe",
From: "+1222",
To: "+1222",
CommandAuthorized: true,
},
{},
{
@@ -263,6 +269,7 @@ describe("directive behavior", () => {
From: "+1222",
To: "+1222",
Provider: "whatsapp",
CommandAuthorized: true,
},
{},
{
@@ -300,7 +307,7 @@ describe("directive behavior", () => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const res = await getReplyFromConfig(
{ Body: "/think", From: "+1222", To: "+1222" },
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -173,7 +173,7 @@ describe("directive behavior", () => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const res = await getReplyFromConfig(
{ Body: "/verbose on", From: "+1222", To: "+1222" },
{ Body: "/verbose on", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -197,7 +197,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/verbose off", From: "+1222", To: "+1222" },
{ Body: "/verbose off", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -223,7 +223,7 @@ describe("directive behavior", () => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const res = await getReplyFromConfig(
{ Body: "/think", From: "+1222", To: "+1222" },
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -248,7 +248,7 @@ describe("directive behavior", () => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const res = await getReplyFromConfig(
{ Body: "/think", From: "+1222", To: "+1222" },
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -73,7 +73,7 @@ describe("directive behavior", () => {
]);
const res = await getReplyFromConfig(
{ Body: "/think", From: "+1222", To: "+1222" },
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -105,7 +105,7 @@ describe("directive behavior", () => {
]);
const res = await getReplyFromConfig(
{ Body: "/think", From: "+1222", To: "+1222" },
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -66,7 +66,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model list", From: "+1222", To: "+1222" },
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -97,7 +97,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model", From: "+1222", To: "+1222" },
{ Body: "/model", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -137,7 +137,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model list", From: "+1222", To: "+1222" },
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -178,7 +178,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model list", From: "+1222", To: "+1222" },
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -205,7 +205,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json");
await getReplyFromConfig(
{ Body: "/model openai/gpt-4.1-mini", From: "+1222", To: "+1222" },
{ Body: "/model openai/gpt-4.1-mini", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -235,7 +235,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json");
await getReplyFromConfig(
{ Body: "/model Opus", From: "+1222", To: "+1222" },
{ Body: "/model Opus", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -68,7 +68,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json");
await getReplyFromConfig(
{ Body: "/model ki", From: "+1222", To: "+1222" },
{ Body: "/model ki", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -135,7 +135,7 @@ describe("directive behavior", () => {
);
const res = await getReplyFromConfig(
{ Body: "/model Opus@anthropic:work", From: "+1222", To: "+1222" },
{ Body: "/model Opus@anthropic:work", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -167,7 +167,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json");
await getReplyFromConfig(
{ Body: "/model Opus", From: "+1222", To: "+1222" },
{ Body: "/model Opus", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -200,6 +200,7 @@ describe("directive behavior", () => {
From: "+1222",
To: "+1222",
Provider: "whatsapp",
CommandAuthorized: true,
},
{},
{
@@ -230,6 +231,7 @@ describe("directive behavior", () => {
From: "+1222",
To: "+1222",
Provider: "whatsapp",
CommandAuthorized: true,
},
{},
{
Loaded 100 of 251 files, more files were not shown because too many files have changed in this diff. Show more